Răsfoiți Sursa

Merge branch 'master' into react0r

ray-react0r 1 an în urmă
părinte
comite
3cba4b32a3

+ 14 - 2
.github/workflows/test.yml

@@ -6,7 +6,7 @@ jobs:
     strategy:
     strategy:
       matrix:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', pypy-3.8, pypy-3.9]
+        python-version: ['3.8', '3.9', '3.10', '3.11', '3.12.3', '3.13.0-beta.4', pypy-3.8, pypy-3.9]
         exclude:
         exclude:
           - os: windows-latest
           - os: windows-latest
             python-version: pypy-3.8
             python-version: pypy-3.8
@@ -21,7 +21,7 @@ jobs:
       - name: Install Test dependencies
       - name: Install Test dependencies
         run: pip install tox
         run: pip install tox
       - name: Test
       - name: Test
-        run: tox
+        run: tox -e py
       - name: Install Coveralls
       - name: Install Coveralls
         if: github.event_name == 'push'
         if: github.event_name == 'push'
         run: pip install coveralls
         run: pip install coveralls
@@ -46,3 +46,15 @@ jobs:
         env:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         run: coveralls --service=github --finish
         run: coveralls --service=github --finish
+
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.12'
+      - name: Install tox
+        run: pip install tox
+      - name: Lint
+        run: tox -e flake8,mypy,isort

+ 2 - 0
CHANGELOG.md

@@ -4,6 +4,8 @@
 * Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar
 * Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar
 * Enhancement: Added free-busy report
 * Enhancement: Added free-busy report
 * Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports
 * Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports
+* Enhancement: remove unexpected control codes from uploaded items
+* Drop: remove unused requirement "typeguard"
 * Improve: Refactored some date parsing code
 * Improve: Refactored some date parsing code
 
 
 ## 3.2.2
 ## 3.2.2

+ 27 - 9
DOCUMENTATION.md

@@ -350,16 +350,13 @@ location /radicale/ { # The trailing / is important!
 }
 }
 ```
 ```
 
 
