Radicale 3.2.2__tar.gz → 3.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {radicale-3.2.2 → radicale-3.2.3}/CHANGELOG.md +11 -1
  2. {radicale-3.2.2 → radicale-3.2.3}/DOCUMENTATION.md +45 -9
  3. {radicale-3.2.2 → radicale-3.2.3}/PKG-INFO +2 -3
  4. {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/PKG-INFO +2 -3
  5. {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/requires.txt +0 -4
  6. {radicale-3.2.2 → radicale-3.2.3}/config +10 -0
  7. {radicale-3.2.2 → radicale-3.2.3}/radicale/__init__.py +1 -1
  8. {radicale-3.2.2 → radicale-3.2.3}/radicale/__main__.py +2 -2
  9. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/__init__.py +3 -3
  10. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/base.py +8 -4
  11. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/propfind.py +3 -3
  12. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/put.py +1 -1
  13. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/report.py +127 -12
  14. {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/__init__.py +7 -1
  15. {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/htpasswd.py +1 -1
  16. {radicale-3.2.2 → radicale-3.2.3}/radicale/config.py +15 -1
  17. {radicale-3.2.2 → radicale-3.2.3}/radicale/hook/__init__.py +11 -2
  18. {radicale-3.2.2 → radicale-3.2.3}/radicale/httputils.py +1 -1
  19. {radicale-3.2.2 → radicale-3.2.3}/radicale/item/__init__.py +7 -1
  20. {radicale-3.2.2 → radicale-3.2.3}/radicale/item/filter.py +58 -29
  21. {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/from_file.py +8 -4
  22. {radicale-3.2.2 → radicale-3.2.3}/radicale/server.py +2 -2
  23. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/get.py +3 -3
  24. {radicale-3.2.2 → radicale-3.2.3}/setup.cfg +21 -14
  25. {radicale-3.2.2 → radicale-3.2.3}/setup.py +4 -3
  26. {radicale-3.2.2 → radicale-3.2.3}/COPYING.md +0 -0
  27. {radicale-3.2.2 → radicale-3.2.3}/MANIFEST.in +0 -0
  28. {radicale-3.2.2 → radicale-3.2.3}/README.md +0 -0
  29. {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/SOURCES.txt +0 -0
  30. {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/dependency_links.txt +0 -0
  31. {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/entry_points.txt +0 -0
  32. {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/top_level.txt +0 -0
  33. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/delete.py +0 -0
  34. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/get.py +0 -0
  35. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/head.py +0 -0
  36. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/mkcalendar.py +0 -0
  37. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/mkcol.py +0 -0
  38. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/move.py +0 -0
  39. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/options.py +0 -0
  40. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/post.py +0 -0
  41. {radicale-3.2.2 → radicale-3.2.3}/radicale/app/proppatch.py +0 -0
  42. {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/denyall.py +0 -0
  43. {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/http_x_remote_user.py +0 -0
  44. {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/none.py +0 -0
  45. {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/remote_user.py +0 -0
  46. {radicale-3.2.2 → radicale-3.2.3}/radicale/hook/none.py +0 -0
  47. {radicale-3.2.2 → radicale-3.2.3}/radicale/hook/rabbitmq/__init__.py +0 -0
  48. {radicale-3.2.2 → radicale-3.2.3}/radicale/log.py +0 -0
  49. {radicale-3.2.2 → radicale-3.2.3}/radicale/pathutils.py +0 -0
  50. {radicale-3.2.2 → radicale-3.2.3}/radicale/py.typed +0 -0
  51. {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/__init__.py +0 -0
  52. {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/authenticated.py +0 -0
  53. {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/owner_only.py +0 -0
  54. {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/owner_write.py +0 -0
  55. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/__init__.py +0 -0
  56. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/__init__.py +0 -0
  57. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/base.py +0 -0
  58. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/cache.py +0 -0
  59. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/create_collection.py +0 -0
  60. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/delete.py +0 -0
  61. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/discover.py +0 -0
  62. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/history.py +0 -0
  63. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/lock.py +0 -0
  64. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/meta.py +0 -0
  65. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/move.py +0 -0
  66. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/sync.py +0 -0
  67. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/upload.py +0 -0
  68. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/verify.py +0 -0
  69. {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem_nolock.py +0 -0
  70. {radicale-3.2.2 → radicale-3.2.3}/radicale/types.py +0 -0
  71. {radicale-3.2.2 → radicale-3.2.3}/radicale/utils.py +0 -0
  72. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/__init__.py +0 -0
  73. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal.py +0 -0
  74. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icon.png +0 -0
  75. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/delete.svg +0 -0
  76. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/download.svg +0 -0
  77. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/edit.svg +0 -0
  78. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/new.svg +0 -0
  79. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/upload.svg +0 -0
  80. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/loading.svg +0 -0
  81. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/logo.svg +0 -0
  82. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/main.css +0 -0
  83. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/fn.js +0 -0
  84. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/index.html +0 -0
  85. {radicale-3.2.2 → radicale-3.2.3}/radicale/web/none.py +0 -0
  86. {radicale-3.2.2 → radicale-3.2.3}/radicale/xmlutils.py +0 -0
  87. {radicale-3.2.2 → radicale-3.2.3}/radicale.wsgi +0 -0
  88. {radicale-3.2.2 → radicale-3.2.3}/rights +0 -0
@@ -1,6 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 3.dev
3
+ ## 3.2.3
4
+ * Add: support for Python 3.13
5
+ * Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar
6
+ * Fix: typos in code
7
+ * Enhancement: Added free-busy report
8
+ * Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports
9
+ * Enhancement: remove unexpected control codes from uploaded items
10
+ * Enhancement: add 'strip_domain' setting for username handling
11
+ * Enhancement: add option to toggle debug log of rights rule with doesn't match
12
+ * Drop: remove unused requirement "typeguard"
13
+ * Improve: Refactored some date parsing code
4
14
 
5
15
  ## 3.2.2
6
16
  * Enhancement: add support for auth.type=denyall (will be default for security reasons in upcoming releases)
@@ -350,16 +350,13 @@ location /radicale/ { # The trailing / is important!
350
350
  }
351
351
  ```
352
352
 
353
- Example **Caddy** configuration with basicauth from Caddy:
353
+ Example **Caddy** configuration:
354
354
 
355
- ```Caddy
356
- handle_path /radicale* {
357
- basicauth {
358
- user hash
359
- }
355
+ ```
356
+ handle_path /radicale/* {
357
+ uri strip_prefix /radicale
360
358
  reverse_proxy localhost:5232 {
361
- header_up +X-Script-Name "/radicale"
362
- header_up +X-remote-user "{http.auth.user.id}"
359
+ header_up X-Script-Name /radicale
363
360
  }
364
361
  }
365
362
  ```
@@ -440,6 +437,21 @@ location /radicale/ {
440
437
  }
441
438
  ```
442
439
 
440
+ Example **Caddy** configuration:
441
+
442
+ ```
443
+ handle_path /radicale/* {
444
+ uri strip_prefix /radicale
445
+ basicauth {
446
+ USER HASH
447
+ }
448
+ reverse_proxy localhost:5232 {
449
+ header_up X-Script-Name /radicale
450
+ header_up X-remote-user {http.auth.user.id}
451
+ }
452
+ }
453
+ ```
454
+
443
455
  Example **Apache** configuration:
444
456
 
445
457
  ```apache
@@ -795,6 +807,12 @@ providers like ldap, kerberos
795
807
 
796
808
  Default: `False`
797
809
 
810
+ ##### strip_domain
811
+
812
+ Strip domain from username
813
+
814
+ Default: `False`
815
+
798
816
  #### rights
799
817
 
800
818
  ##### type
@@ -865,7 +883,7 @@ Delete sync-token that are older than the specified time. (seconds)
865
883
 
866
884
  Default: `2592000`
867
885
 
868
- #### skip_broken_item
886
+ ##### skip_broken_item
869
887
 
870
888
  Skip broken item instead of triggering an exception
871
889
 
@@ -960,6 +978,12 @@ Log response on level=debug
960
978
 
961
979
  Default: `False`
962
980
 
981
+ ##### rights_rule_doesnt_match_on_debug = True
982
+
983
+ Log rights rule which doesn't match on level=debug
984
+
985
+ Default: `False`
986
+
963
987
  #### headers
964
988
 
965
989
  In this section additional HTTP headers that are sent to clients can be
@@ -1005,6 +1029,18 @@ RabbitMQ queue type for the topic.
1005
1029
 
1006
1030
  Default: classic
1007
1031
 
1032
+ #### reporting
1033
+ ##### max_freebusy_occurrence
1034
+
1035
+ When returning a free-busy report, a list of busy time occurrences are
1036
+ generated based on a given time frame. Large time frames could
1037
+ generate a lot of occurrences based on the time frame supplied. This
1038
+ setting limits the lookup to prevent potential denial of service
1039
+ attacks on large time frames. If the limit is reached, an HTTP error
1040
+ is thrown instead of returning the results.
1041
+
1042
+ Default: 10000
1043
+
1008
1044
  ## Supported Clients
1009
1045
 
1010
1046
  Radicale has been tested with:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Radicale
3
- Version: 3.2.2
3
+ Version: 3.2.3
4
4
  Summary: CalDAV and CardDAV Server
5
5
  Home-page: https://radicale.org/
6
6
  Author: Guillaume Ayoub
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.9
21
21
  Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
24
25
  Classifier: Programming Language :: Python :: Implementation :: CPython
25
26
  Classifier: Programming Language :: Python :: Implementation :: PyPy
26
27
  Classifier: Topic :: Office/Business :: Groupware
@@ -32,10 +33,8 @@ Requires-Dist: passlib
32
33
  Requires-Dist: vobject>=0.9.6
33
34
  Requires-Dist: python-dateutil>=2.7.3
34
35
  Requires-Dist: pika>=1.1.0
35
- Requires-Dist: setuptools; python_version < "3.9"
36
36
  Provides-Extra: test
37
37
  Requires-Dist: pytest>=7; extra == "test"
38
- Requires-Dist: typeguard<4.3; extra == "test"
39
38
  Requires-Dist: waitress; extra == "test"
40
39
  Requires-Dist: bcrypt; extra == "test"
41
40
  Provides-Extra: bcrypt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Radicale
3
- Version: 3.2.2
3
+ Version: 3.2.3
4
4
  Summary: CalDAV and CardDAV Server
5
5
  Home-page: https://radicale.org/
6
6
  Author: Guillaume Ayoub
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.9
21
21
  Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
24
25
  Classifier: Programming Language :: Python :: Implementation :: CPython
25
26
  Classifier: Programming Language :: Python :: Implementation :: PyPy
26
27
  Classifier: Topic :: Office/Business :: Groupware
@@ -32,10 +33,8 @@ Requires-Dist: passlib
32
33
  Requires-Dist: vobject>=0.9.6
33
34
  Requires-Dist: python-dateutil>=2.7.3
34
35
  Requires-Dist: pika>=1.1.0
35
- Requires-Dist: setuptools; python_version < "3.9"
36
36
  Provides-Extra: test
37
37
  Requires-Dist: pytest>=7; extra == "test"
38
- Requires-Dist: typeguard<4.3; extra == "test"
39
38
  Requires-Dist: waitress; extra == "test"
40
39
  Requires-Dist: bcrypt; extra == "test"
41
40
  Provides-Extra: bcrypt
@@ -4,14 +4,10 @@ vobject>=0.9.6
4
4
  python-dateutil>=2.7.3
5
5
  pika>=1.1.0
6
6
 
7
- [:python_version < "3.9"]
8
- setuptools
9
-
10
7
  [bcrypt]
11
8
  bcrypt
12
9
 
13
10
  [test]
14
11
  pytest>=7
15
- typeguard<4.3
16
12
  waitress
17
13
  bcrypt
@@ -73,6 +73,8 @@
73
73
  # Convert username to lowercase, must be true for case-insensitive auth providers
74
74
  #lc_username = False
75
75
 
76
+ # Strip domain name from username
77
+ #strip_domain = False
76
78
 
77
79
  [rights]
78
80
 
@@ -156,6 +158,8 @@
156
158
  # Log response content on level=debug
157
159
  #response_content_on_debug = False
158
160
 
161
+ # Log rights rule which doesn't match on level=debug
162
+ #rights_rule_doesnt_match_on_debug = False
159
163
 
160
164
  [headers]
161
165
 
@@ -170,3 +174,9 @@
170
174
  #rabbitmq_endpoint =
171
175
  #rabbitmq_topic =
172
176
  #rabbitmq_queue_type = classic
177
+
178
+ [reporting]
179
+
180
+ # When returning a free-busy report, limit the number of returned
181
+ # occurences per event to prevent DOS attacks.
182
+ #max_freebusy_occurrence = 10000
@@ -61,7 +61,7 @@ def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream
61
61
  if not miss and source != "default config":
62
62
  default_config_active = False
63
63
  if default_config_active:
64
- logger.warn("%s", "No config file found/readable - only default config is active")
64
+ logger.warning("%s", "No config file found/readable - only default config is active")
65
65
  _application_instance = Application(configuration)
66
66
  if _application_config_path != config_path:
67
67
  raise ValueError("RADICALE_CONFIG must not change: %r != %r" %
@@ -175,7 +175,7 @@ def run() -> None:
175
175
  default_config_active = False
176
176
 
177
177
  if default_config_active:
178
- logger.warn("%s", "No config file found/readable - only default config is active")
178
+ logger.warning("%s", "No config file found/readable - only default config is active")
179
179
 
180
180
  if args_ns.verify_storage:
181
181
  logger.info("Verifying storage")
@@ -183,7 +183,7 @@ def run() -> None:
183
183
  storage_ = storage.load(configuration)
184
184
  with storage_.acquire_lock("r"):
185
185
  if not storage_.verify():
186
- logger.critical("Storage verifcation failed")
186
+ logger.critical("Storage verification failed")
187
187
  sys.exit(1)
188
188
  except Exception as e:
189
189
  logger.critical("An exception occurred during storage "
@@ -146,7 +146,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
146
146
  if self._response_content_on_debug:
147
147
  logger.debug("Response content:\n%s", answer)
148
148
  else:
149
- logger.debug("Response content: suppressed by config/option [auth] response_content_on_debug")
149
+ logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
150
150
  headers["Content-Type"] += "; charset=%s" % self._encoding
151
151
  answer = answer.encode(self._encoding)
152
152
  accept_encoding = [
@@ -196,7 +196,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
196
196
  logger.debug("Request header:\n%s",
197
197
  pprint.pformat(self._scrub_headers(environ)))
198
198
  else:
199
- logger.debug("Request header: suppressed by config/option [auth] request_header_on_debug")
199
+ logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
200
200
 
201
201
  # SCRIPT_NAME is already removed from PATH_INFO, according to the
202
202
  # WSGI specification.
@@ -232,7 +232,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
232
232
  path.rstrip("/").endswith("/.well-known/carddav")):
233
233
  return response(*httputils.redirect(
234
234
  base_prefix + "/", client.MOVED_PERMANENTLY))
235
- # Return NOT FOUND for all other paths containing ".well-knwon"
235
+ # Return NOT FOUND for all other paths containing ".well-known"
236
236
  if path.endswith("/.well-known") or "/.well-known/" in path:
237
237
  return response(*httputils.NOT_FOUND)
238
238
 
@@ -51,6 +51,7 @@ class ApplicationBase:
51
51
  self._encoding = configuration.get("encoding", "request")
52
52
  self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content")
53
53
  self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
54
+ self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
54
55
  self._hook = hook.load(configuration)
55
56
 
56
57
  def _read_xml_request_body(self, environ: types.WSGIEnviron
@@ -66,17 +67,20 @@ class ApplicationBase:
66
67
  logger.debug("Request content (Invalid XML):\n%s", content)
67
68
  raise RuntimeError("Failed to parse XML: %s" % e) from e
68
69
  if logger.isEnabledFor(logging.DEBUG):
69
- logger.debug("Request content:\n%s",
70
- xmlutils.pretty_xml(xml_content))
70
+ if self._request_content_on_debug:
71
+ logger.debug("Request content (XML):\n%s",
72
+ xmlutils.pretty_xml(xml_content))
73
+ else:
74
+ logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
71
75
  return xml_content
72
76
 
73
77
  def _xml_response(self, xml_content: ET.Element) -> bytes:
74
78
  if logger.isEnabledFor(logging.DEBUG):
75
79
  if self._response_content_on_debug:
76
- logger.debug("Response content:\n%s",
80
+ logger.debug("Response content (XML):\n%s",
77
81
  xmlutils.pretty_xml(xml_content))
78
82
  else:
79
- logger.debug("Response content: suppressed by config/option [auth] response_content_on_debug")
83
+ logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
80
84
  f = io.BytesIO()
81
85
  ET.ElementTree(xml_content).write(f, encoding=self._encoding,
82
86
  xml_declaration=True)
@@ -322,13 +322,13 @@ def xml_propfind_response(
322
322
 
323
323
  responses[404 if is404 else 200].append(element)
324
324
 
325
- for status_code, childs in responses.items():
326
- if not childs:
325
+ for status_code, children in responses.items():
326
+ if not children:
327
327
  continue
328
328
  propstat = ET.Element(xmlutils.make_clark("D:propstat"))
329
329
  response.append(propstat)
330
330
  prop = ET.Element(xmlutils.make_clark("D:prop"))
331
- prop.extend(childs)
331
+ prop.extend(children)
332
332
  propstat.append(prop)
333
333
  status = ET.Element(xmlutils.make_clark("D:status"))
334
334
  status.text = xmlutils.make_response(status_code)
@@ -150,7 +150,7 @@ class ApplicationPartPut(ApplicationBase):
150
150
  if self._log_bad_put_request_content:
151
151
  logger.warning("Bad PUT request content of %r:\n%s", path, content)
152
152
  else:
153
- logger.debug("Bad PUT request content: suppressed by config/option [auth] bad_put_request_content")
153
+ logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
154
154
  return httputils.BAD_REQUEST
155
155
  (prepared_items, prepared_tag, prepared_write_whole_collection,
156
156
  prepared_props, prepared_exc_info) = prepare(
@@ -28,6 +28,7 @@ from typing import (Any, Callable, Iterable, Iterator, List, Optional,
28
28
  Sequence, Tuple, Union)
29
29
  from urllib.parse import unquote, urlparse
30
30
 
31
+ import vobject
31
32
  import vobject.base
32
33
  from vobject.base import ContentLine
33
34
 
@@ -38,11 +39,110 @@ from radicale.item import filter as radicale_filter
38
39
  from radicale.log import logger
39
40
 
40
41
 
42
+ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
43
+ collection: storage.BaseCollection, encoding: str,
44
+ unlock_storage_fn: Callable[[], None],
45
+ max_occurrence: int
46
+ ) -> Tuple[int, Union[ET.Element, str]]:
47
+ # NOTE: this function returns both an Element and a string because
48
+ # free-busy reports are an edge-case on the return type according
49
+ # to the spec.
50
+
51
+ multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
52
+ if xml_request is None:
53
+ return client.MULTI_STATUS, multistatus
54
+ root = xml_request
55
+ if (root.tag == xmlutils.make_clark("C:free-busy-query") and
56
+ collection.tag != "VCALENDAR"):
57
+ logger.warning("Invalid REPORT method %r on %r requested",
58
+ xmlutils.make_human_tag(root.tag), path)
59
+ return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
60
+
61
+ time_range_element = root.find(xmlutils.make_clark("C:time-range"))
62
+ assert isinstance(time_range_element, ET.Element)
63
+
64
+ # Build a single filter from the free busy query for retrieval
65
+ # TODO: filter for VFREEBUSY in additional to VEVENT but
66
+ # test_filter doesn't support that yet.
67
+ vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
68
+ attrib={'name': 'VEVENT'})
69
+ vevent_cf_element.append(time_range_element)
70
+ vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
71
+ attrib={'name': 'VCALENDAR'})
72
+ vcalendar_cf_element.append(vevent_cf_element)
73
+ filter_element = ET.Element(xmlutils.make_clark("C:filter"))
74
+ filter_element.append(vcalendar_cf_element)
75
+ filters = (filter_element,)
76
+
77
+ # First pull from storage
78
+ retrieved_items = list(collection.get_filtered(filters))
79
+ # !!! Don't access storage after this !!!
80
+ unlock_storage_fn()
81
+
82
+ cal = vobject.iCalendar()
83
+ collection_tag = collection.tag
84
+ while retrieved_items:
85
+ # Second filtering before evaluating occurrences.
86
+ # ``item.vobject_item`` might be accessed during filtering.
87
+ # Don't keep reference to ``item``, because VObject requires a lot of
88
+ # memory.
89
+ item, filter_matched = retrieved_items.pop(0)
90
+ if not filter_matched:
91
+ try:
92
+ if not test_filter(collection_tag, item, filter_element):
93
+ continue
94
+ except ValueError as e:
95
+ raise ValueError("Failed to free-busy filter item %r from %r: %s" %
96
+ (item.href, collection.path, e)) from e
97
+ except Exception as e:
98
+ raise RuntimeError("Failed to free-busy filter item %r from %r: %s" %
99
+ (item.href, collection.path, e)) from e
100
+
101
+ fbtype = None
102
+ if item.component_name == 'VEVENT':
103
+ transp = getattr(item.vobject_item.vevent, 'transp', None)
104
+ if transp and transp.value != 'OPAQUE':
105
+ continue
106
+
107
+ status = getattr(item.vobject_item.vevent, 'status', None)
108
+ if not status or status.value == 'CONFIRMED':
109
+ fbtype = 'BUSY'
110
+ elif status.value == 'CANCELLED':
111
+ fbtype = 'FREE'
112
+ elif status.value == 'TENTATIVE':
113
+ fbtype = 'BUSY-TENTATIVE'
114
+ else:
115
+ # Could do fbtype = status.value for x-name, I prefer this
116
+ fbtype = 'BUSY'
117
+
118
+ # TODO: coalesce overlapping periods
119
+
120
+ if max_occurrence > 0:
121
+ n_occurrences = max_occurrence+1
122
+ else:
123
+ n_occurrences = 0
124
+ occurrences = radicale_filter.time_range_fill(item.vobject_item,
125
+ time_range_element,
126
+ "VEVENT",
127
+ n=n_occurrences)
128
+ if len(occurrences) >= max_occurrence:
129
+ raise ValueError("FREEBUSY occurrences limit of {} hit"
130
+ .format(max_occurrence))
131
+
132
+ for occurrence in occurrences:
133
+ vfb = cal.add('vfreebusy')
134
+ vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value
135
+ vfb.add('dtstart').value, vfb.add('dtend').value = occurrence
136
+ if fbtype:
137
+ vfb.add('fbtype').value = fbtype
138
+ return (client.OK, cal.serialize())
139
+
140
+
41
141
  def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
42
142
  collection: storage.BaseCollection, encoding: str,
43
143
  unlock_storage_fn: Callable[[], None]
44
144
  ) -> Tuple[int, ET.Element]:
45
- """Read and answer REPORT requests.
145
+ """Read and answer REPORT requests that return XML.
46
146
 
47
147
  Read rfc3253-3.6 for info.
48
148
 
@@ -271,7 +371,7 @@ def _make_vobject_expanded_item(
271
371
  if hasattr(item.vobject_item.vevent, 'rrule'):
272
372
  rruleset = vevent.getrruleset()
273
373
 
274
- # There is something strage behavour during serialization native datetime, so converting manualy
374
+ # There is something strange behaviour during serialization native datetime, so converting manually
275
375
  vevent.dtstart.value = vevent.dtstart.value.strftime(dt_format)
276
376
  if dt_end is not None:
277
377
  vevent.dtend.value = vevent.dtend.value.strftime(dt_format)
@@ -426,13 +526,28 @@ class ApplicationPartReport(ApplicationBase):
426
526
  else:
427
527
  assert item.collection is not None
428
528
  collection = item.collection
429
- try:
430
- status, xml_answer = xml_report(
431
- base_prefix, path, xml_content, collection, self._encoding,
432
- lock_stack.close)
433
- except ValueError as e:
434
- logger.warning(
435
- "Bad REPORT request on %r: %s", path, e, exc_info=True)
436
- return httputils.BAD_REQUEST
437
- headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
438
- return status, headers, self._xml_response(xml_answer)
529
+
530
+ if xml_content is not None and \
531
+ xml_content.tag == xmlutils.make_clark("C:free-busy-query"):
532
+ max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence")
533
+ try:
534
+ status, body = free_busy_report(
535
+ base_prefix, path, xml_content, collection, self._encoding,
536
+ lock_stack.close, max_occurrence)
537
+ except ValueError as e:
538
+ logger.warning(
539
+ "Bad REPORT request on %r: %s", path, e, exc_info=True)
540
+ return httputils.BAD_REQUEST
541
+ headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
542
+ return status, headers, str(body)
543
+ else:
544
+ try:
545
+ status, xml_answer = xml_report(
546
+ base_prefix, path, xml_content, collection, self._encoding,
547
+ lock_stack.close)
548
+ except ValueError as e:
549
+ logger.warning(
550
+ "Bad REPORT request on %r: %s", path, e, exc_info=True)
551
+ return httputils.BAD_REQUEST
552
+ headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
553
+ return status, headers, self._xml_response(xml_answer)
@@ -52,6 +52,7 @@ def load(configuration: "config.Configuration") -> "BaseAuth":
52
52
  class BaseAuth:
53
53
 
54
54
  _lc_username: bool
55
+ _strip_domain: bool
55
56
 
56
57
  def __init__(self, configuration: "config.Configuration") -> None:
57
58
  """Initialize BaseAuth.
@@ -63,6 +64,7 @@ class BaseAuth:
63
64
  """
64
65
  self.configuration = configuration
65
66
  self._lc_username = configuration.get("auth", "lc_username")
67
+ self._strip_domain = configuration.get("auth", "strip_domain")
66
68
 
67
69
  def get_external_login(self, environ: types.WSGIEnviron) -> Union[
68
70
  Tuple[()], Tuple[str, str]]:
@@ -91,4 +93,8 @@ class BaseAuth:
91
93
  raise NotImplementedError
92
94
 
93
95
  def login(self, login: str, password: str) -> str:
94
- return self._login(login, password).lower() if self._lc_username else self._login(login, password)
96
+ if self._lc_username:
97
+ login = login.lower()
98
+ if self._strip_domain:
99
+ login = login.split('@')[0]
100
+ return self._login(login, password)
@@ -36,7 +36,7 @@ pointed to by the ``htpasswd_filename`` configuration value while assuming
36
36
  the password encryption method specified via the ``htpasswd_encryption``
37
37
  configuration value.
38
38
 
39
- The following htpasswd password encrpytion methods are supported by Radicale
39
+ The following htpasswd password encryption methods are supported by Radicale
40
40
  out-of-the-box:
41
41
  - plain-text (created by htpasswd -p ...) -- INSECURE
42
42
  - MD5-APR1 (htpasswd -m ...) -- htpasswd's default method, INSECURE
@@ -191,6 +191,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
191
191
  "value": "1",
192
192
  "help": "incorrect authentication delay",
193
193
  "type": positive_float}),
194
+ ("strip_domain", {
195
+ "value": "False",
196
+ "help": "strip domain from username",
197
+ "type": bool}),
194
198
  ("lc_username", {
195
199
  "value": "False",
196
200
  "help": "convert username to lowercase, must be true for case-insensitive auth providers",
@@ -288,12 +292,22 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
288
292
  "value": "False",
289
293
  "help": "log response content on level=debug",
290
294
  "type": bool}),
295
+ ("rights_rule_doesnt_match_on_debug", {
296
+ "value": "False",
297
+ "help": "log rights rules which doesn't match on level=debug",
298
+ "type": bool}),
291
299
  ("mask_passwords", {
292
300
  "value": "True",
293
301
  "help": "mask passwords in logs",
294
302
  "type": bool})])),
295
303
  ("headers", OrderedDict([
296
- ("_allow_extra", str)]))])
304
+ ("_allow_extra", str)])),
305
+ ("reporting", OrderedDict([
306
+ ("max_freebusy_occurrence", {
307
+ "value": "10000",
308
+ "help": "number of occurrences per event when reporting",
309
+ "type": positive_int})]))
310
+ ])
297
311
 
298
312
 
299
313
  def parse_compound_paths(*compound_paths: Optional[str]
@@ -3,14 +3,23 @@ from enum import Enum
3
3
  from typing import Sequence
4
4
 
5
5
  from radicale import pathutils, utils
6
+ from radicale.log import logger
6
7
 
7
8
  INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq")
8
9
 
9
10
 
10
11
  def load(configuration):
11
12
  """Load the storage module chosen in configuration."""
12
- return utils.load_plugin(
13
- INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
13
+ try:
14
+ return utils.load_plugin(
15
+ INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
16
+ except Exception as e:
17
+ logger.warning(e)
18
+ logger.warning("Hook \"%s\" failed to load, falling back to \"none\"." % configuration.get("hook", "type"))
19
+ configuration = configuration.copy()
20
+ configuration.update({"hook": {"type": "none"}}, "hook", privileged=True)
21
+ return utils.load_plugin(
22
+ INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
14
23
 
15
24
 
16
25
  class BaseHook:
@@ -146,7 +146,7 @@ def read_request_body(configuration: "config.Configuration",
146
146
  if configuration.get("logging", "request_content_on_debug"):
147
147
  logger.debug("Request content:\n%s", content)
148
148
  else:
149
- logger.debug("Request content: suppressed by config/option [auth] request_content_on_debug")
149
+ logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
150
150
  return content
151
151
 
152
152
 
@@ -49,6 +49,12 @@ def read_components(s: str) -> List[vobject.base.Component]:
49
49
  s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)"
50
50
  r"data:[^;,\r\n]*;base64,", r"\1", s,
51
51
  flags=re.MULTILINE | re.IGNORECASE)
52
+ # Workaround for bug with malformed ICS files containing control codes
53
+ # Filter out all control codes except those we expect to find:
54
+ # * 0x09 Horizontal Tab
55
+ # * 0x0A Line Feed
56
+ # * 0x0D Carriage Return
57
+ s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s)
52
58
  return list(vobject.readComponents(s, allowQP=True))
53
59
 
54
60
 
@@ -298,7 +304,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
298
304
  Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are
299
305
  POSIX timestamps.
300
306
 
301
- This is intened to be used for matching against simplified prefilters.
307
+ This is intended to be used for matching against simplified prefilters.
302
308
 
303
309
  """
304
310
  if not tag:
@@ -48,10 +48,34 @@ def date_to_datetime(d: date) -> datetime:
48
48
  if not isinstance(d, datetime):
49
49
  d = datetime.combine(d, datetime.min.time())
50
50
  if not d.tzinfo:
51
- d = d.replace(tzinfo=timezone.utc)
51
+ # NOTE: using vobject's UTC as it wasn't playing well with datetime's.
52
+ d = d.replace(tzinfo=vobject.icalendar.utc)
52
53
  return d
53
54
 
54
55
 
56
+ def parse_time_range(time_filter: ET.Element) -> Tuple[datetime, datetime]:
57
+ start_text = time_filter.get("start")
58
+ end_text = time_filter.get("end")
59
+ if start_text:
60
+ start = datetime.strptime(
61
+ start_text, "%Y%m%dT%H%M%SZ").replace(
62
+ tzinfo=timezone.utc)
63
+ else:
64
+ start = DATETIME_MIN
65
+ if end_text:
66
+ end = datetime.strptime(
67
+ end_text, "%Y%m%dT%H%M%SZ").replace(
68
+ tzinfo=timezone.utc)
69
+ else:
70
+ end = DATETIME_MAX
71
+ return start, end
72
+
73
+
74
+ def time_range_timestamps(time_filter: ET.Element) -> Tuple[int, int]:
75
+ start, end = parse_time_range(time_filter)
76
+ return (math.floor(start.timestamp()), math.ceil(end.timestamp()))
77
+
78
+
55
79
  def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
56
80
  """Check whether the ``item`` matches the comp ``filter_``.
57
81
 
@@ -147,21 +171,10 @@ def time_range_match(vobject_item: vobject.base.Component,
147
171
  """Check whether the component/property ``child_name`` of
148
172
  ``vobject_item`` matches the time-range ``filter_``."""
149
173
 
150
- start_text = filter_.get("start")
151
- end_text = filter_.get("end")
152
- if not start_text and not end_text:
174
+ if not filter_.get("start") and not filter_.get("end"):
153
175
  return False
154
- if start_text:
155
- start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ")
156
- else:
157
- start = datetime.min
158
- if end_text:
159
- end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ")
160
- else:
161
- end = datetime.max
162
- start = start.replace(tzinfo=timezone.utc)
163
- end = end.replace(tzinfo=timezone.utc)
164
176
 
177
+ start, end = parse_time_range(filter_)
165
178
  matched = False
166
179
 
167
180
  def range_fn(range_start: datetime, range_end: datetime,
@@ -181,6 +194,35 @@ def time_range_match(vobject_item: vobject.base.Component,
181
194
  return matched
182
195
 
183
196
 
197
+ def time_range_fill(vobject_item: vobject.base.Component,
198
+ filter_: ET.Element, child_name: str, n: int = 1
199
+ ) -> List[Tuple[datetime, datetime]]:
200
+ """Create a list of ``n`` occurances from the component/property ``child_name``
201
+ of ``vobject_item``."""
202
+ if not filter_.get("start") and not filter_.get("end"):
203
+ return []
204
+
205
+ start, end = parse_time_range(filter_)
206
+ ranges: List[Tuple[datetime, datetime]] = []
207
+
208
+ def range_fn(range_start: datetime, range_end: datetime,
209
+ is_recurrence: bool) -> bool:
210
+ nonlocal ranges
211
+ if start < range_end and range_start < end:
212
+ ranges.append((range_start, range_end))
213
+ if n > 0 and len(ranges) >= n:
214
+ return True
215
+ if end < range_start and not is_recurrence:
216
+ return True
217
+ return False
218
+
219
+ def infinity_fn(range_start: datetime) -> bool:
220
+ return False
221
+
222
+ visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
223
+ return ranges
224
+
225
+
184
226
  def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
185
227
  range_fn: Callable[[datetime, datetime, bool], bool],
186
228
  infinity_fn: Callable[[datetime], bool]) -> None:
@@ -199,7 +241,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
199
241
 
200
242
  """
201
243
 
202
- # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled
244
+ # HACK: According to rfc5545-3.8.4.4 a recurrence that is rescheduled
203
245
  # with Recurrence ID affects the recurrence itself and all following
204
246
  # recurrences too. This is not respected and client don't seem to bother
205
247
  # either.
@@ -543,20 +585,7 @@ def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str
543
585
  if time_filter.tag != xmlutils.make_clark("C:time-range"):
544
586
  simple = False
545
587
  continue
546
- start_text = time_filter.get("start")
547
- end_text = time_filter.get("end")
548
- if start_text:
549
- start = math.floor(datetime.strptime(
550
- start_text, "%Y%m%dT%H%M%SZ").replace(
551
- tzinfo=timezone.utc).timestamp())
552
- else:
553
- start = TIMESTAMP_MIN
554
- if end_text:
555
- end = math.ceil(datetime.strptime(
556
- end_text, "%Y%m%dT%H%M%SZ").replace(
557
- tzinfo=timezone.utc).timestamp())
558
- else:
559
- end = TIMESTAMP_MAX
588
+ start, end = time_range_timestamps(time_filter)
560
589
  return tag, start, end, simple
561
590
  return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
562
591
  return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
@@ -22,7 +22,7 @@ config (section "rights", key "file").
22
22
  The login is matched against the "user" key, and the collection path
23
23
  is matched against the "collection" key. In the "collection" regex you can use
24
24
  `{user}` and get groups from the "user" regex with `{0}`, `{1}`, etc.
25
- In consequence of the parameter subsitution you have to write `{{` and `}}`
25
+ In consequence of the parameter substitution you have to write `{{` and `}}`
26
26
  if you want to use regular curly braces in the "user" and "collection" regexes.
27
27
 
28
28
  For example, for the "user" key, ".+" means "authenticated user" and ".*"
@@ -48,6 +48,7 @@ class Rights(rights.BaseRights):
48
48
  def __init__(self, configuration: config.Configuration) -> None:
49
49
  super().__init__(configuration)
50
50
  self._filename = configuration.get("rights", "file")
51
+ self._log_rights_rule_doesnt_match_on_debug = configuration.get("logging", "rights_rule_doesnt_match_on_debug")
51
52
 
52
53
  def authorization(self, user: str, path: str) -> str:
53
54
  user = user or ""
@@ -61,6 +62,8 @@ class Rights(rights.BaseRights):
61
62
  except Exception as e:
62
63
  raise RuntimeError("Failed to load rights file %r: %s" %
63
64
  (self._filename, e)) from e
65
+ if not self._log_rights_rule_doesnt_match_on_debug:
66
+ logger.debug("logging of rules which doesn't match suppressed by config/option [logging] rights_rule_doesnt_match_on_debug")
64
67
  for section in rights_config.sections():
65
68
  try:
66
69
  user_pattern = rights_config.get(section, "user")
@@ -80,8 +83,9 @@ class Rights(rights.BaseRights):
80
83
  user, sane_path, user_pattern,
81
84
  collection_pattern, section, permission)
82
85
  return permission
83
- logger.debug("Rule %r:%r doesn't match %r:%r from section %r",
84
- user, sane_path, user_pattern, collection_pattern,
85
- section)
86
+ if self._log_rights_rule_doesnt_match_on_debug:
87
+ logger.debug("Rule %r:%r doesn't match %r:%r from section %r",
88
+ user, sane_path, user_pattern, collection_pattern,
89
+ section)
86
90
  logger.info("Rights: %r:%r doesn't match any section", user, sane_path)
87
91
  return ""
@@ -291,7 +291,7 @@ def serve(configuration: config.Configuration,
291
291
  try:
292
292
  getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
293
293
  except OSError as e:
294
- logger.warn("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e))
294
+ logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e))
295
295
  continue
296
296
  logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo))
297
297
  for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo:
@@ -299,7 +299,7 @@ def serve(configuration: config.Configuration,
299
299
  try:
300
300
  server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler)
301
301
  except OSError as e:
302
- logger.warn("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
302
+ logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
303
303
  continue
304
304
  servers[server.socket] = server
305
305
  server.set_app(application)
@@ -84,7 +84,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
84
84
  cache_content = self._load_item_cache(href, cache_hash)
85
85
  if cache_content is None:
86
86
  with self._acquire_cache_lock("item"):
87
- # Lock the item cache to prevent multpile processes from
87
+ # Lock the item cache to prevent multiple processes from
88
88
  # generating the same data in parallel.
89
89
  # This improves the performance for multiple requests.
90
90
  if self._storage._lock.locked == "r":
@@ -127,7 +127,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
127
127
 
128
128
  def get_multi(self, hrefs: Iterable[str]
129
129
  ) -> Iterator[Tuple[str, Optional[radicale_item.Item]]]:
130
- # It's faster to check for file name collissions here, because
130
+ # It's faster to check for file name collisions here, because
131
131
  # we only need to call os.listdir once.
132
132
  files = None
133
133
  for href in hrefs:
@@ -146,7 +146,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
146
146
 
147
147
  def get_all(self) -> Iterator[radicale_item.Item]:
148
148
  for href in self._list():
149
- # We don't need to check for collissions, because the file names
149
+ # We don't need to check for collisions, because the file names
150
150
  # are from os.listdir.
151
151
  item = self._get(href, verify_href=False)
152
152
  if item is not None:
@@ -1,24 +1,31 @@
1
1
  [tool:pytest]
2
- addopts = --typeguard-packages=radicale
3
2
 
4
3
  [tox:tox]
4
+ min_version = 4.0
5
+ envlist = py, flake8, isort, mypy
5
6
 
6
7
  [testenv]
7
- extras = test
8
+ extras =
9
+ test
8
10
  deps =
9
- flake8
10
- isort
11
- mypy; implementation_name!='pypy' or python_version>='3.9'
12
- types-setuptools
11
+ pytest
13
12
  pytest-cov
14
- commands =
15
- flake8 .
16
- isort --check --diff .
17
- python -c 'import importlib.util, subprocess, sys; \
18
- importlib.util.find_spec("mypy") \
19
- and sys.exit(subprocess.run(["mypy", "."]).returncode) \
20
- or print("Skipped: mypy is not installed")'
21
- pytest -r s --cov --cov-report=term --cov-report=xml .
13
+ commands = pytest -r s --cov --cov-report=term --cov-report=xml .
14
+
15
+ [testenv:flake8]
16
+ deps = flake8==7.1.0
17
+ commands = flake8 .
18
+ skip_install = True
19
+
20
+ [testenv:isort]
21
+ deps = isort==5.13.2
22
+ commands = isort --check --diff .
23
+ skip_install = True
24
+
25
+ [testenv:mypy]
26
+ deps = mypy==1.11.0
27
+ commands = mypy .
28
+ skip_install = True
22
29
 
23
30
  [tool:isort]
24
31
  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
@@ -19,7 +19,7 @@ from setuptools import find_packages, setup
19
19
 
20
20
  # When the version is updated, a new section in the CHANGELOG.md file must be
21
21
  # added too.
22
- VERSION = "3.2.2"
22
+ VERSION = "3.2.3"
23
23
 
24
24
  with open("README.md", encoding="utf-8") as f:
25
25
  long_description = f.read()
@@ -38,9 +38,9 @@ web_files = ["web/internal_data/css/icon.png",
38
38
  install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
39
39
  "python-dateutil>=2.7.3",
40
40
  "pika>=1.1.0",
41
- "setuptools; python_version<'3.9'"]
41
+ ]
42
42
  bcrypt_requires = ["bcrypt"]
43
- test_requires = ["pytest>=7", "typeguard<4.3", "waitress", *bcrypt_requires]
43
+ test_requires = ["pytest>=7", "waitress", *bcrypt_requires]
44
44
 
45
45
  setup(
46
46
  name="Radicale",
@@ -75,6 +75,7 @@ setup(
75
75
  "Programming Language :: Python :: 3.10",
76
76
  "Programming Language :: Python :: 3.11",
77
77
  "Programming Language :: Python :: 3.12",
78
+ "Programming Language :: Python :: 3.13",
78
79
  "Programming Language :: Python :: Implementation :: CPython",
79
80
  "Programming Language :: Python :: Implementation :: PyPy",
80
81
  "Topic :: Office/Business :: Groupware"])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes