plain 0.68.0__py3-none-any.whl → 0.103.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 (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/README.md CHANGED
@@ -31,7 +31,7 @@ The `plain` package includes everything you need to start handling web requests
31
31
  - [plain.cache](/plain-cache/plain/cache/README.md) - A database-driven general purpose cache.
32
32
  - [plain.email](/plain-email/plain/email/README.md) - Send emails with SMTP or custom backends.
33
33
  - [plain.sessions](/plain-sessions/plain/sessions/README.md) - User sessions and cookies.
34
- - [plain.worker](/plain-worker/plain/worker/README.md) - Background jobs stored in the database.
34
+ - [plain.jobs](/plain-jobs/plain/jobs/README.md) - Background jobs stored in the database.
35
35
  - [plain.api](/plain-api/plain/api/README.md) - Build APIs with Plain views.
36
36
 
37
37
  ## Auth Packages
@@ -0,0 +1,88 @@
1
+ # Plain Framework
2
+
3
+ Plain is a Python web framework.
4
+
5
+ - Always use `uv run` to execute commands — never use bare `python` or `plain` directly.
6
+ - Plain is a Django fork but has different APIs — never assume Django patterns will work.
7
+ - When unsure about an API or something doesn't work, run `uv run plain docs <package>` first. Add `--symbols` if you need the full API surface.
8
+ - Use the `/plain-install` skill to add new Plain packages.
9
+ - Use the `/plain-upgrade` skill to upgrade Plain packages.
10
+
11
+ ## Documentation
12
+
13
+ Run `uv run plain docs --list` to see all official packages (installed and uninstalled) with descriptions.
14
+ Run `uv run plain docs <package>` for markdown documentation (installed packages only).
15
+ Run `uv run plain docs <package> --symbols` for the symbolicated API surface.
16
+ For uninstalled packages, the CLI shows the install command and an online docs URL.
17
+
18
+ Online docs URL pattern: `https://plainframework.com/docs/<pip-name>/<module/path>/README.md`
19
+ Example: `https://plainframework.com/docs/plain-models/plain/models/README.md`
20
+
21
+ Examples:
22
+
23
+ - `uv run plain docs models` - Models and database docs
24
+ - `uv run plain docs models --symbols` - Models API surface
25
+ - `uv run plain docs templates` - Jinja2 templates
26
+ - `uv run plain docs assets` - Static assets
27
+
28
+ ### All official packages
29
+
30
+ - **plain** — Web framework core
31
+ - **plain-admin** — Backend admin interface
32
+ - **plain-api** — Class-based API views
33
+ - **plain-auth** — User authentication and authorization
34
+ - **plain-cache** — Database-backed cache with optional expiration
35
+ - **plain-code** — Preconfigured code formatting and linting
36
+ - **plain-dev** — Local development server with auto-reload
37
+ - **plain-elements** — HTML template components
38
+ - **plain-email** — Send email
39
+ - **plain-esbuild** — Build JavaScript with esbuild
40
+ - **plain-flags** — Feature flags via database models
41
+ - **plain-htmx** — HTMX integration for templates and views
42
+ - **plain-jobs** — Background jobs with a database-driven queue
43
+ - **plain-loginlink** — Link-based authentication
44
+ - **plain-models** — Model data and store it in a database
45
+ - **plain-oauth** — OAuth provider login
46
+ - **plain-observer** — On-page telemetry and observability
47
+ - **plain-pages** — Serve static pages, markdown, and assets
48
+ - **plain-pageviews** — Client-side pageview tracking
49
+ - **plain-passwords** — Password authentication
50
+ - **plain-pytest** — Test with pytest
51
+ - **plain-redirection** — URL redirection with admin and logging
52
+ - **plain-scan** — Test for production best practices
53
+ - **plain-sessions** — Database-backed sessions
54
+ - **plain-start** — Bootstrap a new project from templates
55
+ - **plain-support** — Support forms for your application
56
+ - **plain-tailwind** — Tailwind CSS without JavaScript or npm
57
+ - **plain-toolbar** — Debug toolbar
58
+ - **plain-tunnel** — Remote access to local dev server
59
+ - **plain-vendor** — Vendor CDN scripts and styles
60
+
61
+ ## Shell
62
+
63
+ `uv run plain shell` opens an interactive Python shell with Plain configured and database access.
64
+
65
+ Run a one-off command:
66
+
67
+ ```
68
+ uv run plain shell -c "from app.users.models import User; print(User.query.count())"
69
+ ```
70
+
71
+ Run a script:
72
+
73
+ ```
74
+ uv run plain run script.py
75
+ ```
76
+
77
+ ## HTTP Requests
78
+
79
+ Use `uv run plain request` to make test HTTP requests against the dev database.
80
+
81
+ ```
82
+ uv run plain request /path
83
+ uv run plain request /path --user 1
84
+ uv run plain request /path --header "Accept: application/json"
85
+ uv run plain request /path --method POST --data '{"key": "value"}'
86
+ uv run plain request /path --no-body # Headers only
87
+ uv run plain request /path --no-headers # Body only
88
+ ```
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: plain-install
3
+ description: Installs Plain packages and guides through setup steps. Use when adding new packages to a project.
4
+ ---
5
+
6
+ # Install Plain Packages
7
+
8
+ ## 1. Install the package(s)
9
+
10
+ ```
11
+ uv run plain install <package-name> [additional-packages...]
12
+ ```
13
+
14
+ ## 2. Complete setup for each package
15
+
16
+ 1. Run `uv run plain docs <package>` and read the installation instructions
17
+ 2. If the docs indicate it's a dev tool, move it: `uv remove <package> && uv add <package> --dev`
18
+ 3. Complete any code modifications from the installation instructions
19
+
20
+ ## Guidelines
21
+
22
+ - DO NOT commit any changes
23
+ - Report back with:
24
+ - Whether setup completed successfully
25
+ - Any manual steps the user needs to complete
26
+ - Any issues or errors encountered
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: plain-upgrade
3
+ description: Upgrades Plain packages and applies required migration changes. Use when updating to newer package versions.
4
+ ---
5
+
6
+ # Upgrade Plain Packages
7
+
8
+ ## 1. Run the upgrade
9
+
10
+ ```
11
+ uv run plain upgrade [package-names...]
12
+ ```
13
+
14
+ This will show which packages were upgraded (e.g., `plain-models: 0.1.0 -> 0.2.0`).
15
+
16
+ ## 2. Apply code changes for each upgraded package
17
+
18
+ For each package that was upgraded:
19
+
20
+ 1. Run `uv run plain changelog <package> --from <old-version> --to <new-version>`
21
+ 2. Read the "Upgrade instructions" section
22
+ 3. If it says "No changes required", skip to next package
23
+ 4. Apply any required code changes
24
+
25
+ ## 3. Validate
26
+
27
+ 1. Run `uv run plain fix` to fix formatting
28
+ 2. Run `uv run plain preflight` to validate configuration
29
+
30
+ ## Guidelines
31
+
32
+ - Process ALL packages before testing
33
+ - DO NOT commit any changes
34
+ - Keep code changes minimal and focused
35
+ - Report any issues or conflicts encountered
plain/assets/compile.py CHANGED
@@ -1,13 +1,17 @@
1
+ from __future__ import annotations
2
+
1
3
  import gzip
2
4
  import os
3
5
  import shutil
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
4
8
 
5
9
  from plain.runtime import PLAIN_TEMP_PATH
6
10
 
7
- from .finders import iter_assets
8
- from .fingerprints import AssetsFingerprintsManifest, get_file_fingerprint
11
+ from .finders import Asset, _iter_assets
12
+ from .fingerprints import AssetsFingerprintsManifest, _get_file_fingerprint
9
13
 
10
- SKIP_COMPRESS_EXTENSIONS = (
14
+ _SKIP_COMPRESS_EXTENSIONS = (
11
15
  # Images
12
16
  ".jpg",
13
17
  ".jpeg",
@@ -40,7 +44,7 @@ SKIP_COMPRESS_EXTENSIONS = (
40
44
  )
41
45
 
42
46
 
43
- def get_compiled_path():
47
+ def get_compiled_path() -> Path:
44
48
  """
45
49
  Get the path at runtime to the compiled assets directory.
46
50
 
@@ -49,14 +53,16 @@ def get_compiled_path():
49
53
  return PLAIN_TEMP_PATH / "assets" / "compiled"
50
54
 
51
55
 
52
- def compile_assets(*, target_dir, keep_original, fingerprint, compress):
56
+ def compile_assets(
57
+ *, target_dir: str, keep_original: bool, fingerprint: bool, compress: bool
58
+ ) -> Iterator[tuple[str, str, list[str]]]:
53
59
  """
54
60
  Compile all assets to the target directory and save a JSON manifest
55
61
  mapping the original filenames to the compiled filenames.
56
62
  """
57
63
  manifest = AssetsFingerprintsManifest()
58
64
 
59
- for asset in iter_assets():
65
+ for asset in _iter_assets():
60
66
  url_path = asset.url_path
61
67
  resolved_path, compiled_paths = compile_asset(
62
68
  asset=asset,
@@ -73,17 +79,24 @@ def compile_assets(*, target_dir, keep_original, fingerprint, compress):
73
79
  manifest.save()
74
80
 
75
81
 
76
- def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
82
+ def compile_asset(
83
+ *,
84
+ asset: Asset,
85
+ target_dir: str,
86
+ keep_original: bool,
87
+ fingerprint: bool,
88
+ compress: bool,
89
+ ) -> tuple[str, list[str]]:
77
90
  """
78
91
  Compile an asset to multiple output paths.
79
92
  """
80
- compiled_paths = []
93
+ compiled_paths: list[str] = []
81
94
 
82
95
  # The expected destination for the original asset
83
96
  target_path = os.path.join(target_dir, asset.url_path)
84
97
 
85
98
  # Keep track of where the final, resolved asset ends up
86
- resolved_url_path = asset.url_path
99
+ resolved_url_path: str = asset.url_path
87
100
 
88
101
  # Make sure all the expected directories exist
89
102
  os.makedirs(os.path.dirname(target_path), exist_ok=True)
@@ -99,16 +112,16 @@ def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
99
112
  # Fingerprint it with an md5 hash
100
113
  # (maybe need a setting with fnmatch patterns for files to NOT fingerprint?
101
114
  # that would allow pre-fingerprinted files to be used as-is, and keep source maps etc in tact)
102
- fingerprint_hash = get_file_fingerprint(asset.absolute_path)
115
+ fingerprint_hash = _get_file_fingerprint(asset.absolute_path)
103
116
 
104
117
  fingerprinted_basename = f"{base}.{fingerprint_hash}{extension}"
105
118
  fingerprinted_path = os.path.join(target_dir, fingerprinted_basename)
106
119
  shutil.copy(asset.absolute_path, fingerprinted_path)
107
120
  compiled_paths.append(fingerprinted_path)
108
121
 
109
- resolved_url_path = os.path.relpath(fingerprinted_path, target_dir)
122
+ resolved_url_path = str(os.path.relpath(fingerprinted_path, target_dir))
110
123
 
111
- if compress and extension.lower() not in SKIP_COMPRESS_EXTENSIONS:
124
+ if compress and extension.lower() not in _SKIP_COMPRESS_EXTENSIONS:
112
125
  for path in compiled_paths.copy():
113
126
  gzip_path = f"{path}.gz"
114
127
  with gzip.GzipFile(gzip_path, "wb") as f:
plain/assets/finders.py CHANGED
@@ -1,42 +1,48 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
4
+ from collections.abc import Iterator
2
5
 
6
+ from plain.internal import internalcode
3
7
  from plain.packages import packages_registry
4
8
  from plain.runtime import APP_PATH
5
9
 
6
- APP_ASSETS_DIR = APP_PATH / "assets"
10
+ _APP_ASSETS_DIR = APP_PATH / "assets"
11
+
12
+ _SKIP_ASSETS = (".DS_Store", ".gitignore")
13
+
7
14
 
8
- SKIP_ASSETS = (".DS_Store", ".gitignore")
15
+ @internalcode
16
+ class Asset:
17
+ def __init__(self, *, url_path: str, absolute_path: str):
18
+ self.url_path = url_path
19
+ self.absolute_path = absolute_path
9
20
 
21
+ def __str__(self) -> str:
22
+ return self.url_path
10
23
 
11
- def iter_assets():
24
+
25
+ def _iter_assets() -> Iterator[Asset]:
12
26
  """
13
27
  Iterate all valid asset files found in the installed
14
28
  packages and the app itself.
15
29
  """
16
30
 
17
- class Asset:
18
- def __init__(self, *, url_path, absolute_path):
19
- self.url_path = url_path
20
- self.absolute_path = absolute_path
21
-
22
- def __str__(self):
23
- return self.url_path
24
-
25
- def _iter_assets_dir(path):
31
+ def __iter_assets_dir(path: str) -> Iterator[tuple[str, str]]:
26
32
  for root, _, files in os.walk(path):
27
33
  for f in files:
28
- if f in SKIP_ASSETS:
34
+ if f in _SKIP_ASSETS:
29
35
  continue
30
36
  abs_path = os.path.join(root, f)
31
37
  url_path = os.path.relpath(abs_path, path)
32
38
  yield url_path, abs_path
33
39
 
34
- for asset_dir in iter_asset_dirs():
35
- for url_path, abs_path in _iter_assets_dir(asset_dir):
40
+ for asset_dir in _iter_asset_dirs():
41
+ for url_path, abs_path in __iter_assets_dir(asset_dir):
36
42
  yield Asset(url_path=url_path, absolute_path=abs_path)
37
43
 
38
44
 
39
- def iter_asset_dirs():
45
+ def _iter_asset_dirs() -> Iterator[str]:
40
46
  """
41
47
  Iterate all directories containing assets, from installed
42
48
  packages and from app/assets.
@@ -48,4 +54,5 @@ def iter_asset_dirs():
48
54
  yield asset_dir
49
55
 
50
56
  # The app/assets take priority over everything
51
- yield APP_ASSETS_DIR
57
+ if _APP_ASSETS_DIR.exists():
58
+ yield _APP_ASSETS_DIR
@@ -2,11 +2,13 @@ import hashlib
2
2
  import json
3
3
  from functools import cache
4
4
 
5
+ from plain.internal import internalcode
5
6
  from plain.runtime import PLAIN_TEMP_PATH
6
7
 
7
- FINGERPRINT_LENGTH = 7
8
+ _FINGERPRINT_LENGTH = 7
8
9
 
9
10
 
11
+ @internalcode
10
12
  class AssetsFingerprintsManifest(dict):
11
13
  """
12
14
  A manifest of original filenames to fingerprinted filenames.
@@ -15,18 +17,18 @@ class AssetsFingerprintsManifest(dict):
15
17
  def __init__(self):
16
18
  self.path = PLAIN_TEMP_PATH / "assets" / "fingerprints.json"
17
19
 
18
- def load(self):
20
+ def load(self) -> None:
19
21
  if self.path.exists():
20
22
  with open(self.path) as f:
21
23
  self.update(json.load(f))
22
24
 
23
- def save(self):
25
+ def save(self) -> None:
24
26
  with open(self.path, "w") as f:
25
27
  json.dump(self, f, indent=2)
26
28
 
27
29
 
28
30
  @cache
29
- def _get_manifest():
31
+ def _get_manifest() -> AssetsFingerprintsManifest:
30
32
  """
31
33
  A cached function for loading the asset fingerprints manifest,
32
34
  so we don't have to keep loading it from disk over and over.
@@ -36,23 +38,24 @@ def _get_manifest():
36
38
  return manifest
37
39
 
38
40
 
39
- def get_fingerprinted_url_path(url_path):
41
+ def get_fingerprinted_url_path(url_path: str) -> str | None:
40
42
  """
41
43
  Get the final fingerprinted path for an asset URL path.
42
44
  """
43
45
  manifest = _get_manifest()
44
46
  if url_path in manifest:
45
47
  return manifest[url_path]
48
+ return None
46
49
 
47
50
 
48
- def get_file_fingerprint(file_path):
51
+ def _get_file_fingerprint(file_path: str) -> str:
49
52
  """
50
53
  Get the fingerprint hash for a file.
51
54
  """
52
55
  with open(file_path, "rb") as f:
53
56
  content = f.read()
54
57
  fingerprint_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()[
55
- :FINGERPRINT_LENGTH
58
+ :_FINGERPRINT_LENGTH
56
59
  ]
57
60
 
58
61
  return fingerprint_hash
plain/assets/urls.py CHANGED
@@ -18,7 +18,7 @@ class AssetsRouter(Router):
18
18
  ]
19
19
 
20
20
 
21
- def get_asset_url(url_path):
21
+ def get_asset_url(url_path: str) -> str:
22
22
  """
23
23
  Get the full URL to a given asset path.
24
24
  """
plain/assets/views.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import functools
2
4
  import mimetypes
3
5
  import os
@@ -6,19 +8,20 @@ from io import BytesIO
6
8
 
7
9
  from plain.http import (
8
10
  FileResponse,
9
- Http404,
11
+ NotFoundError404,
12
+ NotModifiedResponse,
13
+ RedirectResponse,
10
14
  Response,
11
- ResponseNotModified,
12
- ResponseRedirect,
13
15
  StreamingResponse,
14
16
  )
17
+ from plain.http.response import ResponseHeaders
15
18
  from plain.runtime import settings
16
19
  from plain.urls import reverse
17
20
  from plain.views import View
18
21
 
19
22
  from .compile import get_compiled_path
20
- from .finders import iter_assets
21
- from .fingerprints import FINGERPRINT_LENGTH, get_fingerprinted_url_path
23
+ from .finders import _iter_assets
24
+ from .fingerprints import _FINGERPRINT_LENGTH, get_fingerprinted_url_path
22
25
 
23
26
 
24
27
  class AssetView(View):
@@ -28,16 +31,19 @@ class AssetView(View):
28
31
  This class could be subclassed to further tweak the responses or behavior.
29
32
  """
30
33
 
31
- def __init__(self, asset_path=None):
34
+ def __init__(self, asset_path: str | None = None):
32
35
  # Allow a path to be passed in AssetView.as_view(path="...")
33
36
  self.asset_path = asset_path
34
37
 
35
- def get_url_path(self):
38
+ def get_url_path(self) -> str | None:
36
39
  return self.asset_path or self.url_kwargs["path"]
37
40
 
38
- def get(self):
41
+ def get(self) -> Response | FileResponse | StreamingResponse:
39
42
  url_path = self.get_url_path()
40
43
 
44
+ if not url_path:
45
+ raise NotFoundError404("Asset path not found")
46
+
41
47
  # Make a trailing slash work, but we don't expect it
42
48
  url_path = url_path.rstrip("/")
43
49
 
@@ -50,7 +56,11 @@ class AssetView(View):
50
56
  if redirect_response := self.get_redirect_response(url_path):
51
57
  return redirect_response
52
58
 
59
+ # check_asset_path validates and raises if path is invalid
60
+ # After this point, absolute_path is guaranteed to be a valid str
53
61
  self.check_asset_path(absolute_path)
62
+ # Type guard: absolute_path is now str (check_asset_path raises if None/invalid)
63
+ assert absolute_path is not None
54
64
 
55
65
  if encoded_path := self.get_encoded_path(absolute_path):
56
66
  absolute_path = encoded_path
@@ -71,35 +81,36 @@ class AssetView(View):
71
81
  response.headers = self.update_headers(response.headers, absolute_path)
72
82
  return response
73
83
 
74
- def get_asset_path(self, path):
84
+ def get_asset_path(self, path: str) -> str:
75
85
  """Get the path to the compiled asset"""
76
86
  compiled_path = os.path.abspath(get_compiled_path())
77
87
  asset_path = os.path.join(compiled_path, path)
78
88
 
79
89
  # Make sure we don't try to escape the compiled assests path
80
90
  if not os.path.commonpath([compiled_path, asset_path]) == compiled_path:
81
- raise Http404("Asset not found")
91
+ raise NotFoundError404("Asset not found")
82
92
 
83
93
  return asset_path
84
94
 
85
- def get_debug_asset_path(self, path):
95
+ def get_debug_asset_path(self, path: str) -> str | None:
86
96
  """Make a "live" check to find the uncompiled asset in the filesystem"""
87
- for asset in iter_assets():
97
+ for asset in _iter_assets():
88
98
  if asset.url_path == path:
89
99
  return asset.absolute_path
100
+ return None
90
101
 
91
- def check_asset_path(self, path):
102
+ def check_asset_path(self, path: str | None) -> None:
92
103
  if not path:
93
- raise Http404("Asset not found")
104
+ raise NotFoundError404("Asset not found")
94
105
 
95
106
  if not os.path.exists(path):
96
- raise Http404("Asset not found")
107
+ raise NotFoundError404("Asset not found")
97
108
 
98
109
  if os.path.isdir(path):
99
- raise Http404("Asset is a directory")
110
+ raise NotFoundError404("Asset is a directory")
100
111
 
101
112
  @functools.cache
102
- def get_last_modified(self, path):
113
+ def get_last_modified(self, path: str) -> str | None:
103
114
  try:
104
115
  mtime = os.path.getmtime(path)
105
116
  except OSError:
@@ -107,23 +118,24 @@ class AssetView(View):
107
118
 
108
119
  if mtime:
109
120
  return formatdate(mtime, usegmt=True)
121
+ return None
110
122
 
111
123
  @functools.cache
112
- def get_etag(self, path):
124
+ def get_etag(self, path: str) -> str:
113
125
  try:
114
126
  mtime = os.path.getmtime(path)
115
127
  except OSError:
116
- mtime = None
128
+ mtime = 0.0
117
129
 
118
130
  timestamp = int(mtime)
119
131
  size = self.get_size(path)
120
132
  return f'"{timestamp:x}-{size:x}"'
121
133
 
122
134
  @functools.cache
123
- def get_size(self, path):
135
+ def get_size(self, path: str) -> int:
124
136
  return os.path.getsize(path)
125
137
 
126
- def update_headers(self, headers, path):
138
+ def update_headers(self, headers: ResponseHeaders, path: str) -> ResponseHeaders:
127
139
  headers.setdefault("Access-Control-Allow-Origin", "*")
128
140
 
129
141
  # Always vary on Accept-Encoding
@@ -165,7 +177,7 @@ class AssetView(View):
165
177
 
166
178
  return headers
167
179
 
168
- def is_immutable(self, path):
180
+ def is_immutable(self, path: str) -> bool:
169
181
  """
170
182
  Determine whether an asset looks like it is immutable.
171
183
 
@@ -177,19 +189,19 @@ class AssetView(View):
177
189
  extension = None
178
190
  while extension != "":
179
191
  base, extension = os.path.splitext(base)
180
- if len(extension) == FINGERPRINT_LENGTH + 1 and extension[1:].isalnum():
192
+ if len(extension) == _FINGERPRINT_LENGTH + 1 and extension[1:].isalnum():
181
193
  return True
182
194
 
183
195
  return False
184
196
 
185
- def get_encoded_path(self, path):
197
+ def get_encoded_path(self, path: str) -> str | None:
186
198
  """
187
199
  If the client supports compression, return the path to the compressed file.
188
200
  Otherwise, return the original path.
189
201
  """
190
202
  accept_encoding = self.request.headers.get("Accept-Encoding")
191
203
  if not accept_encoding:
192
- return
204
+ return None
193
205
 
194
206
  if "br" in accept_encoding:
195
207
  br_path = path + ".br"
@@ -200,33 +212,34 @@ class AssetView(View):
200
212
  gzip_path = path + ".gz"
201
213
  if os.path.exists(gzip_path):
202
214
  return gzip_path
215
+ return None
203
216
 
204
- def get_redirect_response(self, path):
217
+ def get_redirect_response(self, path: str) -> RedirectResponse | None:
205
218
  """If the asset is not found, try to redirect to the fingerprinted path"""
206
219
  fingerprinted_url_path = get_fingerprinted_url_path(path)
207
220
 
208
221
  if not fingerprinted_url_path or fingerprinted_url_path == path:
209
222
  # Don't need to redirect if there is no fingerprinted path,
210
223
  # or we're already looking at it.
211
- return
224
+ return None
212
225
 
213
226
  from .urls import AssetsRouter
214
227
 
215
228
  namespace = AssetsRouter.namespace
216
229
 
217
- return ResponseRedirect(
230
+ return RedirectResponse(
218
231
  redirect_to=reverse(f"{namespace}:asset", fingerprinted_url_path),
219
232
  headers={
220
233
  "Cache-Control": "max-age=60", # Can cache this for a short time, but the fingerprinted path can change
221
234
  },
222
235
  )
223
236
 
224
- def get_conditional_response(self, path):
237
+ def get_conditional_response(self, path: str) -> NotModifiedResponse | None:
225
238
  """
226
239
  Support conditional requests (HTTP 304 response) based on ETag and Last-Modified headers.
227
240
  """
228
241
  if self.request.headers.get("If-None-Match") == self.get_etag(path):
229
- response = ResponseNotModified()
242
+ response = NotModifiedResponse()
230
243
  response.headers = self.update_headers(response.headers, path)
231
244
  return response
232
245
 
@@ -238,11 +251,12 @@ class AssetView(View):
238
251
  and last_modified
239
252
  and if_modified_since >= last_modified
240
253
  ):
241
- response = ResponseNotModified()
254
+ response = NotModifiedResponse()
242
255
  response.headers = self.update_headers(response.headers, path)
243
256
  return response
257
+ return None
244
258
 
245
- def get_range_response(self, path):
259
+ def get_range_response(self, path: str) -> Response | StreamingResponse | None:
246
260
  """
247
261
  Support range requests (HTTP 206 response).
248
262
  """
@@ -266,7 +280,7 @@ class AssetView(View):
266
280
  status_code=416, headers=[("Content-Range", f"bytes */{file_size}")]
267
281
  )
268
282
 
269
- end = min(end, file_size - 1)
283
+ end = int(min(end, file_size - 1))
270
284
 
271
285
  with open(path, "rb") as f:
272
286
  f.seek(start)