Browse Source

More type hints

Unrud 4 years ago
parent
commit
cecb17df03
51 changed files with 1377 additions and 960 deletions
  1. 29 27
      radicale/__init__.py
  2. 28 24
      radicale/__main__.py
  3. 91 166
      radicale/app/__init__.py
  4. 131 0
      radicale/app/base.py
  5. 17 10
      radicale/app/delete.py
  6. 15 13
      radicale/app/get.py
  7. 8 2
      radicale/app/head.py
  8. 15 14
      radicale/app/mkcalendar.py
  9. 12 11
      radicale/app/mkcol.py
  10. 17 12
      radicale/app/move.py
  11. 6 3
      radicale/app/options.py
  12. 6 3
      radicale/app/post.py
  13. 70 57
      radicale/app/propfind.py
  14. 20 18
      radicale/app/proppatch.py
  15. 43 29
      radicale/app/put.py
  16. 115 101
      radicale/app/report.py
  17. 13 7
      radicale/auth/__init__.py
  18. 13 8
      radicale/auth/htpasswd.py
  19. 7 2
      radicale/auth/http_x_remote_user.py
  20. 2 1
      radicale/auth/none.py
  21. 7 2
      radicale/auth/remote_user.py
  22. 63 43
      radicale/config.py
  23. 27 23
      radicale/httputils.py
  24. 87 49
      radicale/item/__init__.py
  25. 64 48
      radicale/item/filter.py
  26. 30 24
      radicale/log.py
  27. 44 39
      radicale/pathutils.py
  28. 12 7
      radicale/rights/__init__.py
  29. 5 4
      radicale/rights/authenticated.py
  30. 8 6
      radicale/rights/from_file.py
  31. 2 1
      radicale/rights/owner_only.py
  32. 2 1
      radicale/rights/owner_write.py
  33. 115 63
      radicale/server.py
  34. 84 53
      radicale/storage/__init__.py
  35. 5 8
      radicale/storage/multifilesystem/cache.py
  36. 5 4
      radicale/storage/multifilesystem/get.py
  37. 7 11
      radicale/storage/multifilesystem/history.py
  38. 3 2
      radicale/storage/multifilesystem/lock.py
  39. 6 5
      radicale/storage/multifilesystem/meta.py
  40. 6 8
      radicale/storage/multifilesystem/sync.py
  41. 4 3
      radicale/storage/multifilesystem/upload.py
  42. 2 2
      radicale/storage/multifilesystem/verify.py
  43. 1 1
      radicale/tests/__init__.py
  44. 2 1
      radicale/tests/test_auth.py
  45. 10 10
      radicale/tests/test_base.py
  46. 61 0
      radicale/types.py
  47. 8 2
      radicale/utils.py
  48. 15 7
      radicale/web/__init__.py
  49. 10 5
      radicale/web/internal.py
  50. 4 2
      radicale/web/none.py
  51. 20 18
      radicale/xmlutils.py

+ 29 - 27
radicale/__init__.py

@@ -27,46 +27,48 @@ Configuration files can be specified in the environment variable
 
 import os
 import threading
+from typing import Iterable, Optional, cast
 
 import pkg_resources
 
-from radicale import config, log
+from radicale import config, log, types
 from radicale.app import Application
 from radicale.log import logger
 
-VERSION = pkg_resources.get_distribution("radicale").version
+VERSION: str = pkg_resources.get_distribution("radicale").version
 
-_application = None
-_application_config_path = None
+_application_instance: Optional[Application] = None
+_application_config_path: Optional[str] = None
 _application_lock = threading.Lock()
 
 
-def _init_application(config_path, wsgi_errors):
-    global _application, _application_config_path
+def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream
+                              ) -> Application:
+    global _application_instance, _application_config_path
     with _application_lock:
-        if _application is not None:
-            return
-        log.setup()
-        with log.register_stream(wsgi_errors):
-            _application_config_path = config_path
-            configuration = config.load(config.parse_compound_paths(
-                config.DEFAULT_CONFIG_PATH,
-                config_path))
-            log.set_level(configuration.get("logging", "level"))
-            # Log configuration after logger is configured
-            for source, miss in configuration.sources():
-                logger.info("%s %s", "Skipped missing" if miss else "Loaded",
-                            source)
-            _application = Application(configuration)
+        if _application_instance is None:
+            log.setup()
+            with log.register_stream(wsgi_errors):
+                _application_config_path = config_path
+                configuration = config.load(config.parse_compound_paths(
+                    config.DEFAULT_CONFIG_PATH,
+                    config_path))
+                log.set_level(cast(str, configuration.get("logging", "level")))
+                # Log configuration after logger is configured
+                for source, miss in configuration.sources():
+                    logger.info("%s %s", "Skipped missing" if miss
+                                else "Loaded", source)
+                _application_instance = Application(configuration)
+    if _application_config_path != config_path:
+        raise ValueError("RADICALE_CONFIG must not change: %r != %r" %
+                         (config_path, _application_config_path))
+    return _application_instance
 
 
-def application(environ, start_response):
+def application(environ: types.WSGIEnviron,
+                start_response: types.WSGIStartResponse) -> Iterable[bytes]:
     """Entry point for external WSGI servers."""
     config_path = environ.get("RADICALE_CONFIG",
                               os.environ.get("RADICALE_CONFIG"))
-    if _application is None:
-        _init_application(config_path, environ["wsgi.errors"])
-    if _application_config_path != config_path:
-        raise ValueError("RADICALE_CONFIG must not change: %s != %s" %
-                         (repr(config_path), repr(_application_config_path)))
-    return _application(environ, start_response)
+    app = _get_application_instance(config_path, environ["wsgi.errors"])
+    return app(environ, start_response)

+ 28 - 24
radicale/__main__.py

@@ -29,24 +29,27 @@ import os
 import signal
 import socket
 import sys
+from types import FrameType
+from typing import Dict, List, cast
 
 from radicale import VERSION, config, log, server, storage
 from radicale.log import logger
 
 
-def run():
+def run() -> None:
     """Run Radicale as a standalone server."""
     exit_signal_numbers = [signal.SIGTERM, signal.SIGINT]
     if os.name == "posix":
         exit_signal_numbers.append(signal.SIGHUP)
         exit_signal_numbers.append(signal.SIGQUIT)
-    elif os.name == "nt":
+    if sys.platform == "win32":
         exit_signal_numbers.append(signal.SIGBREAK)
 
     # Raise SystemExit when signal arrives to run cleanup code
     # (like destructors, try-finish etc.), otherwise the process exits
     # without running any of them
-    def exit_signal_handler(signal_number, stack_frame):
+    def exit_signal_handler(signal_number: "signal.Signals",
+                            stack_frame: FrameType) -> None:
         sys.exit(1)
     for signal_number in exit_signal_numbers:
         signal.signal(signal_number, exit_signal_handler)
@@ -60,12 +63,12 @@ def run():
     parser.add_argument("--version", action="version", version=VERSION)
     parser.add_argument("--verify-storage", action="store_true",
                         help="check the storage for errors and exit")
-    parser.add_argument(
-        "-C", "--config", help="use specific configuration files", nargs="*")
+    parser.add_argument("-C", "--config",
+                        help="use specific configuration files", nargs="*")
     parser.add_argument("-D", "--debug", action="store_true",
                         help="print debug information")
 
-    groups = {}
+    groups: Dict["argparse._ArgumentGroup", List[str]] = {}
     for section, values in config.DEFAULT_CONFIG_SCHEMA.items():
         if section.startswith("_"):
             continue
@@ -76,7 +79,7 @@ def run():
                 continue
             kwargs = data.copy()
             long_name = "--%s-%s" % (section, option.replace("_", "-"))
-            args = list(kwargs.pop("aliases", ()))
+            args: List[str] = list(kwargs.pop("aliases", ()))
             args.append(long_name)
             kwargs["dest"] = "%s_%s" % (section, option)
             groups[group].append(kwargs["dest"])
@@ -100,22 +103,22 @@ def run():
                 del kwargs["type"]
                 group.add_argument(*args, **kwargs)
 
-    args = parser.parse_args()
+    args_ns = parser.parse_args()
 
     # Preliminary configure logging
-    if args.debug:
-        args.logging_level = "debug"
+    if args_ns.debug:
+        args_ns.logging_level = "debug"
     with contextlib.suppress(ValueError):
         log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"](
-            args.logging_level))
+            args_ns.logging_level))
 
     # Update Radicale configuration according to arguments
     arguments_config = {}
     for group, actions in groups.items():
-        section = group.title
+        section = group.title or ""
         section_config = {}
         for action in actions:
-            value = getattr(args, action)
+            value = getattr(args_ns, action)
             if value is not None:
                 section_config[action.split('_', 1)[1]] = value
         if section_config:
@@ -125,31 +128,31 @@ def run():
         configuration = config.load(config.parse_compound_paths(
             config.DEFAULT_CONFIG_PATH,
             os.environ.get("RADICALE_CONFIG"),
-            os.pathsep.join(args.config) if args.config else None))
+            os.pathsep.join(args_ns.config) if args_ns.config else None))
         if arguments_config:
-            configuration.update(arguments_config, "arguments")
+            configuration.update(arguments_config, "command line arguments")
     except Exception as e:
-        logger.fatal("Invalid configuration: %s", e, exc_info=True)
+        logger.critical("Invalid configuration: %s", e, exc_info=True)
         sys.exit(1)
 
     # Configure logging
-    log.set_level(configuration.get("logging", "level"))
+    log.set_level(cast(str, configuration.get("logging", "level")))
 
     # Log configuration after logger is configured
     for source, miss in configuration.sources():
         logger.info("%s %s", "Skipped missing" if miss else "Loaded", source)
 
-    if args.verify_storage:
+    if args_ns.verify_storage:
         logger.info("Verifying storage")
         try:
             storage_ = storage.load(configuration)
             with storage_.acquire_lock("r"):
                 if not storage_.verify():
-                    logger.fatal("Storage verifcation failed")
+                    logger.critical("Storage verifcation failed")
                     sys.exit(1)
         except Exception as e:
-            logger.fatal("An exception occurred during storage verification: "
-                         "%s", e, exc_info=True)
+            logger.critical("An exception occurred during storage "
+                            "verification: %s", e, exc_info=True)
             sys.exit(1)
         return
 
@@ -157,7 +160,8 @@ def run():
     shutdown_socket, shutdown_socket_out = socket.socketpair()
 
     # Shutdown server when signal arrives
-    def shutdown_signal_handler(signal_number, stack_frame):
+    def shutdown_signal_handler(signal_number: "signal.Signals",
+                                stack_frame: FrameType) -> None:
         shutdown_socket.close()
     for signal_number in exit_signal_numbers:
         signal.signal(signal_number, shutdown_signal_handler)
@@ -165,8 +169,8 @@ def run():
     try:
         server.serve(configuration, shutdown_socket_out)
     except Exception as e:
-        logger.fatal("An exception occurred during server startup: %s", e,
-                     exc_info=True)
+        logger.critical("An exception occurred during server startup: %s", e,
+                        exc_info=True)
         sys.exit(1)
 
 

+ 91 - 166
radicale/app/__init__.py

@@ -27,53 +27,53 @@ the built-in server (see ``radicale.server`` module).
 
 import base64
 import datetime
-import io
-import logging
-import posixpath
 import pprint
 import random
-import sys
 import time
-import xml.etree.ElementTree as ET
 import zlib
 from http import client
+from typing import Iterable, List, Mapping, Tuple, Union
 
 import pkg_resources
 
-from radicale import (auth, httputils, log, pathutils, rights, storage, web,
-                      xmlutils)
-from radicale.app.delete import ApplicationDeleteMixin
-from radicale.app.get import ApplicationGetMixin
-from radicale.app.head import ApplicationHeadMixin
-from radicale.app.mkcalendar import ApplicationMkcalendarMixin
-from radicale.app.mkcol import ApplicationMkcolMixin
-from radicale.app.move import ApplicationMoveMixin
-from radicale.app.options import ApplicationOptionsMixin
-from radicale.app.post import ApplicationPostMixin
-from radicale.app.propfind import ApplicationPropfindMixin
-from radicale.app.proppatch import ApplicationProppatchMixin
-from radicale.app.put import ApplicationPutMixin
-from radicale.app.report import ApplicationReportMixin
+from radicale import config, httputils, log, pathutils, types
+from radicale.app.base import ApplicationBase
+from radicale.app.delete import ApplicationPartDelete
+from radicale.app.get import ApplicationPartGet
+from radicale.app.head import ApplicationPartHead
+from radicale.app.mkcalendar import ApplicationPartMkcalendar
+from radicale.app.mkcol import ApplicationPartMkcol
+from radicale.app.move import ApplicationPartMove
+from radicale.app.options import ApplicationPartOptions
+from radicale.app.post import ApplicationPartPost
+from radicale.app.propfind import ApplicationPartPropfind
+from radicale.app.proppatch import ApplicationPartProppatch
+from radicale.app.put import ApplicationPartPut
+from radicale.app.report import ApplicationPartReport
 from radicale.log import logger
 
-# WORKAROUND: https://github.com/tiran/defusedxml/issues/54
-import defusedxml.ElementTree as DefusedET  # isort: skip
-sys.modules["xml.etree"].ElementTree = ET  # type: ignore[attr-defined]
+VERSION: str = pkg_resources.get_distribution("radicale").version
 
-VERSION = pkg_resources.get_distribution("radicale").version
+# Combination of types.WSGIStartResponse and WSGI application return value
+_IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
 
 
-class Application(
-        ApplicationDeleteMixin, ApplicationGetMixin, ApplicationHeadMixin,
-        ApplicationMkcalendarMixin, ApplicationMkcolMixin,
-        ApplicationMoveMixin, ApplicationOptionsMixin,
-        ApplicationPropfindMixin, ApplicationProppatchMixin,
-        ApplicationPostMixin, ApplicationPutMixin,
-        ApplicationReportMixin):
-
+class Application(ApplicationPartDelete, ApplicationPartHead,
+                  ApplicationPartGet, ApplicationPartMkcalendar,
+                  ApplicationPartMkcol, ApplicationPartMove,
+                  ApplicationPartOptions, ApplicationPartPropfind,
+                  ApplicationPartProppatch, ApplicationPartPost,
+                  ApplicationPartPut, ApplicationPartReport, ApplicationBase):
     """WSGI application."""
 
-    def __init__(self, configuration):
+    _mask_passwords: bool
+    _auth_delay: float
+    _internal_server: bool
+    _max_content_length: int
+    _auth_realm: str
+    _extra_headers: Mapping[str, str]
+
+    def __init__(self, configuration: config.Configuration) -> None:
         """Initialize Application.
 
         ``configuration`` see ``radicale.config`` module.
@@ -81,60 +81,59 @@ class Application(
         this object, it is kept as an internal reference.
 
         """
-        super().__init__()
-        self.configuration = configuration
-        self._auth = auth.load(configuration)
-        self._storage = storage.load(configuration)
-        self._rights = rights.load(configuration)
-        self._web = web.load(configuration)
-        self._encoding = configuration.get("encoding", "request")
-
-    def _headers_log(self, environ):
-        """Sanitize headers for logging."""
-        request_environ = dict(environ)
-
-        # Mask passwords
-        mask_passwords = self.configuration.get("logging", "mask_passwords")
-        authorization = request_environ.get("HTTP_AUTHORIZATION", "")
-        if mask_passwords and authorization.startswith("Basic"):
-            request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
-        if request_environ.get("HTTP_COOKIE"):
-            request_environ["HTTP_COOKIE"] = "**masked**"
-
-        return request_environ
-
-    def __call__(self, environ, start_response):
+        super().__init__(configuration)
+        self._mask_passwords = configuration.get("logging", "mask_passwords")
+        self._auth_delay = configuration.get("auth", "delay")
+        self._internal_server = configuration.get("server", "_internal_server")
+        self._max_content_length = configuration.get(
+            "server", "max_content_length")
+        self._auth_realm = configuration.get("auth", "realm")
+        self._extra_headers = dict()
+        for key in self.configuration.options("headers"):
+            self._extra_headers[key] = configuration.get("headers", key)
+
+    def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
+        """Mask passwords and cookies."""
+        headers = dict(environ)
+        if (self._mask_passwords and
+                headers.get("HTTP_AUTHORIZATION", "").startswith("Basic")):
+            headers["HTTP_AUTHORIZATION"] = "Basic **masked**"
+        if headers.get("HTTP_COOKIE"):
+            headers["HTTP_COOKIE"] = "**masked**"
+        return headers
+
+    def __call__(self, environ: types.WSGIEnviron, start_response:
+                 types.WSGIStartResponse) -> Iterable[bytes]:
         with log.register_stream(environ["wsgi.errors"]):
             try:
-                status, headers, answers = self._handle_request(environ)
+                status_text, headers, answers = self._handle_request(environ)
             except Exception as e:
-                try:
-                    method = str(environ["REQUEST_METHOD"])
-                except Exception:
-                    method = "unknown"
-                try:
-                    path = str(environ.get("PATH_INFO", ""))
-                except Exception:
-                    path = ""
                 logger.error("An exception occurred during %s request on %r: "
-                             "%s", method, path, e, exc_info=True)
-                status, headers, answer = httputils.INTERNAL_SERVER_ERROR
-                answer = answer.encode("ascii")
-                status = "%d %s" % (
-                    status.value, client.responses.get(status, "Unknown"))
-                headers = [
-                    ("Content-Length", str(len(answer)))] + list(headers)
+                             "%s", environ.get("REQUEST_METHOD", "unknown"),
+                             environ.get("PATH_INFO", ""), e, exc_info=True)
+                # Make minimal response
+                status, raw_headers, raw_answer = (
+                    httputils.INTERNAL_SERVER_ERROR)
+                assert isinstance(raw_answer, str)
+                answer = raw_answer.encode("ascii")
+                status_text = "%d %s" % (
+                    status, client.responses.get(status, "Unknown"))
+                headers = [*raw_headers, ("Content-Length", str(len(answer)))]
                 answers = [answer]
-            start_response(status, headers)
+            start_response(status_text, headers)
         return answers
 
-    def _handle_request(self, environ):
+    def _handle_request(self, environ: types.WSGIEnviron
+                        ) -> _IntermediateResponse:
         """Manage a request."""
-        def response(status, headers=(), answer=None):
+        def response(status: int, headers: types.WSGIResponseHeaders,
+                     answer: Union[None, str, bytes]) -> _IntermediateResponse:
+            """Helper to create response from internal types.WSGIResponse"""
             headers = dict(headers)
             # Set content length
-            if answer:
-                if hasattr(answer, "encode"):
+            answers = []
+            if answer is not None:
+                if isinstance(answer, str):
                     logger.debug("Response content:\n%s", answer)
                     headers["Content-Type"] += "; charset=%s" % self._encoding
                     answer = answer.encode(self._encoding)
@@ -149,21 +148,22 @@ class Application(
                     headers["Content-Encoding"] = "gzip"
 
                 headers["Content-Length"] = str(len(answer))
+                answers.append(answer)
 
             # Add extra headers set in configuration
-            for key in self.configuration.options("headers"):
-                headers[key] = self.configuration.get("headers", key)
+            headers.update(self._extra_headers)
 
             # Start response
             time_end = datetime.datetime.now()
-            status = "%d %s" % (
+            status_text = "%d %s" % (
                 status, client.responses.get(status, "Unknown"))
             logger.info(
                 "%s response status for %r%s in %.3f seconds: %s",
                 environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""),
-                depthinfo, (time_end - time_begin).total_seconds(), status)
+                depthinfo, (time_end - time_begin).total_seconds(),
+                status_text)
             # Return response content
-            return status, list(headers.items()), [answer] if answer else []
+            return status_text, list(headers.items()), answers
 
         remote_host = "unknown"
         if environ.get("REMOTE_HOST"):
@@ -184,8 +184,8 @@ class Application(
             "%s request for %r%s received from %s%s",
             environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo,
             remote_host, remote_useragent)
-        headers = pprint.pformat(self._headers_log(environ))
-        logger.debug("Request headers:\n%s", headers)
+        logger.debug("Request headers:\n%s",
+                     pprint.pformat(self._scrub_headers(environ)))
 
         # Let reverse proxies overwrite SCRIPT_NAME
         if "HTTP_X_SCRIPT_NAME" in environ:
