plain 0.78.1__py3-none-any.whl → 0.79.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.

Potentially problematic release.


This version of plain might be problematic. Click here for more details.

plain/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.79.0](https://github.com/dropseed/plain/releases/plain@0.79.0) (2025-10-22)
4
+
5
+ ### What's changed
6
+
7
+ - Response objects now have an `exception` attribute that stores the exception that caused 5xx errors ([0a243ba89c](https://github.com/dropseed/plain/commit/0a243ba89c))
8
+ - Middleware classes now use an abstract base class `HttpMiddleware` with a `process_request()` method ([b960eed6c6](https://github.com/dropseed/plain/commit/b960eed6c6))
9
+ - CSRF middleware now raises `PermissionDenied` instead of rendering a custom `CsrfFailureView` ([d4b93e59b3](https://github.com/dropseed/plain/commit/d4b93e59b3))
10
+ - The `HTTP_ERROR_VIEWS` setting has been removed ([7a4e3a31f4](https://github.com/dropseed/plain/commit/7a4e3a31f4))
11
+ - Standalone `plain-changelog` and `plain-upgrade` executables have been removed in favor of the built-in commands ([07c3a4c540](https://github.com/dropseed/plain/commit/07c3a4c540))
12
+ - Standalone `plain-build` executable has been removed ([99301ea797](https://github.com/dropseed/plain/commit/99301ea797))
13
+ - Removed automatic logging of all HTTP 400+ status codes for cleaner logs ([c2769d7281](https://github.com/dropseed/plain/commit/c2769d7281))
14
+
15
+ ### Upgrade instructions
16
+
17
+ - If you have custom middleware, inherit from `HttpMiddleware` and rename your `__call__()` method to `process_request()`:
18
+
19
+ ```python
20
+ # Before:
21
+ class MyMiddleware:
22
+ def __init__(self, get_response):
23
+ self.get_response = get_response
24
+
25
+ def __call__(self, request):
26
+ response = self.get_response(request)
27
+ return response
28
+
29
+ # After:
30
+ from plain.http import HttpMiddleware
31
+
32
+ class MyMiddleware(HttpMiddleware):
33
+ def process_request(self, request):
34
+ response = self.get_response(request)
35
+ return response
36
+ ```
37
+
38
+ - Remove any custom `HTTP_ERROR_VIEWS` setting from your configuration - error views are now controlled entirely by exception handlers
39
+ - If you were calling `plain-changelog` or `plain-upgrade` as standalone commands, use `plain changelog` or `plain upgrade` instead
40
+ - If you were calling `plain-build` as a standalone command, use `plain build` instead
41
+
42
+ ## [0.78.2](https://github.com/dropseed/plain/releases/plain@0.78.2) (2025-10-20)
43
+
44
+ ### What's changed
45
+
46
+ - Updated package metadata to use `[dependency-groups]` instead of `[tool.uv]` for development dependencies, following PEP 735 standard ([1b43a3a272](https://github.com/dropseed/plain/commit/1b43a3a272))
47
+
48
+ ### Upgrade instructions
49
+
50
+ - No changes required
51
+
3
52
  ## [0.78.1](https://github.com/dropseed/plain/releases/plain@0.78.1) (2025-10-17)
4
53
 
5
54
  ### What's changed
plain/cli/changelog.py CHANGED
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import click
8
8
 
9
9
  from .output import style_markdown
10
+ from .runtime import without_runtime_setup
10
11
 
11
12
 
12
13
  def parse_version(version_str: str) -> tuple[int, ...]:
@@ -42,6 +43,7 @@ def compare_versions(v1: str, v2: str) -> int:
42
43
  return 0
43
44
 
44
45
 
46
+ @without_runtime_setup
45
47
  @click.command("changelog")
46
48
  @click.argument("package_label")
47
49
  @click.option("--from", "from_version", help="Show entries from this version onwards")
plain/cli/upgrade.py CHANGED
@@ -6,10 +6,12 @@ from pathlib import Path
6
6
  import click
7
7
 
8
8
  from .agent.prompt import prompt_agent
9
+ from .runtime import without_runtime_setup
9
10
 
10
11
  LOCK_FILE = Path("uv.lock")
11
12
 
12
13
 
14
+ @without_runtime_setup
13
15
  @click.command()
14
16
  @click.argument("packages", nargs=-1)
15
17
  @click.option(
@@ -144,7 +146,7 @@ def build_prompt(before_after: dict[str, tuple[str | None, str | None]]) -> str:
144
146
  "## Instructions",
145
147
  "",
146
148
  "1. **Process each package systematically:**",
147
- " - For each package, run: `uv run plain-changelog {package} --from {before} --to {after}`",
149
+ " - For each package, run: `uv run plain changelog {package} --from {before} --to {after}`",
148
150
  " - Read the 'Upgrade instructions' section carefully",
149
151
  " - If it says 'No changes required', skip to the next package",
150
152
  " - Apply any required code changes as specified",
plain/csrf/middleware.py CHANGED
@@ -1,24 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
4
3
  import re
5
4
  from collections.abc import Callable
6
5
  from typing import TYPE_CHECKING
7
6
  from urllib.parse import urlparse
8
7
 
9
- from plain.logs.utils import log_response
8
+ from plain.exceptions import PermissionDenied
9
+ from plain.http import HttpMiddleware
10
10
  from plain.runtime import settings
11
11
 
12
- from .views import CsrfFailureView
13
-
14
12
  if TYPE_CHECKING:
15
- from plain.http import Response
16
- from plain.http.request import Request
17
-
18
- logger = logging.getLogger("plain.security.csrf")
13
+ from plain.http import Request, Response
19
14
 
20
15
 
21
- class CsrfViewMiddleware:
16
+ class CsrfViewMiddleware(HttpMiddleware):
22
17
  """
23
18
  Modern CSRF protection middleware using Sec-Fetch-Site headers and origin validation.
24
19
  Based on Filippo Valsorda's 2025 research (https://words.filippo.io/csrf/).
@@ -28,20 +23,20 @@ class CsrfViewMiddleware:
28
23
  """
29
24
 
30
25
  def __init__(self, get_response: Callable[[Request], Response]):
31
- self.get_response = get_response
26
+ super().__init__(get_response)
32
27
 
33
28
  # Compile CSRF exempt patterns once for performance
34
29
  self.csrf_exempt_patterns: list[re.Pattern[str]] = [
35
30
  re.compile(r) for r in settings.CSRF_EXEMPT_PATHS
36
31
  ]
37
32
 
38
- def __call__(self, request: Request) -> Response:
33
+ def process_request(self, request: Request) -> Response:
39
34
  allowed, reason = self.should_allow_request(request)
40
35
 
41
- if allowed:
42
- return self.get_response(request)
43
- else:
44
- return self.reject(request, reason)
36
+ if not allowed:
37
+ raise PermissionDenied(reason)
38
+
39
+ return self.get_response(request)
45
40
 
46
41
  def should_allow_request(self, request: Request) -> tuple[bool, str]:
47
42
  # 1. Allow safe methods (GET, HEAD, OPTIONS)
@@ -127,17 +122,3 @@ class CsrfViewMiddleware:
127
122
  False,
128
123
  f"Cross-origin request detected - Origin {origin} does not match Host",
129
124
  )
130
-
131
- def reject(self, request: Request, reason: str) -> Response:
132
- """Reject a request with a 403 Forbidden response."""
133
-
134
- response = CsrfFailureView.as_view()(request, reason=reason)
135
- log_response(
136
- "Forbidden (%s): %s",
137
- reason,
138
- request.path,
139
- response=response,
140
- request=request,
141
- logger=logger,
142
- )
143
- return response
plain/http/__init__.py CHANGED
@@ -1,12 +1,13 @@
1
- from plain.http.cookie import parse_cookie
2
- from plain.http.request import (
1
+ from .cookie import parse_cookie
2
+ from .middleware import HttpMiddleware
3
+ from .request import (
3
4
  QueryDict,
4
5
  RawPostDataException,
5
6
  Request,
6
7
  RequestHeaders,
7
8
  UnreadablePostError,
8
9
  )
9
- from plain.http.response import (
10
+ from .response import (
10
11
  BadHeaderError,
11
12
  FileResponse,
12
13
  Http404,
@@ -25,6 +26,7 @@ from plain.http.response import (
25
26
  )
26
27
 
27
28
  __all__ = [
29
+ "HttpMiddleware",
28
30
  "parse_cookie",
29
31
  "Request",
30
32
  "RequestHeaders",
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from plain.http import Request, Response
9
+
10
+
11
+ class HttpMiddleware(ABC):
12
+ """
13
+ Abstract base class for HTTP middleware.
14
+
15
+ Subclasses must implement process_request() to handle the request/response cycle.
16
+
17
+ Example:
18
+ class MyMiddleware(HttpMiddleware):
19
+ def process_request(self, request: Request) -> Response:
20
+ # Pre-processing
21
+ response = self.get_response(request)
22
+ # Post-processing
23
+ return response
24
+ """
25
+
26
+ def __init__(self, get_response: Callable[[Request], Response]):
27
+ self.get_response = get_response
28
+
29
+ @abstractmethod
30
+ def process_request(self, request: Request) -> Response:
31
+ """Process the request and return a response. Must be implemented by subclasses."""
32
+ ...
plain/http/response.py CHANGED
@@ -148,6 +148,8 @@ class ResponseBase:
148
148
  if not 100 <= self.status_code <= 599:
149
149
  raise ValueError("HTTP status code must be an integer from 100 to 599.")
150
150
  self._reason_phrase = reason
151
+ # Exception that caused this response, if any (primarily for 500 errors)
152
+ self.exception: Exception | None = None
151
153
 
152
154
  @property
153
155
  def reason_phrase(self) -> str:
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
4
3
  import types
5
4
  from typing import TYPE_CHECKING
6
5
 
@@ -8,7 +7,6 @@ from opentelemetry import baggage, trace
8
7
  from opentelemetry.semconv.attributes import http_attributes, url_attributes
9
8
 
10
9
  from plain.exceptions import ImproperlyConfigured
11
- from plain.logs.utils import log_response
12
10
  from plain.runtime import settings
13
11
  from plain.urls import get_resolver
14
12
  from plain.utils.module_loading import import_string
@@ -21,8 +19,6 @@ if TYPE_CHECKING:
21
19
  from plain.http import Request, Response, ResponseBase
22
20
  from plain.urls import ResolverMatch
23
21
 
24
- logger = logging.getLogger("plain.request")
25
-
26
22
 
27
23
  # These middleware classes are always used by Plain.
28
24
  BUILTIN_BEFORE_MIDDLEWARE = [
@@ -66,7 +62,7 @@ class BaseHandler:
66
62
  f"Middleware factory {middleware_path} returned None."
67
63
  )
68
64
 
69
- handler = convert_exception_to_response(mw_instance)
65
+ handler = convert_exception_to_response(mw_instance.process_request)
70
66
 
71
67
  # We only assign to this when initialization is complete as it is used
72
68
  # as a flag for initialization being complete.
@@ -117,14 +113,9 @@ class BaseHandler:
117
113
  else trace.StatusCode.ERROR
118
114
  )
119
115
 
120
- if response.status_code >= 400:
121
- log_response(
122
- "%s: %s",
123
- response.reason_phrase,
124
- request.path,
125
- response=response,
126
- request=request,
127
- )
116
+ if response.exception:
117
+ span.record_exception(response.exception)
118
+
128
119
  return response
129
120
 
130
121
  def _get_response(self, request: Request) -> ResponseBase:
@@ -4,7 +4,6 @@ import logging
4
4
  from functools import wraps
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from plain import signals
8
7
  from plain.exceptions import (
9
8
  BadRequest,
10
9
  PermissionDenied,
@@ -15,9 +14,7 @@ from plain.exceptions import (
15
14
  )
16
15
  from plain.http import Http404, ResponseServerError
17
16
  from plain.http.multipartparser import MultiPartParserError
18
- from plain.logs.utils import log_response
19
17
  from plain.runtime import settings
20
- from plain.utils.module_loading import import_string
21
18
  from plain.views.errors import ErrorView
22
19
 
23
20
  if TYPE_CHECKING:
@@ -26,6 +23,9 @@ if TYPE_CHECKING:
26
23
  from plain.http import Request, Response
27
24
 
28
25
 
26
+ request_logger = logging.getLogger("plain.request")
27
+
28
+
29
29
  def convert_exception_to_response(
30
30
  get_response: Callable[[Request], Response],
31
31
  ) -> Callable[[Request], Response]:
@@ -63,37 +63,34 @@ def response_for_exception(request: Request, exc: Exception) -> Response:
63
63
  response = get_exception_response(
64
64
  request=request, status_code=403, exception=exc
65
65
  )
66
- log_response(
66
+ request_logger.warning(
67
67
  "Forbidden (Permission denied): %s",
68
68
  request.path,
69
- response=response,
70
- request=request,
71
- exception=exc,
69
+ extra={"status_code": response.status_code, "request": request},
70
+ exc_info=exc,
72
71
  )
73
72
 
74
73
  elif isinstance(exc, MultiPartParserError):
75
74
  response = get_exception_response(
76
75
  request=request, status_code=400, exception=None
77
76
  )
78
- log_response(
77
+ request_logger.warning(
79
78
  "Bad request (Unable to parse request body): %s",
80
79
  request.path,
81
- response=response,
82
- request=request,
83
- exception=exc,
80
+ extra={"status_code": response.status_code, "request": request},
81
+ exc_info=exc,
84
82
  )
85
83
 
86
84
  elif isinstance(exc, BadRequest):
87
85
  response = get_exception_response(
88
86
  request=request, status_code=400, exception=exc
89
87
  )
90
- log_response(
88
+ request_logger.warning(
91
89
  "%s: %s",
92
90
  str(exc),
93
91
  request.path,
94
- response=response,
95
- request=request,
96
- exception=exc,
92
+ extra={"status_code": response.status_code, "request": request},
93
+ exc_info=exc,
97
94
  )
98
95
  elif isinstance(exc, SuspiciousOperation):
99
96
  if isinstance(exc, RequestDataTooBig | TooManyFieldsSent | TooManyFilesSent):
@@ -106,25 +103,23 @@ def response_for_exception(request: Request, exc: Exception) -> Response:
106
103
  security_logger = logging.getLogger(f"plain.security.{exc.__class__.__name__}")
107
104
  security_logger.error(
108
105
  str(exc),
109
- exc_info=exc,
110
106
  extra={"status_code": 400, "request": request},
107
+ exc_info=exc,
111
108
  )
112
109
  response = get_exception_response(
113
110
  request=request, status_code=400, exception=None
114
111
  )
115
112
 
116
113
  else:
117
- signals.got_request_exception.send(sender=None, request=request)
118
114
  response = get_exception_response(
119
115
  request=request, status_code=500, exception=None
120
116
  )
121
- log_response(
117
+ request_logger.error(
122
118
  "%s: %s",
123
119
  response.reason_phrase,
124
120
  request.path,
125
- response=response,
126
- request=request,
127
- exception=exc,
121
+ extra={"status_code": response.status_code, "request": request},
122
+ exc_info=exc,
128
123
  )
129
124
 
130
125
  return response
@@ -134,29 +129,18 @@ def get_exception_response(
134
129
  *, request: Request, status_code: int, exception: Exception | None
135
130
  ) -> Response:
136
131
  try:
137
- view_class = get_error_view(status_code=status_code, exception=exception)
138
- return view_class(request)
139
- except Exception:
140
- signals.got_request_exception.send(sender=None, request=request)
141
-
132
+ view_class = ErrorView.as_view(status_code=status_code, exception=exception)
133
+ response = view_class(request)
134
+ if response.status_code >= 500 and exception is not None:
135
+ # Attach the exception to the response for logging/observability
136
+ response.exception = exception
137
+ return response
138
+ except Exception as e:
142
139
  # In development mode, re-raise the exception to get a full stack trace
143
140
  if settings.DEBUG:
144
141
  raise
145
142
 
146
143
  # If we can't load the view, return a 500 response
147
- return ResponseServerError()
148
-
149
-
150
- def get_error_view(
151
- *, status_code: int, exception: Exception | None
152
- ) -> Callable[[Request], Response]:
153
- views_by_status = settings.HTTP_ERROR_VIEWS
154
- if status_code in views_by_status:
155
- view = views_by_status[status_code]
156
- if isinstance(view, str):
157
- # Import the view if it's a string
158
- view = import_string(view)
159
- return view.as_view()
160
-
161
- # Create a standard view for any other status code
162
- return ErrorView.as_view(status_code=status_code, exception=exception)
144
+ response = ResponseServerError()
145
+ response.exception = e
146
+ return response
@@ -2,19 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from plain.http import HttpMiddleware
5
6
  from plain.runtime import settings
6
7
 
7
8
  if TYPE_CHECKING:
8
- from collections.abc import Callable
9
-
10
9
  from plain.http import Request, Response
11
10
 
12
11
 
13
- class DefaultHeadersMiddleware:
14
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
15
- self.get_response = get_response
16
-
17
- def __call__(self, request: Request) -> Response:
12
+ class DefaultHeadersMiddleware(HttpMiddleware):
13
+ def process_request(self, request: Request) -> Response:
18
14
  response = self.get_response(request)
19
15
 
20
16
  for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
@@ -4,13 +4,11 @@ import ipaddress
4
4
  import logging
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from plain.http import Request, ResponseBadRequest
7
+ from plain.http import HttpMiddleware, Request, ResponseBadRequest
8
8
  from plain.runtime import settings
9
9
  from plain.utils.regex_helper import _lazy_re_compile
10
10
 
11
11
  if TYPE_CHECKING:
12
- from collections.abc import Callable
13
-
14
12
  from plain.http import Response
15
13
 
16
14
  logger = logging.getLogger(__name__)
@@ -20,7 +18,7 @@ host_validation_re = _lazy_re_compile(
20
18
  )
21
19
 
22
20
 
23
- class HostValidationMiddleware:
21
+ class HostValidationMiddleware(HttpMiddleware):
24
22
  """
25
23
  Middleware to validate the Host header against ALLOWED_HOSTS.
26
24
 
@@ -29,10 +27,7 @@ class HostValidationMiddleware:
29
27
  host is not allowed.
30
28
  """
31
29
 
32
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
33
- self.get_response = get_response
34
-
35
- def __call__(self, request: Request) -> Response:
30
+ def process_request(self, request: Request) -> Response:
36
31
  if not is_host_valid(request):
37
32
  host = request.host
38
33
  msg = f"Invalid HTTP_HOST header: {host!r}."
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from plain.http import ResponseRedirect
5
+ from plain.http import HttpMiddleware, ResponseRedirect
6
6
  from plain.runtime import settings
7
7
 
8
8
  if TYPE_CHECKING:
@@ -11,14 +11,14 @@ if TYPE_CHECKING:
11
11
  from plain.http import Request, Response
12
12
 
13
13
 
14
- class HttpsRedirectMiddleware:
15
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
16
- self.get_response = get_response
14
+ class HttpsRedirectMiddleware(HttpMiddleware):
15
+ def __init__(self, get_response: Callable[[Request], Response]):
16
+ super().__init__(get_response)
17
17
 
18
18
  # Settings for HTTPS
19
19
  self.https_redirect_enabled = settings.HTTPS_REDIRECT_ENABLED
20
20
 
21
- def __call__(self, request: Request) -> Response:
21
+ def process_request(self, request: Request) -> Response:
22
22
  """
23
23
  Perform a blanket HTTP→HTTPS redirect when enabled.
24
24
  """
@@ -2,23 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from plain.http import ResponseRedirect
5
+ from plain.http import HttpMiddleware, ResponseRedirect
6
6
  from plain.runtime import settings
7
7
  from plain.urls import Resolver404, get_resolver
8
8
  from plain.utils.http import escape_leading_slashes
9
9
 
10
10
  if TYPE_CHECKING:
11
- from collections.abc import Callable
12
-
13
11
  from plain.http import Request, Response
14
12
  from plain.urls import ResolverMatch
15
13
 
16
14
 
17
- class RedirectSlashMiddleware:
18
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
19
- self.get_response = get_response
20
-
21
- def __call__(self, request: Request) -> Response:
15
+ class RedirectSlashMiddleware(HttpMiddleware):
16
+ def process_request(self, request: Request) -> Response:
22
17
  """
23
18
  Rewrite the URL based on settings.APPEND_SLASH
24
19
  """
@@ -111,9 +111,6 @@ DATA_UPLOAD_MAX_NUMBER_FILES = 100
111
111
  # (i.e. "/tmp" on *nix systems).
112
112
  FILE_UPLOAD_TEMP_DIR = None
113
113
 
114
- # User-defined overrides for error views by status code
115
- HTTP_ERROR_VIEWS: dict[int, type] = {}
116
-
117
114
  # MARK: Middleware
118
115
 
119
116
  # List of middleware to use. Order is important; in the request phase, these
plain/signals/__init__.py CHANGED
@@ -2,4 +2,3 @@ from plain.signals.dispatch import Signal
2
2
 
3
3
  request_started = Signal()
4
4
  request_finished = Signal()
5
- got_request_exception = Signal()
plain/test/client.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- import sys
5
4
  from http import HTTPStatus
6
5
  from http.cookies import SimpleCookie
7
6
  from io import BytesIO, IOBase
@@ -14,7 +13,7 @@ from plain.internal.handlers.base import BaseHandler
14
13
  from plain.internal.handlers.wsgi import WSGIRequest
15
14
  from plain.json import PlainJSONEncoder
16
15
  from plain.runtime import settings
17
- from plain.signals import got_request_exception, request_started
16
+ from plain.signals import request_started
18
17
  from plain.urls import get_resolver
19
18
  from plain.utils.encoding import force_bytes
20
19
  from plain.utils.functional import SimpleLazyObject
@@ -55,7 +54,6 @@ class ClientResponse:
55
54
  response: ResponseBase,
56
55
  client: Client,
57
56
  request: dict[str, Any],
58
- exc_info: tuple[Any, Any, Any] | None,
59
57
  ):
60
58
  # Store wrapped response in __dict__ directly to avoid __setattr__ recursion
61
59
  object.__setattr__(self, "_response", response)
@@ -66,7 +64,6 @@ class ClientResponse:
66
64
  self.wsgi_request: WSGIRequest
67
65
  self.redirect_chain: list[tuple[str, int]]
68
66
  self.resolver_match: SimpleLazyObject | ResolverMatch
69
- self.exc_info = exc_info
70
67
  # Optional: set by plain.auth if available
71
68
  # self.user: Model
72
69
 
@@ -530,7 +527,6 @@ class Client:
530
527
  self._request_factory = RequestFactory(headers=headers, **defaults)
531
528
  self.handler = ClientHandler()
532
529
  self.raise_request_exception = raise_request_exception
533
- self.exc_info: tuple[Any, Any, Any] | None = None
534
530
  self.extra: dict[str, Any] | None = None
535
531
  self.headers: dict[str, str] | None = None
536
532
 
@@ -553,25 +549,20 @@ class Client:
553
549
  """
554
550
  environ = self._request_factory._base_environ(**request)
555
551
 
556
- # Capture exceptions created by the handler.
557
- exception_uid = f"request-exception-{id(request)}"
558
- got_request_exception.connect(self.store_exc_info, dispatch_uid=exception_uid)
559
- try:
560
- response = self.handler(environ)
561
- finally:
562
- # signals.template_rendered.disconnect(dispatch_uid=signal_uid)
563
- got_request_exception.disconnect(dispatch_uid=exception_uid)
552
+ # Make the request
553
+ response = self.handler(environ)
564
554
 
565
555
  # Wrap the response in ClientResponse for test-specific attributes
566
556
  client_response = ClientResponse(
567
557
  response=response,
568
558
  client=self,
569
559
  request=request,
570
- exc_info=self.exc_info,
571
560
  )
572
561
 
573
- # Check for signaled exceptions and potentially re-raise
574
- self.check_exception()
562
+ # Re-raise the exception if configured to do so
563
+ # Only 5xx errors have response.exception set
564
+ if client_response.exception and self.raise_request_exception:
565
+ raise client_response.exception
575
566
 
576
567
  # If the request had a user, make it available on the response.
577
568
  try:
@@ -901,18 +892,6 @@ class Client:
901
892
 
902
893
  return response
903
894
 
904
- def store_exc_info(self, **kwargs: Any) -> None:
905
- """Store exceptions when they are generated by a view."""
906
- self.exc_info = sys.exc_info()
907
-
908
- def check_exception(self) -> None:
909
- """Check for signaled exceptions and potentially re-raise."""
910
- if self.exc_info:
911
- _, exc_value, _ = self.exc_info
912
- self.exc_info = None
913
- if self.raise_request_exception:
914
- raise exc_value
915
-
916
895
  @property
917
896
  def session(self) -> Any:
918
897
  """Return the current session variables."""
plain/views/README.md CHANGED
@@ -251,27 +251,16 @@ class ExampleView(DetailView):
251
251
 
252
252
  ## Error views
253
253
 
254
- By default, HTTP errors will be rendered by `templates/<status_code>.html` or `templates/error.html`.
254
+ HTTP errors are automatically rendered using templates. Create a template named `<status_code>.html` in your templates directory to customize the error page for that status code.
255
255
 
256
- You can define your own error views by pointing the `HTTP_ERROR_VIEWS` setting to a dictionary of status codes and view classes.
256
+ For example:
257
257
 
258
- ```python
259
- # app/settings.py
260
- HTTP_ERROR_VIEWS = {
261
- 404: "errors.NotFoundView",
262
- }
263
- ```
264
-
265
- ```python
266
- # app/errors.py
267
- from plain.views import View
258
+ - `templates/404.html` - Page not found
259
+ - `templates/403.html` - Forbidden
260
+ - `templates/500.html` - Server error
261
+ - `templates/error.html` - Generic fallback for all errors
268
262
 
269
-
270
- class NotFoundView(View):
271
- def get(self):
272
- # A custom implementation or error view handling
273
- pass
274
- ```
263
+ The templates receive a context with `status_code` and `exception` variables.
275
264
 
276
265
  ## Redirect views
277
266
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.78.1
3
+ Version: 0.79.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,5 @@
1
1
  plain/AGENTS.md,sha256=As6EFSWWHJ9lYIxb2LMRqNRteH45SRs7a_VFslzF53M,1046
2
- plain/CHANGELOG.md,sha256=KM-gzR9XLYBO7517RspHvAJ4TP3izeeIg5N6KDiAoEU,28258
2
+ plain/CHANGELOG.md,sha256=1maDvdJj2PRr6h9sZ-aSsiA8NL1k_m3T-nKTWBFxIIg,30779
3
3
  plain/README.md,sha256=VvzhXNvf4I6ddmjBP9AExxxFXxs7RwyoxdgFm-W5dsg,4072
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=C2OnFHtRGMrpCiHSt-P2r58JypgQZ62qzDBpV4mhtFM,855
@@ -23,7 +23,7 @@ plain/chores/registry.py,sha256=IRpx3f6Z1qlqcEpHTe6O6JocNmaplLu7BVOqGrafSXU,1221
23
23
  plain/cli/README.md,sha256=Wn6o0fVL-SRMztTTHO71P4QzOvntgO-MqjbRrsk3WAw,1661
24
24
  plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
25
25
  plain/cli/build.py,sha256=Jg5LMbmXHhCXZIYj_Gcjou3yGiEWw8wpbOGGsdk-wZw,3203
26
- plain/cli/changelog.py,sha256=yCY887PT_D2viLz9f-uyu07Znqiv2-NEyCBqquNIukw,3590
26
+ plain/cli/changelog.py,sha256=B8uz-eE3byUPW8nBgnaVdcgjJ8qlopBhfM33Zwa2eMI,3656
27
27
  plain/cli/chores.py,sha256=FzQat9ofEZTlCiszGvV3deJwx8G0c-fBhn1mI560Iec,2568
28
28
  plain/cli/core.py,sha256=nL-a7zPtEBIa_XV-VOd9lqEUqmQAhlzsdHNwEmxNkWE,4285
29
29
  plain/cli/docs.py,sha256=PU3v7Z7qgYFG-bClpuDg4JeWwC8uvLYX3ovkQDMseVs,1146
@@ -39,7 +39,7 @@ plain/cli/server.py,sha256=ngUMtxB90t5FnG7HQwOIkN-pwY6ltKPZZ4PLSqN2Y3E,3001
39
39
  plain/cli/settings.py,sha256=kafbcPzy8khdtzLRyOHRl215va3E7U_h5USOA39UA3k,2008
40
40
  plain/cli/shell.py,sha256=urTp24D4UsKmYi9nT7OOdlT4WhXjkpFVrGYfNNVsXEE,1980
41
41
  plain/cli/startup.py,sha256=1nxXQucDkBxXriEN4wI2tiwG96PBNFndVrOyfzvJFdI,1061
42
- plain/cli/upgrade.py,sha256=0k33jNKvF_FSBcWNq_yEUgidGWrWlOURNfV8EaGE75U,5555
42
+ plain/cli/upgrade.py,sha256=rR4_2YQorV47D8FenWmw_O3EhwQm0go2GO5-d42qj-k,5621
43
43
  plain/cli/urls.py,sha256=lydpAdLj-F4UOa9ROHVBho3cCGIgARiTCRD8McIB7pE,4007
44
44
  plain/cli/utils.py,sha256=IJB73O2wugn1N2NZyU2nhmhEvhcuQn2FkENcoRzDot4,274
45
45
  plain/cli/agent/__init__.py,sha256=AAy7GMxYHw7ZKpREnGgG4xs979Aqb-aSeR_zr4fx5Gg,503
@@ -49,8 +49,7 @@ plain/cli/agent/md.py,sha256=eRspArtBVQNqxIii6J7sPPZqBjc-qmAF49aLnwZoWAE,2778
49
49
  plain/cli/agent/prompt.py,sha256=hMb4RXiMF68xSSHC3gXrCUjv5jZa7flQzL4FZvUMu1I,1261
50
50
  plain/cli/agent/request.py,sha256=cBdN9w4msPqfaE13DHrvAE0lkSdkTdTkIjZzBht8Ydc,6432
51
51
  plain/csrf/README.md,sha256=ApWpB-qlEf0LkOKm9Yr-6f_lB9XJEvGFDo_fraw8ghI,2391
52
- plain/csrf/middleware.py,sha256=p46QBXtJDrcCc5xwgZt7n-SIny4adShaFPTfBVXTNTM,5389
53
- plain/csrf/views.py,sha256=ckQp-ZJCdtgFukJLHQDtKARBhng6AWOP-1ijsW5Ddqo,736
52
+ plain/csrf/middleware.py,sha256=VxnL4vHehzcu2SfrMp7v0XKUOdJEuU1oLdZRTxEO4wQ,4877
54
53
  plain/forms/README.md,sha256=7MJQxNBoKkg0rW16qF6bGpUBxZrMrWjl2DZZk6gjzAU,2258
55
54
  plain/forms/__init__.py,sha256=sK-Y1QC_7iueo1BSbYrGT0uq2LooB0U8_j5VksNyZ4c,236
56
55
  plain/forms/boundfield.py,sha256=PEquPRn1BoVd4ZkIin8tjkBzRDMv-41wO8qHV0S1shg,1967
@@ -58,11 +57,12 @@ plain/forms/exceptions.py,sha256=MuMUF-33Qsc_HWk5zI3rcWzdvXzQbEOKAZvJKkbrL58,320
58
57
  plain/forms/fields.py,sha256=GQSTI6-eaHivIXj3TQSw2LpyWMbN0NZ-SHjUO_oxqeA,37132
59
58
  plain/forms/forms.py,sha256=GnJWzjIXOPByfrdiqjjo4xdKdkBw1gBD6Hq4mg73gHQ,11259
60
59
  plain/http/README.md,sha256=32uuWbarAcG_qmP-Fltk-_26HFKfY3dBdlrO2FDb7D0,756
61
- plain/http/__init__.py,sha256=jdMvhgalWf8OVkb0EGNLzuo6w4xAU8xDQwRIFvAyJsc,1001
60
+ plain/http/__init__.py,sha256=gUTIGh-GbSIlh3SP-Db-XAOX4P_j2hh2445w8Cm-zKQ,1032
62
61
  plain/http/cookie.py,sha256=x13G3LIr0jxnPK1NQRptmi0DrAq9PsivQnQTm4LKaW0,2191
62
+ plain/http/middleware.py,sha256=TPs585IIFjgp-5uUAJtIoigH6uwTS3FJqwFSsQdayd4,960
63
63
  plain/http/multipartparser.py,sha256=3W9osVGV9LshNF3aAUCBp7OBYTgD6hN2jS7T15BIKCs,28350
64
64
  plain/http/request.py,sha256=ficL1Lh-71tU1SVFKD4beLEJsPk7eesZG0nPPbACMTk,26462
65
- plain/http/response.py,sha256=efAJ2M_uwK8EYMXchOk-b0Jrx3Hukch_rPOW9nG5AV8,24842
65
+ plain/http/response.py,sha256=9AlV1PfBsimNFW5LV-o9xcRvp1uXEFeXMnq1vAYPYh0,24971
66
66
  plain/internal/__init__.py,sha256=n2AgdfNelt_tp8CS9JDzHMy_aiTUMPGZiFFwKmNz2fg,262
67
67
  plain/internal/reloader.py,sha256=n7B-F-WeUXp37pAnvzKX9tcEbUxHSlYqa4gItyA_zko,2662
68
68
  plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
@@ -74,21 +74,20 @@ plain/internal/files/uploadedfile.py,sha256=RaMeOMMB5LhH_QTEda9fGcI4kEg5CgCLE3kT
74
74
  plain/internal/files/uploadhandler.py,sha256=zUEMePuCsoaukRMCy5yBvMHaeOBageUw2sLBHsXpmew,7982
75
75
  plain/internal/files/utils.py,sha256=9aWCkGGGRdZLbI921IOgeUOD9zxx4FgpWCzXvq0lMXU,2871
76
76
  plain/internal/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
77
- plain/internal/handlers/base.py,sha256=DhJSZqVMrzKGCMrcpkbq6aMXMm0Q94lPuvAIurpAQ0Y,6671
78
- plain/internal/handlers/exception.py,sha256=9Qf9dfQANuaeNx9-DMFJzg3Y3un61NicxfK7YnK3RTk,5226
77
+ plain/internal/handlers/base.py,sha256=ZlpOYqd45X0wPYlmxHwXR5kyCkWBB6ZJeSGZka5VA3U,6403
78
+ plain/internal/handlers/exception.py,sha256=P7oTqVtKoIHBOxCml1BSgNBV_rQWyCHlO5hS87NVjXo,4872
79
79
  plain/internal/handlers/wsgi.py,sha256=d2Hcs4fzTSir6OvtpVromTw0RmmFAqTL4_EaBjoHtNU,8919
80
80
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
- plain/internal/middleware/headers.py,sha256=WM46oSTlmPsysbstOUoQLV8vsfGxbT9UpjYqEsw0FFQ,1200
82
- plain/internal/middleware/hosts.py,sha256=veh42e1JRNwegP4dVx_urQroEC857YAKt3ThDHyr9Rc,6017
83
- plain/internal/middleware/https.py,sha256=mB2fxOisij3HFL-N99Pa3gd3fI7ca49L4fxoQ96emEY,1088
84
- plain/internal/middleware/slash.py,sha256=VwiMZ__L8gMT0zrpwCAoLEJy2-GhR8Czc6kQnmu8K3w,3195
81
+ plain/internal/middleware/headers.py,sha256=-lgba1Em1FiqFbaeI7bwCkd4yI-UuOnEf3vh1eKK87Q,1100
82
+ plain/internal/middleware/hosts.py,sha256=UXMts7cKA9ocOK-J8DAcZPbKYQtfVyLSLH2QQX3mKjM,5895
83
+ plain/internal/middleware/https.py,sha256=8n990O0Ej5cRrBlfAnLW2nZ2mx0wn0002C4HuRhVpsM,1117
84
+ plain/internal/middleware/slash.py,sha256=V_rTb9v8uMUR_N2TLETBwlJFItoL4WD9JnRyFaFs878,3073
85
85
  plain/logs/README.md,sha256=rzOHfngjizLgXL21g0svC1Cdya2s_gBA_E-IljtHpy8,4069
86
86
  plain/logs/__init__.py,sha256=gFVMcNn5D6z0JrvUJgGsOeYj1NKNtEXhw0MvPDtkN6w,58
87
87
  plain/logs/configure.py,sha256=z2c_wBHPVRSSkKVBUEwYCUvQi-Kf449n-SysC65tjeo,1306
88
88
  plain/logs/debug.py,sha256=x2M4UcVexnn_5G0WCJd5iX6RFAGqEiRjE81dpMOqBBU,1336
89
89
  plain/logs/formatters.py,sha256=1yNeA7RGa9Ao1TZRu20idQtPOIg43PZogFR-B5n7VnA,2503
90
90
  plain/logs/loggers.py,sha256=FcvfVHbSLarY5IVsEoQcvNxeJkkoBz69a2a39rnyQ-4,5249
91
- plain/logs/utils.py,sha256=BuHFynr9Oy8R7LzN3WycBvDY1lNX8tAxJ3TBsnchb0k,1628
92
91
  plain/packages/README.md,sha256=iNqMtwFDVNf2TqKUzLKQW5Y4_GsssmdB4cVerzu27Ro,2674
93
92
  plain/packages/__init__.py,sha256=OpQny0xLplPdPpozVUUkrW2gB-IIYyDT1b4zMzOcCC4,160
94
93
  plain/packages/config.py,sha256=dxs_i-z6noQF_6j3lq11mhnQ1Bj10u2CXTJY0JgeQgc,3166
@@ -103,7 +102,7 @@ plain/preflight/security.py,sha256=nMbflO2LN49oKAAaa-tYvuweNB0RWv1z3w9niU037n0,3
103
102
  plain/preflight/urls.py,sha256=Asw_vq-70NRqr15yuBAYL0JCZ04liumORYT3I3KmF_k,437
104
103
  plain/runtime/README.md,sha256=ZZ3NPTjtxwyfw1V827YNwkWc8MiH5rWiy27HrN13qZ8,4819
105
104
  plain/runtime/__init__.py,sha256=dvF5ipRckVf6LQgY4kdxE_dTlCdncuawQf3o5EqLq9k,2524
106
- plain/runtime/global_settings.py,sha256=Q-bQP3tNnnuJZvfevGai639RIF_jhd7Dszt-DzTTz68,5509
105
+ plain/runtime/global_settings.py,sha256=3nYptasBjcodSR2Nq8Pm0KViPpvIS4SDOTShr5294Kk,5413
107
106
  plain/runtime/user_settings.py,sha256=Tbd0J6bxp18tKmFsQcdlxeFhUQU68PYtsswzQ2IcfNc,11467
108
107
  plain/runtime/utils.py,sha256=sHOv9SWCalBtg32GtZofimM2XaQtf_jAyyf6RQuOlGc,851
109
108
  plain/server/LICENSE,sha256=Xt_dw4qYwQI9qSi2u8yMZeb4HuMRp5tESRKhtvvJBgA,1707
@@ -130,7 +129,7 @@ plain/server/workers/sync.py,sha256=I28Icl1aKNIOlVaM76UOqQvjtSetkG-tdc5eiAvAmYA,
130
129
  plain/server/workers/thread.py,sha256=6F_YfhrlPV3hmxwI8_-jgGq4pCmkQBKVO15Wrg-iQ7Y,13504
131
130
  plain/server/workers/workertmp.py,sha256=egGReVvldlOBQfQGcpLpjt0zvPwR4C_N-UJKG-U_6w4,1299
132
131
  plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
133
- plain/signals/__init__.py,sha256=eAs0kLqptuP6I31dWXeAqRNji3svplpAV4Ez6ktjwXM,131
132
+ plain/signals/__init__.py,sha256=VDhotllLUQVg3eA1LuAJM9pwGTaf_bzRGzc4mJhd-sY,98
134
133
  plain/signals/dispatch/__init__.py,sha256=FzEygqV9HsM6gopio7O2Oh_X230nA4d5Q9s0sUjMq0E,292
135
134
  plain/signals/dispatch/dispatcher.py,sha256=PMOHS59ZkN1dFtep-9PYuQ1DcSBlGg_1JTIG5blf-Us,12307
136
135
  plain/signals/dispatch/license.txt,sha256=o9EhDhsC4Q5HbmD-IfNGVTEkXtNE33r5rIt3lleJ8gc,1727
@@ -145,7 +144,7 @@ plain/templates/jinja/filters.py,sha256=g70cw1jzvYco2v-u4SeceOWBX_qxHI5k9AODMn8e
145
144
  plain/templates/jinja/globals.py,sha256=TXl6uObqis_KXYP-jL3SvwqhATaoc7_hU8_fwpBMXyk,570
146
145
  plain/test/README.md,sha256=tNzaVjma0sgowIrViJguCgVy8A2d8mUKApZO2RxTYyU,1140
147
146
  plain/test/__init__.py,sha256=MhNHtp7MYBl9kq-pMRGY11kJ6kU1I6vOkjNkit1TYRg,94
148
- plain/test/client.py,sha256=wgmZOv8kyBDBDq6wamlFdVrmVCfWn5YD62dtARIpILA,31714
147
+ plain/test/client.py,sha256=0T-RNAy6XTTEcOdYNFXSQyhRX_FqG9gNAj2ckwCvo68,30831
149
148
  plain/test/encoding.py,sha256=txj_FCbC4GxH-JCkopW5LaZz8cGsrKQiculjFkjkzuY,3372
150
149
  plain/test/exceptions.py,sha256=Cn4cauBelCiZPnbIXru-zKePXEQn-dit8M4v74C_dTk,492
151
150
  plain/urls/README.md,sha256=026RkCK6I0GdqK3RE2QBLcCLIsiwtyKxgI2F0KBX95E,3882
@@ -180,7 +179,7 @@ plain/utils/text.py,sha256=teav7elbqEtGnhKG3ajf-V9Hb-Gsg8uqDrogqWizqjI,10094
180
179
  plain/utils/timesince.py,sha256=a_-ZoPK_s3Pt998CW4rWp0clZ1XyK2x04hCqak2giII,5928
181
180
  plain/utils/timezone.py,sha256=M_I5yvs9NsHbtNBPJgHErvWw9vatzx4M96tRQs5gS3g,6823
182
181
  plain/utils/tree.py,sha256=rj_JpZ2kVD3UExWoKnsRdVCoRjvzkuVOONcHzREjSyw,4766
183
- plain/views/README.md,sha256=6mcoSQp60n8qgoIMNDQr29WThpi-NCj8EMqxPNwWpiE,7189
182
+ plain/views/README.md,sha256=-WWjDCvheG9cmocrmkzHtk6PXhbFAKpx2vVF6-z4mV8,7137
184
183
  plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
185
184
  plain/views/base.py,sha256=yWh6S68PsYcH1dvRdibQIanBYkjo2iJ8IAbR2PTWQrk,4419
186
185
  plain/views/errors.py,sha256=tHD7MNnZcMyiQ46RMAnX1Ne3Zbbkr1zAiVfJyaaLtSQ,1447
@@ -189,8 +188,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
189
188
  plain/views/objects.py,sha256=5y0PoPPo07dQTTcJ_9kZcx0iI1O7regsooAIK4VqXQ0,5579
190
189
  plain/views/redirect.py,sha256=mIpSAFcaEyeLDyv4Fr6g_ektduG4Wfa6s6L-rkdazmM,2154
191
190
  plain/views/templates.py,sha256=9LgDMCv4C7JzLiyw8jHF-i4350ukwgixC_9y4faEGu0,1885
192
- plain-0.78.1.dist-info/METADATA,sha256=r3Abxj0lYj5yV39kplR3XPl6Dv-V2Xd5H_d5jWEToVw,4516
193
- plain-0.78.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
194
- plain-0.78.1.dist-info/entry_points.txt,sha256=wvMzY-iREvfqRgyLm77htPp4j_8CQslLoZA15_AnNo8,171
195
- plain-0.78.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
196
- plain-0.78.1.dist-info/RECORD,,
191
+ plain-0.79.0.dist-info/METADATA,sha256=-lUH8_JrMqylAVv-K4JprhZJz4F2Kt8v1qn5Ri0xvBs,4516
192
+ plain-0.79.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
193
+ plain-0.79.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
194
+ plain-0.79.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
195
+ plain-0.79.0.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plain = plain.cli.core:cli
plain/csrf/views.py DELETED
@@ -1,34 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from plain.http import Response
4
- from plain.views import TemplateView
5
-
6
-
7
- class CsrfFailureView(TemplateView):
8
- template_name = "403.html"
9
-
10
- def get_response(self) -> Response:
11
- response = super().get_response()
12
- response.status_code = 403
13
- return response
14
-
15
- def post(self) -> Response:
16
- return self.get()
17
-
18
- def put(self) -> Response:
19
- return self.get()
20
-
21
- def patch(self) -> Response:
22
- return self.get()
23
-
24
- def delete(self) -> Response:
25
- return self.get()
26
-
27
- def head(self) -> Response:
28
- return self.get()
29
-
30
- def options(self) -> Response:
31
- return self.get()
32
-
33
- def trace(self) -> Response:
34
- return self.get()
plain/logs/utils.py DELETED
@@ -1,56 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import TYPE_CHECKING, Any
5
-
6
- if TYPE_CHECKING:
7
- from plain.http.request import Request
8
- from plain.http.response import ResponseBase
9
-
10
- request_logger = logging.getLogger("plain.request")
11
-
12
-
13
- def log_response(
14
- message: str,
15
- *args: Any,
16
- response: ResponseBase | None = None,
17
- request: Request | None = None,
18
- logger: logging.Logger = request_logger,
19
- level: str | None = None,
20
- exception: BaseException | None = None,
21
- ) -> None:
22
- """
23
- Log errors based on Response status.
24
-
25
- Log 5xx responses as errors and 4xx responses as warnings (unless a level
26
- is given as a keyword argument). The Response status_code and the
27
- request are passed to the logger's extra parameter.
28
- """
29
- if response is None:
30
- return
31
-
32
- # Check if the response has already been logged. Multiple requests to log
33
- # the same response can be received in some cases, e.g., when the
34
- # response is the result of an exception and is logged when the exception
35
- # is caught, to record the exception.
36
- if getattr(response, "_has_been_logged", False):
37
- return
38
-
39
- if level is None:
40
- if response.status_code >= 500:
41
- level = "error"
42
- elif response.status_code >= 400:
43
- level = "warning"
44
- else:
45
- level = "info"
46
-
47
- getattr(logger, level)(
48
- message,
49
- *args,
50
- extra={
51
- "status_code": response.status_code,
52
- "request": request,
53
- },
54
- exc_info=exception,
55
- )
56
- response._has_been_logged = True # type: ignore[attr-defined]
@@ -1,5 +0,0 @@
1
- [console_scripts]
2
- plain = plain.cli.core:cli
3
- plain-build = plain.cli.build:build
4
- plain-changelog = plain.cli.changelog:changelog
5
- plain-upgrade = plain.cli.upgrade:upgrade
File without changes