diff --git a/radicale/__init__.py b/radicale/__init__.py index 6dd9545..ef98635 100644 --- a/radicale/__init__.py +++ b/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) diff --git a/radicale/__main__.py b/radicale/__main__.py index d3cb1df..d88aeeb 100644 --- a/radicale/__main__.py +++ b/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) diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 4bf1e9a..c1bd090 100644 --- a/radicale/app/__init__.py +++ b/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") + 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 _headers_log(self, environ): - """Sanitize headers for logging.""" - request_environ = dict(environ) + 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 - # 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): + 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))) diff --git a/radicale/app/base.py b/radicale/app/base.py new file mode 100644 index 0000000..7b139a2 --- /dev/null +++ b/radicale/app/base.py @@ -0,0 +1,131 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2020 Unrud +# +# 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 . + +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))) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 05437c8..c47fea3 100644 --- a/radicale/app/delete.py +++ b/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} diff --git a/radicale/app/get.py b/radicale/app/get.py index 79e068a..7e99002 100644 --- a/radicale/app/get.py +++ b/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, diff --git a/radicale/app/head.py b/radicale/app/head.py index 7ba004f..357682d 100644 --- a/radicale/app/head.py +++ b/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 . +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 diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index f0649d7..67b6c3f 100644 --- a/radicale/app/mkcalendar.py +++ b/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) diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 4118141..5d87169 100644 --- a/radicale/app/mkcol.py +++ b/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) diff --git a/radicale/app/move.py b/radicale/app/move.py index c2fe71a..37a7294 100644 --- a/radicale/app/move.py +++ b/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) diff --git a/radicale/app/options.py b/radicale/app/options.py index c110a28..19f00f0 100644 --- a/radicale/app/options.py +++ b/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( diff --git a/radicale/app/post.py b/radicale/app/post.py index a3de951..e350a10 100644 --- a/radicale/app/post.py +++ b/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 . -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) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 55ca2d3..e4f674e 100644 --- a/radicale/app/propfind.py +++ b/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") - collection = item - else: - collection = item.collection - 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 / + 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: - uri = pathutils.unstrip_path( - posixpath.join(collection.path, item.href)) + 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")) 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) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 38804dd..7170266 100644 --- a/radicale/app/proppatch.py +++ b/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 . -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): diff --git a/radicale/app/put.py b/radicale/app/put.py index 2951974..bdb9fae 100644 --- a/radicale/app/put.py +++ b/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)): diff --git a/radicale/app/report.py b/radicale/app/report.py index 0c35783..b2e6335 100644 --- a/radicale/app/report.py +++ b/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) diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index fcd6c90..8de6c5f 100644 --- a/radicale/auth/__init__.py +++ b/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 diff --git a/radicale/auth/htpasswd.py b/radicale/auth/htpasswd.py index 18a09e3..35e30f9 100644 --- a/radicale/auth/htpasswd.py +++ b/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 diff --git a/radicale/auth/http_x_remote_user.py b/radicale/auth/http_x_remote_user.py index aa353f2..8c0f236 100644 --- a/radicale/auth/http_x_remote_user.py +++ b/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", ""), "" diff --git a/radicale/auth/none.py b/radicale/auth/none.py index b785e7e..b75a33c 100644 --- a/radicale/auth/none.py +++ b/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 diff --git a/radicale/auth/remote_user.py b/radicale/auth/remote_user.py index 1c2d49a..8117591 100644 --- a/radicale/auth/remote_user.py +++ b/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", ""), "" diff --git a/radicale/config.py b/radicale/config.py index 880f2a5..c094711 100644 --- a/radicale/config.py +++ b/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 -class Configuration: - SOURCE_MISSING: ClassVar[Any] = {} +_Self = TypeVar("_Self", bound="Configuration") - def __init__(self, schema): + +class Configuration: + + 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) diff --git a/radicale/httputils.py b/radicale/httputils.py index 6911e15..d8a31b6 100644 --- a/radicale/httputils.py +++ b/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 diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index ce6d775..3c9c59d 100644 --- a/radicale/item/__init__.py +++ b/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``. - Returns a tuple (``tag``, ``start``, ``end``) where ``tag`` is a string - and ``start`` and ``end`` are POSIX timestamps (as int). + ``tag`` must be set to the return value of ``find_tag``. + + 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() diff --git a/radicale/item/filter.py b/radicale/item/filter.py index 17919cf..973f4dd 100644 --- a/radicale/item/filter.py +++ b/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 diff --git a/radicale/log.py b/radicale/log.py index a31fe17..708a36f 100644 --- a/radicale/log.py +++ b/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" - def __init__(self): + _streams: Dict[int, types.ErrorStream] + + 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) diff --git a/radicale/pathutils.py b/radicale/pathutils.py index 7a2e675..fdbff5d 100644 --- a/radicale/pathutils.py +++ b/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) diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index 1f0a534..62a79cb 100644 --- a/radicale/rights/__init__.py +++ b/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. diff --git a/radicale/rights/authenticated.py b/radicale/rights/authenticated.py index 1d171d3..fc669b4 100644 --- a/radicale/rights/authenticated.py +++ b/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) diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index d6626fb..5ec90ce 100644 --- a/radicale/rights/from_file.py +++ b/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: " diff --git a/radicale/rights/owner_only.py b/radicale/rights/owner_only.py index 339e6fc..bfcad8e 100644 --- a/radicale/rights/owner_only.py +++ b/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) diff --git a/radicale/rights/owner_write.py b/radicale/rights/owner_write.py index e718e02..be4c92d 100644 --- a/radicale/rights/owner_write.py +++ b/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) diff --git a/radicale/server.py b/radicale/server.py index ceeda83..7ec5b3c 100644 --- a/radicale/server.py +++ b/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 - def __init__(self, configuration, family, address, RequestHandlerClass): + # We wait for child threads ourself (ThreadingMixIn) + block_on_close: bool = False + daemon_threads: bool = True + + 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) + 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 finish_request_locked(self, request, client_address): - return super().finish_request(request, client_address) + def verify_request( # type:ignore[override] + self, request: socket.socket, client_address_and_socket: + Tuple[ADDRESS_TYPE, socket.socket]) -> bool: + return True - def finish_request(self, request, client_address): - *client_address, client_socket = client_address - client_address = tuple(client_address) + 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() + worker_socket.close() - def handle_error(self, request, client_address): - if issubclass(sys.exc_info()[0], socket.timeout): + 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() diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index 7d2f1e7..0163c7c 100644 --- a/radicale/storage/__init__.py +++ b/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 + 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): + 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 diff --git a/radicale/storage/multifilesystem/cache.py b/radicale/storage/multifilesystem/cache.py index c143a71..74327b3 100644 --- a/radicale/storage/multifilesystem/cache.py +++ b/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 . +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): diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py index 0721450..d1b6322 100644 --- a/radicale/storage/multifilesystem/get.py +++ b/radicale/storage/multifilesystem/get.py @@ -17,11 +17,12 @@ # along with Radicale. If not, see . 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) diff --git a/radicale/storage/multifilesystem/history.py b/radicale/storage/multifilesystem/history.py index 36d4219..56afd01 100644 --- a/radicale/storage/multifilesystem/history.py +++ b/radicale/storage/multifilesystem/history.py @@ -17,10 +17,11 @@ # along with Radicale. If not, see . 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. diff --git a/radicale/storage/multifilesystem/lock.py b/radicale/storage/multifilesystem/lock.py index 3060dd7..87b2edc 100644 --- a/radicale/storage/multifilesystem/lock.py +++ b/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")} diff --git a/radicale/storage/multifilesystem/meta.py b/radicale/storage/multifilesystem/meta.py index c0dfa9b..7b196fa 100644 --- a/radicale/storage/multifilesystem/meta.py +++ b/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: diff --git a/radicale/storage/multifilesystem/sync.py b/radicale/storage/multifilesystem/sync.py index 21c00c1..03fe773 100644 --- a/radicale/storage/multifilesystem/sync.py +++ b/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 . +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 diff --git a/radicale/storage/multifilesystem/upload.py b/radicale/storage/multifilesystem/upload.py index d0dc5fb..cf0f998 100644 --- a/radicale/storage/multifilesystem/upload.py +++ b/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: diff --git a/radicale/storage/multifilesystem/verify.py b/radicale/storage/multifilesystem/verify.py index 61b81d7..93a0509 100644 --- a/radicale/storage/multifilesystem/verify.py +++ b/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 diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index a3de300..f8900b1 100644 --- a/radicale/tests/__init__.py +++ b/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 diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index 8061fdd..5379be7 100644 --- a/radicale/tests/test_auth.py +++ b/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 "): diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index e8ad347..5952615 100644 --- a/radicale/tests/test_base.py +++ b/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) diff --git a/radicale/types.py b/radicale/types.py new file mode 100644 index 0000000..f0393e3 --- /dev/null +++ b/radicale/types.py @@ -0,0 +1,61 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2020 Unrud +# +# 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 . + +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] diff --git a/radicale/utils.py b/radicale/utils.py index 656912d..8163ea7 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -17,12 +17,18 @@ # along with Radicale. If not, see . 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) diff --git a/radicale/web/__init__.py b/radicale/web/__init__.py index c18e8d3..efcbf61 100644 --- a/radicale/web/__init__.py +++ b/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 "/". diff --git a/radicale/web/internal.py b/radicale/web/internal.py index eb88c42..0a3b3b7 100644 --- a/radicale/web/internal.py +++ b/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: diff --git a/radicale/web/none.py b/radicale/web/none.py index ab49011..423579e 100644 --- a/radicale/web/none.py +++ b/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": diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index fa6f6bf..5fb8628 100644 --- a/radicale/xmlutils.py +++ b/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 '\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