Merge pull request #1935 from pbiering/improve-profiling
Improve profiling and logging
This commit is contained in:
@@ -6,6 +6,8 @@
|
|||||||
* Extend: [auth] imap: add fallback support for LOGIN towards remote IMAP server (replaced in 3.5.0)
|
* Extend: [auth] imap: add fallback support for LOGIN towards remote IMAP server (replaced in 3.5.0)
|
||||||
* Fix: improper detection of HTTP_X_FORWARDED_PORT on MOVE
|
* Fix: improper detection of HTTP_X_FORWARDED_PORT on MOVE
|
||||||
* Extend: [logging] with profiling log per reqest or regular per request method
|
* Extend: [logging] with profiling log per reqest or regular per request method
|
||||||
|
* New: [logging] option to log response header on debug loglevel
|
||||||
|
* Adjust: [logging] header/content debug log indended by space to be skipped by logwatch
|
||||||
|
|
||||||
## 3.5.9
|
## 3.5.9
|
||||||
* Extend: [auth] add support for type http_remote_user
|
* Extend: [auth] add support for type http_remote_user
|
||||||
|
|||||||
@@ -1654,7 +1654,7 @@ Default: `False`
|
|||||||
|
|
||||||
_(>= 3.2.2)_
|
_(>= 3.2.2)_
|
||||||
|
|
||||||
Log request on `level = debug`
|
Log request header on `level = debug`
|
||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
@@ -1662,7 +1662,15 @@ Default: `False`
|
|||||||
|
|
||||||
_(>= 3.2.2)_
|
_(>= 3.2.2)_
|
||||||
|
|
||||||
Log request on `level = debug`
|
Log request content (body) on `level = debug`
|
||||||
|
|
||||||
|
Default: `False`
|
||||||
|
|
||||||
|
##### response_header_on_debug
|
||||||
|
|
||||||
|
_(>= 3.5.10)_
|
||||||
|
|
||||||
|
Log response header on `level = debug`
|
||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
@@ -1670,7 +1678,7 @@ Default: `False`
|
|||||||
|
|
||||||
_(>= 3.2.2)_
|
_(>= 3.2.2)_
|
||||||
|
|
||||||
Log response on `level = debug`
|
Log response content (body) on `level = debug`
|
||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
@@ -1711,6 +1719,22 @@ Log profiling data per request minimum duration (seconds) before logging, otherw
|
|||||||
|
|
||||||
Default: `3`
|
Default: `3`
|
||||||
|
|
||||||
|
##### profiling_per_request_header
|
||||||
|
|
||||||
|
_(>= 3.5.10)_
|
||||||
|
|
||||||
|
Log profiling request header (if passing minimum duration)
|
||||||
|
|
||||||
|
Default: `False`
|
||||||
|
|
||||||
|
##### profiling_per_request_xml
|
||||||
|
|
||||||
|
_(>= 3.5.10)_
|
||||||
|
|
||||||
|
Log profiling request XML (if passing minimum duration)
|
||||||
|
|
||||||
|
Default: `False`
|
||||||
|
|
||||||
##### profiling_per_request_method_interval
|
##### profiling_per_request_method_interval
|
||||||
|
|
||||||
_(>= 3.5.10)_
|
_(>= 3.5.10)_
|
||||||
|
|||||||
11
config
11
config
@@ -312,6 +312,9 @@
|
|||||||
# Log request content on level=debug
|
# Log request content on level=debug
|
||||||
#request_content_on_debug = False
|
#request_content_on_debug = False
|
||||||
|
|
||||||
|
# Log response header on level=debug
|
||||||
|
#response_header_on_debug = False
|
||||||
|
|
||||||
# Log response content on level=debug
|
# Log response content on level=debug
|
||||||
#response_content_on_debug = False
|
#response_content_on_debug = False
|
||||||
|
|
||||||
@@ -325,9 +328,15 @@
|
|||||||
# Value: per_request | per_request_method | none
|
# Value: per_request | per_request_method | none
|
||||||
#profiling = none
|
#profiling = none
|
||||||
|
|
||||||
# Log profiling data per request minium duration (seconds)
|
# Log profiling data per request minimum duration (seconds)
|
||||||
#profiling_per_request_min_duration = 3
|
#profiling_per_request_min_duration = 3
|
||||||
|
|
||||||
|
# Log profiling request header (if passing minimum duration)
|
||||||
|
#profiling_per_request_header = False
|
||||||
|
|
||||||
|
# Log profiling request XML (if passing minimum duration)
|
||||||
|
#profiling_per_request_xml = False
|
||||||
|
|
||||||
# Log profiling data per request method interval (seconds)
|
# Log profiling data per request method interval (seconds)
|
||||||
#profiling_per_request_method_interval = 600
|
#profiling_per_request_method_interval = 600
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import zlib
|
|||||||
from http import client
|
from http import client
|
||||||
from typing import Iterable, List, Mapping, Tuple, Union
|
from typing import Iterable, List, Mapping, Tuple, Union
|
||||||
|
|
||||||
from radicale import config, httputils, log, pathutils, types
|
from radicale import config, httputils, log, pathutils, types, utils
|
||||||
from radicale.app.base import ApplicationBase
|
from radicale.app.base import ApplicationBase
|
||||||
from radicale.app.delete import ApplicationPartDelete
|
from radicale.app.delete import ApplicationPartDelete
|
||||||
from radicale.app.get import ApplicationPartGet
|
from radicale.app.get import ApplicationPartGet
|
||||||
@@ -96,8 +96,15 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
super().__init__(configuration)
|
super().__init__(configuration)
|
||||||
self._mask_passwords = configuration.get("logging", "mask_passwords")
|
self._mask_passwords = configuration.get("logging", "mask_passwords")
|
||||||
self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
|
self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
|
||||||
|
logger.info("log bad put request content: %s", self._bad_put_request_content)
|
||||||
self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")
|
self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")
|
||||||
|
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
|
||||||
|
self._response_header_on_debug = configuration.get("logging", "response_header_on_debug")
|
||||||
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
|
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
|
||||||
|
logger.debug("log request header on debug: %s", self._request_header_on_debug)
|
||||||
|
logger.debug("log request content on debug: %s", self._request_content_on_debug)
|
||||||
|
logger.debug("log response header on debug: %s", self._response_header_on_debug)
|
||||||
|
logger.debug("log response content on debug: %s", self._response_content_on_debug)
|
||||||
self._auth_delay = configuration.get("auth", "delay")
|
self._auth_delay = configuration.get("auth", "delay")
|
||||||
self._auth_type = configuration.get("auth", "type")
|
self._auth_type = configuration.get("auth", "type")
|
||||||
self._web_type = configuration.get("web", "type")
|
self._web_type = configuration.get("web", "type")
|
||||||
@@ -130,6 +137,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
# Profiling options
|
# Profiling options
|
||||||
self._profiling = configuration.get("logging", "profiling")
|
self._profiling = configuration.get("logging", "profiling")
|
||||||
self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
|
self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
|
||||||
|
self._profiling_per_request_header = configuration.get("logging", "profiling_per_request_header")
|
||||||
|
self._profiling_per_request_xml = configuration.get("logging", "profiling_per_request_xml")
|
||||||
self._profiling_per_request_method_interval = configuration.get("logging", "profiling_per_request_method_interval")
|
self._profiling_per_request_method_interval = configuration.get("logging", "profiling_per_request_method_interval")
|
||||||
self._profiling_top_x_functions = configuration.get("logging", "profiling_top_x_functions")
|
self._profiling_top_x_functions = configuration.get("logging", "profiling_top_x_functions")
|
||||||
if self._profiling in config.PROFILING:
|
if self._profiling in config.PROFILING:
|
||||||
@@ -142,6 +151,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
logger.info("profiling top X functions: %d", self._profiling_top_x_functions)
|
logger.info("profiling top X functions: %d", self._profiling_top_x_functions)
|
||||||
if self._profiling_per_request:
|
if self._profiling_per_request:
|
||||||
logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration)
|
logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration)
|
||||||
|
logger.info("profiling per request header: %s", self._profiling_per_request_header)
|
||||||
|
logger.info("profiling per request xml : %s", self._profiling_per_request_xml)
|
||||||
if self._profiling_per_request_method:
|
if self._profiling_per_request_method:
|
||||||
logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval)
|
logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval)
|
||||||
# Profiling per request method initialization
|
# Profiling per request method initialization
|
||||||
@@ -164,9 +175,11 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
for method in REQUEST_METHODS:
|
for method in REQUEST_METHODS:
|
||||||
if self.profiler_per_request_method_counter[method] > 0:
|
if self.profiler_per_request_method_counter[method] > 0:
|
||||||
s = io.StringIO()
|
s = io.StringIO()
|
||||||
|
s.write("**Profiling statistics BEGIN**\n")
|
||||||
stats = pstats.Stats(self.profiler_per_request_method[method], stream=s).sort_stats('cumulative')
|
stats = pstats.Stats(self.profiler_per_request_method[method], stream=s).sort_stats('cumulative')
|
||||||
stats.print_stats(self._profiling_top_x_functions) # Print top X functions
|
stats.print_stats(self._profiling_top_x_functions) # Print top X functions
|
||||||
logger.info("Profiling data per request method %s after %d seconds and %d requests: %s", method, profiler_timedelta_start, self.profiler_per_request_method_counter[method], s.getvalue())
|
s.write("**Profiling statistics END**\n")
|
||||||
|
logger.info("Profiling data per request method %s after %d seconds and %d requests:\n%s", method, profiler_timedelta_start, self.profiler_per_request_method_counter[method], utils.textwrap_str(s.getvalue(), -1))
|
||||||
else:
|
else:
|
||||||
if shutdown:
|
if shutdown:
|
||||||
logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
|
logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
|
||||||
@@ -193,7 +206,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
"%s", environ.get("REQUEST_METHOD", "unknown"),
|
"%s", environ.get("REQUEST_METHOD", "unknown"),
|
||||||
environ.get("PATH_INFO", ""), e, exc_info=True)
|
environ.get("PATH_INFO", ""), e, exc_info=True)
|
||||||
# Make minimal response
|
# Make minimal response
|
||||||
status, raw_headers, raw_answer = (
|
status, raw_headers, raw_answer, xml_request = (
|
||||||
httputils.INTERNAL_SERVER_ERROR)
|
httputils.INTERNAL_SERVER_ERROR)
|
||||||
assert isinstance(raw_answer, str)
|
assert isinstance(raw_answer, str)
|
||||||
answer = raw_answer.encode("ascii")
|
answer = raw_answer.encode("ascii")
|
||||||
@@ -213,12 +226,15 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
unsafe_path = environ.get("PATH_INFO", "")
|
unsafe_path = environ.get("PATH_INFO", "")
|
||||||
https = environ.get("HTTPS", "")
|
https = environ.get("HTTPS", "")
|
||||||
profiler = None
|
profiler = None
|
||||||
|
profiler_active = False
|
||||||
|
xml_request = None
|
||||||
|
|
||||||
context = AuthContext()
|
context = AuthContext()
|
||||||
|
|
||||||
"""Manage a request."""
|
"""Manage a request."""
|
||||||
def response(status: int, headers: types.WSGIResponseHeaders,
|
def response(status: int, headers: types.WSGIResponseHeaders,
|
||||||
answer: Union[None, str, bytes]) -> _IntermediateResponse:
|
answer: Union[None, str, bytes],
|
||||||
|
xml_request: Union[None, str] = None) -> _IntermediateResponse:
|
||||||
"""Helper to create response from internal types.WSGIResponse"""
|
"""Helper to create response from internal types.WSGIResponse"""
|
||||||
headers = dict(headers)
|
headers = dict(headers)
|
||||||
content_encoding = "plain"
|
content_encoding = "plain"
|
||||||
@@ -227,7 +243,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
if answer is not None:
|
if answer is not None:
|
||||||
if isinstance(answer, str):
|
if isinstance(answer, str):
|
||||||
if self._response_content_on_debug:
|
if self._response_content_on_debug:
|
||||||
logger.debug("Response content:\n%s", answer)
|
logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer))
|
||||||
else:
|
else:
|
||||||
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
|
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
|
||||||
headers["Content-Type"] += "; charset=%s" % self._encoding
|
headers["Content-Type"] += "; charset=%s" % self._encoding
|
||||||
@@ -249,6 +265,11 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
# Add extra headers set in configuration
|
# Add extra headers set in configuration
|
||||||
headers.update(self._extra_headers)
|
headers.update(self._extra_headers)
|
||||||
|
|
||||||
|
if self._response_header_on_debug:
|
||||||
|
logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers)))
|
||||||
|
else:
|
||||||
|
logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug")
|
||||||
|
|
||||||
# Start response
|
# Start response
|
||||||
time_end = datetime.datetime.now()
|
time_end = datetime.datetime.now()
|
||||||
time_delta_seconds = (time_end - time_begin).total_seconds()
|
time_delta_seconds = (time_end - time_begin).total_seconds()
|
||||||
@@ -265,17 +286,32 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
|
|
||||||
# Profiling end
|
# Profiling end
|
||||||
if self._profiling_per_request:
|
if self._profiling_per_request:
|
||||||
if profiler is not None:
|
if profiler_active is True:
|
||||||
# Profiling per request
|
if profiler is not None:
|
||||||
if time_delta_seconds < self._profiling_per_request_min_duration:
|
# Profiling per request
|
||||||
logger.debug("Profiling data per request %s for %r%s: (suppressed because duration below minimum %.3f < %.3f)", request_method, unsafe_path, depthinfo, time_delta_seconds, self._profiling_per_request_min_duration)
|
if time_delta_seconds < self._profiling_per_request_min_duration:
|
||||||
|
logger.debug("Profiling data per request %s for %r%s: (suppressed because duration below minimum %.3f < %.3f)", request_method, unsafe_path, depthinfo, time_delta_seconds, self._profiling_per_request_min_duration)
|
||||||
|
else:
|
||||||
|
s = io.StringIO()
|
||||||
|
s.write("**Profiling statistics BEGIN**\n")
|
||||||
|
stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
|
||||||
|
stats.print_stats(self._profiling_top_x_functions) # Print top X functions
|
||||||
|
s.write("**Profiling statistics END**\n")
|
||||||
|
if self._profiling_per_request_header:
|
||||||
|
s.write("**Profiling request header BEGIN**\n")
|
||||||
|
s.write(pprint.pformat(self._scrub_headers(environ)))
|
||||||
|
s.write("\n**Profiling request header END**")
|
||||||
|
if self._profiling_per_request_xml:
|
||||||
|
if xml_request is not None:
|
||||||
|
s.write("\n**Profiling request content (XML) BEGIN**\n")
|
||||||
|
if xml_request is not None:
|
||||||
|
s.write(xml_request)
|
||||||
|
s.write("**Profiling request content (XML) END**")
|
||||||
|
logger.info("Profiling data per request %s for %r%s:\n%s", request_method, unsafe_path, depthinfo, utils.textwrap_str(s.getvalue(), -1))
|
||||||
else:
|
else:
|
||||||
s = io.StringIO()
|
logger.debug("Profiling data per request %s for %r%s: (suppressed because of no data)", request_method, unsafe_path, depthinfo)
|
||||||
stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
|
|
||||||
stats.print_stats(self._profiling_top_x_functions) # Print top X functions
|
|
||||||
logger.info("Profiling data per request %s for %r%s: %s", request_method, unsafe_path, depthinfo, s.getvalue())
|
|
||||||
else:
|
else:
|
||||||
logger.debug("Profiling data per request %s for %r%s: (suppressed because of no data)", request_method, unsafe_path, depthinfo)
|
logger.info("Profiling data per request %s for %r%s: (not available because of concurrent running profiling request)", request_method, unsafe_path, depthinfo)
|
||||||
elif self._profiling_per_request_method:
|
elif self._profiling_per_request_method:
|
||||||
self.profiler_per_request_method[request_method].disable()
|
self.profiler_per_request_method[request_method].disable()
|
||||||
self.profiler_per_request_method_counter[request_method] += 1
|
self.profiler_per_request_method_counter[request_method] += 1
|
||||||
@@ -318,7 +354,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
remote_host, remote_useragent, https_info)
|
remote_host, remote_useragent, https_info)
|
||||||
if self._request_header_on_debug:
|
if self._request_header_on_debug:
|
||||||
logger.debug("Request header:\n%s",
|
logger.debug("Request header:\n%s",
|
||||||
pprint.pformat(self._scrub_headers(environ)))
|
utils.textwrap_str(pprint.pformat(self._scrub_headers(environ))))
|
||||||
else:
|
else:
|
||||||
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
|
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
|
||||||
|
|
||||||
@@ -453,27 +489,39 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
# Profiling
|
# Profiling
|
||||||
if self._profiling_per_request:
|
if self._profiling_per_request:
|
||||||
profiler = cProfile.Profile()
|
profiler = cProfile.Profile()
|
||||||
profiler.enable()
|
try:
|
||||||
|
profiler.enable()
|
||||||
|
except ValueError:
|
||||||
|
profiler_active = False
|
||||||
|
else:
|
||||||
|
profiler_active = True
|
||||||
elif self._profiling_per_request_method:
|
elif self._profiling_per_request_method:
|
||||||
self.profiler_per_request_method[request_method].enable()
|
try:
|
||||||
|
self.profiler_per_request_method[request_method].enable()
|
||||||
|
except ValueError:
|
||||||
|
profiler_active = False
|
||||||
|
else:
|
||||||
|
profiler_active = True
|
||||||
|
|
||||||
status, headers, answer = function(
|
status, headers, answer, xml_request = function(
|
||||||
environ, base_prefix, path, user, remote_host, remote_useragent)
|
environ, base_prefix, path, user, remote_host, remote_useragent)
|
||||||
|
|
||||||
# Profiling
|
# Profiling
|
||||||
if self._profiling_per_request:
|
if self._profiling_per_request:
|
||||||
if profiler is not None:
|
if profiler is not None:
|
||||||
profiler.disable()
|
if profiler_active is True:
|
||||||
|
profiler.disable()
|
||||||
elif self._profiling_per_request_method:
|
elif self._profiling_per_request_method:
|
||||||
self.profiler_per_request_method[request_method].disable()
|
if profiler_active is True:
|
||||||
|
self.profiler_per_request_method[request_method].disable()
|
||||||
|
|
||||||
if (status, headers, answer) == httputils.NOT_ALLOWED:
|
if (status, headers, answer, xml_request) == httputils.NOT_ALLOWED:
|
||||||
logger.info("Access to %r denied for %s", path,
|
logger.info("Access to %r denied for %s", path,
|
||||||
repr(user) if user else "anonymous user")
|
repr(user) if user else "anonymous user")
|
||||||
else:
|
else:
|
||||||
status, headers, answer = httputils.NOT_ALLOWED
|
status, headers, answer, xml_request = httputils.NOT_ALLOWED
|
||||||
|
|
||||||
if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and
|
if ((status, headers, answer, xml_request) == httputils.NOT_ALLOWED and not user and
|
||||||
not external_login):
|
not external_login):
|
||||||
# Unknown or unauthorized user
|
# Unknown or unauthorized user
|
||||||
logger.debug("Asking client for authentication")
|
logger.debug("Asking client for authentication")
|
||||||
@@ -483,4 +531,4 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
"WWW-Authenticate":
|
"WWW-Authenticate":
|
||||||
"Basic realm=\"%s\"" % self._auth_realm})
|
"Basic realm=\"%s\"" % self._auth_realm})
|
||||||
|
|
||||||
return response(status, headers, answer)
|
return response(status, headers, answer, xml_request)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import xml.etree.ElementTree as ET
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from radicale import (auth, config, hook, httputils, pathutils, rights,
|
from radicale import (auth, config, hook, httputils, pathutils, rights,
|
||||||
storage, types, web, xmlutils)
|
storage, types, utils, web, xmlutils)
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
# HACK: https://github.com/tiran/defusedxml/issues/54
|
# HACK: https://github.com/tiran/defusedxml/issues/54
|
||||||
@@ -71,7 +71,7 @@ class ApplicationBase:
|
|||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
if self._request_content_on_debug:
|
if self._request_content_on_debug:
|
||||||
logger.debug("Request content (XML):\n%s",
|
logger.debug("Request content (XML):\n%s",
|
||||||
xmlutils.pretty_xml(xml_content))
|
utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
||||||
else:
|
else:
|
||||||
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
|
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
|
||||||
return xml_content
|
return xml_content
|
||||||
@@ -80,7 +80,7 @@ class ApplicationBase:
|
|||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
if self._response_content_on_debug:
|
if self._response_content_on_debug:
|
||||||
logger.debug("Response content (XML):\n%s",
|
logger.debug("Response content (XML):\n%s",
|
||||||
xmlutils.pretty_xml(xml_content))
|
utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
||||||
else:
|
else:
|
||||||
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
|
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
|
||||||
f = io.BytesIO()
|
f = io.BytesIO()
|
||||||
@@ -93,7 +93,7 @@ class ApplicationBase:
|
|||||||
"""Generate XML error response."""
|
"""Generate XML error response."""
|
||||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
content = self._xml_response(xmlutils.webdav_error(human_tag))
|
content = self._xml_response(xmlutils.webdav_error(human_tag))
|
||||||
return status, headers, content
|
return status, headers, content, None
|
||||||
|
|
||||||
|
|
||||||
class Access:
|
class Access:
|
||||||
|
|||||||
@@ -110,4 +110,4 @@ class ApplicationPartDelete(ApplicationBase):
|
|||||||
for notification_item in hook_notification_item_list:
|
for notification_item in hook_notification_item_list:
|
||||||
self._hook.notify(notification_item)
|
self._hook.notify(notification_item)
|
||||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
return client.OK, headers, self._xml_response(xml_answer)
|
return client.OK, headers, self._xml_response(xml_answer), None
|
||||||
|
|||||||
@@ -109,4 +109,4 @@ class ApplicationPartGet(ApplicationBase):
|
|||||||
if content_disposition:
|
if content_disposition:
|
||||||
headers["Content-Disposition"] = content_disposition
|
headers["Content-Disposition"] = content_disposition
|
||||||
answer = item.serialize()
|
answer = item.serialize()
|
||||||
return client.OK, headers, answer
|
return client.OK, headers, answer, None
|
||||||
|
|||||||
@@ -89,4 +89,4 @@ class ApplicationPartMkcalendar(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
|
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
return client.CREATED, {}, None
|
return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -94,4 +94,4 @@ class ApplicationPartMkcol(ApplicationBase):
|
|||||||
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
|
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful")
|
logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful")
|
||||||
return client.CREATED, {}, None
|
return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -127,4 +127,4 @@ class ApplicationPartMove(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad MOVE request on %r: %s", path, e, exc_info=True)
|
"Bad MOVE request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
return client.NO_CONTENT if to_item else client.CREATED, {}, None
|
return client.NO_CONTENT if to_item else client.CREATED, {}, None, None
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ class ApplicationPartOptions(ApplicationBase):
|
|||||||
"Allow": ", ".join(
|
"Allow": ", ".join(
|
||||||
name[3:] for name in dir(self) if name.startswith("do_")),
|
name[3:] for name in dir(self) if name.startswith("do_")),
|
||||||
"DAV": httputils.DAV_HEADERS}
|
"DAV": httputils.DAV_HEADERS}
|
||||||
return client.OK, headers, None
|
return client.OK, headers, None, None
|
||||||
|
|||||||
@@ -410,4 +410,4 @@ class ApplicationPartPropfind(ApplicationBase):
|
|||||||
allowed_items, user, self._encoding)
|
allowed_items, user, self._encoding)
|
||||||
if xml_answer is None:
|
if xml_answer is None:
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer)
|
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -131,4 +131,4 @@ class ApplicationPartProppatch(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
|
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer)
|
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
|
|||||||
content = vobject_item
|
content = vobject_item
|
||||||
else:
|
else:
|
||||||
content = item._text
|
content = item._text
|
||||||
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, content)
|
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
|
||||||
else:
|
else:
|
||||||
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
||||||
raise
|
raise
|
||||||
@@ -117,7 +117,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
|
|||||||
content = vobject_item
|
content = vobject_item
|
||||||
else:
|
else:
|
||||||
content = item._text
|
content = item._text
|
||||||
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, content)
|
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
|
||||||
else:
|
else:
|
||||||
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
||||||
raise
|
raise
|
||||||
@@ -180,7 +180,7 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
|
"Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
|
||||||
if self._log_bad_put_request_content:
|
if self._log_bad_put_request_content:
|
||||||
logger.warning("Bad PUT request content of %r:\n%s", path, content)
|
logger.warning("Bad PUT request content of %r:\n%s", path, utils.textwrap_str(content))
|
||||||
else:
|
else:
|
||||||
logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
|
logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
@@ -334,7 +334,7 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
if (item and item.uid == prepared_item.uid):
|
if (item and item.uid == prepared_item.uid):
|
||||||
logger.debug("PUT request updated existing item %r", path)
|
logger.debug("PUT request updated existing item %r", path)
|
||||||
headers = {"ETag": etag}
|
headers = {"ETag": etag}
|
||||||
return client.NO_CONTENT, headers, None
|
return client.NO_CONTENT, headers, None, None
|
||||||
|
|
||||||
headers = {"ETag": etag}
|
headers = {"ETag": etag}
|
||||||
return client.CREATED, headers, None
|
return client.CREATED, headers, None, None
|
||||||
|
|||||||
@@ -815,7 +815,7 @@ class ApplicationPartReport(ApplicationBase):
|
|||||||
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
|
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
|
||||||
return status, headers, str(body)
|
return status, headers, str(body), xmlutils.pretty_xml(xml_content)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
status, xml_answer = xml_report(
|
status, xml_answer = xml_report(
|
||||||
@@ -826,4 +826,4 @@ class ApplicationPartReport(ApplicationBase):
|
|||||||
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
return status, headers, self._xml_response(xml_answer)
|
return status, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -577,6 +577,10 @@ This is an automated message. Please do not reply.""",
|
|||||||
"value": "False",
|
"value": "False",
|
||||||
"help": "log request content on level=debug",
|
"help": "log request content on level=debug",
|
||||||
"type": bool}),
|
"type": bool}),
|
||||||
|
("response_header_on_debug", {
|
||||||
|
"value": "False",
|
||||||
|
"help": "log response header on level=debug",
|
||||||
|
"type": bool}),
|
||||||
("response_content_on_debug", {
|
("response_content_on_debug", {
|
||||||
"value": "False",
|
"value": "False",
|
||||||
"help": "log response content on level=debug",
|
"help": "log response content on level=debug",
|
||||||
@@ -597,6 +601,14 @@ This is an automated message. Please do not reply.""",
|
|||||||
"value": "3",
|
"value": "3",
|
||||||
"help": "log profiling data per request minimum duration (seconds)",
|
"help": "log profiling data per request minimum duration (seconds)",
|
||||||
"type": int}),
|
"type": int}),
|
||||||
|
("profiling_per_request_header", {
|
||||||
|
"value": "False",
|
||||||
|
"help": "Log profiling request body (if passing minimum duration)",
|
||||||
|
"type": bool}),
|
||||||
|
("profiling_per_request_xml", {
|
||||||
|
"value": "False",
|
||||||
|
"help": "Log profiling request XML (if passing minimum duration)",
|
||||||
|
"type": bool}),
|
||||||
("profiling_per_request_method_interval", {
|
("profiling_per_request_method_interval", {
|
||||||
"value": "600",
|
"value": "600",
|
||||||
"help": "log profiling data per request method interval (seconds)",
|
"help": "log profiling data per request method interval (seconds)",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import time
|
|||||||
from http import client
|
from http import client
|
||||||
from typing import List, Mapping, Union, cast
|
from typing import List, Mapping, Union, cast
|
||||||
|
|
||||||
from radicale import config, pathutils, types
|
from radicale import config, pathutils, types, utils
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
if sys.version_info < (3, 9):
|
if sys.version_info < (3, 9):
|
||||||
@@ -49,42 +49,42 @@ else:
|
|||||||
|
|
||||||
NOT_ALLOWED: types.WSGIResponse = (
|
NOT_ALLOWED: types.WSGIResponse = (
|
||||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||||
"Access to the requested resource forbidden.")
|
"Access to the requested resource forbidden.", None)
|
||||||
FORBIDDEN: types.WSGIResponse = (
|
FORBIDDEN: types.WSGIResponse = (
|
||||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||||
"Action on the requested resource refused.")
|
"Action on the requested resource refused.", None)
|
||||||
BAD_REQUEST: types.WSGIResponse = (
|
BAD_REQUEST: types.WSGIResponse = (
|
||||||
client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request")
|
client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request", None)
|
||||||
NOT_FOUND: types.WSGIResponse = (
|
NOT_FOUND: types.WSGIResponse = (
|
||||||
client.NOT_FOUND, (("Content-Type", "text/plain"),),
|
client.NOT_FOUND, (("Content-Type", "text/plain"),),
|
||||||
"The requested resource could not be found.")
|
"The requested resource could not be found.", None)
|
||||||
CONFLICT: types.WSGIResponse = (
|
CONFLICT: types.WSGIResponse = (
|
||||||
client.CONFLICT, (("Content-Type", "text/plain"),),
|
client.CONFLICT, (("Content-Type", "text/plain"),),
|
||||||
"Conflict in the request.")
|
"Conflict in the request.", None)
|
||||||
METHOD_NOT_ALLOWED: types.WSGIResponse = (
|
METHOD_NOT_ALLOWED: types.WSGIResponse = (
|
||||||
client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
|
client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
|
||||||
"The method is not allowed on the requested resource.")
|
"The method is not allowed on the requested resource.", None)
|
||||||
PRECONDITION_FAILED: types.WSGIResponse = (
|
PRECONDITION_FAILED: types.WSGIResponse = (
|
||||||
client.PRECONDITION_FAILED,
|
client.PRECONDITION_FAILED,
|
||||||
(("Content-Type", "text/plain"),), "Precondition failed.")
|
(("Content-Type", "text/plain"),), "Precondition failed.", None)
|
||||||
REQUEST_TIMEOUT: types.WSGIResponse = (
|
REQUEST_TIMEOUT: types.WSGIResponse = (
|
||||||
client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
|
client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
|
||||||
"Connection timed out.")
|
"Connection timed out.", None)
|
||||||
REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
|
REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
|
||||||
client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
|
client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
|
||||||
"Request body too large.")
|
"Request body too large.", None)
|
||||||
REMOTE_DESTINATION: types.WSGIResponse = (
|
REMOTE_DESTINATION: types.WSGIResponse = (
|
||||||
client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
|
client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
|
||||||
"Remote destination not supported.")
|
"Remote destination not supported.", None)
|
||||||
DIRECTORY_LISTING: types.WSGIResponse = (
|
DIRECTORY_LISTING: types.WSGIResponse = (
|
||||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||||
"Directory listings are not supported.")
|
"Directory listings are not supported.", None)
|
||||||
INSUFFICIENT_STORAGE: types.WSGIResponse = (
|
INSUFFICIENT_STORAGE: types.WSGIResponse = (
|
||||||
client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),),
|
client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),),
|
||||||
"Insufficient Storage. Please contact the administrator.")
|
"Insufficient Storage. Please contact the administrator.", None)
|
||||||
INTERNAL_SERVER_ERROR: types.WSGIResponse = (
|
INTERNAL_SERVER_ERROR: types.WSGIResponse = (
|
||||||
client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
|
client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
|
||||||
"A server error occurred. Please contact the administrator.")
|
"A server error occurred. Please contact the administrator.", None)
|
||||||
|
|
||||||
DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
|
DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ def read_request_body(configuration: "config.Configuration",
|
|||||||
content = decode_request(configuration, environ,
|
content = decode_request(configuration, environ,
|
||||||
read_raw_request_body(configuration, environ))
|
read_raw_request_body(configuration, environ))
|
||||||
if configuration.get("logging", "request_content_on_debug"):
|
if configuration.get("logging", "request_content_on_debug"):
|
||||||
logger.debug("Request content:\n%s", content)
|
logger.debug("Request content:\n%s", utils.textwrap_str(content))
|
||||||
else:
|
else:
|
||||||
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
|
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
|
||||||
return content
|
return content
|
||||||
@@ -159,7 +159,7 @@ def read_request_body(configuration: "config.Configuration",
|
|||||||
def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
|
def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
|
||||||
return (status,
|
return (status,
|
||||||
{"Location": location, "Content-Type": "text/plain"},
|
{"Location": location, "Content-Type": "text/plain"},
|
||||||
"Redirected to %s" % location)
|
"Redirected to %s" % location, None)
|
||||||
|
|
||||||
|
|
||||||
def _serve_traversable(
|
def _serve_traversable(
|
||||||
@@ -214,7 +214,7 @@ def _serve_traversable(
|
|||||||
# adjust on the fly default main.js of InfCloud installation
|
# adjust on the fly default main.js of InfCloud installation
|
||||||
logger.debug("Adjust on-the-fly default InfCloud main.js in served page: %r", path)
|
logger.debug("Adjust on-the-fly default InfCloud main.js in served page: %r", path)
|
||||||
answer = answer.replace(b"'InfCloud - the open source CalDAV/CardDAV web client'", b"'InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server'")
|
answer = answer.replace(b"'InfCloud - the open source CalDAV/CardDAV web client'", b"'InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server'")
|
||||||
return client.OK, headers, answer
|
return client.OK, headers, answer, None
|
||||||
|
|
||||||
|
|
||||||
def serve_resource(
|
def serve_resource(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||||
|
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
#
|
#
|
||||||
# This library is free software: you can redistribute it and/or modify
|
# This library is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -28,9 +29,9 @@ class Web(web.BaseWeb):
|
|||||||
|
|
||||||
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||||
user: str) -> types.WSGIResponse:
|
user: str) -> types.WSGIResponse:
|
||||||
return client.OK, {"Content-Type": "text/plain"}, "custom"
|
return client.OK, {"Content-Type": "text/plain"}, "custom", None
|
||||||
|
|
||||||
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||||
user: str) -> types.WSGIResponse:
|
user: str) -> types.WSGIResponse:
|
||||||
content = httputils.read_request_body(self.configuration, environ)
|
content = httputils.read_request_body(self.configuration, environ)
|
||||||
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content
|
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2020 Unrud <unrud@outlook.com>
|
# Copyright © 2020-2023 Unrud <unrud@outlook.com>
|
||||||
|
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
#
|
#
|
||||||
# This library is free software: you can redistribute it and/or modify
|
# This library is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -20,7 +21,7 @@ from typing import (Any, Callable, ContextManager, Iterator, List, Mapping,
|
|||||||
runtime_checkable)
|
runtime_checkable)
|
||||||
|
|
||||||
WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]]
|
WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]]
|
||||||
WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]]
|
WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes], Union[None, str]]
|
||||||
WSGIEnviron = Mapping[str, Any]
|
WSGIEnviron = Mapping[str, Any]
|
||||||
WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]
|
WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import datetime
|
|||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
|
import textwrap
|
||||||
from importlib import import_module, metadata
|
from importlib import import_module, metadata
|
||||||
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
|
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
|
||||||
|
|
||||||
@@ -291,3 +292,16 @@ def format_ut(unixtime: int) -> str:
|
|||||||
dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC)
|
dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC)
|
||||||
r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")"
|
r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")"
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def limit_str(content: str, limit: int) -> str:
|
||||||
|
length = len(content)
|
||||||
|
if limit > 0 and length >= limit:
|
||||||
|
return content[:limit] + ("...(shortened because original length %d > limit %d)" % (length, limit))
|
||||||
|
else:
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def textwrap_str(content: str, limit: int = 2000) -> str:
|
||||||
|
# TODO: add support for config option and prefix
|
||||||
|
return textwrap.indent(limit_str(content, limit), " ", lambda line: True)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
||||||
|
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
#
|
#
|
||||||
# This library is free software: you can redistribute it and/or modify
|
# This library is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -32,4 +33,4 @@ class Web(web.BaseWeb):
|
|||||||
assert pathutils.sanitize_path(path) == path
|
assert pathutils.sanitize_path(path) == path
|
||||||
if path != "/.web":
|
if path != "/.web":
|
||||||
return httputils.redirect(base_prefix + "/.web")
|
return httputils.redirect(base_prefix + "/.web")
|
||||||
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!"
|
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!", None
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Copyright © 2008 Nicolas Kandel
|
# Copyright © 2008 Nicolas Kandel
|
||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2015 Guillaume Ayoub
|
# Copyright © 2008-2015 Guillaume Ayoub
|
||||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||||
|
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
#
|
#
|
||||||
# This library is free software: you can redistribute it and/or modify
|
# This library is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -26,7 +27,7 @@ import copy
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from http import client
|
from http import client
|
||||||
from typing import Dict, Mapping, Optional
|
from typing import Dict, Mapping, Optional, Union
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from radicale import item, pathutils
|
from radicale import item, pathutils
|
||||||
@@ -56,7 +57,7 @@ for short, url in NAMESPACES.items():
|
|||||||
ET.register_namespace("" if short == "D" else short, url)
|
ET.register_namespace("" if short == "D" else short, url)
|
||||||
|
|
||||||
|
|
||||||
def pretty_xml(element: ET.Element) -> str:
|
def pretty_xml(element: Union[ET.Element, None]) -> str:
|
||||||
"""Indent an ElementTree ``element`` and its children."""
|
"""Indent an ElementTree ``element`` and its children."""
|
||||||
def pretty_xml_recursive(element: ET.Element, level: int) -> None:
|
def pretty_xml_recursive(element: ET.Element, level: int) -> None:
|
||||||
indent = "\n" + level * " "
|
indent = "\n" + level * " "
|
||||||
@@ -71,6 +72,9 @@ def pretty_xml(element: ET.Element) -> str:
|
|||||||
sub_element.tail = indent
|
sub_element.tail = indent
|
||||||
elif level > 0 and not (element.tail or "").strip():
|
elif level > 0 and not (element.tail or "").strip():
|
||||||
element.tail = indent
|
element.tail = indent
|
||||||
|
|
||||||
|
if element is None:
|
||||||
|
return ""
|
||||||
element = copy.deepcopy(element)
|
element = copy.deepcopy(element)
|
||||||
pretty_xml_recursive(element, 0)
|
pretty_xml_recursive(element, 0)
|
||||||
return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")
|
return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")
|
||||||
|
|||||||
Reference in New Issue
Block a user