plain 0.68.1__py3-none-any.whl → 0.70.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/AGENTS.md +1 -1
- plain/CHANGELOG.md +23 -0
- plain/assets/compile.py +20 -7
- plain/assets/finders.py +15 -11
- plain/assets/fingerprints.py +6 -5
- plain/assets/urls.py +1 -1
- plain/assets/views.py +23 -17
- plain/chores/registry.py +14 -9
- plain/cli/agent/__init__.py +1 -1
- plain/cli/agent/docs.py +7 -6
- plain/cli/agent/llmdocs.py +18 -8
- plain/cli/agent/md.py +19 -14
- plain/cli/agent/prompt.py +1 -1
- plain/cli/agent/request.py +37 -17
- plain/cli/build.py +2 -2
- plain/cli/changelog.py +8 -4
- plain/cli/chores.py +4 -4
- plain/cli/core.py +8 -5
- plain/cli/docs.py +2 -2
- plain/cli/formatting.py +10 -7
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +3 -3
- plain/cli/print.py +1 -1
- plain/cli/registry.py +10 -6
- plain/cli/scaffold.py +1 -1
- plain/cli/settings.py +1 -1
- plain/cli/shell.py +10 -7
- plain/cli/startup.py +3 -3
- plain/cli/urls.py +10 -4
- plain/cli/utils.py +2 -2
- plain/csrf/middleware.py +15 -5
- plain/csrf/views.py +11 -8
- plain/debug.py +5 -2
- plain/exceptions.py +20 -51
- plain/forms/__init__.py +1 -1
- plain/forms/boundfield.py +14 -7
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +139 -97
- plain/forms/forms.py +55 -39
- plain/http/cookie.py +15 -7
- plain/http/multipartparser.py +50 -30
- plain/http/request.py +97 -73
- plain/http/response.py +99 -80
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +34 -18
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +23 -5
- plain/internal/files/uploadedfile.py +42 -26
- plain/internal/files/uploadhandler.py +48 -27
- plain/internal/files/utils.py +13 -6
- plain/internal/handlers/base.py +20 -6
- plain/internal/handlers/exception.py +19 -5
- plain/internal/handlers/wsgi.py +30 -18
- plain/internal/middleware/headers.py +11 -2
- plain/internal/middleware/hosts.py +10 -2
- plain/internal/middleware/https.py +13 -3
- plain/internal/middleware/slash.py +15 -5
- plain/json.py +2 -1
- plain/logs/configure.py +3 -1
- plain/logs/debug.py +16 -5
- plain/logs/formatters.py +6 -3
- plain/logs/loggers.py +56 -52
- plain/logs/utils.py +19 -9
- plain/packages/config.py +14 -6
- plain/packages/registry.py +27 -12
- plain/paginator.py +31 -21
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +25 -10
- plain/preflight/results.py +10 -4
- plain/preflight/security.py +7 -5
- plain/preflight/urls.py +4 -1
- plain/runtime/__init__.py +4 -3
- plain/runtime/global_settings.py +1 -1
- plain/runtime/user_settings.py +26 -17
- plain/runtime/utils.py +1 -1
- plain/signals/dispatch/dispatcher.py +39 -17
- plain/signing.py +49 -30
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +4 -3
- plain/templates/jinja/extensions.py +9 -3
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +1 -1
- plain/test/client.py +246 -174
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +10 -2
- plain/urls/converters.py +13 -10
- plain/urls/patterns.py +32 -20
- plain/urls/resolvers.py +32 -22
- plain/urls/utils.py +5 -1
- plain/utils/cache.py +14 -8
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +84 -54
- plain/utils/dateparse.py +10 -7
- plain/utils/deconstruct.py +12 -4
- plain/utils/decorators.py +5 -1
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +62 -47
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +21 -14
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +23 -13
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +34 -18
- plain/utils/timezone.py +30 -19
- plain/utils/tree.py +31 -18
- plain/validators.py +71 -44
- plain/views/base.py +16 -6
- plain/views/errors.py +11 -4
- plain/views/exceptions.py +4 -1
- plain/views/objects.py +27 -17
- plain/views/redirect.py +14 -10
- plain/views/templates.py +1 -1
- plain/wsgi.py +3 -1
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
- plain-0.70.0.dist-info/RECORD +169 -0
- plain-0.68.1.dist-info/RECORD +0 -169
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
plain/cli/agent/request.py
CHANGED
@@ -1,4 +1,7 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import json
|
4
|
+
from typing import Any
|
2
5
|
|
3
6
|
import click
|
4
7
|
|
@@ -37,7 +40,15 @@ from plain.test import Client
|
|
37
40
|
multiple=True,
|
38
41
|
help="Additional headers (format: 'Name: Value')",
|
39
42
|
)
|
40
|
-
def request(
|
43
|
+
def request(
|
44
|
+
path: str,
|
45
|
+
method: str,
|
46
|
+
data: str | None,
|
47
|
+
user_id: str | None,
|
48
|
+
follow: bool,
|
49
|
+
content_type: str | None,
|
50
|
+
headers: tuple[str, ...],
|
51
|
+
) -> None:
|
41
52
|
"""Make an HTTP request using the test client against the development database."""
|
42
53
|
|
43
54
|
try:
|
@@ -90,11 +101,11 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
90
101
|
|
91
102
|
# Make the request
|
92
103
|
method = method.upper()
|
93
|
-
kwargs = {
|
94
|
-
"path": path,
|
104
|
+
kwargs: dict[str, object] = {
|
95
105
|
"follow": follow,
|
96
|
-
"headers": header_dict or None,
|
97
106
|
}
|
107
|
+
if header_dict:
|
108
|
+
kwargs["headers"] = header_dict
|
98
109
|
|
99
110
|
if method in ("POST", "PUT", "PATCH") and data:
|
100
111
|
kwargs["data"] = data
|
@@ -103,21 +114,21 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
103
114
|
|
104
115
|
# Call the appropriate client method
|
105
116
|
if method == "GET":
|
106
|
-
response = client.get(**kwargs)
|
117
|
+
response = client.get(path, **kwargs)
|
107
118
|
elif method == "POST":
|
108
|
-
response = client.post(**kwargs)
|
119
|
+
response = client.post(path, **kwargs)
|
109
120
|
elif method == "PUT":
|
110
|
-
response = client.put(**kwargs)
|
121
|
+
response = client.put(path, **kwargs)
|
111
122
|
elif method == "PATCH":
|
112
|
-
response = client.patch(**kwargs)
|
123
|
+
response = client.patch(path, **kwargs)
|
113
124
|
elif method == "DELETE":
|
114
|
-
response = client.delete(**kwargs)
|
125
|
+
response = client.delete(path, **kwargs)
|
115
126
|
elif method == "HEAD":
|
116
|
-
response = client.head(**kwargs)
|
127
|
+
response = client.head(path, **kwargs)
|
117
128
|
elif method == "OPTIONS":
|
118
|
-
response = client.options(**kwargs)
|
129
|
+
response = client.options(path, **kwargs)
|
119
130
|
elif method == "TRACE":
|
120
|
-
response = client.trace(**kwargs)
|
131
|
+
response = client.trace(path, **kwargs)
|
121
132
|
else:
|
122
133
|
click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
|
123
134
|
return
|
@@ -135,8 +146,11 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
135
146
|
|
136
147
|
if hasattr(response, "resolver_match") and response.resolver_match:
|
137
148
|
match = response.resolver_match
|
138
|
-
|
139
|
-
|
149
|
+
namespaced_url_name = getattr(match, "namespaced_url_name", None)
|
150
|
+
url_name_attr = getattr(match, "url_name", None)
|
151
|
+
url_name = namespaced_url_name or url_name_attr
|
152
|
+
if url_name:
|
153
|
+
click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
|
140
154
|
|
141
155
|
# Show headers
|
142
156
|
if response.headers:
|
@@ -151,9 +165,15 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
151
165
|
|
152
166
|
if "json" in content_type.lower():
|
153
167
|
try:
|
154
|
-
|
155
|
-
|
156
|
-
|
168
|
+
# The test client adds a json() method to the response
|
169
|
+
json_method = getattr(response, "json", None)
|
170
|
+
if json_method and callable(json_method):
|
171
|
+
json_data: Any = json_method()
|
172
|
+
click.secho("Response Body (JSON):", fg="yellow", bold=True)
|
173
|
+
click.echo(json.dumps(json_data, indent=2))
|
174
|
+
else:
|
175
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
176
|
+
click.echo(response.content.decode("utf-8", errors="replace"))
|
157
177
|
except Exception:
|
158
178
|
click.secho("Response Body:", fg="yellow", bold=True)
|
159
179
|
click.echo(response.content.decode("utf-8", errors="replace"))
|
plain/cli/build.py
CHANGED
@@ -33,7 +33,7 @@ from plain.assets.compile import compile_assets, get_compiled_path
|
|
33
33
|
default=True,
|
34
34
|
help="Compress the assets",
|
35
35
|
)
|
36
|
-
def build(keep_original, fingerprint, compress):
|
36
|
+
def build(keep_original: bool, fingerprint: bool, compress: bool) -> None:
|
37
37
|
"""Pre-deployment build step (compile assets, css, js, etc.)"""
|
38
38
|
|
39
39
|
if not keep_original and not fingerprint:
|
@@ -79,7 +79,7 @@ def build(keep_original, fingerprint, compress):
|
|
79
79
|
total_compiled = 0
|
80
80
|
|
81
81
|
for url_path, resolved_url_path, compiled_paths in compile_assets(
|
82
|
-
target_dir=target_dir,
|
82
|
+
target_dir=str(target_dir),
|
83
83
|
keep_original=keep_original,
|
84
84
|
fingerprint=fingerprint,
|
85
85
|
compress=compress,
|
plain/cli/changelog.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import re
|
2
4
|
from importlib.util import find_spec
|
3
5
|
from pathlib import Path
|
@@ -7,7 +9,7 @@ import click
|
|
7
9
|
from .output import style_markdown
|
8
10
|
|
9
11
|
|
10
|
-
def parse_version(version_str):
|
12
|
+
def parse_version(version_str: str) -> tuple[int, ...]:
|
11
13
|
"""Parse a version string into a tuple of integers for comparison."""
|
12
14
|
# Remove 'v' prefix if present and split by dots
|
13
15
|
clean_version = version_str.lstrip("v")
|
@@ -22,7 +24,7 @@ def parse_version(version_str):
|
|
22
24
|
return tuple(parts)
|
23
25
|
|
24
26
|
|
25
|
-
def compare_versions(v1, v2):
|
27
|
+
def compare_versions(v1: str, v2: str) -> int:
|
26
28
|
"""Compare two version strings. Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2."""
|
27
29
|
parsed_v1 = parse_version(v1)
|
28
30
|
parsed_v2 = parse_version(v2)
|
@@ -44,7 +46,9 @@ def compare_versions(v1, v2):
|
|
44
46
|
@click.argument("package_label")
|
45
47
|
@click.option("--from", "from_version", help="Show entries from this version onwards")
|
46
48
|
@click.option("--to", "to_version", help="Show entries up to this version")
|
47
|
-
def changelog(
|
49
|
+
def changelog(
|
50
|
+
package_label: str, from_version: str | None, to_version: str | None
|
51
|
+
) -> None:
|
48
52
|
"""Show changelog entries for a package."""
|
49
53
|
module_name = package_label.replace("-", ".")
|
50
54
|
spec = find_spec(module_name)
|
@@ -85,7 +89,7 @@ def changelog(package_label, from_version, to_version):
|
|
85
89
|
if current_version is not None:
|
86
90
|
entries.append((current_version, current_lines))
|
87
91
|
|
88
|
-
def version_found(version):
|
92
|
+
def version_found(version: str) -> bool:
|
89
93
|
return any(compare_versions(v, version) == 0 for v, _ in entries)
|
90
94
|
|
91
95
|
if from_version and not version_found(from_version):
|
plain/cli/chores.py
CHANGED
@@ -7,7 +7,7 @@ logger = logging.getLogger("plain.chores")
|
|
7
7
|
|
8
8
|
|
9
9
|
@click.group()
|
10
|
-
def chores():
|
10
|
+
def chores() -> None:
|
11
11
|
"""Routine maintenance tasks"""
|
12
12
|
pass
|
13
13
|
|
@@ -17,7 +17,7 @@ def chores():
|
|
17
17
|
@click.option(
|
18
18
|
"--name", default=None, type=str, help="Name of the chore to run", multiple=True
|
19
19
|
)
|
20
|
-
def list_chores(group, name):
|
20
|
+
def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
|
21
21
|
"""
|
22
22
|
List all registered chores.
|
23
23
|
"""
|
@@ -50,7 +50,7 @@ def list_chores(group, name):
|
|
50
50
|
@click.option(
|
51
51
|
"--dry-run", is_flag=True, help="Show what would be done without executing"
|
52
52
|
)
|
53
|
-
def run_chores(group, name, dry_run):
|
53
|
+
def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) -> None:
|
54
54
|
"""
|
55
55
|
Run the specified chores.
|
56
56
|
"""
|
@@ -72,7 +72,7 @@ def run_chores(group, name, dry_run):
|
|
72
72
|
for chore in chores:
|
73
73
|
click.echo(f"{chore.name}:", nl=False)
|
74
74
|
if dry_run:
|
75
|
-
click.
|
75
|
+
click.secho(" (dry run)", fg="yellow", nl=False)
|
76
76
|
else:
|
77
77
|
try:
|
78
78
|
result = chore.run()
|
plain/cli/core.py
CHANGED
@@ -1,4 +1,7 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import traceback
|
4
|
+
from typing import Any
|
2
5
|
|
3
6
|
import click
|
4
7
|
from click.core import Command, Context
|
@@ -24,7 +27,7 @@ from .utils import utils
|
|
24
27
|
|
25
28
|
|
26
29
|
@click.group()
|
27
|
-
def plain_cli():
|
30
|
+
def plain_cli() -> None:
|
28
31
|
pass
|
29
32
|
|
30
33
|
|
@@ -49,14 +52,14 @@ class CLIRegistryGroup(click.Group):
|
|
49
52
|
Click Group that exposes commands from the CLI registry.
|
50
53
|
"""
|
51
54
|
|
52
|
-
def __init__(self, *args, **kwargs):
|
55
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
53
56
|
super().__init__(*args, **kwargs)
|
54
57
|
cli_registry.import_modules()
|
55
58
|
|
56
|
-
def list_commands(self, ctx):
|
59
|
+
def list_commands(self, ctx: Context) -> list[str]:
|
57
60
|
return sorted(cli_registry.get_commands().keys())
|
58
61
|
|
59
|
-
def get_command(self, ctx, name):
|
62
|
+
def get_command(self, ctx: Context, name: str) -> Command | None:
|
60
63
|
commands = cli_registry.get_commands()
|
61
64
|
return commands.get(name)
|
62
65
|
|
@@ -64,7 +67,7 @@ class CLIRegistryGroup(click.Group):
|
|
64
67
|
class PlainCommandCollection(click.CommandCollection):
|
65
68
|
context_class = PlainContext
|
66
69
|
|
67
|
-
def __init__(self, *args, **kwargs):
|
70
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
68
71
|
sources = []
|
69
72
|
|
70
73
|
try:
|
plain/cli/docs.py
CHANGED
@@ -7,9 +7,9 @@ from .output import iterate_markdown
|
|
7
7
|
|
8
8
|
|
9
9
|
@click.command()
|
10
|
-
@click.option("--open")
|
10
|
+
@click.option("--open", is_flag=True, help="Open the README in your default editor")
|
11
11
|
@click.argument("module", default="")
|
12
|
-
def docs(module, open):
|
12
|
+
def docs(module: str, open: bool) -> None:
|
13
13
|
if not module:
|
14
14
|
raise click.UsageError(
|
15
15
|
"You must specify a module. For LLM-friendly docs, use `plain agent docs`."
|
plain/cli/formatting.py
CHANGED
@@ -1,24 +1,27 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import os
|
4
|
+
from typing import Any
|
2
5
|
|
3
6
|
import click
|
4
7
|
from click.formatting import iter_rows, measure_table, term_len, wrap_text
|
5
8
|
|
6
9
|
|
7
10
|
class PlainHelpFormatter(click.HelpFormatter):
|
8
|
-
def write_heading(self, heading):
|
11
|
+
def write_heading(self, heading: str) -> None:
|
9
12
|
styled_heading = click.style(heading, underline=True)
|
10
13
|
self.write(f"{'':>{self.current_indent}}{styled_heading}\n")
|
11
14
|
|
12
|
-
def write_usage(self, prog, args, prefix="Usage: "):
|
15
|
+
def write_usage(self, prog: str, args: str = "", prefix: str = "Usage: ") -> None:
|
13
16
|
prefix_styled = click.style(prefix, italic=True)
|
14
17
|
super().write_usage(prog, args, prefix=prefix_styled)
|
15
18
|
|
16
19
|
def write_dl(
|
17
20
|
self,
|
18
|
-
rows,
|
19
|
-
col_max=30,
|
20
|
-
col_spacing=2,
|
21
|
-
):
|
21
|
+
rows: list[tuple[str, str]],
|
22
|
+
col_max: int = 30,
|
23
|
+
col_spacing: int = 2,
|
24
|
+
) -> None:
|
22
25
|
"""Writes a definition list into the buffer. This is how options
|
23
26
|
and commands are usually formatted.
|
24
27
|
|
@@ -62,7 +65,7 @@ class PlainHelpFormatter(click.HelpFormatter):
|
|
62
65
|
class PlainContext(click.Context):
|
63
66
|
formatter_class = PlainHelpFormatter
|
64
67
|
|
65
|
-
def __init__(self, *args, **kwargs):
|
68
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
66
69
|
super().__init__(*args, **kwargs)
|
67
70
|
|
68
71
|
# Force colors in CI environments
|
plain/cli/output.py
CHANGED
@@ -1,11 +1,15 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections.abc import Iterator
|
4
|
+
|
1
5
|
import click
|
2
6
|
|
3
7
|
|
4
|
-
def style_markdown(content):
|
8
|
+
def style_markdown(content: str) -> str:
|
5
9
|
return "".join(iterate_markdown(content))
|
6
10
|
|
7
11
|
|
8
|
-
def iterate_markdown(content):
|
12
|
+
def iterate_markdown(content: str) -> Iterator[str]:
|
9
13
|
"""
|
10
14
|
Iterator that does basic markdown for a Click pager.
|
11
15
|
|
plain/cli/preflight.py
CHANGED
@@ -10,7 +10,7 @@ from plain.runtime import settings
|
|
10
10
|
|
11
11
|
|
12
12
|
@click.group("preflight")
|
13
|
-
def preflight_cli():
|
13
|
+
def preflight_cli() -> None:
|
14
14
|
"""Run or manage preflight checks."""
|
15
15
|
pass
|
16
16
|
|
@@ -32,7 +32,7 @@ def preflight_cli():
|
|
32
32
|
is_flag=True,
|
33
33
|
help="Hide progress output and warnings, only show errors.",
|
34
34
|
)
|
35
|
-
def check_command(deploy, format, quiet):
|
35
|
+
def check_command(deploy: bool, format: str, quiet: bool) -> None:
|
36
36
|
"""
|
37
37
|
Use the system check framework to validate entire Plain project.
|
38
38
|
Exit with error code if any errors are found. Warnings do not cause failure.
|
@@ -203,7 +203,7 @@ def check_command(deploy, format, quiet):
|
|
203
203
|
|
204
204
|
|
205
205
|
@preflight_cli.command("list")
|
206
|
-
def list_checks():
|
206
|
+
def list_checks() -> None:
|
207
207
|
"""List all available preflight checks."""
|
208
208
|
packages_registry.autodiscover_modules("preflight", include_app=True)
|
209
209
|
|
plain/cli/print.py
CHANGED
plain/cli/registry.py
CHANGED
@@ -1,23 +1,27 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any
|
4
|
+
|
1
5
|
from plain.packages import packages_registry
|
2
6
|
|
3
7
|
|
4
8
|
class CLIRegistry:
|
5
9
|
def __init__(self):
|
6
|
-
self._commands = {}
|
10
|
+
self._commands: dict[str, Any] = {}
|
7
11
|
|
8
|
-
def register_command(self, cmd, name):
|
12
|
+
def register_command(self, cmd: Any, name: str) -> None:
|
9
13
|
"""
|
10
14
|
Register a CLI command or group with the specified name.
|
11
15
|
"""
|
12
16
|
self._commands[name] = cmd
|
13
17
|
|
14
|
-
def import_modules(self):
|
18
|
+
def import_modules(self) -> None:
|
15
19
|
"""
|
16
20
|
Import modules from installed packages and app to trigger registration.
|
17
21
|
"""
|
18
22
|
packages_registry.autodiscover_modules("cli", include_app=True)
|
19
23
|
|
20
|
-
def get_commands(self):
|
24
|
+
def get_commands(self) -> dict[str, Any]:
|
21
25
|
"""
|
22
26
|
Get all registered commands.
|
23
27
|
"""
|
@@ -27,7 +31,7 @@ class CLIRegistry:
|
|
27
31
|
cli_registry = CLIRegistry()
|
28
32
|
|
29
33
|
|
30
|
-
def register_cli(name):
|
34
|
+
def register_cli(name: str) -> Any:
|
31
35
|
"""
|
32
36
|
Register a CLI command or group with the given name.
|
33
37
|
|
@@ -38,7 +42,7 @@ def register_cli(name):
|
|
38
42
|
pass
|
39
43
|
"""
|
40
44
|
|
41
|
-
def wrapper(cmd):
|
45
|
+
def wrapper(cmd: Any) -> Any:
|
42
46
|
cli_registry.register_command(cmd, name)
|
43
47
|
return cmd
|
44
48
|
|
plain/cli/scaffold.py
CHANGED
plain/cli/settings.py
CHANGED
plain/cli/shell.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import os
|
2
4
|
import subprocess
|
3
5
|
import sys
|
@@ -17,7 +19,7 @@ import click
|
|
17
19
|
"--command",
|
18
20
|
help="Execute the given command and exit.",
|
19
21
|
)
|
20
|
-
def shell(interface, command):
|
22
|
+
def shell(interface: str | None, command: str | None) -> None:
|
21
23
|
"""
|
22
24
|
Runs a Python interactive interpreter. Tries to use IPython or
|
23
25
|
bpython, if one of them is available.
|
@@ -32,13 +34,14 @@ def shell(interface, command):
|
|
32
34
|
sys.exit(result.returncode)
|
33
35
|
return
|
34
36
|
|
37
|
+
interface_list: list[str]
|
35
38
|
if interface:
|
36
|
-
|
39
|
+
interface_list = [interface]
|
37
40
|
else:
|
38
41
|
|
39
|
-
def get_default_interface():
|
42
|
+
def get_default_interface() -> list[str]:
|
40
43
|
try:
|
41
|
-
import IPython # noqa
|
44
|
+
import IPython # noqa: F401 # type: ignore[import-not-found]
|
42
45
|
|
43
46
|
return ["python", "-m", "IPython"]
|
44
47
|
except ImportError:
|
@@ -46,10 +49,10 @@ def shell(interface, command):
|
|
46
49
|
|
47
50
|
return ["python"]
|
48
51
|
|
49
|
-
|
52
|
+
interface_list = get_default_interface()
|
50
53
|
|
51
54
|
result = subprocess.run(
|
52
|
-
|
55
|
+
interface_list,
|
53
56
|
env={
|
54
57
|
"PYTHONSTARTUP": os.path.join(os.path.dirname(__file__), "startup.py"),
|
55
58
|
**os.environ,
|
@@ -61,7 +64,7 @@ def shell(interface, command):
|
|
61
64
|
|
62
65
|
@click.command()
|
63
66
|
@click.argument("script", nargs=1, type=click.Path(exists=True))
|
64
|
-
def run(script):
|
67
|
+
def run(script: str) -> None:
|
65
68
|
"""Run a Python script in the context of your app"""
|
66
69
|
before_script = "import plain.runtime; plain.runtime.setup()"
|
67
70
|
command = f"{before_script}; exec(open('{script}').read())"
|
plain/cli/startup.py
CHANGED
@@ -3,19 +3,19 @@ import plain.runtime
|
|
3
3
|
plain.runtime.setup()
|
4
4
|
|
5
5
|
|
6
|
-
def print_bold(s):
|
6
|
+
def print_bold(s: str) -> None:
|
7
7
|
print("\033[1m", end="")
|
8
8
|
print(s)
|
9
9
|
print("\033[0m", end="")
|
10
10
|
|
11
11
|
|
12
|
-
def print_italic(s):
|
12
|
+
def print_italic(s: str) -> None:
|
13
13
|
print("\x1b[3m", end="")
|
14
14
|
print(s)
|
15
15
|
print("\x1b[0m", end="")
|
16
16
|
|
17
17
|
|
18
|
-
def print_dim(s):
|
18
|
+
def print_dim(s: str) -> None:
|
19
19
|
print("\x1b[2m", end="")
|
20
20
|
print(s)
|
21
21
|
print("\x1b[0m", end="")
|
plain/cli/urls.py
CHANGED
@@ -1,15 +1,19 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections.abc import Iterator
|
4
|
+
|
1
5
|
import click
|
2
6
|
|
3
7
|
|
4
8
|
@click.group()
|
5
|
-
def urls():
|
9
|
+
def urls() -> None:
|
6
10
|
"""URL related commands"""
|
7
11
|
pass
|
8
12
|
|
9
13
|
|
10
14
|
@urls.command("list")
|
11
15
|
@click.option("--flat", is_flag=True, help="List all URLs in a flat list")
|
12
|
-
def list_urls(flat):
|
16
|
+
def list_urls(flat: bool) -> None:
|
13
17
|
"""Print all URL patterns under settings.URLS_ROUTER"""
|
14
18
|
from plain.runtime import settings
|
15
19
|
from plain.urls import URLResolver, get_resolver
|
@@ -20,7 +24,9 @@ def list_urls(flat):
|
|
20
24
|
resolver = get_resolver(settings.URLS_ROUTER)
|
21
25
|
if flat:
|
22
26
|
|
23
|
-
def flat_list(
|
27
|
+
def flat_list(
|
28
|
+
patterns: list, prefix: str = "", curr_ns: str = ""
|
29
|
+
) -> Iterator[str]:
|
24
30
|
for pattern in patterns:
|
25
31
|
full_pattern = f"{prefix}{pattern.pattern}"
|
26
32
|
if isinstance(pattern, URLResolver):
|
@@ -50,7 +56,7 @@ def list_urls(flat):
|
|
50
56
|
click.echo(p)
|
51
57
|
else:
|
52
58
|
|
53
|
-
def print_tree(patterns, prefix="", curr_ns=""):
|
59
|
+
def print_tree(patterns: list, prefix: str = "", curr_ns: str = "") -> None:
|
54
60
|
count = len(patterns)
|
55
61
|
for idx, pattern in enumerate(patterns):
|
56
62
|
is_last = idx == (count - 1)
|
plain/cli/utils.py
CHANGED
@@ -4,12 +4,12 @@ from plain.utils.crypto import get_random_string
|
|
4
4
|
|
5
5
|
|
6
6
|
@click.group()
|
7
|
-
def utils():
|
7
|
+
def utils() -> None:
|
8
8
|
pass
|
9
9
|
|
10
10
|
|
11
11
|
@utils.command()
|
12
|
-
def generate_secret_key():
|
12
|
+
def generate_secret_key() -> None:
|
13
13
|
"""Generate a new secret key"""
|
14
14
|
new_secret_key = get_random_string(50)
|
15
15
|
click.echo(new_secret_key)
|
plain/csrf/middleware.py
CHANGED
@@ -1,5 +1,9 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import logging
|
2
4
|
import re
|
5
|
+
from collections.abc import Callable
|
6
|
+
from typing import TYPE_CHECKING
|
3
7
|
from urllib.parse import urlparse
|
4
8
|
|
5
9
|
from plain.logs.utils import log_response
|
@@ -7,6 +11,10 @@ from plain.runtime import settings
|
|
7
11
|
|
8
12
|
from .views import CsrfFailureView
|
9
13
|
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from plain.http import Response
|
16
|
+
from plain.http.request import HttpRequest
|
17
|
+
|
10
18
|
logger = logging.getLogger("plain.security.csrf")
|
11
19
|
|
12
20
|
|
@@ -19,13 +27,15 @@ class CsrfViewMiddleware:
|
|
19
27
|
like subdomains can have different trust levels and are rejected.
|
20
28
|
"""
|
21
29
|
|
22
|
-
def __init__(self, get_response):
|
30
|
+
def __init__(self, get_response: Callable[[HttpRequest], Response]):
|
23
31
|
self.get_response = get_response
|
24
32
|
|
25
33
|
# Compile CSRF exempt patterns once for performance
|
26
|
-
self.csrf_exempt_patterns
|
34
|
+
self.csrf_exempt_patterns: list[re.Pattern[str]] = [
|
35
|
+
re.compile(r) for r in settings.CSRF_EXEMPT_PATHS
|
36
|
+
]
|
27
37
|
|
28
|
-
def __call__(self, request):
|
38
|
+
def __call__(self, request: HttpRequest) -> Response:
|
29
39
|
allowed, reason = self.should_allow_request(request)
|
30
40
|
|
31
41
|
if allowed:
|
@@ -33,7 +43,7 @@ class CsrfViewMiddleware:
|
|
33
43
|
else:
|
34
44
|
return self.reject(request, reason)
|
35
45
|
|
36
|
-
def should_allow_request(self, request) -> tuple[bool, str]:
|
46
|
+
def should_allow_request(self, request: HttpRequest) -> tuple[bool, str]:
|
37
47
|
# 1. Allow safe methods (GET, HEAD, OPTIONS)
|
38
48
|
if request.method in ("GET", "HEAD", "OPTIONS"):
|
39
49
|
return True, f"Safe HTTP method: {request.method}"
|
@@ -118,7 +128,7 @@ class CsrfViewMiddleware:
|
|
118
128
|
f"Cross-origin request detected - Origin {origin} does not match Host",
|
119
129
|
)
|
120
130
|
|
121
|
-
def reject(self, request, reason):
|
131
|
+
def reject(self, request: HttpRequest, reason: str) -> Response:
|
122
132
|
"""Reject a request with a 403 Forbidden response."""
|
123
133
|
|
124
134
|
response = CsrfFailureView.as_view()(request, reason=reason)
|
plain/csrf/views.py
CHANGED
@@ -1,31 +1,34 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from plain.http import Response
|
1
4
|
from plain.views import TemplateView
|
2
5
|
|
3
6
|
|
4
7
|
class CsrfFailureView(TemplateView):
|
5
8
|
template_name = "403.html"
|
6
9
|
|
7
|
-
def get_response(self):
|
10
|
+
def get_response(self) -> Response:
|
8
11
|
response = super().get_response()
|
9
12
|
response.status_code = 403
|
10
13
|
return response
|
11
14
|
|
12
|
-
def post(self):
|
15
|
+
def post(self) -> Response:
|
13
16
|
return self.get()
|
14
17
|
|
15
|
-
def put(self):
|
18
|
+
def put(self) -> Response:
|
16
19
|
return self.get()
|
17
20
|
|
18
|
-
def patch(self):
|
21
|
+
def patch(self) -> Response:
|
19
22
|
return self.get()
|
20
23
|
|
21
|
-
def delete(self):
|
24
|
+
def delete(self) -> Response:
|
22
25
|
return self.get()
|
23
26
|
|
24
|
-
def head(self):
|
27
|
+
def head(self) -> Response:
|
25
28
|
return self.get()
|
26
29
|
|
27
|
-
def options(self):
|
30
|
+
def options(self) -> Response:
|
28
31
|
return self.get()
|
29
32
|
|
30
|
-
def trace(self):
|
33
|
+
def trace(self) -> Response:
|
31
34
|
return self.get()
|