plain 0.52.2__py3-none-any.whl → 0.54.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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.54.0](https://github.com/dropseed/plain/releases/plain@0.54.0) (2025-07-18)
4
+
5
+ ### What's changed
6
+
7
+ - Added OpenTelemetry instrumentation for HTTP requests, views, and template rendering ([b0224d0418](https://github.com/dropseed/plain/commit/b0224d0418))
8
+ - Added `plain-observer` package reference to plain README ([f29ff4dafe](https://github.com/dropseed/plain/commit/f29ff4dafe))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.53.0](https://github.com/dropseed/plain/releases/plain@0.53.0) (2025-07-18)
15
+
16
+ ### What's changed
17
+
18
+ - Added a `pluralize` filter for Jinja templates to handle singular/plural forms ([4cef9829ed](https://github.com/dropseed/plain/commit/4cef9829ed))
19
+ - Added `get_signed_cookie()` method to `HttpRequest` for retrieving and verifying signed cookies ([f8796c8786](https://github.com/dropseed/plain/commit/f8796c8786))
20
+ - Improved CLI error handling by using `click.UsageError` instead of manual error printing ([88f06c5184](https://github.com/dropseed/plain/commit/88f06c5184))
21
+ - Simplified preflight check success message ([adffc06152](https://github.com/dropseed/plain/commit/adffc06152))
22
+
23
+ ### Upgrade instructions
24
+
25
+ - No changes required
26
+
3
27
  ## [0.52.2](https://github.com/dropseed/plain/releases/plain@0.52.2) (2025-06-27)
4
28
 
5
29
  ### What's changed
plain/README.md CHANGED
@@ -48,6 +48,7 @@ The `plain` package includes everything you need to start handling web requests
48
48
  - [plain.support](/plain-support/plain/support/README.md) - Customer support forms.
49
49
  - [plain.redirection](/plain-redirection/plain/redirection/README.md) - Redirects managed in the database.
50
50
  - [plain.pageviews](/plain-pageviews/plain/pageviews/README.md) - Basic self-hosted page view tracking and reporting.
51
+ - [plain.observer](/plain-observer/plain/observer/README.md) - On-page telemetry reporting.
51
52
 
52
53
  ## Dev Packages
53
54
 
plain/cli/build.py CHANGED
@@ -37,12 +37,9 @@ def build(keep_original, fingerprint, compress):
37
37
  """Pre-deployment build step (compile assets, css, js, etc.)"""
38
38
 
39
39
  if not keep_original and not fingerprint:
40
- click.secho(
41
- "You must either keep the original assets or fingerprint them.",
42
- fg="red",
43
- err=True,
40
+ raise click.UsageError(
41
+ "You must either keep the original assets or fingerprint them."
44
42
  )
45
- sys.exit(1)
46
43
 
47
44
  # Run user-defined build commands first
48
45
  pyproject_path = plain.runtime.APP_PATH.parent / "pyproject.toml"
plain/cli/docs.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import ast
2
2
  import importlib.util
3
- import sys
4
3
  from pathlib import Path
5
4
 
6
5
  import click
@@ -16,8 +15,7 @@ from .output import iterate_markdown
16
15
  @click.argument("module", default="")
17
16
  def docs(module, llm, open):
18
17
  if not module and not llm:
19
- click.secho("You must specify a module or use --llm", fg="red")
20
- sys.exit(1)
18
+ raise click.UsageError("You must specify a module or use --llm")
21
19
 
22
20
  if llm:
23
21
  paths = [Path(__file__).parent.parent]
@@ -49,14 +47,12 @@ def docs(module, llm, open):
49
47
  # Get the README.md file for the module
50
48
  spec = importlib.util.find_spec(module)
51
49
  if not spec:
52
- click.secho(f"Module {module} not found", fg="red")
53
- sys.exit(1)
50
+ raise click.UsageError(f"Module {module} not found")
54
51
 
55
52
  module_path = Path(spec.origin).parent
56
53
  readme_path = module_path / "README.md"
57
54
  if not readme_path.exists():
58
- click.secho(f"README.md not found for {module}", fg="red")
59
- sys.exit(1)
55
+ raise click.UsageError(f"README.md not found for {module}")
60
56
 
61
57
  if open:
62
58
  click.launch(str(readme_path))
plain/cli/preflight.py CHANGED
@@ -123,4 +123,4 @@ def preflight_checks(package_label, deploy, fail_level, database):
123
123
  msg = header + body + footer
124
124
  click.echo(msg, err=True)
125
125
  else:
126
- click.secho("✔ Preflight check identified no issues.", err=True, fg="green")
126
+ click.secho("✔ Checks passed", err=True, fg="green")
plain/cli/urls.py CHANGED
@@ -1,5 +1,3 @@
1
- import sys
2
-
3
1
  import click
4
2
 
5
3
 
@@ -17,8 +15,7 @@ def list_urls(flat):
17
15
  from plain.urls import URLResolver, get_resolver
18
16
 
19
17
  if not settings.URLS_ROUTER:
20
- click.secho("URLS_ROUTER is not set", fg="red")
21
- sys.exit(1)
18
+ raise click.UsageError("URLS_ROUTER is not set")
22
19
 
23
20
  resolver = get_resolver(settings.URLS_ROUTER)
24
21
  if flat:
plain/http/cookie.py CHANGED
@@ -1,5 +1,9 @@
1
1
  from http import cookies
2
2
 
3
+ from plain.runtime import settings
4
+ from plain.signing import BadSignature, TimestampSigner
5
+ from plain.utils.encoding import force_bytes
6
+
3
7
 
4
8
  def parse_cookie(cookie):
5
9
  """
@@ -18,3 +22,43 @@ def parse_cookie(cookie):
18
22
  # unquote using Python's algorithm.
19
23
  cookiedict[key] = cookies._unquote(val)
20
24
  return cookiedict
25
+
26
+
27
+ def _cookie_key(key):
28
+ """
29
+ Generate a key for cookie signing that matches the pattern used by
30
+ set_signed_cookie and get_signed_cookie.
31
+ """
32
+ return b"plain.http.cookies" + force_bytes(key)
33
+
34
+
35
+ def get_signed_cookie_signer(key, salt=""):
36
+ """
37
+ Create a TimestampSigner for signed cookies with the same configuration
38
+ used by both set_signed_cookie and get_signed_cookie.
39
+ """
40
+ return TimestampSigner(
41
+ key=_cookie_key(settings.SECRET_KEY),
42
+ fallback_keys=map(_cookie_key, settings.SECRET_KEY_FALLBACKS),
43
+ salt=key + salt,
44
+ )
45
+
46
+
47
+ def sign_cookie_value(key, value, salt=""):
48
+ """
49
+ Sign a cookie value using the standard Plain cookie signing approach.
50
+ """
51
+ signer = get_signed_cookie_signer(key, salt)
52
+ return signer.sign(value)
53
+
54
+
55
+ def unsign_cookie_value(key, signed_value, salt="", max_age=None, default=None):
56
+ """
57
+ Unsign a cookie value using the standard Plain cookie signing approach.
58
+ Returns the default value if the signature is invalid or the cookie has expired.
59
+ """
60
+ signer = get_signed_cookie_signer(key, salt)
61
+ try:
62
+ return signer.unsign(signed_value, max_age=max_age)
63
+ except BadSignature:
64
+ return default
plain/http/request.py CHANGED
@@ -13,6 +13,7 @@ from plain.exceptions import (
13
13
  RequestDataTooBig,
14
14
  TooManyFieldsSent,
15
15
  )
16
+ from plain.http.cookie import unsign_cookie_value
16
17
  from plain.http.multipartparser import (
17
18
  MultiPartParser,
18
19
  MultiPartParserError,
@@ -427,6 +428,20 @@ class HttpRequest:
427
428
  def readlines(self):
428
429
  return list(self)
429
430
 
431
+ def get_signed_cookie(self, key, default=None, salt="", max_age=None):
432
+ """
433
+ Retrieve a cookie value signed with the SECRET_KEY.
434
+
435
+ Return default if the cookie doesn't exist or signature verification fails.
436
+ """
437
+
438
+ try:
439
+ cookie_value = self.cookies[key]
440
+ except KeyError:
441
+ return default
442
+
443
+ return unsign_cookie_value(key, cookie_value, salt, max_age, default)
444
+
430
445
 
431
446
  class HttpHeaders(CaseInsensitiveMapping):
432
447
  HTTP_PREFIX = "HTTP_"
plain/http/response.py CHANGED
@@ -12,13 +12,14 @@ from http.client import responses
12
12
  from http.cookies import SimpleCookie
13
13
  from urllib.parse import urlparse
14
14
 
15
- from plain import signals, signing
15
+ from plain import signals
16
16
  from plain.exceptions import DisallowedRedirect
17
+ from plain.http.cookie import sign_cookie_value
17
18
  from plain.json import PlainJSONEncoder
18
19
  from plain.runtime import settings
19
20
  from plain.utils import timezone
20
21
  from plain.utils.datastructures import CaseInsensitiveMapping
21
- from plain.utils.encoding import force_bytes, iri_to_uri
22
+ from plain.utils.encoding import iri_to_uri
22
23
  from plain.utils.http import content_disposition_header, http_date
23
24
  from plain.utils.regex_helper import _lazy_re_compile
24
25
 
@@ -260,16 +261,8 @@ class ResponseBase:
260
261
  def set_signed_cookie(self, key, value, salt="", **kwargs):
261
262
  """Set a cookie signed with the SECRET_KEY."""
262
263
 
263
- def _cookie_key(k):
264
- return b"plain.http.cookies" + force_bytes(k)
265
-
266
- signer = signing.TimestampSigner(
267
- key=_cookie_key(settings.SECRET_KEY),
268
- fallback_keys=map(_cookie_key, settings.SECRET_KEY_FALLBACKS),
269
- salt=key + salt,
270
- )
271
- value = signer.sign(value)
272
- return self.set_cookie(key, value, **kwargs)
264
+ signed_value = sign_cookie_value(key, value, salt)
265
+ return self.set_cookie(key, signed_value, **kwargs)
273
266
 
274
267
  def delete_cookie(self, key, path="/", domain=None, samesite=None):
275
268
  # Browsers can ignore the Set-Cookie header if the cookie doesn't use
@@ -1,6 +1,9 @@
1
1
  import logging
2
2
  import types
3
3
 
4
+ from opentelemetry import baggage, trace
5
+ from opentelemetry.semconv.attributes import http_attributes, url_attributes
6
+
4
7
  from plain.exceptions import ImproperlyConfigured
5
8
  from plain.logs import log_response
6
9
  from plain.runtime import settings
@@ -26,6 +29,9 @@ BUILTIN_AFTER_MIDDLEWARE = [
26
29
  ]
27
30
 
28
31
 
32
+ tracer = trace.get_tracer("plain")
33
+
34
+
29
35
  class BaseHandler:
30
36
  _middleware_chain = None
31
37
 
@@ -35,8 +41,7 @@ class BaseHandler:
35
41
 
36
42
  Must be called after the environment is fixed (see __call__ in subclasses).
37
43
  """
38
- get_response = self._get_response
39
- handler = convert_exception_to_response(get_response)
44
+ handler = convert_exception_to_response(self._get_response)
40
45
 
41
46
  middlewares = reversed(
42
47
  BUILTIN_BEFORE_MIDDLEWARE + settings.MIDDLEWARE + BUILTIN_AFTER_MIDDLEWARE
@@ -59,18 +64,55 @@ class BaseHandler:
59
64
 
60
65
  def get_response(self, request):
61
66
  """Return a Response object for the given HttpRequest."""
62
- # Setup default url resolver for this thread
63
- response = self._middleware_chain(request)
64
- response._resource_closers.append(request.close)
65
- if response.status_code >= 400:
66
- log_response(
67
- "%s: %s",
68
- response.reason_phrase,
69
- request.path,
70
- response=response,
71
- request=request,
67
+
68
+ span_attributes = {
69
+ "plain.request.id": request.unique_id,
70
+ http_attributes.HTTP_REQUEST_METHOD: request.method,
71
+ url_attributes.URL_PATH: request.path_info,
72
+ url_attributes.URL_SCHEME: request.scheme,
73
+ }
74
+
75
+ # Add full URL if we can build it (requires proper WSGI environment)
76
+ try:
77
+ span_attributes[url_attributes.URL_FULL] = request.build_absolute_uri()
78
+ except KeyError:
79
+ # Missing required WSGI environment variables (e.g. in tests)
80
+ pass
81
+
82
+ # Add query string if present
83
+ if query_string := request.meta.get("QUERY_STRING"):
84
+ span_attributes[url_attributes.URL_QUERY] = query_string
85
+
86
+ span_context = baggage.set_baggage("http.request.cookies", request.cookies)
87
+
88
+ with tracer.start_as_current_span(
89
+ f"{request.method} {request.path_info}",
90
+ context=span_context,
91
+ attributes=span_attributes,
92
+ kind=trace.SpanKind.SERVER,
93
+ ) as span:
94
+ response = self._middleware_chain(request)
95
+ response._resource_closers.append(request.close)
96
+
97
+ span.set_attribute(
98
+ http_attributes.HTTP_RESPONSE_STATUS_CODE, response.status_code
99
+ )
100
+
101
+ span.set_status(
102
+ trace.StatusCode.OK
103
+ if response.status_code < 400
104
+ else trace.StatusCode.ERROR
72
105
  )
73
- return response
106
+
107
+ if response.status_code >= 400:
108
+ log_response(
109
+ "%s: %s",
110
+ response.reason_phrase,
111
+ request.path,
112
+ response=response,
113
+ request=request,
114
+ )
115
+ return response
74
116
 
75
117
  def _get_response(self, request):
76
118
  """
@@ -94,9 +136,18 @@ class BaseHandler:
94
136
  Retrieve/set the urlrouter for the request. Return the view resolved,
95
137
  with its args and kwargs.
96
138
  """
139
+
97
140
  resolver = get_resolver()
98
141
  # Resolve the view, and assign the match object back to the request.
99
142
  resolver_match = resolver.resolve(request.path_info)
143
+
144
+ span = trace.get_current_span()
145
+ span.set_attribute(http_attributes.HTTP_ROUTE, resolver_match.route)
146
+
147
+ # Route makes a better name
148
+ if resolver_match.route:
149
+ span.update_name(f"{request.method} {resolver_match.route}")
150
+
100
151
  request.resolver_match = resolver_match
101
152
  return resolver_match
102
153
 
plain/templates/core.py CHANGED
@@ -1,7 +1,14 @@
1
1
  import jinja2
2
+ from opentelemetry import trace
3
+ from opentelemetry.semconv._incubating.attributes.code_attributes import (
4
+ CODE_FUNCTION_NAME,
5
+ CODE_NAMESPACE,
6
+ )
2
7
 
3
8
  from .jinja import environment
4
9
 
10
+ tracer = trace.get_tracer("plain")
11
+
5
12
 
6
13
  class TemplateFileMissing(Exception):
7
14
  def __str__(self) -> str:
@@ -21,4 +28,15 @@ class Template:
21
28
  raise TemplateFileMissing(filename)
22
29
 
23
30
  def render(self, context: dict) -> str:
24
- return self._jinja_template.render(context)
31
+ with tracer.start_as_current_span(
32
+ f"render {self.filename}",
33
+ kind=trace.SpanKind.INTERNAL,
34
+ attributes={
35
+ CODE_FUNCTION_NAME: "render",
36
+ CODE_NAMESPACE: f"{self.__class__.__module__}.{self.__class__.__qualname__}",
37
+ "template.filename": self.filename,
38
+ "template.engine": "jinja2",
39
+ },
40
+ ):
41
+ result = self._jinja_template.render(context)
42
+ return result
@@ -15,6 +15,25 @@ def localtime_filter(value, timezone=None):
15
15
  return localtime(value, timezone)
16
16
 
17
17
 
18
+ def pluralize_filter(value, singular="", plural="s"):
19
+ """Returns plural suffix based on the value count.
20
+
21
+ Usage:
22
+ {{ count }} item{{ count|pluralize }}
23
+ {{ count }} ox{{ count|pluralize("en") }}
24
+ {{ count }} cact{{ count|pluralize("us","i") }}
25
+ """
26
+ try:
27
+ count = int(value)
28
+ except (ValueError, TypeError):
29
+ return singular
30
+
31
+ if count == 1:
32
+ return singular
33
+
34
+ return plural
35
+
36
+
18
37
  default_filters = {
19
38
  # The standard Python ones
20
39
  "strftime": datetime.datetime.strftime,
@@ -27,4 +46,5 @@ default_filters = {
27
46
  "timesince": timesince,
28
47
  "json_script": json_script,
29
48
  "islice": islice, # slice for dict.items()
49
+ "pluralize": pluralize_filter,
30
50
  }
plain/views/base.py CHANGED
@@ -1,6 +1,12 @@
1
1
  import logging
2
2
  from http import HTTPMethod
3
3
 
4
+ from opentelemetry import trace
5
+ from opentelemetry.semconv._incubating.attributes.code_attributes import (
6
+ CODE_FUNCTION_NAME,
7
+ CODE_NAMESPACE,
8
+ )
9
+
4
10
  from plain.http import (
5
11
  HttpRequest,
6
12
  JsonResponse,
@@ -16,6 +22,9 @@ from .exceptions import ResponseException
16
22
  logger = logging.getLogger("plain.request")
17
23
 
18
24
 
25
+ tracer = trace.get_tracer("plain")
26
+
27
+
19
28
  class View:
20
29
  request: HttpRequest
21
30
  url_args: tuple
@@ -35,9 +44,23 @@ class View:
35
44
  @classonlymethod
36
45
  def as_view(cls, *init_args, **init_kwargs):
37
46
  def view(request, *url_args, **url_kwargs):
38
- v = cls(*init_args, **init_kwargs)
39
- v.setup(request, *url_args, **url_kwargs)
40
- return v.get_response()
47
+ with tracer.start_as_current_span(
48
+ f"{cls.__name__}",
49
+ kind=trace.SpanKind.INTERNAL,
50
+ attributes={
51
+ CODE_FUNCTION_NAME: "as_view",
52
+ CODE_NAMESPACE: f"{cls.__module__}.{cls.__qualname__}",
53
+ },
54
+ ) as span:
55
+ v = cls(*init_args, **init_kwargs)
56
+ v.setup(request, *url_args, **url_kwargs)
57
+ response = v.get_response()
58
+ span.set_status(
59
+ trace.StatusCode.OK
60
+ if response.status_code < 400
61
+ else trace.StatusCode.ERROR
62
+ )
63
+ return response
41
64
 
42
65
  view.view_class = cls
43
66
 
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.52.2
3
+ Version: 0.54.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
7
7
  Requires-Python: >=3.11
8
8
  Requires-Dist: click>=8.0.0
9
9
  Requires-Dist: jinja2>=3.1.2
10
+ Requires-Dist: opentelemetry-api>=1.34.1
11
+ Requires-Dist: opentelemetry-semantic-conventions>=0.55b1
10
12
  Description-Content-Type: text/markdown
11
13
 
12
14
  # Plain
@@ -59,6 +61,7 @@ The `plain` package includes everything you need to start handling web requests
59
61
  - [plain.support](/plain-support/plain/support/README.md) - Customer support forms.
60
62
  - [plain.redirection](/plain-redirection/plain/redirection/README.md) - Redirects managed in the database.
61
63
  - [plain.pageviews](/plain-pageviews/plain/pageviews/README.md) - Basic self-hosted page view tracking and reporting.
64
+ - [plain.observer](/plain-observer/plain/observer/README.md) - On-page telemetry reporting.
62
65
 
63
66
  ## Dev Packages
64
67
 
@@ -1,5 +1,5 @@
1
- plain/CHANGELOG.md,sha256=gfkKcBoXisHose_cDs9fcJb_YFzcvKw8eB4c42mmfiw,3732
2
- plain/README.md,sha256=gik6DBZcJAITcm4WRq_L53AxkjY45eQLafyTCSf0CKE,3986
1
+ plain/CHANGELOG.md,sha256=x8vKNqfNIAeNvkBhz1648Y4zqH3MpfHACrkLW-8F9mk,4907
2
+ plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
3
3
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
4
4
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
5
5
  plain/exceptions.py,sha256=Z9cbPE5im_Y-bjVq8cqC85gBoqOr80rLFG5wTKixrwE,5894
@@ -20,22 +20,22 @@ plain/chores/__init__.py,sha256=r9TXtQCH-VbvfnIJ5F8FxgQC35GRWFOfmMZN3q9niLg,67
20
20
  plain/chores/registry.py,sha256=V3WjuekRI22LFvJbqSkUXQtiOtuE2ZK8gKV1TRvxRUI,1866
21
21
  plain/cli/README.md,sha256=GzBry6mEilhM80SfVUg02ydGwAk0m-s6FAqQR1nRsMM,2022
22
22
  plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
23
- plain/cli/build.py,sha256=dKUYBNegvb6QNckR7XZ7CJJtINwZcmDvbUdv2dWwjf8,3226
23
+ plain/cli/build.py,sha256=Lo6AYghJz0DM9fIVUSiBSOKa5vR0XCOxZWEjza6sc8Q,3172
24
24
  plain/cli/changelog.py,sha256=j-k1yZk9mpm-fLZgeWastiyIisxNSuAJfXTQ2B6WQmk,3457
25
25
  plain/cli/chores.py,sha256=xXSSFvr8T5jWfLWqe6E8YVMw1BkQxyOHHVuY0x9RH0A,2412
26
26
  plain/cli/core.py,sha256=D3t83ujjjHayblM-RuttrGoNf8hMV9-l3zQsbhVAjWU,2991
27
- plain/cli/docs.py,sha256=KCJCP_OVFb34zOkA6x7X6-iGFzx2tv4ZgXAM99TjWNg,7443
27
+ plain/cli/docs.py,sha256=5-2_nQnInZAzHu3VnMW88gZyrhukhdjrkMKTMt0RRpI,7367
28
28
  plain/cli/formatting.py,sha256=1hZH13y1qwHcU2K2_Na388nw9uvoeQH8LrWL-O9h8Yc,2207
29
29
  plain/cli/help.py,sha256=NefZSEIixrX_WELVSnJDHRpLDWf7_4PXmkkMm3Q2mzo,787
30
30
  plain/cli/output.py,sha256=Fe3xS6Va4Bi1ZNrqi0nh09THTsdCyMW2b9SPY5I4n-o,1318
31
- plain/cli/preflight.py,sha256=FWFwMZ0W_t8ObTTRMnBmaiGN8PqdEAWgmSEPGDwZFpA,4148
31
+ plain/cli/preflight.py,sha256=8tHBD4L4nPLUKThfaYx3SUZSJzC48oV2m_Hbn6W4ODc,4124
32
32
  plain/cli/print.py,sha256=XraUYrgODOJquIiEv78wSCYGRBplHXtXSS9QtFG5hqY,217
33
33
  plain/cli/registry.py,sha256=yKVMSDjW8g10nlV9sPXFGJQmhC_U-k4J4kM7N2OQVLA,1467
34
34
  plain/cli/scaffold.py,sha256=mcywA9DzfwoBSqWl5-Zpgcy1mTNUGEgdvoxXUrGcEVk,1351
35
35
  plain/cli/settings.py,sha256=9cx4bue664I2P7kUedlf4YhCPB0tSKSE4Q8mGyzEv2o,1995
36
36
  plain/cli/shell.py,sha256=iIwvlTdTBjLBBUdXMAmIRWSoynszOZI79-mrBg4RegU,1373
37
37
  plain/cli/startup.py,sha256=wLaFuyUb4ewWhtehBCGicrRCXIIGCRbeCT3ce9hUv-A,1022
38
- plain/cli/urls.py,sha256=7FOvLjfV1GsYKnb7SGlIgEfchQcrkWdYU1nY6aazGBI,3855
38
+ plain/cli/urls.py,sha256=ghCW36aRszxmTo06A50FIvYopb6kQ07QekkDzM6_A1o,3824
39
39
  plain/cli/utils.py,sha256=VwlIh0z7XxzVV8I3qM2kZo07fkJFPoeeVZa1ODG616k,258
40
40
  plain/csrf/README.md,sha256=nxCpPk1HF5eAM-7paxg9D-9RVCU9jXsSPAVHkJvA_DU,717
41
41
  plain/csrf/middleware.py,sha256=U3B9R7ciO_UAf7O3jdNtVu6QZ_3Yrm8isRdnW6bVKX4,17401
@@ -48,10 +48,10 @@ plain/forms/fields.py,sha256=OyL4eZIgJ_XMLPHGar17hLepFmwHV-hSnb_n7s18yUU,34709
48
48
  plain/forms/forms.py,sha256=hF7Dl8rEaiBTZhFQyfbh1Zf54BSEka8RYpBiGqkTa8I,10441
49
49
  plain/http/README.md,sha256=F9wbahgSU3jIDEG14gJjdPJRem4weUNvwnwhb7o3cu0,722
50
50
  plain/http/__init__.py,sha256=DIsDRbBsCGa4qZgq-fUuQS0kkxfbTU_3KpIM9VvH04w,1067
51
- plain/http/cookie.py,sha256=11FnSG3Plo6T3jZDbPoCw7SKh9ExdBio3pTmIO03URg,597
51
+ plain/http/cookie.py,sha256=THd7nOl-2ugeBPKgOhbD87aM2oxUbNH8HWrarUn0fpM,1955
52
52
  plain/http/multipartparser.py,sha256=Z1dFJNAd8N5RHUuF67jh1jBfZOFepORsre_3ee6CgOQ,27266
53
- plain/http/request.py,sha256=DbnC_E-PeiLM9pVJdcO869BtAU2gniLflMnPAOrrKU8,25618
54
- plain/http/response.py,sha256=WvVxQgQMq9X8YRBa9_neowfP3mx3TzN90SShSulfFQo,23970
53
+ plain/http/request.py,sha256=93b2gqkfEsBczUyP_9vlueVoxyzzfbnJ423PDAk8aHc,26103
54
+ plain/http/response.py,sha256=0xUhkTiT6JwohdwA7ymY2vpdCQVl4hnEExjk01LrJbg,23734
55
55
  plain/internal/__init__.py,sha256=fVBaYLCXEQc-7riHMSlw3vMTTuF7-0Bj2I8aGzv0o0w,171
56
56
  plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
57
57
  plain/internal/files/base.py,sha256=2z19tik2_xgXlI6nfVZ4woSF9WB0RSUzsvOfi1Bz8Wg,4113
@@ -62,7 +62,7 @@ plain/internal/files/uploadedfile.py,sha256=JRB7T3quQjg-1y3l1ASPxywtSQZhaeMc45uF
62
62
  plain/internal/files/uploadhandler.py,sha256=63_QUwAwfq3bevw79i0S7zt2EB2UBoO7MaauvezaVMY,7198
63
63
  plain/internal/files/utils.py,sha256=xN4HTJXDRdcoNyrL1dFd528MBwodRlHZM8DGTD_oBIg,2646
64
64
  plain/internal/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
- plain/internal/handlers/base.py,sha256=6K8wd5DojOYA-UEQTd1jxdDggm5ThHXQahVcuJqd2qo,4169
65
+ plain/internal/handlers/base.py,sha256=odNSI5v5c8WsQjebPeKpKtmEreiFNQrwuG-_lM8mY3E,5882
66
66
  plain/internal/handlers/exception.py,sha256=vfha_6-fz6S6VYCP1PMBfue2Gw-_th6jqaTE372fGlw,4809
67
67
  plain/internal/handlers/wsgi.py,sha256=dgPT29t_F9llB-c5RYU3SHxGuZNaZ83xRjOfuOmtOl8,8209
68
68
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -96,11 +96,11 @@ plain/signals/dispatch/dispatcher.py,sha256=VxSlqn9PCOTghPPJLOqZPs6FNQZfV2BJpMfF
96
96
  plain/signals/dispatch/license.txt,sha256=o9EhDhsC4Q5HbmD-IfNGVTEkXtNE33r5rIt3lleJ8gc,1727
97
97
  plain/templates/README.md,sha256=G43IdTRJX6mfxsExUTQf9QAwPeHLsDgY7PvKXdx4l6g,2432
98
98
  plain/templates/__init__.py,sha256=bX76FakE9T7mfK3N0deN85HlwHNQpeigytSC9Z8LcOs,451
99
- plain/templates/core.py,sha256=iw58EAmyyv8N5HDA-Sq4-fLgz_qx8v8WJfurgR116jw,625
99
+ plain/templates/core.py,sha256=mbcH0yTeFOI3XOg9dYSroXRIcdv9sETEy4HzY-ugwco,1258
100
100
  plain/templates/jinja/__init__.py,sha256=xvYif0feMYR9pWjN0czthq2dk3qI4D5UQjgj9yp4dNA,2776
101
101
  plain/templates/jinja/environments.py,sha256=9plifzvQj--aTN1cCpJ2WdzQxZJpzB8S_4hghgQRQT0,2064
102
102
  plain/templates/jinja/extensions.py,sha256=AEmmmHDbdRW8fhjYDzq9eSSNbp9WHsXenD8tPthjc0s,1351
103
- plain/templates/jinja/filters.py,sha256=t_u8BkWtEpJFLbLywONxWKrenSzOvDJhfOLwlZiXHDU,968
103
+ plain/templates/jinja/filters.py,sha256=ft5XUr4OLeQayn-MSxrycpFLyyN_yEo7j5WhWMwpTOs,1445
104
104
  plain/templates/jinja/globals.py,sha256=VMpuMZvwWOmb5MbzKK-ox-QEX_WSsXFxq0mm8biJgaU,558
105
105
  plain/test/README.md,sha256=fv4YzziU2QxgcNHSgv7aDUO45sDOofVuCNrV1NPbWzo,1106
106
106
  plain/test/__init__.py,sha256=MhNHtp7MYBl9kq-pMRGY11kJ6kU1I6vOkjNkit1TYRg,94
@@ -141,7 +141,7 @@ plain/utils/timezone.py,sha256=6u0sE-9RVp0_OCe0Y1KiYYQoq5THWLokZFQYY8jf78g,6221
141
141
  plain/utils/tree.py,sha256=wdWzmfsgc26YDF2wxhAY3yVxXTixQYqYDKE9mL3L3ZY,4383
142
142
  plain/views/README.md,sha256=_jR_8_eccE1Qwc9sbUhD_hpZGGf0r-HY4W-al6kqtGs,6762
143
143
  plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
144
- plain/views/base.py,sha256=WVwJZ-N8zt0lMjVWffxglgORZBq6TeXiYmT3I8dP7fg,3430
144
+ plain/views/base.py,sha256=CC9UvMZeAjVvi90vGjoZzsQ0jnhbg3-7qCKQ8-Pb6cg,4184
145
145
  plain/views/csrf.py,sha256=7q6l5xzLWhRnMY64aNj0hR6G-3pxI2yhRwG6k_5j00E,144
146
146
  plain/views/errors.py,sha256=jbNCJIzowwCsEvqyJ3opMeZpPDqTyhtrbqb0VnAm2HE,1263
147
147
  plain/views/exceptions.py,sha256=b4euI49ZUKS9O8AGAcFfiDpstzkRAuuj_uYQXzWNHME,138
@@ -149,8 +149,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
149
149
  plain/views/objects.py,sha256=GGbcfg_9fPZ-PiaBwIHG2e__8GfWDR7JQtQ15wTyiHg,5970
150
150
  plain/views/redirect.py,sha256=daq2cQIkdDF78bt43sjuZxRAyJm_t_SKw6tyPmiXPIc,1985
151
151
  plain/views/templates.py,sha256=ivkI7LU7BXDQ0d4Geab96Is4-Cp03KbIntXRT1J8e6I,2139
152
- plain-0.52.2.dist-info/METADATA,sha256=rNNWQbg6FlIJB8mElbHG2ZVPjylzN3pOP7ukO-meXlg,4297
153
- plain-0.52.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
154
- plain-0.52.2.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
155
- plain-0.52.2.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
156
- plain-0.52.2.dist-info/RECORD,,
152
+ plain-0.54.0.dist-info/METADATA,sha256=uCFa4LMC3-o4Vbr_ZKT-Kb3DGxAy9xW0UWzrEIk3CBU,4488
153
+ plain-0.54.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
154
+ plain-0.54.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
155
+ plain-0.54.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
156
+ plain-0.54.0.dist-info/RECORD,,
File without changes