plain 0.52.2__tar.gz → 0.54.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plain-0.52.2 → plain-0.54.0}/.gitignore +2 -1
- {plain-0.52.2 → plain-0.54.0}/PKG-INFO +4 -1
- {plain-0.52.2 → plain-0.54.0}/plain/CHANGELOG.md +24 -0
- {plain-0.52.2 → plain-0.54.0}/plain/README.md +1 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/build.py +2 -5
- {plain-0.52.2 → plain-0.54.0}/plain/cli/docs.py +3 -7
- {plain-0.52.2 → plain-0.54.0}/plain/cli/preflight.py +1 -1
- {plain-0.52.2 → plain-0.54.0}/plain/cli/urls.py +1 -4
- plain-0.54.0/plain/http/cookie.py +64 -0
- {plain-0.52.2 → plain-0.54.0}/plain/http/request.py +15 -0
- {plain-0.52.2 → plain-0.54.0}/plain/http/response.py +5 -12
- {plain-0.52.2 → plain-0.54.0}/plain/internal/handlers/base.py +64 -13
- plain-0.54.0/plain/templates/core.py +42 -0
- {plain-0.52.2 → plain-0.54.0}/plain/templates/jinja/filters.py +20 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/base.py +26 -3
- {plain-0.52.2 → plain-0.54.0}/pyproject.toml +3 -1
- plain-0.54.0/tests/.gitignore +1 -0
- plain-0.52.2/plain/http/cookie.py +0 -20
- plain-0.52.2/plain/templates/core.py +0 -24
- plain-0.52.2/tests/.gitignore +0 -3
- {plain-0.52.2 → plain-0.54.0}/LICENSE +0 -0
- {plain-0.52.2 → plain-0.54.0}/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/__main__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/assets/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/assets/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/assets/compile.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/assets/finders.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/assets/fingerprints.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/assets/urls.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/assets/views.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/chores/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/chores/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/chores/registry.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/changelog.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/chores.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/core.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/formatting.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/help.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/output.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/print.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/registry.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/scaffold.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/settings.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/shell.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/startup.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/cli/utils.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/csrf/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/csrf/middleware.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/csrf/views.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/debug.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/exceptions.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/forms/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/forms/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/forms/boundfield.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/forms/exceptions.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/forms/fields.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/forms/forms.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/http/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/http/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/http/multipartparser.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/base.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/locks.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/move.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/temp.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/files/utils.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/handlers/exception.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/middleware/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/middleware/headers.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/middleware/https.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/internal/middleware/slash.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/json.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/logs/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/logs/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/logs/configure.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/logs/loggers.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/logs/utils.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/packages/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/packages/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/packages/config.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/packages/registry.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/paginator.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/preflight/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/preflight/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/preflight/files.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/preflight/messages.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/preflight/registry.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/preflight/security.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/preflight/urls.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/runtime/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/runtime/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/runtime/global_settings.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/runtime/user_settings.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/signals/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/signals/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/signing.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/templates/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/templates/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/templates/jinja/environments.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/templates/jinja/globals.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/test/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/test/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/test/client.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/test/encoding.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/test/exceptions.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/converters.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/exceptions.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/patterns.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/resolvers.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/routers.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/urls/utils.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/cache.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/crypto.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/datastructures.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/dateparse.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/deconstruct.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/decorators.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/duration.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/encoding.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/functional.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/hashable.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/html.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/http.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/inspect.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/ipv6.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/itercompat.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/module_loading.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/regex_helper.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/safestring.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/text.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/timesince.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/timezone.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/utils/tree.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/validators.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/README.md +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/csrf.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/errors.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/exceptions.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/forms.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/objects.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/redirect.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/views/templates.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/plain/wsgi.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/app/.gitignore +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/app/settings.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/app/test/__init__.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/app/test/default_settings.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/app/urls.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/conftest.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/test_cli.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/test_runtime.py +0 -0
- {plain-0.52.2 → plain-0.54.0}/tests/test_wsgi.py +0 -0
@@ -1,12 +1,14 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: plain
|
3
|
-
Version: 0.
|
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,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
|
@@ -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
|
|
@@ -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.
|
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"
|
@@ -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.
|
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.
|
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.
|
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))
|
@@ -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("✔
|
126
|
+
click.secho("✔ Checks passed", err=True, fg="green")
|
@@ -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.
|
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:
|
@@ -0,0 +1,64 @@
|
|
1
|
+
from http import cookies
|
2
|
+
|
3
|
+
from plain.runtime import settings
|
4
|
+
from plain.signing import BadSignature, TimestampSigner
|
5
|
+
from plain.utils.encoding import force_bytes
|
6
|
+
|
7
|
+
|
8
|
+
def parse_cookie(cookie):
|
9
|
+
"""
|
10
|
+
Return a dictionary parsed from a `Cookie:` header string.
|
11
|
+
"""
|
12
|
+
cookiedict = {}
|
13
|
+
for chunk in cookie.split(";"):
|
14
|
+
if "=" in chunk:
|
15
|
+
key, val = chunk.split("=", 1)
|
16
|
+
else:
|
17
|
+
# Assume an empty name per
|
18
|
+
# https://bugzilla.mozilla.org/show_bug.cgi?id=169091
|
19
|
+
key, val = "", chunk
|
20
|
+
key, val = key.strip(), val.strip()
|
21
|
+
if key or val:
|
22
|
+
# unquote using Python's algorithm.
|
23
|
+
cookiedict[key] = cookies._unquote(val)
|
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
|
@@ -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_"
|
@@ -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
|
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
|
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
|
-
|
264
|
-
|
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
|
-
|
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
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
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
|
|
@@ -0,0 +1,42 @@
|
|
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
|
+
)
|
7
|
+
|
8
|
+
from .jinja import environment
|
9
|
+
|
10
|
+
tracer = trace.get_tracer("plain")
|
11
|
+
|
12
|
+
|
13
|
+
class TemplateFileMissing(Exception):
|
14
|
+
def __str__(self) -> str:
|
15
|
+
if self.args:
|
16
|
+
return f"Template file {self.args[0]} not found"
|
17
|
+
else:
|
18
|
+
return "Template file not found"
|
19
|
+
|
20
|
+
|
21
|
+
class Template:
|
22
|
+
def __init__(self, filename: str) -> None:
|
23
|
+
self.filename = filename
|
24
|
+
|
25
|
+
try:
|
26
|
+
self._jinja_template = environment.get_template(filename)
|
27
|
+
except jinja2.TemplateNotFound:
|
28
|
+
raise TemplateFileMissing(filename)
|
29
|
+
|
30
|
+
def render(self, context: dict) -> str:
|
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
|
}
|
@@ -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
|
-
|
39
|
-
|
40
|
-
|
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
|
[project]
|
2
2
|
name = "plain"
|
3
|
-
version = "0.
|
3
|
+
version = "0.54.0"
|
4
4
|
description = "A web framework for building products with Python."
|
5
5
|
authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
|
6
6
|
readme = "README.md"
|
7
7
|
dependencies = [
|
8
8
|
"jinja2>=3.1.2",
|
9
9
|
"click>=8.0.0",
|
10
|
+
"opentelemetry-api>=1.34.1",
|
11
|
+
"opentelemetry-semantic-conventions>=0.55b1",
|
10
12
|
]
|
11
13
|
requires-python = ">=3.11"
|
12
14
|
|
@@ -0,0 +1 @@
|
|
1
|
+
.plain
|
@@ -1,20 +0,0 @@
|
|
1
|
-
from http import cookies
|
2
|
-
|
3
|
-
|
4
|
-
def parse_cookie(cookie):
|
5
|
-
"""
|
6
|
-
Return a dictionary parsed from a `Cookie:` header string.
|
7
|
-
"""
|
8
|
-
cookiedict = {}
|
9
|
-
for chunk in cookie.split(";"):
|
10
|
-
if "=" in chunk:
|
11
|
-
key, val = chunk.split("=", 1)
|
12
|
-
else:
|
13
|
-
# Assume an empty name per
|
14
|
-
# https://bugzilla.mozilla.org/show_bug.cgi?id=169091
|
15
|
-
key, val = "", chunk
|
16
|
-
key, val = key.strip(), val.strip()
|
17
|
-
if key or val:
|
18
|
-
# unquote using Python's algorithm.
|
19
|
-
cookiedict[key] = cookies._unquote(val)
|
20
|
-
return cookiedict
|
@@ -1,24 +0,0 @@
|
|
1
|
-
import jinja2
|
2
|
-
|
3
|
-
from .jinja import environment
|
4
|
-
|
5
|
-
|
6
|
-
class TemplateFileMissing(Exception):
|
7
|
-
def __str__(self) -> str:
|
8
|
-
if self.args:
|
9
|
-
return f"Template file {self.args[0]} not found"
|
10
|
-
else:
|
11
|
-
return "Template file not found"
|
12
|
-
|
13
|
-
|
14
|
-
class Template:
|
15
|
-
def __init__(self, filename: str) -> None:
|
16
|
-
self.filename = filename
|
17
|
-
|
18
|
-
try:
|
19
|
-
self._jinja_template = environment.get_template(filename)
|
20
|
-
except jinja2.TemplateNotFound:
|
21
|
-
raise TemplateFileMissing(filename)
|
22
|
-
|
23
|
-
def render(self, context: dict) -> str:
|
24
|
-
return self._jinja_template.render(context)
|
plain-0.52.2/tests/.gitignore
DELETED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|