plain 0.69.0__py3-none-any.whl → 0.70.0__py3-none-any.whl
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.
- plain/AGENTS.md +1 -1
- plain/CHANGELOG.md +11 -0
- plain/assets/compile.py +20 -7
- plain/assets/finders.py +15 -11
- plain/assets/fingerprints.py +6 -5
- plain/assets/urls.py +1 -1
- plain/assets/views.py +23 -17
- plain/chores/registry.py +14 -9
- plain/cli/agent/__init__.py +1 -1
- plain/cli/agent/docs.py +7 -6
- plain/cli/agent/llmdocs.py +18 -8
- plain/cli/agent/md.py +19 -14
- plain/cli/agent/prompt.py +1 -1
- plain/cli/agent/request.py +37 -17
- plain/cli/build.py +2 -2
- plain/cli/changelog.py +8 -4
- plain/cli/chores.py +4 -4
- plain/cli/core.py +8 -5
- plain/cli/docs.py +2 -2
- plain/cli/formatting.py +10 -7
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +3 -3
- plain/cli/print.py +1 -1
- plain/cli/registry.py +10 -6
- plain/cli/scaffold.py +1 -1
- plain/cli/settings.py +1 -1
- plain/cli/shell.py +10 -7
- plain/cli/startup.py +3 -3
- plain/cli/urls.py +10 -4
- plain/cli/utils.py +2 -2
- plain/csrf/middleware.py +15 -5
- plain/csrf/views.py +11 -8
- plain/debug.py +5 -2
- plain/exceptions.py +19 -8
- plain/forms/__init__.py +1 -1
- plain/forms/boundfield.py +14 -7
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +139 -97
- plain/forms/forms.py +55 -39
- plain/http/cookie.py +15 -7
- plain/http/multipartparser.py +50 -30
- plain/http/request.py +97 -73
- plain/http/response.py +99 -80
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +34 -18
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +23 -5
- plain/internal/files/uploadedfile.py +42 -26
- plain/internal/files/uploadhandler.py +48 -27
- plain/internal/files/utils.py +13 -6
- plain/internal/handlers/base.py +20 -6
- plain/internal/handlers/exception.py +19 -5
- plain/internal/handlers/wsgi.py +30 -18
- plain/internal/middleware/headers.py +11 -2
- plain/internal/middleware/hosts.py +10 -2
- plain/internal/middleware/https.py +13 -3
- plain/internal/middleware/slash.py +15 -5
- plain/json.py +2 -1
- plain/logs/configure.py +3 -1
- plain/logs/debug.py +16 -5
- plain/logs/formatters.py +6 -3
- plain/logs/loggers.py +56 -52
- plain/logs/utils.py +19 -9
- plain/packages/config.py +14 -6
- plain/packages/registry.py +27 -12
- plain/paginator.py +31 -21
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +25 -10
- plain/preflight/results.py +10 -4
- plain/preflight/security.py +7 -5
- plain/preflight/urls.py +4 -1
- plain/runtime/__init__.py +4 -3
- plain/runtime/global_settings.py +1 -1
- plain/runtime/user_settings.py +26 -17
- plain/runtime/utils.py +1 -1
- plain/signals/dispatch/dispatcher.py +39 -17
- plain/signing.py +49 -30
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +4 -3
- plain/templates/jinja/extensions.py +9 -3
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +1 -1
- plain/test/client.py +246 -174
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +10 -2
- plain/urls/converters.py +13 -10
- plain/urls/patterns.py +32 -20
- plain/urls/resolvers.py +32 -22
- plain/urls/utils.py +5 -1
- plain/utils/cache.py +14 -8
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +84 -54
- plain/utils/dateparse.py +10 -7
- plain/utils/deconstruct.py +12 -4
- plain/utils/decorators.py +5 -1
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +62 -47
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +21 -14
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +23 -13
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +34 -18
- plain/utils/timezone.py +30 -19
- plain/utils/tree.py +31 -18
- plain/validators.py +71 -44
- plain/views/base.py +16 -6
- plain/views/errors.py +11 -4
- plain/views/exceptions.py +4 -1
- plain/views/objects.py +15 -15
- plain/views/redirect.py +14 -10
- plain/views/templates.py +1 -1
- plain/wsgi.py +3 -1
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
- plain-0.70.0.dist-info/RECORD +169 -0
- plain-0.69.0.dist-info/RECORD +0 -169
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
- {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,8 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import logging
|
2
4
|
from functools import wraps
|
5
|
+
from typing import TYPE_CHECKING
|
3
6
|
|
4
7
|
from plain import signals
|
5
8
|
from plain.exceptions import (
|
@@ -17,8 +20,15 @@ from plain.runtime import settings
|
|
17
20
|
from plain.utils.module_loading import import_string
|
18
21
|
from plain.views.errors import ErrorView
|
19
22
|
|
23
|
+
if TYPE_CHECKING:
|
24
|
+
from collections.abc import Callable
|
25
|
+
|
26
|
+
from plain.http import HttpRequest, Response
|
27
|
+
|
20
28
|
|
21
|
-
def convert_exception_to_response(
|
29
|
+
def convert_exception_to_response(
|
30
|
+
get_response: Callable[[HttpRequest], Response],
|
31
|
+
) -> Callable[[HttpRequest], Response]:
|
22
32
|
"""
|
23
33
|
Wrap the given get_response callable in exception-to-response conversion.
|
24
34
|
|
@@ -33,7 +43,7 @@ def convert_exception_to_response(get_response):
|
|
33
43
|
"""
|
34
44
|
|
35
45
|
@wraps(get_response)
|
36
|
-
def inner(request):
|
46
|
+
def inner(request: HttpRequest) -> Response:
|
37
47
|
try:
|
38
48
|
response = get_response(request)
|
39
49
|
except Exception as exc:
|
@@ -43,7 +53,7 @@ def convert_exception_to_response(get_response):
|
|
43
53
|
return inner
|
44
54
|
|
45
55
|
|
46
|
-
def response_for_exception(request, exc):
|
56
|
+
def response_for_exception(request: HttpRequest, exc: Exception) -> Response:
|
47
57
|
if isinstance(exc, Http404):
|
48
58
|
response = get_exception_response(
|
49
59
|
request=request, status_code=404, exception=None
|
@@ -120,7 +130,9 @@ def response_for_exception(request, exc):
|
|
120
130
|
return response
|
121
131
|
|
122
132
|
|
123
|
-
def get_exception_response(
|
133
|
+
def get_exception_response(
|
134
|
+
*, request: HttpRequest, status_code: int, exception: Exception | None
|
135
|
+
) -> Response:
|
124
136
|
try:
|
125
137
|
view_class = get_error_view(status_code=status_code, exception=exception)
|
126
138
|
return view_class(request)
|
@@ -135,7 +147,9 @@ def get_exception_response(*, request, status_code, exception):
|
|
135
147
|
return ResponseServerError()
|
136
148
|
|
137
149
|
|
138
|
-
def get_error_view(
|
150
|
+
def get_error_view(
|
151
|
+
*, status_code: int, exception: Exception | None
|
152
|
+
) -> Callable[[HttpRequest], Response]:
|
139
153
|
views_by_status = settings.HTTP_ERROR_VIEWS
|
140
154
|
if status_code in views_by_status:
|
141
155
|
view = views_by_status[status_code]
|
plain/internal/handlers/wsgi.py
CHANGED
@@ -1,13 +1,21 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import uuid
|
2
4
|
from functools import cached_property
|
3
5
|
from io import IOBase
|
6
|
+
from typing import TYPE_CHECKING
|
4
7
|
from urllib.parse import quote
|
5
8
|
|
6
9
|
from plain import signals
|
7
10
|
from plain.http import HttpRequest, QueryDict, parse_cookie
|
8
11
|
from plain.internal.handlers import base
|
12
|
+
from plain.utils.datastructures import MultiValueDict
|
9
13
|
from plain.utils.regex_helper import _lazy_re_compile
|
10
14
|
|
15
|
+
if TYPE_CHECKING:
|
16
|
+
from collections.abc import Callable, Iterable
|
17
|
+
from typing import Any
|
18
|
+
|
11
19
|
_slashes_re = _lazy_re_compile(rb"/+")
|
12
20
|
|
13
21
|
|
@@ -19,13 +27,13 @@ class LimitedStream(IOBase):
|
|
19
27
|
See https://github.com/pallets/werkzeug/blob/dbf78f67/src/werkzeug/wsgi.py#L828
|
20
28
|
"""
|
21
29
|
|
22
|
-
def __init__(self, stream, limit):
|
30
|
+
def __init__(self, stream: Any, limit: int) -> None:
|
23
31
|
self._read = stream.read
|
24
32
|
self._readline = stream.readline
|
25
33
|
self._pos = 0
|
26
34
|
self.limit = limit
|
27
35
|
|
28
|
-
def read(self, size
|
36
|
+
def read(self, size: int = -1, /) -> bytes:
|
29
37
|
_pos = self._pos
|
30
38
|
limit = self.limit
|
31
39
|
if _pos >= limit:
|
@@ -38,7 +46,7 @@ class LimitedStream(IOBase):
|
|
38
46
|
self._pos += len(data)
|
39
47
|
return data
|
40
48
|
|
41
|
-
def readline(self, size
|
49
|
+
def readline(self, size: int = -1, /) -> bytes:
|
42
50
|
_pos = self._pos
|
43
51
|
limit = self.limit
|
44
52
|
if _pos >= limit:
|
@@ -56,7 +64,7 @@ class WSGIRequest(HttpRequest):
|
|
56
64
|
non_picklable_attrs = HttpRequest.non_picklable_attrs | frozenset(["environ"])
|
57
65
|
meta_non_picklable_attrs = frozenset(["wsgi.errors", "wsgi.input"])
|
58
66
|
|
59
|
-
def __init__(self, environ):
|
67
|
+
def __init__(self, environ: dict[str, Any]) -> None:
|
60
68
|
# A unique ID we can use to trace this request
|
61
69
|
self.unique_id = str(uuid.uuid4())
|
62
70
|
|
@@ -86,37 +94,37 @@ class WSGIRequest(HttpRequest):
|
|
86
94
|
self._read_started = False
|
87
95
|
self.resolver_match = None
|
88
96
|
|
89
|
-
def __getstate__(self):
|
97
|
+
def __getstate__(self) -> dict[str, Any]:
|
90
98
|
state = super().__getstate__()
|
91
99
|
for attr in self.meta_non_picklable_attrs:
|
92
100
|
if attr in state["meta"]:
|
93
101
|
del state["meta"][attr]
|
94
102
|
return state
|
95
103
|
|
96
|
-
def _get_scheme(self):
|
104
|
+
def _get_scheme(self) -> str | None:
|
97
105
|
return self.environ.get("wsgi.url_scheme")
|
98
106
|
|
99
107
|
@cached_property
|
100
|
-
def query_params(self):
|
108
|
+
def query_params(self) -> QueryDict:
|
101
109
|
# The WSGI spec says 'QUERY_STRING' may be absent.
|
102
110
|
raw_query_string = get_bytes_from_wsgi(self.environ, "QUERY_STRING", "")
|
103
111
|
return QueryDict(raw_query_string, encoding=self._encoding)
|
104
112
|
|
105
|
-
def _get_data(self):
|
113
|
+
def _get_data(self) -> QueryDict:
|
106
114
|
if not hasattr(self, "_data"):
|
107
115
|
self._load_data_and_files()
|
108
116
|
return self._data
|
109
117
|
|
110
|
-
def _set_data(self, data):
|
118
|
+
def _set_data(self, data: QueryDict) -> None:
|
111
119
|
self._data = data
|
112
120
|
|
113
121
|
@cached_property
|
114
|
-
def cookies(self):
|
122
|
+
def cookies(self) -> dict[str, str]:
|
115
123
|
raw_cookie = get_str_from_wsgi(self.environ, "HTTP_COOKIE", "")
|
116
124
|
return parse_cookie(raw_cookie)
|
117
125
|
|
118
126
|
@property
|
119
|
-
def files(self):
|
127
|
+
def files(self) -> MultiValueDict:
|
120
128
|
if not hasattr(self, "_files"):
|
121
129
|
self._load_data_and_files()
|
122
130
|
return self._files
|
@@ -125,11 +133,15 @@ class WSGIRequest(HttpRequest):
|
|
125
133
|
|
126
134
|
|
127
135
|
class WSGIHandler(base.BaseHandler):
|
128
|
-
def __init__(self, *args, **kwargs):
|
136
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
129
137
|
super().__init__(*args, **kwargs)
|
130
138
|
self.load_middleware()
|
131
139
|
|
132
|
-
def __call__(
|
140
|
+
def __call__(
|
141
|
+
self,
|
142
|
+
environ: dict[str, Any],
|
143
|
+
start_response: Callable[[str, list[tuple[str, str]]], Any],
|
144
|
+
) -> Iterable[bytes]:
|
133
145
|
signals.request_started.send(sender=self.__class__, environ=environ)
|
134
146
|
request = WSGIRequest(environ)
|
135
147
|
response = self.get_response(request)
|
@@ -155,11 +167,11 @@ class WSGIHandler(base.BaseHandler):
|
|
155
167
|
return response
|
156
168
|
|
157
169
|
|
158
|
-
def get_path_info(environ):
|
170
|
+
def get_path_info(environ: dict[str, Any]) -> str:
|
159
171
|
"""Return the HTTP request's PATH_INFO as a string."""
|
160
172
|
path_info = get_bytes_from_wsgi(environ, "PATH_INFO", "/")
|
161
173
|
|
162
|
-
def repercent_broken_unicode(path):
|
174
|
+
def repercent_broken_unicode(path: bytes) -> bytes:
|
163
175
|
"""
|
164
176
|
As per RFC 3987 Section 3.2, step three of converting a URI into an IRI,
|
165
177
|
repercent-encode any octet produced that is not part of a strictly legal
|
@@ -179,7 +191,7 @@ def get_path_info(environ):
|
|
179
191
|
return repercent_broken_unicode(path_info).decode()
|
180
192
|
|
181
193
|
|
182
|
-
def get_script_name(environ):
|
194
|
+
def get_script_name(environ: dict[str, Any]) -> str:
|
183
195
|
"""
|
184
196
|
Return the equivalent of the HTTP request's SCRIPT_NAME environment
|
185
197
|
variable. If Apache mod_rewrite is used, return what would have been
|
@@ -208,7 +220,7 @@ def get_script_name(environ):
|
|
208
220
|
return script_name.decode()
|
209
221
|
|
210
222
|
|
211
|
-
def get_bytes_from_wsgi(environ, key, default):
|
223
|
+
def get_bytes_from_wsgi(environ: dict[str, Any], key: str, default: str) -> bytes:
|
212
224
|
"""
|
213
225
|
Get a value from the WSGI environ dictionary as bytes.
|
214
226
|
|
@@ -221,7 +233,7 @@ def get_bytes_from_wsgi(environ, key, default):
|
|
221
233
|
return value.encode("iso-8859-1")
|
222
234
|
|
223
235
|
|
224
|
-
def get_str_from_wsgi(environ, key, default):
|
236
|
+
def get_str_from_wsgi(environ: dict[str, Any], key: str, default: str) -> str:
|
225
237
|
"""
|
226
238
|
Get a value from the WSGI environ dictionary as str.
|
227
239
|
|
@@ -1,11 +1,20 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
|
1
5
|
from plain.runtime import settings
|
2
6
|
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from collections.abc import Callable
|
9
|
+
|
10
|
+
from plain.http import HttpRequest, Response
|
11
|
+
|
3
12
|
|
4
13
|
class DefaultHeadersMiddleware:
|
5
|
-
def __init__(self, get_response):
|
14
|
+
def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
|
6
15
|
self.get_response = get_response
|
7
16
|
|
8
|
-
def __call__(self, request):
|
17
|
+
def __call__(self, request: HttpRequest) -> Response:
|
9
18
|
response = self.get_response(request)
|
10
19
|
|
11
20
|
for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
|
@@ -1,10 +1,18 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import ipaddress
|
2
4
|
import logging
|
5
|
+
from typing import TYPE_CHECKING
|
3
6
|
|
4
7
|
from plain.http import HttpRequest, ResponseBadRequest
|
5
8
|
from plain.runtime import settings
|
6
9
|
from plain.utils.regex_helper import _lazy_re_compile
|
7
10
|
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from collections.abc import Callable
|
13
|
+
|
14
|
+
from plain.http import Response
|
15
|
+
|
8
16
|
logger = logging.getLogger(__name__)
|
9
17
|
|
10
18
|
host_validation_re = _lazy_re_compile(
|
@@ -21,10 +29,10 @@ class HostValidationMiddleware:
|
|
21
29
|
host is not allowed.
|
22
30
|
"""
|
23
31
|
|
24
|
-
def __init__(self, get_response):
|
32
|
+
def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
|
25
33
|
self.get_response = get_response
|
26
34
|
|
27
|
-
def __call__(self, request):
|
35
|
+
def __call__(self, request: HttpRequest) -> Response:
|
28
36
|
if not is_host_valid(request):
|
29
37
|
host = request.host
|
30
38
|
msg = f"Invalid HTTP_HOST header: {host!r}."
|
@@ -1,15 +1,24 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
|
1
5
|
from plain.http import ResponseRedirect
|
2
6
|
from plain.runtime import settings
|
3
7
|
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from collections.abc import Callable
|
10
|
+
|
11
|
+
from plain.http import HttpRequest, Response
|
12
|
+
|
4
13
|
|
5
14
|
class HttpsRedirectMiddleware:
|
6
|
-
def __init__(self, get_response):
|
15
|
+
def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
|
7
16
|
self.get_response = get_response
|
8
17
|
|
9
18
|
# Settings for HTTPS
|
10
19
|
self.https_redirect_enabled = settings.HTTPS_REDIRECT_ENABLED
|
11
20
|
|
12
|
-
def __call__(self, request):
|
21
|
+
def __call__(self, request: HttpRequest) -> Response:
|
13
22
|
"""
|
14
23
|
Perform a blanket HTTP→HTTPS redirect when enabled.
|
15
24
|
"""
|
@@ -19,8 +28,9 @@ class HttpsRedirectMiddleware:
|
|
19
28
|
|
20
29
|
return self.get_response(request)
|
21
30
|
|
22
|
-
def maybe_https_redirect(self, request):
|
31
|
+
def maybe_https_redirect(self, request: HttpRequest) -> Response | None:
|
23
32
|
if self.https_redirect_enabled and not request.is_https():
|
24
33
|
return ResponseRedirect(
|
25
34
|
f"https://{request.host}{request.get_full_path()}", status_code=301
|
26
35
|
)
|
36
|
+
return None
|
@@ -1,14 +1,24 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
|
1
5
|
from plain.http import ResponseRedirect
|
2
6
|
from plain.runtime import settings
|
3
7
|
from plain.urls import Resolver404, get_resolver
|
4
8
|
from plain.utils.http import escape_leading_slashes
|
5
9
|
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from collections.abc import Callable
|
12
|
+
|
13
|
+
from plain.http import HttpRequest, Response
|
14
|
+
from plain.urls import ResolverMatch
|
15
|
+
|
6
16
|
|
7
17
|
class RedirectSlashMiddleware:
|
8
|
-
def __init__(self, get_response):
|
18
|
+
def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
|
9
19
|
self.get_response = get_response
|
10
20
|
|
11
|
-
def __call__(self, request):
|
21
|
+
def __call__(self, request: HttpRequest) -> Response:
|
12
22
|
"""
|
13
23
|
Rewrite the URL based on settings.APPEND_SLASH
|
14
24
|
"""
|
@@ -29,7 +39,7 @@ class RedirectSlashMiddleware:
|
|
29
39
|
return response
|
30
40
|
|
31
41
|
@staticmethod
|
32
|
-
def _is_valid_path(path):
|
42
|
+
def _is_valid_path(path: str) -> ResolverMatch | bool:
|
33
43
|
"""
|
34
44
|
Return the ResolverMatch if the given path resolves against the default URL
|
35
45
|
resolver, False otherwise. This is a convenience method to make working
|
@@ -40,7 +50,7 @@ class RedirectSlashMiddleware:
|
|
40
50
|
except Resolver404:
|
41
51
|
return False
|
42
52
|
|
43
|
-
def should_redirect_with_slash(self, request):
|
53
|
+
def should_redirect_with_slash(self, request: HttpRequest) -> ResolverMatch | bool:
|
44
54
|
"""
|
45
55
|
Return True if settings.APPEND_SLASH is True and appending a slash to
|
46
56
|
the request path turns an invalid path into a valid one.
|
@@ -50,7 +60,7 @@ class RedirectSlashMiddleware:
|
|
50
60
|
return self._is_valid_path(f"{request.path_info}/")
|
51
61
|
return False
|
52
62
|
|
53
|
-
def get_full_path_with_slash(self, request):
|
63
|
+
def get_full_path_with_slash(self, request: HttpRequest) -> str:
|
54
64
|
"""
|
55
65
|
Return the full path of the request with a trailing slash appended.
|
56
66
|
|
plain/json.py
CHANGED
@@ -2,6 +2,7 @@ import datetime
|
|
2
2
|
import decimal
|
3
3
|
import json
|
4
4
|
import uuid
|
5
|
+
from typing import Any
|
5
6
|
|
6
7
|
from plain.utils.duration import duration_iso_string
|
7
8
|
from plain.utils.functional import Promise
|
@@ -14,7 +15,7 @@ class PlainJSONEncoder(json.JSONEncoder):
|
|
14
15
|
UUIDs.
|
15
16
|
"""
|
16
17
|
|
17
|
-
def default(self, o):
|
18
|
+
def default(self, o: Any) -> Any:
|
18
19
|
# See "Date Time String Format" in the ECMA-262 specification.
|
19
20
|
if isinstance(o, datetime.datetime):
|
20
21
|
r = o.isoformat()
|
plain/logs/configure.py
CHANGED
@@ -3,7 +3,9 @@ import logging
|
|
3
3
|
from .formatters import JSONFormatter, KeyValueFormatter
|
4
4
|
|
5
5
|
|
6
|
-
def configure_logging(
|
6
|
+
def configure_logging(
|
7
|
+
*, plain_log_level: int | str, app_log_level: int | str, app_log_format: str
|
8
|
+
) -> None:
|
7
9
|
# Create and configure the plain logger (uses standard Logger, not AppLogger)
|
8
10
|
plain_logger = logging.Logger("plain")
|
9
11
|
plain_logger.setLevel(plain_log_level)
|
plain/logs/debug.py
CHANGED
@@ -1,26 +1,37 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import logging
|
2
4
|
import threading
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from types import TracebackType
|
3
9
|
|
4
10
|
|
5
11
|
class DebugMode:
|
6
12
|
"""Context manager to temporarily set DEBUG level on a logger with reference counting."""
|
7
13
|
|
8
|
-
def __init__(self, logger):
|
14
|
+
def __init__(self, logger: logging.Logger):
|
9
15
|
self.logger = logger
|
10
16
|
self.original_level = None
|
11
17
|
self._ref_count = 0
|
12
18
|
self._lock = threading.Lock()
|
13
19
|
|
14
|
-
def __enter__(self):
|
20
|
+
def __enter__(self) -> DebugMode:
|
15
21
|
"""Store original level and set to DEBUG."""
|
16
22
|
self.start()
|
17
23
|
return self
|
18
24
|
|
19
|
-
def __exit__(
|
25
|
+
def __exit__(
|
26
|
+
self,
|
27
|
+
exc_type: type[BaseException] | None,
|
28
|
+
exc_val: BaseException | None,
|
29
|
+
exc_tb: TracebackType | None,
|
30
|
+
) -> None:
|
20
31
|
"""Restore original level."""
|
21
32
|
self.end()
|
22
33
|
|
23
|
-
def start(self):
|
34
|
+
def start(self) -> None:
|
24
35
|
"""Enable DEBUG logging level."""
|
25
36
|
with self._lock:
|
26
37
|
if self._ref_count == 0:
|
@@ -28,7 +39,7 @@ class DebugMode:
|
|
28
39
|
self.logger.setLevel(logging.DEBUG)
|
29
40
|
self._ref_count += 1
|
30
41
|
|
31
|
-
def end(self):
|
42
|
+
def end(self) -> None:
|
32
43
|
"""Restore original logging level."""
|
33
44
|
with self._lock:
|
34
45
|
self._ref_count = max(0, self._ref_count - 1)
|
plain/logs/formatters.py
CHANGED
@@ -1,11 +1,14 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import json
|
2
4
|
import logging
|
5
|
+
from typing import Any
|
3
6
|
|
4
7
|
|
5
8
|
class KeyValueFormatter(logging.Formatter):
|
6
9
|
"""Formatter that outputs key-value pairs from Plain's context system."""
|
7
10
|
|
8
|
-
def format(self, record):
|
11
|
+
def format(self, record: logging.LogRecord) -> str:
|
9
12
|
# Build key-value pairs from context
|
10
13
|
kv_pairs = []
|
11
14
|
|
@@ -22,7 +25,7 @@ class KeyValueFormatter(logging.Formatter):
|
|
22
25
|
return super().format(record)
|
23
26
|
|
24
27
|
@staticmethod
|
25
|
-
def _format_value(value):
|
28
|
+
def _format_value(value: Any) -> str:
|
26
29
|
"""Format a value for key-value output."""
|
27
30
|
if isinstance(value, str):
|
28
31
|
s = value
|
@@ -46,7 +49,7 @@ class KeyValueFormatter(logging.Formatter):
|
|
46
49
|
class JSONFormatter(logging.Formatter):
|
47
50
|
"""Formatter that outputs JSON from Plain's context system, with optional format string."""
|
48
51
|
|
49
|
-
def format(self, record):
|
52
|
+
def format(self, record: logging.LogRecord) -> str:
|
50
53
|
# Build the JSON object from Plain's context data
|
51
54
|
log_obj = {
|
52
55
|
"timestamp": self.formatTime(record),
|
plain/logs/loggers.py
CHANGED
@@ -1,5 +1,9 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import logging
|
4
|
+
from collections.abc import Generator
|
2
5
|
from contextlib import contextmanager
|
6
|
+
from typing import Any
|
3
7
|
|
4
8
|
from .debug import DebugMode
|
5
9
|
|
@@ -7,13 +11,13 @@ from .debug import DebugMode
|
|
7
11
|
class AppLogger(logging.Logger):
|
8
12
|
"""Enhanced logger that supports kwargs-style logging and context management."""
|
9
13
|
|
10
|
-
def __init__(self, name):
|
14
|
+
def __init__(self, name: str):
|
11
15
|
super().__init__(name)
|
12
16
|
self.context = {} # Public, mutable context dict
|
13
17
|
self.debug_mode = DebugMode(self)
|
14
18
|
|
15
19
|
@contextmanager
|
16
|
-
def include_context(self, **kwargs):
|
20
|
+
def include_context(self, **kwargs: Any) -> Generator[None, None, None]:
|
17
21
|
"""Context manager for temporary context."""
|
18
22
|
# Store original context
|
19
23
|
original_context = self.context.copy()
|
@@ -27,21 +31,21 @@ class AppLogger(logging.Logger):
|
|
27
31
|
# Restore original context
|
28
32
|
self.context = original_context
|
29
33
|
|
30
|
-
def force_debug(self):
|
34
|
+
def force_debug(self) -> DebugMode:
|
31
35
|
"""Return context manager for temporarily enabling DEBUG level logging."""
|
32
36
|
return self.debug_mode
|
33
37
|
|
34
38
|
# Override logging methods with explicit parameters for IDE support
|
35
39
|
def debug(
|
36
40
|
self,
|
37
|
-
msg,
|
38
|
-
*args,
|
39
|
-
exc_info=None,
|
40
|
-
extra=None,
|
41
|
-
stack_info=False,
|
42
|
-
stacklevel=1,
|
43
|
-
**context,
|
44
|
-
):
|
41
|
+
msg: object,
|
42
|
+
*args: object,
|
43
|
+
exc_info: Any = None,
|
44
|
+
extra: dict[str, Any] | None = None,
|
45
|
+
stack_info: bool = False,
|
46
|
+
stacklevel: int = 1,
|
47
|
+
**context: Any,
|
48
|
+
) -> None:
|
45
49
|
if self.isEnabledFor(logging.DEBUG):
|
46
50
|
self._log(
|
47
51
|
logging.DEBUG,
|
@@ -56,14 +60,14 @@ class AppLogger(logging.Logger):
|
|
56
60
|
|
57
61
|
def info(
|
58
62
|
self,
|
59
|
-
msg,
|
60
|
-
*args,
|
61
|
-
exc_info=None,
|
62
|
-
extra=None,
|
63
|
-
stack_info=False,
|
64
|
-
stacklevel=1,
|
65
|
-
**context,
|
66
|
-
):
|
63
|
+
msg: object,
|
64
|
+
*args: object,
|
65
|
+
exc_info: Any = None,
|
66
|
+
extra: dict[str, Any] | None = None,
|
67
|
+
stack_info: bool = False,
|
68
|
+
stacklevel: int = 1,
|
69
|
+
**context: Any,
|
70
|
+
) -> None:
|
67
71
|
if self.isEnabledFor(logging.INFO):
|
68
72
|
self._log(
|
69
73
|
logging.INFO,
|
@@ -78,14 +82,14 @@ class AppLogger(logging.Logger):
|
|
78
82
|
|
79
83
|
def warning(
|
80
84
|
self,
|
81
|
-
msg,
|
82
|
-
*args,
|
83
|
-
exc_info=None,
|
84
|
-
extra=None,
|
85
|
-
stack_info=False,
|
86
|
-
stacklevel=1,
|
87
|
-
**context,
|
88
|
-
):
|
85
|
+
msg: object,
|
86
|
+
*args: object,
|
87
|
+
exc_info: Any = None,
|
88
|
+
extra: dict[str, Any] | None = None,
|
89
|
+
stack_info: bool = False,
|
90
|
+
stacklevel: int = 1,
|
91
|
+
**context: Any,
|
92
|
+
) -> None:
|
89
93
|
if self.isEnabledFor(logging.WARNING):
|
90
94
|
self._log(
|
91
95
|
logging.WARNING,
|
@@ -100,14 +104,14 @@ class AppLogger(logging.Logger):
|
|
100
104
|
|
101
105
|
def error(
|
102
106
|
self,
|
103
|
-
msg,
|
104
|
-
*args,
|
105
|
-
exc_info=None,
|
106
|
-
extra=None,
|
107
|
-
stack_info=False,
|
108
|
-
stacklevel=1,
|
109
|
-
**context,
|
110
|
-
):
|
107
|
+
msg: object,
|
108
|
+
*args: object,
|
109
|
+
exc_info: Any = None,
|
110
|
+
extra: dict[str, Any] | None = None,
|
111
|
+
stack_info: bool = False,
|
112
|
+
stacklevel: int = 1,
|
113
|
+
**context: Any,
|
114
|
+
) -> None:
|
111
115
|
if self.isEnabledFor(logging.ERROR):
|
112
116
|
self._log(
|
113
117
|
logging.ERROR,
|
@@ -122,14 +126,14 @@ class AppLogger(logging.Logger):
|
|
122
126
|
|
123
127
|
def critical(
|
124
128
|
self,
|
125
|
-
msg,
|
126
|
-
*args,
|
127
|
-
exc_info=None,
|
128
|
-
extra=None,
|
129
|
-
stack_info=False,
|
130
|
-
stacklevel=1,
|
131
|
-
**context,
|
132
|
-
):
|
129
|
+
msg: object,
|
130
|
+
*args: object,
|
131
|
+
exc_info: Any = None,
|
132
|
+
extra: dict[str, Any] | None = None,
|
133
|
+
stack_info: bool = False,
|
134
|
+
stacklevel: int = 1,
|
135
|
+
**context: Any,
|
136
|
+
) -> None:
|
133
137
|
if self.isEnabledFor(logging.CRITICAL):
|
134
138
|
self._log(
|
135
139
|
logging.CRITICAL,
|
@@ -144,15 +148,15 @@ class AppLogger(logging.Logger):
|
|
144
148
|
|
145
149
|
def _log(
|
146
150
|
self,
|
147
|
-
level,
|
148
|
-
msg,
|
149
|
-
args,
|
150
|
-
exc_info=None,
|
151
|
-
extra=None,
|
152
|
-
stack_info=False,
|
153
|
-
stacklevel=1,
|
154
|
-
**context,
|
155
|
-
):
|
151
|
+
level: int,
|
152
|
+
msg: object,
|
153
|
+
args: tuple[object, ...],
|
154
|
+
exc_info: Any = None,
|
155
|
+
extra: dict[str, Any] | None = None,
|
156
|
+
stack_info: bool = False,
|
157
|
+
stacklevel: int = 1,
|
158
|
+
**context: Any,
|
159
|
+
) -> None:
|
156
160
|
"""Low-level logging routine which creates a LogRecord and then calls all handlers."""
|
157
161
|
# Check if extra already has a 'context' key
|
158
162
|
if extra and "context" in extra:
|