@@ -237,9 +237,8 @@ class Application(
             logger.warning("Failed login attempt from %s: %r",
                            remote_host, login)
             # Random delay to avoid timing oracles and bruteforce attacks
-            delay = self.configuration.get("auth", "delay")
-            if delay > 0:
-                random_delay = delay * (0.5 + random.random())
+            if self._auth_delay > 0:
+                random_delay = self._auth_delay * (0.5 + random.random())
                 logger.debug("Sleeping %.3f seconds", random_delay)
                 time.sleep(random_delay)
 
@@ -252,8 +251,8 @@ class Application(
         if user:
             principal_path = "/%s/" % user
             with self._storage.acquire_lock("r", user):
-                principal = next(self._storage.discover(
-                    principal_path, depth="1"), None)
+                principal = next(iter(self._storage.discover(
+                    principal_path, depth="1")), None)
             if not principal:
                 if "W" in self._rights.authorization(user, principal_path):
                     with self._storage.acquire_lock("w", user):
@@ -267,13 +266,12 @@ class Application(
                     logger.warning("Access to principal path %r denied by "
                                    "rights backend", principal_path)
 
-        if self.configuration.get("server", "_internal_server"):
+        if self._internal_server:
             # Verify content length
             content_length = int(environ.get("CONTENT_LENGTH") or 0)
             if content_length:
-                max_content_length = self.configuration.get(
-                    "server", "max_content_length")
-                if max_content_length and content_length > max_content_length:
+                if (self._max_content_length > 0 and
+                        content_length > self._max_content_length):
                     logger.info("Request body too large: %d", content_length)
                     return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
 
@@ -291,82 +289,9 @@ class Application(
             # Unknown or unauthorized user
             logger.debug("Asking client for authentication")
             status = client.UNAUTHORIZED
-            realm = self.configuration.get("auth", "realm")
             headers = dict(headers)
             headers.update({
                 "WWW-Authenticate":
-                "Basic realm=\"%s\"" % realm})
+                "Basic realm=\"%s\"" % self._auth_realm})
 
         return response(status, headers, answer)
-
-    def _read_xml_request_body(self, environ):
-        content = httputils.decode_request(
-            self.configuration, environ,
-            httputils.read_raw_request_body(self.configuration, environ))
-        if not content:
-            return None
-        try:
-            xml_content = DefusedET.fromstring(content)
-        except ET.ParseError as e:
-            logger.debug("Request content (Invalid XML):\n%s", content)
-            raise RuntimeError("Failed to parse XML: %s" % e) from e
-        if logger.isEnabledFor(logging.DEBUG):
-            logger.debug("Request content:\n%s",
-                         xmlutils.pretty_xml(xml_content))
-        return xml_content
-
-    def _xml_response(self, xml_content):
-        if logger.isEnabledFor(logging.DEBUG):
-            logger.debug("Response content:\n%s",
-                         xmlutils.pretty_xml(xml_content))
-        f = io.BytesIO()
-        ET.ElementTree(xml_content).write(f, encoding=self._encoding,
-                                          xml_declaration=True)
-        return f.getvalue()
-
-    def _webdav_error_response(self, status, human_tag):
-        """Generate XML error response."""
-        headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
-        content = self._xml_response(xmlutils.webdav_error(human_tag))
-        return status, headers, content
-
-
-class Access:
-    """Helper class to check access rights of an item"""
-
-    def __init__(self, rights, user, path):
-        self._rights = rights
-        self.user = user
-        self.path = path
-        self.parent_path = pathutils.unstrip_path(
-            posixpath.dirname(pathutils.strip_path(path)), True)
-        self.permissions = self._rights.authorization(self.user, self.path)
-        self._parent_permissions = None
-
-    @property
-    def parent_permissions(self):
-        if self.path == self.parent_path:
-            return self.permissions
-        if self._parent_permissions is None:
-            self._parent_permissions = self._rights.authorization(
-                self.user, self.parent_path)
-        return self._parent_permissions
-
-    def check(self, permission, item=None):
-        if permission not in "rw":
-            raise ValueError("Invalid permission argument: %r" % permission)
-        if not item:
-            permissions = permission + permission.upper()
-            parent_permissions = permission
-        elif isinstance(item, storage.BaseCollection):
-            if item.get_meta("tag"):
-                permissions = permission
-            else:
-                permissions = permission.upper()
-            parent_permissions = ""
-        else:
-            permissions = ""
-            parent_permissions = permission
-        return bool(rights.intersect(self.permissions, permissions) or (
-            self.path != self.parent_path and
-            rights.intersect(self.parent_permissions, parent_permissions)))

+ 131 - 0
radicale/app/base.py

@@ -0,0 +1,131 @@
+# This file is part of Radicale Server - Calendar Server
+# Copyright © 2020 Unrud <unrud@outlook.com>
+#
+# This library 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, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This library 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 Radicale.  If not, see <http://www.gnu.org/licenses/>.
+
+import io
+import logging
+import posixpath
+import sys
+import xml.etree.ElementTree as ET
+from typing import Optional
+
+from radicale import (auth, config, httputils, pathutils, rights, storage,
+                      types, web, xmlutils)
+from radicale.log import logger
+
+# HACK: https://github.com/tiran/defusedxml/issues/54
+import defusedxml.ElementTree as DefusedET  # isort:skip
+sys.modules["xml.etree"].ElementTree = ET  # type:ignore[attr-defined]
+
+
+class ApplicationBase:
+
+    configuration: config.Configuration
+    _auth: auth.BaseAuth
+    _storage: storage.BaseStorage
+    _rights: rights.BaseRights
+    _web: web.BaseWeb
+    _encoding: str
+
+    def __init__(self, configuration: config.Configuration) -> None:
+        self.configuration = configuration
+        self._auth = auth.load(configuration)
+        self._storage = storage.load(configuration)
+        self._rights = rights.load(configuration)
+        self._web = web.load(configuration)
+        self._encoding = configuration.get("encoding", "request")
+
+    def _read_xml_request_body(self, environ: types.WSGIEnviron
+                               ) -> Optional[ET.Element]:
+        content = httputils.decode_request(
+            self.configuration, environ,
+            httputils.read_raw_request_body(self.configuration, environ))
+        if not content:
+            return None
+        try:
+            xml_content = DefusedET.fromstring(content)
+        except ET.ParseError as e:
+            logger.debug("Request content (Invalid XML):\n%s", content)
+            raise RuntimeError("Failed to parse XML: %s" % e) from e
+        if logger.isEnabledFor(logging.DEBUG):
+            logger.debug("Request content:\n%s",
+                         xmlutils.pretty_xml(xml_content))
+        return xml_content
+
+    def _xml_response(self, xml_content: ET.Element) -> bytes:
+        if logger.isEnabledFor(logging.DEBUG):
+            logger.debug("Response content:\n%s",
+                         xmlutils.pretty_xml(xml_content))
+        f = io.BytesIO()
+        ET.ElementTree(xml_content).write(f, encoding=self._encoding,
+                                          xml_declaration=True)
+        return f.getvalue()
+
+    def _webdav_error_response(self, status: int, human_tag: str
+                               ) -> types.WSGIResponse:
+        """Generate XML error response."""
+        headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
+        content = self._xml_response(xmlutils.webdav_error(human_tag))
+        return status, headers, content
+
+
+class Access:
+    """Helper class to check access rights of an item"""
+
+    user: str
+    path: str
+    parent_path: str
+    permissions: str
+    _rights: rights.BaseRights
+    _parent_permissions: Optional[str]
+
+    def __init__(self, rights: rights.BaseRights, user: str, path: str
+                 ) -> None:
+        self._rights = rights
+        self.user = user
+        self.path = path
+        self.parent_path = pathutils.unstrip_path(
+            posixpath.dirname(pathutils.strip_path(path)), True)
+        self.permissions = self._rights.authorization(self.user, self.path)
+        self._parent_permissions = None
+
+    @property
+    def parent_permissions(self) -> str:
+        if self.path == self.parent_path:
+            return self.permissions
+        if self._parent_permissions is None:
+            self._parent_permissions = self._rights.authorization(
+                self.user, self.parent_path)
+        return self._parent_permissions
+
+    def check(self, permission: str,
+              item: Optional[types.CollectionOrItem] = None) -> bool:
+        if permission not in "rw":
+            raise ValueError("Invalid permission argument: %r" % permission)
+        if not item:
+            permissions = permission + permission.upper()
+            parent_permissions = permission
+        elif isinstance(item, storage.BaseCollection):
+            if item.tag:
+                permissions = permission
+            else:
+                permissions = permission.upper()
+            parent_permissions = ""
+        else:
+            permissions = ""
+            parent_permissions = permission
+        return bool(rights.intersect(self.permissions, permissions) or (
+            self.path != self.parent_path and
+            rights.intersect(self.parent_permissions, parent_permissions)))

+ 17 - 10
radicale/app/delete.py

@@ -19,25 +19,28 @@
 
 import xml.etree.ElementTree as ET
 from http import client
+from typing import Optional
 
-from radicale import app, httputils, storage, xmlutils
+from radicale import httputils, storage, types, xmlutils
+from radicale.app.base import Access, ApplicationBase
 
 
-def xml_delete(base_prefix, path, collection, href=None):
+def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
+               item_href: Optional[str] = None) -> ET.Element:
     """Read and answer DELETE requests.
 
     Read rfc4918-9.6 for info.
 
     """
-    collection.delete(href)
+    collection.delete(item_href)
 
     multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
     response = ET.Element(xmlutils.make_clark("D:response"))
     multistatus.append(response)
 
-    href = ET.Element(xmlutils.make_clark("D:href"))
-    href.text = xmlutils.make_href(base_prefix, path)
-    response.append(href)
+    href_element = ET.Element(xmlutils.make_clark("D:href"))
+    href_element.text = xmlutils.make_href(base_prefix, path)
+    response.append(href_element)
 
     status = ET.Element(xmlutils.make_clark("D:status"))
     status.text = xmlutils.make_response(200)
@@ -46,14 +49,16 @@ def xml_delete(base_prefix, path, collection, href=None):
     return multistatus
 
 
-class ApplicationDeleteMixin:
-    def do_DELETE(self, environ, base_prefix, path, user):
+class ApplicationPartDelete(ApplicationBase):
+
+    def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str,
+                  path: str, user: str) -> types.WSGIResponse:
         """Manage DELETE request."""
-        access = app.Access(self._rights, user, path)
+        access = Access(self._rights, user, path)
         if not access.check("w"):
             return httputils.NOT_ALLOWED
         with self._storage.acquire_lock("w", user):
-            item = next(self._storage.discover(path), None)
+            item = next(iter(self._storage.discover(path)), None)
             if not item:
                 return httputils.NOT_FOUND
             if not access.check("w", item):
@@ -65,6 +70,8 @@ class ApplicationDeleteMixin:
             if isinstance(item, storage.BaseCollection):
                 xml_answer = xml_delete(base_prefix, path, item)
             else:
+                assert item.collection is not None
+                assert item.href is not None
                 xml_answer = xml_delete(
                     base_prefix, path, item.collection, item.href)
             headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}

+ 15 - 13
radicale/app/get.py

@@ -21,17 +21,17 @@ import posixpath
 from http import client
 from urllib.parse import quote
 
-from radicale import app, httputils, pathutils, storage, xmlutils
+from radicale import httputils, pathutils, storage, types, xmlutils
+from radicale.app.base import Access, ApplicationBase
 from radicale.log import logger
 
 
-def propose_filename(collection):
+def propose_filename(collection: storage.BaseCollection) -> str:
     """Propose a filename for a collection."""
-    tag = collection.get_meta("tag")
-    if tag == "VADDRESSBOOK":
+    if collection.tag == "VADDRESSBOOK":
         fallback_title = "Address book"
         suffix = ".vcf"
-    elif tag == "VCALENDAR":
+    elif collection.tag == "VCALENDAR":
         fallback_title = "Calendar"
         suffix = ".ics"
     else:
@@ -43,8 +43,9 @@ def propose_filename(collection):
     return title
 
 
-class ApplicationGetMixin:
-    def _content_disposition_attachement(self, filename):
+class ApplicationPartGet(ApplicationBase):
+
+    def _content_disposition_attachement(self, filename: str) -> str:
         value = "attachement"
         try:
             encoded_filename = quote(filename, encoding=self._encoding)
@@ -56,7 +57,8 @@ class ApplicationGetMixin:
             value += "; filename*=%s''%s" % (self._encoding, encoded_filename)
         return value
 
-    def do_GET(self, environ, base_prefix, path, user):
+    def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
+               user: str) -> types.WSGIResponse:
         """Manage GET request."""
         # Redirect to .web if the root URL is requested
         if not pathutils.strip_path(path):
@@ -70,11 +72,11 @@ class ApplicationGetMixin:
         # Dispatch .web URL to web module
         if path == "/.web" or path.startswith("/.web/"):
             return self._web.get(environ, base_prefix, path, user)
-        access = app.Access(self._rights, user, path)
+        access = Access(self._rights, user, path)
         if not access.check("r") and "i" not in access.permissions:
             return httputils.NOT_ALLOWED
         with self._storage.acquire_lock("r", user):
-            item = next(self._storage.discover(path), None)
+            item = next(iter(self._storage.discover(path)), None)
             if not item:
                 return httputils.NOT_FOUND
             if access.check("r", item):
@@ -84,11 +86,10 @@ class ApplicationGetMixin:
             else:
                 return httputils.NOT_ALLOWED
             if isinstance(item, storage.BaseCollection):
-                tag = item.get_meta("tag")
-                if not tag:
+                if not item.tag:
                     return (httputils.NOT_ALLOWED if limited_access else
                             httputils.DIRECTORY_LISTING)
-                content_type = xmlutils.MIMETYPES[tag]
+                content_type = xmlutils.MIMETYPES[item.tag]
                 content_disposition = self._content_disposition_attachement(
                     propose_filename(item))
             elif limited_access:
@@ -96,6 +97,7 @@ class ApplicationGetMixin:
             else:
                 content_type = xmlutils.OBJECT_MIMETYPES[item.name]
                 content_disposition = ""
+            assert item.last_modified
             headers = {
                 "Content-Type": content_type,
                 "Last-Modified": item.last_modified,

+ 8 - 2
radicale/app/head.py

@@ -17,9 +17,15 @@
 # You should have received a copy of the GNU General Public License
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
+from radicale import types
+from radicale.app.base import ApplicationBase
+from radicale.app.get import ApplicationPartGet
 
-class ApplicationHeadMixin:
-    def do_HEAD(self, environ, base_prefix, path, user):
+
+class ApplicationPartHead(ApplicationPartGet, ApplicationBase):
+
+    def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
+                user: str) -> types.WSGIResponse:
         """Manage HEAD request."""
         status, headers, _ = self.do_GET(environ, base_prefix, path, user)
         return status, headers, None

+ 15 - 14
radicale/app/mkcalendar.py

@@ -21,14 +21,16 @@ import posixpath
 import socket
 from http import client
 
-from radicale import httputils
-from radicale import item as radicale_item
-from radicale import pathutils, storage, xmlutils
+import radicale.item as radicale_item
+from radicale import httputils, pathutils, storage, types, xmlutils
+from radicale.app.base import ApplicationBase
 from radicale.log import logger
 
 
-class ApplicationMkcalendarMixin:
-    def do_MKCALENDAR(self, environ, base_prefix, path, user):
+class ApplicationPartMkcalendar(ApplicationBase):
+
+    def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str,
+                      path: str, user: str) -> types.WSGIResponse:
         """Manage MKCALENDAR request."""
         if "w" not in self._rights.authorization(user, path):
             return httputils.NOT_ALLOWED
@@ -42,29 +44,28 @@ class ApplicationMkcalendarMixin:
             logger.debug("Client timed out", exc_info=True)
             return httputils.REQUEST_TIMEOUT
         # Prepare before locking
-        props = xmlutils.props_from_request(xml_content)
-        props = {k: v for k, v in props.items() if v is not None}
-        props["tag"] = "VCALENDAR"
-        # TODO: use this?
-        # timezone = props.get("C:calendar-timezone")
+        props_with_remove = xmlutils.props_from_request(xml_content)
+        props_with_remove["tag"] = "VCALENDAR"
         try:
-            radicale_item.check_and_sanitize_props(props)
+            props = radicale_item.check_and_sanitize_props(props_with_remove)
         except ValueError as e:
             logger.warning(
                 "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
             return httputils.BAD_REQUEST
+        # TODO: use this?
+        # timezone = props.get("C:calendar-timezone")
         with self._storage.acquire_lock("w", user):
-            item = next(self._storage.discover(path), None)
+            item = next(iter(self._storage.discover(path)), None)
             if item:
                 return self._webdav_error_response(
                     client.CONFLICT, "D:resource-must-be-null")
             parent_path = pathutils.unstrip_path(
                 posixpath.dirname(pathutils.strip_path(path)), True)
-            parent_item = next(self._storage.discover(parent_path), None)
+            parent_item = next(iter(self._storage.discover(parent_path)), None)
             if not parent_item:
                 return httputils.CONFLICT
             if (not isinstance(parent_item, storage.BaseCollection) or
-                    parent_item.get_meta("tag")):
+                    parent_item.tag):
                 return httputils.FORBIDDEN
             try:
                 self._storage.create_collection(path, props=props)

+ 12 - 11
radicale/app/mkcol.py

@@ -21,14 +21,16 @@ import posixpath
 import socket
 from http import client
 
-from radicale import httputils
-from radicale import item as radicale_item
-from radicale import pathutils, rights, storage, xmlutils
+import radicale.item as radicale_item
+from radicale import httputils, pathutils, rights, storage, types, xmlutils
+from radicale.app.base import ApplicationBase
 from radicale.log import logger
 
 
-class ApplicationMkcolMixin:
-    def do_MKCOL(self, environ, base_prefix, path, user):
+class ApplicationPartMkcol(ApplicationBase):
+
+    def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str,
+                 path: str, user: str) -> types.WSGIResponse:
         """Manage MKCOL request."""
         permissions = self._rights.authorization(user, path)
         if not rights.intersect(permissions, "Ww"):
@@ -43,10 +45,9 @@ class ApplicationMkcolMixin:
             logger.debug("Client timed out", exc_info=True)
             return httputils.REQUEST_TIMEOUT
         # Prepare before locking
-        props = xmlutils.props_from_request(xml_content)
-        props = {k: v for k, v in props.items() if v is not None}
+        props_with_remove = xmlutils.props_from_request(xml_content)
         try:
-            radicale_item.check_and_sanitize_props(props)
+            props = radicale_item.check_and_sanitize_props(props_with_remove)
         except ValueError as e:
             logger.warning(
                 "Bad MKCOL request on %r: %s", path, e, exc_info=True)
@@ -55,16 +56,16 @@ class ApplicationMkcolMixin:
                 not props.get("tag") and "W" not in permissions):
             return httputils.NOT_ALLOWED
         with self._storage.acquire_lock("w", user):
-            item = next(self._storage.discover(path), None)
+            item = next(iter(self._storage.discover(path)), None)
             if item:
                 return httputils.METHOD_NOT_ALLOWED
             parent_path = pathutils.unstrip_path(
                 posixpath.dirname(pathutils.strip_path(path)), True)
-            parent_item = next(self._storage.discover(parent_path), None)
+            parent_item = next(iter(self._storage.discover(parent_path)), None)
             if not parent_item:
                 return httputils.CONFLICT
             if (not isinstance(parent_item, storage.BaseCollection) or
-                    parent_item.get_meta("tag")):
+                    parent_item.tag):
                 return httputils.FORBIDDEN
             try:
                 self._storage.create_collection(path, props=props)

+ 17 - 12
radicale/app/move.py

@@ -21,12 +21,15 @@ import posixpath
 from http import client
 from urllib.parse import urlparse
 
-from radicale import app, httputils, pathutils, storage
+from radicale import httputils, pathutils, storage, types
+from radicale.app.base import Access, ApplicationBase
 from radicale.log import logger
 
 
-class ApplicationMoveMixin:
-    def do_MOVE(self, environ, base_prefix, path, user):
+class ApplicationPartMove(ApplicationBase):
+
+    def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str,
+                path: str, user: str) -> types.WSGIResponse:
         """Manage MOVE request."""
         raw_dest = environ.get("HTTP_DESTINATION", "")
         to_url = urlparse(raw_dest)
@@ -34,7 +37,7 @@ class ApplicationMoveMixin:
             logger.info("Unsupported destination address: %r", raw_dest)
             # Remote destination server, not supported
             return httputils.REMOTE_DESTINATION
-        access = app.Access(self._rights, user, path)
+        access = Access(self._rights, user, path)
         if not access.check("w"):
             return httputils.NOT_ALLOWED
         to_path = pathutils.sanitize_path(to_url.path)
@@ -43,12 +46,12 @@ class ApplicationMoveMixin:
                            "start with base prefix", to_path, path)
             return httputils.NOT_ALLOWED
         to_path = to_path[len(base_prefix):]
-        to_access = app.Access(self._rights, user, to_path)
+        to_access = Access(self._rights, user, to_path)
         if not to_access.check("w"):
             return httputils.NOT_ALLOWED
 
         with self._storage.acquire_lock("w", user):
-            item = next(self._storage.discover(path), None)
+            item = next(iter(self._storage.discover(path)), None)
             if not item:
                 return httputils.NOT_FOUND
             if (not access.check("w", item) or
@@ -58,17 +61,19 @@ class ApplicationMoveMixin:
                 # TODO: support moving collections
                 return httputils.METHOD_NOT_ALLOWED
 
-            to_item = next(self._storage.discover(to_path), None)
+            to_item = next(iter(self._storage.discover(to_path)), None)
             if isinstance(to_item, storage.BaseCollection):
                 return httputils.FORBIDDEN
             to_parent_path = pathutils.unstrip_path(
                 posixpath.dirname(pathutils.strip_path(to_path)), True)
-            to_collection = next(
-                self._storage.discover(to_parent_path), None)
+            to_collection = next(iter(
+                self._storage.discover(to_parent_path)), None)
             if not to_collection:
                 return httputils.CONFLICT
-            tag = item.collection.get_meta("tag")
-            if not tag or tag != to_collection.get_meta("tag"):
+            assert isinstance(to_collection, storage.BaseCollection)
+            assert item.collection is not None
+            collection_tag = item.collection.tag
+            if not collection_tag or collection_tag != to_collection.tag:
                 return httputils.FORBIDDEN
             if to_item and environ.get("HTTP_OVERWRITE", "F") != "T":
                 return httputils.PRECONDITION_FAILED
@@ -78,7 +83,7 @@ class ApplicationMoveMixin:
                     to_collection.has_uid(item.uid)):
                 return self._webdav_error_response(
                     client.CONFLICT, "%s:no-uid-conflict" % (
-                        "C" if tag == "VCALENDAR" else "CR"))
+                        "C" if collection_tag == "VCALENDAR" else "CR"))
             to_href = posixpath.basename(pathutils.strip_path(to_path))
             try:
                 self._storage.move(item, to_collection, to_href)

+ 6 - 3
radicale/app/options.py

@@ -19,11 +19,14 @@
 
 from http import client
 
