Blob Blame History Raw
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4b82fa5..4eebaa3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -6,7 +6,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', pypy-3.7, pypy-3.8, pypy-3.9]
+        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', pypy-3.7, pypy-3.8, pypy-3.9]
         exclude:
           - os: windows-latest
             python-version: pypy-3.7
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eed6458..0a1577c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 # Changelog
 
+## master
+
 ## 3.1.8
 
 * Fix setuptools requirement if installing wheel
diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md
index f82e77d..663ffef 100644
--- a/DOCUMENTATION.md
+++ b/DOCUMENTATION.md
@@ -23,7 +23,7 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV
 Radicale is really easy to install and works out-of-the-box.
 
 ```bash
-python3 -m pip install --upgrade radicale
+python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
 python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections
 ```
 
@@ -36,7 +36,7 @@ Want more? Check the [tutorials](#tutorials) and the
 #### What's New?
 
 Read the
-[changelog on GitHub.](https://github.com/Kozea/Radicale/blob/v3/CHANGELOG.md)
+[changelog on GitHub.](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md)
 
 ## Tutorials
 
@@ -64,7 +64,7 @@ Then open a console and type:
 ```bash
 # Run the following command as root or
 # add the --user argument to only install for the current user
-$ python3 -m pip install --upgrade radicale
+$ python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
 $ python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections
 ```
 
@@ -82,7 +82,7 @@ click on "Install now". Wait a couple of minutes, it's done!
 Launch a command prompt and type:
 
 ```powershell
-python -m pip install --upgrade radicale
+python -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
 python -m radicale --storage-filesystem-folder=~/radicale/collections
 ```
 
@@ -1348,7 +1348,7 @@ add new features, fix bugs or update the documentation.
 #### Documentation
 
 To change or complement the documentation create a pull request to
-[DOCUMENTATION.md](https://github.com/Kozea/Radicale/blob/v3/DOCUMENTATION.md).
+[DOCUMENTATION.md](https://github.com/Kozea/Radicale/blob/master/DOCUMENTATION.md).
 
 ## Download
 
diff --git a/Dockerfile b/Dockerfile
index cb14a58..1bfc82a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,8 +2,8 @@
 
 FROM python:3-alpine
 
-# Version of Radicale
-ARG VERSION=v3
+# Version of Radicale (e.g. v3)
+ARG VERSION=master
 # Persistent storage for data
 VOLUME /var/lib/radicale
 # TCP port of Radicale
diff --git a/README.md b/README.md
index 7ee078f..aed74fc 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 # Radicale
 
-[![Test](https://github.com/Kozea/Radicale/actions/workflows/test.yml/badge.svg?branch=v3)](https://github.com/Kozea/Radicale/actions/workflows/test.yml)
-[![Coverage Status](https://coveralls.io/repos/github/Kozea/Radicale/badge.svg?branch=v3)](https://coveralls.io/github/Kozea/Radicale?branch=v3)
+[![Test](https://github.com/Kozea/Radicale/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/Kozea/Radicale/actions/workflows/test.yml)
+[![Coverage Status](https://coveralls.io/repos/github/Kozea/Radicale/badge.svg?branch=master)](https://coveralls.io/github/Kozea/Radicale?branch=master)
 
 Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV
 (contacts) server, that:
@@ -17,4 +17,4 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV
 * Is GPLv3-licensed free software.
 
 For the complete documentation, please visit
-[Radicale v3 Documentation](https://radicale.org/v3.html).
+[Radicale master Documentation](https://radicale.org/master.html).
diff --git a/radicale/app/move.py b/radicale/app/move.py
index fda8525..0c38eed 100644
--- a/radicale/app/move.py
+++ b/radicale/app/move.py
@@ -18,6 +18,7 @@
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
 import posixpath
+import re
 from http import client
 from urllib.parse import urlparse
 
@@ -26,6 +27,21 @@ from radicale.app.base import Access, ApplicationBase
 from radicale.log import logger
 
 
+def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False):
+    if environ.get("HTTP_X_FORWARDED_HOST"):
+        host = environ["HTTP_X_FORWARDED_HOST"]
+        proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http"
+        port = "443" if proto == "https" else "80"
+    else:
+        host = environ.get("HTTP_HOST") or environ["SERVER_NAME"]
+        proto = environ["wsgi.url_scheme"]
+        port = environ["SERVER_PORT"]
+    if (not force_port and port == ("443" if proto == "https" else "80") or
+            re.search(r":\d+$", host)):
+        return host
+    return host + ":" + port
+
+
 class ApplicationPartMove(ApplicationBase):
 
     def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str,
@@ -33,7 +49,11 @@ class ApplicationPartMove(ApplicationBase):
         """Manage MOVE request."""
         raw_dest = environ.get("HTTP_DESTINATION", "")
         to_url = urlparse(raw_dest)
-        if to_url.netloc != environ["HTTP_HOST"]:
+        to_netloc_with_port = to_url.netloc
+        if to_url.port is None:
+            to_netloc_with_port += (":443" if to_url.scheme == "https"
+                                    else ":80")
+        if to_netloc_with_port != get_server_netloc(environ, force_port=True):
             logger.info("Unsupported destination address: %r", raw_dest)
             # Remote destination server, not supported
             return httputils.REMOTE_DESTINATION
diff --git a/radicale/item/filter.py b/radicale/item/filter.py
index 587dc36..6a89ffa 100644
--- a/radicale/item/filter.py
+++ b/radicale/item/filter.py
@@ -468,7 +468,15 @@ def text_match(vobject_item: vobject.base.Component,
             match(attrib) for child in children
             for attrib in child.params.get(attrib_name, []))
     else:
-        condition = any(match(child.value) for child in children)
+        res = []
+        for child in children:
+            # Some filters such as CATEGORIES provide a list in child.value
+            if type(child.value) is list:
+                for value in child.value:
+                    res.append(match(value))
+            else:
+                res.append(match(child.value))
+        condition = any(res)
     if filter_.get("negate-condition") == "yes":
         return not condition
     return condition
diff --git a/radicale/log.py b/radicale/log.py
index eaa842b..8d54a1b 100644
--- a/radicale/log.py
+++ b/radicale/log.py
@@ -25,16 +25,25 @@ Log messages are sent to the first available target of:
 
 """
 
+import contextlib
+import io
 import logging
 import os
+import socket
+import struct
 import sys
 import threading
-from typing import Any, Callable, ClassVar, Dict, Iterator, Union
+import time
+from typing import (Any, Callable, ClassVar, Dict, Iterator, Mapping, Optional,
+                    Tuple, Union, cast)
 
 from radicale import types
 
 LOGGER_NAME: str = "radicale"
-LOGGER_FORMAT: str = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s"
+LOGGER_FORMATS: Mapping[str, str] = {
+    "verbose": "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s",
+    "journal": "[%(ident)s] [%(levelname)s] %(message)s",
+}
 DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z"
 
 logger: logging.Logger = logging.getLogger(LOGGER_NAME)
@@ -59,12 +68,17 @@ class IdentLogRecordFactory:
 
     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
+        ident = ("%d" % record.process if record.process is not None
+                 else record.processName or "unknown")
+        tid = None
+        if record.thread is not None:
+            if record.thread != threading.main_thread().ident:
+                ident += "/%s" % (record.threadName or "unknown")
+            if (sys.version_info >= (3, 8) and
+                    record.thread == threading.get_ident()):
+                tid = threading.get_native_id()
         record.ident = ident  # type:ignore[attr-defined]
+        record.tid = tid  # type:ignore[attr-defined]
         return record
 
 
@@ -75,19 +89,102 @@ class ThreadedStreamHandler(logging.Handler):
     terminator: ClassVar[str] = "\n"
 
     _streams: Dict[int, types.ErrorStream]
+    _journal_stream_id: Optional[Tuple[int, int]]
+    _journal_socket: Optional[socket.socket]
+    _journal_socket_failed: bool
+    _formatters: Mapping[str, logging.Formatter]
+    _formatter: Optional[logging.Formatter]
 
-    def __init__(self) -> None:
+    def __init__(self, format_name: Optional[str] = None) -> None:
         super().__init__()
         self._streams = {}
+        self._journal_stream_id = None
+        with contextlib.suppress(TypeError, ValueError):
+            dev, inode = os.environ.get("JOURNAL_STREAM", "").split(":", 1)
+            self._journal_stream_id = (int(dev), int(inode))
+        self._journal_socket = None
+        self._journal_socket_failed = False
+        self._formatters = {name: logging.Formatter(fmt, DATE_FORMAT)
+                            for name, fmt in LOGGER_FORMATS.items()}
+        self._formatter = (self._formatters[format_name]
+                           if format_name is not None else None)
+
+    def _get_formatter(self, default_format_name: str) -> logging.Formatter:
+        return self._formatter or self._formatters[default_format_name]
+
+    def _detect_journal(self, stream: types.ErrorStream) -> bool:
+        if not self._journal_stream_id or not isinstance(stream, io.IOBase):
+            return False
+        try:
+            stat = os.fstat(stream.fileno())
+        except OSError:
+            return False
+        return self._journal_stream_id == (stat.st_dev, stat.st_ino)
+
+    @staticmethod
+    def _encode_journal(data: Mapping[str, Optional[Union[str, int]]]
+                        ) -> bytes:
+        msg = b""
+        for key, value in data.items():
+            if value is None:
+                continue
+            keyb = key.encode()
+            valueb = str(value).encode()
+            if b"\n" in valueb:
+                msg += (keyb + b"\n" +
+                        struct.pack("<Q", len(valueb)) + valueb + b"\n")
+            else:
+                msg += keyb + b"=" + valueb + b"\n"
+        return msg
+
+    def _try_emit_journal(self, record: logging.LogRecord) -> bool:
+        if not self._journal_socket:
+            # Try to connect to systemd journal socket
+            if self._journal_socket_failed or not hasattr(socket, "AF_UNIX"):
+                return False
+            journal_socket = None
+            try:
+                journal_socket = socket.socket(
+                    socket.AF_UNIX, socket.SOCK_DGRAM)
+                journal_socket.connect("/run/systemd/journal/socket")
+            except OSError as e:
+                self._journal_socket_failed = True
+                if journal_socket:
+                    journal_socket.close()
+                # Log after setting `_journal_socket_failed` to prevent loop!
+                logger.error("Failed to connect to systemd journal: %s",
+                             e, exc_info=True)
+                return False
+            self._journal_socket = journal_socket
+
+        priority = {"DEBUG": 7,
+                    "INFO": 6,
+                    "WARNING": 4,
+                    "ERROR": 3,
+                    "CRITICAL": 2}.get(record.levelname, 4)
+        timestamp = time.strftime("%Y-%m-%dT%H:%M:%S.%%03dZ",
+                                  time.gmtime(record.created)) % record.msecs
+        data = {"PRIORITY": priority,
+                "TID": cast(Optional[int], getattr(record, "tid", None)),
+                "SYSLOG_IDENTIFIER": record.name,
+                "SYSLOG_FACILITY": 1,
+                "SYSLOG_PID": record.process,
+                "SYSLOG_TIMESTAMP": timestamp,
+                "CODE_FILE": record.pathname,
+                "CODE_LINE": record.lineno,
+                "CODE_FUNC": record.funcName,
+                "MESSAGE": self._get_formatter("journal").format(record)}
+        self._journal_socket.sendall(self._encode_journal(data))
+        return True
 
     def emit(self, record: logging.LogRecord) -> None:
         try:
             stream = self._streams.get(threading.get_ident(), sys.stderr)
-            msg = self.format(record)
-            stream.write(msg)
-            stream.write(self.terminator)
-            if hasattr(stream, "flush"):
-                stream.flush()
+            if self._detect_journal(stream) and self._try_emit_journal(record):
+                return
+            msg = self._get_formatter("verbose").format(record)
+            stream.write(msg + self.terminator)
+            stream.flush()
         except Exception:
             self.handleError(record)
 
@@ -111,13 +208,16 @@ def register_stream(stream: types.ErrorStream) -> Iterator[None]:
 def setup() -> None:
     """Set global logging up."""
     global register_stream
-    handler = ThreadedStreamHandler()
-    logging.basicConfig(format=LOGGER_FORMAT, datefmt=DATE_FORMAT,
-                        handlers=[handler])
+    format_name = os.environ.get("RADICALE_LOG_FORMAT") or None
+    sane_format_name = format_name if format_name in LOGGER_FORMATS else None
+    handler = ThreadedStreamHandler(sane_format_name)
+    logging.basicConfig(handlers=[handler])
     register_stream = handler.register_stream
     log_record_factory = IdentLogRecordFactory(logging.getLogRecordFactory())
     logging.setLogRecordFactory(log_record_factory)
     set_level(logging.WARNING)
+    if format_name != sane_format_name:
+        logger.error("Invalid RADICALE_LOG_FORMAT: %r", format_name)
 
 
 def set_level(level: Union[int, str]) -> None:
diff --git a/radicale/server.py b/radicale/server.py
index 6cb4c7b..62fe4ef 100644
--- a/radicale/server.py
+++ b/radicale/server.py
@@ -58,11 +58,16 @@ elif sys.platform == "win32":
 
 
 # IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
-ADDRESS_TYPE = Union[Tuple[str, int], Tuple[str, int, int, int]]
+ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
+                     Tuple[str, int, int, int]]
 
 
 def format_address(address: ADDRESS_TYPE) -> str:
-    return "[%s]:%d" % address[:2]
+    host, port, *_ = address
+    if not isinstance(host, str):
+        raise NotImplementedError("Unsupported address format: %r" %
+                                  (address,))
+    return "[%s]:%d" % (host, port)
 
 
 class ParallelHTTPServer(socketserver.ThreadingMixIn,
diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py
index 2e13256..942cbe8 100644
--- a/radicale/tests/__init__.py
+++ b/radicale/tests/__init__.py
@@ -25,6 +25,7 @@ import logging
 import shutil
 import sys
 import tempfile
+import wsgiref.util
 import xml.etree.ElementTree as ET
 from io import BytesIO
 from typing import Any, Dict, List, Optional, Tuple, Union
@@ -83,11 +84,12 @@ class BaseTest:
                     login.encode(encoding)).decode()
         environ["REQUEST_METHOD"] = method.upper()
         environ["PATH_INFO"] = path
-        if data:
+        if data is not None:
             data_bytes = data.encode(encoding)
             environ["wsgi.input"] = BytesIO(data_bytes)
             environ["CONTENT_LENGTH"] = str(len(data_bytes))
         environ["wsgi.errors"] = sys.stderr
+        wsgiref.util.setup_testing_defaults(environ)
         status = headers = None
 
         def start_response(status_: str, headers_: List[Tuple[str, str]]
@@ -137,8 +139,8 @@ class BaseTest:
         status, _, answer = self.request("GET", path, check=check, **kwargs)
         return status, answer
 
-    def post(self, path: str, data: str = None, check: Optional[int] = 200,
-             **kwargs) -> Tuple[int, str]:
+    def post(self, path: str, data: Optional[str] = None,
+             check: Optional[int] = 200,  **kwargs) -> Tuple[int, str]:
         status, _, answer = self.request("POST", path, data, check=check,
                                          **kwargs)
         return status, answer
diff --git a/radicale/tests/static/event1.ics b/radicale/tests/static/event1.ics
index bc04d80..4e66917 100644
--- a/radicale/tests/static/event1.ics
+++ b/radicale/tests/static/event1.ics
@@ -25,6 +25,7 @@ LAST-MODIFIED:20130902T150158Z
 DTSTAMP:20130902T150158Z
 UID:event1
 SUMMARY:Event
+CATEGORIES:some_category1,another_category2
 ORGANIZER:mailto:unclesam@example.com
 ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
 ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py
index 5ea37bf..a0d3d53 100644
--- a/radicale/tests/test_base.py
+++ b/radicale/tests/test_base.py
@@ -355,7 +355,7 @@ permissions: RrWw""")
         path2 = "/calendar.ics/event2.ics"
         self.put(path1, event)
         self.request("MOVE", path1, check=201,
-                     HTTP_DESTINATION=path2, HTTP_HOST="")
+                     HTTP_DESTINATION="http://127.0.0.1/"+path2)
         self.get(path1, check=404)
         self.get(path2)
 
@@ -368,7 +368,7 @@ permissions: RrWw""")
         path2 = "/calendar2.ics/event2.ics"
         self.put(path1, event)
         self.request("MOVE", path1, check=201,
-                     HTTP_DESTINATION=path2, HTTP_HOST="")
+                     HTTP_DESTINATION="http://127.0.0.1/"+path2)
         self.get(path1, check=404)
         self.get(path2)
 
@@ -382,7 +382,7 @@ permissions: RrWw""")
         self.put(path1, event)
         self.put("/calendar2.ics/event1.ics", event)
         status, _, answer = self.request(
-            "MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="")
+            "MOVE", path1, HTTP_DESTINATION="http://127.0.0.1/"+path2)
         assert status in (403, 409)
         xml = DefusedET.fromstring(answer)
         assert xml.tag == xmlutils.make_clark("D:error")
@@ -398,9 +398,9 @@ permissions: RrWw""")
         self.put(path1, event)
         self.put(path2, event)
         self.request("MOVE", path1, check=412,
-                     HTTP_DESTINATION=path2, HTTP_HOST="")
-        self.request("MOVE", path1, check=204,
-                     HTTP_DESTINATION=path2, HTTP_HOST="", HTTP_OVERWRITE="T")
+                     HTTP_DESTINATION="http://127.0.0.1/"+path2)
+        self.request("MOVE", path1, check=204, HTTP_OVERWRITE="T",
+                     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
@@ -413,8 +413,9 @@ permissions: RrWw""")
         path2 = "/calendar2.ics/event2.ics"
         self.put(path1, event1)
         self.put(path2, event2)
-        status, _, answer = self.request("MOVE", path1, HTTP_DESTINATION=path2,
-                                         HTTP_HOST="", HTTP_OVERWRITE="T")
+        status, _, answer = self.request(
+            "MOVE", path1, HTTP_OVERWRITE="T",
+            HTTP_DESTINATION="http://127.0.0.1/"+path2)
         assert status in (403, 409)
         xml = DefusedET.fromstring(answer)
         assert xml.tag == xmlutils.make_clark("D:error")
@@ -916,6 +917,22 @@ permissions: RrWw""")
             <C:text-match>event</C:text-match>
         </C:prop-filter>
     </C:comp-filter>
+</C:comp-filter>"""])
+        assert "/calendar.ics/event1.ics" in self._test_filter(["""\
+<C:comp-filter name="VCALENDAR">
+    <C:comp-filter name="VEVENT">
+        <C:prop-filter name="CATEGORIES">
+            <C:text-match>some_category1</C:text-match>
+        </C:prop-filter>
+    </C:comp-filter>
+</C:comp-filter>"""])
+        assert "/calendar.ics/event1.ics" in self._test_filter(["""\
+<C:comp-filter name="VCALENDAR">
+    <C:comp-filter name="VEVENT">
+        <C:prop-filter name="CATEGORIES">
+            <C:text-match collation="i;octet">some_category1</C:text-match>
+        </C:prop-filter>
+    </C:comp-filter>
 </C:comp-filter>"""])
         assert "/calendar.ics/event1.ics" not in self._test_filter(["""\
 <C:comp-filter name="VCALENDAR">
@@ -1471,7 +1488,7 @@ permissions: RrWw""")
         sync_token, responses = self._report_sync_token(calendar_path)
         assert len(responses) == 1 and responses[event1_path] == 200
         self.request("MOVE", event1_path, check=201,
-                     HTTP_DESTINATION=event2_path, HTTP_HOST="")
+                     HTTP_DESTINATION="http://127.0.0.1/"+event2_path)
         sync_token, responses = self._report_sync_token(
             calendar_path, sync_token)
         if not self.full_sync_token_support and not sync_token:
@@ -1490,9 +1507,9 @@ permissions: RrWw""")
         sync_token, responses = self._report_sync_token(calendar_path)
         assert len(responses) == 1 and responses[event1_path] == 200
         self.request("MOVE", event1_path, check=201,
-                     HTTP_DESTINATION=event2_path, HTTP_HOST="")
+                     HTTP_DESTINATION="http://127.0.0.1/"+event2_path)
         self.request("MOVE", event2_path, check=201,
-                     HTTP_DESTINATION=event1_path, HTTP_HOST="")
+                     HTTP_DESTINATION="http://127.0.0.1/"+event1_path)
         sync_token, responses = self._report_sync_token(
             calendar_path, sync_token)
         if not self.full_sync_token_support and not sync_token:
diff --git a/radicale/types.py b/radicale/types.py
index 0eb3fd6..c7e1904 100644
--- a/radicale/types.py
+++ b/radicale/types.py
@@ -50,8 +50,8 @@ if sys.version_info >= (3, 8):
 
     @runtime_checkable
     class ErrorStream(Protocol):
-        def flush(self) -> None: ...
-        def write(self, s: str) -> None: ...
+        def flush(self) -> object: ...
+        def write(self, s: str) -> object: ...
 else:
     ErrorStream = Any
     InputStream = Any
diff --git a/setup.cfg b/setup.cfg
index a77b43b..fe2038f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -28,8 +28,9 @@ known_third_party = defusedxml,passlib,pkg_resources,pytest,vobject
 
 [flake8]
 # Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398)
-select = E,F,W,C90,DOES-NOT-EXIST
-ignore = E121,E123,E126,E226,E24,E704,W503,W504,DOES-NOT-EXIST
+# DNE: DOES-NOT-EXIST
+select = E,F,W,C90,DNE000
+ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000
 extend-exclude = build
 
 [mypy]
diff --git a/setup.py b/setup.py
index dafea7a..144e77c 100644
--- a/setup.py
+++ b/setup.py
@@ -19,7 +19,7 @@ from setuptools import find_packages, setup
 
 # When the version is updated, a new section in the CHANGELOG.md file must be
 # added too.
-VERSION = "3.1.8"
+VERSION = "3.dev"
 
 with open("README.md", encoding="utf-8") as f:
     long_description = f.read()
@@ -33,7 +33,7 @@ install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
                     "setuptools; python_version<'3.9'"]
 bcrypt_requires = ["passlib[bcrypt]", "bcrypt"]
 # typeguard requires pytest<7
-test_requires = ["pytest<7", "typeguard", "waitress", *bcrypt_requires]
+test_requires = ["pytest<7", "typeguard<3", "waitress", *bcrypt_requires]
 
 setup(
     name="Radicale",
@@ -53,7 +53,7 @@ setup(
     install_requires=install_requires,
     extras_require={"test": test_requires, "bcrypt": bcrypt_requires},
     keywords=["calendar", "addressbook", "CalDAV", "CardDAV"],
-    python_requires=">=3.6.0",
+    python_requires=">=3.7.0",
     classifiers=[
         "Development Status :: 5 - Production/Stable",
         "Environment :: Console",
@@ -63,11 +63,11 @@ setup(
         "License :: OSI Approved :: GNU General Public License (GPL)",
         "Operating System :: OS Independent",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Office/Business :: Groupware"])