More type hints

This commit is contained in:
Unrud 2021-07-26 20:56:46 +02:00 committed by Unrud
parent 12fe5ce637
commit cecb17df03
51 changed files with 1374 additions and 957 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)))

131
radicale/app/base.py Normal file
View File

@ -0,0 +1,131 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2020 Unrud <unrud@outlook.com>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import io
import logging
import posixpath
import sys
import xml.etree.ElementTree as ET
from typing import Optional
from radicale import (auth, config, httputils, pathutils, rights, storage,
types, web, xmlutils)
from radicale.log import logger
# HACK: https://github.com/tiran/defusedxml/issues/54
import defusedxml.ElementTree as DefusedET # isort:skip
sys.modules["xml.etree"].ElementTree = ET # type:ignore[attr-defined]
class ApplicationBase:
configuration: config.Configuration
_auth: auth.BaseAuth
_storage: storage.BaseStorage
_rights: rights.BaseRights
_web: web.BaseWeb
_encoding: str
def __init__(self, configuration: config.Configuration) -> None:
self.configuration = configuration
self._auth = auth.load(configuration)
self._storage = storage.load(configuration)
self._rights = rights.load(configuration)
self._web = web.load(configuration)
self._encoding = configuration.get("encoding", "request")
def _read_xml_request_body(self, environ: types.WSGIEnviron
) -> Optional[ET.Element]:
content = httputils.decode_request(
self.configuration, environ,
httputils.read_raw_request_body(self.configuration, environ))
if not content:
return None
try:
xml_content = DefusedET.fromstring(content)
except ET.ParseError as e:
logger.debug("Request content (Invalid XML):\n%s", content)
raise RuntimeError("Failed to parse XML: %s" % e) from e
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content:\n%s",
xmlutils.pretty_xml(xml_content))
return xml_content
def _xml_response(self, xml_content: ET.Element) -> bytes:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response content:\n%s",
xmlutils.pretty_xml(xml_content))
f = io.BytesIO()
ET.ElementTree(xml_content).write(f, encoding=self._encoding,
xml_declaration=True)
return f.getvalue()
def _webdav_error_response(self, status: int, human_tag: str
) -> types.WSGIResponse:
"""Generate XML error response."""
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
content = self._xml_response(xmlutils.webdav_error(human_tag))
return status, headers, content
class Access:
"""Helper class to check access rights of an item"""
user: str
path: str
parent_path: str
permissions: str
_rights: rights.BaseRights
_parent_permissions: Optional[str]
def __init__(self, rights: rights.BaseRights, user: str, path: str
) -> None:
self._rights = rights
self.user = user
self.path = path
self.parent_path = pathutils.unstrip_path(
posixpath.dirname(pathutils.strip_path(path)), True)
self.permissions = self._rights.authorization(self.user, self.path)
self._parent_permissions = None
@property
def parent_permissions(self) -> str:
if self.path == self.parent_path:
return self.permissions
if self._parent_permissions is None:
self._parent_permissions = self._rights.authorization(
self.user, self.parent_path)
return self._parent_permissions
def check(self, permission: str,
item: Optional[types.CollectionOrItem] = None) -> bool:
if permission not in "rw":
raise ValueError("Invalid permission argument: %r" % permission)
if not item:
permissions = permission + permission.upper()
parent_permissions = permission
elif isinstance(item, storage.BaseCollection):
if item.tag:
permissions = permission
else:
permissions = permission.upper()
parent_permissions = ""
else:
permissions = ""
parent_permissions = permission
return bool(rights.intersect(self.permissions, permissions) or (
self.path != self.parent_path and
rights.intersect(self.parent_permissions, parent_permissions)))

View File

@ -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}

View File

@ -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,

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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(

View File

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

View File

@ -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)

View File

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

View File

@ -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)):

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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", ""), ""

View File

@ -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

View File

@ -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", ""), ""

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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.

View File

@ -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)

View File

@ -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: "

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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")}

View File

@ -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:

View File

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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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 "):

View File

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

61
radicale/types.py Normal file
View File

@ -0,0 +1,61 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2020 Unrud <unrud@outlook.com>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import contextlib
import sys
from typing import (Any, Callable, ContextManager, Iterator, List, Mapping,
MutableMapping, Sequence, Tuple, TypeVar, Union)
WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]]
WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]]
WSGIEnviron = Mapping[str, Any]
WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]
CONFIG = Mapping[str, Mapping[str, Any]]
MUTABLE_CONFIG = MutableMapping[str, MutableMapping[str, Any]]
CONFIG_SCHEMA = Mapping[str, Mapping[str, Any]]
_T = TypeVar("_T")
def contextmanager(func: Callable[..., Iterator[_T]]
) -> Callable[..., ContextManager[_T]]:
"""Compatibility wrapper for `contextlib.contextmanager` with
`typeguard`"""
result = contextlib.contextmanager(func)
result.__annotations__ = {**func.__annotations__,
"return": ContextManager[_T]}
return result
if sys.version_info >= (3, 8):
from typing import Protocol, runtime_checkable
@runtime_checkable
class InputStream(Protocol):
def read(self, size: int = ...) -> bytes: ...
@runtime_checkable
class ErrorStream(Protocol):
def flush(self) -> None: ...
def write(self, s: str) -> None: ...
else:
ErrorStream = Any
InputStream = Any
from radicale import item, storage # noqa:E402 isort:skip
CollectionOrItem = Union[item.Item, storage.BaseCollection]

View File

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

View File

@ -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 "/".

View File

@ -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:

View File

@ -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":

View File

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