-from radicale import httputils
+from radicale import httputils, types
+from radicale.app.base import ApplicationBase
 
 
-class ApplicationOptionsMixin:
-    def do_OPTIONS(self, environ, base_prefix, path, user):
+class ApplicationPartOptions(ApplicationBase):
+
+    def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str,
+                   path: str, user: str) -> types.WSGIResponse:
         """Manage OPTIONS request."""
         headers = {
             "Allow": ", ".join(

+ 6 - 3
radicale/app/post.py

@@ -18,11 +18,14 @@
 # You should have received a copy of the GNU General Public License
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
-from radicale import httputils
+from radicale import httputils, types
+from radicale.app.base import ApplicationBase
 
 
-class ApplicationPostMixin:
-    def do_POST(self, environ, base_prefix, path, user):
+class ApplicationPartPost(ApplicationBase):
+
+    def do_POST(self, environ: types.WSGIEnviron, base_prefix: str,
+                path: str, user: str) -> types.WSGIResponse:
         """Manage POST request."""
         if path == "/.web" or path.startswith("/.web/"):
             return self._web.post(environ, base_prefix, path, user)

+ 70 - 57
radicale/app/propfind.py

@@ -23,13 +23,17 @@ import posixpath
 import socket
 import xml.etree.ElementTree as ET
 from http import client
+from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
 
-from radicale import app, httputils, pathutils, rights, storage, xmlutils
+from radicale import httputils, pathutils, rights, storage, types, xmlutils
+from radicale.app.base import Access, ApplicationBase
 from radicale.log import logger
 
 
-def xml_propfind(base_prefix, path, xml_request, allowed_items, user,
-                 encoding):
+def xml_propfind(base_prefix: str, path: str,
+                 xml_request: Optional[ET.Element],
+                 allowed_items: Iterable[Tuple[types.CollectionOrItem, str]],
+                 user: str, encoding: str) -> Optional[ET.Element]:
     """Read and answer PROPFIND requests.
 
     Read rfc4918-9.1 for info.
@@ -43,7 +47,7 @@ def xml_propfind(base_prefix, path, xml_request, allowed_items, user,
     top_element = (xml_request[0] if xml_request is not None else
                    ET.Element(xmlutils.make_clark("D:allprop")))
 
-    props = ()
+    props: List[str] = []
     allprop = False
     propname = False
     if top_element.tag == xmlutils.make_clark("D:allprop"):
@@ -51,13 +55,13 @@ def xml_propfind(base_prefix, path, xml_request, allowed_items, user,
     elif top_element.tag == xmlutils.make_clark("D:propname"):
         propname = True
     elif top_element.tag == xmlutils.make_clark("D:prop"):
-        props = [prop.tag for prop in top_element]
+        props.extend(prop.tag for prop in top_element)
 
     if xmlutils.make_clark("D:current-user-principal") in props and not user:
         # Ask for authentication
         # Returning the DAV:unauthenticated pseudo-principal as specified in
         # RFC 5397 doesn't seem to work with DAVx5.
-        return client.FORBIDDEN, None
+        return None
 
     # Writing answer
     multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
@@ -68,29 +72,32 @@ def xml_propfind(base_prefix, path, xml_request, allowed_items, user,
             base_prefix, path, item, props, user, encoding, write=write,
             allprop=allprop, propname=propname))
 
-    return client.MULTI_STATUS, multistatus
+    return multistatus
 
 
-def xml_propfind_response(base_prefix, path, item, props, user, encoding,
-                          write=False, propname=False, allprop=False):
+def xml_propfind_response(
+        base_prefix: str, path: str, item: types.CollectionOrItem,
+        props: Sequence[str], user: str, encoding: str, write: bool = False,
+        propname: bool = False, allprop: bool = False) -> ET.Element:
     """Build and return a PROPFIND response."""
     if propname and allprop or (props and (propname or allprop)):
         raise ValueError("Only use one of props, propname and allprops")
-    is_collection = isinstance(item, storage.BaseCollection)
-    if is_collection:
-        is_leaf = item.get_meta("tag") in ("VADDRESSBOOK", "VCALENDAR")
+
+    if isinstance(item, storage.BaseCollection):
+        is_collection = True
+        is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR")
         collection = item
+        # Some clients expect collections to end with `/`
+        uri = pathutils.unstrip_path(item.path, True)
     else:
+        is_collection = is_leaf = False
+        assert item.collection is not None
+        assert item.href
         collection = item.collection
-
+        uri = pathutils.unstrip_path(posixpath.join(
+            collection.path, item.href))
     response = ET.Element(xmlutils.make_clark("D:response"))
     href = ET.Element(xmlutils.make_clark("D:href"))
-    if is_collection:
-        # Some clients expect collections to end with /
-        uri = pathutils.unstrip_path(item.path, True)
-    else:
-        uri = pathutils.unstrip_path(
-            posixpath.join(collection.path, item.href))
     href.text = xmlutils.make_href(base_prefix, uri)
     response.append(href)
 
@@ -120,12 +127,12 @@ def xml_propfind_response(base_prefix, path, item, props, user, encoding,
             if is_leaf:
                 props.append(xmlutils.make_clark("D:displayname"))
                 props.append(xmlutils.make_clark("D:sync-token"))
-            if collection.get_meta("tag") == "VCALENDAR":
+            if collection.tag == "VCALENDAR":
                 props.append(xmlutils.make_clark("CS:getctag"))
                 props.append(
                     xmlutils.make_clark("C:supported-calendar-component-set"))
 
-            meta = item.get_meta()
+            meta = collection.get_meta()
             for tag in meta:
                 if tag == "tag":
                     continue
@@ -133,11 +140,11 @@ def xml_propfind_response(base_prefix, path, item, props, user, encoding,
                 if clark_tag not in props:
                     props.append(clark_tag)
 
-    responses = collections.defaultdict(list)
+    responses: Dict[int, List[ET.Element]] = collections.defaultdict(list)
     if propname:
         for tag in props:
             responses[200].append(ET.Element(tag))
-        props = ()
+        props = []
     for tag in props:
         element = ET.Element(tag)
         is404 = False
@@ -159,18 +166,18 @@ def xml_propfind_response(base_prefix, path, item, props, user, encoding,
                       xmlutils.make_clark("D:principal-URL"),
                       xmlutils.make_clark("CR:addressbook-home-set"),
                       xmlutils.make_clark("C:calendar-home-set")) and
-              collection.is_principal and is_collection):
+              is_collection and collection.is_principal):
             child_element = ET.Element(xmlutils.make_clark("D:href"))
             child_element.text = xmlutils.make_href(base_prefix, path)
             element.append(child_element)
         elif tag == xmlutils.make_clark("C:supported-calendar-component-set"):
             human_tag = xmlutils.make_human_tag(tag)
             if is_collection and is_leaf:
-                meta = item.get_meta(human_tag)
-                if meta:
-                    components = meta.split(",")
+                components_text = collection.get_meta(human_tag)
+                if components_text:
+                    components = components_text.split(",")
                 else:
-                    components = ("VTODO", "VEVENT", "VJOURNAL")
+                    components = ["VTODO", "VEVENT", "VJOURNAL"]
                 for component in components:
                     comp = ET.Element(xmlutils.make_clark("C:comp"))
                     comp.set("name", component)
@@ -205,10 +212,10 @@ def xml_propfind_response(base_prefix, path, item, props, user, encoding,
                        "D:principal-property-search"]
             if is_collection and is_leaf:
                 reports.append("D:sync-collection")
-                if item.get_meta("tag") == "VADDRESSBOOK":
+                if collection.tag == "VADDRESSBOOK":
                     reports.append("CR:addressbook-multiget")
                     reports.append("CR:addressbook-query")
-                elif item.get_meta("tag") == "VCALENDAR":
+                elif collection.tag == "VCALENDAR":
                     reports.append("C:calendar-multiget")
                     reports.append("C:calendar-query")
             for human_tag in reports:
@@ -234,20 +241,21 @@ def xml_propfind_response(base_prefix, path, item, props, user, encoding,
         elif is_collection:
             if tag == xmlutils.make_clark("D:getcontenttype"):
                 if is_leaf:
-                    element.text = xmlutils.MIMETYPES[item.get_meta("tag")]
+                    element.text = xmlutils.MIMETYPES[
+                        collection.tag]
                 else:
                     is404 = True
             elif tag == xmlutils.make_clark("D:resourcetype"):
-                if item.is_principal:
+                if collection.is_principal:
                     child_element = ET.Element(
                         xmlutils.make_clark("D:principal"))
                     element.append(child_element)
                 if is_leaf:
-                    if item.get_meta("tag") == "VADDRESSBOOK":
+                    if collection.tag == "VADDRESSBOOK":
                         child_element = ET.Element(
                             xmlutils.make_clark("CR:addressbook"))
                         element.append(child_element)
-                    elif item.get_meta("tag") == "VCALENDAR":
+                    elif collection.tag == "VCALENDAR":
                         child_element = ET.Element(
                             xmlutils.make_clark("C:calendar"))
                         element.append(child_element)
@@ -255,38 +263,39 @@ def xml_propfind_response(base_prefix, path, item, props, user, encoding,
                 element.append(child_element)
             elif tag == xmlutils.make_clark("RADICALE:displayname"):
                 # Only for internal use by the web interface
-                displayname = item.get_meta("D:displayname")
+                displayname = collection.get_meta("D:displayname")
                 if displayname is not None:
                     element.text = displayname
                 else:
                     is404 = True
             elif tag == xmlutils.make_clark("D:displayname"):
-                displayname = item.get_meta("D:displayname")
+                displayname = collection.get_meta("D:displayname")
                 if not displayname and is_leaf:
-                    displayname = item.path
+                    displayname = collection.path
                 if displayname is not None:
                     element.text = displayname
                 else:
                     is404 = True
             elif tag == xmlutils.make_clark("CS:getctag"):
                 if is_leaf:
-                    element.text = item.etag
+                    element.text = collection.etag
                 else:
                     is404 = True
             elif tag == xmlutils.make_clark("D:sync-token"):
                 if is_leaf:
-                    element.text, _ = item.sync()
+                    element.text, _ = collection.sync()
                 else:
                     is404 = True
             else:
                 human_tag = xmlutils.make_human_tag(tag)
-                meta = item.get_meta(human_tag)
-                if meta is not None:
-                    element.text = meta
+                tag_text = collection.get_meta(human_tag)
+                if tag_text is not None:
+                    element.text = tag_text
                 else:
                     is404 = True
         # Not for collections
         elif tag == xmlutils.make_clark("D:getcontenttype"):
+            assert not isinstance(item, storage.BaseCollection)
             element.text = xmlutils.get_content_type(item, encoding)
         elif tag == xmlutils.make_clark("D:resourcetype"):
             # resourcetype must be returned empty for non-collection elements
@@ -311,13 +320,16 @@ def xml_propfind_response(base_prefix, path, item, props, user, encoding,
     return response
 
 
-class ApplicationPropfindMixin:
-    def _collect_allowed_items(self, items, user):
+class ApplicationPartPropfind(ApplicationBase):
+
+    def _collect_allowed_items(
+            self, items: Iterable[types.CollectionOrItem], user: str
+            ) -> Iterator[Tuple[types.CollectionOrItem, str]]:
         """Get items from request that user is allowed to access."""
         for item in items:
             if isinstance(item, storage.BaseCollection):
                 path = pathutils.unstrip_path(item.path, True)
-                if item.get_meta("tag"):
+                if item.tag:
                     permissions = rights.intersect(
                         self._rights.authorization(user, path), "rw")
                     target = "collection with tag %r" % item.path
@@ -326,6 +338,7 @@ class ApplicationPropfindMixin:
                         self._rights.authorization(user, path), "RW")
                     target = "collection %r" % item.path
             else:
+                assert item.collection is not None
                 path = pathutils.unstrip_path(item.collection.path, True)
                 permissions = rights.intersect(
                     self._rights.authorization(user, path), "rw")
@@ -345,9 +358,10 @@ class ApplicationPropfindMixin:
             if permission:
                 yield item, permission
 
-    def do_PROPFIND(self, environ, base_prefix, path, user):
+    def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str,
+                    path: str, user: str) -> types.WSGIResponse:
         """Manage PROPFIND request."""
-        access = app.Access(self._rights, user, path)
+        access = Access(self._rights, user, path)
         if not access.check("r"):
             return httputils.NOT_ALLOWED
         try:
@@ -360,22 +374,21 @@ class ApplicationPropfindMixin:
             logger.debug("Client timed out", exc_info=True)
             return httputils.REQUEST_TIMEOUT
         with self._storage.acquire_lock("r", user):
-            items = self._storage.discover(
-                path, environ.get("HTTP_DEPTH", "0"))
+            items_iter = iter(self._storage.discover(
+                path, environ.get("HTTP_DEPTH", "0")))
             # take root item for rights checking
-            item = next(items, None)
+            item = next(items_iter, None)
             if not item:
                 return httputils.NOT_FOUND
             if not access.check("r", item):
                 return httputils.NOT_ALLOWED
             # put item back
-            items = itertools.chain([item], items)
-            allowed_items = self._collect_allowed_items(items, user)
+            items_iter = itertools.chain([item], items_iter)
+            allowed_items = self._collect_allowed_items(items_iter, user)
             headers = {"DAV": httputils.DAV_HEADERS,
                        "Content-Type": "text/xml; charset=%s" % self._encoding}
-            status, xml_answer = xml_propfind(
-                base_prefix, path, xml_content, allowed_items, user,
-                self._encoding)
-            if status == client.FORBIDDEN and xml_answer is None:
+            xml_answer = xml_propfind(base_prefix, path, xml_content,
+                                      allowed_items, user, self._encoding)
+            if xml_answer is None:
                 return httputils.NOT_ALLOWED
-            return status, headers, self._xml_response(xml_answer)
+            return client.MULTI_STATUS, headers, self._xml_response(xml_answer)

+ 20 - 18
radicale/app/proppatch.py

@@ -17,18 +17,20 @@
 # You should have received a copy of the GNU General Public License
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
-import contextlib
 import socket
 import xml.etree.ElementTree as ET
 from http import client
+from typing import Dict, Optional, cast
 
-from radicale import app, httputils
-from radicale import item as radicale_item
-from radicale import storage, xmlutils
+import radicale.item as radicale_item
+from radicale import httputils, storage, types, xmlutils
+from radicale.app.base import Access, ApplicationBase
 from radicale.log import logger
 
 
-def xml_proppatch(base_prefix, path, xml_request, collection):
+def xml_proppatch(base_prefix: str, path: str,
+                  xml_request: Optional[ET.Element],
+                  collection: storage.BaseCollection) -> ET.Element:
     """Read and answer PROPPATCH requests.
 
     Read rfc4918-9.2 for info.
@@ -49,24 +51,24 @@ def xml_proppatch(base_prefix, path, xml_request, collection):
     propstat.append(status)
     response.append(propstat)
 
-    new_props = collection.get_meta()
-    for short_name, value in xmlutils.props_from_request(xml_request).items():
-        if value is None:
-            with contextlib.suppress(KeyError):
-                del new_props[short_name]
-        else:
-            new_props[short_name] = value
+    props_with_remove = xmlutils.props_from_request(xml_request)
+    all_props_with_remove = cast(Dict[str, Optional[str]],
+                                 dict(collection.get_meta()))
+    all_props_with_remove.update(props_with_remove)
+    all_props = radicale_item.check_and_sanitize_props(all_props_with_remove)
+    collection.set_meta(all_props)
+    for short_name in props_with_remove:
         props_ok.append(ET.Element(xmlutils.make_clark(short_name)))
-    radicale_item.check_and_sanitize_props(new_props)
-    collection.set_meta(new_props)
 
     return multistatus
 
 
-class ApplicationProppatchMixin:
-    def do_PROPPATCH(self, environ, base_prefix, path, user):
+class ApplicationPartProppatch(ApplicationBase):
+
+    def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str,
+                     path: str, user: str) -> types.WSGIResponse:
         """Manage PROPPATCH request."""
-        access = app.Access(self._rights, user, path)
+        access = Access(self._rights, user, path)
         if not access.check("w"):
             return httputils.NOT_ALLOWED
         try:
@@ -79,7 +81,7 @@ class ApplicationProppatchMixin:
             logger.debug("Client timed out", exc_info=True)
             return httputils.REQUEST_TIMEOUT
         with self._storage.acquire_lock("w", user):
-            item = next(self._storage.discover(path), None)
+            item = next(iter(self._storage.discover(path)), None)
             if not item:
                 return httputils.NOT_FOUND
             if not access.check("w", item):

+ 43 - 29
radicale/app/put.py

@@ -22,20 +22,30 @@ import posixpath
 import socket
 import sys
 from http import client
+from types import TracebackType
+from typing import Iterator, List, Mapping, MutableMapping, Optional, Tuple
 
 import vobject
 
-from radicale import app, httputils
-from radicale import item as radicale_item
-from radicale import pathutils, rights, storage, xmlutils
+import radicale.item as radicale_item
+from radicale import httputils, pathutils, rights, storage, types, xmlutils
+from radicale.app.base import Access, ApplicationBase
 from radicale.log import logger
 
-MIMETYPE_TAGS = {value: key for key, value in xmlutils.MIMETYPES.items()}
+MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in
+                                    xmlutils.MIMETYPES.items()}
 
 
-def prepare(vobject_items, path, content_type, permissions, parent_permissions,
-            tag=None, write_whole_collection=None):
-    if (write_whole_collection or permissions and not parent_permissions):
+def prepare(vobject_items: List[vobject.base.Component], path: str,
+            content_type: str, permission: bool, parent_permission: bool,
+            tag: Optional[str] = None,
+            write_whole_collection: Optional[bool] = None) -> Tuple[
+                Iterator[radicale_item.Item],  # items
+                Optional[str],  # tag
+                Optional[bool],  # write_whole_collection
+                Optional[MutableMapping[str, str]],  # props
+                Optional[Tuple[type, BaseException, Optional[TracebackType]]]]:
+    if (write_whole_collection or permission and not parent_permission):
         write_whole_collection = True
         tag = radicale_item.predict_tag_of_whole_collection(
             vobject_items, MIMETYPE_TAGS.get(content_type))
@@ -43,20 +53,20 @@ def prepare(vobject_items, path, content_type, permissions, parent_permissions,
             raise ValueError("Can't determine collection tag")
         collection_path = pathutils.strip_path(path)
     elif (write_whole_collection is not None and not write_whole_collection or
-          not permissions and parent_permissions):
+          not permission and parent_permission):
         write_whole_collection = False
         if tag is None:
             tag = radicale_item.predict_tag_of_parent_collection(vobject_items)
         collection_path = posixpath.dirname(pathutils.strip_path(path))
-    props = None
+    props: Optional[MutableMapping[str, str]] = None
     stored_exc_info = None
     items = []
     try:
-        if tag:
+        if tag and write_whole_collection is not None:
             radicale_item.check_and_sanitize_items(
                 vobject_items, is_collection=write_whole_collection, tag=tag)
             if write_whole_collection and tag == "VCALENDAR":
-                vobject_components = []
+                vobject_components: List[vobject.base.Component] = []
                 vobject_item, = vobject_items
                 for content in ("vevent", "vtodo", "vjournal"):
                     vobject_components.extend(
@@ -98,23 +108,25 @@ def prepare(vobject_items, path, content_type, permissions, parent_permissions,
                     caldesc = vobject_items[0].x_wr_caldesc.value
                     if caldesc:
                         props["C:calendar-description"] = caldesc
-            radicale_item.check_and_sanitize_props(props)
+            props = radicale_item.check_and_sanitize_props(props)
     except Exception:
-        stored_exc_info = sys.exc_info()
+        exc_info_or_none_tuple = sys.exc_info()
+        assert exc_info_or_none_tuple[0] is not None
+        stored_exc_info = exc_info_or_none_tuple
 
-    # Use generator for items and delete references to free memory
-    # early
-    def items_generator():
+    # Use iterator for items and delete references to free memory early
+    def items_iter() -> Iterator[radicale_item.Item]:
         while items:
             yield items.pop(0)
-    return (items_generator(), tag, write_whole_collection, props,
-            stored_exc_info)
+    return items_iter(), tag, write_whole_collection, props, stored_exc_info
 
 
-class ApplicationPutMixin:
-    def do_PUT(self, environ, base_prefix, path, user):
+class ApplicationPartPut(ApplicationBase):
+
+    def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
+               path: str, user: str) -> types.WSGIResponse:
         """Manage PUT request."""
-        access = app.Access(self._rights, user, path)
+        access = Access(self._rights, user, path)
         if not access.check("w"):
             return httputils.NOT_ALLOWED
         try:
@@ -126,9 +138,10 @@ class ApplicationPutMixin:
             logger.debug("Client timed out", exc_info=True)
             return httputils.REQUEST_TIMEOUT
         # Prepare before locking
-        content_type = environ.get("CONTENT_TYPE", "").split(";")[0]
+        content_type = environ.get("CONTENT_TYPE", "").split(";",
+                                                             maxsplit=1)[0]
         try:
-            vobject_items = tuple(vobject.readComponents(content or ""))
+            vobject_items = list(vobject.readComponents(content or ""))
         except Exception as e:
             logger.warning(
                 "Bad PUT request on %r: %s", path, e, exc_info=True)
@@ -140,20 +153,20 @@ class ApplicationPutMixin:
              bool(rights.intersect(access.parent_permissions, "w")))
 
         with self._storage.acquire_lock("w", user):
-            item = next(self._storage.discover(path), None)
-            parent_item = next(
-                self._storage.discover(access.parent_path), None)
-            if not parent_item:
+            item = next(iter(self._storage.discover(path)), None)
+            parent_item = next(iter(
+                self._storage.discover(access.parent_path)), None)
+            if not isinstance(parent_item, storage.BaseCollection):
                 return httputils.CONFLICT
 
             write_whole_collection = (
                 isinstance(item, storage.BaseCollection) or
-                not parent_item.get_meta("tag"))
+                not parent_item.tag)
 
             if write_whole_collection:
                 tag = prepared_tag
             else:
-                tag = parent_item.get_meta("tag")
+                tag = parent_item.tag
 
             if write_whole_collection:
                 if ("w" if tag else "W") not in access.permissions:
@@ -198,6 +211,7 @@ class ApplicationPutMixin:
                         "Bad PUT request on %r: %s", path, e, exc_info=True)
                     return httputils.BAD_REQUEST
             else:
+                assert not isinstance(item, storage.BaseCollection)
                 prepared_item, = prepared_items
                 if (item and item.uid != prepared_item.uid or
                         not item and parent_item.has_uid(prepared_item.uid)):

+ 115 - 101
radicale/app/report.py

@@ -22,15 +22,20 @@ import posixpath
 import socket
 import xml.etree.ElementTree as ET
 from http import client
+from typing import Callable, Iterable, Iterator, Optional, Sequence, Tuple
 from urllib.parse import unquote, urlparse
 
-from radicale import app, httputils, pathutils, storage, xmlutils
+import radicale.item as radicale_item
+from radicale import httputils, pathutils, storage, types, xmlutils
+from radicale.app.base import Access, ApplicationBase
 from radicale.item import filter as radicale_filter
 from radicale.log import logger
 
 
