plain 0.69.0__py3-none-any.whl → 0.70.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +11 -0
  3. plain/assets/compile.py +20 -7
  4. plain/assets/finders.py +15 -11
  5. plain/assets/fingerprints.py +6 -5
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +23 -17
  8. plain/chores/registry.py +14 -9
  9. plain/cli/agent/__init__.py +1 -1
  10. plain/cli/agent/docs.py +7 -6
  11. plain/cli/agent/llmdocs.py +18 -8
  12. plain/cli/agent/md.py +19 -14
  13. plain/cli/agent/prompt.py +1 -1
  14. plain/cli/agent/request.py +37 -17
  15. plain/cli/build.py +2 -2
  16. plain/cli/changelog.py +8 -4
  17. plain/cli/chores.py +4 -4
  18. plain/cli/core.py +8 -5
  19. plain/cli/docs.py +2 -2
  20. plain/cli/formatting.py +10 -7
  21. plain/cli/output.py +6 -2
  22. plain/cli/preflight.py +3 -3
  23. plain/cli/print.py +1 -1
  24. plain/cli/registry.py +10 -6
  25. plain/cli/scaffold.py +1 -1
  26. plain/cli/settings.py +1 -1
  27. plain/cli/shell.py +10 -7
  28. plain/cli/startup.py +3 -3
  29. plain/cli/urls.py +10 -4
  30. plain/cli/utils.py +2 -2
  31. plain/csrf/middleware.py +15 -5
  32. plain/csrf/views.py +11 -8
  33. plain/debug.py +5 -2
  34. plain/exceptions.py +19 -8
  35. plain/forms/__init__.py +1 -1
  36. plain/forms/boundfield.py +14 -7
  37. plain/forms/exceptions.py +1 -1
  38. plain/forms/fields.py +139 -97
  39. plain/forms/forms.py +55 -39
  40. plain/http/cookie.py +15 -7
  41. plain/http/multipartparser.py +50 -30
  42. plain/http/request.py +97 -73
  43. plain/http/response.py +99 -80
  44. plain/internal/__init__.py +8 -1
  45. plain/internal/files/base.py +34 -18
  46. plain/internal/files/locks.py +19 -11
  47. plain/internal/files/move.py +8 -3
  48. plain/internal/files/temp.py +23 -5
  49. plain/internal/files/uploadedfile.py +42 -26
  50. plain/internal/files/uploadhandler.py +48 -27
  51. plain/internal/files/utils.py +13 -6
  52. plain/internal/handlers/base.py +20 -6
  53. plain/internal/handlers/exception.py +19 -5
  54. plain/internal/handlers/wsgi.py +30 -18
  55. plain/internal/middleware/headers.py +11 -2
  56. plain/internal/middleware/hosts.py +10 -2
  57. plain/internal/middleware/https.py +13 -3
  58. plain/internal/middleware/slash.py +15 -5
  59. plain/json.py +2 -1
  60. plain/logs/configure.py +3 -1
  61. plain/logs/debug.py +16 -5
  62. plain/logs/formatters.py +6 -3
  63. plain/logs/loggers.py +56 -52
  64. plain/logs/utils.py +19 -9
  65. plain/packages/config.py +14 -6
  66. plain/packages/registry.py +27 -12
  67. plain/paginator.py +31 -21
  68. plain/preflight/checks.py +3 -1
  69. plain/preflight/files.py +3 -1
  70. plain/preflight/registry.py +25 -10
  71. plain/preflight/results.py +10 -4
  72. plain/preflight/security.py +7 -5
  73. plain/preflight/urls.py +4 -1
  74. plain/runtime/__init__.py +4 -3
  75. plain/runtime/global_settings.py +1 -1
  76. plain/runtime/user_settings.py +26 -17
  77. plain/runtime/utils.py +1 -1
  78. plain/signals/dispatch/dispatcher.py +39 -17
  79. plain/signing.py +49 -30
  80. plain/templates/jinja/__init__.py +13 -5
  81. plain/templates/jinja/environments.py +4 -3
  82. plain/templates/jinja/extensions.py +9 -3
  83. plain/templates/jinja/filters.py +7 -2
  84. plain/templates/jinja/globals.py +1 -1
  85. plain/test/client.py +246 -174
  86. plain/test/encoding.py +9 -6
  87. plain/test/exceptions.py +10 -2
  88. plain/urls/converters.py +13 -10
  89. plain/urls/patterns.py +32 -20
  90. plain/urls/resolvers.py +32 -22
  91. plain/urls/utils.py +5 -1
  92. plain/utils/cache.py +14 -8
  93. plain/utils/crypto.py +21 -5
  94. plain/utils/datastructures.py +84 -54
  95. plain/utils/dateparse.py +10 -7
  96. plain/utils/deconstruct.py +12 -4
  97. plain/utils/decorators.py +5 -1
  98. plain/utils/duration.py +8 -4
  99. plain/utils/encoding.py +14 -7
  100. plain/utils/functional.py +62 -47
  101. plain/utils/hashable.py +5 -1
  102. plain/utils/html.py +21 -14
  103. plain/utils/http.py +16 -9
  104. plain/utils/inspect.py +14 -6
  105. plain/utils/ipv6.py +7 -3
  106. plain/utils/itercompat.py +6 -1
  107. plain/utils/module_loading.py +7 -3
  108. plain/utils/regex_helper.py +23 -13
  109. plain/utils/safestring.py +14 -6
  110. plain/utils/text.py +34 -18
  111. plain/utils/timezone.py +30 -19
  112. plain/utils/tree.py +31 -18
  113. plain/validators.py +71 -44
  114. plain/views/base.py +16 -6
  115. plain/views/errors.py +11 -4
  116. plain/views/exceptions.py +4 -1
  117. plain/views/objects.py +15 -15
  118. plain/views/redirect.py +14 -10
  119. plain/views/templates.py +1 -1
  120. plain/wsgi.py +3 -1
  121. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
  122. plain-0.70.0.dist-info/RECORD +169 -0
  123. plain-0.69.0.dist-info/RECORD +0 -169
  124. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
  125. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
  126. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
