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"])