-def xml_report(base_prefix, path, xml_request, collection, encoding,
-               unlock_storage_fn):
+def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
+               collection: storage.BaseCollection, encoding: str,
+               unlock_storage_fn: Callable[[], None]
+               ) -> Tuple[int, ET.Element]:
     """Read and answer REPORT requests.
 
     Read rfc3253-3.6 for info.
@@ -40,10 +45,9 @@ def xml_report(base_prefix, path, xml_request, collection, encoding,
     if xml_request is None:
         return client.MULTI_STATUS, multistatus
     root = xml_request
-    if root.tag in (
-            xmlutils.make_clark("D:principal-search-property-set"),
-            xmlutils.make_clark("D:principal-property-search"),
-            xmlutils.make_clark("D:expand-property")):
+    if root.tag in (xmlutils.make_clark("D:principal-search-property-set"),
+                    xmlutils.make_clark("D:principal-property-search"),
+                    xmlutils.make_clark("D:expand-property")):
         # We don't support searching for principals or indirect retrieving of
         # properties, just return an empty result.
         # InfCloud asks for expand-property reports (even if we don't announce
@@ -52,28 +56,28 @@ def xml_report(base_prefix, path, xml_request, collection, encoding,
                        xmlutils.make_human_tag(root.tag), path)
         return client.MULTI_STATUS, multistatus
     if (root.tag == xmlutils.make_clark("C:calendar-multiget") and
-            collection.get_meta("tag") != "VCALENDAR" or
+            collection.tag != "VCALENDAR" or
             root.tag == xmlutils.make_clark("CR:addressbook-multiget") and
-            collection.get_meta("tag") != "VADDRESSBOOK" or
+            collection.tag != "VADDRESSBOOK" or
             root.tag == xmlutils.make_clark("D:sync-collection") and
-            collection.get_meta("tag") not in ("VADDRESSBOOK", "VCALENDAR")):
+            collection.tag not in ("VADDRESSBOOK", "VCALENDAR")):
         logger.warning("Invalid REPORT method %r on %r requested",
                        xmlutils.make_human_tag(root.tag), path)
-        return (client.FORBIDDEN,
-                xmlutils.webdav_error("D:supported-report"))
+        return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
     prop_element = root.find(xmlutils.make_clark("D:prop"))
-    props = (
-        [prop.tag for prop in prop_element]
-        if prop_element is not None else [])
+    props = ([prop.tag for prop in prop_element]
+             if prop_element is not None else [])
 
+    hreferences: Iterable[str]
     if root.tag in (
             xmlutils.make_clark("C:calendar-multiget"),
             xmlutils.make_clark("CR:addressbook-multiget")):
         # Read rfc4791-7.9 for info
         hreferences = set()
         for href_element in root.findall(xmlutils.make_clark("D:href")):
-            href_path = pathutils.sanitize_path(
-                unquote(urlparse(href_element.text).path))
+            temp_url_path = urlparse(href_element.text).path
+            assert isinstance(temp_url_path, str)
+            href_path = pathutils.sanitize_path(unquote(temp_url_path))
             if (href_path + "/").startswith(base_prefix + "/"):
                 hreferences.add(href_path[len(base_prefix):])
             else:
@@ -107,82 +111,13 @@ def xml_report(base_prefix, path, xml_request, collection, encoding,
         root.findall(xmlutils.make_clark("C:filter")) +
         root.findall(xmlutils.make_clark("CR:filter")))
 
-    def retrieve_items(collection, hreferences, multistatus):
-        """Retrieves all items that are referenced in ``hreferences`` from
-           ``collection`` and adds 404 responses for missing and invalid items
-           to ``multistatus``."""
-        collection_requested = False
-
-        def get_names():
-            """Extracts all names from references in ``hreferences`` and adds
-               404 responses for invalid references to ``multistatus``.
-               If the whole collections is referenced ``collection_requested``
-               gets set to ``True``."""
-            nonlocal collection_requested
-            for hreference in hreferences:
-                try:
-                    name = pathutils.name_from_path(hreference, collection)
-                except ValueError as e:
-                    logger.warning("Skipping invalid path %r in REPORT request"
-                                   " on %r: %s", hreference, path, e)
-                    response = xml_item_response(base_prefix, hreference,
-                                                 found_item=False)
-                    multistatus.append(response)
-                    continue
-                if name:
-                    # Reference is an item
-                    yield name
-                else:
-                    # Reference is a collection
-                    collection_requested = True
-
-        for name, item in collection.get_multi(get_names()):
-            if not item:
-                uri = pathutils.unstrip_path(
-                    posixpath.join(collection.path, name))
-                response = xml_item_response(base_prefix, uri,
-                                             found_item=False)
-                multistatus.append(response)
-            else:
-                yield item, False
-        if collection_requested:
-            yield from collection.get_filtered(filters)
-
     # Retrieve everything required for finishing the request.
-    retrieved_items = list(retrieve_items(collection, hreferences,
-                                          multistatus))
-    collection_tag = collection.get_meta("tag")
-    # Don't access storage after this!
+    retrieved_items = list(retrieve_items(
+        base_prefix, path, collection, hreferences, filters, multistatus))
+    collection_tag = collection.tag
+    # !!! Don't access storage after this !!!
     unlock_storage_fn()
 
-    def match(item, filter_):
-        tag = collection_tag
-        if (tag == "VCALENDAR" and
-                filter_.tag != xmlutils.make_clark("C:%s" % filter_)):
-            if len(filter_) == 0:
-                return True
-            if len(filter_) > 1:
-                raise ValueError("Filter with %d children" % len(filter_))
-            if filter_[0].tag != xmlutils.make_clark("C:comp-filter"):
-                raise ValueError("Unexpected %r in filter" % filter_[0].tag)
-            return radicale_filter.comp_match(item, filter_[0])
-        if (tag == "VADDRESSBOOK" and
-                filter_.tag != xmlutils.make_clark("CR:%s" % filter_)):
-            for child in filter_:
-                if child.tag != xmlutils.make_clark("CR:prop-filter"):
-                    raise ValueError("Unexpected %r in filter" % child.tag)
-            test = filter_.get("test", "anyof")
-            if test == "anyof":
-                return any(
-                    radicale_filter.prop_match(item.vobject_item, f, "CR")
-                    for f in filter_)
-            if test == "allof":
-                return all(
-                    radicale_filter.prop_match(item.vobject_item, f, "CR")
-                    for f in filter_)
-            raise ValueError("Unsupported filter test: %r" % test)
-        raise ValueError("Unsupported filter %r for %r" % (filter_.tag, tag))
-
     while retrieved_items:
         # ``item.vobject_item`` might be accessed during filtering.
         # Don't keep reference to ``item``, because VObject requires a lot of
@@ -190,7 +125,8 @@ def xml_report(base_prefix, path, xml_request, collection, encoding,
         item, filters_matched = retrieved_items.pop(0)
         if filters and not filters_matched:
             try:
-                if not all(match(item, filter_) for filter_ in filters):
+                if not all(test_filter(collection_tag, item, filter_)
+                           for filter_ in filters):
                     continue
             except ValueError as e:
                 raise ValueError("Failed to filter item %r from %r: %s" %
@@ -218,6 +154,7 @@ def xml_report(base_prefix, path, xml_request, collection, encoding,
             else:
                 not_found_props.append(element)
 
+        assert item.href
         uri = pathutils.unstrip_path(
             posixpath.join(collection.path, item.href))
         multistatus.append(xml_item_response(
@@ -227,8 +164,10 @@ def xml_report(base_prefix, path, xml_request, collection, encoding,
     return client.MULTI_STATUS, multistatus
 
 
-def xml_item_response(base_prefix, href, found_props=(), not_found_props=(),
-                      found_item=True):
+def xml_item_response(base_prefix: str, href: str,
+                      found_props: Sequence[ET.Element] = (),
+                      not_found_props: Sequence[ET.Element] = (),
+                      found_item: bool = True) -> ET.Element:
     response = ET.Element(xmlutils.make_clark("D:response"))
 
     href_element = ET.Element(xmlutils.make_clark("D:href"))
@@ -255,24 +194,98 @@ def xml_item_response(base_prefix, href, found_props=(), not_found_props=(),
     return response
 
 
-class ApplicationReportMixin:
-    def do_REPORT(self, environ, base_prefix, path, user):
+def retrieve_items(
+        base_prefix: str, path: str, collection: storage.BaseCollection,
+        hreferences: Iterable[str], filters: Sequence[ET.Element],
+        multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]:
+    """Retrieves all items that are referenced in ``hreferences`` from
+       ``collection`` and adds 404 responses for missing and invalid items
+       to ``multistatus``."""
+    collection_requested = False
+
+    def get_names() -> Iterator[str]:
+        """Extracts all names from references in ``hreferences`` and adds
+           404 responses for invalid references to ``multistatus``.
+           If the whole collections is referenced ``collection_requested``
+           gets set to ``True``."""
+        nonlocal collection_requested
+        for hreference in hreferences:
+            try:
+                name = pathutils.name_from_path(hreference, collection)
+            except ValueError as e:
+                logger.warning("Skipping invalid path %r in REPORT request on "
+                               "%r: %s", hreference, path, e)
+                response = xml_item_response(base_prefix, hreference,
+                                             found_item=False)
+                multistatus.append(response)
+                continue
+            if name:
+                # Reference is an item
+                yield name
+            else:
+                # Reference is a collection
+                collection_requested = True
+
+    for name, item in collection.get_multi(get_names()):
+        if not item:
+            uri = pathutils.unstrip_path(posixpath.join(collection.path, name))
+            response = xml_item_response(base_prefix, uri, found_item=False)
+            multistatus.append(response)
+        else:
+            yield item, False
+    if collection_requested:
+        yield from collection.get_filtered(filters)
+
+
+def test_filter(collection_tag: str, item: radicale_item.Item,
+                filter_: ET.Element) -> bool:
+    """Match an item against a filter."""
+    if (collection_tag == "VCALENDAR" and
+            filter_.tag != xmlutils.make_clark("C:%s" % filter_)):
+        if len(filter_) == 0:
+            return True
+        if len(filter_) > 1:
+            raise ValueError("Filter with %d children" % len(filter_))
+        if filter_[0].tag != xmlutils.make_clark("C:comp-filter"):
+            raise ValueError("Unexpected %r in filter" % filter_[0].tag)
+        return radicale_filter.comp_match(item, filter_[0])
+    if (collection_tag == "VADDRESSBOOK" and
+            filter_.tag != xmlutils.make_clark("CR:%s" % filter_)):
+        for child in filter_:
+            if child.tag != xmlutils.make_clark("CR:prop-filter"):
+                raise ValueError("Unexpected %r in filter" % child.tag)
+        test = filter_.get("test", "anyof")
+        if test == "anyof":
+            return any(radicale_filter.prop_match(item.vobject_item, f, "CR")
+                       for f in filter_)
+        if test == "allof":
+            return all(radicale_filter.prop_match(item.vobject_item, f, "CR")
+                       for f in filter_)
+        raise ValueError("Unsupported filter test: %r" % test)
+    raise ValueError("Unsupported filter %r for %r" %
+                     (filter_.tag, collection_tag))
+
+
+class ApplicationPartReport(ApplicationBase):
+
+    def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
+                  path: str, user: str) -> types.WSGIResponse:
         """Manage REPORT request."""
-        access = app.Access(self._rights, user, path)
+        access = Access(self._rights, user, path)
         if not access.check("r"):
             return httputils.NOT_ALLOWED
         try:
             xml_content = self._read_xml_request_body(environ)
         except RuntimeError as e:
-            logger.warning(
-                "Bad REPORT request on %r: %s", path, e, exc_info=True)
+            logger.warning("Bad REPORT request on %r: %s", path, e,
+                           exc_info=True)
             return httputils.BAD_REQUEST
         except socket.timeout:
             logger.debug("Client timed out", exc_info=True)
             return httputils.REQUEST_TIMEOUT
         with contextlib.ExitStack() as lock_stack:
             lock_stack.enter_context(self._storage.acquire_lock("r", user))
-            item = next(self._storage.discover(path), None)
+            item = next(iter(self._storage.discover(path)), None)
             if not item:
                 return httputils.NOT_FOUND
             if not access.check("r", item):
@@ -280,8 +293,8 @@ class ApplicationReportMixin:
             if isinstance(item, storage.BaseCollection):
                 collection = item
             else:
+                assert item.collection is not None
                 collection = item.collection
-            headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
             try:
                 status, xml_answer = xml_report(
                     base_prefix, path, xml_content, collection, self._encoding,
@@ -290,4 +303,5 @@ class ApplicationReportMixin:
                 logger.warning(
                     "Bad REPORT request on %r: %s", path, e, exc_info=True)
                 return httputils.BAD_REQUEST
-            return status, headers, self._xml_response(xml_answer)
+        headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
+        return status, headers, self._xml_response(xml_answer)

+ 13 - 7
radicale/auth/__init__.py

@@ -28,18 +28,23 @@ Take a look at the class ``BaseAuth`` if you want to implement your own.
 
 """
 
-from radicale import utils
+from typing import Sequence, Tuple, Union
 
-INTERNAL_TYPES = ("none", "remote_user", "http_x_remote_user", "htpasswd")
+from radicale import config, types, utils
 
+INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
+                                 "htpasswd")
 
-def load(configuration):
+
+def load(configuration: "config.Configuration") -> "BaseAuth":
     """Load the authentication module chosen in configuration."""
-    return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", configuration)
+    return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth,
+                             configuration)
 
 
 class BaseAuth:
-    def __init__(self, configuration):
+
+    def __init__(self, configuration: "config.Configuration") -> None:
         """Initialize BaseAuth.
 
         ``configuration`` see ``radicale.config`` module.
@@ -49,7 +54,8 @@ class BaseAuth:
         """
         self.configuration = configuration
 
-    def get_external_login(self, environ):
+    def get_external_login(self, environ: types.WSGIEnviron) -> Union[
+            Tuple[()], Tuple[str, str]]:
         """Optionally provide the login and password externally.
 
         ``environ`` a dict with the WSGI environment
@@ -61,7 +67,7 @@ class BaseAuth:
         """
         return ()
 
-    def login(self, login, password):
+    def login(self, login: str, password: str) -> str:
         """Check credentials and map login to internal user
 
         ``login`` the login name

+ 13 - 8
radicale/auth/htpasswd.py

@@ -49,18 +49,23 @@ When passlib[bcrypt] is installed:
 
 import functools
 import hmac
+from typing import Any
 
 from passlib.hash import apr_md5_crypt
 
-from radicale import auth
+from radicale import auth, config
 
 
 class Auth(auth.BaseAuth):
-    def __init__(self, configuration):
+
+    _filename: str
+    _encoding: str
+
+    def __init__(self, configuration: config.Configuration) -> None:
         super().__init__(configuration)
         self._filename = configuration.get("auth", "htpasswd_filename")
-        self._encoding = self.configuration.get("encoding", "stock")
-        encryption = configuration.get("auth", "htpasswd_encryption")
+        self._encoding = configuration.get("encoding", "stock")
+        encryption: str = configuration.get("auth", "htpasswd_encryption")
 
         if encryption == "plain":
             self._verify = self._plain
@@ -82,17 +87,17 @@ class Auth(auth.BaseAuth):
             raise RuntimeError("The htpasswd encryption method %r is not "
                                "supported." % encryption)
 
-    def _plain(self, hash_value, password):
+    def _plain(self, hash_value: str, password: str) -> bool:
         """Check if ``hash_value`` and ``password`` match, plain method."""
         return hmac.compare_digest(hash_value.encode(), password.encode())
 
-    def _bcrypt(self, bcrypt, hash_value, password):
+    def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> bool:
         return bcrypt.verify(password, hash_value.strip())
 
-    def _md5apr1(self, hash_value, password):
+    def _md5apr1(self, hash_value: str, password: str) -> bool:
         return apr_md5_crypt.verify(password, hash_value.strip())
 
-    def login(self, login, password):
+    def login(self, login: str, password: str) -> str:
         """Validate credentials.
 
         Iterate through htpasswd credential file until login matches, extract

+ 7 - 2
radicale/auth/http_x_remote_user.py

@@ -26,9 +26,14 @@ if the reverse proxy is not configured properly.
 
 """
 
-import radicale.auth.none as none
+from typing import Tuple, Union
+
+from radicale import types
+from radicale.auth import none
 
 
 class Auth(none.Auth):
-    def get_external_login(self, environ):
+
+    def get_external_login(self, environ: types.WSGIEnviron) -> Union[
+            Tuple[()], Tuple[str, str]]:
         return environ.get("HTTP_X_REMOTE_USER", ""), ""

+ 2 - 1
radicale/auth/none.py

@@ -26,5 +26,6 @@ from radicale import auth
 
 
 class Auth(auth.BaseAuth):
-    def login(self, login, password):
+
+    def login(self, login: str, password: str) -> str:
         return login

+ 7 - 2
radicale/auth/remote_user.py

@@ -25,9 +25,14 @@ It's intended for use with an external WSGI server.
 
 """
 
-import radicale.auth.none as none
+from typing import Tuple, Union
+
+from radicale import types
+from radicale.auth import none
 
 
 class Auth(none.Auth):
-    def get_external_login(self, environ):
+
+    def get_external_login(self, environ: types.WSGIEnviron
+                           ) -> Union[Tuple[()], Tuple[str, str]]:
         return environ.get("REMOTE_USER", ""), ""

+ 63 - 43
radicale/config.py

@@ -29,25 +29,27 @@ import contextlib
 import math
 import os
 import string
+import sys
 from collections import OrderedDict
 from configparser import RawConfigParser
-from typing import Any, ClassVar
+from typing import (Any, Callable, ClassVar, Iterable, List, Optional,
+                    Sequence, Tuple, TypeVar, Union)
 
-from radicale import auth, rights, storage, web
+from radicale import auth, rights, storage, types, web
 