-Example **Caddy** configuration with basicauth from Caddy:
+Example **Caddy** configuration:
 
 
-```Caddy
-handle_path /radicale* {
-    basicauth {
-        user hash
-    }
+```
+handle_path /radicale/* {
+    uri strip_prefix /radicale
     reverse_proxy localhost:5232 {
     reverse_proxy localhost:5232 {
-        header_up +X-Script-Name "/radicale"
-        header_up +X-remote-user "{http.auth.user.id}"
+        header_up X-Script-Name /radicale
     }
     }
 }
 }
 ```
 ```
@@ -440,6 +437,21 @@ location /radicale/ {
 }
 }
 ```
 ```
 
 
+Example **Caddy** configuration:
+
+```
+handle_path /radicale/* {
+    uri strip_prefix /radicale
+    basicauth {
+        USER HASH
+    }
+    reverse_proxy localhost:5232 {
+        header_up X-Script-Name /radicale
+        header_up X-remote-user {http.auth.user.id}
+    }
+}
+```
+
 Example **Apache** configuration:
 Example **Apache** configuration:
 
 
 ```apache
 ```apache
@@ -795,6 +807,12 @@ providers like ldap, kerberos
 
 
 Default: `False`
 Default: `False`
 
 
+##### strip_domain
+
+Strip domain from username
+
+Default: `False`
+
 #### rights
 #### rights
 
 
 ##### type
 ##### type
@@ -865,7 +883,7 @@ Delete sync-token that are older than the specified time. (seconds)
 
 
 Default: `2592000`
 Default: `2592000`
 
 
-#### skip_broken_item
+##### skip_broken_item
 
 
 Skip broken item instead of triggering an exception
 Skip broken item instead of triggering an exception
 
 

+ 2 - 0
config

@@ -73,6 +73,8 @@
 # Convert username to lowercase, must be true for case-insensitive auth providers
 # Convert username to lowercase, must be true for case-insensitive auth providers
 #lc_username = False
 #lc_username = False
 
 
+# Strip domain name from username
+#strip_domain = False
 
 
 [rights]
 [rights]
 
 

+ 1 - 1
radicale/__init__.py

@@ -61,7 +61,7 @@ def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream
                     if not miss and source != "default config":
                     if not miss and source != "default config":
                         default_config_active = False
                         default_config_active = False
                 if default_config_active:
                 if default_config_active:
-                    logger.warn("%s", "No config file found/readable - only default config is active")
+                    logger.warning("%s", "No config file found/readable - only default config is active")
                 _application_instance = Application(configuration)
                 _application_instance = Application(configuration)
     if _application_config_path != config_path:
     if _application_config_path != config_path:
         raise ValueError("RADICALE_CONFIG must not change: %r != %r" %
         raise ValueError("RADICALE_CONFIG must not change: %r != %r" %

+ 2 - 2
radicale/__main__.py

@@ -175,7 +175,7 @@ def run() -> None:
             default_config_active = False
             default_config_active = False
 
 
     if default_config_active:
     if default_config_active:
-        logger.warn("%s", "No config file found/readable - only default config is active")
+        logger.warning("%s", "No config file found/readable - only default config is active")
 
 
     if args_ns.verify_storage:
     if args_ns.verify_storage:
         logger.info("Verifying storage")
         logger.info("Verifying storage")
@@ -183,7 +183,7 @@ def run() -> None:
             storage_ = storage.load(configuration)
             storage_ = storage.load(configuration)
             with storage_.acquire_lock("r"):
             with storage_.acquire_lock("r"):
                 if not storage_.verify():
                 if not storage_.verify():
-                    logger.critical("Storage verifcation failed")
+                    logger.critical("Storage verification failed")
                     sys.exit(1)
                     sys.exit(1)
         except Exception as e:
         except Exception as e:
             logger.critical("An exception occurred during storage "
             logger.critical("An exception occurred during storage "

+ 1 - 1
radicale/app/__init__.py

@@ -232,7 +232,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
                 path.rstrip("/").endswith("/.well-known/carddav")):
                 path.rstrip("/").endswith("/.well-known/carddav")):
             return response(*httputils.redirect(
             return response(*httputils.redirect(
                 base_prefix + "/", client.MOVED_PERMANENTLY))
                 base_prefix + "/", client.MOVED_PERMANENTLY))
-        # Return NOT FOUND for all other paths containing ".well-knwon"
+        # Return NOT FOUND for all other paths containing ".well-known"
         if path.endswith("/.well-known") or "/.well-known/" in path:
         if path.endswith("/.well-known") or "/.well-known/" in path:
             return response(*httputils.NOT_FOUND)
             return response(*httputils.NOT_FOUND)
 
 

+ 3 - 3
radicale/app/propfind.py

@@ -322,13 +322,13 @@ def xml_propfind_response(
 
 
         responses[404 if is404 else 200].append(element)
         responses[404 if is404 else 200].append(element)
 
 
-    for status_code, childs in responses.items():
-        if not childs:
+    for status_code, children in responses.items():
+        if not children:
             continue
             continue
         propstat = ET.Element(xmlutils.make_clark("D:propstat"))
         propstat = ET.Element(xmlutils.make_clark("D:propstat"))
         response.append(propstat)
         response.append(propstat)
         prop = ET.Element(xmlutils.make_clark("D:prop"))
         prop = ET.Element(xmlutils.make_clark("D:prop"))
-        prop.extend(childs)
+        prop.extend(children)
         propstat.append(prop)
         propstat.append(prop)
         status = ET.Element(xmlutils.make_clark("D:status"))
         status = ET.Element(xmlutils.make_clark("D:status"))
         status.text = xmlutils.make_response(status_code)
         status.text = xmlutils.make_response(status_code)

+ 1 - 1
radicale/app/report.py

@@ -363,7 +363,7 @@ def _make_vobject_expanded_item(
     if hasattr(item.vobject_item.vevent, 'rrule'):
     if hasattr(item.vobject_item.vevent, 'rrule'):
         rruleset = vevent.getrruleset()
         rruleset = vevent.getrruleset()
 
 
-    # There is something strage behavour during serialization native datetime, so converting manualy
+    # There is something strange behaviour during serialization native datetime, so converting manually
     vevent.dtstart.value = vevent.dtstart.value.strftime(dt_format)
     vevent.dtstart.value = vevent.dtstart.value.strftime(dt_format)
     if dt_end is not None:
     if dt_end is not None:
         vevent.dtend.value = vevent.dtend.value.strftime(dt_format)
         vevent.dtend.value = vevent.dtend.value.strftime(dt_format)

+ 7 - 1
radicale/auth/__init__.py

@@ -52,6 +52,7 @@ def load(configuration: "config.Configuration") -> "BaseAuth":
 class BaseAuth:
 class BaseAuth:
 
 
     _lc_username: bool
     _lc_username: bool
+    _strip_domain: bool
 
 
     def __init__(self, configuration: "config.Configuration") -> None:
     def __init__(self, configuration: "config.Configuration") -> None:
         """Initialize BaseAuth.
         """Initialize BaseAuth.
@@ -63,6 +64,7 @@ class BaseAuth:
         """
         """
         self.configuration = configuration
         self.configuration = configuration
         self._lc_username = configuration.get("auth", "lc_username")
         self._lc_username = configuration.get("auth", "lc_username")
+        self._strip_domain = configuration.get("auth", "strip_domain")
 
 
     def get_external_login(self, environ: types.WSGIEnviron) -> Union[
     def get_external_login(self, environ: types.WSGIEnviron) -> Union[
             Tuple[()], Tuple[str, str]]:
             Tuple[()], Tuple[str, str]]:
@@ -91,4 +93,8 @@ class BaseAuth:
         raise NotImplementedError
         raise NotImplementedError
 
 
     def login(self, login: str, password: str) -> str:
     def login(self, login: str, password: str) -> str:
-        return self._login(login, password).lower() if self._lc_username else self._login(login, password)
+        if self._lc_username:
+            login = login.lower()
+        if self._strip_domain:
+            login = login.split('@')[0]
+        return self._login(login, password)

+ 1 - 1
radicale/auth/htpasswd.py

@@ -36,7 +36,7 @@ pointed to by the ``htpasswd_filename`` configuration value while assuming
 the password encryption method specified via the ``htpasswd_encryption``
 the password encryption method specified via the ``htpasswd_encryption``
 configuration value.
 configuration value.
 
 
-The following htpasswd password encrpytion methods are supported by Radicale
+The following htpasswd password encryption methods are supported by Radicale
 out-of-the-box:
 out-of-the-box:
     - plain-text (created by htpasswd -p ...) -- INSECURE
     - plain-text (created by htpasswd -p ...) -- INSECURE
     - MD5-APR1   (htpasswd -m ...) -- htpasswd's default method, INSECURE
     - MD5-APR1   (htpasswd -m ...) -- htpasswd's default method, INSECURE

+ 4 - 0
radicale/config.py

@@ -191,6 +191,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
             "value": "1",
             "value": "1",
             "help": "incorrect authentication delay",
             "help": "incorrect authentication delay",
             "type": positive_float}),
             "type": positive_float}),
+        ("strip_domain", {
+            "value": "False",
+            "help": "strip domain from username",
+            "type": bool}),
         ("lc_username", {
         ("lc_username", {
             "value": "False",
             "value": "False",
             "help": "convert username to lowercase, must be true for case-insensitive auth providers",
             "help": "convert username to lowercase, must be true for case-insensitive auth providers",

+ 11 - 2
radicale/hook/__init__.py

@@ -3,14 +3,23 @@ from enum import Enum
 from typing import Sequence
 from typing import Sequence
 
 
 from radicale import pathutils, utils
 from radicale import pathutils, utils
+from radicale.log import logger
 
 
 INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq")
 INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq")
 
 
 
 
 def load(configuration):
 def load(configuration):
     """Load the storage module chosen in configuration."""
     """Load the storage module chosen in configuration."""
-    return utils.load_plugin(
-        INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
+    try:
+        return utils.load_plugin(
+            INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
+    except Exception as e:
+        logger.warn(e)
+        logger.warn("Hook \"%s\" failed to load, falling back to \"none\"." % configuration.get("hook", "type"))
+        configuration = configuration.copy()
+        configuration.update({"hook": {"type": "none"}}, "hook", privileged=True)
+        return utils.load_plugin(
+            INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
 
 
 
 
 class BaseHook:
 class BaseHook:

+ 7 - 1
radicale/item/__init__.py

@@ -49,6 +49,12 @@ def read_components(s: str) -> List[vobject.base.Component]:
     s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)"
     s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)"
                r"data:[^;,\r\n]*;base64,", r"\1", s,
                r"data:[^;,\r\n]*;base64,", r"\1", s,
                flags=re.MULTILINE | re.IGNORECASE)
                flags=re.MULTILINE | re.IGNORECASE)
+    # Workaround for bug with malformed ICS files containing control codes
+    # Filter out all control codes except those we expect to find:
+    #  * 0x09 Horizontal Tab
+    #  * 0x0A Line Feed
+    #  * 0x0D Carriage Return
+    s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s)
     return list(vobject.readComponents(s, allowQP=True))
     return list(vobject.readComponents(s, allowQP=True))
 
 
 
 
@@ -298,7 +304,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
     Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are
     Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are
     POSIX timestamps.
     POSIX timestamps.
 
 
-    This is intened to be used for matching against simplified prefilters.
+    This is intended to be used for matching against simplified prefilters.
 
 
     """
     """
     if not tag:
     if not tag:

+ 1 - 1
radicale/item/filter.py

@@ -241,7 +241,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
 
 
     """
     """
 
 
-    # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled
+    # HACK: According to rfc5545-3.8.4.4 a recurrence that is rescheduled
     # with Recurrence ID affects the recurrence itself and all following
     # with Recurrence ID affects the recurrence itself and all following
     # recurrences too. This is not respected and client don't seem to bother
     # recurrences too. This is not respected and client don't seem to bother
     # either.
     # either.

+ 1 - 1
radicale/rights/from_file.py

@@ -22,7 +22,7 @@ config (section "rights", key "file").
 The login is matched against the "user" key, and the collection path
 The login is matched against the "user" key, and the collection path
 is matched against the "collection" key. In the "collection" regex you can use
 is matched against the "collection" key. In the "collection" regex you can use
 `{user}` and get groups from the "user" regex with `{0}`, `{1}`, etc.
 `{user}` and get groups from the "user" regex with `{0}`, `{1}`, etc.
-In consequence of the parameter subsitution you have to write `{{` and `}}`
+In consequence of the parameter substitution you have to write `{{` and `}}`
 if you want to use regular curly braces in the "user" and "collection" regexes.
 if you want to use regular curly braces in the "user" and "collection" regexes.
 
 
 For example, for the "user" key, ".+" means "authenticated user" and ".*"
 For example, for the "user" key, ".+" means "authenticated user" and ".*"

+ 2 - 2
radicale/server.py

@@ -291,7 +291,7 @@ def serve(configuration: config.Configuration,
             try:
             try:
                 getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
                 getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
             except OSError as e:
             except OSError as e:
-                logger.warn("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e))
+                logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e))
                 continue
                 continue
             logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo))
             logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo))
             for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo:
             for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo:
@@ -299,7 +299,7 @@ def serve(configuration: config.Configuration,
                 try:
                 try:
                     server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler)
                     server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler)
                 except OSError as e:
                 except OSError as e:
-                    logger.warn("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
+                    logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
                     continue
                     continue
                 servers[server.socket] = server
                 servers[server.socket] = server
                 server.set_app(application)
                 server.set_app(application)

+ 3 - 3
radicale/storage/multifilesystem/get.py

@@ -84,7 +84,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
         cache_content = self._load_item_cache(href, cache_hash)
         cache_content = self._load_item_cache(href, cache_hash)
         if cache_content is None:
         if cache_content is None:
             with self._acquire_cache_lock("item"):
             with self._acquire_cache_lock("item"):
-                # Lock the item cache to prevent multpile processes from
+                # Lock the item cache to prevent multiple processes from
                 # generating the same data in parallel.
                 # generating the same data in parallel.
                 # This improves the performance for multiple requests.
                 # This improves the performance for multiple requests.
                 if self._storage._lock.locked == "r":
                 if self._storage._lock.locked == "r":
@@ -127,7 +127,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
 
 
     def get_multi(self, hrefs: Iterable[str]
     def get_multi(self, hrefs: Iterable[str]
                   ) -> Iterator[Tuple[str, Optional[radicale_item.Item]]]:
                   ) -> Iterator[Tuple[str, Optional[radicale_item.Item]]]:
-        # It's faster to check for file name collissions here, because
+        # It's faster to check for file name collisions here, because
         # we only need to call os.listdir once.
         # we only need to call os.listdir once.
         files = None
         files = None
         for href in hrefs:
         for href in hrefs:
@@ -146,7 +146,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
 
 
     def get_all(self) -> Iterator[radicale_item.Item]:
     def get_all(self) -> Iterator[radicale_item.Item]:
         for href in self._list():
         for href in self._list():
-            # We don't need to check for collissions, because the file names
+            # We don't need to check for collisions, because the file names
             # are from os.listdir.
             # are from os.listdir.
             item = self._get(href, verify_href=False)
             item = self._get(href, verify_href=False)
             if item is not None:
             if item is not None:

+ 5 - 5
radicale/tests/__init__.py

@@ -112,7 +112,7 @@ class BaseTest:
         for response in xml.findall(xmlutils.make_clark("D:response")):
         for response in xml.findall(xmlutils.make_clark("D:response")):
             href = response.find(xmlutils.make_clark("D:href"))
             href = response.find(xmlutils.make_clark("D:href"))
             assert href.text not in path_responses
             assert href.text not in path_responses
-            prop_respones: Dict[str, Tuple[int, ET.Element]] = {}
+            prop_responses: Dict[str, Tuple[int, ET.Element]] = {}
             for propstat in response.findall(
             for propstat in response.findall(
                     xmlutils.make_clark("D:propstat")):
                     xmlutils.make_clark("D:propstat")):
                 status = propstat.find(xmlutils.make_clark("D:status"))
                 status = propstat.find(xmlutils.make_clark("D:status"))
@@ -121,16 +121,16 @@ class BaseTest:
                 for element in propstat.findall(
                 for element in propstat.findall(
                         "./%s/*" % xmlutils.make_clark("D:prop")):
                         "./%s/*" % xmlutils.make_clark("D:prop")):
                     human_tag = xmlutils.make_human_tag(element.tag)
                     human_tag = xmlutils.make_human_tag(element.tag)
-                    assert human_tag not in prop_respones
-                    prop_respones[human_tag] = (status_code, element)
+                    assert human_tag not in prop_responses
+                    prop_responses[human_tag] = (status_code, element)
             status = response.find(xmlutils.make_clark("D:status"))
             status = response.find(xmlutils.make_clark("D:status"))
             if status is not None:
             if status is not None:
-                assert not prop_respones
+                assert not prop_responses
                 assert status.text.startswith("HTTP/1.1 ")
                 assert status.text.startswith("HTTP/1.1 ")
                 status_code = int(status.text.split(" ")[1])
                 status_code = int(status.text.split(" ")[1])
                 path_responses[href.text] = status_code
                 path_responses[href.text] = status_code
             else:
             else:
-                path_responses[href.text] = prop_respones
+                path_responses[href.text] = prop_responses
         return path_responses
         return path_responses
 
 
     @staticmethod
     @staticmethod

+ 10 - 0
radicale/tests/test_auth.py

@@ -115,6 +115,16 @@ class TestBaseAuthRequests(BaseTest):
     def test_htpasswd_comment(self) -> None:
     def test_htpasswd_comment(self) -> None:
         self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n")
         self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n")
 
 
+    def test_htpasswd_lc_username(self) -> None:
+        self.configure({"auth": {"lc_username": "True"}})
+        self._test_htpasswd("plain", "tmp:bepo", (
+            ("tmp", "bepo", True), ("TMP", "bepo", True), ("tmp1", "bepo", False)))
+
+    def test_htpasswd_strip_domain(self) -> None:
+        self.configure({"auth": {"strip_domain": "True"}})
+        self._test_htpasswd("plain", "tmp:bepo", (
+            ("tmp", "bepo", True), ("tmp@domain.example", "bepo", True), ("tmp1", "bepo", False)))
+
     def test_remote_user(self) -> None:
     def test_remote_user(self) -> None:
         self.configure({"auth": {"type": "remote_user"}})
         self.configure({"auth": {"type": "remote_user"}})
         _, responses = self.propfind("/", """\
         _, responses = self.propfind("/", """\

+ 5 - 5
radicale/tests/test_base.py

@@ -360,7 +360,7 @@ permissions: RrWw""")
         self.get(path1, check=404)
         self.get(path1, check=404)
         self.get(path2)
         self.get(path2)
 
 
-    def test_move_between_colections(self) -> None:
+    def test_move_between_collections(self) -> None:
         """Move a item."""
         """Move a item."""
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar2.ics/")
         self.mkcalendar("/calendar2.ics/")
@@ -373,7 +373,7 @@ permissions: RrWw""")
         self.get(path1, check=404)
         self.get(path1, check=404)
         self.get(path2)
         self.get(path2)
 
 
-    def test_move_between_colections_duplicate_uid(self) -> None:
+    def test_move_between_collections_duplicate_uid(self) -> None:
         """Move a item to a collection which already contains the UID."""
         """Move a item to a collection which already contains the UID."""
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar2.ics/")
         self.mkcalendar("/calendar2.ics/")
@@ -389,7 +389,7 @@ permissions: RrWw""")
         assert xml.tag == xmlutils.make_clark("D:error")
         assert xml.tag == xmlutils.make_clark("D:error")
         assert xml.find(xmlutils.make_clark("C:no-uid-conflict")) is not None
         assert xml.find(xmlutils.make_clark("C:no-uid-conflict")) is not None
 
 
-    def test_move_between_colections_overwrite(self) -> None:
+    def test_move_between_collections_overwrite(self) -> None:
         """Move a item to a collection which already contains the item."""
         """Move a item to a collection which already contains the item."""
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar2.ics/")
         self.mkcalendar("/calendar2.ics/")
@@ -403,8 +403,8 @@ permissions: RrWw""")
         self.request("MOVE", path1, check=204, HTTP_OVERWRITE="T",
         self.request("MOVE", path1, check=204, HTTP_OVERWRITE="T",
                      HTTP_DESTINATION="http://127.0.0.1/"+path2)
                      HTTP_DESTINATION="http://127.0.0.1/"+path2)
 
 
-    def test_move_between_colections_overwrite_uid_conflict(self) -> None:
-        """Move a item to a collection which already contains the item with
+    def test_move_between_collections_overwrite_uid_conflict(self) -> None:
+        """Move an item to a collection which already contains the item with
            a different UID."""
            a different UID."""
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar1.ics/")
         self.mkcalendar("/calendar2.ics/")
         self.mkcalendar("/calendar2.ics/")

+ 21 - 16
setup.cfg

@@ -1,26 +1,31 @@
 [tool:pytest]
 [tool:pytest]
-addopts = --typeguard-packages=radicale
 
 
 [tox:tox]
 [tox:tox]
+min_version = 4.0
+envlist = py, flake8, isort, mypy
 
 
 [testenv]
 [testenv]
-extras = test
+extras =
+    test
 deps =
 deps =
-    flake8
-    isort
-    # mypy installation fails with pypy<3.9
-    mypy; implementation_name!='pypy' or python_version>='3.9'
-    types-setuptools
+    pytest
     pytest-cov
     pytest-cov
-commands =
-    flake8 .
-    isort --check --diff .
-    # Run mypy if it's installed
-    python -c 'import importlib.util, subprocess, sys; \
-               importlib.util.find_spec("mypy") \
-                   and sys.exit(subprocess.run(["mypy", "."]).returncode) \
-                   or print("Skipped: mypy is not installed")'
-    pytest -r s --cov --cov-report=term --cov-report=xml .
+commands = pytest -r s --cov --cov-report=term --cov-report=xml .
+
+[testenv:flake8]
+deps = flake8==7.1.0
+commands = flake8 .
+skip_install = True
+
+[testenv:isort]
+deps = isort==5.13.2
+commands = isort --check --diff .
+skip_install = True
+
+[testenv:mypy]
+deps = mypy==1.11.0
+commands = mypy .
+skip_install = True
 
 
 [tool:isort]
 [tool:isort]
 known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib
 known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib

+ 3 - 2
setup.py

@@ -38,9 +38,9 @@ web_files = ["web/internal_data/css/icon.png",
 install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
 install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
                     "python-dateutil>=2.7.3",
                     "python-dateutil>=2.7.3",
                     "pika>=1.1.0",
                     "pika>=1.1.0",
-                    "setuptools; python_version<'3.9'"]
+                    ]
 bcrypt_requires = ["bcrypt"]
 bcrypt_requires = ["bcrypt"]
-test_requires = ["pytest>=7", "typeguard<4.3", "waitress", *bcrypt_requires]
+test_requires = ["pytest>=7", "waitress", *bcrypt_requires]
 
 
 setup(
 setup(
     name="Radicale",
     name="Radicale",
@@ -75,6 +75,7 @@ setup(
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: 3.12",
         "Programming Language :: Python :: 3.12",
+        "Programming Language :: Python :: 3.13",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Office/Business :: Groupware"])
         "Topic :: Office/Business :: Groupware"])