diff -Nru xandikos-0.2.8/bin/xandikos xandikos-0.2.10/bin/xandikos --- xandikos-0.2.8/bin/xandikos 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/bin/xandikos 2023-09-06 09:15:03.000000000 +0000 @@ -18,6 +18,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. +import asyncio import os import sys @@ -28,4 +29,4 @@ from xandikos.__main__ import main -sys.exit(main(sys.argv)) +sys.exit(asyncio.run(main(sys.argv[1:]))) diff -Nru xandikos-0.2.8/Cargo.lock xandikos-0.2.10/Cargo.lock --- xandikos-0.2.8/Cargo.lock 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/Cargo.lock 2023-09-04 21:36:08.000000000 +0000 @@ -0,0 +1,616 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c677ab05e09154296dd37acecd46420c17b9713e8366facafa8fc0885167cf4c" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34d21f9bf1b425d2968943631ec91202fe5e837264063503708b83013f8fc938" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914c8c79fb560f238ef6429439a30023c862f7a28e688c58f7203f12b29970bd" +dependencies = [ + "anstream", + "anstyle", + "bitflags 1.3.2", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "errno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "gimli" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "linux-raw-sys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" + +[[package]] +name = "memoffset" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b1ac5b3731ba34fdaa9785f8d74d17448cd18f30cf19e0c7e7b1fdb5272109" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb946f5ac61bb61a5014924910d936ebd2b23b705f7a4a3c40b05c720b079a3" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd4d7c5337821916ea2a1d21d1092e8443cf34879e53a0ac653fbb98f44ff65c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d39c55dab3fc5a4b25bbd1ac10a2da452c4aca13bb450f22818a002e29648d" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97daff08a4c48320587b5224cc98d609e3c27b6d437315bd40b605c98eeb5918" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.38.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0c3dde1fc030af041adc40e79c0e7fbcf431dd24870053d187d7c66e4b87453" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "socket2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "718fa2415bcb8d8bd775917a1bf12a7931b6dfa890753378538118181e0cb398" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0e916b1148c8e263850e1ebcbd046f333e0683c724876bb0da63ea4373dc8a" + +[[package]] +name = "tokio" +version = "1.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.31", +] + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unindent" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "xandikos" +version = "0.2.10" +dependencies = [ + "clap", + "pyo3", + "tokio", +] diff -Nru xandikos-0.2.8/Cargo.toml xandikos-0.2.10/Cargo.toml --- xandikos-0.2.8/Cargo.toml 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/Cargo.toml 2023-09-04 21:36:08.000000000 +0000 @@ -0,0 +1,20 @@ +[package] +name = "xandikos" +version = "0.2.10" +authors = [ "Jelmer Vernooij ",] +edition = "2021" +license = "GPL-3.0+" +description = "Lightweight CalDAV/CardDAV server" +repository = "https://github.com/jelmer/xandikos.git" +homepage = "https://github.com/jelmer/xandikos" + +[dependencies] +clap = "<=4.2" + +[dependencies.pyo3] +version = "0.18" +features = [ "auto-initialize",] + +[dependencies.tokio] +version = "1" +features = [ "full",] diff -Nru xandikos-0.2.8/compat/common.sh xandikos-0.2.10/compat/common.sh --- xandikos-0.2.8/compat/common.sh 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/compat/common.sh 2023-09-06 09:15:03.000000000 +0000 @@ -20,14 +20,16 @@ run_xandikos() { PORT="$1" - shift 1 - ${XANDIKOS} -p${PORT} -llocalhost -d ${SERVEDIR} "$@" 2>&1 >$DAEMON_LOG & + METRICS_PORT="$2" + shift 2 + echo "Writing daemon log to $DAEMON_LOG" + ${XANDIKOS} --no-detect-systemd --port=${PORT} --metrics-port=${METRICS_PORT} -llocalhost -d ${SERVEDIR} "$@" 2>&1 >$DAEMON_LOG & XANDIKOS_PID=$! trap xandikos_cleanup 0 EXIT i=0 - while [ $i -lt 10 ] + while [ $i -lt 50 ] do - if curl http://localhost:${PORT}/ >/dev/null; then + if [ "$(curl http://localhost:${METRICS_PORT}/health)" = "ok" ]; then break fi sleep 1 diff -Nru xandikos-0.2.8/compat/.gitignore xandikos-0.2.10/compat/.gitignore --- xandikos-0.2.8/compat/.gitignore 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/compat/.gitignore 2023-09-06 09:15:03.000000000 +0000 @@ -1,4 +1,3 @@ litmus-*.tar.gz vdirsyncer/ -ccs-caldavtester/ pycaldav/ diff -Nru xandikos-0.2.8/compat/README.rst xandikos-0.2.10/compat/README.rst --- xandikos-0.2.8/compat/README.rst 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/compat/README.rst 2023-09-06 09:15:03.000000000 +0000 @@ -5,4 +5,3 @@ - `Vdirsyncer `_ - `litmus `_ -- `caldavtester `_ diff -Nru xandikos-0.2.8/compat/testcaldav.sh xandikos-0.2.10/compat/testcaldav.sh --- xandikos-0.2.8/compat/testcaldav.sh 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/compat/testcaldav.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,16 +0,0 @@ -#!/bin/bash -e - -BRANCH=master - -cd $(dirname $0) - -if [ ! -d ccs-caldavtester ]; then - git clone https://github.com/apple/ccs-caldavtester.git -else - pushd ccs-caldavtester - git pull --ff-only origin $BRANCH - popd -fi - -cd ccs-caldavtester -python2 ./testcaldav.py "$@" diff -Nru xandikos-0.2.8/compat/xandikos-caldavtester.sh xandikos-0.2.10/compat/xandikos-caldavtester.sh --- xandikos-0.2.8/compat/xandikos-caldavtester.sh 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/compat/xandikos-caldavtester.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,66 +0,0 @@ -#!/bin/bash -# Run caldavtester tests against Xandikos. -set -e - -. $(dirname $0)/common.sh - -CFGDIR=$(readlink -f $(dirname $0)) - -if which testcaldav >/dev/null; then - TESTCALDAV=testcaldav -else - TESTCALDAV="$(dirname $0)/testcaldav.sh" -fi - -function mkcol() { - p="$1" - t="$2" - git init -q "${SERVEDIR}/$p" - if [[ -n "$t" ]]; then - echo "[xandikos]" >> "${SERVEDIR}/$p/.git/config" - echo " type = $t" >> "${SERVEDIR}/$p/.git/config" - fi -} - -function mkcalendar() { - p="$1" - mkcol "$p" "calendar" -} - -function mkaddressbook() { - p="$1" - mkcol "$p" "addressbook" -} - -function mkprincipal() { - p="$1" - mkcol "$p" "principal" -} - -mkcol addressbooks -mkcol addressbooks/__uids__ -for I in `seq 1 40`; do - mkprincipal "addressbooks/__uids__/user$(printf %02d $I)" - mkaddressbook addressbooks/__uids__/user$(printf %02d $I)/addressbook -done -mkcol calendars -mkcol calendars/__uids__ -mkcalendar calendars/users -for I in `seq 1 40`; do - mkprincipal "calendars/__uids__/user$(printf %02d $I)" - mkcalendar calendars/__uids__/user$(printf %02d $I)/calendar - mkcalendar calendars/__uids__/user$(printf %02d $I)/tasks - mkcalendar calendars/__uids__/user$(printf %02d $I)/inbox - mkcalendar calendars/__uids__/user$(printf %02d $I)/outbox -done -mkprincipal calendars/__uids__/i18nuser -mkcalendar calendars/__uids__/i18nuser/calendar -mkcol principals -mkcol principals/__uids__ -mkprincipal principals/__uids__/user01/ -mkcol principals/users -mkprincipal principals/users/user01 - -run_xandikos 5233 --defaults - -$TESTCALDAV --print-details-onfail -s ${CFGDIR}/serverinfo.xml ${TESTS} diff -Nru xandikos-0.2.8/compat/xandikos-litmus.sh xandikos-0.2.10/compat/xandikos-litmus.sh --- xandikos-0.2.8/compat/xandikos-litmus.sh 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/compat/xandikos-litmus.sh 2023-09-06 09:15:03.000000000 +0000 @@ -7,7 +7,7 @@ set -e -run_xandikos 5233 --autocreate +run_xandikos 5233 5234 --autocreate if which litmus >/dev/null; then LITMUS=litmus diff -Nru xandikos-0.2.8/compat/xandikos-pycaldav.sh xandikos-0.2.10/compat/xandikos-pycaldav.sh --- xandikos-0.2.8/compat/xandikos-pycaldav.sh 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/compat/xandikos-pycaldav.sh 2023-09-06 09:15:03.000000000 +0000 @@ -22,13 +22,12 @@ {'url': 'http://localhost:5233/', # Until recurring support is added in xandikos. # See https://github.com/jelmer/xandikos/issues/102 - 'norecurring': True, - 'noexpand': True, + 'incompatibilities': ['no_expand', 'no_recurring', 'no_scheduling', 'text_search_not_working'], } ] EOF -run_xandikos 5233 --defaults +run_xandikos 5233 5234 --defaults pushd $(dirname $0)/pycaldav ${PYTHON:-python3} -m pytest tests "$@" diff -Nru xandikos-0.2.8/debian/changelog xandikos-0.2.10/debian/changelog --- xandikos-0.2.8/debian/changelog 2022-01-09 12:08:32.000000000 +0000 +++ xandikos-0.2.10/debian/changelog 2023-09-06 09:24:13.000000000 +0000 @@ -1,3 +1,13 @@ +xandikos (0.2.10-1) unstable; urgency=medium + + * Ignore .egg-info changes during build. Closes: #1046990 + * New upstream release. + + Fixes compatibility with dulwich. Closes: #1051311 + * Set upstream metadata fields: Bug-Database. + * Update standards version to 4.6.2, no changes needed. + + -- Jelmer Vernooij Wed, 06 Sep 2023 10:24:13 +0100 + xandikos (0.2.8-1) unstable; urgency=low * New upstream release. diff -Nru xandikos-0.2.8/debian/control xandikos-0.2.10/debian/control --- xandikos-0.2.8/debian/control 2022-01-09 12:08:32.000000000 +0000 +++ xandikos-0.2.10/debian/control 2023-09-06 09:24:13.000000000 +0000 @@ -3,15 +3,15 @@ Priority: optional Maintainer: Jelmer Vernooij Homepage: https://www.xandikos.org/ -Build-Depends: python3-all, debhelper-compat (= 13), python3-dulwich (>= 0.19.1), dh-python, python3-icalendar, python3-defusedxml, python3-jinja2, python3-setuptools, python3-aiohttp, python3-aiohttp-openmetrics +Build-Depends: python3-all, debhelper-compat (= 13), python3-dulwich (>= 0.19.1), dh-python, python3-icalendar, python3-defusedxml, python3-jinja2, python3-setuptools, python3-aiohttp, python3-aiohttp-openmetrics, python3-vobject Rules-Requires-Root: no -Standards-Version: 4.6.0 +Standards-Version: 4.6.2 Vcs-Browser: https://salsa.debian.org/jelmer/xandikos Vcs-Git: https://salsa.debian.org/jelmer/xandikos.git Package: xandikos Architecture: all -Depends: ${python3:Depends}, ${misc:Depends}, python3-icalendar, python3-defusedxml, python3-dulwich (>= 0.19.1), python3-jinja2, python3-aiohttp +Depends: ${python3:Depends}, ${misc:Depends}, python3-icalendar, python3-defusedxml, python3-dulwich (>= 0.19.1), python3-jinja2, python3-aiohttp, python3-vobject Recommends: python3-aiohttp-openmetrics Description: Git-backed CalDAV/CardDAV server Xandikos is a standards-compliant CalDAV/CardDAV server that backs onto a diff -Nru xandikos-0.2.8/debian/source/options xandikos-0.2.10/debian/source/options --- xandikos-0.2.8/debian/source/options 2022-01-09 12:08:32.000000000 +0000 +++ xandikos-0.2.10/debian/source/options 2023-09-06 09:24:13.000000000 +0000 @@ -1,2 +1,2 @@ extend-diff-ignore = "^\.travis\.yml$" -extend-diff-ignore = "^xandikos\.egg-info/SOURCES\.txt$" +extend-diff-ignore = "^xandikos\.egg-info/.*$" diff -Nru xandikos-0.2.8/debian/tests/control xandikos-0.2.10/debian/tests/control --- xandikos-0.2.8/debian/tests/control 2022-01-09 12:08:32.000000000 +0000 +++ xandikos-0.2.10/debian/tests/control 2023-09-06 09:24:13.000000000 +0000 @@ -1,6 +1,6 @@ -Tests: litmus -Depends: litmus, curl, @ -Restrictions: allow-stderr +#Tests: litmus +#Depends: litmus, curl, @ +#Restrictions: allow-stderr # Disabled because caldav-tester is Python2-only # Tests: caldav-tester diff -Nru xandikos-0.2.8/debian/upstream/metadata xandikos-0.2.10/debian/upstream/metadata --- xandikos-0.2.8/debian/upstream/metadata 2022-01-09 12:08:32.000000000 +0000 +++ xandikos-0.2.10/debian/upstream/metadata 2023-09-06 09:24:13.000000000 +0000 @@ -1,5 +1,5 @@ Repository: https://github.com/jelmer/xandikos.git Repository-Browse: https://github.com/jelmer/xandikos -Bug-Database: https://github.com/jelmer/xandikos/issues/ +Bug-Database: https://github.com/jelmer/xandikos/issues Bug-Submit: https://github.com/jelmer/xandikos/issues/new Security-Contact: https://github.com/jelmer/xandikos/tree/HEAD/SECURITY.md diff -Nru xandikos-0.2.8/disperse.conf xandikos-0.2.10/disperse.conf --- xandikos-0.2.8/disperse.conf 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/disperse.conf 2023-09-04 21:27:52.000000000 +0000 @@ -0,0 +1,17 @@ +# See https://github.com/jelmer/disperse +name: "xandikos" +news_file: "NEWS" +timeout_days: 5 +tag_name: "v$VERSION" +github_url: "https://github.com/jelmer/xandikos" +verify_command: "make check" +update_version { + path: "xandikos/__init__.py" + match: "^__version__ = \((.*)\)$" + new_line: "__version__ = $TUPLED_VERSION" +} +update_version { + path: "Cargo.toml" + match: "^version = \"(.*)\"$" + new_line: "version = \"$VERSION\"" +} diff -Nru xandikos-0.2.8/Dockerfile xandikos-0.2.10/Dockerfile --- xandikos-0.2.8/Dockerfile 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/Dockerfile 2023-09-06 09:15:03.000000000 +0000 @@ -6,12 +6,15 @@ FROM debian:sid-slim LABEL maintainer="jelmer@jelmer.uk" RUN apt-get update && \ - apt-get -y install python3-icalendar python3-dulwich python3-jinja2 python3-defusedxml python3-aiohttp python3-pip && \ - python3 -m pip install aiohttp-openmetrics && \ - apt-get clean + apt-get -y install --no-install-recommends python3-icalendar python3-dulwich python3-jinja2 python3-defusedxml python3-aiohttp python3-vobject python3-aiohttp-openmetrics && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/ && \ + groupadd -g 1000 xandikos && \ + useradd -d /code -c Xandikos -g xandikos -M -s /bin/bash -u 1000 xandikos ADD . /code WORKDIR /code VOLUME /data EXPOSE 8000 -ENTRYPOINT python3 -m xandikos.web --port=8000 --listen-address=0.0.0.0 -d/data -CMD "--defaults" +USER xandikos +ENTRYPOINT ["python3", "-m", "xandikos.web", "--port=8000", "--metrics-port=8001", "--listen-address=0.0.0.0", "-d", "/data"] +CMD ["--defaults"] diff -Nru xandikos-0.2.8/docs/Makefile xandikos-0.2.10/docs/Makefile --- xandikos-0.2.8/docs/Makefile 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/docs/Makefile 2022-03-05 13:42:51.000000000 +0000 @@ -0,0 +1,15 @@ +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff -Nru xandikos-0.2.8/docs/source/clients.rst xandikos-0.2.10/docs/source/clients.rst --- xandikos-0.2.8/docs/source/clients.rst 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/docs/source/clients.rst 2022-03-15 20:56:28.000000000 +0000 @@ -0,0 +1,57 @@ +Configuring Clients +=================== + +Xandikos supports ``auto-discovery`` of DAV collections (i.e. calendars or +addressbooks). Most clients today do as well, but there are some exceptions. + +This section contains basic instructions on how to use various clients with Xandikos. +Please do send us patches if your favourite client is missing. + +Evolution +--------- + +Select "CardDAV" (address books) or "CalDAV" (calendars) as the type when +adding a new account. + +Simplify provide the root URL of your Xandikos instance. Hit the "Find +Addressbooks" or "Find Calenders" button and Evolution will prompt for +credentials and show you a list of all relevant calendars or addressbooks. + +DAVx5 +-------- + +vdirsyncer +---------- + +sogo connector for Icedove/Thunderbird +-------------------------------------- + +caldavzap/carddavmate +--------------------- + +pycardsyncer +------------ + +akonadi +------- + +CalDAV-Sync +----------- + +CardDAV-Sync +------------ + +Calendarsync +------------ + +AgendaV +------- + +CardBook +-------- + +Tasks +----- + +Apple iOS +--------- diff -Nru xandikos-0.2.8/docs/source/conf.py xandikos-0.2.10/docs/source/conf.py --- xandikos-0.2.8/docs/source/conf.py 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/docs/source/conf.py 2022-03-05 13:42:51.000000000 +0000 @@ -0,0 +1,52 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'Xandikos' +copyright = '2022 Jelmer Vernooij et al' +author = 'Jelmer Vernooij' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'furo' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] diff -Nru xandikos-0.2.8/docs/source/getting-started.rst xandikos-0.2.10/docs/source/getting-started.rst --- xandikos-0.2.8/docs/source/getting-started.rst 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/docs/source/getting-started.rst 2022-08-21 12:45:46.000000000 +0000 @@ -0,0 +1,59 @@ +.. _getting-started: + +Getting Started +=============== + +Xandikos can either be run in a container (e.g. in docker or Kubernetes) or +outside of a container. + +It is recommended that you run it behind a reverse proxy, since Xandikos by +itself does not provide authentication support. See :ref:`reverse-proxy` for +details. + +Running from systemd +-------------------- + +Xandikos supports socket activation through systemd. To use systemd, run something like: + +.. code-block:: shell + + cp examples/xandikos.{socket,service} /etc/systemd/system + systemctl daemon-reload + systemctl enable xandikos.socket + +Running from docker +------------------- + +There is a docker image that gets regularly updated at +``ghcr.io/jelmer/xandikos``. + +If you use docker-compose, see the example configuration in +``examples/docker-compose.yml``. + +To run in docker interactively, try something like: + +.. code-block:: shell + + mkdir /tmp/xandikos + docker -it run ghcr.io/jelmer/xandikos -v /tmp/xandikos:/data + +The following environment variables are supported by the docker image: + + * ``CURRENT_USER_PRINCIPAL``: path to current user principal; defaults to "/$USER" + * ``AUTOCREATE``: whether to automatically create missing directories ("yes" or "no") + * ``DEFAULTS``: whether to create a default directory hierarch with one + calendar and one addressbook ("yes" or "no") + * ``ROUTE_PREFIX``: HTTP prefix under which Xandikos should run + +Running from kubernetes +----------------------- + +Here is an example configuration for running Xandikos in kubernetes: + +.. literalinclude:: ../../examples/xandikos.k8s.yaml + :language: yaml + +If you're using the prometheus operator, you may want also want to use this service monitor: + +.. literalinclude:: ../../examples/xandikos-servicemonitor.k8s.yaml + :language: yaml diff -Nru xandikos-0.2.8/docs/source/index.rst xandikos-0.2.10/docs/source/index.rst --- xandikos-0.2.8/docs/source/index.rst 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/docs/source/index.rst 2022-03-05 15:33:15.000000000 +0000 @@ -0,0 +1,17 @@ +Xandikos +======== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + getting-started + reverse-proxy + clients + troubleshooting + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`search` diff -Nru xandikos-0.2.8/docs/source/reverse-proxy.rst xandikos-0.2.10/docs/source/reverse-proxy.rst --- xandikos-0.2.8/docs/source/reverse-proxy.rst 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/docs/source/reverse-proxy.rst 2022-08-21 12:45:46.000000000 +0000 @@ -0,0 +1,35 @@ +.. _reverse-proxy: + +Running behind a reverse proxy +============================== + +By default, Xandikos does not provide any authentication support. Instead, it +is recommended that it is run behind a reverse HTTP proxy that does. + +The author has used both nginx and Apache in front of Xandikos, but any +reverse HTTP proxy should do. + +If you expose Xandikos at the root of a domain, no further configuration is +necessary. When exposing it on a different path prefix, make sure to set the +``--route-prefix`` argument to Xandikos appropriately. + +.well-known +----------- + +When serving Xandikos on a prefix, you may still want to provide +the appropriate ``.well-known`` files at the root so that clients +can find the DAV server without having to specify the subprefix. + +For this to work, reverse proxy the ``.well-known/carddav`` and +``.well-known/caldav`` files to Xandikos. + +Example: Kubernetes ingress +--------------------------- + +Here is an example configuring Xandikos to listen on ``/dav`` using the +Kubernetes nginx ingress controller. Note that this relies on the +appropriate server being set up in kubernetes (see :ref:`getting-started`) and +the ``my-htpasswd`` secret being present and having a htpasswd like file in it. + +.. literalinclude:: ../../examples/xandikos-ingress.k8s.yaml + :language: yaml diff -Nru xandikos-0.2.8/docs/source/troubleshooting.rst xandikos-0.2.10/docs/source/troubleshooting.rst --- xandikos-0.2.8/docs/source/troubleshooting.rst 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/docs/source/troubleshooting.rst 2022-09-06 23:44:10.000000000 +0000 @@ -0,0 +1,32 @@ +Troubleshooting +=============== + +Support channels +---------------- + +For help, please try the `Xandikos Discussions Forum +`_, +IRC (``#xandikos`` on irc.oftc.net), or Matrix (`#xandikos:matrix.org +`_). + +Debugging \*DAV +--------------- + +Your client may have a way of increasing log verbosity; this can often be very +helpful. + +Xandikos also has several command-line flags that may help with debugging: + + * ``--dump-dav-xml``: Write all \*DAV communication to standard out; + interpreting the contents may require in-depth \*DAV knowledge, but + providing this data is usually sufficient for one of the Xandikos + developers to identify the cause of an issue. + + * ``--no-strict``: Don't follow a strict interpretation of the + various standards, for clients that don't follow them. + + * ``--debug``: Print extra information about Xandikos' internal state. + +If you do find that a particular server requires ``--no-strict``, please +do report it - either to the servers' authors or in the +[Xandikos Discussions](https://github.com/jelmer/xandikos/discussions). diff -Nru xandikos-0.2.8/examples/docker-compose.yml xandikos-0.2.10/examples/docker-compose.yml --- xandikos-0.2.8/examples/docker-compose.yml 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/examples/docker-compose.yml 2022-01-31 16:29:00.000000000 +0000 @@ -0,0 +1,10 @@ +version: "3.4" + +services: + xandikos: + image: ghcr.io/jelmer/xandikos + ports: + - 8000:8000 + volumes: + - /path/to/xandikos/data:/data + restart: unless-stopped diff -Nru xandikos-0.2.8/examples/gunicorn.conf.py xandikos-0.2.10/examples/gunicorn.conf.py --- xandikos-0.2.8/examples/gunicorn.conf.py 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/examples/gunicorn.conf.py 2022-05-16 20:17:49.000000000 +0000 @@ -0,0 +1,42 @@ +# Gunicorn config file +# +# Usage +# ---------------------------------------------------------- +# +# Install: 1) copy this config to src directory for xandikos +# 2) run 'pip install gunicorn' +# 3) mkdir logs && mkdir data +# +# Execute: 'gunicorn' +# +wsgi_app = 'xandikos.wsgi:app' + +# Server Mechanics +# ======================================== +# daemon mode +daemon = False + +# enviroment variables +raw_env = [ + 'XANDIKOSPATH=./data', + 'CURRENT_USER_PRINCIPAL=/user/', + 'AUTOCREATE=defaults' +] + +# Server Socket +# ======================================== +bind = '0.0.0.0:8000' + +# Worker Processes +# ======================================== +workers = 2 + +# Logging +# ======================================== +# access log +accesslog = './logs/access.log' +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + +# gunicorn log +errorlog = '-' +loglevel = 'info' diff -Nru xandikos-0.2.8/examples/xandikos-ingress.k8s.yaml xandikos-0.2.10/examples/xandikos-ingress.k8s.yaml --- xandikos-0.2.8/examples/xandikos-ingress.k8s.yaml 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/examples/xandikos-ingress.k8s.yaml 2022-03-05 15:18:12.000000000 +0000 @@ -0,0 +1,46 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: xandikos + annotations: + nginx.ingress.kubernetes.io/auth-type: basic + nginx.ingress.kubernetes.io/auth-secret: my-htpasswd + nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required - mysite' +spec: + ingressClassName: nginx + rules: + - host: example.com + http: + paths: + - backend: + service: + name: xandikos + port: + name: web + path: /dav(/|$)(.*) + pathType: Prefix +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: xandikos-wellknown +spec: + ingressClassName: nginx + rules: + - host: example.com + http: + paths: + - backend: + service: + name: xandikos + port: + name: web + path: /.well-known/carddav + pathType: Exact + - backend: + service: + name: xandikos + port: + name: web + path: /.well-known/caldav + pathType: Exact diff -Nru xandikos-0.2.8/examples/xandikos.k8s.yaml xandikos-0.2.10/examples/xandikos.k8s.yaml --- xandikos-0.2.8/examples/xandikos.k8s.yaml 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/examples/xandikos.k8s.yaml 2022-05-26 16:19:36.000000000 +0000 @@ -0,0 +1,73 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: xandikos +spec: + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: RollingUpdate + replicas: 1 + selector: + matchLabels: + app: xandikos + template: + metadata: + labels: + app: xandikos + spec: + containers: + - name: xandikos + image: ghcr.io/jelmer/xandikos + imagePullPolicy: Always + command: + - "python3" + - "-m" + - "xandikos.web" + - "--port=8081" + - "-d/data" + - "--defaults" + - "--listen-address=0.0.0.0" + - "--current-user-principal=/jelmer" + - "--route-prefix=/dav" + resources: + limits: + cpu: "2" + memory: "2Gi" + requests: + cpu: "0.1" + memory: "10M" + livenessProbe: + httpGet: + path: /health + port: 8081 + initialDelaySeconds: 30 + periodSeconds: 3 + timeoutSeconds: 90 + ports: + - containerPort: 8081 + volumeMounts: + - name: xandikos-volume + mountPath: /data + securityContext: + fsGroup: 1000 + volumes: + - name: xandikos-volume + persistentVolumeClaim: + claimName: xandikos +--- +apiVersion: v1 +kind: Service +metadata: + name: xandikos + labels: + app: xandikos +spec: + ports: + - port: 8081 + name: web + selector: + app: xandikos + type: ClusterIP diff -Nru xandikos-0.2.8/examples/xandikos-servicemonitor.k8s.yaml xandikos-0.2.10/examples/xandikos-servicemonitor.k8s.yaml --- xandikos-0.2.8/examples/xandikos-servicemonitor.k8s.yaml 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/examples/xandikos-servicemonitor.k8s.yaml 2022-03-05 14:41:09.000000000 +0000 @@ -0,0 +1,13 @@ +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: xandikos + labels: + app: xandikos +spec: + selector: + matchLabels: + app: xandikos + endpoints: + - port: web diff -Nru xandikos-0.2.8/.flake8 xandikos-0.2.10/.flake8 --- xandikos-0.2.8/.flake8 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/.flake8 2023-09-06 09:15:03.000000000 +0000 @@ -1,5 +1,8 @@ [flake8] -extend-ignore = E203, E266, E501, W293, W291 +extend-ignore = E203, E266, E501, W293, W291, W503 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9 +ignore = W504,E203,W503 +exclude = compat/vdirsyncer/,.tox,.git,compat/pycaldav,examples/gunicorn.conf.py +application-package-names = xandikos diff -Nru xandikos-0.2.8/.github/workflows/container.yml xandikos-0.2.10/.github/workflows/container.yml --- xandikos-0.2.8/.github/workflows/container.yml 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/.github/workflows/container.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1,41 +0,0 @@ -name: Create and publish a Docker image - -on: - release: - types: [created] - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-and-push-image: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build and push Docker image - uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff -Nru xandikos-0.2.8/.github/workflows/pythonpackage.yml xandikos-0.2.10/.github/workflows/pythonpackage.yml --- xandikos-0.2.8/.github/workflows/pythonpackage.yml 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/.github/workflows/pythonpackage.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1,52 +0,0 @@ -name: Python package - -on: [push, pull_request] - -jobs: - build: - - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.7, 3.8, pypy3] - fail-fast: false - - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -U pip coverage codecov flake8 flake8 pycalendar vobject requests six tzlocal pytz attrs aiohttp aiohttp-wsgi prometheus-client multidict pytest - python setup.py develop - - name: Style checks - run: | - python -m flake8 - - name: Typing checks - run: | - pip install -U mypy types-python-dateutil types-pytz - python -m mypy xandikos - if: "matrix.python-version != 'pypy3'" - - name: Test suite run - run: | - python -m unittest xandikos.tests.test_suite - env: - PYTHONHASHSEED: random - - name: Run litmus tests - run: | - make check-litmus - if: "matrix.os == 'ubuntu-latest'" - - name: Run caldavtester tests - run: | - make check-caldavtester - if: "matrix.os == 'ubuntu-latest'" - - name: Run pycaldav tests - run: | - sudo apt install libxml2-dev libxslt1-dev - pip install -U nose lxml - make check-pycaldav - if: "matrix.os == 'ubuntu-latest'" diff -Nru xandikos-0.2.8/.github/workflows/pythonpublish.yml xandikos-0.2.10/.github/workflows/pythonpublish.yml --- xandikos-0.2.8/.github/workflows/pythonpublish.yml 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/.github/workflows/pythonpublish.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1,32 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [created] - -jobs: - deploy: - - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.x'] - - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* diff -Nru xandikos-0.2.8/.gitignore xandikos-0.2.10/.gitignore --- xandikos-0.2.8/.gitignore 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/.gitignore 1970-01-01 00:00:00.000000000 +0000 @@ -1,16 +0,0 @@ -*.pyc -*~ -build/ -.testrepository/ -MANIFEST -.tox/ -.*.sw? -.coverage -htmlcov/ -dist -.pybuild -*.egg* -child.log -debug.log -.mypy_cache -.stestr diff -Nru xandikos-0.2.8/grafana-dashboard.json xandikos-0.2.10/grafana-dashboard.json --- xandikos-0.2.8/grafana-dashboard.json 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/grafana-dashboard.json 2022-10-16 15:09:31.000000000 +0000 @@ -0,0 +1,106 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 10286, + "links": [], + "panels": [ + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "7.5.11", + "targets": [ + { + "exemplar": true, + "expr": "up{job=\"xandikos\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Health", + "type": "stat" + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Xandikos", + "uid": "k7dunuVVk", + "version": 2 +} diff -Nru xandikos-0.2.8/Makefile xandikos-0.2.10/Makefile --- xandikos-0.2.8/Makefile 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/Makefile 2023-09-06 09:15:03.000000000 +0000 @@ -14,6 +14,7 @@ style: $(PYTHON) -m flake8 + isort --check . typing: $(PYTHON) -m mypy xandikos @@ -42,21 +43,9 @@ coverage-vdirsyncer: XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-vdirsyncer.sh -check-caldavtester: - TESTS="$(CALDAVTESTER_TESTS)" ./compat/xandikos-caldavtester.sh +check-all: check check-vdirsyncer check-litmus check-pycaldav style -coverage-caldavtester: - TESTS="$(CALDAVTESTER_TESTS)" XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-caldavtester.sh - -check-caldavtester-all: - ./compat/xandikos-caldavtester.sh - -coverage-caldavtester-all: - XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-caldavtester.sh - -check-all: check check-vdirsyncer check-litmus check-caldavtester check-pycaldav style - -coverage-all: coverage coverage-litmus coverage-vdirsyncer coverage-caldavtester +coverage-all: coverage coverage-litmus coverage-vdirsyncer coverage: $(COVERAGE_RUN) --source=xandikos -m unittest $(TESTSUITE) @@ -64,7 +53,15 @@ coverage-html: coverage $(COVERAGE) html +docs: + $(MAKE) -C docs html + +.PHONY: docs + docker: - docker build -t jvernooij/xandikos -t ghcr.io/jelmer/xandikos . - docker push jvernooij/xandikos - docker push ghcr.io/jelmer/xandikos + buildah build -t jvernooij/xandikos -t ghcr.io/jelmer/xandikos . + buildah push jvernooij/xandikos + buildah push ghcr.io/jelmer/xandikos + +reformat: + isort . diff -Nru xandikos-0.2.8/MANIFEST.in xandikos-0.2.10/MANIFEST.in --- xandikos-0.2.8/MANIFEST.in 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/MANIFEST.in 2023-09-06 09:15:03.000000000 +0000 @@ -9,5 +9,5 @@ include compat/*.sha256sum include notes/*.rst include tox.ini -include xandikos.1 graft examples +graft man diff -Nru xandikos-0.2.8/NEWS xandikos-0.2.10/NEWS --- xandikos-0.2.8/NEWS 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/NEWS 2023-09-06 09:15:03.000000000 +0000 @@ -1,3 +1,17 @@ +0.2.10 2023-09-04 + + * Add support for systemd socket activation. + (schnusch, #136, #155) + + * Add basic documentation. + (Jelmer Vernooij) + + * Use entry points to install xandikos script. + (Jelmer Vernooij, #163) + + * ``sync-collection``: handle invalid tokens. + (Jelmer Vernooij) + 0.2.8 2022-01-09 0.2.7 2021-12-27 diff -Nru xandikos-0.2.8/notes/dav-compliance.rst xandikos-0.2.10/notes/dav-compliance.rst --- xandikos-0.2.8/notes/dav-compliance.rst 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/notes/dav-compliance.rst 2023-09-06 09:15:03.000000000 +0000 @@ -326,3 +326,12 @@ Experimental support for WebDAV Mount is available in the 'mount' branch, but won't be merged without a good use case. + +Managed Attachments +------------------- + +Apple extension: + +https://datatracker.ietf.org/doc/html/draft-ietf-calext-caldav-attachments-04 + +Currently unsupported. diff -Nru xandikos-0.2.8/notes/indexes.rst xandikos-0.2.10/notes/indexes.rst --- xandikos-0.2.8/notes/indexes.rst 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/notes/indexes.rst 2023-09-06 09:15:03.000000000 +0000 @@ -49,21 +49,23 @@ This is the naive, slow, fallback implementation. - :param resource: Resource to check + Args: + resource: Resource to check """ raise NotImplementedError(self.check_slow) def check_index(self, values): """Check whether this filter applies to a resources based on index values. - :param values: Dictionary mapping indexes to index values + Args: + values: Dictionary mapping indexes to index values """ raise NotImplementedError(self.check_index) def required_indexes(self): """Return a list of indexes that this Filter needs to function. - :return: List of ORed options, similar to a Depends line in Debian + Returns: List of ORed options, similar to a Depends line in Debian """ raise NotImplementedError(self.required_indexes) diff -Nru xandikos-0.2.8/notes/README.rst xandikos-0.2.10/notes/README.rst --- xandikos-0.2.8/notes/README.rst 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/notes/README.rst 2022-03-05 13:56:59.000000000 +0000 @@ -0,0 +1,3 @@ +This directory contains rough design documentation for Xandikos. + +For user-targeted documentation, see docs/. diff -Nru xandikos-0.2.8/notes/webdav.rst xandikos-0.2.10/notes/webdav.rst --- xandikos-0.2.8/notes/webdav.rst 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/notes/webdav.rst 2023-09-06 09:15:03.000000000 +0000 @@ -34,25 +34,27 @@ def get_body(self): """Returns the body of the resource. - :return: bytes representing contents + Returns: bytes representing contents """ def set_body(self, body): """Set the body of the resource. - :param body: body (as bytes) + Args: + body: body (as bytes) """ def proplist(self): """Return list of properties. - :return: List of property names + Returns: List of property names """ def propupdate(self, updates): """Update properties. - :param updates: Dictionary mapping names to new values + Args: + updates: Dictionary mapping names to new values """ def lock(self): @@ -62,7 +64,7 @@ def members(self): """List members. - :return: List tuples of (name, DAVResource) + Returns: List tuples of (name, DAVResource) """ # TODO(jelmer): COPY diff -Nru xandikos-0.2.8/pyproject.toml xandikos-0.2.10/pyproject.toml --- xandikos-0.2.8/pyproject.toml 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/pyproject.toml 2023-09-04 10:27:41.000000000 +0000 @@ -0,0 +1,93 @@ +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "xandikos" +description = "Lightweight CalDAV/CardDAV server" +readme = "README.rst" +authors = [{name = "Jelmer Vernooij", email = "jelmer@jelmer.uk"}] +license = {text = "GNU GPLv3 or later"} +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Operating System :: POSIX", +] +urls = {Homepage = "https://www.xandikos.org/"} +requires-python = ">=3.9" +dependencies = [ + "aiohttp", + "icalendar>=5.0.4", + "dulwich>=0.21.6", + "defusedxml", + "jinja2", + "multidict", + "vobject", +] +dynamic = ["version"] + +[project.optional-dependencies] +prometheus = ["aiohttp_openmetrics"] +systemd = ["systemd_python"] + +[project.scripts] +xandikos = "xandikos.__main__:main" + +[tool.setuptools] +include-package-data = false + +[tool.setuptools.packages] +find = {namespaces = false} + +[tool.setuptools.package-data] +xandikos = [ + "templates/*.html", + "py.typed", +] + +[tool.setuptools.dynamic] +version = {attr = "xandikos.__version__"} + +[tool.mypy] +ignore_missing_imports = true + +[tool.distutils.bdist_wheel] +universal = 1 + +[tool.ruff] +select = [ + "ANN", + "D", + "E", + "F", + "UP", +] +ignore = [ + "ANN001", + "ANN002", + "ANN003", + "ANN101", # missing-type-self + "ANN102", + "ANN201", + "ANN202", + "ANN204", + "ANN206", + "D100", + "D101", + "D102", + "D103", + "D104", + "D105", + "D107", + "D403", + "D417", + "E501", +] +target-version = "py37" + +[tool.ruff.pydocstyle] +convention = "google" diff -Nru xandikos-0.2.8/README.rst xandikos-0.2.10/README.rst --- xandikos-0.2.8/README.rst 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/README.rst 2023-09-06 09:15:03.000000000 +0000 @@ -3,6 +3,8 @@ Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month in the ancient Macedonian calendar, used in Macedon in the first millennium BC. +Extended documentation can be found `on the home page `_. + Implemented standards ===================== @@ -61,8 +63,8 @@ Dependencies ============ -At the moment, Xandikos supports Python 3.4 and higher as well as Pypy 3. It -also uses `Dulwich `_, +At the moment, Xandikos supports Python 3 (see pyproject.toml for specific version) +as well as Pypy 3. It also uses `Dulwich `_, `Jinja2 `_, `icalendar `_, and `defusedxml `_. @@ -84,7 +86,9 @@ A Dockerfile is also provided; see the comments on the top of the file for configuration instructions. The docker image is regularly built and -published at ``ghcr.io/jelmer/xandikos``. +published at ``ghcr.io/jelmer/xandikos``. See +``examples/docker-compose.yml`` and the +`man page `_ for more info. Running ======= diff -Nru xandikos-0.2.8/.readthedocs.yaml xandikos-0.2.10/.readthedocs.yaml --- xandikos-0.2.8/.readthedocs.yaml 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/.readthedocs.yaml 2022-03-05 18:42:51.000000000 +0000 @@ -0,0 +1,10 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py diff -Nru xandikos-0.2.8/releaser.conf xandikos-0.2.10/releaser.conf --- xandikos-0.2.8/releaser.conf 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/releaser.conf 1970-01-01 00:00:00.000000000 +0000 @@ -1,15 +0,0 @@ -name: "xandikos" -news_file: "NEWS" -timeout_days: 5 -tag_name: "v$VERSION" -verify_command: "make check" -update_version { - path: "setup.py" - match: "^version = \"(.*)\"$" - new_line: "version = \"$VERSION\"" -} -update_version { - path: "xandikos/__init__.py" - match: "^__version__ = \((.*)\)$" - new_line: "__version__ = $TUPLED_VERSION" -} diff -Nru xandikos-0.2.8/setup.cfg xandikos-0.2.10/setup.cfg --- xandikos-0.2.8/setup.cfg 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/setup.cfg 1970-01-01 00:00:00.000000000 +0000 @@ -1,11 +0,0 @@ -[flake8] -ignore = W504,E203,W503 -exclude = compat/vdirsyncer/,.tox,compat/ccs-caldavtester,.git,compat/pycaldav -application-package-names = xandikos - -[mypy] -# A number of xandikos' dependencies don't have type hints yet -ignore_missing_imports = True - -[bdist_wheel] -universal = 1 diff -Nru xandikos-0.2.8/setup.py xandikos-0.2.10/setup.py --- xandikos-0.2.8/setup.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/setup.py 2023-09-06 09:15:03.000000000 +0000 @@ -1,68 +1,4 @@ -#!/usr/bin/env python3 -# encoding: utf-8 -# -# Xandikos -# Copyright (C) 2016-2017 Jelmer Vernooij , et al. -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; version 3 -# of the License or (at your option) any later version of -# the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, -# MA 02110-1301, USA. +#!/usr/bin/python3 +from setuptools import setup -from setuptools import find_packages, setup -import sys - -version = "0.2.8" - -with open('README.rst', encoding='utf-8') as f: - long_description = f.read() - -if sys.platform == 'win32': - # Strip out non-mbcs characters - long_description = long_description.encode('ascii', 'replace').decode() - -setup(name="xandikos", - description="Lightweight CalDAV/CardDAV server", - long_description=long_description, - version=version, - author="Jelmer Vernooij", - author_email="jelmer@jelmer.uk", - license="GNU GPLv3 or later", - url="https://www.xandikos.org/", - install_requires=[ - 'aiohttp', - 'icalendar', - 'dulwich>=0.19.1', - 'defusedxml', - 'jinja2', - 'multidict', - ], - extras_require={ - 'prometheus': ['aiohttp_openmetrics'], - }, - packages=find_packages(), - package_data={'xandikos': ['templates/*.html']}, - data_files=[('share/man/man8', ['man/xandikos.8'])], - scripts=['bin/xandikos'], - test_suite='xandikos.tests.test_suite', - classifiers=[ - 'Development Status :: 4 - Beta', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', # noqa - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Operating System :: POSIX', - ]) +setup() diff -Nru xandikos-0.2.8/xandikos/access.py xandikos-0.2.10/xandikos/access.py --- xandikos-0.2.8/xandikos/access.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/access.py 2023-09-06 09:15:03.000000000 +0000 @@ -31,7 +31,7 @@ class CurrentUserPrivilegeSetProperty(webdav.Property): - """current-user-privilege-set property + """current-user-privilege-set property. See http://www.webdav.org/specs/rfc3744.html, section 3.7 """ diff -Nru xandikos-0.2.8/xandikos/apache.py xandikos-0.2.10/xandikos/apache.py --- xandikos-0.2.8/xandikos/apache.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/apache.py 2023-09-06 09:15:03.000000000 +0000 @@ -25,7 +25,7 @@ class ExecutableProperty(webdav.Property): - """executable property + """executable property. Equivalent of the 'x' bit on POSIX. """ @@ -43,4 +43,4 @@ elif el.text == "F": resource.set_is_executable(False) else: - raise ValueError("invalid executable setting %r" % el.text) + raise ValueError(f"invalid executable setting {el.text!r}") diff -Nru xandikos-0.2.8/xandikos/caldav.py xandikos-0.2.10/xandikos/caldav.py --- xandikos-0.2.8/xandikos/caldav.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/caldav.py 2023-09-06 09:15:03.000000000 +0000 @@ -23,22 +23,15 @@ """ import datetime import itertools -import pytz - -from .icalendar import ( - apply_time_range_vevent, - as_tz_aware_ts, - expand_calendar_rrule, -) -from icalendar.cal import ( - component_factory, - Calendar as ICalendar, - FreeBusy, - Component, -) -from icalendar.prop import vDDDTypes, vPeriod, LocalTimezone -from xandikos import davcommon, webdav +import pytz +from icalendar.cal import Calendar as ICalendar +from icalendar.cal import Component, FreeBusy, component_factory +from icalendar.prop import LocalTimezone, vDDDTypes, vPeriod + +from . import davcommon, webdav +from .icalendar import (apply_time_range_vevent, as_tz_aware_ts, + expand_calendar_rrule) ET = webdav.ET @@ -66,7 +59,8 @@ class Calendar(webdav.Collection): - resource_types = webdav.Collection.resource_types + [CALENDAR_RESOURCE_TYPE] + resource_types = webdav.Collection.resource_types + [ + CALENDAR_RESOURCE_TYPE] def get_calendar_description(self) -> str: """Return the calendar description.""" @@ -107,14 +101,14 @@ def get_supported_calendar_components(self) -> str: """Return set of supported calendar components in this calendar. - :return: iterable over component names + Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) def get_supported_calendar_data_types(self) -> str: """Return supported calendar data types. - :return: iterable over (content_type, version) tuples + Returns: iterable over (content_type, version) tuples """ raise NotImplementedError(self.get_supported_calendar_data_types) @@ -159,9 +153,10 @@ This is a naive implementation; subclasses should ideally provide their own implementation that is faster. - :param create_filter_fn: Callback that constructs a + Args: + create_filter_fn: Callback that constructs a filter; takes a filter building class. - :return: Iterator over name, resource objects + Returns: Iterator over name, resource objects """ raise NotImplementedError(self.calendar_query) @@ -175,9 +170,10 @@ raise NotImplementedError(self.get_xmpp_uri) -class Subscription(object): +class Subscription: - resource_types = webdav.Collection.resource_types + [SUBSCRIPTION_RESOURCE_TYPE] + resource_types = webdav.Collection.resource_types + [ + SUBSCRIPTION_RESOURCE_TYPE] def get_source_url(self): """Get the source URL for this calendar.""" @@ -202,12 +198,12 @@ def get_supported_calendar_components(self): """Return set of supported calendar components in this calendar. - :return: iterable over component names + Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) -class CalendarHomeSet(object): +class CalendarHomeSet: def get_managed_attachments_server_url(self): """Return the attachments server URL.""" raise NotImplementedError(self.get_managed_attachments_server_url) @@ -219,20 +215,20 @@ def get_calendar_home_set(self): """Get the calendar home set. - :return: a set of URLs + Returns: a set of URLs """ raise NotImplementedError(self.get_calendar_home_set) def get_calendar_user_address_set(self): """Get the calendar user address set. - :return: a set of URLs (usually mailto:...) + Returns: a set of URLs (usually mailto:...) """ raise NotImplementedError(self.get_calendar_user_address_set) class CalendarHomeSetProperty(webdav.Property): - """calendar-home-set property + """calendar-home-set property. See https://www.ietf.org/rfc/rfc4791.txt, section 6.2.1. """ @@ -265,7 +261,8 @@ raise NotImplementedError -def _extract_from_component(incomp: Component, outcomp: Component, requested) -> None: +def _extract_from_component( + incomp: Component, outcomp: Component, requested) -> None: """Extract specific properties from a calendar event. Args: @@ -291,14 +288,15 @@ outcomp.add_component(outsub) _extract_from_component(insub, outsub, tag) else: - raise AssertionError("invalid element %r" % tag) + raise AssertionError(f"invalid element {tag!r}") def extract_from_calendar(incal, requested): """Extract requested components/properties from calendar. - :param incal: Calendar to filter - :param requested: element with requested + Args: + incal: Calendar to filter + requested: element with requested components/properties """ for tag in requested: @@ -312,17 +310,19 @@ incal = expand_calendar_rrule(incal, start, end) elif tag.tag == ("{%s}limit-recurrence-set" % NAMESPACE): # TODO(jelmer): https://github.com/jelmer/xandikos/issues/103 - raise NotImplementedError("limit-recurrence-set is not yet implemented") + raise NotImplementedError( + "limit-recurrence-set is not yet implemented") elif tag.tag == ("{%s}limit-freebusy-set" % NAMESPACE): # TODO(jelmer): https://github.com/jelmer/xandikos/issues/104 - raise NotImplementedError("limit-freebusy-set is not yet implemented") + raise NotImplementedError( + "limit-freebusy-set is not yet implemented") else: - raise AssertionError("invalid element %r" % tag) + raise AssertionError(f"invalid element {tag!r}") return incal class CalendarDataProperty(davcommon.SubbedProperty): - """calendar-data property + """calendar-data property. See https://tools.ietf.org/html/rfc4791, section 5.2.4 @@ -339,7 +339,7 @@ if len(requested) == 0: serialized_cal = b"".join(await resource.get_body()) else: - calendar = calendar_from_resource(resource) + calendar = await calendar_from_resource(resource) if calendar is None: raise KeyError c = extract_from_calendar(calendar, requested) @@ -389,7 +389,7 @@ elif subel.tag == "{urn:ietf:params:xml:ns:caldav}is-not-defined": pass else: - raise AssertionError("unknown subelement %r" % subel.tag) + raise AssertionError(f"unknown subelement {subel.tag!r}") return prop_filter @@ -432,8 +432,6 @@ start = vDDDTypes.from_ical(start) end = vDDDTypes.from_ical(end) assert end > start - assert end.tzinfo - assert start.tzinfo return (start, end) @@ -466,7 +464,7 @@ elif subel.tag == "{urn:ietf:params:xml:ns:caldav}time-range": parse_time_range(subel, comp_filter.filter_time_range) else: - raise AssertionError("unknown filter tag %r" % subel.tag) + raise AssertionError(f"unknown filter tag {subel.tag!r}") return comp_filter @@ -475,17 +473,18 @@ if subel.tag == "{urn:ietf:params:xml:ns:caldav}comp-filter": parse_comp_filter(subel, cls.filter_subcomponent) else: - raise AssertionError("unknown filter tag %r" % subel.tag) + raise AssertionError(f"unknown filter tag {subel.tag!r}") return cls -def calendar_from_resource(resource): +async def calendar_from_resource(resource): try: if resource.get_content_type() != "text/calendar": return None except KeyError: return None - return resource.file.calendar + file = await resource.get_file() + return file.calendar def extract_tzid(cal): @@ -522,6 +521,7 @@ base_href, base_resource, depth, + strict ): # TODO(jelmer): Verify that resource is a calendar requested = None @@ -535,9 +535,15 @@ elif el.tag == "{urn:ietf:params:xml:ns:caldav}timezone": tztext = el.text else: - raise webdav.BadRequestError( - "Unknown tag %s in report %s" % (el.tag, self.name) + webdav.nonfatal_bad_request( + f"Unknown tag {el.tag} in report {self.name}", + strict ) + if requested is None: + # The CalDAV RFC says that behaviour mimicks that of PROPFIND, + # and the WebDAV RFC says that no body implies {DAV}allprop + # This isn't exactly an empty body, but close enough. + requested = ET.Element('{DAV:}allprop') if tztext is not None: tz = get_pytz_from_text(tztext) else: @@ -571,7 +577,7 @@ class CalendarColorProperty(webdav.Property): - """calendar-color property + """calendar-color property. This contains a HTML #RRGGBB color code, as CDATA. """ @@ -587,7 +593,7 @@ class SupportedCalendarComponentSetProperty(webdav.Property): - """supported-calendar-component-set property + """supported-calendar-component-set property. Set of supported calendar components by this calendar. @@ -629,7 +635,8 @@ content_type, version, ) in resource.get_supported_calendar_data_types(): - subel = ET.SubElement(el, "{urn:ietf:params:xml:ns:caldav}calendar-data") + subel = ET.SubElement( + el, "{urn:ietf:params:xml:ns:caldav}calendar-data") subel.set("content-type", content_type) subel.set("version", version) @@ -867,14 +874,14 @@ elif transp == TRANSPARENCY_OPAQUE: ET.SubElement(el, "{%s}opaque" % NAMESPACE) else: - raise ValueError("Invalid transparency %s" % transp) + raise ValueError(f"Invalid transparency {transp}") def map_freebusy(comp): transp = comp.get("TRANSP", "OPAQUE") if transp == "TRANSPARENT": return "FREE" - assert transp == "OPAQUE", "unknown transp %r" % transp + assert transp == "OPAQUE", f"unknown transp {transp!r}" status = comp.get("STATUS", "CONFIRMED") if status == "CONFIRMED": return "BUSY" @@ -885,7 +892,7 @@ elif status.startswith("X-"): return status else: - raise AssertionError("unknown status %r" % status) + raise AssertionError(f"unknown status {status!r}") def extract_freebusy(comp, tzify): @@ -903,7 +910,7 @@ async def iter_freebusy(resources, start, end, tzify): async for (href, resource) in resources: - c = calendar_from_resource(resource) + c = await calendar_from_resource(resource) if c is None: continue if c.name != "VCALENDAR": @@ -934,21 +941,21 @@ base_href, base_resource, depth, + strict ): requested = None for el in body: if el.tag == "{urn:ietf:params:xml:ns:caldav}time-range": requested = el else: - raise AssertionError("unexpected XML element") + webdav.nonfatal_bad_request("unexpected XML element", strict) + continue tz = get_calendar_timezone(base_resource) def tzify(dt): return as_tz_aware_ts(dt, tz).astimezone(pytz.utc) (start, end) = _parse_time_range(requested) - assert start.tzinfo - assert end.tzinfo ret = ICalendar() ret["VERSION"] = "2.0" ret["PRODID"] = PRODID @@ -987,7 +994,7 @@ request, "403 Forbidden", error=ET.Element("{DAV:}resource-must-be-null"), - description=("Something already exists at %r" % path), + description=f"Something already exists at {path!r}", ) try: resource = app.backend.create_collection(path) @@ -998,7 +1005,8 @@ href, resource, el, environ ) ET.SubElement(el, "{urn:ietf:params:xml:ns:caldav}calendar") - await app.properties["{DAV:}resourcetype"].set_value(href, resource, el) + await app.properties["{DAV:}resourcetype"].set_value( + href, resource, el) if base_content_type in ("text/xml", "application/xml"): et = await webdav._readXmlBody( request, @@ -1008,9 +1016,10 @@ propstat = [] for el in et: if el.tag != "{DAV:}set": - raise webdav.BadRequestError( - "Unknown tag %s in mkcalendar" % el.tag - ) + webdav.nonfatal_bad_request( + f"Unknown tag {el.tag} in mkcalendar", + app.strict) + continue propstat.extend( [ ps @@ -1019,7 +1028,8 @@ ) ] ) - ret = ET.Element("{urn:ietf:params:xml:ns:carldav:}mkcalendar-response") + ret = ET.Element( + "{urn:ietf:params:xml:ns:carldav:}mkcalendar-response") for propstat_el in webdav.propstat_as_xml(propstat): ret.append(propstat_el) return webdav._send_xml_response( diff -Nru xandikos-0.2.8/xandikos/carddav.py xandikos-0.2.10/xandikos/carddav.py --- xandikos-0.2.8/xandikos/carddav.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/carddav.py 2023-09-06 09:15:03.000000000 +0000 @@ -22,13 +22,8 @@ https://tools.ietf.org/html/rfc6352 """ -from typing import Set - -from xandikos import ( - collation as _mod_collation, - davcommon, - webdav, -) +from . import collation as _mod_collation +from . import davcommon, webdav ET = webdav.ET @@ -42,7 +37,7 @@ class AddressbookHomeSetProperty(webdav.Property): - """addressbook-home-set property + """addressbook-home-set property. See https://tools.ietf.org/html/rfc6352, section 7.1.1 """ @@ -59,7 +54,7 @@ class AddressDataProperty(davcommon.SubbedProperty): - """address-data property + """address-data property. See https://tools.ietf.org/html/rfc6352, section 10.4 @@ -103,7 +98,8 @@ class Addressbook(webdav.Collection): - resource_types = webdav.Collection.resource_types + [ADDRESSBOOK_RESOURCE_TYPE] + resource_types = webdav.Collection.resource_types + [ + ADDRESSBOOK_RESOURCE_TYPE] def get_addressbook_description(self) -> str: raise NotImplementedError(self.get_addressbook_description) @@ -120,19 +116,19 @@ def get_supported_address_data_types(self): """Get list of supported data types. - :return: List of tuples with content type and version + Returns: List of tuples with content type and version """ raise NotImplementedError(self.get_supported_address_data_types) def get_max_resource_size(self) -> int: - """Get maximum object size this address book will store (in bytes) + """Get maximum object size this address book will store (in bytes). Absence indicates no maximum. """ raise NotImplementedError(self.get_max_resource_size) def get_max_image_size(self) -> int: - """Get maximum image size this address book will store (in bytes) + """Get maximum image size this address book will store (in bytes). Absence indicates no maximum. """ @@ -142,10 +138,10 @@ class PrincipalExtensions: """Extensions to webdav.Principal.""" - def get_addressbook_home_set(self) -> Set[str]: + def get_addressbook_home_set(self) -> set[str]: """Return set of addressbook home URLs. - :return: set of URLs + Returns: set of URLs """ raise NotImplementedError(self.get_addressbook_home_set) @@ -219,24 +215,22 @@ el.text = str(resource.get_max_image_size()) -def addressbook_from_resource(resource): +async def addressbook_from_resource(resource): try: if resource.get_content_type() != "text/vcard": return None except KeyError: return None - return resource.file.addressbook + file = await resource.get_file() + return file.addressbook.contents -def apply_text_match(el, value): +def apply_text_match(el: ET.Element, value: str) -> bool: collation = el.get("collation", "i;ascii-casemap") negate_condition = el.get("negate-condition", "no") - # TODO(jelmer): Handle match-type: 'contains', 'equals', 'starts-with', - # 'ends-with' match_type = el.get("match-type", "contains") - if match_type != "contains": - raise NotImplementedError("match_type != contains: %r" % match_type) - matches = _mod_collation.collations[collation](el.text, value) + matches = _mod_collation.collations[collation]( + value, el.text or '', match_type) if negate_condition == "yes": return not matches @@ -246,7 +240,8 @@ def apply_param_filter(el, prop): name = el.get("name") - if len(el) == 1 and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined": + if (len(el) == 1 + and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined"): return name not in prop.params try: @@ -264,14 +259,15 @@ def apply_prop_filter(el, ab): - name = el.get("name") + name = el.get("name").lower() # From https://tools.ietf.org/html/rfc6352 # A CARDDAV:prop-filter is said to match if: # The CARDDAV:prop-filter XML element contains a CARDDAV:is-not-defined XML # element and no property of the type specified by the "name" attribute # exists in the enclosing calendar component; - if len(el) == 1 and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined": + if (len(el) == 1 + and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined"): return name not in ab try: @@ -279,22 +275,28 @@ except KeyError: return False - for subel in el: - if subel.tag == "{urn:ietf:params:xml:ns:carddav}text-match": - if not apply_text_match(subel, prop): - return False - elif subel.tag == "{urn:ietf:params:xml:ns:carddav}param-filter": - if not apply_param_filter(subel, prop): - return False - return True + for prop_el in prop: + matched = True + for subel in el: + if subel.tag == "{urn:ietf:params:xml:ns:carddav}text-match": + if not apply_text_match(subel, str(prop_el)): + matched = False + break + elif subel.tag == "{urn:ietf:params:xml:ns:carddav}param-filter": + if not apply_param_filter(subel, prop_el): + matched = False + break + if matched: + return True + return False -def apply_filter(el, resource): +async def apply_filter(el, resource): """Compile a filter element into a Python function.""" if el is None or not list(el): # Empty filter, let's not bother parsing return lambda x: True - ab = addressbook_from_resource(resource) + ab = await addressbook_from_resource(resource) if ab is None: return False test_name = el.get("test", "anyof") @@ -318,6 +320,7 @@ base_href, base_resource, depth, + strict ): requested = None filter_el = None @@ -330,18 +333,28 @@ elif el.tag == ("{%s}limit" % NAMESPACE): limit = el else: - raise webdav.BadRequestError( - "Unknown tag %s in report %s" % (el.tag, self.name) - ) + webdav.nonfatal_bad_request( + f"Unknown tag {el.tag} in report {self.name}", + strict) + if requested is None: + # The CardDAV RFC says that behaviour mimicks that of PROPFIND, + # and the WebDAV RFC says that no body implies {DAV}allprop + # This isn't exactly an empty body, but close enough. + requested = ET.Element('{DAV:}allprop') if limit is not None: try: [nresults_el] = list(limit) except ValueError: - raise webdav.BadRequestError("Invalid number of subelements in limit") - try: - nresults = int(nresults_el.text) - except ValueError: - raise webdav.BadRequestError("nresults not a number") + webdav.nonfatal_bad_request( + "Invalid number of subelements in limit", strict) + nresults = None + else: + try: + nresults = int(nresults_el.text) + except ValueError: + webdav.nonfatal_bad_request( + "nresults not a number", strict) + nresults = None else: nresults = None @@ -349,7 +362,7 @@ async for (href, resource) in webdav.traverse_resource( base_resource, base_href, depth ): - if not apply_filter(filter_el, resource): + if not await apply_filter(filter_el, resource): continue if nresults is not None and i >= nresults: break @@ -361,5 +374,6 @@ environ, requested, ) - yield webdav.Status(href, "200 OK", propstat=[s async for s in propstat]) + yield webdav.Status( + href, "200 OK", propstat=[s async for s in propstat]) i += 1 diff -Nru xandikos-0.2.8/xandikos/collation.py xandikos-0.2.10/xandikos/collation.py --- xandikos-0.2.8/xandikos/collation.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/collation.py 2023-09-06 09:15:03.000000000 +0000 @@ -23,28 +23,49 @@ class UnknownCollation(Exception): - def __init__(self, collation: str): - super(UnknownCollation, self).__init__( - "Collation %r is not supported" % collation + def __init__(self, collation: str) -> None: + super().__init__( + f"Collation {collation!r} is not supported" ) self.collation = collation -collations = { - "i;ascii-casemap": lambda a, b: ( - a.decode("ascii").upper() == b.decode("ascii").upper() +def _match(a, b, k): + if k == "equals": + return a == b + elif k == "contains": + return b in a + elif k == "starts-with": + return a.startswith(b) + elif k == "ends-with": + return b.endswith(b) + else: + raise NotImplementedError + + +collations: dict[str, Callable[[str, str, str], bool]] = { + "i;ascii-casemap": lambda a, b, k: _match( + a.encode("ascii").upper(), b.encode("ascii").upper(), k ), - "i;octet": lambda a, b: a == b, + "i;octet": lambda a, b, k: _match(a, b, k), + # TODO(jelmer): Follow all rules as specified in + # https://datatracker.ietf.org/doc/html/rfc5051 + "i;unicode-casemap": lambda a, b, k: _match( + a.encode('utf-8', 'surrogateescape').upper(), + b.encode('utf-8', 'surrogateescape').upper(), + k), } -def get_collation(name: str) -> Callable[[str, str], bool]: +def get_collation(name: str) -> Callable[[str, str, str], bool]: """Get a collation by name. - :param name: Collation name - :raises UnknownCollation: If the collation is not supported + Args: + name: Collation name + Raises: + UnknownCollation: If the collation is not supported """ try: return collations[name] - except KeyError: - raise UnknownCollation(name) + except KeyError as exc: + raise UnknownCollation(name) from exc diff -Nru xandikos-0.2.8/xandikos/davcommon.py xandikos-0.2.10/xandikos/davcommon.py --- xandikos-0.2.8/xandikos/davcommon.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/davcommon.py 2023-09-06 09:15:03.000000000 +0000 @@ -30,11 +30,12 @@ async def get_value_ext(self, href, resource, el, environ, requested): """Get the value of a data property. - :param href: Resource href - :param resource: Resource to get value for - :param el: Element to fill in - :param environ: WSGI environ dict - :param requested: Requested property (including subelements) + Args: + href: Resource href + resource: Resource to get value for + el: Element to fill in + environ: WSGI environ dict + requested: Requested property (including subelements) """ raise NotImplementedError(self.get_value_ext) @@ -68,6 +69,7 @@ base_href, resource, depth, + strict ): # TODO(jelmer): Verify that depth == "0" # TODO(jelmer): Verify that resource is an the right resource type @@ -79,9 +81,14 @@ elif el.tag == "{DAV:}href": hrefs.append(webdav.read_href_element(el)) else: - raise webdav.BadRequestError( - "Unknown tag %s in report %s" % (el.tag, self.name) - ) + webdav.nonfatal_bad_request( + f"Unknown tag {el.tag} in report {self.name}", + strict) + if requested is None: + # The CalDAV RFC says that behaviour mimicks that of PROPFIND, + # and the WebDAV RFC says that no body implies {DAV}allprop + # This isn't exactly an empty body, but close enough. + requested = ET.Element('{DAV:}allprop') for (href, resource) in resources_by_hrefs(hrefs): if resource is None: yield webdav.Status(href, "404 Not Found", propstat=[]) diff -Nru xandikos-0.2.8/xandikos/icalendar.py xandikos-0.2.10/xandikos/icalendar.py --- xandikos-0.2.8/xandikos/icalendar.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/icalendar.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,30 +17,29 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""ICalendar file handling. +"""ICalendar file handling.""" -""" - -import datetime import logging -import pytz +from collections.abc import Iterable +from datetime import datetime, time, timedelta, timezone +from typing import Callable, Optional, Union import dateutil.rrule -from icalendar.cal import Calendar, component_factory -from icalendar.prop import ( - vDatetime, - vDDDTypes, - vText, -) -from xandikos.store import ( - Filter, - File, - InvalidFileContents, -) - -from . import ( - collation as _mod_collation, -) +import pytz +from icalendar.cal import Calendar, Component, component_factory +from icalendar.prop import TypesFactory, vCategory, vDatetime, vDDDTypes, vText + +from xandikos.store import File, Filter, InvalidFileContents + +from . import collation as _mod_collation +from .store.index import IndexDict, IndexKey, IndexValue, IndexValueIterator + +TYPES_FACTORY = TypesFactory() + +PropTypes = Union[vText] + +TzifyFunction = Callable[[datetime], datetime] + # TODO(jelmer): Populate this further based on # https://tools.ietf.org/html/rfc5545#3.3.11 @@ -48,26 +47,32 @@ class MissingProperty(Exception): - def __init__(self, property_name): - super(MissingProperty, self).__init__("Property %r missing" % property_name) + def __init__(self, property_name) -> None: + super().__init__( + f"Property {property_name!r} missing") self.property_name = property_name def validate_calendar(cal, strict=False): """Validate a calendar object. - :param cal: Calendar object - :return: iterator over error messages + Args: + cal: Calendar object + Returns: iterator over error messages """ - for error in validate_component(cal, strict=strict): - yield error + yield from validate_component(cal, strict=strict) + + +# SubIndexDict is like IndexDict, but None can also occur as a key +SubIndexDict = dict[Optional[IndexKey], IndexValue] -def create_subindexes(indexes, base): - ret = {} +def create_subindexes( + indexes: Union[SubIndexDict, IndexDict], base: str) -> SubIndexDict: + ret: SubIndexDict = {} for k, v in indexes.items(): if k is not None and k.startswith(base + "/"): - ret[k[len(base) + 1 :]] = v + ret[k[len(base) + 1:]] = v elif k == base: ret[None] = v return ret @@ -76,14 +81,15 @@ def validate_component(comp, strict=False): """Validate a calendar component. - :param comp: Calendar component + Args: + comp: Calendar component """ # Check text fields for invalid characters for (name, value) in comp.items(): if isinstance(value, vText): for c in _INVALID_CONTROL_CHARACTERS: if c in value: - yield "Invalid character %s in field %s" % ( + yield "Invalid character {} in field {}".format( c.encode("unicode_escape"), name, ) @@ -92,18 +98,18 @@ try: comp[required] except KeyError: - yield "Missing required field %s" % required + yield f"Missing required field {required}" for subcomp in comp.subcomponents: - for error in validate_component(subcomp, strict=strict): - yield error + yield from validate_component(subcomp, strict=strict) def calendar_component_delta(old_cal, new_cal): """Find the differences between components in two calendars. - :param old_cal: Old calendar (can be None) - :param new_cal: New calendar (can be None) - :yield: (old_component, new_component) tuples (either can be None) + Args: + old_cal: Old calendar (can be None) + new_cal: New calendar (can be None) + Returns: iterator over (old_component, new_component) tuples (either can be None) """ by_uid = {} by_content = {} @@ -153,7 +159,7 @@ def describe_component(component): if component.name == "VTODO": try: - return "task '%s'" % component["SUMMARY"] + return f"task '{component['SUMMARY']}'" except KeyError: return "task" else: @@ -163,61 +169,62 @@ return "calendar item" -DELTA_IGNORE_FIELDS = set( - [ - "LAST-MODIFIED", - "SEQUENCE", - "DTSTAMP", - "PRODID", - "CREATED", - "COMPLETED", - "X-MOZ-GENERATION", - "X-LIC-ERROR", - "UID", - ] -) +DELTA_IGNORE_FIELDS = { + "LAST-MODIFIED", + "SEQUENCE", + "DTSTAMP", + "PRODID", + "CREATED", + "COMPLETED", + "X-MOZ-GENERATION", + "X-LIC-ERROR", + "UID", +} def describe_calendar_delta(old_cal, new_cal): """Describe the differences between two calendars. - :param old_cal: Old calendar (can be None) - :param new_cal: New calendar (can be None) - :yield: Lines describing changes + Args: + old_cal: Old calendar (can be None) + new_cal: New calendar (can be None) + Returns: Lines describing changes """ # TODO(jelmer): Extend - for old_component, new_component in calendar_component_delta(old_cal, new_cal): + for old_component, new_component in calendar_component_delta( + old_cal, new_cal): if not new_component: - yield "Deleted %s" % describe_component(old_component) + yield f"Deleted {describe_component(old_component)}" continue description = describe_component(new_component) if not old_component: - yield "Added %s" % describe_component(new_component) + yield f"Added {describe_component(new_component)}" continue for field, old_value, new_value in calendar_prop_delta( old_component, new_component ): if field.upper() in DELTA_IGNORE_FIELDS: continue - if old_component.name.upper() == "VTODO" and field.upper() == "STATUS": + if (old_component.name.upper() == "VTODO" + and field.upper() == "STATUS"): if new_value is None: - yield "status of %s deleted" % description + yield f"status of {description} deleted" else: human_readable = { "NEEDS-ACTION": "needing action", "COMPLETED": "complete", "CANCELLED": "cancelled", } - yield "%s marked as %s" % ( + yield "{} marked as {}".format( description, human_readable.get(new_value.upper(), new_value), ) elif field.upper() == "DESCRIPTION": - yield "changed description of %s" % description + yield f"changed description of {description}" elif field.upper() == "SUMMARY": - yield "changed summary of %s" % description + yield f"changed summary of {description}" elif field.upper() == "LOCATION": - yield "changed location of %s to %s" % (description, new_value) + yield f"changed location of {description} to {new_value}" elif ( old_component.name.upper() == "VTODO" and field.upper() == "PERCENT-COMPLETE" @@ -225,31 +232,31 @@ ): yield "%s marked as %d%% completed." % (description, new_value) elif field.upper() == "DUE": - yield "changed due date for %s from %s to %s" % ( + yield "changed due date for {} from {} to {}".format( description, old_value.dt if old_value else "none", new_value.dt if new_value else "none", ) elif field.upper() == "DTSTART": - yield "changed start date/time of %s from %s to %s" % ( + yield "changed start date/time of {} from {} to {}".format( description, old_value.dt if old_value else "none", new_value.dt if new_value else "none", ) elif field.upper() == "DTEND": - yield "changed end date/time of %s from %s to %s" % ( + yield "changed end date/time of {} from {} to {}".format( description, old_value.dt if old_value else "none", new_value.dt if new_value else "none", ) elif field.upper() == "CLASS": - yield "changed class of %s from %s to %s" % ( + yield "changed class of {} from {} to {}".format( description, old_value.lower() if old_value else "none", new_value.lower() if new_value else "none", ) else: - yield "modified field %s in %s" % (field, description) + yield f"modified field {field} in {description}" logging.debug( "Changed %s/%s or %s/%s from %s to %s.", old_component.name, @@ -281,7 +288,7 @@ if getattr(dtstart.dt, "time", None) is not None: return start <= tzify(dtstart.dt) else: - return start < (tzify(dtstart.dt) + datetime.timedelta(1)) + return start < (tzify(dtstart.dt) + timedelta(1)) def apply_time_range_vjournal(start, end, comp, tzify): @@ -295,7 +302,7 @@ if getattr(dtstart.dt, "time", None) is not None: return start <= tzify(dtstart.dt) else: - return start < (tzify(dtstart.dt) + datetime.timedelta(1)) + return start < (tzify(dtstart.dt) + timedelta(1)) def apply_time_range_vtodo(start, end, comp, tzify): @@ -307,7 +314,8 @@ duration = comp.get("DURATION") if duration and not due: return start <= tzify(dtstart.dt) + duration.dt and ( - end > tzify(dtstart.dt) or end >= tzify(dtstart.dt) + duration.dt + end > tzify(dtstart.dt) + or end >= tzify(dtstart.dt) + duration.dt ) elif due and not duration: return (start <= tzify(dtstart.dt) or start < tzify(due.dt)) and ( @@ -323,9 +331,9 @@ created = comp.get("CREATED") if completed: if created: - return (start <= tzify(created.dt) or start <= tzify(completed.dt)) and ( - end >= tzify(created.dt) or end >= tzify(completed.dt) - ) + return (start <= tzify(created.dt) + or start <= tzify(completed.dt)) and ( + end >= tzify(created.dt) or end >= tzify(completed.dt)) else: return start <= tzify(completed.dt) and end >= tzify(completed.dt) elif created: @@ -351,25 +359,30 @@ raise NotImplementedError(apply_time_range_valarm) -class PropertyTimeRangeMatcher(object): - def __init__(self, start, end): +class PropertyTimeRangeMatcher: + def __init__(self, start: datetime, end: datetime) -> None: self.start = start self.end = end - def __repr__(self): - return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.start!r}, {self.end!r})" def match(self, prop, tzify): dt = tzify(prop.dt) return dt >= self.start and dt <= self.end - def match_indexes(self, prop, tzify): + def match_indexes(self, prop: SubIndexDict, tzify: TzifyFunction): return any( - self.match(vDDDTypes(vDatetime.from_ical(p)), tzify) for p in prop[None] - ) + self.match( + vDDDTypes(vDDDTypes.from_ical(p.decode('utf-8'))), tzify) + for p in prop[None] if not isinstance(p, bool)) + + +TimeRangeFilter = Callable[ + [datetime, datetime, Component, TzifyFunction], bool] -class ComponentTimeRangeMatcher(object): +class ComponentTimeRangeMatcher: all_props = [ "DTSTART", @@ -383,7 +396,7 @@ # According to https://tools.ietf.org/html/rfc4791, section 9.9 these # are the properties to check. - component_handlers = { + component_handlers: dict[str, TimeRangeFilter] = { "VEVENT": apply_time_range_vevent, "VTODO": apply_time_range_vtodo, "VJOURNAL": apply_time_range_vjournal, @@ -391,52 +404,58 @@ "VALARM": apply_time_range_valarm, } - def __init__(self, start, end, comp=None): + def __init__(self, start, end, comp=None) -> None: self.start = start self.end = end self.comp = comp - def __repr__(self): + def __repr__(self) -> str: if self.comp is not None: - return "%s(%r, %r, comp=%r)" % ( + return "{}({!r}, {!r}, comp={!r})".format( self.__class__.__name__, self.start, self.end, self.comp, ) else: - return "%s(%r, %r)" % ( - self.__class__.__name__, - self.start, - self.end, - ) + return f"{self.__class__.__name__}({self.start!r}, {self.end!r})" - def match(self, comp, tzify): + def match(self, comp: Component, tzify: TzifyFunction): try: component_handler = self.component_handlers[comp.name] except KeyError: - logging.warning("unknown component %r in time-range filter", comp.name) + logging.warning( + "unknown component %r in time-range filter", comp.name) return False return component_handler(self.start, self.end, comp, tzify) - def match_indexes(self, indexes, tzify): - vs = {} - for name, value in indexes.items(): - if name and name[2:] in self.all_props: - if value and value[0]: - if not isinstance(value[0], vDDDTypes): - vs[name[2:]] = vDDDTypes(vDatetime.from_ical(value[0])) - else: - vs[name[2:]] = value[0] + def match_indexes(self, indexes: SubIndexDict, tzify: TzifyFunction): + vs: dict[str, vDDDTypes] = {} + for name, values in indexes.items(): + if not name: + continue + field = name[2:] + if field not in self.all_props: + continue + for value in values: + if value and not isinstance(value, bool): + vs.setdefault(field, []).append( + vDDDTypes(vDDDTypes.from_ical(value.decode('utf-8')))) try: component_handler = self.component_handlers[self.comp] except KeyError: - logging.warning("unknown component %r in time-range filter", self.comp) + logging.warning( + "unknown component %r in time-range filter", self.comp) return False - return component_handler(self.start, self.end, vs, tzify) + return component_handler( + self.start, + self.end, + # TODO(jelmer): What to do if there is more than one value? + {k: vs[0] for (k, vs) in vs.items()}, + tzify) - def index_keys(self): + def index_keys(self) -> list[list[str]]: if self.comp == "VEVENT": props = ["DTSTART", "DTEND", "DURATION"] elif self.comp == "VTODO": @@ -452,73 +471,95 @@ return [["P=" + prop] for prop in props] -class TextMatcher(object): - def __init__(self, text, collation=None, negate_condition=False): - if isinstance(text, str): - text = text.encode() +class TextMatcher: + def __init__(self, name: str, text: str, + collation: Optional[str] = None, + negate_condition: bool = False) -> None: + self.name = name + self.type_fn = TYPES_FACTORY.for_property(name) + assert isinstance(text, str) self.text = text if collation is None: collation = "i;ascii-casemap" self.collation = _mod_collation.get_collation(collation) self.negate_condition = negate_condition - def __repr__(self): - return "%s(%r, collation=%r, negate_condition=%r)" % ( + def __repr__(self) -> str: + return "{}({!r}, {!r}, collation={!r}, negate_condition={!r})".format( self.__class__.__name__, + self.name, self.text, self.collation, self.negate_condition, ) - def match_indexes(self, indexes): - return any(self.match(k) for k in indexes[None]) + def match_indexes(self, indexes: SubIndexDict): + return any( + self.match(self.type_fn(self.type_fn.from_ical(k))) + for k in indexes[None]) - def match(self, prop): + def match(self, prop: Union[vText, vCategory, str]): if isinstance(prop, vText): - prop = prop.encode() - matches = self.collation(self.text, prop) + matches = self.collation(self.text, str(prop), 'equals') + elif isinstance(prop, str): + matches = self.collation(self.text, prop, 'equals') + elif isinstance(prop, vCategory): + matches = any([self.match(cat) for cat in prop.cats]) + else: + logging.warning( + "potentially unsupported value in text match search: " + + repr(prop)) + return False if self.negate_condition: return not matches else: return matches -class ComponentFilter(object): - def __init__(self, name, children=None, is_not_defined=False, time_range=None): +class ComponentFilter: + + time_range: Optional[ComponentTimeRangeMatcher] + + def __init__( + self, name: str, children=None, is_not_defined: bool = False, + time_range=None) -> None: self.name = name self.children = children self.is_not_defined = is_not_defined self.time_range = time_range self.children = children or [] - def __repr__(self): - return "%s(%r, children=%r, is_not_defined=%r, time_range=%r)" % ( - self.__class__.__name__, - self.name, - self.children, - self.is_not_defined, - self.time_range, - ) - - def filter_subcomponent(self, name, is_not_defined=False, time_range=None): + def __repr__(self) -> str: + return ("{}({!r}, children={!r}, is_not_defined={!r}, time_range={!r})" + .format( + self.__class__.__name__, + self.name, + self.children, + self.is_not_defined, + self.time_range)) + + def filter_subcomponent( + self, name: str, is_not_defined: bool = False, + time_range: Optional[ComponentTimeRangeMatcher] = None): ret = ComponentFilter( name=name, is_not_defined=is_not_defined, time_range=time_range ) self.children.append(ret) return ret - def filter_property(self, name, is_not_defined=False, time_range=None): + def filter_property(self, name: str, is_not_defined: bool = False, + time_range: Optional[PropertyTimeRangeMatcher] = None): ret = PropertyFilter( name=name, is_not_defined=is_not_defined, time_range=time_range ) self.children.append(ret) return ret - def filter_time_range(self, start, end): + def filter_time_range(self, start: datetime, end: datetime): self.time_range = ComponentTimeRangeMatcher(start, end, comp=self.name) return self.time_range - def match(self, comp, tzify): + def match(self, comp: Component, tzify: TzifyFunction): # From https://tools.ietf.org/html/rfc4791, 9.7.1: # A CALDAV:comp-filter is said to match if: @@ -538,7 +579,8 @@ # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range # XML element and at least one recurrence instance in the targeted # calendar component is scheduled to overlap the specified time range - if self.time_range is not None and not self.time_range.match(comp, tzify): + if (self.time_range is not None + and not self.time_range.match(comp, tzify)): return False # ... and all specified CALDAV:prop-filter and CALDAV:comp-filter child @@ -557,10 +599,11 @@ def _implicitly_defined(self): return any( - not getattr(child, "is_not_defined", False) for child in self.children + not getattr(child, "is_not_defined", False) + for child in self.children ) - def match_indexes(self, indexes, tzify): + def match_indexes(self, indexes: IndexDict, tzify: TzifyFunction): myindex = "C=" + self.name if self.is_not_defined: return not bool(indexes[myindex]) @@ -583,44 +626,51 @@ def index_keys(self): mine = "C=" + self.name - for child in self.children + ([self.time_range] if self.time_range else []): + for child in ( + self.children + + ([self.time_range] if self.time_range else [])): for tl in child.index_keys(): yield [(mine + "/" + child_index) for child_index in tl] if not self._implicitly_defined(): yield [mine] -class PropertyFilter(object): - def __init__(self, name, children=None, is_not_defined=False, time_range=None): +class PropertyFilter: + + def __init__(self, name: str, children=None, is_not_defined: bool = False, + time_range: Optional[PropertyTimeRangeMatcher] = None) -> None: self.name = name self.is_not_defined = is_not_defined self.children = children or [] self.time_range = time_range - def __repr__(self): - return "%s(%r, children=%r, is_not_defined=%r, time_range=%r)" % ( - self.__class__.__name__, - self.name, - self.children, - self.is_not_defined, - self.time_range, - ) - - def filter_parameter(self, name, is_not_defined=False): + def __repr__(self) -> str: + return ("{}({!r}, children={!r}, is_not_defined={!r}, time_range={!r})" + .format( + self.__class__.__name__, self.name, self.children, + self.is_not_defined, self.time_range)) + + def filter_parameter( + self, name: str, + is_not_defined: bool = False) -> "ParameterFilter": ret = ParameterFilter(name=name, is_not_defined=is_not_defined) self.children.append(ret) return ret - def filter_time_range(self, start, end): + def filter_time_range( + self, start: datetime, end: datetime) -> PropertyTimeRangeMatcher: self.time_range = PropertyTimeRangeMatcher(start, end) return self.time_range - def filter_text_match(self, text, collation=None, negate_condition=False): - ret = TextMatcher(text, collation=collation, negate_condition=negate_condition) + def filter_text_match( + self, text: str, collation: Optional[str] = None, + negate_condition: bool = False) -> TextMatcher: + ret = TextMatcher(self.name, text, collation=collation, + negate_condition=negate_condition) self.children.append(ret) return ret - def match(self, comp, tzify): + def match(self, comp: Component, tzify: TzifyFunction) -> bool: # From https://tools.ietf.org/html/rfc4791, 9.7.2: # A CALDAV:comp-filter is said to match if: @@ -645,11 +695,13 @@ return True - def match_indexes(self, indexes, tzify): + def match_indexes( + self, indexes: SubIndexDict, + tzify: TzifyFunction) -> bool: myindex = "P=" + self.name if self.is_not_defined: return not bool(indexes[myindex]) - subindexes = create_subindexes(indexes, myindex) + subindexes: SubIndexDict = create_subindexes(indexes, myindex) if not self.children and not self.time_range: return bool(indexes[myindex]) @@ -674,23 +726,30 @@ yield [mine] -class ParameterFilter(object): - def __init__(self, name, children=None, is_not_defined=False): +class ParameterFilter: + + children: list[TextMatcher] + + def __init__(self, name: str, children: Optional[list[TextMatcher]] = None, + is_not_defined: bool = False) -> None: self.name = name self.is_not_defined = is_not_defined self.children = children or [] - def filter_text_match(self, text, collation=None, negate_condition=False): - ret = TextMatcher(text, collation=collation, negate_condition=negate_condition) + def filter_text_match(self, text: str, collation: Optional[str] = None, + negate_condition: bool = False) -> TextMatcher: + ret = TextMatcher( + self.name, text, collation=collation, + negate_condition=negate_condition) self.children.append(ret) return ret - def match(self, prop): + def match(self, prop: PropTypes) -> bool: if self.is_not_defined: return self.name not in prop.params try: - value = prop.params[self.name].encode() + value = prop.params[self.name] except KeyError: return False @@ -699,21 +758,21 @@ return False return True - def index_keys(self): + def index_keys(self) -> Iterable[list[str]]: yield ["A=" + self.name] - def match_indexes(self, indexes): + def match_indexes(self, indexes: IndexDict) -> bool: myindex = "A=" + self.name if self.is_not_defined: return not bool(indexes[myindex]) - try: - value = indexes[myindex][0] - except IndexError: + subindexes = create_subindexes(indexes, myindex) + + if not subindexes: return False for child in self.children: - if not child.match(value): + if not child.match_indexes(subindexes): return False return True @@ -723,9 +782,9 @@ content_type = "text/calendar" - def __init__(self, default_timezone): + def __init__(self, default_timezone: Union[str, timezone]) -> None: self.tzify = lambda dt: as_tz_aware_ts(dt, default_timezone) - self.children = [] + self.children: list[ComponentFilter] = [] def filter_subcomponent(self, name, is_not_defined=False, time_range=None): ret = ComponentFilter( @@ -734,7 +793,10 @@ self.children.append(ret) return ret - def check(self, name, file): + def check(self, name: str, file: File) -> bool: + if not isinstance(file, ICalendarFile): + return False + c = file.calendar if c is None: return False @@ -753,7 +815,7 @@ return False return True - def check_from_indexes(self, name, indexes): + def check_from_indexes(self, name: str, indexes: IndexDict) -> bool: for child_filter in self.children: try: if not child_filter.match_indexes(indexes, self.tzify): @@ -768,14 +830,14 @@ return False return True - def index_keys(self): + def index_keys(self) -> list[str]: subindexes = [] for child in self.children: subindexes.extend(child.index_keys()) return subindexes - def __repr__(self): - return "%s(%r)" % (self.__class__.__name__, self.children) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.children!r})" class ICalendarFile(File): @@ -783,11 +845,11 @@ content_type = "text/calendar" - def __init__(self, content, content_type): - super(ICalendarFile, self).__init__(content, content_type) + def __init__(self, content, content_type) -> None: + super().__init__(content, content_type) self._calendar = None - def validate(self): + def validate(self) -> None: """Verify that file contents are valid.""" cal = self.calendar # TODO(jelmer): return the list of errors to the caller @@ -810,8 +872,9 @@ if self._calendar is None: try: self._calendar = Calendar.from_ical(b"".join(self.content)) - except ValueError as e: - raise InvalidFileContents(self.content_type, self.content, str(e)) + except ValueError as exc: + raise InvalidFileContents( + self.content_type, self.content, str(exc)) from exc return self._calendar def describe_delta(self, name, previous): @@ -824,7 +887,7 @@ except NotImplementedError: lines = [] if not lines: - lines = super(ICalendarFile, self).describe_delta(name, previous) + lines = super().describe_delta(name, previous) return lines def describe(self, name): @@ -838,13 +901,14 @@ return component["SUMMARY"] except KeyError: pass - return super(ICalendarFile, self).describe(name) + return super().describe(name) def get_uid(self): """Extract the UID from a VCalendar file. - :param cal: Calendar, possibly serialized. - :return: UID + Args: + cal: Calendar, possibly serialized. + Returns: UID """ for component in self.calendar.subcomponents: try: @@ -853,15 +917,17 @@ pass raise KeyError - def _get_index(self, key): + def _get_index(self, key: IndexKey) -> IndexValueIterator: todo = [(self.calendar, key.split("/"))] rest = [] + c: Component while todo: (c, segments) = todo.pop(0) if segments and segments[0].startswith("C="): if c.name == segments[0][2:]: if len(segments) > 1 and segments[1].startswith("C="): - todo.extend((comp, segments[1:]) for comp in c.subcomponents) + todo.extend( + (comp, segments[1:]) for comp in c.subcomponents) else: rest.append((c, segments[1:])) @@ -871,30 +937,31 @@ elif segments[0].startswith("P="): assert len(segments) == 1 try: - yield c[segments[0][2:]] + p = c[segments[0][2:]] except KeyError: pass + else: + if p is not None: + yield p.to_ical() else: - raise AssertionError("segments: %r" % segments) + raise AssertionError(f"segments: {segments!r}") -def as_tz_aware_ts(dt, default_timezone): +def as_tz_aware_ts(dt, default_timezone: Union[str, timezone]) -> datetime: if not getattr(dt, "time", None): - dt = datetime.datetime.combine(dt, datetime.time()) + dt = datetime.combine(dt, time()) if dt.tzinfo is None: dt = dt.replace(tzinfo=default_timezone) assert dt.tzinfo return dt -def rruleset_from_comp(comp): - if "RRULE" not in comp: - return None +def rruleset_from_comp(comp: Component) -> dateutil.rrule.rruleset: dtstart = comp["DTSTART"].dt rrulestr = comp["RRULE"].to_ical().decode("utf-8") rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=dtstart) rs = dateutil.rrule.rruleset() - rs.rrule(rrule) + rs.rrule(rrule) # type: ignore if "EXDATE" in comp: for exdate in comp["EXDATE"]: rs.exdate(exdate) @@ -908,7 +975,11 @@ return rs -def _expand_rrule_component(incomp, start, end, existing): +def _expand_rrule_component( + incomp: Component, start: datetime, end: datetime, + existing: dict[str, Component]) -> Iterable[Component]: + if "RRULE" not in incomp: + return rs = rruleset_from_comp(incomp) for field in ["RRULE", "EXRULE", "UNTIL", "RDATE", "EXDATE"]: if field in incomp: @@ -926,10 +997,12 @@ yield outcomp -def expand_calendar_rrule(incal, start, end): +def expand_calendar_rrule( + incal: Calendar, start: datetime, end: datetime) -> Calendar: outcal = Calendar() if incal.name != "VCALENDAR": - raise AssertionError("called on file with root component %s" % incal.name) + raise AssertionError( + f"called on file with root component {incal.name}") for field in incal: outcal[field] = incal[field] known = {} diff -Nru xandikos-0.2.8/xandikos/infit.py xandikos-0.2.10/xandikos/infit.py --- xandikos-0.2.8/xandikos/infit.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/infit.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,9 +17,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""Inf-It properties. -""" -from xandikos import webdav, carddav +"""Inf-It properties.""" +from xandikos import carddav, webdav class SettingsProperty(webdav.Property): diff -Nru xandikos-0.2.8/xandikos/__init__.py xandikos-0.2.10/xandikos/__init__.py --- xandikos-0.2.8/xandikos/__init__.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/__init__.py 2023-09-06 09:15:03.000000000 +0000 @@ -1,4 +1,3 @@ -# encoding: utf-8 # # Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. @@ -21,7 +20,7 @@ """CalDAV/CardDAV server.""" -__version__ = (0, 2, 8) +__version__ = (0, 2, 10) version_string = ".".join(map(str, __version__)) import defusedxml.ElementTree # noqa: This does some monkey-patching on-load diff -Nru xandikos-0.2.8/xandikos/__main__.py xandikos-0.2.10/xandikos/__main__.py --- xandikos-0.2.8/xandikos/__main__.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/__main__.py 2023-09-06 09:15:03.000000000 +0000 @@ -19,16 +19,17 @@ """Xandikos command-line handling.""" -from typing import List +import asyncio -def main(argv: List[str]): +def main(argv=None): + # For now, just invoke xandikos.web from .web import main - return main(argv) + return asyncio.run(main(argv)) if __name__ == "__main__": import sys - main(sys.argv) + sys.exit(main(sys.argv[1:])) diff -Nru xandikos-0.2.8/xandikos/quota.py xandikos-0.2.10/xandikos/quota.py --- xandikos-0.2.8/xandikos/quota.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/quota.py 2023-09-06 09:15:03.000000000 +0000 @@ -23,12 +23,11 @@ """ from xandikos import webdav - FEATURE: str = "quota" class QuotaAvailableBytesProperty(webdav.Property): - """quota-available-bytes""" + """quota-available-bytes.""" name = "{DAV:}quota-available-bytes" resource_type = None @@ -40,7 +39,7 @@ class QuotaUsedBytesProperty(webdav.Property): - """quota-used-bytes""" + """quota-used-bytes.""" name = "{DAV:}quota-used-bytes" resource_type = None diff -Nru xandikos-0.2.8/xandikos/scheduling.py xandikos-0.2.10/xandikos/scheduling.py --- xandikos-0.2.8/xandikos/scheduling.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/scheduling.py 2023-09-06 09:15:03.000000000 +0000 @@ -24,7 +24,6 @@ from xandikos import caldav, webdav - SCHEDULE_INBOX_RESOURCE_TYPE = "{%s}schedule-inbox" % caldav.NAMESPACE SCHEDULE_OUTBOX_RESOURCE_TYPE = "{%s}schedule-outbox" % caldav.NAMESPACE @@ -48,7 +47,8 @@ class ScheduleInbox(webdav.Collection): - resource_types = webdav.Collection.resource_types + [SCHEDULE_INBOX_RESOURCE_TYPE] + resource_types = webdav.Collection.resource_types + [ + SCHEDULE_INBOX_RESOURCE_TYPE] def get_calendar_user_type(self): # Default, per section 2.4.2 @@ -73,14 +73,14 @@ def get_supported_calendar_components(self): """Return set of supported calendar components in this calendar. - :return: iterable over component names + Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) def get_supported_calendar_data_types(self): """Return supported calendar data types. - :return: iterable over (content_type, version) tuples + Returns: iterable over (content_type, version) tuples """ raise NotImplementedError(self.get_supported_calendar_data_types) @@ -114,19 +114,20 @@ class ScheduleOutbox(webdav.Collection): - resource_types = webdav.Collection.resource_types + [SCHEDULE_OUTBOX_RESOURCE_TYPE] + resource_types = webdav.Collection.resource_types + [ + SCHEDULE_OUTBOX_RESOURCE_TYPE] def get_supported_calendar_components(self): """Return set of supported calendar components in this calendar. - :return: iterable over component names + Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) def get_supported_calendar_data_types(self): """Return supported calendar data types. - :return: iterable over (content_type, version) tuples + Returns: iterable over (content_type, version) tuples """ raise NotImplementedError(self.get_supported_calendar_data_types) @@ -176,7 +177,7 @@ class CalendarUserAddressSetProperty(webdav.Property): - """calendar-user-address-set property + """calendar-user-address-set property. See https://tools.ietf.org/html/rfc6638, section 2.4.1 """ @@ -191,7 +192,7 @@ class ScheduleTagProperty(webdav.Property): - """schedule-tag property + """schedule-tag property. See https://tools.ietf.org/html/rfc6638, section 3.2.10 """ @@ -207,7 +208,7 @@ class CalendarUserTypeProperty(webdav.Property): - """calendar-user-type property + """calendar-user-type property. See https://tools.ietf.org/html/rfc6638, section 2.4.2 """ diff -Nru xandikos-0.2.8/xandikos/server_info.py xandikos-0.2.10/xandikos/server_info.py --- xandikos-0.2.8/xandikos/server_info.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/server_info.py 2023-09-06 09:15:03.000000000 +0000 @@ -24,8 +24,9 @@ import hashlib -from xandikos import version_string -from xandikos import webdav +from typing import List + +from xandikos import version_string, webdav ET = webdav.ET @@ -34,13 +35,13 @@ SERVER_INFO_MIME_TYPE = "application/server-info+xml" -class ServerInfo(object): +class ServerInfo: """Server info.""" - def __init__(self): + def __init__(self) -> None: self._token = None - self._features = [] - self._applications = [] + self._features: List[str] = [] + self._applications: List[str] = [] def add_feature(self, feature): self._features.append(feature) @@ -49,7 +50,7 @@ @property def token(self): if self._token is None: - h = hashlib.sha1sum() + h = hashlib.sha1() h.update(version_string.encode("utf-8")) for z in self._features + self._applications: h.update(z.encode("utf-8")) diff -Nru xandikos-0.2.8/xandikos/store/config.py xandikos-0.2.10/xandikos/store/config.py --- xandikos-0.2.8/xandikos/store/config.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/store/config.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,15 +17,14 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""Collection configuration file. -""" +"""Collection configuration file.""" import configparser FILENAME = ".xandikos" -class CollectionMetadata(object): +class CollectionMetadata: """Metadata for a configuration.""" def get_color(self) -> str: @@ -63,7 +62,7 @@ class FileBasedCollectionMetadata(CollectionMetadata): """Metadata for a configuration.""" - def __init__(self, cp=None, save=None): + def __init__(self, cp=None, save=None) -> None: if cp is None: cp = configparser.ConfigParser() self._configparser = cp diff -Nru xandikos-0.2.8/xandikos/store/git.py xandikos-0.2.10/xandikos/store/git.py --- xandikos-0.2.8/xandikos/store/git.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/store/git.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,51 +17,32 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""Git store. -""" +"""Git store.""" import configparser import errno -from io import BytesIO, StringIO import logging import os import shutil import stat import uuid +from io import BytesIO, StringIO +from typing import Optional, Iterable -from . import ( - DEFAULT_MIME_TYPE, - MIMETYPES, - Store, - DuplicateUidError, - InvalidETag, - InvalidFileContents, - NoSuchItem, - NotStoreError, - OutOfSpaceError, - LockedError, - VALID_STORE_TYPES, - open_by_content_type, - open_by_extension, -) -from .config import ( - FILENAME as CONFIG_FILENAME, - CollectionMetadata, - FileBasedCollectionMetadata, -) -from .index import MemoryIndex - - -from dulwich.file import GitFile, FileLocked -from dulwich.index import ( - Index, - IndexEntry, - index_entry_from_stat, - write_index_dict, -) +import dulwich.repo +from dulwich.file import FileLocked, GitFile +from dulwich.index import (Index, index_entry_from_stat, + write_index_dict) from dulwich.objects import Blob, Tree from dulwich.pack import SHA1Writer -import dulwich.repo + +from . import (DEFAULT_MIME_TYPE, MIMETYPES, VALID_STORE_TYPES, + DuplicateUidError, InvalidCTag, InvalidETag, + InvalidFileContents, LockedError, NoSuchItem, NotStoreError, + OutOfSpaceError, Store, open_by_content_type, open_by_extension) +from .config import FILENAME as CONFIG_FILENAME +from .config import CollectionMetadata, FileBasedCollectionMetadata +from .index import MemoryIndex DEFAULT_ENCODING = "utf-8" @@ -70,7 +51,7 @@ class RepoCollectionMetadata(CollectionMetadata): - def __init__(self, repo): + def __init__(self, repo) -> None: self._repo = repo @classmethod @@ -156,7 +137,8 @@ def set_comment(self, comment): config = self._repo.get_config() if comment is not None: - config.set(b"xandikos", b"comment", comment.encode(DEFAULT_ENCODING)) + config.set( + b"xandikos", b"comment", comment.encode(DEFAULT_ENCODING)) else: # TODO(jelmer): Add and use config.remove() config.set(b"xandikos", b"comment", b"") @@ -172,7 +154,8 @@ store_type = config.get(b"xandikos", b"type") store_type = store_type.decode(DEFAULT_ENCODING) if store_type not in VALID_STORE_TYPES: - logging.warning("Invalid store type %s set for %r.", store_type, self._repo) + logging.warning( + "Invalid store type %s set for %r.", store_type, self._repo) return store_type def get_order(self): @@ -190,8 +173,8 @@ self._write_config(config) -class locked_index(object): - def __init__(self, path): +class locked_index: + def __init__(self, path) -> None: self._path = path def __enter__(self): @@ -215,15 +198,25 @@ class GitStore(Store): """A Store backed by a Git Repository.""" - def __init__(self, repo, ref=b"refs/heads/master", check_for_duplicate_uids=True): - super(GitStore, self).__init__(MemoryIndex()) + def __init__(self, repo, *, ref: bytes = b"refs/heads/master", + check_for_duplicate_uids=True, + **kwargs) -> None: + super().__init__(MemoryIndex(), **kwargs) self.ref = ref self.repo = repo # Maps uids to (sha, fname) - self._uid_to_fname = {} + self._uid_to_fname: dict[str, tuple[bytes, str]] = {} self._check_for_duplicate_uids = check_for_duplicate_uids # Set of blob ids that have already been scanned - self._fname_to_uid = {} + self._fname_to_uid: dict[str, tuple[str, str]] = {} + + def _get_etag(self, name: str) -> str: + raise NotImplementedError(self._get_etag) + + def _import_one( + self, name: str, data: Iterable[bytes], message: str, + author: Optional[str] = None): + raise NotImplementedError(self._import_one) @property def config(self): @@ -248,8 +241,8 @@ return FileBasedCollectionMetadata(cp, save=save_config) - def __repr__(self): - return "%s(%r, ref=%r)" % (type(self).__name__, self.repo, self.ref) + def __repr__(self) -> str: + return f"{type(self).__name__}({self.repo!r}, ref={self.ref!r})" @property def path(self): @@ -276,30 +269,32 @@ def import_one( self, - name, - content_type, - data, - message=None, - author=None, - replace_etag=None, - ): + name: str, + content_type: str, + data: Iterable[bytes], + message: Optional[str] = None, + author: Optional[str] = None, + replace_etag: Optional[str] = None, + ) -> tuple[str, str]: """Import a single object. - :param name: name of the object - :param content_type: Content type - :param data: serialized object as list of bytes - :param message: Commit message - :param author: Optional author - :param replace_etag: optional etag of object to replace - :raise InvalidETag: when the name already exists but with different - etag - :raise DuplicateUidError: when the uid already exists - :return: etag + Args: + name: name of the object + content_type: Content type + data: serialized object as list of bytes + message: Commit message + author: Optional author + replace_etag: optional etag of object to replace + Raises: + InvalidETag: when the name already exists but with different etag + DuplicateUidError: when the uid already exists + Returns: etag """ if content_type is None: fi = open_by_extension(data, name, self.extra_file_handlers) else: - fi = open_by_content_type(data, content_type, self.extra_file_handlers) + fi = open_by_content_type( + data, content_type, self.extra_file_handlers) if name is None: name = str(uuid.uuid4()) extension = MIMETYPES.guess_extension(content_type) @@ -323,9 +318,10 @@ def _get_raw(self, name, etag=None): """Get the raw contents of an object. - :param name: Name of the item - :param etag: Optional etag - :return: raw contents as chunks + Args: + name: Name of the item + etag: Optional etag + Returns: raw contents as chunks """ if etag is None: etag = self._get_etag(name) @@ -338,10 +334,12 @@ etag = sha.decode("ascii") if name in removed: removed.remove(name) - if name in self._fname_to_uid and self._fname_to_uid[name][0] == etag: + if (name in self._fname_to_uid + and self._fname_to_uid[name][0] == etag): continue blob = self.repo.object_store[sha] - fi = open_by_extension(blob.chunked, name, self.extra_file_handlers) + fi = open_by_extension( + blob.chunked, name, self.extra_file_handlers) try: uid = fi.get_uid() except KeyError: @@ -368,8 +366,9 @@ def iter_with_etag(self, ctag=None): """Iterate over all items in the store with etag. - :param ctag: Ctag to iterate for - :yield: (name, content_type, etag) tuples + Args: + ctag: Ctag to iterate for + Returns: iterator over (name, content_type, etag) tuples """ for (name, mode, sha) in self._iterblobs(ctag): (mime_type, _) = MIMETYPES.guess_type(name) @@ -381,38 +380,40 @@ def create(cls, path): """Create a new store backed by a Git repository on disk. - :return: A `GitStore` + Returns: A `GitStore` """ raise NotImplementedError(cls.create) @classmethod - def open_from_path(cls, path): + def open_from_path(cls, path, **kwargs): """Open a GitStore from a path. - :param path: Path - :return: A `GitStore` + Args: + path: Path + Returns: A `GitStore` """ try: - return cls.open(dulwich.repo.Repo(path)) + return cls.open(dulwich.repo.Repo(path), **kwargs) except dulwich.repo.NotGitRepository: raise NotStoreError(path) @classmethod - def open(cls, repo): + def open(cls, repo, **kwargs): """Open a GitStore given a Repo object. - :param repo: A Dulwich `Repo` - :return: A `GitStore` + Args: + repo: A Dulwich `Repo` + Returns: A `GitStore` """ if repo.has_index(): - return TreeGitStore(repo) + return TreeGitStore(repo, **kwargs) else: - return BareGitStore(repo) + return BareGitStore(repo, **kwargs) def get_description(self): """Get extended description. - :return: repository description as string + Returns: repository description as string """ try: return self.config.get_description() @@ -422,21 +423,23 @@ def set_description(self, description): """Set extended description. - :param description: repository description as string + Args: + description: repository description as string """ self.config.set_description(description) def set_comment(self, comment): """Set comment. - :param comment: Comment + Args: + comment: Comment """ self.config.set_comment(comment) def get_comment(self): """Get comment. - :return: Comment + Returns: Comment """ try: return self.config.get_comment() @@ -446,7 +449,7 @@ def get_color(self): """Get color. - :return: A Color code, or None + Returns: A Color code, or None """ try: return self.config.get_color() @@ -471,7 +474,7 @@ def get_displayname(self): """Get display name. - :return: The display name, or None if not set + Returns: The display name, or None if not set """ try: return self.config.get_displayname() @@ -481,14 +484,16 @@ def set_displayname(self, displayname): """Set the display name. - :param displayname: New display name + Args: + displayname: New display name """ self.config.set_displayname(displayname) def set_type(self, store_type): """Set store type. - :param store_type: New store type (one of VALID_STORE_TYPES) + Args: + store_type: New store type (one of VALID_STORE_TYPES) """ self.config.set_type(store_type) @@ -500,14 +505,15 @@ try: return self.config.get_type() except KeyError: - return super(GitStore, self).get_type() + return super().get_type() def iter_changes(self, old_ctag, new_ctag): """Get changes between two versions of this store. - :param old_ctag: Old ctag (None for empty Store) - :param new_ctag: New ctag - :return: Iterator over (name, content_type, old_etag, new_etag) + Args: + old_ctag: Old ctag (None for empty Store) + new_ctag: New ctag + Returns: Iterator over (name, content_type, old_etag, new_etag) """ if old_ctag is None: t = Tree() @@ -517,7 +523,8 @@ name: (content_type, etag) for (name, content_type, etag) in self.iter_with_etag(old_ctag) } - for (name, new_content_type, new_etag) in self.iter_with_etag(new_ctag): + for (name, new_content_type, new_etag) in self.iter_with_etag( + new_ctag): try: (old_content_type, old_etag) = previous[name] except KeyError: @@ -562,7 +569,10 @@ if ctag is None: tree = self._get_current_tree() else: - tree = self.repo.object_store[ctag.encode("ascii")] + try: + tree = self.repo.object_store[ctag.encode("ascii")] + except KeyError as exc: + raise InvalidCTag(ctag) from exc for (name, mode, sha) in tree.iteritems(): name = name.decode(DEFAULT_ENCODING) if name == CONFIG_FILENAME: @@ -570,10 +580,10 @@ yield (name, mode, sha) @classmethod - def create_memory(cls): + def create_memory(cls) -> "GitStore": """Create a new store backed by a memory repository. - :return: A `GitStore` + Returns: A `GitStore` """ return cls(dulwich.repo.MemoryRepo()) @@ -582,14 +592,16 @@ message=message, tree=tree_id, ref=self.ref, author=author ) - def _import_one(self, name, data, message, author=None): + def _import_one(self, name: str, data: Iterable[bytes], message: str, + author: Optional[str] = None) -> bytes: """Import a single object. - :param name: Optional name of the object - :param data: serialized object as bytes - :param message: optional commit message - :param author: optional author - :return: etag + Args: + name: Optional name of the object + data: serialized object as bytes + message: optional commit message + author: optional author + Returns: etag """ b = Blob() b.chunked = data @@ -599,25 +611,28 @@ tree[name_enc] = (0o644 | stat.S_IFREG, b.id) self.repo.object_store.add_objects([(tree, ""), (b, name_enc)]) if tree.id != old_tree_id: - self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING), author=author) + self._commit_tree( + tree.id, message.encode(DEFAULT_ENCODING), author=author) return b.id def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. - :param name: Filename to delete - :param message; Commit message - :param author: Optional author to store - :param etag: Optional mandatory etag of object to remove - :raise NoSuchItem: when the item doesn't exist - :raise InvalidETag: If the specified ETag doesn't match the curren + Args: + name: Filename to delete + message; Commit message + author: Optional author to store + etag: Optional mandatory etag of object to remove + Raises: + NoSuchItem: when the item doesn't exist + InvalidETag: If the specified ETag doesn't match the curren """ tree = self._get_current_tree() name_enc = name.encode(DEFAULT_ENCODING) try: current_sha = tree[name_enc][1] - except KeyError: - raise NoSuchItem(name) + except KeyError as exc: + raise NoSuchItem(name) from exc if etag is not None and current_sha != etag.encode("ascii"): raise InvalidETag(name, etag, current_sha.decode("ascii")) del tree[name_enc] @@ -629,13 +644,14 @@ self.extra_file_handlers, ) message = "Delete " + fi.describe(name) - self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING), author=author) + self._commit_tree( + tree.id, message.encode(DEFAULT_ENCODING), author=author) @classmethod def create(cls, path): """Create a new store backed by a Git repository on disk. - :return: A `GitStore` + Returns: A `GitStore` """ os.mkdir(path) return cls(dulwich.repo.Repo.init_bare(path)) @@ -643,7 +659,7 @@ def subdirectories(self): """Returns subdirectories to probe for other stores. - :return: List of names + Returns: List of names """ # Or perhaps just return all subdirectories but filter out # Git-owned ones? @@ -657,7 +673,7 @@ def create(cls, path, bare=True): """Create a new store backed by a Git repository on disk. - :return: A `GitStore` + Returns: A `GitStore` """ os.mkdir(path) return cls(dulwich.repo.Repo.init(path)) @@ -671,14 +687,15 @@ tree = index.commit(self.repo.object_store) return self.repo.do_commit(message=message, author=author, tree=tree) - def _import_one(self, name, data, message, author=None): + def _import_one(self, name: str, data: Iterable[bytes], message: str, author: Optional[str] = None) -> bytes: """Import a single object. - :param name: name of the object - :param data: serialized object as list of bytes - :param message: Commit message - :param author: Optional author - :return: etag + Args: + name: name of the object + data: serialized object as list of bytes + message: Commit message + author: Optional author + Returns: etag """ try: with locked_index(self.repo.index_path()) as index: @@ -688,38 +705,44 @@ st = os.lstat(p) blob = Blob.from_string(b"".join(data)) encoded_name = name.encode(DEFAULT_ENCODING) - if encoded_name not in index or blob.id != index[encoded_name].sha: + if (encoded_name not in index + or blob.id != index[encoded_name].sha): self.repo.object_store.add_object(blob) - index[encoded_name] = IndexEntry( - *index_entry_from_stat(st, blob.id, 0) - ) + index[encoded_name] = index_entry_from_stat(st, blob.id) self._commit_tree( index, message.encode(DEFAULT_ENCODING), author=author ) return blob.id - except OSError as e: - if e.errno == errno.ENOSPC: - raise OutOfSpaceError() + except FileLocked as exc: + raise LockedError(name) from exc + except OSError as exc: + if exc.errno == errno.ENOSPC: + raise OutOfSpaceError() from exc raise def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. - :param name: Filename to delete - :param message: Commit message - :param author: Optional author - :param etag: Optional mandatory etag of object to remove - :raise NoSuchItem: when the item doesn't exist - :raise InvalidETag: If the specified ETag doesn't match the curren + Args: + name: Filename to delete + message: Commit message + author: Optional author + etag: Optional mandatory etag of object to remove + Raise: + NoSuchItem: when the item doesn't exist + InvalidETag: If the specified ETag doesn't match the curren """ p = os.path.join(self.repo.path, name) try: with open(p, "rb") as f: current_blob = Blob.from_string(f.read()) - except IOError: - raise NoSuchItem(name) + except FileNotFoundError as exc: + raise NoSuchItem(name) from exc + except IsADirectoryError as exc: + raise NoSuchItem(name) from exc if message is None: - fi = open_by_extension(current_blob.chunked, name, self.extra_file_handlers) + fi = open_by_extension( + current_blob.chunked, name, self.extra_file_handlers) message = "Delete " + fi.describe(name) if etag is not None: with open(p, "rb") as f: @@ -747,7 +770,10 @@ :yield: (name, etag) tuples """ if ctag is not None: - tree = self.repo.object_store[ctag.encode("ascii")] + try: + tree = self.repo.object_store[ctag.encode("ascii")] + except KeyError as exc: + raise InvalidCTag(ctag) from exc for (name, mode, sha) in tree.iteritems(): name = name.decode(DEFAULT_ENCODING) if name == CONFIG_FILENAME: @@ -764,7 +790,7 @@ def subdirectories(self): """Returns subdirectories to probe for other stores. - :return: List of names + Returns: List of names """ ret = [] for name in os.listdir(self.path): diff -Nru xandikos-0.2.8/xandikos/store/index.py xandikos-0.2.10/xandikos/store/index.py --- xandikos-0.2.8/xandikos/store/index.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/store/index.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,36 +17,42 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""Indexing. -""" +"""Indexing.""" import collections import logging +from collections.abc import Iterable, Iterator +from typing import Optional, Union, Dict, Set +IndexKey = str +IndexValue = list[Union[bytes, bool]] +IndexValueIterator = Iterator[Union[bytes, bool]] +IndexDict = dict[IndexKey, IndexValue] -INDEXING_THRESHOLD = 5 +DEFAULT_INDEXING_THRESHOLD = 5 -class Index(object): + +class Index: """Index management.""" - def available_keys(self): + def available_keys(self) -> Iterable[IndexKey]: """Return list of available index keys.""" - raise NotImplementedError(self.available_indexes) + raise NotImplementedError(self.available_keys) - def get_values(self, name, etag, keys): + def get_values(self, name: str, etag: str, keys: list[IndexKey]): """Get the values for specified keys for a name.""" raise NotImplementedError(self.get_values) - def iter_etags(self): + def iter_etags(self) -> Iterator[str]: """Return all the etags covered by this index.""" raise NotImplementedError(self.iter_etags) class MemoryIndex(Index): - def __init__(self): - self._indexes = {} - self._in_index = set() + def __init__(self) -> None: + self._indexes: Dict[IndexKey, Dict[str, IndexValue]] = {} + self._in_index: Set[str] = set() def available_keys(self): return self._indexes.keys() @@ -81,16 +87,20 @@ self._indexes[key] = {} -class IndexManager(object): - def __init__(self, index, threshold=INDEXING_THRESHOLD): +class AutoIndexManager: + def __init__(self, index, threshold: Optional[int] = None) -> None: self.index = index - self.desired = collections.defaultdict(lambda: 0) + self.desired: dict[IndexKey, int] = collections.defaultdict(lambda: 0) + if threshold is None: + threshold = DEFAULT_INDEXING_THRESHOLD self.indexing_threshold = threshold - def find_present_keys(self, necessary_keys): + def find_present_keys( + self, necessary_keys: Iterable[IndexKey]) -> Optional[ + Iterable[IndexKey]]: available_keys = self.index.available_keys() needed_keys = [] - missing_keys = [] + missing_keys: list[IndexKey] = [] new_index_keys = set() for keys in necessary_keys: found = False diff -Nru xandikos-0.2.8/xandikos/store/__init__.py xandikos-0.2.10/xandikos/store/__init__.py --- xandikos-0.2.8/xandikos/store/__init__.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/store/__init__.py 2023-09-06 09:15:03.000000000 +0000 @@ -25,9 +25,10 @@ import logging import mimetypes -from typing import Optional, Iterable, Tuple, Iterator, Dict, Type +from collections.abc import Iterable, Iterator +from typing import Optional -from .index import IndexManager +from .index import AutoIndexManager, IndexDict, IndexKey, IndexValueIterator STORE_TYPE_ADDRESSBOOK = "addressbook" STORE_TYPE_CALENDAR = "calendar" @@ -52,16 +53,21 @@ DEFAULT_MIME_TYPE = "application/octet-stream" -PARANOID = False +class InvalidCTag(Exception): + """The request CTag can not be retrieved.""" -class File(object): + def __init__(self, ctag) -> None: + self.ctag = ctag + + +class File: """A file type handler.""" content: Iterable[bytes] content_type: str - def __init__(self, content: Iterable[bytes], content_type: str): + def __init__(self, content: Iterable[bytes], content_type: str) -> None: self.content = content self.content_type = content_type @@ -88,17 +94,20 @@ :raise NotImplementedError: If UIDs aren't supported for this format :raise KeyError: If there is no UID set on this file :raise InvalidFileContents: If the file is misformatted - :return: UID + Returns: UID """ raise NotImplementedError(self.get_uid) - def describe_delta(self, name: str, previous: Optional["File"]) -> Iterator[str]: + def describe_delta( + self, name: str, previous: Optional["File"]) -> Iterator[str]: """Describe the important difference between this and previous one. - :param name: File name - :param previous: Previous file to compare to. - :raise InvalidFileContents: If the file is misformatted - :return: List of strings describing change + Args: + name: File name + previous: Previous file to compare to. + Raises: + InvalidFileContents: If the file is misformatted + Returns: List of strings describing change """ assert name is not None item_description = self.describe(name) @@ -108,19 +117,22 @@ else: yield "Modified " + item_description - def _get_index(self, key): + def _get_index(self, key: IndexKey) -> IndexValueIterator: """Obtain an index for this file. - :param key: Index key - :yield: Index values + Args: + key: Index key + Returns: + iterator over index values """ raise NotImplementedError(self._get_index) - def get_indexes(self, keys): + def get_indexes(self, keys: Iterable[IndexKey]) -> IndexDict: """Obtain indexes for this file. - :param keys: Iterable of index keys - :return: Dictionary mapping key names to values + Args: + keys: Iterable of index keys + Returns: Dictionary mapping key names to values """ ret = {} for k in keys: @@ -128,7 +140,7 @@ return ret -class Filter(object): +class Filter: """A filter that can be used to query for certain resources. Filters are often resource-type specific. @@ -139,25 +151,27 @@ def check(self, name: str, resource: File) -> bool: """Check if this filter applies to a resource. - :param name: Name of the resource - :param resource: File object - :return: boolean + Args: + name: Name of the resource + resource: File object + Returns: boolean """ raise NotImplementedError(self.check) - def index_keys(self): + def index_keys(self) -> list[IndexKey]: """Returns a list of indexes that could be used to apply this filter. - :return: AND-list of OR-options + Returns: AND-list of OR-options """ raise NotImplementedError(self.index_keys) - def check_from_indexes(self, name: str, indexes) -> bool: + def check_from_indexes(self, name: str, indexes: IndexDict) -> bool: """Check from a set of indexes whether a resource matches. - :param name: Name of the resource - :param indexes: Dictionary mapping index names to values - :return: boolean + Args: + name: Name of the resource + indexes: Dictionary mapping index names to values + Returns: boolean """ raise NotImplementedError(self.check_from_indexes) @@ -167,9 +181,10 @@ ) -> File: """Open a file based on content type. - :param content: list of bytestrings with content - :param content_type: MIME type - :return: File instance + Args: + content: list of bytestrings with content + content_type: MIME type + Returns: File instance """ return extra_file_handlers.get(content_type.split(";")[0], File)( content, content_type @@ -179,13 +194,14 @@ def open_by_extension( content: Iterable[bytes], name: str, - extra_file_handlers: Dict[str, Type[File]], + extra_file_handlers: dict[str, type[File]], ) -> File: """Open a file based on the filename extension. - :param content: list of bytestrings with content - :param name: Name of file to open - :return: File instance + Args: + content: list of bytestrings with content + name: Name of file to open + Returns: File instance """ (mime_type, _) = MIMETYPES.guess_type(name) if mime_type is None: @@ -198,7 +214,7 @@ class DuplicateUidError(Exception): """UID already exists in store.""" - def __init__(self, uid: str, existing_name: str, new_name: str): + def __init__(self, uid: str, existing_name: str, new_name: str) -> None: self.uid = uid self.existing_name = existing_name self.new_name = new_name @@ -207,14 +223,14 @@ class NoSuchItem(Exception): """No such item.""" - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name = name class InvalidETag(Exception): """Unexpected value for etag.""" - def __init__(self, name: str, expected_etag: str, got_etag: str): + def __init__(self, name: str, expected_etag: str, got_etag: str) -> None: self.name = name self.expected_etag = expected_etag self.got_etag = got_etag @@ -223,14 +239,14 @@ class NotStoreError(Exception): """Not a store.""" - def __init__(self, path: str): + def __init__(self, path: str) -> None: self.path = path class InvalidFileContents(Exception): """Invalid file contents.""" - def __init__(self, content_type: str, data, error): + def __init__(self, content_type: str, data, error) -> None: self.content_type = content_type self.data = data self.error = error @@ -239,43 +255,51 @@ class OutOfSpaceError(Exception): """Out of disk space.""" - def __init__(self): + def __init__(self) -> None: pass class LockedError(Exception): """File or store being accessed is locked.""" - def __init__(self, path: str): + def __init__(self, path: str) -> None: self.path = path -class Store(object): +class Store: """A object store.""" - extra_file_handlers: Dict[str, Type[File]] + extra_file_handlers: dict[str, type[File]] - def __init__(self, index): + def __init__(self, index, *, double_check_indexes: bool = False, + index_threshold: Optional[int] = None) -> None: self.extra_file_handlers = {} self.index = index - self.index_manager = IndexManager(self.index) + self.index_manager = AutoIndexManager( + self.index, threshold=index_threshold) + self.double_check_indexes = double_check_indexes - def load_extra_file_handler(self, file_handler: Type[File]) -> None: + def load_extra_file_handler(self, file_handler: type[File]) -> None: self.extra_file_handlers[file_handler.content_type] = file_handler - def iter_with_etag(self, ctag: str = None) -> Iterator[Tuple[str, str, str]]: + def iter_with_etag( + self, ctag: Optional[str] = None) -> Iterator[ + tuple[str, str, str]]: """Iterate over all items in the store with etag. - :param ctag: Possible ctag to iterate for - :yield: (name, content_type, etag) tuples + Args: + ctag: Possible ctag to iterate for + Returns: iterator over (name, content_type, etag) tuples """ raise NotImplementedError(self.iter_with_etag) - def iter_with_filter(self, filter: Filter) -> Iterator[Tuple[str, File, str]]: + def iter_with_filter( + self, filter: Filter) -> Iterator[tuple[str, File, str]]: """Iterate over all items in the store that match a particular filter. - :param filter: Filter to apply - :yield: (name, file, etag) tuples + Args: + filter: Filter to apply + Returns: iterator over (name, file, etag) tuples """ if self.index_manager is not None: try: @@ -283,14 +307,15 @@ except NotImplementedError: pass else: - present_keys = self.index_manager.find_present_keys(necessary_keys) + present_keys = self.index_manager.find_present_keys( + necessary_keys) if present_keys is not None: return self._iter_with_filter_indexes(filter, present_keys) return self._iter_with_filter_naive(filter) def _iter_with_filter_naive( self, filter: Filter - ) -> Iterator[Tuple[str, File, str]]: + ) -> Iterator[tuple[str, File, str]]: for (name, content_type, etag) in self.iter_with_etag(): if not filter.content_type == content_type: continue @@ -303,7 +328,7 @@ def _iter_with_filter_indexes( self, filter: Filter, keys - ) -> Iterator[Tuple[str, File, str]]: + ) -> Iterator[tuple[str, File, str]]: for (name, content_type, etag) in self.iter_with_etag(): if not filter.content_type == content_type: continue @@ -326,17 +351,16 @@ if file_values is None: continue file = self.get_file(name, content_type, etag) - if PARANOID: + if self.double_check_indexes: if file_values != file.get_indexes(keys): raise AssertionError( - "%r != %r" % (file_values, file.get_indexes(keys)) - ) - if filter.check_from_indexes(name, file_values) != filter.check( - name, file - ): + f"{file_values!r} != {file.get_indexes(keys)!r}") + if (filter.check_from_indexes(name, file_values) + != filter.check(name, file)): raise AssertionError( - "index based filter not matching real file filter" - ) + f"index based filter {filter} " + f"(values: {file_values}) not matching " + "real file filter") if filter.check_from_indexes(name, file_values): file = self.get_file(name, content_type, etag) yield (name, file, etag) @@ -349,7 +373,7 @@ ) -> File: """Get the contents of an object. - :return: A File object + Returns: A File object """ if content_type is None: return open_by_extension( @@ -364,12 +388,14 @@ extra_file_handlers=self.extra_file_handlers, ) - def _get_raw(self, name: str, etag: Optional[str] = None) -> Iterable[bytes]: + def _get_raw( + self, name: str, etag: Optional[str] = None) -> Iterable[bytes]: """Get the raw contents of an object. - :param name: Filename - :param etag: Optional etag to return - :return: raw contents + Args: + name: Filename + etag: Optional etag to return + Returns: raw contents """ raise NotImplementedError(self._get_raw) @@ -380,21 +406,25 @@ def import_one( self, name: str, + content_type: str, data: Iterable[bytes], message: Optional[str] = None, author: Optional[str] = None, replace_etag: Optional[str] = None, - ) -> Tuple[str, str]: + ) -> tuple[str, str]: """Import a single object. - :param name: Name of the object - :param data: serialized object as list of bytes - :param message: Commit message - :param author: Optional author - :param replace_etag: Etag to replace - :raise NameExists: when the name already exists - :raise DuplicateUidError: when the uid already exists - :return: (name, etag) + Args: + name: Name of the object + content_type: Content type of the object + data: serialized object as list of bytes + message: Commit message + author: Optional author + replace_etag: Etag to replace + Raise: + NameExists: when the name already exists + DuplicateUidError: when the uid already exists + Returns: (name, etag) """ raise NotImplementedError(self.import_one) @@ -407,26 +437,29 @@ ) -> None: """Delete an item. - :param name: Filename to delete - :param message: Commit message - :param author: Optional author - :param etag: Optional mandatory etag of object to remove - :raise NoSuchItem: when the item doesn't exist - :raise InvalidETag: If the specified ETag doesn't match the current + Args: + name: Filename to delete + message: Commit message + author: Optional author + etag: Optional mandatory etag of object to remove + Raises: + NoSuchItem: when the item doesn't exist + InvalidETag: If the specified ETag doesn't match the current """ raise NotImplementedError(self.delete_one) def set_type(self, store_type: str) -> None: """Set store type. - :param store_type: New store type (one of VALID_STORE_TYPES) + Args: + store_type: New store type (one of VALID_STORE_TYPES) """ raise NotImplementedError(self.set_type) def get_type(self) -> str: """Get type of this store. - :return: one of VALID_STORE_TYPES + Returns: one of VALID_STORE_TYPES """ ret = STORE_TYPE_OTHER for (name, content_type, etag) in self.iter_with_etag(): @@ -439,7 +472,8 @@ def set_description(self, description: str) -> None: """Set the extended description of this store. - :param description: String with description + Args: + description: String with description """ raise NotImplementedError(self.set_description) @@ -465,26 +499,28 @@ def iter_changes( self, old_ctag: str, new_ctag: str - ) -> Iterator[Tuple[str, str, str, str]]: + ) -> Iterator[tuple[str, str, str, str]]: """Get changes between two versions of this store. - :param old_ctag: Old ctag (None for empty Store) - :param new_ctag: New ctag - :return: Iterator over (name, content_type, old_etag, new_etag) + Args: + old_ctag: Old ctag (None for empty Store) + new_ctag: New ctag + Returns: Iterator over (name, content_type, old_etag, new_etag) """ raise NotImplementedError(self.iter_changes) def get_comment(self) -> str: """Retrieve store comment. - :return: Comment + Returns: Comment """ raise NotImplementedError(self.get_comment) def set_comment(self, comment: str) -> None: """Set comment. - :param comment: New comment to set + Args: + comment: New comment to set """ raise NotImplementedError(self.set_comment) @@ -495,7 +531,7 @@ def subdirectories(self) -> Iterator[str]: """Returns subdirectories to probe for other stores. - :return: List of names + Returns: List of names """ raise NotImplementedError(self.subdirectories) @@ -511,8 +547,9 @@ def open_store(location: str) -> Store: """Open store from a location string. - :param location: Location string to open - :return: A `Store` + Args: + location: Location string to open + Returns: A `Store` """ # For now, just support opening git stores from .git import GitStore diff -Nru xandikos-0.2.8/xandikos/store/vdir.py xandikos-0.2.10/xandikos/store/vdir.py --- xandikos-0.2.8/xandikos/store/vdir.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/store/vdir.py 2023-09-06 09:15:03.000000000 +0000 @@ -23,30 +23,19 @@ """ import configparser -import errno import hashlib import logging import os import shutil +from typing import Dict import uuid -from . import ( - MIMETYPES, - Store, - DuplicateUidError, - InvalidETag, - InvalidFileContents, - NoSuchItem, - open_by_content_type, - open_by_extension, -) -from .config import ( - FileBasedCollectionMetadata, - FILENAME as CONFIG_FILENAME, -) +from . import (MIMETYPES, DuplicateUidError, InvalidETag, InvalidFileContents, + NoSuchItem, Store, open_by_content_type, open_by_extension) +from .config import FILENAME as CONFIG_FILENAME +from .config import FileBasedCollectionMetadata from .index import MemoryIndex - DEFAULT_ENCODING = "utf-8" @@ -56,14 +45,14 @@ class VdirStore(Store): """A Store backed by a Vdir directory.""" - def __init__(self, path, check_for_duplicate_uids=True): - super(VdirStore, self).__init__(MemoryIndex()) + def __init__(self, path, check_for_duplicate_uids=True) -> None: + super().__init__(MemoryIndex()) self.path = path self._check_for_duplicate_uids = check_for_duplicate_uids # Set of blob ids that have already been scanned - self._fname_to_uid = {} + self._fname_to_uid: Dict[str, str] = {} # Maps uids to (sha, fname) - self._uid_to_fname = {} + self._uid_to_fname: Dict[str, str] = {} cp = configparser.ConfigParser() cp.read([os.path.join(self.path, CONFIG_FILENAME)]) @@ -73,8 +62,8 @@ self.config = FileBasedCollectionMetadata(cp, save=save_config) - def __repr__(self): - return "%s(%r)" % (type(self).__name__, self.path) + def __repr__(self) -> str: + return f"{type(self).__name__}({self.path!r})" def _get_etag(self, name): path = os.path.join(self.path, name) @@ -83,34 +72,36 @@ with open(path, "rb") as f: for chunk in f: md5.update(chunk) - except IOError as e: - if e.errno == errno.ENOENT: - raise KeyError - raise + except FileNotFoundError as exc: + raise KeyError(name) from exc + except IsADirectoryError as exc: + raise KeyError(name) from exc return md5.hexdigest() def _get_raw(self, name, etag=None): """Get the raw contents of an object. - :param name: Name of the item - :param etag: Optional etag (ignored) - :return: raw contents as chunks + Args: + name: Name of the item + etag: Optional etag (ignored) + Returns: raw contents as chunks """ path = os.path.join(self.path, name) try: with open(path, "rb") as f: return [f.read()] - except IOError as e: - if e.errno == errno.ENOENT: - raise KeyError - raise + except FileNotFoundError as exc: + raise KeyError(name) from exc + except IsADirectoryError as exc: + raise KeyError(name) from exc def _scan_uids(self): removed = set(self._fname_to_uid.keys()) for (name, content_type, etag) in self.iter_with_etag(): if name in removed: removed.remove(name) - if name in self._fname_to_uid and self._fname_to_uid[name][0] == etag: + if (name in self._fname_to_uid + and self._fname_to_uid[name][0] == etag): continue fi = open_by_extension( self._get_raw(name, etag), name, self.extra_file_handlers @@ -165,21 +156,23 @@ ): """Import a single object. - :param name: name of the object - :param content_type: Content type - :param data: serialized object as list of bytes - :param message: Commit message - :param author: Optional author - :param replace_etag: optional etag of object to replace - :raise InvalidETag: when the name already exists but with different - etag - :raise DuplicateUidError: when the uid already exists - :return: etag + Args: + name: name of the object + content_type: Content type + data: serialized object as list of bytes + message: Commit message + author: Optional author + replace_etag: optional etag of object to replace + Raises: + InvalidETag: when the name already exists but with different etag + DuplicateUidError: when the uid already exists + Returns: etag """ if content_type is None: fi = open_by_extension(data, name, self.extra_file_handlers) else: - fi = open_by_content_type(data, content_type, self.extra_file_handlers) + fi = open_by_content_type( + data, content_type, self.extra_file_handlers) if name is None: name = str(uuid.uuid4()) extension = MIMETYPES.guess_extension(content_type) @@ -208,8 +201,9 @@ def iter_with_etag(self, ctag=None): """Iterate over all items in the store with etag. - :param ctag: Ctag to iterate for - :yield: (name, content_type, etag) tuples + Args: + ctag: Ctag to iterate for + Returns: iterator over (name, content_type, etag) tuples """ for name in os.listdir(self.path): if name.endswith(".tmp"): @@ -228,7 +222,7 @@ def create(cls, path: str) -> "VdirStore": """Create a new store backed by a Vdir on disk. - :return: A `VdirStore` + Returns: A `VdirStore` """ os.mkdir(path) return cls(path) @@ -237,44 +231,49 @@ def open_from_path(cls, path: str) -> "VdirStore": """Open a VdirStore from a path. - :param path: Path - :return: A `VdirStore` + Args: + path: Path + Returns: A `VdirStore` """ return cls(path) def get_description(self): """Get extended description. - :return: repository description as string + Returns: repository description as string """ return self.config.get_description() def set_description(self, description): """Set extended description. - :param description: repository description as string + Args: + description: repository description as string """ self.config.set_description(description) def set_comment(self, comment): """Set comment. - :param comment: Comment + Args: + comment: Comment """ raise NotImplementedError(self.set_comment) def get_comment(self): """Get comment. - :return: Comment + Returns: Comment """ raise NotImplementedError(self.get_comment) def _read_metadata(self, name): try: - with open(os.path.join(self.path, name), "r") as f: + with open(os.path.join(self.path, name)) as f: return f.read().strip() - except EnvironmentError: + except FileNotFoundError: + return None + except IsADirectoryError: return None def _write_metadata(self, name, data): @@ -288,7 +287,7 @@ def get_color(self): """Get color. - :return: A Color code, or None + Returns: A Color code, or None """ color = self._read_metadata("color") if color is not None: @@ -311,23 +310,25 @@ def get_displayname(self): """Get display name. - :return: The display name, or None if not set + Returns: The display name, or None if not set """ return self._read_metadata("displayname") def set_displayname(self, displayname): """Set the display name. - :param displayname: New display name + Args: + displayname: New display name """ self._write_metadata("displayname", displayname) def iter_changes(self, old_ctag, new_ctag): """Get changes between two versions of this store. - :param old_ctag: Old ctag (None for empty Store) - :param new_ctag: New ctag - :return: Iterator over (name, content_type, old_etag, new_etag) + Args: + old_ctag: Old ctag (None for empty Store) + new_ctag: New ctag + Returns: Iterator over (name, content_type, old_etag, new_etag) """ raise NotImplementedError(self.iter_changes) @@ -338,12 +339,14 @@ def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. - :param name: Filename to delete - :param message: Commit message - :param author: Optional author - :param etag: Optional mandatory etag of object to remove - :raise NoSuchItem: when the item doesn't exist - :raise InvalidETag: If the specified ETag doesn't match the curren + Args: + name: Filename to delete + message: Commit message + author: Optional author + etag: Optional mandatory etag of object to remove + Raises: + NoSuchItem: when the item doesn't exist + InvalidETag: If the specified ETag doesn't match the curren """ path = os.path.join(self.path, name) if etag is not None: @@ -355,10 +358,10 @@ raise InvalidETag(name, etag, current_etag) try: os.unlink(path) - except EnvironmentError as e: - if e.errno == errno.ENOENT: - raise NoSuchItem(path) - raise + except FileNotFoundError as exc: + raise NoSuchItem(path) from exc + except IsADirectoryError as exc: + raise NoSuchItem(path) from exc def get_ctag(self): """Return the ctag for this store.""" @@ -367,7 +370,7 @@ def subdirectories(self): """Returns subdirectories to probe for other stores. - :return: List of names + Returns: List of names """ ret = [] for name in os.listdir(self.path): diff -Nru xandikos-0.2.8/xandikos/sync.py xandikos-0.2.10/xandikos/sync.py --- xandikos-0.2.8/xandikos/sync.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/sync.py 2023-09-06 09:15:03.000000000 +0000 @@ -23,7 +23,6 @@ """ import itertools - import urllib.parse from xandikos import webdav @@ -34,10 +33,10 @@ FEATURE = "sync-collection" -class SyncToken(object): +class SyncToken: """A sync token wrapper.""" - def __init__(self, token): + def __init__(self, token) -> None: self.token = token def aselement(self): @@ -46,6 +45,13 @@ return ret +class InvalidToken(Exception): + """Requested token is invalid.""" + + def __init__(self, token) -> None: + self.token = token + + class SyncCollectionReporter(webdav.Reporter): """sync-collection reporter implementation. @@ -64,6 +70,7 @@ href, resource, depth, + strict ): old_token = None sync_level = None @@ -79,52 +86,67 @@ elif el.tag == "{DAV:}prop": requested = list(el) else: - raise webdav.BadRequestError("unknown tag %s" % el.tag) + webdav.nonfatal_bad_request( + f"unknown tag {el.tag}", strict) # TODO(jelmer): Implement sync_level infinite if sync_level not in ("1",): - raise webdav.BadRequestError("sync level %r unsupported" % sync_level) + raise webdav.BadRequestError( + f"sync level {sync_level!r} unsupported") new_token = resource.get_sync_token() try: - diff_iter = resource.iter_differences_since(old_token, new_token) - except NotImplementedError: - yield webdav.Status( - href, - "403 Forbidden", - error=ET.Element("{DAV:}sync-traversal-supported"), - ) - return - - if limit is not None: try: - [nresults_el] = list(limit) - except ValueError: - raise webdav.BadRequestError("Invalid number of subelements in limit") - try: - nresults = int(nresults_el.text) - except ValueError: - raise webdav.BadRequestError("nresults not a number") - diff_iter = itertools.islice(diff_iter, nresults) - - for (name, old_resource, new_resource) in diff_iter: - subhref = urllib.parse.urljoin(webdav.ensure_trailing_slash(href), name) - if new_resource is None: - yield webdav.Status(subhref, status="404 Not Found") - else: - propstat = [] - for prop in requested: - if old_resource is not None: - old_propstat = await webdav.get_property_from_element( - href, old_resource, properties, environ, prop - ) + diff_iter = resource.iter_differences_since( + old_token, new_token) + except NotImplementedError: + yield webdav.Status( + href, + "403 Forbidden", + error=ET.Element("{DAV:}sync-traversal-supported"), + ) + return + + if limit is not None: + try: + [nresults_el] = list(limit) + except ValueError: + webdav.nonfatal_bad_request( + "Invalid number of subelements in limit", + strict) + else: + try: + nresults = int(nresults_el.text) + except ValueError: + webdav.nonfatal_bad_request( + "nresults not a number", strict) else: - old_propstat = None - new_propstat = await webdav.get_property_from_element( - href, new_resource, properties, environ, prop - ) - if old_propstat != new_propstat: - propstat.append(new_propstat) - yield webdav.Status(subhref, propstat=propstat) + diff_iter = itertools.islice(diff_iter, nresults) + + for (name, old_resource, new_resource) in diff_iter: + subhref = urllib.parse.urljoin( + webdav.ensure_trailing_slash(href), name) + if new_resource is None: + yield webdav.Status(subhref, status="404 Not Found") + else: + propstat = [] + for prop in requested: + if old_resource is not None: + old_propstat = ( + await webdav.get_property_from_element( + href, old_resource, properties, environ, + prop)) + else: + old_propstat = None + new_propstat = await webdav.get_property_from_element( + href, new_resource, properties, environ, prop + ) + if old_propstat != new_propstat: + propstat.append(new_propstat) + yield webdav.Status(subhref, propstat=propstat) + except InvalidToken as exc: + raise webdav.PreconditionFailure( + '{DAV:}valid-sync-token', + f"Requested sync token {exc.token} is invalid") from exc yield SyncToken(new_token) diff -Nru xandikos-0.2.8/xandikos/tests/__init__.py xandikos-0.2.10/xandikos/tests/__init__.py --- xandikos-0.2.8/xandikos/tests/__init__.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/__init__.py 2023-09-06 09:15:03.000000000 +0000 @@ -24,9 +24,11 @@ names = [ "api", "caldav", + "carddav", "config", "icalendar", "store", + "vcard", "webdav", "web", "wsgi", diff -Nru xandikos-0.2.8/xandikos/tests/test_api.py xandikos-0.2.10/xandikos/tests/test_api.py --- xandikos-0.2.8/xandikos/tests/test_api.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_api.py 2023-09-06 09:15:03.000000000 +0000 @@ -21,10 +21,7 @@ import tempfile import unittest -from xandikos.web import ( - XandikosApp, - XandikosBackend, -) +from ..web import XandikosApp, XandikosBackend class WebTests(unittest.TestCase): diff -Nru xandikos-0.2.8/xandikos/tests/test_caldav.py xandikos-0.2.10/xandikos/tests/test_caldav.py --- xandikos-0.2.8/xandikos/tests/test_caldav.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_caldav.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,15 +17,16 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -from icalendar.cal import Calendar as ICalendar import unittest from wsgiref.util import setup_testing_defaults -from xandikos import caldav -from xandikos.webdav import Property, WebDAVApp, ET +from icalendar.cal import Calendar as ICalendar +from xandikos import caldav from xandikos.tests import test_webdav +from ..webdav import ET, Property, WebDAVApp + class WebTests(test_webdav.WebTestCase): def makeApp(self, backend): @@ -51,7 +52,7 @@ return _code[0], _headers, contents def test_mkcalendar_ok(self): - class Backend(object): + class Backend: def create_collection(self, relpath): pass @@ -82,7 +83,7 @@ class ExtractfromCalendarTests(unittest.TestCase): def setUp(self): - super(ExtractfromCalendarTests, self).setUp() + super().setUp() self.requested = ET.Element("{%s}calendar-data" % caldav.NAMESPACE) def extractEqual(self, incal_str, outcal_str): @@ -117,7 +118,8 @@ ) def test_comp_nested(self): - vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) + vcal_comp = ET.SubElement( + self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") vtodo_comp = ET.SubElement(vcal_comp, "{%s}comp" % caldav.NAMESPACE) vtodo_comp.set("name", "VTODO") @@ -153,11 +155,13 @@ ) def test_prop(self): - vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) + vcal_comp = ET.SubElement( + self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") vtodo_comp = ET.SubElement(vcal_comp, "{%s}comp" % caldav.NAMESPACE) vtodo_comp.set("name", "VTODO") - completed_prop = ET.SubElement(vtodo_comp, "{%s}prop" % caldav.NAMESPACE) + completed_prop = ET.SubElement( + vtodo_comp, "{%s}prop" % caldav.NAMESPACE) completed_prop.set("name", "COMPLETED") self.extractEqual( """\ @@ -191,7 +195,8 @@ ) def test_allprop(self): - vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) + vcal_comp = ET.SubElement( + self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") vtodo_comp = ET.SubElement(vcal_comp, "{%s}comp" % caldav.NAMESPACE) vtodo_comp.set("name", "VTODO") @@ -216,7 +221,8 @@ ) def test_allcomp(self): - vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) + vcal_comp = ET.SubElement( + self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") ET.SubElement(vcal_comp, "{%s}allcomp" % caldav.NAMESPACE) self.extractEqual( diff -Nru xandikos-0.2.8/xandikos/tests/test_carddav.py xandikos-0.2.10/xandikos/tests/test_carddav.py --- xandikos-0.2.8/xandikos/tests/test_carddav.py 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_carddav.py 2023-02-28 01:03:05.000000000 +0000 @@ -0,0 +1,47 @@ +# Xandikos +# Copyright (C) 2022 Jelmer Vernooij , et al. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; version 3 +# of the License or (at your option) any later version of +# the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. + +import asyncio +import unittest + +from ..carddav import NAMESPACE, apply_filter +from ..vcard import VCardFile +from ..webdav import ET +from .test_vcard import EXAMPLE_VCARD1 + + +class TestApplyFilter(unittest.TestCase): + + async def get_file(self): + return VCardFile([EXAMPLE_VCARD1], "text/vcard") + + def get_content_type(self): + return "text/vcard" + + def test_apply_filter(self): + el = ET.Element("{%s}filter" % NAMESPACE) + el.set("test", "anyof") + pf = ET.SubElement(el, "{%s}prop-filter" % NAMESPACE) + pf.set("name", "FN") + tm = ET.SubElement(pf, "{%s}text-match" % NAMESPACE) + tm.set("collation", "i;unicode-casemap") + tm.set("match-type", "contains") + tm.text = "Jeffrey" + loop = asyncio.get_event_loop() + self.assertTrue(loop.run_until_complete(apply_filter(el, self))) diff -Nru xandikos-0.2.8/xandikos/tests/test_config.py xandikos-0.2.10/xandikos/tests/test_config.py --- xandikos-0.2.8/xandikos/tests/test_config.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_config.py 2023-09-06 09:15:03.000000000 +0000 @@ -20,10 +20,10 @@ """Tests for xandikos.store.config.""" from io import StringIO - from unittest import TestCase import dulwich.repo + from ..store.config import FileBasedCollectionMetadata from ..store.git import RepoCollectionMetadata @@ -90,7 +90,7 @@ self.assertRaises(KeyError, cc.get_displayname) -class MetadataTests(object): +class MetadataTests: def test_color(self): self.assertRaises(KeyError, self._config.get_color) self._config.set_color("#ffffff") @@ -115,7 +115,8 @@ def test_description(self): self.assertRaises(KeyError, self._config.get_description) self._config.set_description("this is a description") - self.assertEqual("this is a description", self._config.get_description()) + self.assertEqual( + "this is a description", self._config.get_description()) self._config.set_description(None) self.assertRaises(KeyError, self._config.get_description) @@ -129,12 +130,12 @@ class FileMetadataTests(TestCase, MetadataTests): def setUp(self): - super(FileMetadataTests, self).setUp() + super().setUp() self._config = FileBasedCollectionMetadata() class RepoMetadataTests(TestCase, MetadataTests): def setUp(self): - super(RepoMetadataTests, self).setUp() + super().setUp() self._repo = dulwich.repo.MemoryRepo() self._config = RepoCollectionMetadata(self._repo) diff -Nru xandikos-0.2.8/xandikos/tests/test_icalendar.py xandikos-0.2.10/xandikos/tests/test_icalendar.py --- xandikos-0.2.8/xandikos/tests/test_icalendar.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_icalendar.py 2023-09-06 09:15:03.000000000 +0000 @@ -19,27 +19,20 @@ """Tests for xandikos.icalendar.""" +import unittest from datetime import datetime import pytz -import unittest - from icalendar.cal import Event +from icalendar.prop import vCategory, vText -from xandikos import ( - collation as _mod_collation, -) -from xandikos.icalendar import ( - CalendarFilter, - ICalendarFile, - MissingProperty, - TextMatcher, - validate_calendar, - apply_time_range_vevent, - as_tz_aware_ts, -) +from xandikos import collation as _mod_collation from xandikos.store import InvalidFileContents +from ..icalendar import (CalendarFilter, ICalendarFile, MissingProperty, + TextMatcher, apply_time_range_vevent, as_tz_aware_ts, + validate_calendar) + EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR VERSION:2.0 @@ -50,6 +43,7 @@ LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something +CATEGORIES:home UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR @@ -113,7 +107,8 @@ ["Missing required field UID"], list(validate_calendar(fi.calendar, strict=True)), ) - self.assertEqual([], list(validate_calendar(fi.calendar, strict=False))) + self.assertEqual( + [], list(validate_calendar(fi.calendar, strict=False))) self.assertRaises(KeyError, fi.get_uid) def test_invalid_character(self): @@ -134,7 +129,8 @@ filter.filter_subcomponent("VCALENDAR").filter_subcomponent("VEVENT") self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VEVENT"]]) self.assertEqual( - self.cal.get_indexes(["C=VCALENDAR/C=VEVENT", "C=VCALENDAR/C=VTODO"]), + self.cal.get_indexes( + ["C=VCALENDAR/C=VEVENT", "C=VCALENDAR/C=VTODO"]), {"C=VCALENDAR/C=VEVENT": [], "C=VCALENDAR/C=VTODO": [True]}, ) self.assertFalse( @@ -194,9 +190,10 @@ filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("X-SUMMARY") - self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=X-SUMMARY"]]) - self.assertFalse( - filter.check_from_indexes("file", {"C=VCALENDAR/C=VTODO/P=X-SUMMARY": []}) + self.assertEqual( + filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=X-SUMMARY"]]) + self.assertFalse(filter.check_from_indexes( + "file", {"C=VCALENDAR/C=VTODO/P=X-SUMMARY": []}) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) @@ -246,10 +243,12 @@ def test_prop_text_match(self): filter = CalendarFilter(None) - filter.filter_subcomponent("VCALENDAR").filter_subcomponent( - "VTODO" - ).filter_property("SUMMARY").filter_text_match(b"do something different") - self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=SUMMARY"]]) + f = filter.filter_subcomponent("VCALENDAR") + f = f.filter_subcomponent("VTODO") + f = f.filter_property("SUMMARY") + f.filter_text_match("do something different") + self.assertEqual( + filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=SUMMARY"]]) self.assertFalse( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=SUMMARY": [b"do something"]} @@ -259,7 +258,7 @@ filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" - ).filter_property("SUMMARY").filter_text_match(b"do something") + ).filter_property("SUMMARY").filter_text_match("do something") self.assertTrue( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=SUMMARY": [b"do something"]} @@ -267,14 +266,45 @@ ) self.assertTrue(filter.check("file", self.cal)) - def test_param_text_match(self): - self.cal = ICalendarFile([EXAMPLE_VCALENDAR_WITH_PARAM], "text/calendar") + def test_prop_text_match_category(self): + filter = CalendarFilter(None) + f = filter.filter_subcomponent("VCALENDAR") + f = f.filter_subcomponent("VTODO") + f = f.filter_property("CATEGORIES") + f.filter_text_match("work") + self.assertEqual( + self.cal.get_indexes(["C=VCALENDAR/C=VTODO/P=CATEGORIES"]), + {"C=VCALENDAR/C=VTODO/P=CATEGORIES": [b'home']}, + ) + + self.assertEqual( + filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=CATEGORIES"]]) + self.assertFalse( + filter.check_from_indexes( + "file", {"C=VCALENDAR/C=VTODO/P=CATEGORIES": [b"home"]} + ) + ) + self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" - ).filter_property("CREATED").filter_parameter("TZID").filter_text_match( - b"America/Blah" + ).filter_property("CATEGORIES").filter_text_match("home") + self.assertTrue( + filter.check_from_indexes( + "file", {"C=VCALENDAR/C=VTODO/P=CATEGORIES": [b"home"]} + ) ) + self.assertTrue(filter.check("file", self.cal)) + + def test_param_text_match(self): + self.cal = ICalendarFile( + [EXAMPLE_VCALENDAR_WITH_PARAM], "text/calendar") + filter = CalendarFilter(None) + f = filter.filter_subcomponent("VCALENDAR") + f = f.filter_subcomponent("VTODO") + f = f.filter_property("CREATED") + f = f.filter_parameter("TZID") + f.filter_text_match("America/Blah") self.assertEqual( filter.index_keys(), [ @@ -290,11 +320,11 @@ ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) - filter.filter_subcomponent("VCALENDAR").filter_subcomponent( - "VTODO" - ).filter_property("CREATED").filter_parameter("TZID").filter_text_match( - b"America/Denver" - ) + f = filter.filter_subcomponent("VCALENDAR") + f = f.filter_subcomponent("VTODO") + f = f.filter_property("CREATED") + f = f.filter_parameter("TZID") + f.filter_text_match("America/Denver") self.assertTrue( filter.check_from_indexes( "file", @@ -307,17 +337,25 @@ return as_tz_aware_ts(dt, pytz.utc) def test_prop_apply_time_range(self): - filter = CalendarFilter(self._tzify) + filter = CalendarFilter(pytz.utc) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("CREATED").filter_time_range( self._tzify(datetime(2019, 3, 10, 22, 35, 12)), self._tzify(datetime(2019, 3, 18, 22, 35, 12)), ) - self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=CREATED"]]) + self.assertEqual( + filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=CREATED"]]) + self.assertFalse( + filter.check_from_indexes( + "file", + {"C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"]} + ) + ) self.assertFalse( filter.check_from_indexes( - "file", {"C=VCALENDAR/C=VTODO/P=CREATED": ["20150314T223512Z"]} + "file", + {"C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314"]} ) ) self.assertFalse(filter.check("file", self.cal)) @@ -330,13 +368,17 @@ ) self.assertTrue( filter.check_from_indexes( - "file", {"C=VCALENDAR/C=VTODO/P=CREATED": ["20150314T223512Z"]} - ) + "file", + {"C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"]}) ) self.assertTrue(filter.check("file", self.cal)) def test_comp_apply_time_range(self): - filter = CalendarFilter(self._tzify) + self.assertEqual( + self.cal.get_indexes(["C=VCALENDAR/C=VTODO/P=CREATED"]), + {'C=VCALENDAR/C=VTODO/P=CREATED': [b'20150314T223512Z']}) + + filter = CalendarFilter(pytz.utc) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_time_range( @@ -358,7 +400,20 @@ filter.check_from_indexes( "file", { - "C=VCALENDAR/C=VTODO/P=CREATED": ["20150314T223512Z"], + "C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"], + "C=VCALENDAR/C=VTODO": [True], + "C=VCALENDAR/C=VTODO/P=DUE": [], + "C=VCALENDAR/C=VTODO/P=DURATION": [], + "C=VCALENDAR/C=VTODO/P=COMPLETED": [], + "C=VCALENDAR/C=VTODO/P=DTSTART": [], + }, + ) + ) + self.assertFalse( + filter.check_from_indexes( + "file", + { + "C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314"], "C=VCALENDAR/C=VTODO": [True], "C=VCALENDAR/C=VTODO/P=DUE": [], "C=VCALENDAR/C=VTODO/P=DURATION": [], @@ -368,7 +423,7 @@ ) ) self.assertFalse(filter.check("file", self.cal)) - filter = CalendarFilter(self._tzify) + filter = CalendarFilter(pytz.utc) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_time_range( @@ -379,7 +434,7 @@ filter.check_from_indexes( "file", { - "C=VCALENDAR/C=VTODO/P=CREATED": ["20150314T223512Z"], + "C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"], "C=VCALENDAR/C=VTODO": [True], "C=VCALENDAR/C=VTODO/P=DUE": [], "C=VCALENDAR/C=VTODO/P=DURATION": [], @@ -393,28 +448,51 @@ class TextMatchTest(unittest.TestCase): def test_default_collation(self): - tm = TextMatcher(b"foobar") - self.assertTrue(tm.match(b"FOOBAR")) - self.assertTrue(tm.match(b"foobar")) - self.assertFalse(tm.match(b"fobar")) + tm = TextMatcher("summary", "foobar") + self.assertTrue(tm.match(vText("FOOBAR"))) + self.assertTrue(tm.match(vText("foobar"))) + self.assertFalse(tm.match(vText("fobar"))) + self.assertTrue(tm.match_indexes({None: [b'foobar']})) + self.assertTrue(tm.match_indexes({None: [b'FOOBAR']})) + self.assertFalse(tm.match_indexes({None: [b'fobar']})) def test_casecmp_collation(self): - tm = TextMatcher(b"foobar", collation="i;ascii-casemap") - self.assertTrue(tm.match(b"FOOBAR")) - self.assertTrue(tm.match(b"foobar")) - self.assertFalse(tm.match(b"fobar")) + tm = TextMatcher("summary", "foobar", collation="i;ascii-casemap") + self.assertTrue(tm.match(vText("FOOBAR"))) + self.assertTrue(tm.match(vText("foobar"))) + self.assertFalse(tm.match(vText("fobar"))) + self.assertTrue(tm.match_indexes({None: [b'foobar']})) + self.assertTrue(tm.match_indexes({None: [b'FOOBAR']})) + self.assertFalse(tm.match_indexes({None: [b'fobar']})) def test_cmp_collation(self): - tm = TextMatcher(b"foobar", "i;octet") - self.assertFalse(tm.match(b"FOOBAR")) - self.assertTrue(tm.match(b"foobar")) - self.assertFalse(tm.match(b"fobar")) + tm = TextMatcher("summary", "foobar", collation="i;octet") + self.assertFalse(tm.match(vText("FOOBAR"))) + self.assertTrue(tm.match(vText("foobar"))) + self.assertFalse(tm.match(vText("fobar"))) + self.assertFalse(tm.match_indexes({None: [b'FOOBAR']})) + self.assertTrue(tm.match_indexes({None: [b'foobar']})) + self.assertFalse(tm.match_indexes({None: [b'fobar']})) + + def test_category(self): + tm = TextMatcher("categories", "foobar") + self.assertTrue(tm.match(vCategory(["FOOBAR", "blah"]))) + self.assertTrue(tm.match(vCategory(["foobar"]))) + self.assertFalse(tm.match(vCategory(["fobar"]))) + self.assertTrue(tm.match_indexes({None: [b'foobar,blah']})) + self.assertFalse(tm.match_indexes({None: [b'foobarblah']})) + + def test_unknown_type(self): + tm = TextMatcher("dontknow", "foobar") + self.assertFalse(tm.match(object())) + self.assertFalse(tm.match_indexes({None: [b'foobarblah']})) def test_unknown_collation(self): self.assertRaises( _mod_collation.UnknownCollation, TextMatcher, - b"foobar", + "summary", + "foobar", collation="i;blah", ) diff -Nru xandikos-0.2.8/xandikos/tests/test_store.py xandikos-0.2.10/xandikos/tests/test_store.py --- xandikos-0.2.8/xandikos/tests/test_store.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_store.py 2023-09-06 09:15:03.000000000 +0000 @@ -19,27 +19,20 @@ import logging import os -import tempfile import shutil import stat +import tempfile import unittest from dulwich.objects import Blob, Commit, Tree from dulwich.repo import Repo -from typing import Type +from xandikos.store import (DuplicateUidError, File, Filter, InvalidETag, + NoSuchItem, Store) -from xandikos.icalendar import ICalendarFile -from xandikos.store import ( - DuplicateUidError, - File, - InvalidETag, - NoSuchItem, - Filter, - Store, -) -from xandikos.store.git import GitStore, BareGitStore, TreeGitStore -from xandikos.store.vdir import VdirStore +from ..icalendar import ICalendarFile +from ..store.git import BareGitStore, GitStore, TreeGitStore +from ..store.vdir import VdirStore EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR @@ -116,10 +109,11 @@ """ -class BaseStoreTest(object): +class BaseStoreTest: def test_import_one(self): gc = self.create_store() - (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name, etag) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertIsInstance(etag, str) self.assertEqual( [("foo.ics", "text/calendar", etag)], list(gc.iter_with_etag()) @@ -127,14 +121,16 @@ def test_with_filter(self): gc = self.create_store() - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) - (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name2, etag2) = gc.import_one( + "bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) class DummyFilter(Filter): content_type = "text/calendar" - def __init__(self, text): + def __init__(self, text) -> None: self.text = text def check(self, name, resource): @@ -142,7 +138,7 @@ self.assertEqual( 2, - len(list(gc.iter_with_filter(filter=DummyFilter(b"do something")))), + len(list(gc.iter_with_filter(filter=DummyFilter(b"do something")))) ) [(ret_name, ret_file, ret_etag)] = list( @@ -158,8 +154,10 @@ def test_get_by_index(self): gc = self.create_store() - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) - (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name2, etag2) = gc.import_one( + "bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) (name3, etag3) = gc.import_one( "bar.txt", "text/plain", [b"Not a calendar file."] ) @@ -171,21 +169,21 @@ content_type = "text/calendar" - def __init__(self, text): + def __init__(self, text) -> None: self.text = text def index_keys(self): return [[filtertext]] def check_from_indexes(self, name, index_values): - return any(self.text in v.encode() for v in index_values[filtertext]) + return any(self.text in v for v in index_values[filtertext]) def check(self, name, resource): return self.text in b"".join(resource.content) self.assertEqual( 2, - len(list(gc.iter_with_filter(filter=DummyFilter(b"do something")))), + len(list(gc.iter_with_filter(filter=DummyFilter(b"do something")))) ) [(ret_name, ret_file, ret_etag)] = list( @@ -211,7 +209,8 @@ def test_import_one_duplicate_uid(self): gc = self.create_store() - (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name, etag) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertRaises( DuplicateUidError, gc.import_one, @@ -222,11 +221,13 @@ def test_import_one_duplicate_name(self): gc = self.create_store() - (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name, etag) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name, etag) = gc.import_one( "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR2], replace_etag=etag ) - (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name, etag) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertRaises( InvalidETag, gc.import_one, @@ -238,8 +239,10 @@ def test_get_raw(self): gc = self.create_store() - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) - (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name2, etag2) = gc.import_one( + "bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) self.assertEqual( EXAMPLE_VCALENDAR1_NORMALIZED, b"".join(gc._get_raw("foo.ics", etag1)), @@ -252,8 +255,10 @@ def test_get_file(self): gc = self.create_store() - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) - (name1, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name1, etag2) = gc.import_one( + "bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) f1 = gc.get_file("foo.ics", "text/calendar", etag1) self.assertEqual(EXAMPLE_VCALENDAR1_NORMALIZED, b"".join(f1.content)) self.assertEqual("text/calendar", f1.content_type) @@ -265,7 +270,8 @@ def test_delete_one(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertEqual( [("foo.ics", "text/calendar", etag1)], list(gc.iter_with_etag()) ) @@ -275,7 +281,8 @@ def test_delete_one_with_etag(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertEqual( [("foo.ics", "text/calendar", etag1)], list(gc.iter_with_etag()) ) @@ -289,25 +296,23 @@ def test_delete_one_invalid_etag(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) - (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) - self.assertEqual( - set( - [ - ("foo.ics", "text/calendar", etag1), - ("bar.ics", "text/calendar", etag2), - ] - ), + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name2, etag2) = gc.import_one( + "bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) + self.assertEqual( + { + ("foo.ics", "text/calendar", etag1), + ("bar.ics", "text/calendar", etag2), + }, set(gc.iter_with_etag()), ) self.assertRaises(InvalidETag, gc.delete_one, "foo.ics", etag=etag2) self.assertEqual( - set( - [ - ("foo.ics", "text/calendar", etag1), - ("bar.ics", "text/calendar", etag2), - ] - ), + { + ("foo.ics", "text/calendar", etag1), + ("bar.ics", "text/calendar", etag2), + }, set(gc.iter_with_etag()), ) @@ -326,7 +331,7 @@ class BaseGitStoreTest(BaseStoreTest): - kls: Type[Store] + kls: type[Store] def create_store(self): raise NotImplementedError(self.create_store) @@ -345,14 +350,16 @@ logging.getLogger("").setLevel(logging.ERROR) gc = self.create_store() bid = self.add_blob(gc, "foo.ics", EXAMPLE_VCALENDAR_NO_UID) - self.assertEqual([("foo.ics", "text/calendar", bid)], list(gc.iter_with_etag())) + self.assertEqual( + [("foo.ics", "text/calendar", bid)], list(gc.iter_with_etag())) gc._scan_uids() logging.getLogger("").setLevel(logging.NOTSET) def test_iter_with_etag(self): gc = self.create_store() bid = self.add_blob(gc, "foo.ics", EXAMPLE_VCALENDAR1) - self.assertEqual([("foo.ics", "text/calendar", bid)], list(gc.iter_with_etag())) + self.assertEqual( + [("foo.ics", "text/calendar", bid)], list(gc.iter_with_etag())) def test_get_description_from_git_config(self): gc = self.create_store() @@ -396,8 +403,10 @@ def test_import_only_once(self): gc = self.create_store() - (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) - (name2, etag2) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name1, etag1) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) + (name2, etag2) = gc.import_one( + "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertEqual(name1, name2) self.assertEqual(etag1, etag2) walker = gc.repo.get_walker(include=[gc.repo.refs[gc.ref]]) @@ -453,7 +462,8 @@ gc = self.create_store() self.assertEqual(Tree().id.decode("ascii"), gc.get_ctag()) self.add_blob(gc, "foo.ics", EXAMPLE_VCALENDAR1) - self.assertEqual(gc._get_current_tree().id.decode("ascii"), gc.get_ctag()) + self.assertEqual( + gc._get_current_tree().id.decode("ascii"), gc.get_ctag()) class TreeGitStoreTest(BaseGitStoreTest, unittest.TestCase): diff -Nru xandikos-0.2.8/xandikos/tests/test_vcard.py xandikos-0.2.10/xandikos/tests/test_vcard.py --- xandikos-0.2.8/xandikos/tests/test_vcard.py 1970-01-01 00:00:00.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_vcard.py 2023-04-19 17:43:14.000000000 +0000 @@ -0,0 +1,42 @@ +# Xandikos +# Copyright (C) 2022 Jelmer Vernooij , et al. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; version 3 +# of the License or (at your option) any later version of +# the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. + +"""Tests for xandikos.vcard.""" + +import unittest + +from ..vcard import VCardFile + +EXAMPLE_VCARD1 = b"""\ +BEGIN:VCARD +VERSION:3.0 +EMAIL;TYPE=INTERNET:jeffrey@osafoundation.org +EMAIL;TYPE=INTERNET:jeffery@example.org +ORG:Open Source Applications Foundation +FN:Jeffrey Harris +N:Harris;Jeffrey;;; +END:VCARD +""" + + +class ParseVcardTests(unittest.TestCase): + + def test_validate(self): + fi = VCardFile([EXAMPLE_VCARD1], "text/vcard") + fi.validate() diff -Nru xandikos-0.2.8/xandikos/tests/test_webdav.py xandikos-0.2.10/xandikos/tests/test_webdav.py --- xandikos-0.2.8/xandikos/tests/test_webdav.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_webdav.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,23 +17,24 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -from io import BytesIO import logging import unittest +from io import BytesIO from wsgiref.util import setup_testing_defaults from xandikos import webdav -from xandikos.webdav import Collection, ET, Property, Resource, WebDAVApp + +from ..webdav import ET, Collection, Property, Resource, WebDAVApp class WebTestCase(unittest.TestCase): def setUp(self): - super(WebTestCase, self).setUp() + super().setUp() logging.disable(logging.WARNING) self.addCleanup(logging.disable, logging.NOTSET) def makeApp(self, resources, properties): - class Backend(object): + class Backend: get_resource = resources.get app = WebDAVApp(Backend()) @@ -166,7 +167,7 @@ new_body = [] class TestResource(Resource): - def set_body(self, body, replace_etag=None): + async def set_body(self, body, replace_etag=None): new_body.extend(body) async def get_etag(self): @@ -194,7 +195,7 @@ self.assertEqual(b"", contents) def test_mkcol_ok(self): - class Backend(object): + class Backend: def create_collection(self, relpath): pass @@ -207,7 +208,8 @@ self.assertEqual(b"", contents) def test_mkcol_exists(self): - app = self.makeApp({"/resource": Resource(), "/resource/bla": Resource()}, []) + app = self.makeApp( + {"/resource": Resource(), "/resource/bla": Resource()}, []) code, headers, contents = self.mkcol(app, "/resource/bla") self.assertEqual("405 Method Not Allowed", code) self.assertEqual(b"", contents) @@ -220,7 +222,8 @@ def delete_member(unused_self, name, etag=None): self.assertEqual(name, "resource") - app = self.makeApp({"/": TestResource(), "/resource": TestResource()}, []) + app = self.makeApp( + {"/": TestResource(), "/resource": TestResource()}, []) code, headers, contents = self.delete(app, "/resource") self.assertEqual("204 No Content", code) self.assertEqual(b"", contents) @@ -417,7 +420,7 @@ ), ) self.assertEqual( - set(["text/plain", "text/html"]), + {"text/plain", "text/html"}, set( webdav.pick_content_types( [("text/*", {"q": "0.4"}), ("text/plain", {"q": "0.3"})], @@ -468,7 +471,8 @@ def test_one(self): self.assertEqual( {("200 OK", None): ["foo"]}, - webdav.propstat_by_status([webdav.PropStatus("200 OK", None, "foo")]), + webdav.propstat_by_status( + [webdav.PropStatus("200 OK", None, "foo")]), ) def test_multiple(self): @@ -515,5 +519,6 @@ def test_recode(self): self.assertEqual( "/blü", - webdav.path_from_environ({"PATH_INFO": "/bl\xc3\xbc"}, "PATH_INFO"), + webdav.path_from_environ( + {"PATH_INFO": "/bl\xc3\xbc"}, "PATH_INFO"), ) diff -Nru xandikos-0.2.8/xandikos/tests/test_web.py xandikos-0.2.10/xandikos/tests/test_web.py --- xandikos-0.2.8/xandikos/tests/test_web.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_web.py 2023-09-06 09:15:03.000000000 +0000 @@ -27,11 +27,7 @@ from .. import caldav from ..icalendar import ICalendarFile from ..store.vdir import VdirStore -from ..web import ( - XandikosBackend, - CalendarCollection, -) - +from ..web import CalendarCollection, XandikosBackend EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR @@ -51,7 +47,7 @@ class CalendarCollectionTests(unittest.TestCase): def setUp(self): - super(CalendarCollectionTests, self).setUp() + super().setUp() self.tempdir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.tempdir) @@ -97,7 +93,7 @@ f.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("UID").filter_text_match( - b"bdc22720-b9e1-42c9-89c2-a85405d8fbff" + "bdc22720-b9e1-42c9-89c2-a85405d8fbff" ) return f diff -Nru xandikos-0.2.8/xandikos/tests/test_wsgi.py xandikos-0.2.10/xandikos/tests/test_wsgi.py --- xandikos-0.2.8/xandikos/tests/test_wsgi.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/tests/test_wsgi.py 2023-09-06 09:15:03.000000000 +0000 @@ -19,9 +19,7 @@ import unittest -from xandikos.wsgi_helpers import ( - WellknownRedirector, -) +from ..wsgi_helpers import WellknownRedirector class WebTests(unittest.TestCase): diff -Nru xandikos-0.2.8/xandikos/timezones.py xandikos-0.2.10/xandikos/timezones.py --- xandikos-0.2.8/xandikos/timezones.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/timezones.py 2023-09-06 09:15:03.000000000 +0000 @@ -26,7 +26,7 @@ class TimezoneServiceSetProperty(webdav.Property): - """timezone-service-set property + """timezone-service-set property. See http://www.webdav.org/specs/rfc7809.html, section 5.1 """ @@ -38,8 +38,8 @@ in_allprops = False live = True - def __init__(self, timezone_services): - super(TimezoneServiceSetProperty, self).__init__() + def __init__(self, timezone_services) -> None: + super().__init__() self._timezone_services = timezone_services async def get_value(self, base_href, resource, el, environ): diff -Nru xandikos-0.2.8/xandikos/vcard.py xandikos-0.2.10/xandikos/vcard.py --- xandikos-0.2.8/xandikos/vcard.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/vcard.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,24 +17,44 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""VCard file handling. - -""" +"""VCard file handling.""" from .store import File, InvalidFileContents class VCardFile(File): + content_type = "text/vcard" + def __init__(self, content, content_type) -> None: + super().__init__(content, content_type) + self._addressbook = None + def validate(self): c = b"".join(self.content).strip() # TODO(jelmer): Do more extensive checking of VCards - if not c.startswith((b"BEGIN:VCARD\r\n", b"BEGIN:VCARD\n")) or not c.endswith( - b"\nEND:VCARD" - ): + if (not c.startswith((b"BEGIN:VCARD\r\n", b"BEGIN:VCARD\n")) + or not c.endswith(b"\nEND:VCARD")): raise InvalidFileContents( self.content_type, self.content, "Missing header and trailer lines", ) + if not self.addressbook.validate(): + # TODO(jelmer): Get data about what is invalid + raise InvalidFileContents( + self.content_type, + self.content, + "Invalid VCard file") + + @property + def addressbook(self): + if self._addressbook is None: + import vobject + text = b"".join(self.content).decode('utf-8', 'surrogateescape') + try: + self._addressbook = vobject.readOne(text) + except vobject.base.ParseError as exc: + raise InvalidFileContents( + self.content_type, self.content, str(exc)) from exc + return self._addressbook diff -Nru xandikos-0.2.8/xandikos/webdav.py xandikos-0.2.10/xandikos/webdav.py --- xandikos-0.2.8/xandikos/webdav.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/webdav.py 2023-09-06 09:15:03.000000000 +0000 @@ -32,25 +32,16 @@ import logging import os import posixpath -from typing import ( - Callable, - Dict, - Iterable, - AsyncIterable, - List, - Optional, - Union, - Tuple, - Sequence, -) import urllib.parse +from collections.abc import AsyncIterable, Iterable, Iterator, Sequence +from datetime import datetime +from typing import Callable, Optional, Union, Dict, Type from wsgiref.util import request_uri - -from defusedxml.ElementTree import fromstring as xmlparse - # Hmm, defusedxml doesn't have XML generation functions? :( from xml.etree import ElementTree as ET +from defusedxml.ElementTree import fromstring as xmlparse + DEFAULT_ENCODING = "utf-8" COLLECTION_RESOURCE_TYPE = "{DAV:}collection" PRINCIPAL_RESOURCE_TYPE = "{DAV:}principal" @@ -64,18 +55,23 @@ class BadRequestError(Exception): """Base class for bad request errors.""" - def __init__(self, message): - super(BadRequestError, self).__init__(message) + def __init__(self, message) -> None: + super().__init__(message) self.message = message +def nonfatal_bad_request(message, strict=False): + if strict: + raise BadRequestError(message) + logging.debug('Bad request: %s', message) + + class NotAcceptableError(Exception): """Base class for not acceptable errors.""" - def __init__(self, available_content_types, acceptable_content_types): - super(NotAcceptableError, self).__init__( - "Unable to convert from content types %r to one of %r" - % (available_content_types, acceptable_content_types) + def __init__(self, available_content_types, acceptable_content_types) -> None: + super().__init__( + f"Unable to convert from content types {available_content_types!r} to one of {acceptable_content_types!r}" ) self.available_content_types = available_content_types self.acceptable_content_types = acceptable_content_types @@ -84,9 +80,9 @@ class UnsupportedMediaType(Exception): """Base class for unsupported media type errors.""" - def __init__(self, content_type): - super(UnsupportedMediaType, self).__init__( - "Unsupported media type: %r" % (content_type,) + def __init__(self, content_type) -> None: + super().__init__( + f"Unsupported media type: {content_type!r}" ) self.content_type = content_type @@ -94,14 +90,14 @@ class UnauthorizedError(Exception): """Base class for unauthorized errors.""" - def __init__(self): - super(UnauthorizedError, self).__init__("Request unauthorized") + def __init__(self) -> None: + super().__init__("Request unauthorized") -class Response(object): +class Response: """Generic wrapper for HTTP-style responses.""" - def __init__(self, status=200, reason="OK", body=None, headers=None): + def __init__(self, status=200, reason="OK", body=None, headers=None) -> None: if isinstance(status, str): self.status = int(status.split(" ", 1)[0]) self.reason = status.split(" ", 1)[1] @@ -140,8 +136,10 @@ def pick_content_types(accepted_content_types, available_content_types): """Pick best content types for a client. - :param accepted_content_types: Accept variable (as name, params tuples) - :raise NotAcceptableError: If there are no overlapping content types + Args: + accepted_content_types: Accept variable (as name, params tuples) + Raises: + NotAcceptableError: If there are no overlapping content types """ available_content_types = set(available_content_types) acceptable_by_q = {} @@ -150,7 +148,8 @@ if 0 in acceptable_by_q: # Items with q=0 are not acceptable for pat in acceptable_by_q[0]: - available_content_types -= set(fnmatch.filter(available_content_types, pat)) + available_content_types -= set( + fnmatch.filter(available_content_types, pat)) del acceptable_by_q[0] for q, pats in sorted(acceptable_by_q.items(), reverse=True): ret = [] @@ -164,8 +163,9 @@ def parse_type(content_type): """Parse a content-type style header. - :param content_type: type to parse - :return: Tuple with base name and dict with params + Args: + content_type: type to parse + Returns: Tuple with base name and dict with params """ params = {} try: @@ -182,8 +182,9 @@ def parse_accept_header(accept): """Parse a HTTP Accept or Accept-Language header. - :param accept: Accept header contents - :return: List of (content_type, params) tuples + Args: + accept: Accept header contents + Returns: List of (content_type, params) tuples """ ret = [] for part in accept.split(","): @@ -197,7 +198,7 @@ class PreconditionFailure(Exception): """A precondition failed.""" - def __init__(self, precondition, description): + def __init__(self, precondition, description) -> None: self.precondition = precondition self.description = description @@ -213,9 +214,10 @@ def etag_matches(condition, actual_etag): """Check if an etag matches an If-Matches condition. - :param condition: Condition (e.g. '*', '"foo"' or '"foo", "bar"' - :param actual_etag: ETag to compare to. None nonexistant - :return: bool indicating whether condition matches + Args: + condition: Condition (e.g. '*', '"foo"' or '"foo", "bar"' + actual_etag: ETag to compare to. None nonexistant + Returns: bool indicating whether condition matches """ if actual_etag is None and condition: return False @@ -234,8 +236,9 @@ def propstat_by_status(propstat): """Sort a list of propstatus objects by HTTP status. - :param propstat: List of PropStatus objects: - :return: dictionary mapping HTTP status code to list of PropStatus objects + Args: + propstat: List of PropStatus objects: + Returns: dictionary mapping HTTP status code to list of PropStatus objects """ bystatus = {} for propstat in propstat: @@ -250,8 +253,9 @@ def propstat_as_xml(propstat): """Format a list of propstats as XML elements. - :param propstat: List of PropStatus objects - :return: Iterator over {DAV:}propstat elements + Args: + propstat: List of PropStatus objects + Returns: Iterator over {DAV:}propstat elements """ bystatus = propstat_by_status(propstat) for (status, rd), props in sorted(bystatus.items()): @@ -277,7 +281,7 @@ return posixpath.normpath(path) -class Status(object): +class Status: """A DAV response that can be used in multi-status.""" def __init__( @@ -287,15 +291,15 @@ error=None, responsedescription=None, propstat=None, - ): + ) -> None: self.href = str(href) self.status = status self.error = error self.propstat = propstat self.responsedescription = responsedescription - def __repr__(self): - return "<%s(%r, %r, %r)>" % ( + def __repr__(self) -> str: + return "<{}({!r}, {!r}, {!r})>".format( type(self).__name__, self.href, self.status, @@ -310,14 +314,14 @@ if self.propstat: [ret] = list(propstat_as_xml(self.propstat)) body = ET.tostringlist(ret, encoding) - return body, ('text/xml; encoding="%s"' % encoding) + return body, (f'text/xml; encoding="{encoding}"') else: body = ( [self.responsedescription.encode(encoding)] if self.responsedescription else [] ) - return body, ('text/plain; encoding="%s"' % encoding) + return body, (f'text/plain; encoding="{encoding}"') def aselement(self): ret = ET.Element("{DAV:}response") @@ -348,33 +352,33 @@ return wrapper -class Resource(object): +class Resource: """A WebDAV resource.""" # A list of resource type names (e.g. '{DAV:}collection') - resource_types: List[str] = [] + resource_types: list[str] = [] # TODO(jelmer): Be consistent in using get/set functions vs properties. - def set_resource_types(self, resource_types): + def set_resource_types(self, resource_types: list[str]) -> None: """Set the resource types.""" raise NotImplementedError(self.set_resource_types) - def get_displayname(self): + def get_displayname(self) -> str: """Get the resource display name.""" raise KeyError - def set_displayname(self, displayname): + def set_displayname(self, displayname: str) -> None: """Set the resource display name.""" raise NotImplementedError(self.set_displayname) - def get_creationdate(self): + def get_creationdate(self) -> datetime: """Get the resource creation date. - :return: A datetime object + Returns: A datetime object """ raise NotImplementedError(self.get_creationdate) - def get_supported_locks(self): + def get_supported_locks(self) -> list[tuple[str, str]]: """Get the list of supported locks. This should return a list of (lockscope, locktype) tuples. @@ -383,28 +387,28 @@ """ raise NotImplementedError(self.get_supported_locks) - def get_active_locks(self): + def get_active_locks(self) -> list["ActiveLock"]: """Return the list of active locks. - :return: A list of ActiveLock tuples + Returns: A list of ActiveLock tuples """ raise NotImplementedError(self.get_active_locks) - def get_content_type(self): + def get_content_type(self) -> str: """Get the content type for the resource. This is a mime type like text/plain """ raise NotImplementedError(self.get_content_type) - def get_owner(self): + def get_owner(self) -> str: """Get an href identifying the owner of the resource. Can be None if owner information is not known. """ raise NotImplementedError(self.get_owner) - async def get_etag(self): + async def get_etag(self) -> str: """Get the etag for this resource. Contains the ETag header value (from Section 14.19 of [RFC2616]) as it @@ -412,23 +416,29 @@ """ raise NotImplementedError(self.get_etag) - async def get_body(self): + async def get_body(self) -> Iterable[bytes]: """Get resource contents. - :return: Iterable over bytestrings.""" + Returns: Iterable over bytestrings. + """ raise NotImplementedError(self.get_body) - async def render(self, self_url, accepted_content_types, accepted_languages): + async def render( + self, self_url: str, accepted_content_types: list[str], + accepted_languages: list[str]) -> tuple[ + Iterable[bytes], int, str, str, Optional[str]]: """'Render' this resource in the specified content type. The default implementation just checks that the resource' content type is acceptable and if so returns (get_body(), get_content_type(), get_content_language()). - :param accepted_content_types: List of accepted content types - :param accepted_languages: List of accepted languages - :raise NotAcceptableError: if there is no acceptable content type - :return: Tuple with (content_body, content_length, etag, content_type, + Args: + accepted_content_types: List of accepted content types + accepted_languages: List of accepted languages + Raises: + NotAcceptableError: if there is no acceptable content type + Returns: Tuple with (content_body, content_length, etag, content_type, content_language) """ # TODO(jelmer): Check content_language @@ -449,73 +459,78 @@ content_language, ) - async def get_content_length(self): + async def get_content_length(self) -> int: """Get content length. - :return: Length of this objects content. + Returns: Length of this objects content. """ return sum(map(len, await self.get_body())) - def get_content_language(self): + def get_content_language(self) -> str: """Get content language. - :return: Language, as used in HTTP Accept-Language + Returns: Language, as used in HTTP Accept-Language """ raise NotImplementedError(self.get_content_language) - def set_body(self, body, replace_etag=None): + async def set_body( + self, body: Iterable[bytes], + replace_etag: Optional[str] = None) -> str: """Set resource contents. - :param body: Iterable over bytestrings - :return: New ETag + Args: + body: Iterable over bytestrings + Returns: New ETag """ raise NotImplementedError(self.set_body) - def set_comment(self, comment): + def set_comment(self, comment: str) -> None: """Set resource comment. - :param comment: New comment + Args: + comment: New comment """ raise NotImplementedError(self.set_comment) - def get_comment(self): + def get_comment(self) -> str: """Get resource comment. - :return: comment + Returns: comment """ raise NotImplementedError(self.get_comment) - def get_last_modified(self): + def get_last_modified(self) -> datetime: """Get last modified time. - :return: Last modified time + Returns: Last modified time """ raise NotImplementedError(self.get_last_modified) - def get_is_executable(self): + def get_is_executable(self) -> bool: """Get executable bit. - :return: Boolean indicating executability + Returns: Boolean indicating executability """ raise NotImplementedError(self.get_is_executable) - def set_is_executable(self, executable): + def set_is_executable(self, executable: bool) -> None: """Set executable bit. - :param executable: Boolean indicating executability + Args: + executable: Boolean indicating executability """ raise NotImplementedError(self.set_is_executable) - def get_quota_used_bytes(self): + def get_quota_used_bytes(self) -> int: """Return bytes consumed by this resource. If unknown, this can raise KeyError. - :return: an integer + Returns: an integer """ raise NotImplementedError(self.get_quota_used_bytes) - def get_quota_available_bytes(self): + def get_quota_available_bytes(self) -> int: """Return quota available as bytes. This can raise KeyError if there is infinite quota available. @@ -523,7 +538,7 @@ raise NotImplementedError(self.get_quota_available_bytes) -class Property(object): +class Property: """Handler for listing, retrieving and updating DAV Properties.""" # Property name (e.g. '{DAV:}resourcetype') @@ -544,13 +559,14 @@ if self.resource_type is None: return True if isinstance(self.resource_type, tuple): - return any(rs in resource.resource_types for rs in self.resource_type) + return any( + rs in resource.resource_types for rs in self.resource_type) if self.resource_type in resource.resource_types: return True return False async def is_set( - self, href: str, resource: Resource, environ: Dict[str, str] + self, href: str, resource: Resource, environ: dict[str, str] ) -> bool: """Check if this property is set on a resource.""" if not self.supported_on(resource): @@ -567,25 +583,30 @@ href: str, resource: Resource, el: ET.Element, - environ: Dict[str, str], + environ: dict[str, str], ) -> None: """Get property with specified name. - :param href: Resource href - :param resource: Resource for which to retrieve the property - :param el: Element to populate - :param environ: WSGI environment dict - :raise KeyError: if this property is not present + Args: + href: Resource href + resource: Resource for which to retrieve the property + el: Element to populate + environ: WSGI environment dict + Raises: + KeyError: if this property is not present """ raise KeyError(self.name) - async def set_value(self, href: str, resource: Resource, el: ET.Element) -> None: + async def set_value( + self, href: str, resource: Resource, el: ET.Element) -> None: """Set property. - :param href: Resource href - :param resource: Resource to modify - :param el: Element to get new value from (None to remove property) - :raise NotImplementedError: to indicate this property can not be set + Args: + href: Resource href + resource: Resource to modify + el: Element to get new value from (None to remove property) + Raises: + NotImplementedError: to indicate this property can not be set (i.e. is protected) """ raise NotImplementedError(self.set_value) @@ -669,10 +690,11 @@ async def get_value(self, href, resource, el, environ): # Use rfc1123 date (section 3.3.1 of RFC2616) - el.text = resource.get_last_modified().strftime("%a, %d %b %Y %H:%M:%S GMT") + el.text = resource.get_last_modified().strftime( + "%a, %d %b %Y %H:%M:%S GMT") -def format_datetime(dt): +def format_datetime(dt: datetime) -> bytes: s = "%04d%02d%02dT%02d%02d%02dZ" % ( dt.year, dt.month, @@ -748,14 +770,15 @@ in_allprops = False live = True - def __init__(self, get_current_user_principal): - super(CurrentUserPrincipalProperty, self).__init__() + def __init__(self, get_current_user_principal) -> None: + super().__init__() self.get_current_user_principal = get_current_user_principal async def get_value(self, href, resource, el, environ): """Get property with specified name. - :param name: A property name. + Args: + name: A property name. """ current_user_principal = self.get_current_user_principal(environ) if current_user_principal is None: @@ -764,7 +787,8 @@ current_user_principal = ensure_trailing_slash( current_user_principal.lstrip("/") ) - el.append(create_href(current_user_principal, environ["SCRIPT_NAME"])) + el.append(create_href( + current_user_principal, environ["SCRIPT_NAME"])) class PrincipalURLProperty(Property): @@ -777,11 +801,11 @@ async def get_value(self, href, resource, el, environ): """Get property with specified name. - :param name: A property name. + Args: + name: A property name. """ - el.append( - create_href(ensure_trailing_slash(resource.get_principal_url()), href) - ) + el.append(create_href( + ensure_trailing_slash(resource.get_principal_url()), href)) class SupportedReportSetProperty(Property): @@ -791,18 +815,19 @@ in_allprops = False live = True - def __init__(self, reporters): + def __init__(self, reporters) -> None: self._reporters = reporters async def get_value(self, href, resource, el, environ): for name, reporter in self._reporters.items(): if reporter.supported_on(resource): bel = ET.SubElement(el, "{DAV:}supported-report") - ET.SubElement(bel, name) + rel = ET.SubElement(bel, "{DAV:}report") + ET.SubElement(rel, name) class GetCTagProperty(Property): - """getctag property""" + """getctag property.""" name: str resource_type = COLLECTION_RESOURCE_TYPE @@ -814,13 +839,13 @@ class DAVGetCTagProperty(GetCTagProperty): - """getctag property""" + """getctag property.""" name = "{DAV:}getctag" class AppleGetCTagProperty(GetCTagProperty): - """getctag property""" + """getctag property.""" name = "{http://calendarserver.org/ns/}getctag" @@ -866,45 +891,54 @@ resource_types = Resource.resource_types + [COLLECTION_RESOURCE_TYPE] - def members(self): + def members(self) -> Iterable[tuple[str, Resource]]: """List all members. - :return: List of (name, Resource) tuples + Returns: List of (name, Resource) tuples """ raise NotImplementedError(self.members) - def get_member(self, name): + def get_member(self, name: str) -> Resource: """Retrieve a member by name. - :param name: Name of member to retrieve - :return: A Resource + Args; + name: Name of member to retrieve + Returns: + A Resource """ raise NotImplementedError(self.get_member) - def delete_member(self, name, etag=None): + def delete_member(self, name: str, etag: Optional[str] = None) -> None: """Delete a member with a specific name. - :param name: Member name - :param etag: Optional required etag - :raise KeyError: when the item doesn't exist + Args: + name: Member name + etag: Optional required etag + Raises: + KeyError: when the item doesn't exist """ raise NotImplementedError(self.delete_member) - def create_member(self, name, contents, content_type): + async def create_member( + self, name: str, contents: Iterable[bytes], + content_type: str) -> tuple[str, str]: """Create a new member with specified name and contents. - :param name: Member name (can be None) - :param contents: Chunked contents - :param etag: Optional required etag - :return: (name, etag) for the new member + Args: + name: Member name (can be None) + contents: Chunked contents + etag: Optional required etag + Returns: (name, etag) for the new member """ raise NotImplementedError(self.create_member) - def get_sync_token(self): + def get_sync_token(self) -> str: """Get sync-token for the current state of this collection.""" raise NotImplementedError(self.get_sync_token) - def iter_differences_since(self, old_token, new_token): + def iter_differences_since( + self, old_token: str, new_token: str) -> Iterator[ + tuple[str, Optional[Resource], Optional[Resource]]]: """Iterate over differences in this collection. Should return an iterator over (name, old resource, new resource) @@ -919,27 +953,28 @@ """ raise NotImplementedError(self.iter_differences_since) - def get_ctag(self): - raise NotImplementedError(self.getctag) + def get_ctag(self) -> str: + raise NotImplementedError(self.get_ctag) - def get_headervalue(self): + def get_headervalue(self) -> str: raise NotImplementedError(self.get_headervalue) - def destroy(self): + def destroy(self) -> None: """Destroy this collection itself.""" raise NotImplementedError(self.destroy) - def set_refreshrate(self, value): + def set_refreshrate(self, value: Optional[str]) -> None: """Set the recommended refresh rate for this collection. - :param value: Refresh rate (None to remove) + Args: + value: Refresh rate (None to remove) """ raise NotImplementedError(self.set_refreshrate) - def get_refreshrate(self): + def get_refreshrate(self) -> str: """Get the recommended refresh rate. - :return: Recommended refresh rate + Returns: Recommended refresh rate :raise KeyError: if there is no refresh rate set """ raise NotImplementedError(self.get_refreshrate) @@ -950,43 +985,43 @@ resource_Types = Resource.resource_types + [PRINCIPAL_RESOURCE_TYPE] - def get_principal_url(self): + def get_principal_url(self) -> str: """Return the principal URL for this principal. - :return: A URL identifying this principal. + Returns: A URL identifying this principal. """ raise NotImplementedError(self.get_principal_url) - def get_infit_settings(self): + def get_infit_settings(self) -> str: """Return inf-it settings string.""" raise NotImplementedError(self.get_infit_settings) - def set_infit_settings(self, settings): + def set_infit_settings(self, settings: Optional[str]) -> None: """Set inf-it settings string.""" raise NotImplementedError(self.get_infit_settings) - def get_group_membership(self): + def get_group_membership(self) -> list[str]: """Get group membership URLs.""" raise NotImplementedError(self.get_group_membership) - def get_calendar_proxy_read_for(self): + def get_calendar_proxy_read_for(self) -> list[str]: """List principals for which this one is a read proxy. - :return: List of principal hrefs + Returns: List of principal hrefs """ raise NotImplementedError(self.get_calendar_proxy_read_for) - def get_calendar_proxy_write_for(self): + def get_calendar_proxy_write_for(self) -> list[str]: """List principals for which this one is a write proxy. - :return: List of principal hrefs + Returns: List of principal hrefs """ raise NotImplementedError(self.get_calendar_proxy_write_for) - def get_schedule_inbox_url(self): + def get_schedule_inbox_url(self) -> str: raise NotImplementedError(self.get_schedule_inbox_url) - def get_schedule_outbox_url(self): + def get_schedule_outbox_url(self) -> str: raise NotImplementedError(self.get_schedule_outbox_url) @@ -995,12 +1030,13 @@ ): """Get a single property on a resource. - :param href: Resource href - :param resource: Resource object - :param properties: Dictionary of properties - :param environ: WSGI environ dict - :param name: name of property to resolve - :return: PropStatus items + Args: + href: Resource href + resource: Resource object + properties: Dictionary of properties + environ: WSGI environ dict + name: name of property to resolve + Returns: PropStatus items """ return await get_property_from_element( href, resource, properties, environ, ET.Element(name) @@ -1010,18 +1046,19 @@ async def get_property_from_element( href: str, resource: Resource, - properties: Dict[str, Property], + properties: dict[str, Property], environ, requested: ET.Element, ) -> PropStatus: """Get a single property on a resource. - :param href: Resource href - :param resource: Resource object - :param properties: Dictionary of properties - :param environ: WSGI environ dict - :param requested: Requested element - :return: PropStatus items + Args: + href: Resource href + resource: Resource object + properties: Dictionary of properties + environ: WSGI environ dict + requested: Requested element + Returns: PropStatus items """ responsedescription = None ret = ET.Element(requested.tag) @@ -1039,12 +1076,11 @@ try: if not prop.supported_on(resource): raise KeyError - try: - get_value_ext = prop.get_value_ext # type: ignore - except AttributeError: - await prop.get_value(href, resource, ret, environ) + if hasattr(prop, 'get_value_ext'): + await prop.get_value_ext( # type: ignore + href, resource, ret, environ, requested) else: - await get_value_ext(href, resource, ret, environ, requested) + await prop.get_value(href, resource, ret, environ) except KeyError: statuscode = "404 Not Found" except NotImplementedError: @@ -1062,18 +1098,19 @@ async def get_properties( href: str, resource: Resource, - properties: Dict[str, Property], + properties: dict[str, Property], environ, requested: ET.Element, ) -> AsyncIterable[PropStatus]: """Get a set of properties. - :param href: Resource Href - :param resource: Resource object - :param properties: Dictionary of properties - :param requested: XML {DAV:}prop element with properties to look up - :param environ: WSGI environ dict - :return: Iterator over PropStatus items + Args: + href: Resource Href + resource: Resource object + properties: Dictionary of properties + requested: XML {DAV:}prop element with properties to look up + environ: WSGI environ dict + Returns: Iterator over PropStatus items """ for propreq in list(requested): yield await get_property_from_element( @@ -1084,18 +1121,19 @@ async def get_property_names( href: str, resource: Resource, - properties: Dict[str, Property], + properties: dict[str, Property], environ, requested: ET.Element, ) -> AsyncIterable[PropStatus]: """Get a set of property names. - :param href: Resource Href - :param resource: Resource object - :param properties: Dictionary of properties - :param environ: WSGI environ dict - :param requested: XML {DAV:}prop element with properties to look up - :return: Iterator over PropStatus items + Args: + href: Resource Href + resource: Resource object + properties: Dictionary of properties + environ: WSGI environ dict + requested: XML {DAV:}prop element with properties to look up + Returns: Iterator over PropStatus items """ for name, prop in properties.items(): if await prop.is_set(href, resource, environ): @@ -1103,19 +1141,21 @@ async def get_all_properties( - href: str, resource: Resource, properties: Dict[str, Property], environ + href: str, resource: Resource, properties: dict[str, Property], environ ) -> AsyncIterable[PropStatus]: """Get all properties. - :param href: Resource Href - :param resource: Resource object - :param properties: Dictionary of properties - :param requested: XML {DAV:}prop element with properties to look up - :param environ: WSGI environ dict - :return: Iterator over PropStatus items + Args: + href: Resource Href + resource: Resource object + properties: Dictionary of properties + requested: XML {DAV:}prop element with properties to look up + environ: WSGI environ dict + Returns: Iterator over PropStatus items """ for name in properties: - ps = await get_property_from_name(href, resource, properties, name, environ) + ps = await get_property_from_name( + href, resource, properties, name, environ) if ps.statuscode == "200 OK": yield ps @@ -1125,8 +1165,9 @@ Useful for collection hrefs, e.g. when used with urljoin. - :param href: href to possibly add slash to - :return: href with trailing slash + Args: + href: href to possibly add slash to + Returns: href with trailing slash """ if href.endswith("/"): return href @@ -1137,16 +1178,18 @@ base_resource: Resource, base_href: str, depth: str, - members: Optional[Callable[[Collection], Iterable[Tuple[str, Resource]]]] = None, -) -> AsyncIterable[Tuple[str, Resource]]: + members: Optional[ + Callable[[Collection], Iterable[tuple[str, Resource]]]] = None, +) -> AsyncIterable[tuple[str, Resource]]: """Traverse a resource. - :param base_resource: Resource to traverse from - :param base_href: href for base resource - :param depth: Depth ("0", "1", "infinity") - :param members: Function to use to get members of each + Args: + base_resource: Resource to traverse from + base_href: href for base resource + depth: Depth ("0", "1", "infinity") + members: Function to use to get members of each collection. - :return: Iterator over (URL, Resource) tuples + Returns: Iterator over (URL, Resource) tuples """ if members is None: @@ -1172,52 +1215,58 @@ elif depth == "infinity": nextdepth = "infinity" else: - raise AssertionError("invalid depth %r" % depth) + raise AssertionError(f"invalid depth {depth!r}") if COLLECTION_RESOURCE_TYPE in resource.resource_types: for (child_name, child_resource) in members_fn(resource): child_href = urllib.parse.urljoin(href, child_name) todo.append((child_href, child_resource, nextdepth)) -class Reporter(object): +class Reporter: """Implementation for DAV REPORT requests.""" name: str - resource_type: Optional[Union[str, Tuple]] = None + resource_type: Optional[Union[str, tuple]] = None def supported_on(self, resource: Resource) -> bool: """Check if this reporter is available for the specified resource. - :param resource: Resource to check for - :return: boolean indicating whether this reporter is available + Args: + resource: Resource to check for + Returns: boolean indicating whether this reporter is available """ if self.resource_type is None: return True if isinstance(self.resource_type, tuple): - return any(rs in resource.resource_types for rs in self.resource_type) + return any( + rs in resource.resource_types for rs in self.resource_type) return self.resource_type in resource.resource_types async def report( self, - environ: Dict[str, str], + environ: dict[str, str], request_body: ET.Element, - resources_by_hrefs: Callable[[Iterable[str]], Iterable[Tuple[str, Resource]]], - properties: Dict[str, Property], + resources_by_hrefs: + Callable[[Iterable[str]], Iterable[tuple[str, Resource]]], + properties: dict[str, Property], href: str, resource: Resource, depth: str, + strict: bool ) -> Status: """Send a report. - :param environ: wsgi environ - :param request_body: XML Element for request body - :param resources_by_hrefs: Function for retrieving resource by HREF - :param properties: Dictionary mapping names to DAVProperty instances - :param href: Base resource href - :param resource: Resource to start from - :param depth: Depth ("0", "1", ...) - :return: a response + Args: + environ: wsgi environ + request_body: XML Element for request body + resources_by_hrefs: Function for retrieving resource by HREF + properties: Dictionary mapping names to DAVProperty instances + href: Base resource href + resource: Resource to start from + depth: Depth ("0", "1", ...) + strict: + Returns: a response """ raise NotImplementedError(self.report) @@ -1253,27 +1302,32 @@ async def _populate( self, prop_list: ET.Element, - resources_by_hrefs: Callable[[Iterable[str]], List[Tuple[str, Resource]]], - properties: Dict[str, Property], + resources_by_hrefs: + Callable[[Iterable[str]], list[tuple[str, Resource]]], + properties: dict[str, Property], href: str, resource: Resource, environ, + strict ) -> AsyncIterable[Status]: """Expand properties for a resource. - :param prop_list: DAV:property elements to retrieve and expand - :param resources_by_hrefs: Resolve resource by HREF - :param properties: Available properties - :param href: href for current resource - :param resource: current resource - :param environ: WSGI environ dict - :return: Status object + Args: + prop_list: DAV:property elements to retrieve and expand + resources_by_hrefs: Resolve resource by HREF + properties: Available properties + href: href for current resource + resource: current resource + environ: WSGI environ dict + Returns: Status object """ ret = [] for prop in prop_list: prop_name = prop.get("name") if prop_name is None: - logging.warning("Tag %s without name attribute", prop.tag) + nonfatal_bad_request( + f"Tag {prop.tag} without name attribute", + strict) continue # FIXME: Resolve prop_name on resource propstat = await get_property_from_name( @@ -1295,7 +1349,9 @@ else: child_href = read_href_element(prop_child) if child_href is None: - logging.warning("Tag %s without valid href", prop_child.tag) + nonfatal_bad_request( + f"Tag {prop_child.tag} without valid href", + strict) continue child_resource = dict(child_resources).get(child_href) if child_resource is None: @@ -1310,6 +1366,7 @@ child_href, child_resource, environ, + strict ): new_prop.append(response.aselement()) propstat = PropStatus( @@ -1330,6 +1387,7 @@ href, resource, depth, + strict ): async for resp in self._populate( request_body, @@ -1338,6 +1396,7 @@ href, resource, environ, + strict ): yield resp @@ -1408,13 +1467,14 @@ resource.set_comment(el.text) -class Backend(object): +class Backend: """WebDAV backend.""" def create_collection(self, relpath): """Create a collection with the specified relpath. - :param relpath: Collection path + Args: + relpath: Collection path """ raise NotImplementedError(self.create_collection) @@ -1425,10 +1485,11 @@ def _get_resources_by_hrefs(backend, environ, hrefs): """Retrieve multiple resources by href. - :param backend: backend from which to retrieve resources - :param environ: Environment dictionary - :param hrefs: List of hrefs to resolve - :return: iterator over (href, resource) tuples + Args: + backend: backend from which to retrieve resources + environ: Environment dictionary + hrefs: List of hrefs to resolve + Returns: iterator over (href, resource) tuples """ script_name = environ["SCRIPT_NAME"] # TODO(jelmer): Bulk query hrefs in a more efficient manner @@ -1436,7 +1497,7 @@ if not href.startswith(script_name): resource = None else: - path = href[len(script_name) :] + path = href[len(script_name):] if not path.startswith("/"): path = "/" + path resource = backend.get_resource(path) @@ -1444,7 +1505,7 @@ def _send_xml_response(status, et, out_encoding): - body_type = 'text/xml; charset="%s"' % out_encoding + body_type = f'text/xml; charset="{out_encoding}"' if os.environ.get("XANDIKOS_DUMP_DAV_XML"): print("OUT: " + ET.tostring(et).decode("utf-8")) body = ET.tostringlist(et, encoding=out_encoding) @@ -1502,19 +1563,21 @@ async def apply_modify_prop(el, href, resource, properties): """Apply property set/remove operations. - :param el: set element to apply. - :param href: Resource href - :param resource: Resource to apply property modifications on - :param properties: Known properties - :yield: PropStatus objects + Returns: + el: set element to apply. + href: Resource href + resource: Resource to apply property modifications on + properties: Known properties + Returns: PropStatus objects """ if el.tag not in ("{DAV:}set", "{DAV:}remove"): # callers should check tag raise AssertionError try: [requested] = el - except IndexError: - raise BadRequestError("Received more than one element in {DAV:}set element.") + except IndexError as exc: + raise BadRequestError( + "Received more than one element in {DAV:}set element.") from exc if requested.tag != "{DAV:}prop": raise BadRequestError("Expected prop tag, got " + requested.tag) for propel in requested: @@ -1564,14 +1627,15 @@ print("IN: " + body.decode("utf-8")) try: et = xmlparse(body) - except ET.ParseError: - raise BadRequestError("Unable to parse body.") + except ET.ParseError as exc: + raise BadRequestError("Unable to parse body.") from exc if expected_tag is not None and et.tag != expected_tag: - raise BadRequestError("Expected %s tag, got %s" % (expected_tag, et.tag)) + raise BadRequestError( + f"Expected {expected_tag} tag, got {et.tag}") return et -class Method(object): +class Method: @property def name(self): return type(self).__name__.upper()[:-6] @@ -1612,7 +1676,8 @@ return _send_method_not_allowed(app._get_allowed_methods(request)) content_type, params = parse_type(request.content_type) try: - (name, etag) = r.create_member(None, new_contents, content_type) + (name, etag) = await r.create_member( + None, new_contents, content_type) except PreconditionFailure as e: return _send_simple_dav_error( request, @@ -1647,7 +1712,9 @@ if r is not None: # Item already exists; update it try: - new_etag = r.set_body(new_contents, current_etag) + new_etag = await r.set_body(new_contents, current_etag) + except ResourceLocked: + return Response(status=423, reason="Resource Locked") except PreconditionFailure as e: return _send_simple_dav_error( request, @@ -1656,9 +1723,11 @@ description=e.description, ) except NotImplementedError: - return _send_method_not_allowed(app._get_allowed_methods(request)) + return _send_method_not_allowed( + app._get_allowed_methods(request)) else: - return Response(status="204 No Content", headers=[("ETag", new_etag)]) + return Response( + status="204 No Content", headers=[("ETag", new_etag)]) content_type = request.content_type container_path, name = posixpath.split(path) r = app.backend.get_resource(container_path) @@ -1667,7 +1736,8 @@ if COLLECTION_RESOURCE_TYPE not in r.resource_types: return _send_method_not_allowed(app._get_allowed_methods(request)) try: - (new_name, new_etag) = r.create_member(name, new_contents, content_type) + (new_name, new_etag) = await r.create_member( + name, new_contents, content_type) except PreconditionFailure as e: return _send_simple_dav_error( request, @@ -1679,13 +1749,15 @@ return Response(status=507, reason="Insufficient Storage") except ResourceLocked: return Response(status=423, reason="Resource Locked") - return Response(status=201, reason="Created", headers=[("ETag", new_etag)]) + return Response( + status=201, reason="Created", headers=[("ETag", new_etag)]) class ReportMethod(Method): async def handle(self, request, environ, app): # See https://tools.ietf.org/html/rfc3253, section 3.6 - base_href, unused_path, r = app._get_resource_from_environ(request, environ) + base_href, unused_path, r = app._get_resource_from_environ( + request, environ) if r is None: return _send_not_found(request) depth = request.headers.get("Depth", "0") @@ -1698,24 +1770,34 @@ request, "403 Forbidden", error=ET.Element("{DAV:}supported-report"), - description=("Unknown report %s." % et.tag), + description=f"Unknown report {et.tag}.", ) if not reporter.supported_on(r): return _send_simple_dav_error( request, "403 Forbidden", error=ET.Element("{DAV:}supported-report"), - description=("Report %s not supported on resource." % et.tag), + description=f"Report {et.tag} not supported on resource.", + ) + try: + return await reporter.report( + environ, + et, + functools.partial( + _get_resources_by_hrefs, app.backend, environ), + app.properties, + base_href, + r, + depth, + app.strict + ) + except PreconditionFailure as e: + return _send_simple_dav_error( + request, + "412 Precondition Failed", + error=ET.Element(e.precondition), + description=e.description, ) - return await reporter.report( - environ, - et, - functools.partial(_get_resources_by_hrefs, app.backend, environ), - app.properties, - base_href, - r, - depth, - ) class PropfindMethod(Method): @@ -1732,15 +1814,19 @@ if not request.can_read_body: requested = None else: - et = await _readXmlBody(request, "{DAV:}propfind", strict=app.strict) + et = await _readXmlBody( + request, "{DAV:}propfind", strict=app.strict) try: [requested] = et - except ValueError: - raise BadRequestError("Received more than one element in propfind.") - async for href, resource in traverse_resource(base_resource, base_href, depth): + except ValueError as exc: + raise BadRequestError( + "Received more than one element in propfind.") from exc + async for href, resource in traverse_resource( + base_resource, base_href, depth): propstat = [] if requested is None or requested.tag == "{DAV:}allprop": - propstat = get_all_properties(href, resource, app.properties, environ) + propstat = get_all_properties( + href, resource, app.properties, environ) elif requested.tag == "{DAV:}prop": propstat = get_properties( href, resource, app.properties, environ, requested @@ -1750,9 +1836,11 @@ href, resource, app.properties, environ, requested ) else: - raise BadRequestError( - "Expected prop/allprop/propname tag, got " + requested.tag + nonfatal_bad_request( + "Expected prop/allprop/propname tag, got " + requested.tag, + app.strict ) + continue yield Status(href, "200 OK", propstat=[s async for s in propstat]) # By my reading of the WebDAV RFC, it should be legal to return # '200 OK' here if Depth=0, but the RFC is not super clear and @@ -1762,15 +1850,19 @@ class ProppatchMethod(Method): @multistatus async def handle(self, request, environ, app): - href, unused_path, resource = app._get_resource_from_environ(request, environ) + href, unused_path, resource = app._get_resource_from_environ( + request, environ) if resource is None: yield Status(request.url, "404 Not Found") return - et = await _readXmlBody(request, "{DAV:}propertyupdate", strict=app.strict) + et = await _readXmlBody( + request, "{DAV:}propertyupdate", strict=app.strict) propstat = [] for el in et: if el.tag not in ("{DAV:}set", "{DAV:}remove"): - raise BadRequestError("Unknown tag %s in propertyupdate" % el.tag) + nonfatal_bad_request( + f"Unknown tag {el.tag} in propertyupdate", app.strict) + continue propstat.extend( [ ps @@ -1807,7 +1899,9 @@ propstat = [] for el in et: if el.tag != "{DAV:}set": - raise BadRequestError("Unknown tag %s in mkcol" % el.tag) + nonfatal_bad_request( + f"Unknown tag {el.tag} in mkcol", app.strict) + continue propstat.extend( [ ps @@ -1860,10 +1954,12 @@ async def _do_get(request, environ, app, send_body): - unused_href, unused_path, r = app._get_resource_from_environ(request, environ) + unused_href, unused_path, r = app._get_resource_from_environ( + request, environ) if r is None: return _send_not_found(request) - accept_content_types = parse_accept_header(request.headers.get("Accept", "*/*")) + accept_content_types = parse_accept_header( + request.headers.get("Accept", "*/*")) accept_content_languages = parse_accept_header( request.headers.get("Accept-Languages", "*") ) @@ -1874,7 +1970,8 @@ current_etag, content_type, content_languages, - ) = await r.render(request.path, accept_content_types, accept_content_languages) + ) = await r.render( + request.path, accept_content_types, accept_content_languages) if_none_match = request.headers.get("If-None-Match", None) if ( @@ -1904,17 +2001,19 @@ return Response(status=200, reason="OK", headers=headers) -class WSGIRequest(object): +class WSGIRequest: """Request object for wsgi requests (with environ).""" - def __init__(self, environ): + def __init__(self, environ) -> None: self._environ = environ self.method = environ["REQUEST_METHOD"] self.raw_path = environ["SCRIPT_NAME"] + environ["PATH_INFO"] - self.path = environ["SCRIPT_NAME"] + path_from_environ(environ, "PATH_INFO") - self.content_type = environ.get("CONTENT_TYPE", "application/octet-stream") + self.path = environ["SCRIPT_NAME"] + path_from_environ( + environ, "PATH_INFO") + self.content_type = environ.get( + "CONTENT_TYPE", "application/octet-stream") try: - self.content_length = int(environ["CONTENT_LENGTH"]) + self.content_length: Optional[int] = int(environ["CONTENT_LENGTH"]) except (KeyError, ValueError): self.content_length = None from multidict import CIMultiDict @@ -1924,8 +2023,8 @@ ) self.url = request_uri(environ) - class StreamWrapper(object): - def __init__(self, stream): + class StreamWrapper: + def __init__(self, stream) -> None: self._stream = stream async def read(self, size=None): @@ -1948,7 +2047,7 @@ return self._environ["wsgi.input"].read() -class WebDAVApp(object): +class WebDAVApp: """A wsgi App that provides a WebDAV server. A concrete implementation should provide an implementation of the @@ -1956,11 +2055,11 @@ (returning None for nonexistant objects). """ - def __init__(self, backend, strict=True): + def __init__(self, backend, strict=True) -> None: self.backend = backend - self.properties = {} - self.reporters = {} - self.methods = {} + self.properties: Dict[str, Type[Property]] = {} + self.reporters: Dict[str, Type[Reporter]] = {} + self.methods: Dict[str, Type[Method]] = {} self.strict = strict self.register_methods( [ @@ -2027,6 +2126,7 @@ try: return await do.handle(request, environ, self) except BadRequestError as e: + logging.debug('Bad request: %s', e.message) return Response( status="400 Bad Request", body=[e.message.encode(DEFAULT_ENCODING)], @@ -2040,7 +2140,7 @@ return Response( status="415 Unsupported Media Type", body=[ - ("Unsupported media type %r" % e.content_type).encode( + f"Unsupported media type {e.content_type!r}".encode( DEFAULT_ENCODING ) ], @@ -2062,7 +2162,8 @@ except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - response = loop.run_until_complete(self._handle_request(request, environ)) + response = loop.run_until_complete( + self._handle_request(request, environ)) return response.for_wsgi(start_response) async def aiohttp_handler(self, request, route_prefix="/"): diff -Nru xandikos-0.2.8/xandikos/web.py xandikos-0.2.10/xandikos/web.py --- xandikos-0.2.8/xandikos/web.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/web.py 2023-09-06 09:15:03.000000000 +0000 @@ -24,64 +24,75 @@ the carddav support, the caldav support and the DAV store. """ -from email.utils import parseaddr +import asyncio import functools import hashlib -import jinja2 import logging import os import posixpath -from typing import ( - List, - Tuple, - Iterable, - Iterator, - Optional, -) - import shutil +import socket import urllib.parse +from collections.abc import Iterable, Iterator +from email.utils import parseaddr +from typing import Optional + +import jinja2 + +try: + import systemd.daemon +except ImportError: + systemd_imported = False + + def get_systemd_listen_sockets() -> list[socket.socket]: + raise NotImplementedError +else: + systemd_imported = True + + def get_systemd_listen_sockets() -> list[socket.socket]: + socks = [] + for fd in systemd.daemon.listen_fds(): + for family in (socket.AF_UNIX, # type: ignore + socket.AF_INET, socket.AF_INET6): + if systemd.daemon.is_socket(fd, family=family, + type=socket.SOCK_STREAM, + listening=True): + sock = socket.fromfd(fd, family, socket.SOCK_STREAM) + socks.append(sock) + break + else: + raise RuntimeError( + "socket family must be AF_INET, AF_INET6, or AF_UNIX; " + "socket type must be SOCK_STREAM; and it must be listening" + ) + return socks from xandikos import __version__ as xandikos_version -from xandikos import ( - access, - apache, - caldav, - carddav, - quota, - sync, - webdav, - infit, - scheduling, - timezones, - xmpp, -) -from xandikos.icalendar import ( - ICalendarFile, - CalendarFilter, -) -from xandikos.store import ( - Store, - File, - DuplicateUidError, - InvalidFileContents, - NoSuchItem, - NotStoreError, - LockedError, - OutOfSpaceError, - STORE_TYPE_ADDRESSBOOK, - STORE_TYPE_CALENDAR, - STORE_TYPE_PRINCIPAL, - STORE_TYPE_SCHEDULE_INBOX, - STORE_TYPE_SCHEDULE_OUTBOX, - STORE_TYPE_SUBSCRIPTION, - STORE_TYPE_OTHER, -) -from xandikos.store.git import ( - GitStore, - TreeGitStore, -) -from xandikos.vcard import VCardFile +from xandikos import (access, apache, caldav, carddav, infit, quota, + scheduling, sync, timezones, webdav, xmpp) +from xandikos.store import (STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, + STORE_TYPE_OTHER, STORE_TYPE_PRINCIPAL, + STORE_TYPE_SCHEDULE_INBOX, + STORE_TYPE_SCHEDULE_OUTBOX, + STORE_TYPE_SUBSCRIPTION, DuplicateUidError, File, + InvalidCTag, InvalidFileContents, LockedError, + NoSuchItem, NotStoreError, OutOfSpaceError, Store) + +from .icalendar import CalendarFilter, ICalendarFile +from .store.git import GitStore, TreeGitStore +from .vcard import VCardFile + +try: + from asyncio import to_thread # type: ignore +except ImportError: # python < 3.8 + import contextvars + from asyncio import events + + async def to_thread(func, *args, **kwargs): # type: ignore + loop = events.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) WELLKNOWN_DAV_PATHS = { @@ -101,13 +112,14 @@ async def render_jinja_page( - name: str, accepted_content_languages: List[str], **kwargs -) -> Tuple[Iterable[bytes], int, Optional[str], str, List[str]]: + name: str, accepted_content_languages: list[str], **kwargs +) -> tuple[Iterable[bytes], int, Optional[str], str, list[str]]: """Render a HTML page from jinja template. - :param name: Name of the page - :param accepted_content_languages: List of accepted content languages - :return: Tuple of (body, content_length, etag, content_type, languages) + Args: + name: Name of the page + accepted_content_languages: List of accepted content languages + Returns: Tuple of (body, content_length, etag, content_type, languages) """ # TODO(jelmer): Support rendering other languages encoding = "utf-8" @@ -120,7 +132,7 @@ [body_encoded], len(body_encoded), None, - "text/html; encoding=%s" % encoding, + f"text/html; encoding={encoding}", ["en-UK"], ) @@ -128,8 +140,9 @@ def create_strong_etag(etag: str) -> str: """Create strong etags. - :param etag: basic etag - :return: A strong etag + Args: + etag: basic etag + Returns: A strong etag """ return '"' + etag + '"' @@ -151,7 +164,7 @@ content_type: str, etag: str, file: Optional[File] = None, - ): + ) -> None: self.store = store self.name = name self.etag = etag @@ -159,7 +172,7 @@ self._file = file def __repr__(self) -> str: - return "%s(%r, %r, %r, %r)" % ( + return "{}({!r}, {!r}, {!r}, {!r})".format( type(self).__name__, self.store, self.name, @@ -167,33 +180,37 @@ self.get_content_type(), ) - @property - def file(self) -> File: + async def get_file(self) -> File: if self._file is None: - self._file = self.store.get_file(self.name, self.content_type, self.etag) + self._file = await to_thread( + self.store.get_file, self.name, self.content_type, self.etag) + assert self._file is not None return self._file async def get_body(self) -> Iterable[bytes]: - return self.file.content + file = await self.get_file() + return file.content - def set_body(self, data, replace_etag=None): + async def set_body(self, data, replace_etag=None): try: - (name, etag) = self.store.import_one( + (name, etag) = await to_thread( + self.store.import_one, self.name, self.content_type, data, - replace_etag=extract_strong_etag(replace_etag), - ) - except InvalidFileContents as e: + replace_etag=extract_strong_etag(replace_etag)) + except InvalidFileContents as exc: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( "{%s}valid-calendar-data" % caldav.NAMESPACE, - "Not a valid calendar file: %s" % e.error, - ) - except DuplicateUidError: + f"Not a valid calendar file: {exc.error}", + ) from exc + except DuplicateUidError as exc: raise webdav.PreconditionFailure( "{%s}no-uid-conflict" % caldav.NAMESPACE, "UID already in use." - ) + ) from exc + except LockedError as exc: + raise webdav.ResourceLocked() from exc return create_strong_etag(etag) def get_content_language(self) -> str: @@ -248,14 +265,14 @@ raise KeyError -class StoreBasedCollection(object): - def __init__(self, backend, relpath, store): +class StoreBasedCollection: + def __init__(self, backend, relpath, store) -> None: self.backend = backend self.relpath = relpath self.store = store - def __repr__(self): - return "%s(%r)" % (type(self).__name__, self.store) + def __repr__(self) -> str: + return f"{type(self).__name__}({self.store!r})" def set_resource_types(self, resource_types): # TODO(jelmer): Allow more than just this set; allow combining @@ -323,7 +340,7 @@ async def get_etag(self) -> str: return create_strong_etag(self.store.get_ctag()) - def members(self) -> Iterator[Tuple[str, webdav.Resource]]: + def members(self) -> Iterator[tuple[str, webdav.Resource]]: for (name, content_type, etag) in self.store.iter_with_etag(): resource = self._get_resource(name, content_type, etag) yield (name, resource) @@ -352,47 +369,53 @@ # self.get_subcollection(name).destroy() shutil.rmtree(os.path.join(self.store.path, name)) - def create_member( + async def create_member( self, name: str, contents: Iterable[bytes], content_type: str - ) -> Tuple[str, str]: + ) -> tuple[str, str]: try: (name, etag) = self.store.import_one(name, content_type, contents) - except InvalidFileContents as e: + except InvalidFileContents as exc: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( "{%s}valid-calendar-data" % caldav.NAMESPACE, - "Not a valid calendar file: %s" % e.error, - ) - except DuplicateUidError: + f"Not a valid calendar file: {exc.error}", + ) from exc + except DuplicateUidError as exc: raise webdav.PreconditionFailure( "{%s}no-uid-conflict" % caldav.NAMESPACE, "UID already in use." - ) - except OutOfSpaceError: - raise webdav.InsufficientStorage() - except LockedError: - raise webdav.ResourceLocked() + ) from exc + except OutOfSpaceError as exc: + raise webdav.InsufficientStorage() from exc + except LockedError as exc: + raise webdav.ResourceLocked() from exc return (name, create_strong_etag(etag)) def iter_differences_since( self, old_token: str, new_token: str - ) -> Iterator[Tuple[str, Optional[webdav.Resource], Optional[webdav.Resource]]]: + ) -> Iterator[ + tuple[str, Optional[webdav.Resource], Optional[webdav.Resource]]]: old_resource: Optional[webdav.Resource] new_resource: Optional[webdav.Resource] - for ( - name, - content_type, - old_etag, - new_etag, - ) in self.store.iter_changes(old_token, new_token): - if old_etag is not None: - old_resource = self._get_resource(name, content_type, old_etag) - else: - old_resource = None - if new_etag is not None: - new_resource = self._get_resource(name, content_type, new_etag) - else: - new_resource = None - yield (name, old_resource, new_resource) + try: + for ( + name, + content_type, + old_etag, + new_etag, + ) in self.store.iter_changes(old_token, new_token): + if old_etag is not None: + old_resource = self._get_resource( + name, content_type, old_etag) + else: + old_resource = None + if new_etag is not None: + new_resource = self._get_resource( + name, content_type, new_etag) + else: + new_resource = None + yield (name, old_resource, new_resource) + except InvalidCTag as exc: + raise sync.InvalidToken(exc.ctag) from exc def get_owner(self): return None @@ -439,7 +462,8 @@ async def render( self, self_url, accepted_content_types, accepted_content_languages ): - content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) + content_types = webdav.pick_content_types( + accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return await render_jinja_page( "collection.html", @@ -580,7 +604,8 @@ def calendar_query(self, create_filter_fn): filter = create_filter_fn(CalendarFilter) for (name, file, etag) in self.store.iter_with_filter(filter=filter): - resource = self._get_resource(name, file.content_type, etag, file=file) + resource = self._get_resource( + name, file.content_type, etag, file=file) yield (name, resource) def get_xmpp_heartbeat(self): @@ -629,7 +654,7 @@ class CollectionSetResource(webdav.Collection): """Resource for calendar sets.""" - def __init__(self, backend, relpath): + def __init__(self, backend, relpath) -> None: self.backend = backend self.relpath = relpath @@ -712,7 +737,8 @@ async def render( self, self_url, accepted_content_types, accepted_content_languages ): - content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) + content_types = webdav.pick_content_types( + accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return await render_jinja_page( "root.html", accepted_content_languages, self_url=self_url @@ -737,13 +763,15 @@ class RootPage(webdav.Resource): """A non-DAV resource.""" - resource_types: List[str] = [] + resource_types: list[str] = [] - def __init__(self, backend): + def __init__(self, backend) -> None: self.backend = backend - def render(self, self_url, accepted_content_types, accepted_content_languages): - content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) + def render(self, self_url, accepted_content_types, + accepted_content_languages): + content_types = webdav.pick_content_types( + accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return render_jinja_page( "root.html", @@ -833,7 +861,7 @@ p = self.backend._map_to_file_path(relpath) if not os.path.exists(p): raise KeyError - with open(p, "r") as f: + with open(p) as f: return f.read() def get_group_membership(self): @@ -873,7 +901,7 @@ @classmethod def create(cls, backend, relpath): - p = super(PrincipalBare, cls).create(backend, relpath) + p = super().create(backend, relpath) to_create = set() to_create.update(p.get_addressbook_home_set()) to_create.update(p.get_calendar_home_set()) @@ -887,7 +915,8 @@ async def render( self, self_url, accepted_content_types, accepted_content_languages ): - content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) + content_types = webdav.pick_content_types( + accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return await render_jinja_page( "principal.html", @@ -904,11 +933,12 @@ class PrincipalCollection(Collection, Principal): """Principal user resource.""" - resource_types = webdav.Collection.resource_types + [webdav.PRINCIPAL_RESOURCE_TYPE] + resource_types = webdav.Collection.resource_types + [ + webdav.PRINCIPAL_RESOURCE_TYPE] @classmethod def create(cls, backend, relpath): - p = super(PrincipalCollection, cls).create(backend, relpath) + p = super().create(backend, relpath) p.store.set_type(STORE_TYPE_PRINCIPAL) to_create = set() to_create.update(p.get_addressbook_home_set()) @@ -922,17 +952,20 @@ @functools.lru_cache(maxsize=STORE_CACHE_SIZE) -def open_store_from_path(path): - store = GitStore.open_from_path(path) +def open_store_from_path(path: str, **kwargs): + store = GitStore.open_from_path(path, **kwargs) store.load_extra_file_handler(ICalendarFile) store.load_extra_file_handler(VCardFile) return store class XandikosBackend(webdav.Backend): - def __init__(self, path): + def __init__(self, path, *, paranoid: bool = False, + index_threshold: Optional[int] = None) -> None: self.path = path - self._user_principals = set() + self._user_principals: set[str] = set() + self.paranoid = paranoid + self.index_threshold = index_threshold def _map_to_file_path(self, relpath): return os.path.join(self.path, relpath.lstrip("/")) @@ -965,7 +998,9 @@ return None if os.path.isdir(p): try: - store = open_store_from_path(p) + store = open_store_from_path( + p, double_check_indexes=self.paranoid, + index_threshold=self.index_threshold) except NotStoreError: if relpath in self._user_principals: return PrincipalBare(self, relpath) @@ -982,7 +1017,7 @@ }[store.get_type()](self, relpath, store) else: (basepath, name) = os.path.split(relpath) - assert name != "", "path is %r" % relpath + assert name != "", f"path is {relpath!r}" store = self.get_resource(basepath) if store is None: return None @@ -997,8 +1032,8 @@ class XandikosApp(webdav.WebDAVApp): """A wsgi App that provides a Xandikos web server.""" - def __init__(self, backend, current_user_principal, strict=True): - super(XandikosApp, self).__init__(backend, strict=strict) + def __init__(self, backend, current_user_principal, strict=True) -> None: + super().__init__(backend, strict=strict) def get_current_user_principal(env): try: @@ -1009,7 +1044,8 @@ self.register_properties( [ webdav.ResourceTypeProperty(), - webdav.CurrentUserPrincipalProperty(get_current_user_principal), + webdav.CurrentUserPrincipalProperty( + get_current_user_principal), webdav.PrincipalURLProperty(), webdav.DisplayNameProperty(), webdav.GetETagProperty(), @@ -1094,8 +1130,9 @@ def create_principal_defaults(backend, principal): """Create default calendar and addressbook for a principal. - :param backend: Backend in which the principal exists. - :param principal: Principal object + Args: + backend: Backend in which the principal exists. + principal: Principal object """ calendar_path = posixpath.join( principal.relpath, principal.get_calendar_home_set()[0], "calendar" @@ -1131,8 +1168,8 @@ logging.info("Create inbox in %s.", resource.store.path) -class RedirectDavHandler(object): - def __init__(self, dav_root: str): +class RedirectDavHandler: + def __init__(self, dav_root: str) -> None: self._dav_root = dav_root async def __call__(self, request): @@ -1169,7 +1206,7 @@ "", "", port, - avahi.string_array_to_txt_array(["path=%s" % path]), + avahi.string_array_to_txt_array([f"path={path}"]), ) except dbus.DBusException as e: logging.error("Error registering %s: %s", service, e) @@ -1177,14 +1214,100 @@ group.Commit() -def main(argv): +def run_simple_server( + directory: str, + current_user_principal: str, + autocreate: bool = False, + defaults: bool = False, + strict: bool = True, + route_prefix: str = "/", + listen_address: Optional[str] = "::", + port: Optional[int] = 8080, + socket_path: Optional[str] = None) -> None: + """Simple function to run a Xandikos server. + + This function is meant to be used by external code. We'll try our best + not to break API compatibility. + + Args: + directory: Directory to store data in ("/tmp/blah") + current_user_principal: Name of current user principal ("/user") + autocreate: Whether to create missing principals and collections + defaults: Whether to create default calendar and addressbook collections + strict: Whether to be strict in *DAV implementation. Set to False for + buggy clients + route_prefix: Route prefix under which to server ("/") + listen_address: IP address to listen on (None to disable) + port: TCP Port to listen on (None to disable) + socket_path: Unix domain socket path to listen on (None to disable) + """ + backend = XandikosBackend(directory) + backend._mark_as_principal(current_user_principal) + + if autocreate or defaults: + if not os.path.isdir(directory): + os.makedirs(directory) + backend.create_principal( + current_user_principal, create_defaults=defaults + ) + + if not os.path.isdir(directory): + logging.warning( + "%r does not exist. Run xandikos with --autocreate?", + directory, + ) + if not backend.get_resource(current_user_principal): + logging.warning( + "default user principal %s does not exist. " + "Run xandikos with --autocreate?", + current_user_principal, + ) + + main_app = XandikosApp( + backend, + current_user_principal=current_user_principal, + strict=strict, + ) + + async def xandikos_handler(request): + return await main_app.aiohttp_handler(request, route_prefix) + + if socket_path: + logging.info("Listening on unix domain socket %s", socket_path) + if listen_address and port: + logging.info("Listening on %s:%s", listen_address, port) + + from aiohttp import web + + app = web.Application() + for path in WELLKNOWN_DAV_PATHS: + app.router.add_route( + "*", path, RedirectDavHandler(route_prefix).__call__ + ) + + if route_prefix.strip("/"): + xandikos_app = web.Application() + xandikos_app.router.add_route("*", "/{path_info:.*}", xandikos_handler) + + async def redirect_to_subprefix(request): + return web.HTTPFound(route_prefix) + + app.router.add_route("*", "/", redirect_to_subprefix) + app.add_subapp(route_prefix, xandikos_app) + else: + app.router.add_route("*", "/{path_info:.*}", xandikos_handler) + + web.run_app(app, port=port, host=listen_address, path=socket_path) + + +async def main(argv=None): # noqa: C901 import argparse import sys + from xandikos import __version__ parser = argparse.ArgumentParser( - usage="%(prog)s -d ROOT-DIR [OPTIONS]", prog=argv[0] - ) + usage="%(prog)s -d ROOT-DIR [OPTIONS]") parser.add_argument( "--version", @@ -1194,6 +1317,13 @@ access_group = parser.add_argument_group(title="Access Options") access_group.add_argument( + "--no-detect-systemd", + action="store_false", + dest="detect_systemd", + help="Disable systemd detection and socket activation.", + default=systemd_imported + ) + access_group.add_argument( "-l", "--listen-address", dest="listen_address", @@ -1212,6 +1342,11 @@ help="Port to listen on. [%(default)s]", ) access_group.add_argument( + "--metrics-port", + dest="metrics_port", + default=8081, + help="Port to listen on for metrics. [%(default)s]") + access_group.add_argument( "--route-prefix", default="/", help=( @@ -1242,7 +1377,8 @@ "--defaults", action="store_true", dest="defaults", - help=("Create initial calendar and address book. " "Implies --autocreate."), + help=("Create initial calendar and address book. " + "Implies --autocreate."), ) parser.add_argument( "--dump-dav-xml", @@ -1257,10 +1393,19 @@ "--no-strict", action="store_false", dest="strict", - help="Enable workarounds for buggy CalDAV/CardDAV client " "implementations.", + help=("Enable workarounds for buggy CalDAV/CardDAV client " + "implementations."), default=True, ) - options = parser.parse_args(argv[1:]) + parser.add_argument( + '--debug', action='store_true', + help='Print debug messages') + # Hidden arguments. These may change without notice in between releases, + # and are generally just meant for developers. + parser.add_argument('--paranoid', action='store_true', + help=argparse.SUPPRESS) + parser.add_argument('--index-threshold', type=int, help=argparse.SUPPRESS) + options = parser.parse_args(argv) if options.directory is None: parser.print_usage() @@ -1271,9 +1416,19 @@ # os.environ. os.environ["XANDIKOS_DUMP_DAV_XML"] = "1" - logging.basicConfig(level=logging.INFO) + if not options.route_prefix.endswith('/'): + options.route_prefix += '/' - backend = XandikosBackend(options.directory) + if options.debug: + loglevel = logging.DEBUG + else: + loglevel = logging.INFO + + logging.basicConfig(level=loglevel, format='%(message)s') + + backend = XandikosBackend( + os.path.abspath(options.directory), paranoid=options.paranoid, + index_threshold=options.index_threshold) backend._mark_as_principal(options.current_user_principal) if options.autocreate or options.defaults: @@ -1304,29 +1459,50 @@ async def xandikos_handler(request): return await main_app.aiohttp_handler(request, options.route_prefix) - if "/" in options.listen_address: + if options.detect_systemd and not systemd_imported: + parser.error( + 'systemd detection requested, but unable to find systemd_python') + + if options.detect_systemd and systemd.daemon.booted(): + listen_socks = get_systemd_listen_sockets() + socket_path = None + listen_address = None + listen_port = None + logging.info( + "Receiving file descriptors from systemd socket activation") + elif "/" in options.listen_address: socket_path = options.listen_address listen_address = None - listen_port = None # otherwise aiohttp also listens on its default host + listen_port = None # otherwise aiohttp also listens on default host + listen_socks = [] logging.info("Listening on unix domain socket %s", socket_path) else: listen_address = options.listen_address listen_port = options.port socket_path = None + listen_socks = [] logging.info("Listening on %s:%s", listen_address, options.port) from aiohttp import web app = web.Application() - try: - from aiohttp_openmetrics import setup_metrics - except ModuleNotFoundError: - logging.warning("aiohttp-openmetrics not found; /metrics will not be available.") - else: - setup_metrics(app) + if options.metrics_port: + metrics_app = web.Application() + try: + from aiohttp_openmetrics import metrics, metrics_middleware + except ModuleNotFoundError: + logging.warning( + "aiohttp-openmetrics not found; " + "/metrics will not be available.") + else: + app.middlewares.insert(0, metrics_middleware) + metrics_app.router.add_get("/metrics", metrics, name="metrics") - # For now, just always claim everything is okay. - app.router.add_get("/health", lambda r: web.Response(text='ok')) + # For now, just always claim everything is okay. + metrics_app.router.add_get( + "/health", lambda r: web.Response(text='ok')) + else: + metrics_app = None for path in WELLKNOWN_DAV_PATHS: app.router.add_route( @@ -1351,15 +1527,42 @@ import dbus # noqa: F401 except ImportError: logging.error( - "Please install python-avahi and python-dbus for " "avahi support." + "Please install python-avahi and python-dbus for " + "avahi support." ) else: avahi_register(options.port, options.route_prefix) - web.run_app(app, port=listen_port, host=listen_address, path=socket_path) + runner = web.AppRunner(app) + await runner.setup() + sites = [] + if metrics_app: + metrics_runner = web.AppRunner(metrics_app) + await metrics_runner.setup() + # TODO(jelmer): Allow different metrics listen addres? + sites.append(web.TCPSite(metrics_runner, listen_address, + options.metrics_port)) + if listen_socks: + sites.extend([web.SockSite(runner, sock) for sock in listen_socks]) + if socket_path: + sites.append(web.UnixSite(runner, socket_path)) + else: + sites.append(web.TCPSite(runner, listen_address, listen_port)) + + import signal + + # Set SIGINT to default handler; this appears to be necessary + # when running under coverage. + signal.signal(signal.SIGINT, signal.SIG_DFL) + + for site in sites: + await site.start() + + while True: + await asyncio.sleep(3600) if __name__ == "__main__": import sys - main(sys.argv) + sys.exit(asyncio.run(main(sys.argv[1:]))) diff -Nru xandikos-0.2.8/xandikos/wsgi_helpers.py xandikos-0.2.10/xandikos/wsgi_helpers.py --- xandikos-0.2.8/xandikos/wsgi_helpers.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/wsgi_helpers.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,24 +17,24 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""WSGI wrapper for xandikos. -""" +"""WSGI wrapper for xandikos.""" import posixpath from .web import WELLKNOWN_DAV_PATHS -class WellknownRedirector(object): +class WellknownRedirector: """Redirect paths under .well-known/ to the appropriate paths.""" - def __init__(self, inner_app, dav_root): + def __init__(self, inner_app, dav_root) -> None: self._inner_app = inner_app self._dav_root = dav_root def __call__(self, environ, start_response): # See https://tools.ietf.org/html/rfc6764 - path = posixpath.normpath(environ["SCRIPT_NAME"] + environ["PATH_INFO"]) + path = posixpath.normpath( + environ["SCRIPT_NAME"] + environ["PATH_INFO"]) if path in WELLKNOWN_DAV_PATHS: start_response("302 Found", [("Location", self._dav_root)]) return [] diff -Nru xandikos-0.2.8/xandikos/wsgi.py xandikos-0.2.10/xandikos/wsgi.py --- xandikos-0.2.8/xandikos/wsgi.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/wsgi.py 2023-09-06 09:15:03.000000000 +0000 @@ -17,17 +17,12 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -"""WSGI wrapper for xandikos. -""" +"""WSGI wrapper for xandikos.""" import logging import os -from .web import ( - XandikosBackend, - XandikosApp, -) - +from .web import XandikosApp, XandikosBackend backend = XandikosBackend(path=os.environ["XANDIKOSPATH"]) if not os.path.isdir(backend.path): diff -Nru xandikos-0.2.8/xandikos/xmpp.py xandikos-0.2.10/xandikos/xmpp.py --- xandikos-0.2.8/xandikos/xmpp.py 2022-01-09 12:07:05.000000000 +0000 +++ xandikos-0.2.10/xandikos/xmpp.py 2023-09-06 09:15:03.000000000 +0000 @@ -25,6 +25,7 @@ from . import webdav from .caldav import CALENDAR_RESOURCE_TYPE + ET = webdav.ET