-DEFAULT_CONFIG_PATH = os.pathsep.join([
+DEFAULT_CONFIG_PATH: str = os.pathsep.join([
     "?/etc/radicale/config",
     "?~/.config/radicale/config"])
 
 
-def positive_int(value):
+def positive_int(value: Any) -> int:
     value = int(value)
     if value < 0:
         raise ValueError("value is negative: %d" % value)
     return value
 
 
-def positive_float(value):
+def positive_float(value: Any) -> float:
     value = float(value)
     if not math.isfinite(value):
         raise ValueError("value is infinite")
@@ -58,22 +60,22 @@ def positive_float(value):
     return value
 
 
-def logging_level(value):
+def logging_level(value: Any) -> str:
     if value not in ("debug", "info", "warning", "error", "critical"):
         raise ValueError("unsupported level: %r" % value)
     return value
 
 
-def filepath(value):
+def filepath(value: Any) -> str:
     if not value:
         return ""
     value = os.path.expanduser(value)
-    if os.name == "nt":
+    if sys.platform == "win32":
         value = os.path.expandvars(value)
     return os.path.abspath(value)
 
 
-def list_of_ip_address(value):
+def list_of_ip_address(value: Any) -> List[Tuple[str, int]]:
     def ip_address(value):
         try:
             address, port = value.rsplit(":", 1)
@@ -83,25 +85,25 @@ def list_of_ip_address(value):
     return [ip_address(s) for s in value.split(",")]
 
 
-def str_or_callable(value):
+def str_or_callable(value: Any) -> Union[str, Callable]:
     if callable(value):
         return value
     return str(value)
 
 
-def unspecified_type(value):
+def unspecified_type(value: Any) -> Any:
     return value
 
 
-def _convert_to_bool(value):
+def _convert_to_bool(value: Any) -> bool:
     if value.lower() not in RawConfigParser.BOOLEAN_STATES:
         raise ValueError("not a boolean: %r" % value)
     return RawConfigParser.BOOLEAN_STATES[value.lower()]
 
 
-INTERNAL_OPTIONS = ("_allow_extra",)
+INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",)
 # Default configuration
-DEFAULT_CONFIG_SCHEMA = OrderedDict([
+DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
     ("server", OrderedDict([
         ("hosts", {
             "value": "localhost:5232",
@@ -227,7 +229,8 @@ DEFAULT_CONFIG_SCHEMA = OrderedDict([
         ("_allow_extra", str)]))])
 
 
-def parse_compound_paths(*compound_paths):
+def parse_compound_paths(*compound_paths: Optional[str]
+                         ) -> List[Tuple[str, bool]]:
     """Parse a compound path and return the individual paths.
     Paths in a compound path are joined by ``os.pathsep``. If a path starts
     with ``?`` the return value ``IGNORE_IF_MISSING`` is set.
@@ -253,7 +256,8 @@ def parse_compound_paths(*compound_paths):
     return paths
 
 
-def load(paths=()):
+def load(paths: Optional[Iterable[Tuple[str, bool]]] = None
+         ) -> "Configuration":
     """
     Create instance of ``Configuration`` for use with
     ``radicale.app.Application``.
@@ -266,6 +270,8 @@ def load(paths=()):
     The configuration can later be changed with ``Configuration.update()``.
 
     """
+    if paths is None:
+        paths = []
     configuration = Configuration(DEFAULT_CONFIG_SCHEMA)
     for path, ignore_if_missing in paths:
         parser = RawConfigParser()
@@ -279,16 +285,24 @@ def load(paths=()):
                 config = {s: {o: parser[s][o] for o in parser.options(s)}
                           for s in parser.sections()}
         except Exception as e:
-            raise RuntimeError(
-                "Failed to load %s: %s" % (config_source, e)) from e
+            raise RuntimeError("Failed to load %s: %s" % (config_source, e)
+                               ) from e
         configuration.update(config, config_source)
     return configuration
 
 
+_Self = TypeVar("_Self", bound="Configuration")
+
+
 class Configuration:
-    SOURCE_MISSING: ClassVar[Any] = {}
 
-    def __init__(self, schema):
+    SOURCE_MISSING: ClassVar[types.CONFIG] = {}
+
+    _schema: types.CONFIG_SCHEMA
+    _values: types.MUTABLE_CONFIG
+    _configs: List[Tuple[types.CONFIG, str, bool]]
+
+    def __init__(self, schema: types.CONFIG_SCHEMA) -> None:
         """Initialize configuration.
 
         ``schema`` a dict that describes the configuration format.
@@ -309,7 +323,8 @@ class Configuration:
                    for section in self._schema}
         self.update(default, "default config", privileged=True)
 
-    def update(self, config, source=None, privileged=False):
+    def update(self, config: types.CONFIG, source: Optional[str] = None,
+               privileged: bool = False) -> None:
         """Update the configuration.
 
         ``config`` a dict of the format {SECTION: {OPTION: VALUE, ...}, ...}.
@@ -323,8 +338,9 @@ class Configuration:
         ``privileged`` allows updating sections and options starting with "_".
 
         """
-        source = source or "unspecified config"
-        new_values = {}
+        if source is None:
+            source = "unspecified config"
+        new_values: types.MUTABLE_CONFIG = {}
         for section in config:
             if (section not in self._schema or
                     section.startswith("_") and not privileged):
@@ -363,40 +379,41 @@ class Configuration:
             self._values[section] = self._values.get(section, {})
             self._values[section].update(new_values[section])
 
-    def get(self, section, option):
+    def get(self, section: str, option: str) -> Any:
         """Get the value of ``option`` in ``section``."""
         with contextlib.suppress(KeyError):
             return self._values[section][option]
         raise KeyError(section, option)
 
-    def get_raw(self, section, option):
+    def get_raw(self, section: str, option: str) -> Any:
         """Get the raw value of ``option`` in ``section``."""
         for config, _, _ in reversed(self._configs):
             if option in config.get(section, {}):
                 return config[section][option]
         raise KeyError(section, option)
 
-    def get_source(self, section, option):
+    def get_source(self, section: str, option: str) -> str:
         """Get the source that provides ``option`` in ``section``."""
         for config, source, _ in reversed(self._configs):
             if option in config.get(section, {}):
                 return source
         raise KeyError(section, option)
 
-    def sections(self):
+    def sections(self) -> List[str]:
         """List all sections."""
-        return self._values.keys()
+        return list(self._values.keys())
 
-    def options(self, section):
+    def options(self, section: str) -> List[str]:
         """List all options in ``section``"""
-        return self._values[section].keys()
+        return list(self._values[section].keys())
 
-    def sources(self):
+    def sources(self) -> List[Tuple[str, bool]]:
         """List all config sources."""
         return [(source, config is self.SOURCE_MISSING) for
                 config, source, _ in self._configs]
 
-    def copy(self, plugin_schema=None):
+    def copy(self: _Self, plugin_schema: Optional[types.CONFIG_SCHEMA] = None
+             ) -> _Self:
         """Create a copy of the configuration
 
         ``plugin_schema`` is a optional dict that contains additional options
@@ -406,20 +423,23 @@ class Configuration:
         if plugin_schema is None:
             schema = self._schema
         else:
-            schema = self._schema.copy()
+            new_schema = dict(self._schema)
             for section, options in plugin_schema.items():
-                if (section not in schema or "type" not in schema[section] or
-                        "internal" not in schema[section]["type"]):
+                if (section not in new_schema or
+                        "type" not in new_schema[section] or
+                        "internal" not in new_schema[section]["type"]):
                     raise ValueError("not a plugin section: %r" % section)
-                schema[section] = schema[section].copy()
-                schema[section]["type"] = schema[section]["type"].copy()
-                schema[section]["type"]["internal"] = [
-                    self.get(section, "type")]
+                new_section = dict(new_schema[section])
+                new_type = dict(new_section["type"])
+                new_type["internal"] = (self.get(section, "type"),)
+                new_section["type"] = new_type
                 for option, value in options.items():
-                    if option in schema[section]:
-                        raise ValueError("option already exists in %r: %r" % (
-                            section, option))
-                    schema[section][option] = value
+                    if option in new_section:
+                        raise ValueError("option already exists in %r: %r" %
+                                         (section, option))
+                    new_section[option] = value
+                new_schema[section] = new_section
+            schema = new_schema
         copy = type(self)(schema)
         for config, source, privileged in self._configs:
             copy.update(config, source, privileged)

+ 27 - 23
radicale/httputils.py

@@ -22,53 +22,57 @@ Helper functions for HTTP.
 
 """
 
+import contextlib
 from http import client
+from typing import List, cast
 
+from radicale import config, types
 from radicale.log import logger
 
-NOT_ALLOWED = (
+NOT_ALLOWED: types.WSGIResponse = (
     client.FORBIDDEN, (("Content-Type", "text/plain"),),
     "Access to the requested resource forbidden.")
-FORBIDDEN = (
+FORBIDDEN: types.WSGIResponse = (
     client.FORBIDDEN, (("Content-Type", "text/plain"),),
     "Action on the requested resource refused.")
-BAD_REQUEST = (
+BAD_REQUEST: types.WSGIResponse = (
     client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request")
-NOT_FOUND = (
+NOT_FOUND: types.WSGIResponse = (
     client.NOT_FOUND, (("Content-Type", "text/plain"),),
     "The requested resource could not be found.")
-CONFLICT = (
+CONFLICT: types.WSGIResponse = (
     client.CONFLICT, (("Content-Type", "text/plain"),),
     "Conflict in the request.")
-METHOD_NOT_ALLOWED = (
+METHOD_NOT_ALLOWED: types.WSGIResponse = (
     client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
     "The method is not allowed on the requested resource.")
-PRECONDITION_FAILED = (
+PRECONDITION_FAILED: types.WSGIResponse = (
     client.PRECONDITION_FAILED,
     (("Content-Type", "text/plain"),), "Precondition failed.")
-REQUEST_TIMEOUT = (
+REQUEST_TIMEOUT: types.WSGIResponse = (
     client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
     "Connection timed out.")
-REQUEST_ENTITY_TOO_LARGE = (
+REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
     client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
     "Request body too large.")
-REMOTE_DESTINATION = (
+REMOTE_DESTINATION: types.WSGIResponse = (
     client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
     "Remote destination not supported.")
-DIRECTORY_LISTING = (
+DIRECTORY_LISTING: types.WSGIResponse = (
     client.FORBIDDEN, (("Content-Type", "text/plain"),),
     "Directory listings are not supported.")
-INTERNAL_SERVER_ERROR = (
+INTERNAL_SERVER_ERROR: types.WSGIResponse = (
     client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
     "A server error occurred.  Please contact the administrator.")
 
-DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
+DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
 
 
-def decode_request(configuration, environ, text):
+def decode_request(configuration: "config.Configuration",
+                   environ: types.WSGIEnviron, text: bytes) -> str:
     """Try to magically decode ``text`` according to given ``environ``."""
     # List of charsets to try
-    charsets = []
+    charsets: List[str] = []
 
     # First append content charset given in the request
     content_type = environ.get("CONTENT_TYPE")
@@ -76,7 +80,7 @@ def decode_request(configuration, environ, text):
         charsets.append(
             content_type.split("charset=")[1].split(";")[0].strip())
     # Then append default Radicale charset
-    charsets.append(configuration.get("encoding", "request"))
+    charsets.append(cast(str, configuration.get("encoding", "request")))
     # Then append various fallbacks
     charsets.append("utf-8")
     charsets.append("iso8859-1")
@@ -87,15 +91,14 @@ def decode_request(configuration, environ, text):
 
     # Try to decode
     for charset in charsets:
-        try:
+        with contextlib.suppress(UnicodeDecodeError):
             return text.decode(charset)
-        except UnicodeDecodeError:
-            pass
     raise UnicodeDecodeError("decode_request", text, 0, len(text),
                              "all codecs failed [%s]" % ", ".join(charsets))
 
 
-def read_raw_request_body(configuration, environ):
+def read_raw_request_body(configuration: "config.Configuration",
+                          environ: types.WSGIEnviron) -> bytes:
     content_length = int(environ.get("CONTENT_LENGTH") or 0)
     if not content_length:
         return b""
@@ -105,8 +108,9 @@ def read_raw_request_body(configuration, environ):
     return content
 
 
-def read_request_body(configuration, environ):
-    content = decode_request(
-        configuration, environ, read_raw_request_body(configuration, environ))
+def read_request_body(configuration: "config.Configuration",
+                      environ: types.WSGIEnviron) -> str:
+    content = decode_request(configuration, environ,
+                             read_raw_request_body(configuration, environ))
     logger.debug("Request content:\n%s", content)
     return content

+ 87 - 49
radicale/item/__init__.py

@@ -27,27 +27,35 @@ import binascii
 import math
 import os
 import sys
-from datetime import timedelta
+from datetime import datetime, timedelta
 from hashlib import sha256
+from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
+                    Tuple)
 
 import vobject
 
+from radicale import storage  # noqa:F401
 from radicale import pathutils
 from radicale.item import filter as radicale_filter
 from radicale.log import logger
 
 
-def predict_tag_of_parent_collection(vobject_items):
+def predict_tag_of_parent_collection(
+        vobject_items: Sequence[vobject.base.Component]) -> Optional[str]:
+    """Returns the predicted tag or `None`"""
     if len(vobject_items) != 1:
-        return ""
+        return None
     if vobject_items[0].name == "VCALENDAR":
         return "VCALENDAR"
     if vobject_items[0].name in ("VCARD", "VLIST"):
         return "VADDRESSBOOK"
-    return ""
+    return None
 
 
-def predict_tag_of_whole_collection(vobject_items, fallback_tag=None):
+def predict_tag_of_whole_collection(
+        vobject_items: Sequence[vobject.base.Component],
+        fallback_tag: Optional[str] = None) -> Optional[str]:
+    """Returns the predicted tag or `fallback_tag`"""
     if vobject_items and vobject_items[0].name == "VCALENDAR":
         return "VCALENDAR"
     if vobject_items and vobject_items[0].name in ("VCARD", "VLIST"):
@@ -58,9 +66,13 @@ def predict_tag_of_whole_collection(vobject_items, fallback_tag=None):
     return fallback_tag
 
 
-def check_and_sanitize_items(vobject_items, is_collection=False, tag=None):
+def check_and_sanitize_items(
+        vobject_items: List[vobject.base.Component],
+        is_collection: bool = False, tag: str = "") -> None:
     """Check vobject items for common errors and add missing UIDs.
 
+    Modifies the list `vobject_items`.
+
     ``is_collection`` indicates that vobject_item contains unrelated
     components.
 
@@ -169,9 +181,14 @@ def check_and_sanitize_items(vobject_items, is_collection=False, tag=None):
                              (i.name, repr(tag) if tag else "generic"))
 
 
-def check_and_sanitize_props(props):
-    """Check collection properties for common errors."""
-    for k, v in props.copy().items():  # Make copy to be able to delete items
+def check_and_sanitize_props(props: MutableMapping[Any, Any]
+                             ) -> MutableMapping[str, str]:
+    """Check collection properties for common errors.
+
+    Modifies the dict `props`.
+
+    """
+    for k, v in list(props.items()):  # Make copy to be able to delete items
         if not isinstance(k, str):
             raise ValueError("Key must be %r not %r: %r" % (
                 str.__name__, type(k).__name__, k))
@@ -182,14 +199,13 @@ def check_and_sanitize_props(props):
             raise ValueError("Value of %r must be %r not %r: %r" % (
                 k, str.__name__, type(v).__name__, v))
         if k == "tag":
-            if not v:
-                del props[k]
-                continue
-            if v not in ("VCALENDAR", "VADDRESSBOOK"):
+            if v not in ("", "VCALENDAR", "VADDRESSBOOK"):
                 raise ValueError("Unsupported collection tag: %r" % v)
+    return props
 
 
-def find_available_uid(exists_fn, suffix=""):
+def find_available_uid(exists_fn: Callable[[str], bool], suffix: str = ""
+                       ) -> str:
     """Generate a pseudo-random UID"""
     # Prevent infinite loop
     for _ in range(1000):
@@ -202,7 +218,7 @@ def find_available_uid(exists_fn, suffix=""):
     raise RuntimeError("No unique random sequence found")
 
 
-def get_etag(text):
+def get_etag(text: str) -> str:
     """Etag from collection or item.
 
     Encoded as quoted-string (see RFC 2616).
@@ -213,13 +229,13 @@ def get_etag(text):
     return '"%s"' % etag.hexdigest()
 
 
-def get_uid(vobject_component):
+def get_uid(vobject_component: vobject.base.Component) -> str:
     """UID value of an item if defined."""
-    return (vobject_component.uid.value
-            if hasattr(vobject_component, "uid") else None)
+    return (vobject_component.uid.value or ""
+            if hasattr(vobject_component, "uid") else "")
 
 
-def get_uid_from_object(vobject_item):
+def get_uid_from_object(vobject_item: vobject.base.Component) -> str:
     """UID value of an calendar/addressbook object."""
     if vobject_item.name == "VCALENDAR":
         if hasattr(vobject_item, "vevent"):
@@ -230,10 +246,10 @@ def get_uid_from_object(vobject_item):
             return get_uid(vobject_item.vtodo)
     elif vobject_item.name == "VCARD":
         return get_uid(vobject_item)
-    return None
+    return ""
 
 
-def find_tag(vobject_item):
+def find_tag(vobject_item: vobject.base.Component) -> str:
     """Find component name from ``vobject_item``."""
     if vobject_item.name == "VCALENDAR":
         for component in vobject_item.components():
@@ -242,22 +258,24 @@ def find_tag(vobject_item):
     return ""
 
 
-def find_tag_and_time_range(vobject_item):
-    """Find component name and enclosing time range from ``vobject item``.
+def find_time_range(vobject_item: vobject.base.Component, tag: str
+                    ) -> Tuple[int, int]:
+    """Find enclosing time range from ``vobject item``.
+
+    ``tag`` must be set to the return value of ``find_tag``.
 
-    Returns a tuple (``tag``, ``start``, ``end``) where ``tag`` is a string
-    and ``start`` and ``end`` are POSIX timestamps (as int).
+    Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are
+    POSIX timestamps.
 
     This is intened to be used for matching against simplified prefilters.
 
     """
-    tag = find_tag(vobject_item)
     if not tag:
-        return (
-            tag, radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX)
+        return radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX
     start = end = None
 
-    def range_fn(range_start, range_end, is_recurrence):
+    def range_fn(range_start: datetime, range_end: datetime,
+                 is_recurrence: bool) -> bool:
         nonlocal start, end
         if start is None or range_start < start:
             start = range_start
@@ -265,7 +283,7 @@ def find_tag_and_time_range(vobject_item):
             end = range_end
         return False
 
-    def infinity_fn(range_start):
+    def infinity_fn(range_start: datetime) -> bool:
         nonlocal start, end
         if start is None or range_start < start:
             start = range_start
@@ -278,7 +296,7 @@ def find_tag_and_time_range(vobject_item):
     if end is None:
         end = radicale_filter.DATETIME_MAX
     try:
-        return tag, math.floor(start.timestamp()), math.ceil(end.timestamp())
+        return math.floor(start.timestamp()), math.ceil(end.timestamp())
     except ValueError as e:
         if str(e) == ("offset must be a timedelta representing a whole "
                       "number of minutes") and sys.version_info < (3, 6):
@@ -289,10 +307,31 @@ def find_tag_and_time_range(vobject_item):
 class Item:
     """Class for address book and calendar entries."""
 
-    def __init__(self, collection_path=None, collection=None,
-                 vobject_item=None, href=None, last_modified=None, text=None,
-                 etag=None, uid=None, name=None, component_name=None,
-                 time_range=None):
+    collection: Optional["storage.BaseCollection"]
+    href: Optional[str]
+    last_modified: Optional[str]
+
+    _collection_path: str
+    _text: Optional[str]
+    _vobject_item: Optional[vobject.base.Component]
+    _etag: Optional[str]
+    _uid: Optional[str]
+    _name: Optional[str]
+    _component_name: Optional[str]
+    _time_range: Optional[Tuple[int, int]]
+
+    def __init__(self,
+                 collection_path: Optional[str] = None,
+                 collection: Optional["storage.BaseCollection"] = None,
+                 vobject_item: Optional[vobject.base.Component] = None,
+                 href: Optional[str] = None,
+                 last_modified: Optional[str] = None,
+                 text: Optional[str] = None,
+                 etag: Optional[str] = None,
+                 uid: Optional[str] = None,
+                 name: Optional[str] = None,
+                 component_name: Optional[str] = None,
+                 time_range: Optional[Tuple[int, int]] = None):
         """Initialize an item.
 
         ``collection_path`` the path of the parent collection (optional if
@@ -318,8 +357,7 @@ class Item:
         ``component_name`` the name of the primary component (optional).
         See ``find_tag``.
 
-        ``time_range`` the enclosing time range.
-        See ``find_tag_and_time_range``.
+        ``time_range`` the enclosing time range. See ``find_time_range``.
 
         """
         if text is None and vobject_item is None:
@@ -344,7 +382,7 @@ class Item:
         self._component_name = component_name
         self._time_range = time_range
 
-    def serialize(self):
+    def serialize(self) -> str:
         if self._text is None:
             try:
                 self._text = self.vobject_item.serialize()
@@ -366,38 +404,38 @@ class Item:
         return self._vobject_item
 
     @property
-    def etag(self):
+    def etag(self) -> str:
         """Encoded as quoted-string (see RFC 2616)."""
         if self._etag is None:
             self._etag = get_etag(self.serialize())
         return self._etag
 
     @property
-    def uid(self):
+    def uid(self) -> str:
         if self._uid is None:
             self._uid = get_uid_from_object(self.vobject_item)
         return self._uid
 
     @property
-    def name(self):
+    def name(self) -> str:
         if self._name is None:
             self._name = self.vobject_item.name or ""
         return self._name
 
     @property
-    def component_name(self):
-        if self._component_name is not None:
-            return self._component_name
-        return find_tag(self.vobject_item)
+    def component_name(self) -> str:
+        if self._component_name is None:
+            self._component_name = find_tag(self.vobject_item)
+        return self._component_name
 
     @property
-    def time_range(self):
+    def time_range(self) -> Tuple[int, int]:
         if self._time_range is None:
-            self._component_name, *self._time_range = (
-                find_tag_and_time_range(self.vobject_item))
+            self._time_range = find_time_range(
+                self.vobject_item, self.component_name)
         return self._time_range
 
-    def prepare(self):
+    def prepare(self) -> None:
         """Fill cache with values."""
         orig_vobject_item = self._vobject_item
         self.serialize()

+ 64 - 48
radicale/item/filter.py

@@ -19,35 +19,40 @@
 
 
 import math
+import xml.etree.ElementTree as ET
 from datetime import date, datetime, timedelta, timezone
 from itertools import chain
+from typing import (Callable, Iterable, Iterator, List, Optional, Sequence,
+                    Tuple)
 
-from radicale import xmlutils
+import vobject
+
+from radicale import item, xmlutils
 from radicale.log import logger
 
-DAY = timedelta(days=1)
-SECOND = timedelta(seconds=1)
-DATETIME_MIN = datetime.min.replace(tzinfo=timezone.utc)
-DATETIME_MAX = datetime.max.replace(tzinfo=timezone.utc)
-TIMESTAMP_MIN = math.floor(DATETIME_MIN.timestamp())
-TIMESTAMP_MAX = math.ceil(DATETIME_MAX.timestamp())
+DAY: timedelta = timedelta(days=1)
+SECOND: timedelta = timedelta(seconds=1)
+DATETIME_MIN: datetime = datetime.min.replace(tzinfo=timezone.utc)
+DATETIME_MAX: datetime = datetime.max.replace(tzinfo=timezone.utc)
+TIMESTAMP_MIN: int = math.floor(DATETIME_MIN.timestamp())
+TIMESTAMP_MAX: int = math.ceil(DATETIME_MAX.timestamp())
 
 
-def date_to_datetime(date_):
-    """Transform a date to a UTC datetime.
+def date_to_datetime(d: date) -> datetime:
+    """Transform any date to a UTC datetime.
 
-    If date_ is a datetime without timezone, return as UTC datetime. If date_
+    If ``d`` is a datetime without timezone, return as UTC datetime. If ``d``
     is already a datetime with timezone, return as is.
 
     """
-    if not isinstance(date_, datetime):
-        date_ = datetime.combine(date_, datetime.min.time())
-    if not date_.tzinfo:
-        date_ = date_.replace(tzinfo=timezone.utc)
-    return date_
+    if not isinstance(d, datetime):
+        d = datetime.combine(d, datetime.min.time())
+    if not d.tzinfo:
+        d = d.replace(tzinfo=timezone.utc)
+    return d
 
 
-def comp_match(item, filter_, level=0):
+def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
     """Check whether the ``item`` matches the comp ``filter_``.
 
     If ``level`` is ``0``, the filter is applied on the
@@ -70,7 +75,7 @@ def comp_match(item, filter_, level=0):
         return True
     if not tag:
         return False
-    name = filter_.get("name").upper()
+    name = filter_.get("name", "").upper()
     if len(filter_) == 0:
         # Point #1 of rfc4791-9.7.1
         return name == tag
@@ -104,13 +109,14 @@ def comp_match(item, filter_, level=0):
     return True
 
 
-def prop_match(vobject_item, filter_, ns):
+def prop_match(vobject_item: vobject.base.Component,
+               filter_: ET.Element, ns: str) -> bool:
     """Check whether the ``item`` matches the prop ``filter_``.
 
     See rfc4791-9.7.2 and rfc6352-10.5.1.
 
     """
-    name = filter_.get("name").lower()
+    name = filter_.get("name", "").lower()
     if len(filter_) == 0:
         # Point #1 of rfc4791-9.7.2
         return name in vobject_item.contents
@@ -136,20 +142,21 @@ def prop_match(vobject_item, filter_, ns):
     return True
 
 
-def time_range_match(vobject_item, filter_, child_name):
+def time_range_match(vobject_item: vobject.base.Component,
+                     filter_: ET.Element, child_name: str) -> bool:
     """Check whether the component/property ``child_name`` of
        ``vobject_item`` matches the time-range ``filter_``."""
 
-    start = filter_.get("start")
-    end = filter_.get("end")
-    if not start and not end:
+    start_text = filter_.get("start")
+    end_text = filter_.get("end")
+    if not start_text and not end_text:
         return False
-    if start:
-        start = datetime.strptime(start, "%Y%m%dT%H%M%SZ")
+    if start_text:
+        start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ")
     else:
         start = datetime.min
-    if end:
-        end = datetime.strptime(end, "%Y%m%dT%H%M%SZ")
+    if end_text:
+        end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ")
     else:
         end = datetime.max
     start = start.replace(tzinfo=timezone.utc)
@@ -157,7 +164,8 @@ def time_range_match(vobject_item, filter_, child_name):
 
     matched = False
 
-    def range_fn(range_start, range_end, is_recurrence):
+    def range_fn(range_start: datetime, range_end: datetime,
+                 is_recurrence: bool) -> bool:
         nonlocal matched
         if start < range_end and range_start < end:
             matched = True
@@ -166,14 +174,16 @@ def time_range_match(vobject_item, filter_, child_name):
             return True
         return False
 
-    def infinity_fn(start):
+    def infinity_fn(start: datetime) -> bool:
         return False
 
     visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
     return matched
 
 
-def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn):
+def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
+                      range_fn: Callable[[datetime, datetime, bool], bool],
+                      infinity_fn: Callable[[datetime], bool]) -> None:
     """Visit all time ranges in the component/property ``child_name`` of
     `vobject_item`` with visitors ``range_fn`` and ``infinity_fn``.
 
@@ -194,7 +204,8 @@ def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn):
     # recurrences too. This is not respected and client don't seem to bother
     # either.
 
-    def getrruleset(child, ignore=()):
+    def getrruleset(child: vobject.base.Component, ignore: Sequence[date]
+                    ) -> Tuple[Iterable[date], bool]:
         if (hasattr(child, "rrule") and
                 ";UNTIL=" not in child.rrule.value.upper() and
                 ";COUNT=" not in child.rrule.value.upper()):
@@ -207,7 +218,8 @@ def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn):
         return filter(lambda dtstart: dtstart not in ignore,
                       child.getrruleset(addRDate=True)), False
 
-    def get_children(components):
+    def get_children(components: Iterable[vobject.base.Component]) -> Iterator[
+                         Tuple[vobject.base.Component, bool, List[date]]]:
         main = None
         recurrences = []
         for comp in components:
@@ -216,7 +228,7 @@ def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn):
                 if comp.rruleset:
                     # Prevent possible infinite loop
                     raise ValueError("Overwritten recurrence with RRULESET")
-                yield comp, True, ()
+                yield comp, True, []
             else:
                 if main is not None:
                     raise ValueError("Multiple main components")
@@ -418,7 +430,9 @@ def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn):
                 range_fn(child, child + DAY, False)
 
 
-def text_match(vobject_item, filter_, child_name, ns, attrib_name=None):
+def text_match(vobject_item: vobject.base.Component,
+               filter_: ET.Element, child_name: str, ns: str,
+               attrib_name: Optional[str] = None) -> bool:
     """Check whether the ``item`` matches the text-match ``filter_``.
 
     See rfc4791-9.7.5.
@@ -432,7 +446,7 @@ def text_match(vobject_item, filter_, child_name, ns, attrib_name=None):
     if ns == "CR":
         match_type = filter_.get("match-type", match_type)
 
-    def match(value):
+    def match(value: str) -> bool:
         value = value.lower()
         if match_type == "equals":
             return value == text
@@ -445,7 +459,7 @@ def text_match(vobject_item, filter_, child_name, ns, attrib_name=None):
         raise ValueError("Unexpected text-match match-type: %r" % match_type)
 
     children = getattr(vobject_item, "%s_list" % child_name, [])
-    if attrib_name:
+    if attrib_name is not None:
         condition = any(
             match(attrib) for child in children
             for attrib in child.params.get(attrib_name, []))
@@ -456,13 +470,14 @@ def text_match(vobject_item, filter_, child_name, ns, attrib_name=None):
     return condition
 
 
-def param_filter_match(vobject_item, filter_, parent_name, ns):
+def param_filter_match(vobject_item: vobject.base.Component,
+                       filter_: ET.Element, parent_name: str, ns: str) -> bool:
     """Check whether the ``item`` matches the param-filter ``filter_``.
 
     See rfc4791-9.7.3.
 
     """
-    name = filter_.get("name").upper()
+    name = filter_.get("name", "").upper()
     children = getattr(vobject_item, "%s_list" % parent_name, [])
     condition = any(name in child.params for child in children)
     if len(filter_) > 0:
@@ -474,7 +489,8 @@ def param_filter_match(vobject_item, filter_, parent_name, ns):
     return condition
 
 
-def simplify_prefilters(filters, collection_tag="VCALENDAR"):
+def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str
+                        ) -> Tuple[Optional[str], int, int, bool]:
     """Creates a simplified condition from ``filters``.
 
     Returns a tuple (``tag``, ``start``, ``end``, ``simple``) where ``tag`` is
@@ -483,14 +499,14 @@ def simplify_prefilters(filters, collection_tag="VCALENDAR"):
     and the simplified condition are identical.
 
     """
-    flat_filters = tuple(chain.from_iterable(filters))
+    flat_filters = list(chain.from_iterable(filters))
     simple = len(flat_filters) <= 1
     for col_filter in flat_filters:
         if collection_tag != "VCALENDAR":
             simple = False
             break
         if (col_filter.tag != xmlutils.make_clark("C:comp-filter") or
-                col_filter.get("name").upper() != "VCALENDAR"):
+                col_filter.get("name", "").upper() != "VCALENDAR"):
             simple = False
             continue
         simple &= len(col_filter) <= 1
@@ -498,7 +514,7 @@ def simplify_prefilters(filters, collection_tag="VCALENDAR"):
             if comp_filter.tag != xmlutils.make_clark("C:comp-filter"):
                 simple = False
                 continue
-            tag = comp_filter.get("name").upper()
+            tag = comp_filter.get("name", "").upper()
             if comp_filter.find(
                     xmlutils.make_clark("C:is-not-defined")) is not None:
                 simple = False
@@ -511,17 +527,17 @@ def simplify_prefilters(filters, collection_tag="VCALENDAR"):
                 if time_filter.tag != xmlutils.make_clark("C:time-range"):
                     simple = False
                     continue
-                start = time_filter.get("start")
-                end = time_filter.get("end")
-                if start:
+                start_text = time_filter.get("start")
+                end_text = time_filter.get("end")
+                if start_text:
                     start = math.floor(datetime.strptime(
-                        start, "%Y%m%dT%H%M%SZ").replace(
+                        start_text, "%Y%m%dT%H%M%SZ").replace(
                             tzinfo=timezone.utc).timestamp())
                 else:
                     start = TIMESTAMP_MIN
-                if end:
+                if end_text:
                     end = math.ceil(datetime.strptime(
-                        end, "%Y%m%dT%H%M%SZ").replace(
+                        end_text, "%Y%m%dT%H%M%SZ").replace(
                             tzinfo=timezone.utc).timestamp())
                 else:
                     end = TIMESTAMP_MAX

+ 30 - 24
radicale/log.py

@@ -25,42 +25,46 @@ Log messages are sent to the first available target of:
 
 """
 
-import contextlib
 import logging
 import os
 import sys
 import threading
+from typing import Any, Callable, ClassVar, Dict, Iterator, Union
 
-LOGGER_NAME = "radicale"
-LOGGER_FORMAT = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s"
-DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z"
+from radicale import types
 
-logger = logging.getLogger(LOGGER_NAME)
+LOGGER_NAME: str = "radicale"
+LOGGER_FORMAT: str = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s"
+DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z"
+
+logger: logging.Logger = logging.getLogger(LOGGER_NAME)
 
 
 class RemoveTracebackFilter(logging.Filter):
-    def filter(self, record):
+
+    def filter(self, record: logging.LogRecord) -> bool:
         record.exc_info = None
         return True
 
 
-REMOVE_TRACEBACK_FILTER = RemoveTracebackFilter()
+REMOVE_TRACEBACK_FILTER: logging.Filter = RemoveTracebackFilter()
 
 
 class IdentLogRecordFactory:
     """LogRecordFactory that adds ``ident`` attribute."""
 
-    def __init__(self, upstream_factory):
-        self.upstream_factory = upstream_factory
+    def __init__(self, upstream_factory: Callable[..., logging.LogRecord]
+                 ) -> None:
+        self._upstream_factory = upstream_factory
 
-    def __call__(self, *args, **kwargs):
-        record = self.upstream_factory(*args, **kwargs)
+    def __call__(self, *args: Any, **kwargs: Any) -> logging.LogRecord:
+        record = self._upstream_factory(*args, **kwargs)
         ident = "%d" % os.getpid()
         main_thread = threading.main_thread()
         current_thread = threading.current_thread()
         if current_thread.name and main_thread != current_thread:
             ident += "/%s" % current_thread.name
-        record.ident = ident
+        record.ident = ident  # type:ignore[attr-defined]
         return record
 
 
@@ -68,13 +72,15 @@ class ThreadedStreamHandler(logging.Handler):
     """Sends logging output to the stream registered for the current thread or
        ``sys.stderr`` when no stream was registered."""
 
-    terminator = "\n"
+    terminator: ClassVar[str] = "\n"
+
+    _streams: Dict[int, types.ErrorStream]
 
-    def __init__(self):
+    def __init__(self) -> None:
         super().__init__()
         self._streams = {}
 
-    def emit(self, record):
+    def emit(self, record: logging.LogRecord) -> None:
         try:
             stream = self._streams.get(threading.get_ident(), sys.stderr)
             msg = self.format(record)
@@ -85,8 +91,8 @@ class ThreadedStreamHandler(logging.Handler):
         except Exception:
             self.handleError(record)
 
-    @contextlib.contextmanager
-    def register_stream(self, stream):
+    @types.contextmanager
+    def register_stream(self, stream: types.ErrorStream) -> Iterator[None]:
         """Register stream for logging output of the current thread."""
         key = threading.get_ident()
         self._streams[key] = stream
@@ -96,13 +102,13 @@ class ThreadedStreamHandler(logging.Handler):
             del self._streams[key]
 
 
-@contextlib.contextmanager
-def register_stream(stream):
+@types.contextmanager
+def register_stream(stream: types.ErrorStream) -> Iterator[None]:
     """Register stream for logging output of the current thread."""
     yield
 
 
-def setup():
+def setup() -> None:
     """Set global logging up."""
     global register_stream
     handler = ThreadedStreamHandler()
@@ -114,12 +120,12 @@ def setup():
     set_level(logging.WARNING)
 
 
-def set_level(level):
+def set_level(level: Union[int, str]) -> None:
     """Set logging level for global logger."""
     if isinstance(level, str):
         level = getattr(logging, level.upper())
+        assert isinstance(level, int)
     logger.setLevel(level)
-    if level == logging.DEBUG:
-        logger.removeFilter(REMOVE_TRACEBACK_FILTER)
-    else:
+    logger.removeFilter(REMOVE_TRACEBACK_FILTER)
+    if level > logging.DEBUG:
         logger.addFilter(REMOVE_TRACEBACK_FILTER)

+ 44 - 39
radicale/pathutils.py

@@ -21,20 +21,21 @@ Helper functions for working with the file system.
 
 """
 
-import contextlib
 import os
 import posixpath
 import sys
 import threading
 from tempfile import TemporaryDirectory
-from typing import Type, Union
+from typing import Iterator, Type, Union
 
-if os.name == "nt":
+from radicale import storage, types
+
+if sys.platform == "win32":
     import ctypes
     import ctypes.wintypes
     import msvcrt
 
-    LOCKFILE_EXCLUSIVE_LOCK = 2
+    LOCKFILE_EXCLUSIVE_LOCK: int = 2
     ULONG_PTR: Union[Type[ctypes.c_uint32], Type[ctypes.c_uint64]]
     if ctypes.sizeof(ctypes.c_void_p) == 4:
         ULONG_PTR = ctypes.c_uint32
@@ -49,8 +50,7 @@ if os.name == "nt":
             ("offset_high", ctypes.wintypes.DWORD),
             ("h_event", ctypes.wintypes.HANDLE)]
 
-    kernel32 = ctypes.WinDLL(  # type: ignore[attr-defined]
-        "kernel32", use_last_error=True)
+    kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
     lock_file_ex = kernel32.LockFileEx
     lock_file_ex.argtypes = [
         ctypes.wintypes.HANDLE,
@@ -71,13 +71,13 @@ if os.name == "nt":
 elif os.name == "posix":
     import fcntl
 
-HAVE_RENAMEAT2 = False
+HAVE_RENAMEAT2: bool = False
 if sys.platform == "linux":
     import ctypes
 
-    RENAME_EXCHANGE = 2
+    RENAME_EXCHANGE: int = 2
     try:
-        renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
+        renameat2 = ctypes.CDLL("", use_errno=True).renameat2
     except AttributeError:
         pass
     else:
@@ -92,14 +92,19 @@ if sys.platform == "linux":
 class RwLock:
     """A readers-Writer lock that locks a file."""
 
-    def __init__(self, path):
+    _path: str
+    _readers: int
+    _writer: bool
+    _lock: threading.Lock
+
+    def __init__(self, path: str) -> None:
         self._path = path
         self._readers = 0
         self._writer = False
         self._lock = threading.Lock()
 
     @property
-    def locked(self):
+    def locked(self) -> str:
         with self._lock:
             if self._readers > 0:
                 return "r"
@@ -107,12 +112,12 @@ class RwLock:
                 return "w"
             return ""
 
-    @contextlib.contextmanager
-    def acquire(self, mode):
+    @types.contextmanager
+    def acquire(self, mode: str) -> Iterator[None]:
         if mode not in "rw":
             raise ValueError("Invalid mode: %r" % mode)
         with open(self._path, "w+") as lock_file:
-            if os.name == "nt":
+            if sys.platform == "win32":
                 handle = msvcrt.get_osfhandle(lock_file.fileno())
                 flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
                 overlapped = Overlapped()
@@ -120,15 +125,15 @@ class RwLock:
                     if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
                         raise ctypes.WinError()
                 except OSError as e:
-                    raise RuntimeError("Locking the storage failed: %s" %
-                                       e) from e
+                    raise RuntimeError("Locking the storage failed: %s" % e
+                                       ) from e
             elif os.name == "posix":
                 _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
                 try:
                     fcntl.flock(lock_file.fileno(), _cmd)
                 except OSError as e:
-                    raise RuntimeError("Locking the storage failed: %s" %
-                                       e) from e
+                    raise RuntimeError("Locking the storage failed: %s" % e
+                                       ) from e
             else:
                 raise RuntimeError("Locking the storage failed: "
                                    "Unsupported operating system")
@@ -149,7 +154,7 @@ class RwLock:
                     self._writer = False
 
 
-def rename_exchange(src, dst):
+def rename_exchange(src: str, dst: str) -> None:
     """Exchange the files or directories `src` and `dst`.
 
     Both `src` and `dst` must exist but may be of different types.
@@ -181,26 +186,26 @@ def rename_exchange(src, dst):
         finally:
             os.close(src_dir_fd)
     else:
-        with TemporaryDirectory(
-                prefix=".Radicale.tmp-", dir=src_dir) as tmp_dir:
+        with TemporaryDirectory(prefix=".Radicale.tmp-", dir=src_dir
+                                ) as tmp_dir:
             os.rename(dst, os.path.join(tmp_dir, "interim"))
             os.rename(src, dst)
             os.rename(os.path.join(tmp_dir, "interim"), src)
 
 
-def fsync(fd):
+def fsync(fd: int) -> None:
     if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"):
         fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
     else:
         os.fsync(fd)
 
 
-def strip_path(path):
+def strip_path(path: str) -> str:
     assert sanitize_path(path) == path
     return path.strip("/")
 
 
-def unstrip_path(stripped_path, trailing_slash=False):
+def unstrip_path(stripped_path: str, trailing_slash: bool = False) -> str:
     assert strip_path(sanitize_path(stripped_path)) == stripped_path
     assert stripped_path or trailing_slash
     path = "/%s" % stripped_path
@@ -209,7 +214,7 @@ def unstrip_path(stripped_path, trailing_slash=False):
     return path
 
 
-def sanitize_path(path):
+def sanitize_path(path: str) -> str:
     """Make path absolute with leading slash to prevent access to other data.
 
     Preserve potential trailing slash.
@@ -226,16 +231,16 @@ def sanitize_path(path):
     return new_path + trailing_slash
 
 
-def is_safe_path_component(path):
+def is_safe_path_component(path: str) -> bool:
     """Check if path is a single component of a path.
 
     Check that the path is safe to join too.
 
     """
-    return path and "/" not in path and path not in (".", "..")
+    return bool(path) and "/" not in path and path not in (".", "..")
 
 
-def is_safe_filesystem_path_component(path):
+def is_safe_filesystem_path_component(path: str) -> bool:
     """Check if path is a single component of a local and posix filesystem
        path.
 
@@ -243,13 +248,13 @@ def is_safe_filesystem_path_component(path):
 
     """
     return (
-        path and not os.path.splitdrive(path)[0] and
+        bool(path) and not os.path.splitdrive(path)[0] and
         not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
         not path.startswith(".") and not path.endswith("~") and
         is_safe_path_component(path))
 
 
-def path_to_filesystem(root, sane_path):
+def path_to_filesystem(root: str, sane_path: str) -> str:
     """Convert `sane_path` to a local filesystem path relative to `root`.
 
     `root` must be a secure filesystem path, it will be prepend to the path.
@@ -271,25 +276,25 @@ def path_to_filesystem(root, sane_path):
         # Check for conflicting files (e.g. case-insensitive file systems
         # or short names on Windows file systems)
         if (os.path.lexists(safe_path) and
-                part not in (e.name for e in
-                             os.scandir(safe_path_parent))):
+                part not in (e.name for e in os.scandir(safe_path_parent))):
             raise CollidingPathError(part)
     return safe_path
 
 
 class UnsafePathError(ValueError):
-    def __init__(self, path):
-        message = "Can't translate name safely to filesystem: %r" % path
-        super().__init__(message)
+
+    def __init__(self, path: str) -> None:
+        super().__init__("Can't translate name safely to filesystem: %r" %
+                         path)
 
 
 class CollidingPathError(ValueError):
-    def __init__(self, path):
-        message = "File name collision: %r" % path
-        super().__init__(message)
+
+    def __init__(self, path: str) -> None:
+        super().__init__("File name collision: %r" % path)
 
 
-def name_from_path(path, collection):
+def name_from_path(path: str, collection: "storage.BaseCollection") -> str:
     """Return Radicale item name from ``path``."""
     assert sanitize_path(path) == path
     start = unstrip_path(collection.path, True)

+ 12 - 7
radicale/rights/__init__.py

@@ -32,17 +32,21 @@ Take a look at the class ``BaseRights`` if you want to implement your own.
 
 """
 
-from radicale import utils
+from typing import Sequence
 
-INTERNAL_TYPES = ("authenticated", "owner_write", "owner_only", "from_file")
+from radicale import config, utils
 
+INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only",
+                                 "from_file")
 
-def load(configuration):
+
+def load(configuration: "config.Configuration") -> "BaseRights":
     """Load the rights module chosen in configuration."""
-    return utils.load_plugin(INTERNAL_TYPES, "rights", "Rights", configuration)
+    return utils.load_plugin(INTERNAL_TYPES, "rights", "Rights", BaseRights,
+                             configuration)
 
 
-def intersect(a, b):
+def intersect(a: str, b: str) -> str:
     """Intersect two lists of rights.
 
     Returns all rights that are both in ``a`` and ``b``.
@@ -52,7 +56,8 @@ def intersect(a, b):
 
 
 class BaseRights:
-    def __init__(self, configuration):
+
+    def __init__(self, configuration: "config.Configuration") -> None:
         """Initialize BaseRights.
 
         ``configuration`` see ``radicale.config`` module.
@@ -62,7 +67,7 @@ class BaseRights:
         """
         self.configuration = configuration
 
-    def authorization(self, user, path):
+    def authorization(self, user: str, path: str) -> str:
         """Get granted rights of ``user`` for the collection ``path``.
 
         If ``user`` is empty, check for anonymous rights.

+ 5 - 4
radicale/rights/authenticated.py

@@ -21,15 +21,16 @@ calendars and address books.
 
 """
 
-from radicale import pathutils, rights
+from radicale import config, pathutils, rights
 
 
 class Rights(rights.BaseRights):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+
+    def __init__(self, configuration: config.Configuration) -> None:
+        super().__init__(configuration)
         self._verify_user = self.configuration.get("auth", "type") != "none"
 
-    def authorization(self, user, path):
+    def authorization(self, user: str, path: str) -> str:
         if self._verify_user and not user:
             return ""
         sane_path = pathutils.strip_path(path)

+ 8 - 6
radicale/rights/from_file.py

@@ -37,16 +37,19 @@ Leading or ending slashes are trimmed from collection's path.
 import configparser
 import re
 
-from radicale import pathutils, rights
+from radicale import config, pathutils, rights
 from radicale.log import logger
 
 
 class Rights(rights.BaseRights):
-    def __init__(self, configuration):
+
+    _filename: str
+
+    def __init__(self, configuration: config.Configuration) -> None:
         super().__init__(configuration)
         self._filename = configuration.get("rights", "file")
 
-    def authorization(self, user, path):
+    def authorization(self, user: str, path: str) -> str:
         user = user or ""
         sane_path = pathutils.strip_path(path)
         # Prevent "regex injection"
@@ -54,8 +57,7 @@ class Rights(rights.BaseRights):
         rights_config = configparser.ConfigParser()
         try:
             if not rights_config.read(self._filename):
-                raise RuntimeError("No such file: %r" %
-                                   self._filename)
+                raise RuntimeError("No such file: %r" % self._filename)
         except Exception as e:
             raise RuntimeError("Failed to load rights file %r: %s" %
                                (self._filename, e)) from e
@@ -67,7 +69,7 @@ class Rights(rights.BaseRights):
                 user_match = re.fullmatch(user_pattern.format(), user)
                 collection_match = user_match and re.fullmatch(
                     collection_pattern.format(
-                        *map(re.escape, user_match.groups()),
+                        *(re.escape(s) for s in user_match.groups()),
                         user=escaped_user), sane_path)
             except Exception as e:
                 raise RuntimeError("Error in section %r of rights file %r: "

+ 2 - 1
radicale/rights/owner_only.py

@@ -26,7 +26,8 @@ from radicale import pathutils
 
 
 class Rights(authenticated.Rights):
-    def authorization(self, user, path):
+
+    def authorization(self, user: str, path: str) -> str:
         if self._verify_user and not user:
             return ""
         sane_path = pathutils.strip_path(path)

+ 2 - 1
radicale/rights/owner_write.py

@@ -26,7 +26,8 @@ from radicale import pathutils
 
 
 class Rights(authenticated.Rights):
-    def authorization(self, user, path):
+
+    def authorization(self, user: str, path: str) -> str:
         if self._verify_user and not user:
             return ""
         sane_path = pathutils.strip_path(path)

+ 115 - 63
radicale/server.py

@@ -23,14 +23,15 @@ Built-in WSGI server.
 """
 
 import errno
-import os
+import http
 import select
 import socket
 import socketserver
 import ssl
 import sys
 import wsgiref.simple_server
-from typing import MutableMapping
+from typing import (Any, Callable, Dict, List, MutableMapping, Optional, Set,
+                    Tuple, Union)
 from urllib.parse import unquote
 
 from radicale import Application, config
@@ -38,7 +39,7 @@ from radicale.log import logger
 
 COMPAT_EAI_ADDRFAMILY: int
 if hasattr(socket, "EAI_ADDRFAMILY"):
-    COMPAT_EAI_ADDRFAMILY = socket.EAI_ADDRFAMILY  # type: ignore[attr-defined]
+    COMPAT_EAI_ADDRFAMILY = socket.EAI_ADDRFAMILY  # type:ignore[attr-defined]
 elif hasattr(socket, "EAI_NONAME"):
     # Windows and BSD don't have a special error code for this
     COMPAT_EAI_ADDRFAMILY = socket.EAI_NONAME
@@ -51,57 +52,99 @@ elif hasattr(socket, "EAI_NONAME"):
 COMPAT_IPPROTO_IPV6: int
 if hasattr(socket, "IPPROTO_IPV6"):
     COMPAT_IPPROTO_IPV6 = socket.IPPROTO_IPV6
-elif os.name == "nt":
-    # Workaround: https://bugs.python.org/issue29515
+elif sys.platform == "win32":
+    # HACK: https://bugs.python.org/issue29515
     COMPAT_IPPROTO_IPV6 = 41
 
 
-def format_address(address):
+# IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
+ADDRESS_TYPE = Union[Tuple[str, int], Tuple[str, int, int, int]]
+
+
+def format_address(address: ADDRESS_TYPE) -> str:
     return "[%s]:%d" % address[:2]
 
 
 class ParallelHTTPServer(socketserver.ThreadingMixIn,
                          wsgiref.simple_server.WSGIServer):
 
-    # We wait for child threads ourself
-    block_on_close = False
-    daemon_threads = True
+    configuration: config.Configuration
+    worker_sockets: Set[socket.socket]
+    _timeout: float
+
+    # We wait for child threads ourself (ThreadingMixIn)
+    block_on_close: bool = False
+    daemon_threads: bool = True
 
-    def __init__(self, configuration, family, address, RequestHandlerClass):
+    def __init__(self, configuration: config.Configuration, family: int,
+                 address: Tuple[str, int], RequestHandlerClass:
+                 Callable[..., http.server.BaseHTTPRequestHandler]) -> None:
         self.configuration = configuration
         self.address_family = family
         super().__init__(address, RequestHandlerClass)
-        self.client_sockets = set()
+        self.worker_sockets = set()
+        self._timeout = configuration.get("server", "timeout")
 
-    def server_bind(self):
+    def server_bind(self) -> None:
         if self.address_family == socket.AF_INET6:
             # Only allow IPv6 connections to the IPv6 socket
             self.socket.setsockopt(COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
         super().server_bind()
 
-    def get_request(self):
+    def get_request(  # type:ignore[override]
+            self) -> Tuple[socket.socket, Tuple[ADDRESS_TYPE, socket.socket]]:
         # Set timeout for client
-        request, client_address = super().get_request()
-        timeout = self.configuration.get("server", "timeout")
-        if timeout:
-            request.settimeout(timeout)
-        client_socket, client_socket_out = socket.socketpair()
-        self.client_sockets.add(client_socket_out)
-        return request, (*client_address, client_socket)
-
-    def finish_request_locked(self, request, client_address):
-        return super().finish_request(request, client_address)
-
-    def finish_request(self, request, client_address):
-        *client_address, client_socket = client_address
-        client_address = tuple(client_address)
+        request: socket.socket
+        client_address: ADDRESS_TYPE
+        request, client_address = super().get_request()  # type:ignore[misc]
+        if self._timeout > 0:
+            request.settimeout(self._timeout)
+        worker_socket, worker_socket_out = socket.socketpair()
+        self.worker_sockets.add(worker_socket_out)
+        # HACK: Forward `worker_socket` via `client_address` return value
+        # to worker thread.
+        # The super class calls `verify_request`, `process_request` and
+        # `handle_error` with modified `client_address` value.
+        return request, (client_address, worker_socket)
+
+    def verify_request(  # type:ignore[override]
+            self, request: socket.socket, client_address_and_socket:
+            Tuple[ADDRESS_TYPE, socket.socket]) -> bool:
+        return True
+
+    def process_request(  # type:ignore[override]
+            self, request: socket.socket, client_address_and_socket:
+            Tuple[ADDRESS_TYPE, socket.socket]) -> None:
+        # HACK: Super class calls `finish_request` in new thread with
+        # `client_address_and_socket`
+        return super().process_request(
+            request, client_address_and_socket)  # type:ignore[arg-type]
+
+    def finish_request(  # type:ignore[override]
+            self, request: socket.socket, client_address_and_socket:
+            Tuple[ADDRESS_TYPE, socket.socket]) -> None:
+        # HACK: Unpack `client_address_and_socket` and call super class
+        # `finish_request` with original `client_address`
+        client_address, worker_socket = client_address_and_socket
         try:
             return self.finish_request_locked(request, client_address)
         finally:
-            client_socket.close()
-
-    def handle_error(self, request, client_address):
-        if issubclass(sys.exc_info()[0], socket.timeout):
+            worker_socket.close()
+
+    def finish_request_locked(self, request: socket.socket,
+                              client_address: ADDRESS_TYPE) -> None:
+        return super().finish_request(
+            request, client_address)  # type:ignore[arg-type]
+
+    def handle_error(  # type:ignore[override]
+            self, request: socket.socket,
+            client_address_or_client_address_and_socket:
+            Union[ADDRESS_TYPE, Tuple[ADDRESS_TYPE, socket.socket]]) -> None:
+        # HACK: This method can be called with the modified
+        # `client_address_and_socket` or the original `client_address` value
+        e = sys.exc_info()[1]
+        assert e is not None
+        if isinstance(e, socket.timeout):
             logger.info("Client timed out", exc_info=True)
         else:
             logger.error("An exception occurred during request: %s",
@@ -110,12 +153,12 @@ class ParallelHTTPServer(socketserver.ThreadingMixIn,
 
 class ParallelHTTPSServer(ParallelHTTPServer):
 
-    def server_bind(self):
+    def server_bind(self) -> None:
         super().server_bind()
         # Wrap the TCP socket in an SSL socket
-        certfile = self.configuration.get("server", "certificate")
-        keyfile = self.configuration.get("server", "key")
-        cafile = self.configuration.get("server", "certificate_authority")
+        certfile: str = self.configuration.get("server", "certificate")
+        keyfile: str = self.configuration.get("server", "key")
+        cafile: str = self.configuration.get("server", "certificate_authority")
         # Test if the files can be read
         for name, filename in [("certificate", certfile), ("key", keyfile),
                                ("certificate_authority", cafile)]:
@@ -139,7 +182,9 @@ class ParallelHTTPSServer(ParallelHTTPServer):
         self.socket = context.wrap_socket(
             self.socket, server_side=True, do_handshake_on_connect=False)
 
-    def finish_request_locked(self, request, client_address):
+    def finish_request_locked(  # type:ignore[override]
+            self, request: ssl.SSLSocket, client_address: ADDRESS_TYPE
+            ) -> None:
         try:
             try:
                 request.do_handshake()
@@ -151,7 +196,7 @@ class ParallelHTTPSServer(ParallelHTTPServer):
             try:
                 self.handle_error(request, client_address)
             finally:
-                self.shutdown_request(request)
+                self.shutdown_request(request)  # type:ignore[attr-defined]
             return
         return super().finish_request_locked(request, client_address)
 
@@ -161,30 +206,34 @@ class ServerHandler(wsgiref.simple_server.ServerHandler):
     # Don't pollute WSGI environ with OS environment
     os_environ: MutableMapping[str, str] = {}
 
-    def log_exception(self, exc_info):
+    def log_exception(self, exc_info: "wsgiref.handlers._exc_info") -> None:
         logger.error("An exception occurred during request: %s",
-                     exc_info[1], exc_info=exc_info)
+                     exc_info[1], exc_info=exc_info)  # type:ignore[arg-type]
 
 
 class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
     """HTTP requests handler."""
 
-    def log_request(self, code="-", size="-"):
+    # HACK: Assigned in `socketserver.StreamRequestHandler`
+    connection: socket.socket
+
+    def log_request(self, code: Union[int, str] = "-",
+                    size: Union[int, str] = "-") -> None:
         pass  # Disable request logging.
 
-    def log_error(self, format_, *args):
+    def log_error(self, format_: str, *args: Any) -> None:
         logger.error("An error occurred during request: %s", format_ % args)
 
-    def get_environ(self):
+    def get_environ(self) -> Dict[str, Any]:
         env = super().get_environ()
-        if hasattr(self.connection, "getpeercert"):
+        if isinstance(self.connection, ssl.SSLSocket):
             # The certificate can be evaluated by the auth module
             env["REMOTE_CERTIFICATE"] = self.connection.getpeercert()
         # Parent class only tries latin1 encoding
         env["PATH_INFO"] = unquote(self.path.split("?", 1)[0])
         return env
 
-    def handle(self):
+    def handle(self) -> None:
         """Copy of WSGIRequestHandler.handle with different ServerHandler"""
 
         self.raw_requestline = self.rfile.readline(65537)
@@ -201,11 +250,13 @@ class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
         handler = ServerHandler(
             self.rfile, self.wfile, self.get_stderr(), self.get_environ()
         )
-        handler.request_handler = self
-        handler.run(self.server.get_app())
+        handler.request_handler = self  # type:ignore[attr-defined]
+        app = self.server.get_app()  # type:ignore[attr-defined]
+        handler.run(app)
 
 
-def serve(configuration, shutdown_socket=None):
+def serve(configuration: config.Configuration,
+          shutdown_socket: Optional[socket.socket] = None) -> None:
     """Serve radicale from configuration.
 
     `shutdown_socket` can be used to gracefully shutdown the server.
@@ -221,12 +272,13 @@ def serve(configuration, shutdown_socket=None):
     configuration.update({"server": {"_internal_server": "True"}}, "server",
                          privileged=True)
 
-    use_ssl = configuration.get("server", "ssl")
+    use_ssl: bool = configuration.get("server", "ssl")
     server_class = ParallelHTTPSServer if use_ssl else ParallelHTTPServer
     application = Application(configuration)
     servers = {}
     try:
-        for address in configuration.get("server", "hosts"):
+        hosts: List[Tuple[str, int]] = configuration.get("server", "hosts")
+        for address in hosts:
             # Try to bind sockets for IPv4 and IPv6
             possible_families = (socket.AF_INET, socket.AF_INET6)
             bind_ok = False
@@ -270,16 +322,16 @@ def serve(configuration, shutdown_socket=None):
 
         # Mainloop
         select_timeout = None
-        if os.name == "nt":
+        if sys.platform == "win32":
             # Fallback to busy waiting. (select(...) blocks SIGINT on Windows.)
             select_timeout = 1.0
-        max_connections = configuration.get("server", "max_connections")
+        max_connections: int = configuration.get("server", "max_connections")
         logger.info("Radicale server ready")
         while True:
-            rlist = []
+            rlist: List[socket.socket] = []
             # Wait for finished clients
             for server in servers.values():
-                rlist.extend(server.client_sockets)
+                rlist.extend(server.worker_sockets)
             # Accept new connections if max_connections is not reached
             if max_connections <= 0 or len(rlist) < max_connections:
                 rlist.extend(servers)
@@ -287,26 +339,26 @@ def serve(configuration, shutdown_socket=None):
             if shutdown_socket is not None:
                 rlist.append(shutdown_socket)
             rlist, _, _ = select.select(rlist, [], [], select_timeout)
-            rlist = set(rlist)
-            if shutdown_socket in rlist:
+            rset = set(rlist)
+            if shutdown_socket in rset:
                 logger.info("Stopping Radicale")
                 break
             for server in servers.values():
-                finished_sockets = server.client_sockets.intersection(rlist)
+                finished_sockets = server.worker_sockets.intersection(rset)
                 for s in finished_sockets:
                     s.close()
-                    server.client_sockets.remove(s)
-                    rlist.remove(s)
+                    server.worker_sockets.remove(s)
+                    rset.remove(s)
                 if finished_sockets:
                     server.service_actions()
-            if rlist:
-                server = servers.get(rlist.pop())
-                if server:
-                    server.handle_request()
+            if rset:
+                active_server = servers.get(rset.pop())
+                if active_server:
+                    active_server.handle_request()
     finally:
         # Wait for clients to finish and close servers
         for server in servers.values():
-            for s in server.client_sockets:
+            for s in server.worker_sockets:
                 s.recv(1)
                 s.close()
             server.server_close()

+ 84 - 53
radicale/storage/__init__.py

@@ -23,37 +23,44 @@ Take a look at the class ``BaseCollection`` if you want to implement your own.
 
 """
 
-import contextlib
 import json
+import xml.etree.ElementTree as ET
 from hashlib import sha256
+from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set,
+                    Tuple, Union, overload)
 
 import pkg_resources
 import vobject
 
-from radicale import utils
+from radicale import config
+from radicale import item as radicale_item
+from radicale import types, utils
 from radicale.item import filter as radicale_filter
 
-INTERNAL_TYPES = ("multifilesystem",)
+INTERNAL_TYPES: Sequence[str] = ("multifilesystem",)
 
-CACHE_DEPS = ("radicale", "vobject", "python-dateutil",)
-CACHE_VERSION = (";".join(pkg_resources.get_distribution(pkg).version
-                          for pkg in CACHE_DEPS) + ";").encode()
+CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",)
+CACHE_VERSION: bytes = "".join(
+    "%s=%s;" % (pkg, pkg_resources.get_distribution(pkg).version)
+    for pkg in CACHE_DEPS).encode()
 
 
-def load(configuration):
+def load(configuration: "config.Configuration") -> "BaseStorage":
     """Load the storage module chosen in configuration."""
-    return utils.load_plugin(
-        INTERNAL_TYPES, "storage", "Storage", configuration)
+    return utils.load_plugin(INTERNAL_TYPES, "storage", "Storage", BaseStorage,
+                             configuration)
 
 
 class ComponentExistsError(ValueError):
-    def __init__(self, path):
+
+    def __init__(self, path: str) -> None:
         message = "Component already exists: %r" % path
         super().__init__(message)
 
 
 class ComponentNotFoundError(ValueError):
-    def __init__(self, path):
+
+    def __init__(self, path: str) -> None:
         message = "Component doesn't exist: %r" % path
         super().__init__(message)
 
@@ -61,47 +68,58 @@ class ComponentNotFoundError(ValueError):
 class BaseCollection:
 
     @property
-    def path(self):
+    def path(self) -> str:
         """The sanitized path of the collection without leading or
         trailing ``/``."""
         raise NotImplementedError
 
     @property
-    def owner(self):
+    def owner(self) -> str:
         """The owner of the collection."""
         return self.path.split("/", maxsplit=1)[0]
 
     @property
-    def is_principal(self):
+    def is_principal(self) -> bool:
         """Collection is a principal."""
         return bool(self.path) and "/" not in self.path
 
     @property
-    def etag(self):
+    def etag(self) -> str:
         """Encoded as quoted-string (see RFC 2616)."""
         etag = sha256()
         for item in self.get_all():
+            assert item.href
             etag.update((item.href + "/" + item.etag).encode())
         etag.update(json.dumps(self.get_meta(), sort_keys=True).encode())
         return '"%s"' % etag.hexdigest()
 
-    def sync(self, old_token=None):
+    @property
+    def tag(self) -> str:
+        """The tag of the collection."""
+        return self.get_meta("tag") or ""
+
+    def sync(self, old_token: str = "") -> Tuple[str, Iterable[str]]:
         """Get the current sync token and changed items for synchronization.
 
         ``old_token`` an old sync token which is used as the base of the
-        delta update. If sync token is missing, all items are returned.
+        delta update. If sync token is empty, all items are returned.
         ValueError is raised for invalid or old tokens.
 
         WARNING: This simple default implementation treats all sync-token as
                  invalid.
 
         """
+        def hrefs_iter() -> Iterator[str]:
+            for item in self.get_all():
+                assert item.href
+                yield item.href
         token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
         if old_token:
             raise ValueError("Sync token are not supported")
-        return token, (item.href for item in self.get_all())
+        return token, hrefs_iter()
 
-    def get_multi(self, hrefs):
+    def get_multi(self, hrefs: Iterable[str]
+                  ) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]:
         """Fetch multiple items.
 
         It's not required to return the requested items in the correct order.
@@ -113,11 +131,12 @@ class BaseCollection:
         """
         raise NotImplementedError
 
-    def get_all(self):
+    def get_all(self) -> Iterable["radicale_item.Item"]:
         """Fetch all items."""
         raise NotImplementedError
 
-    def get_filtered(self, filters):
+    def get_filtered(self, filters: Iterable[ET.Element]
+                     ) -> Iterable[Tuple["radicale_item.Item", bool]]:
         """Fetch all items with optional filtering.
 
         This can largely improve performance of reports depending on
@@ -128,32 +147,31 @@ class BaseCollection:
         matched.
 
         """
+        if not self.tag:
+            return
         tag, start, end, simple = radicale_filter.simplify_prefilters(
-            filters, collection_tag=self.get_meta("tag"))
+            filters, self.tag)
         for item in self.get_all():
-            if tag:
-                if tag != item.component_name:
-                    continue
-                istart, iend = item.time_range
-                if istart >= end or iend <= start:
-                    continue
-                item_simple = simple and (start <= istart or iend <= end)
-            else:
-                item_simple = simple
-            yield item, item_simple
-
-    def has_uid(self, uid):
+            if tag is not None and tag != item.component_name:
+                continue
+            istart, iend = item.time_range
+            if istart >= end or iend <= start:
+                continue
+            yield item, simple and (start <= istart or iend <= end)
+
+    def has_uid(self, uid: str) -> bool:
         """Check if a UID exists in the collection."""
         for item in self.get_all():
             if item.uid == uid:
                 return True
         return False
 
-    def upload(self, href, item):
+    def upload(self, href: str, item: "radicale_item.Item") -> (
+            "radicale_item.Item"):
         """Upload a new or replace an existing item."""
         raise NotImplementedError
 
-    def delete(self, href=None):
+    def delete(self, href: Optional[str] = None) -> None:
         """Delete an item.
 
         When ``href`` is ``None``, delete the collection.
@@ -161,7 +179,14 @@ class BaseCollection:
         """
         raise NotImplementedError
 
-    def get_meta(self, key=None):
+    @overload
+    def get_meta(self, key: None = None) -> Mapping[str, str]: ...
+
+    @overload
+    def get_meta(self, key: str) -> Optional[str]: ...
+
+    def get_meta(self, key: Optional[str] = None
+                 ) -> Union[Mapping[str, str], Optional[str]]:
         """Get metadata value for collection.
 
         Return the value of the property ``key``. If ``key`` is ``None`` return
@@ -170,7 +195,7 @@ class BaseCollection:
         """
         raise NotImplementedError
 
-    def set_meta(self, props):
+    def set_meta(self, props: Mapping[str, str]) -> None:
         """Set metadata values for collection.
 
         ``props`` a dict with values for properties.
@@ -179,16 +204,16 @@ class BaseCollection:
         raise NotImplementedError
 
     @property
-    def last_modified(self):
+    def last_modified(self) -> str:
         """Get the HTTP-datetime of when the collection was modified."""
         raise NotImplementedError
 
-    def serialize(self):
+    def serialize(self) -> str:
         """Get the unicode string representing the whole collection."""
-        if self.get_meta("tag") == "VCALENDAR":
+        if self.tag == "VCALENDAR":
             in_vcalendar = False
             vtimezones = ""
-            included_tzids = set()
+            included_tzids: Set[str] = set()
             vtimezone = []
             tzid = None
             components = ""
@@ -216,6 +241,7 @@ class BaseCollection:
                             elif depth == 2 and line.startswith("END:"):
                                 if tzid is None or tzid not in included_tzids:
                                     vtimezones += "".join(vtimezone)
+                                if tzid is not None:
                                     included_tzids.add(tzid)
                                 vtimezone.clear()
                                 tzid = None
@@ -240,13 +266,14 @@ class BaseCollection:
             return (template[:template_insert_pos] +
                     vtimezones + components +
                     template[template_insert_pos:])
-        if self.get_meta("tag") == "VADDRESSBOOK":
+        if self.tag == "VADDRESSBOOK":
             return "".join((item.serialize() for item in self.get_all()))
         return ""
 
 
 class BaseStorage:
-    def __init__(self, configuration):
+
+    def __init__(self, configuration: "config.Configuration") -> None:
         """Initialize BaseStorage.
 
         ``configuration`` see ``radicale.config`` module.
@@ -256,7 +283,8 @@ class BaseStorage:
         """
         self.configuration = configuration
 
-    def discover(self, path, depth="0"):
+    def discover(self, path: str, depth: str = "0") -> Iterable[
+            "types.CollectionOrItem"]:
         """Discover a list of collections under the given ``path``.
 
         ``path`` is sanitized.
@@ -272,7 +300,8 @@ class BaseStorage:
         """
         raise NotImplementedError
 
-    def move(self, item, to_collection, to_href):
+    def move(self, item: "radicale_item.Item", to_collection: BaseCollection,
+             to_href: str) -> None:
         """Move an object.
 
         ``item`` is the item to move.
@@ -285,7 +314,10 @@ class BaseStorage:
         """
         raise NotImplementedError
 
-    def create_collection(self, href, items=None, props=None):
+    def create_collection(
+            self, href: str,
+            items: Optional[Iterable["radicale_item.Item"]] = None,
+            props: Optional[Mapping[str, str]] = None) -> BaseCollection:
         """Create a collection.
 
         ``href`` is the sanitized path.
@@ -298,15 +330,14 @@ class BaseStorage:
 
         ``props`` are metadata values for the collection.
 
-        ``props["tag"]`` is the type of collection (VCALENDAR or
-        VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
-        collection.
+        ``props["tag"]`` is the type of collection (VCALENDAR or VADDRESSBOOK).
+        If the key ``tag`` is missing, ``items`` is ignored.
 
         """
         raise NotImplementedError
 
-    @contextlib.contextmanager
-    def acquire_lock(self, mode, user=None):
+    @types.contextmanager
+    def acquire_lock(self, mode: str, user: str = "") -> Iterator[None]:
         """Set a context manager to lock the whole storage.
 
         ``mode`` must either be "r" for shared access or "w" for exclusive
@@ -317,6 +348,6 @@ class BaseStorage:
         """
         raise NotImplementedError
 
-    def verify(self):
+    def verify(self) -> bool:
         """Check the storage for errors."""
         raise NotImplementedError

+ 5 - 8
radicale/storage/multifilesystem/cache.py

@@ -16,6 +16,7 @@
 # You should have received a copy of the GNU General Public License
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
+import contextlib
 import os
 import pickle
 import time
@@ -72,14 +73,10 @@ class CollectionCacheMixin:
                                     "item")
         content = self._item_cache_content(item, cache_hash)
         self._storage._makedirs_synced(cache_folder)
-        try:
-            # Race: Other processes might have created and locked the
-            # file.
-            with self._atomic_write(os.path.join(cache_folder, href),
-                                    "wb") as f:
-                pickle.dump(content, f)
-        except PermissionError:
-            pass
+        # Race: Other processes might have created and locked the file.
+        with contextlib.suppress(PermissionError), self._atomic_write(
+                os.path.join(cache_folder, href), "wb") as f:
+            pickle.dump(content, f)
         return content
 
     def _load_item_cache(self, href, input_hash):

+ 5 - 4
radicale/storage/multifilesystem/get.py

@@ -17,11 +17,12 @@
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
 import os
+import sys
 import time
 
 import vobject
 
-from radicale import item as radicale_item
+import radicale.item as radicale_item
 from radicale import pathutils
 from radicale.log import logger
 
@@ -63,7 +64,7 @@ class CollectionGetMixin:
             return None
         except PermissionError:
             # Windows raises ``PermissionError`` when ``path`` is a directory
-            if (os.name == "nt" and
+            if (sys.platform == "win32" and
                     os.path.isdir(path) and os.access(path, os.R_OK)):
                 return None
             raise
@@ -83,10 +84,10 @@ class CollectionGetMixin:
                         self._load_item_cache(href, input_hash)
                 if input_hash != cache_hash:
                     try:
-                        vobject_items = tuple(vobject.readComponents(
+                        vobject_items = list(vobject.readComponents(
                             raw_text.decode(self._encoding)))
                         radicale_item.check_and_sanitize_items(
-                            vobject_items, tag=self.get_meta("tag"))
+                            vobject_items, tag=self.tag)
                         vobject_item, = vobject_items
                         temp_item = radicale_item.Item(
                             collection=self, vobject_item=vobject_item)

+ 7 - 11
radicale/storage/multifilesystem/history.py

@@ -17,10 +17,11 @@
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
 import binascii
+import contextlib
 import os
 import pickle
 
-from radicale import item as radicale_item
+import radicale.item as radicale_item
 from radicale import pathutils
 from radicale.log import logger
 
@@ -53,13 +54,10 @@ class CollectionHistoryMixin:
             self._storage._makedirs_synced(history_folder)
             history_etag = radicale_item.get_etag(
                 history_etag + "/" + etag).strip("\"")
-            try:
-                # Race: Other processes might have created and locked the file.
-                with self._atomic_write(os.path.join(history_folder, href),
-                                        "wb") as f:
-                    pickle.dump([etag, history_etag], f)
-            except PermissionError:
-                pass
+            # Race: Other processes might have created and locked the file.
+            with contextlib.suppress(PermissionError), self._atomic_write(
+                    os.path.join(history_folder, href), "wb") as f:
+                pickle.dump([etag, history_etag], f)
         return history_etag
 
     def _get_deleted_history_hrefs(self):
@@ -67,7 +65,7 @@ class CollectionHistoryMixin:
         history cache."""
         history_folder = os.path.join(self._filesystem_path,
                                       ".Radicale.cache", "history")
-        try:
+        with contextlib.suppress(FileNotFoundError):
             for entry in os.scandir(history_folder):
                 href = entry.name
                 if not pathutils.is_safe_filesystem_path_component(href):
@@ -75,8 +73,6 @@ class CollectionHistoryMixin:
                 if os.path.isfile(os.path.join(self._filesystem_path, href)):
                     continue
                 yield href
-        except FileNotFoundError:
-            pass
 
     def _clean_history(self):
         # Delete all expired history entries of deleted items.

+ 3 - 2
radicale/storage/multifilesystem/lock.py

@@ -22,6 +22,7 @@ import os
 import shlex
 import signal
 import subprocess
+import sys
 
 from radicale import pathutils
 from radicale.log import logger
@@ -48,7 +49,7 @@ class StorageLockMixin:
         self._lock = pathutils.RwLock(lock_path)
 
     @contextlib.contextmanager
-    def acquire_lock(self, mode, user=None):
+    def acquire_lock(self, mode, user=""):
         with self._lock.acquire(mode):
             yield
             # execute hook
@@ -66,7 +67,7 @@ class StorageLockMixin:
                 if os.name == "posix":
                     # Process group is also used to identify child processes
                     popen_kwargs["preexec_fn"] = os.setpgrp
-                elif os.name == "nt":
+                elif sys.platform == "win32":
                     popen_kwargs["creationflags"] = (
                         subprocess.CREATE_NEW_PROCESS_GROUP)
                 command = hook % {"user": shlex.quote(user or "Anonymous")}

+ 6 - 5
radicale/storage/multifilesystem/meta.py

@@ -19,7 +19,7 @@
 import json
 import os
 
-from radicale import item as radicale_item
+import radicale.item as radicale_item
 
 
 class CollectionMetaMixin:
@@ -35,14 +35,15 @@ class CollectionMetaMixin:
             try:
                 try:
                     with open(self._props_path, encoding=self._encoding) as f:
-                        self._meta_cache = json.load(f)
+                        temp_meta = json.load(f)
                 except FileNotFoundError:
-                    self._meta_cache = {}
-                radicale_item.check_and_sanitize_props(self._meta_cache)
+                    temp_meta = {}
+                self._meta_cache = radicale_item.check_and_sanitize_props(
+                    temp_meta)
             except ValueError as e:
                 raise RuntimeError("Failed to load properties of collection "
                                    "%r: %s" % (self.path, e)) from e
-        return self._meta_cache.get(key) if key else self._meta_cache
+        return self._meta_cache if key is None else self._meta_cache.get(key)
 
     def set_meta(self, props):
         with self._atomic_write(self._props_path, "w") as f:

+ 6 - 8
radicale/storage/multifilesystem/sync.py

@@ -16,6 +16,7 @@
 # You should have received a copy of the GNU General Public License
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
+import contextlib
 import itertools
 import os
 import pickle
@@ -25,7 +26,7 @@ from radicale.log import logger
 
 
 class CollectionSyncMixin:
-    def sync(self, old_token=None):
+    def sync(self, old_token=""):
         # The sync token has the form http://radicale.org/ns/sync/TOKEN_NAME
         # where TOKEN_NAME is the sha256 hash of all history etags of present
         # and past items of the collection.
@@ -37,7 +38,7 @@ class CollectionSyncMixin:
                     return False
             return True
 
-        old_token_name = None
+        old_token_name = ""
         if old_token:
             # Extract the token name from the sync token
             if not old_token.startswith("http://radicale.org/ns/sync/"):
@@ -78,10 +79,9 @@ class CollectionSyncMixin:
                         "Failed to load stored sync token %r in %r: %s",
                         old_token_name, self.path, e, exc_info=True)
                     # Delete the damaged file
-                    try:
+                    with contextlib.suppress(FileNotFoundError,
+                                             PermissionError):
                         os.remove(old_token_path)
-                    except (FileNotFoundError, PermissionError):
-                        pass
                 raise ValueError("Token not found: %r" % old_token)
         # write the new token state or update the modification time of
         # existing token state
@@ -101,11 +101,9 @@ class CollectionSyncMixin:
                 self._clean_history()
         else:
             # Try to update the modification time
-            try:
+            with contextlib.suppress(FileNotFoundError):
                 # Race: Another process might have deleted the file.
                 os.utime(token_path)
-            except FileNotFoundError:
-                pass
         changes = []
         # Find all new, changed and deleted (that are still in the item cache)
         # items

+ 4 - 3
radicale/storage/multifilesystem/upload.py

@@ -18,8 +18,9 @@
 
 import os
 import pickle
+import sys
 
-from radicale import item as radicale_item
+import radicale.item as radicale_item
 from radicale import pathutils
 
 
@@ -63,7 +64,7 @@ class CollectionUploadMixin:
                     "Failed to store item %r in temporary collection %r: %s" %
                     (uid, self.path, e)) from e
             href_candidate_funtions = []
-            if os.name in ("nt", "posix"):
+            if os.name == "posix" or sys.platform == "win32":
                 href_candidate_funtions.append(
                     lambda: uid if uid.lower().endswith(suffix.lower())
                     else uid + suffix)
@@ -88,7 +89,7 @@ class CollectionUploadMixin:
                 except OSError as e:
                     if href_candidate_funtions and (
                             os.name == "posix" and e.errno == 22 or
-                            os.name == "nt" and e.errno == 123):
+                            sys.platform == "win32" and e.errno == 123):
                         continue
                     raise
             with f:

+ 2 - 2
radicale/storage/multifilesystem/verify.py

@@ -67,8 +67,8 @@ class StorageVerifyMixin:
                                      item.href, sane_path)
                 if item_errors == saved_item_errors:
                     collection.sync()
-                if has_child_collections and collection.get_meta("tag"):
+                if has_child_collections and collection.tag:
                     logger.error("Invalid collection %r: %r must not have "
                                  "child collections", sane_path,
-                                 collection.get_meta("tag"))
+                                 collection.tag)
         return item_errors == 0 and collection_errors == 0

+ 1 - 1
radicale/tests/__init__.py

@@ -119,7 +119,7 @@ class BaseTest:
         if not self._check_status(status, 207, check):
             return status, None
         responses = self.parse_responses(answer)
-        if args.get("HTTP_DEPTH", 0) == 0:
+        if args.get("HTTP_DEPTH", "0") == "0":
             assert len(responses) == 1 and path in responses
         return status, responses
 

+ 2 - 1
radicale/tests/test_auth.py

@@ -23,6 +23,7 @@ Radicale tests with simple requests and authentication.
 
 import os
 import shutil
+import sys
 import tempfile
 
 import pytest
@@ -114,7 +115,7 @@ class TestBaseAuthRequests(BaseTest):
     def test_htpasswd_multi(self):
         self._test_htpasswd("plain", "ign:ign\ntmp:bepo")
 
-    @pytest.mark.skipif(os.name == "nt", reason="leading and trailing "
+    @pytest.mark.skipif(sys.platform == "win32", reason="leading and trailing "
                         "whitespaces not allowed in file names")
     def test_htpasswd_whitespace_user(self):
         for user in (" tmp", "tmp ", " tmp "):

+ 10 - 10
radicale/tests/test_base.py

@@ -391,10 +391,10 @@ class BaseRequestsMixIn:
         event = get_file_content("event1.ics")
         event_path = posixpath.join(calendar_path, "event.ics")
         self.put(event_path, event)
-        _, responses = self.propfind("/", HTTP_DEPTH=1)
+        _, responses = self.propfind("/", HTTP_DEPTH="1")
         assert len(responses) == 2
         assert "/" in responses and calendar_path in responses
-        _, responses = self.propfind(calendar_path, HTTP_DEPTH=1)
+        _, responses = self.propfind(calendar_path, HTTP_DEPTH="1")
         assert len(responses) == 2
         assert calendar_path in responses and event_path in responses
 
@@ -1653,8 +1653,8 @@ class TestMultiFileSystem(BaseFileSystemTest, BaseRequestsMixIn):
         assert answer1 == answer2
         assert os.path.exists(os.path.join(cache_folder, "event1.ics"))
 
-    @pytest.mark.skipif(os.name not in ("nt", "posix"),
-                        reason="Only supported on 'nt' and 'posix'")
+    @pytest.mark.skipif(os.name != "posix" and sys.platform != "win32",
+                        reason="Only supported on 'posix' and 'win32'")
     def test_put_whole_calendar_uids_used_as_file_names(self):
         """Test if UIDs are used as file names."""
         BaseRequestsMixIn.test_put_whole_calendar(self)
@@ -1662,8 +1662,8 @@ class TestMultiFileSystem(BaseFileSystemTest, BaseRequestsMixIn):
             _, answer = self.get("/calendar.ics/%s.ics" % uid)
             assert "\r\nUID:%s\r\n" % uid in answer
 
-    @pytest.mark.skipif(os.name not in ("nt", "posix"),
-                        reason="Only supported on 'nt' and 'posix'")
+    @pytest.mark.skipif(os.name != "posix" and sys.platform != "win32",
+                        reason="Only supported on 'posix' and 'win32'")
     def test_put_whole_calendar_random_uids_used_as_file_names(self):
         """Test if UIDs are used as file names."""
         BaseRequestsMixIn.test_put_whole_calendar_without_uids(self)
@@ -1676,8 +1676,8 @@ class TestMultiFileSystem(BaseFileSystemTest, BaseRequestsMixIn):
             _, answer = self.get("/calendar.ics/%s.ics" % uid)
             assert "\r\nUID:%s\r\n" % uid in answer
 
-    @pytest.mark.skipif(os.name not in ("nt", "posix"),
-                        reason="Only supported on 'nt' and 'posix'")
+    @pytest.mark.skipif(os.name != "posix" and sys.platform != "win32",
+                        reason="Only supported on 'posix' and 'win32'")
     def test_put_whole_addressbook_uids_used_as_file_names(self):
         """Test if UIDs are used as file names."""
         BaseRequestsMixIn.test_put_whole_addressbook(self)
@@ -1685,8 +1685,8 @@ class TestMultiFileSystem(BaseFileSystemTest, BaseRequestsMixIn):
             _, answer = self.get("/contacts.vcf/%s.vcf" % uid)
             assert "\r\nUID:%s\r\n" % uid in answer
 
-    @pytest.mark.skipif(os.name not in ("nt", "posix"),
-                        reason="Only supported on 'nt' and 'posix'")
+    @pytest.mark.skipif(os.name != "posix" and sys.platform != "win32",
+                        reason="Only supported on 'posix' and 'win32'")
     def test_put_whole_addressbook_random_uids_used_as_file_names(self):
         """Test if UIDs are used as file names."""
         BaseRequestsMixIn.test_put_whole_addressbook_without_uids(self)

+ 61 - 0
radicale/types.py

@@ -0,0 +1,61 @@
+# This file is part of Radicale Server - Calendar Server
+# Copyright © 2020 Unrud <unrud@outlook.com>
+#
+# This library 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, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This library 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 Radicale.  If not, see <http://www.gnu.org/licenses/>.
+
+import contextlib
+import sys
+from typing import (Any, Callable, ContextManager, Iterator, List, Mapping,
+                    MutableMapping, Sequence, Tuple, TypeVar, Union)
+
+WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]]
+WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]]
+WSGIEnviron = Mapping[str, Any]
+WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]
+
+CONFIG = Mapping[str, Mapping[str, Any]]
+MUTABLE_CONFIG = MutableMapping[str, MutableMapping[str, Any]]
+CONFIG_SCHEMA = Mapping[str, Mapping[str, Any]]
+
+_T = TypeVar("_T")
+
+
+def contextmanager(func: Callable[..., Iterator[_T]]
+                   ) -> Callable[..., ContextManager[_T]]:
+    """Compatibility wrapper for `contextlib.contextmanager` with
+    `typeguard`"""
+    result = contextlib.contextmanager(func)
+    result.__annotations__ = {**func.__annotations__,
+                              "return": ContextManager[_T]}
+    return result
+
+
+if sys.version_info >= (3, 8):
+    from typing import Protocol, runtime_checkable
+
+    @runtime_checkable
+    class InputStream(Protocol):
+        def read(self, size: int = ...) -> bytes: ...
+
+    @runtime_checkable
+    class ErrorStream(Protocol):
+        def flush(self) -> None: ...
+        def write(self, s: str) -> None: ...
+else:
+    ErrorStream = Any
+    InputStream = Any
+
+from radicale import item, storage  # noqa:E402 isort:skip
+
+CollectionOrItem = Union[item.Item, storage.BaseCollection]

+ 8 - 2
radicale/utils.py

@@ -17,12 +17,18 @@
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
 from importlib import import_module
+from typing import Callable, Sequence, Type, TypeVar, Union
 
+from radicale import config
 from radicale.log import logger
 
+_T_co = TypeVar("_T_co", covariant=True)
 
-def load_plugin(internal_types, module_name, class_name, configuration):
-    type_ = configuration.get(module_name, "type")
+
+def load_plugin(internal_types: Sequence[str], module_name: str,
+                class_name: str, base_class: Type[_T_co],
+                configuration: "config.Configuration") -> _T_co:
+    type_: Union[str, Callable] = configuration.get(module_name, "type")
     if callable(type_):
         logger.info("%s type is %r", module_name, type_)
         return type_(configuration)

+ 15 - 7
radicale/web/__init__.py

@@ -21,18 +21,24 @@ Take a look at the class ``BaseWeb`` if you want to implement your own.
 
 """
 
-from radicale import httputils, utils
+from typing import Sequence
 
-INTERNAL_TYPES = ("none", "internal")
+from radicale import config, httputils, types, utils
 
+INTERNAL_TYPES: Sequence[str] = ("none", "internal")
 
-def load(configuration):
+
+def load(configuration: "config.Configuration") -> "BaseWeb":
     """Load the web module chosen in configuration."""
-    return utils.load_plugin(INTERNAL_TYPES, "web", "Web", configuration)
+    return utils.load_plugin(INTERNAL_TYPES, "web", "Web", BaseWeb,
+                             configuration)
 
 
 class BaseWeb:
-    def __init__(self, configuration):
+
+    configuration: "config.Configuration"
+
+    def __init__(self, configuration: "config.Configuration") -> None:
         """Initialize BaseWeb.
 
         ``configuration`` see ``radicale.config`` module.
@@ -42,7 +48,8 @@ class BaseWeb:
         """
         self.configuration = configuration
 
-    def get(self, environ, base_prefix, path, user):
+    def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
+            user: str) -> types.WSGIResponse:
         """GET request.
 
         ``base_prefix`` is sanitized and never ends with "/".
@@ -54,7 +61,8 @@ class BaseWeb:
         """
         return httputils.METHOD_NOT_ALLOWED
 
-    def post(self, environ, base_prefix, path, user):
+    def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
+             user: str) -> types.WSGIResponse:
         """POST request.
 
         ``base_prefix`` is sanitized and never ends with "/".

+ 10 - 5
radicale/web/internal.py

@@ -30,13 +30,14 @@ import os
 import posixpath
 import time
 from http import client
+from typing import Mapping
 
 import pkg_resources
 
-from radicale import httputils, pathutils, web
+from radicale import config, httputils, pathutils, types, web
 from radicale.log import logger
 
-MIMETYPES = {
+MIMETYPES: Mapping[str, str] = {
     ".css": "text/css",
     ".eot": "application/vnd.ms-fontobject",
     ".gif": "image/gif",
@@ -50,16 +51,20 @@ MIMETYPES = {
     ".woff": "application/font-woff",
     ".woff2": "font/woff2",
     ".xml": "text/xml"}
-FALLBACK_MIMETYPE = "application/octet-stream"
+FALLBACK_MIMETYPE: str = "application/octet-stream"
 
 
 class Web(web.BaseWeb):
-    def __init__(self, configuration):
+
+    folder: str
+
+    def __init__(self, configuration: config.Configuration) -> None:
         super().__init__(configuration)
         self.folder = pkg_resources.resource_filename(__name__,
                                                       "internal_data")
 
-    def get(self, environ, base_prefix, path, user):
+    def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
+            user: str) -> types.WSGIResponse:
         assert path == "/.web" or path.startswith("/.web/")
         assert pathutils.sanitize_path(path) == path
         try:

+ 4 - 2
radicale/web/none.py

@@ -21,11 +21,13 @@ A dummy web backend that shows a simple message.
 
 from http import client
 
-from radicale import httputils, pathutils, web
+from radicale import httputils, pathutils, types, web
 
 
 class Web(web.BaseWeb):
-    def get(self, environ, base_prefix, path, user):
+
+    def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
+            user: str) -> types.WSGIResponse:
         assert path == "/.web" or path.startswith("/.web/")
         assert pathutils.sanitize_path(path) == path
         if path != "/.web":

+ 20 - 18
radicale/xmlutils.py

@@ -26,20 +26,21 @@ import copy
 import xml.etree.ElementTree as ET
 from collections import OrderedDict
 from http import client
+from typing import Dict, Mapping, Optional
 from urllib.parse import quote
 
-from radicale import pathutils
+from radicale import item, pathutils
 
-MIMETYPES = {
+MIMETYPES: Mapping[str, str] = {
     "VADDRESSBOOK": "text/vcard",
     "VCALENDAR": "text/calendar"}
 
-OBJECT_MIMETYPES = {
+OBJECT_MIMETYPES: Mapping[str, str] = {
     "VCARD": "text/vcard",
     "VLIST": "text/x-vlist",
     "VCALENDAR": "text/calendar"}
 
-NAMESPACES = {
+NAMESPACES: Mapping[str, str] = {
     "C": "urn:ietf:params:xml:ns:caldav",
     "CR": "urn:ietf:params:xml:ns:carddav",
     "D": "DAV:",
@@ -48,15 +49,15 @@ NAMESPACES = {
     "ME": "http://me.com/_namespace/",
     "RADICALE": "http://radicale.org/ns/"}
 
-NAMESPACES_REV = {}
+NAMESPACES_REV: Mapping[str, str] = {v: k for k, v in NAMESPACES.items()}
+
 for short, url in NAMESPACES.items():
-    NAMESPACES_REV[url] = short
     ET.register_namespace("" if short == "D" else short, url)
 
 
-def pretty_xml(element):
+def pretty_xml(element: ET.Element) -> str:
     """Indent an ElementTree ``element`` and its children."""
-    def pretty_xml_recursive(element, level):
+    def pretty_xml_recursive(element: ET.Element, level: int) -> None:
         indent = "\n" + level * "  "
         if len(element) > 0:
             if not (element.text or "").strip():
@@ -74,7 +75,7 @@ def pretty_xml(element):
     return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")
 
 
-def make_clark(human_tag):
+def make_clark(human_tag: str) -> str:
     """Get XML Clark notation from human tag ``human_tag``.
 
     If ``human_tag`` is already in XML Clark notation it is returned as-is.
@@ -88,13 +89,13 @@ def make_clark(human_tag):
     ns_prefix, tag = human_tag.split(":", maxsplit=1)
     if not ns_prefix or not tag:
         raise ValueError("Invalid XML tag: %r" % human_tag)
-    ns = NAMESPACES.get(ns_prefix)
+    ns = NAMESPACES.get(ns_prefix, "")
     if not ns:
         raise ValueError("Unknown XML namespace prefix: %r" % human_tag)
     return "{%s}%s" % (ns, tag)
 
 
-def make_human_tag(clark_tag):
+def make_human_tag(clark_tag: str) -> str:
     """Replace known namespaces in XML Clark notation ``clark_tag`` with
        prefix.
 
@@ -111,31 +112,31 @@ def make_human_tag(clark_tag):
     ns, tag = clark_tag[len("{"):].split("}", maxsplit=1)
     if not ns or not tag:
         raise ValueError("Invalid XML tag: %r" % clark_tag)
-    ns_prefix = NAMESPACES_REV.get(ns)
+    ns_prefix = NAMESPACES_REV.get(ns, "")
     if ns_prefix:
         return "%s:%s" % (ns_prefix, tag)
     return clark_tag
 
 
-def make_response(code):
+def make_response(code: int) -> str:
     """Return full W3C names from HTTP status codes."""
     return "HTTP/1.1 %i %s" % (code, client.responses[code])
 
 
-def make_href(base_prefix, href):
+def make_href(base_prefix: str, href: str) -> str:
     """Return prefixed href."""
     assert href == pathutils.sanitize_path(href)
     return quote("%s%s" % (base_prefix, href))
 
 
-def webdav_error(human_tag):
+def webdav_error(human_tag: str) -> ET.Element:
     """Generate XML error message."""
     root = ET.Element(make_clark("D:error"))
     root.append(ET.Element(make_clark(human_tag)))
     return root
 
 
-def get_content_type(item, encoding):
+def get_content_type(item: "item.Item", encoding: str) -> str:
     """Get the content-type of an item with charset and component parameters.
     """
     mimetype = OBJECT_MIMETYPES[item.name]
@@ -146,13 +147,14 @@ def get_content_type(item, encoding):
     return content_type
 
 
-def props_from_request(xml_request):
+def props_from_request(xml_request: Optional[ET.Element]
+                       ) -> Dict[str, Optional[str]]:
     """Return a list of properties as a dictionary.
 
     Properties that should be removed are set to `None`.
 
     """
-    result = OrderedDict()
+    result: OrderedDict = OrderedDict()
     if xml_request is None:
         return result