@@ -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(path, method, data, user_id, follow, content_type, headers):
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
- url_name = match.namespaced_url_name or match.url_name or "unnamed"
139
- click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
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
- json_data = response.json()
155
- click.secho("Response Body (JSON):", fg="yellow", bold=True)
156
- click.echo(json.dumps(json_data, indent=2))
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(package_label, from_version, to_version):
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.echo(" (dry run)", fg="yellow")
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
@@ -1,7 +1,7 @@
1
1
  import click
2
2
 
3
3
 
4
- def print_event(msg, newline=True):
4
+ def print_event(msg: str, newline: bool = True) -> None:
5
5
  arrow = click.style("-->", fg=214, bold=True)
6
6
  message = str(msg)
7
7
  if not newline:
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
@@ -7,7 +7,7 @@ import plain.runtime
7
7
 
8
8
  @click.command()
9
9
  @click.argument("package_name")
10
- def create(package_name):
10
+ def create(package_name: str) -> None:
11
11
  """
12
12
  Create a new local package.
13
13
 
plain/cli/settings.py CHANGED
@@ -5,7 +5,7 @@ import plain.runtime
5
5
 
6
6
  @click.command()
7
7
  @click.argument("setting_name")
8
- def setting(setting_name):
8
+ def setting(setting_name: str) -> None:
9
9
  """Print the value of a setting at runtime"""
10
10
  try:
11
11
  setting = getattr(plain.runtime.settings, setting_name)
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
- interface = [interface]
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
- interface = get_default_interface()
52
+ interface_list = get_default_interface()
50
53
 
51
54
  result = subprocess.run(
52
- interface,
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(patterns, prefix="", curr_ns=""):
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 = [re.compile(r) for r in settings.CSRF_EXEMPT_PATHS]
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()