toolframe-web 0.1.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 (43) hide show
  1. toolframe_web/__init__.py +5 -0
  2. toolframe_web/asset_delivery.py +51 -0
  3. toolframe_web/assets.py +205 -0
  4. toolframe_web/authoring.py +29 -0
  5. toolframe_web/cli.py +29 -0
  6. toolframe_web/plugin.py +508 -0
  7. toolframe_web/policy.py +31 -0
  8. toolframe_web/prepare.py +447 -0
  9. toolframe_web/render.py +79 -0
  10. toolframe_web/request_body.py +95 -0
  11. toolframe_web/templates/notes/.gitignore +7 -0
  12. toolframe_web/templates/notes/LICENSE +21 -0
  13. toolframe_web/templates/notes/README.md +63 -0
  14. toolframe_web/templates/notes/pyproject.toml +25 -0
  15. toolframe_web/templates/notes/src/toolframe_web_notes/__init__.py +1 -0
  16. toolframe_web/templates/notes/src/toolframe_web_notes/contracts.py +50 -0
  17. toolframe_web/templates/notes/src/toolframe_web_notes/plugin.py +76 -0
  18. toolframe_web/templates/notes/src/toolframe_web_notes/service.py +81 -0
  19. toolframe_web/templates/notes/src/toolframe_web_notes/web/__init__.py +0 -0
  20. toolframe_web/templates/notes/src/toolframe_web_notes/web/app/__init__.py +16 -0
  21. toolframe_web/templates/notes/src/toolframe_web_notes/web/app/index.js +3 -0
  22. toolframe_web/templates/notes/src/toolframe_web_notes/web/app/styles/input.css +1 -0
  23. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/__init__.py +0 -0
  24. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/__init__.py +6 -0
  25. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/api/__init__.py +17 -0
  26. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/index.js +1 -0
  27. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/model/__init__.py +26 -0
  28. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/model/count.js +6 -0
  29. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/__init__.py +0 -0
  30. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/index.js +47 -0
  31. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/routes.py +94 -0
  32. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/templates/index.html +23 -0
  33. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/templates/notes.html +29 -0
  34. toolframe_web/templates/notes/tests/test_contracts.py +30 -0
  35. toolframe_web/templates/notes/web-assets.toml +54 -0
  36. toolframe_web-0.1.0.dist-info/METADATA +83 -0
  37. toolframe_web-0.1.0.dist-info/RECORD +43 -0
  38. toolframe_web-0.1.0.dist-info/WHEEL +4 -0
  39. toolframe_web-0.1.0.dist-info/entry_points.txt +5 -0
  40. toolframe_web-0.1.0.dist-info/extra_metadata/toolframe-authoring.json +29 -0
  41. toolframe_web-0.1.0.dist-info/licenses/LICENSE +202 -0
  42. toolframe_web-0.1.0.dist-info/licenses/NOTICE +7 -0
  43. toolframe_web-0.1.0.dist-info/licenses/src/toolframe_web/templates/notes/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """HTML helpers for Toolframe Web app authors."""
2
+
3
+ from .render import asset_tags, html, json, redirect, render
4
+
5
+ __all__ = ["asset_tags", "html", "json", "redirect", "render"]
@@ -0,0 +1,51 @@
1
+ """Authorized delivery of current and retained sealed assets."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+
6
+ from starlette.responses import Response
7
+ from toolframe_sdk import PluginError
8
+
9
+ from .assets import AssetSet
10
+ from .policy import asset_policy
11
+
12
+
13
+ async def deliver_asset(provider, request, app, key, epoch, reference):
14
+ asset = request.scope["path"].removeprefix("/_tf/assets/")
15
+ admission = await provider.context.resource_admit(
16
+ key, None, epoch=epoch, access_context=reference, asset=asset
17
+ )
18
+ checksum, _, path = asset.partition("/")
19
+ assets = app.assets
20
+ if checksum != assets.manifest.digest:
21
+ if provider.cache is None:
22
+ raise PluginError("web_asset_missing", "Previous asset set is unavailable")
23
+ assets = await asyncio.to_thread(AssetSet.cached, provider.cache, checksum)
24
+ item = next((file for file in assets.manifest.files if file.path == path), None)
25
+ if item is None:
26
+ raise PluginError("web_asset_missing", "Asset is not declared")
27
+ policy = asset_policy(app.catalog, item)
28
+ # Capabilities may change without changing asset bytes. Revalidate scripts
29
+ # with a validator that includes the current policy, including retained sets.
30
+ script = item.media_type == "text/javascript"
31
+ etag = '"' + hashlib.sha256((item.sha256 + policy).encode()).hexdigest() + '"'
32
+ cache_control = "private, no-store"
33
+ if admission["visibility"] == "public":
34
+ cache_control = (
35
+ "public, max-age=0, must-revalidate"
36
+ if script
37
+ else "public, max-age=31536000, immutable"
38
+ )
39
+ headers = {
40
+ "ETag": etag,
41
+ "X-Content-Type-Options": "nosniff",
42
+ "Cache-Control": cache_control,
43
+ "Content-Security-Policy": policy,
44
+ }
45
+ if request.headers.get("if-none-match") == etag:
46
+ return Response(status_code=304, headers=headers)
47
+ return Response(
48
+ b"" if request.method == "HEAD" else assets.content[path],
49
+ media_type=item.media_type,
50
+ headers=headers,
51
+ )
@@ -0,0 +1,205 @@
1
+ """Sealed, allowlisted browser asset sets. Runtime never downloads or builds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import importlib.resources
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from pydantic import Field, field_validator, model_validator
11
+ from toolframe_sdk import PluginError, digest
12
+ from toolframe_sdk.contracts import Contract
13
+ from toolframe_sdk.web import relative_file
14
+
15
+ MEDIA_TYPES = {
16
+ ".js": "text/javascript",
17
+ ".mjs": "text/javascript",
18
+ ".css": "text/css",
19
+ ".html": "text/html",
20
+ ".json": "application/json",
21
+ ".map": "application/json",
22
+ ".wasm": "application/wasm",
23
+ ".woff": "font/woff",
24
+ ".woff2": "font/woff2",
25
+ ".ttf": "font/ttf",
26
+ ".otf": "font/otf",
27
+ ".svg": "image/svg+xml",
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".webp": "image/webp",
32
+ ".gif": "image/gif",
33
+ ".ico": "image/x-icon",
34
+ ".txt": "text/plain",
35
+ }
36
+ MAX_FILE = 8 * 1024 * 1024
37
+ MAX_SET = 32 * 1024 * 1024
38
+
39
+
40
+ def media_type(path):
41
+ try:
42
+ return MEDIA_TYPES[Path(path).suffix.lower()]
43
+ except KeyError:
44
+ raise ValueError(f"Unsupported public asset extension: {path}") from None
45
+
46
+
47
+ class AssetFile(Contract):
48
+ path: str
49
+ sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
50
+ size: int = Field(ge=0, le=MAX_FILE)
51
+ media_type: str
52
+ source: str = "local"
53
+ license: str | None = None
54
+ dependencies: tuple[str, ...] = ()
55
+
56
+ @model_validator(mode="after")
57
+ def paths(self):
58
+ relative_file(self.path)
59
+ if self.media_type != media_type(self.path):
60
+ raise ValueError("Asset MIME type must match its declared file extension")
61
+ for value in self.dependencies:
62
+ relative_file(value)
63
+ if self.license:
64
+ relative_file(self.license)
65
+ return self
66
+
67
+
68
+ class Document(Contract):
69
+ styles: tuple[str, ...] = ()
70
+ scripts: tuple[str, ...] = ()
71
+ modules: tuple[str, ...] = ()
72
+ imports: dict[str, str] = Field(default_factory=dict)
73
+ visibility: str = "inherit"
74
+
75
+ @field_validator("visibility")
76
+ @classmethod
77
+ def visibility_value(cls, value):
78
+ if value not in {"inherit", "public"}:
79
+ raise ValueError("Asset visibility must be inherit or public")
80
+ return value
81
+
82
+
83
+ class Manifest(Contract):
84
+ schema_version: int = Field(default=1, ge=1, le=1)
85
+ public: str = "web/public"
86
+ document: Document = Field(default_factory=Document)
87
+ files: tuple[AssetFile, ...]
88
+ recipe: dict = Field(default_factory=dict)
89
+ digest: str = Field(pattern=r"^[a-f0-9]{64}$")
90
+
91
+ @model_validator(mode="after")
92
+ def sealed(self):
93
+ relative_file(self.public)
94
+ paths = {item.path for item in self.files}
95
+ if (
96
+ len(self.files) > 2048
97
+ or len(paths) != len(self.files)
98
+ or len({path.casefold() for path in paths}) != len(paths)
99
+ ):
100
+ raise ValueError("Asset paths must be distinct and bounded")
101
+ if sum(item.size for item in self.files) > MAX_SET:
102
+ raise ValueError("Asset set exceeds 32 MiB")
103
+ referenced = {
104
+ *self.document.styles,
105
+ *self.document.scripts,
106
+ *self.document.modules,
107
+ *self.document.imports.values(),
108
+ }
109
+ referenced |= {path for item in self.files for path in item.dependencies}
110
+ referenced |= {item.license for item in self.files if item.license}
111
+ if not referenced <= paths:
112
+ raise ValueError("Manifest references an undeclared asset or license")
113
+ if any(media_type(path) != "text/css" for path in self.document.styles):
114
+ raise ValueError("Style entries must be CSS")
115
+ if any(
116
+ media_type(path) != "text/javascript"
117
+ for path in (
118
+ *self.document.scripts,
119
+ *self.document.modules,
120
+ *self.document.imports.values(),
121
+ )
122
+ ):
123
+ raise ValueError("Script and import entries must be JavaScript")
124
+ if self.digest != digest(self.model_dump(mode="json", exclude={"digest"})):
125
+ raise ValueError("Asset manifest digest mismatch")
126
+ return self
127
+
128
+
129
+ def seal(public, files, *, document=None, recipe=None):
130
+ payload = {
131
+ "schema_version": 1,
132
+ "public": public,
133
+ "document": (document or Document()).model_dump(mode="json"),
134
+ "files": [
135
+ item.model_dump(mode="json")
136
+ for item in sorted(files, key=lambda item: item.path)
137
+ ],
138
+ "recipe": recipe or {},
139
+ }
140
+ return Manifest(**payload, digest=digest(payload))
141
+
142
+
143
+ def safe_file(root, relative):
144
+ relative_file(relative)
145
+ root = Path(root).resolve()
146
+ path = root.joinpath(relative)
147
+ if not path.resolve().is_relative_to(root) or any(
148
+ parent.is_symlink()
149
+ for parent in (path, *path.parents)
150
+ if parent != root and parent.is_relative_to(root)
151
+ ):
152
+ raise ValueError("Assets cannot contain symlinks or escape their package")
153
+ return path
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class AssetSet:
158
+ manifest: Manifest
159
+ content: dict
160
+
161
+ @classmethod
162
+ def load(cls, package, manifest_path):
163
+ root = Path(str(importlib.resources.files(package)))
164
+ path = safe_file(root, manifest_path)
165
+ if path.stat().st_size > 1024 * 1024:
166
+ raise ValueError("Asset manifest exceeds 1 MiB")
167
+ manifest = Manifest.model_validate_json(path.read_bytes())
168
+ content = {}
169
+ for item in manifest.files:
170
+ file = safe_file(root, manifest.public + "/" + item.path)
171
+ if file.stat().st_size != item.size:
172
+ raise ValueError("Asset size mismatch")
173
+ value = file.read_bytes()
174
+ if hashlib.sha256(value).hexdigest() != item.sha256:
175
+ raise ValueError("Asset content hash mismatch")
176
+ content[item.path] = value
177
+ return cls(manifest, content)
178
+
179
+ def persist(self, root):
180
+ target = Path(root) / self.manifest.digest
181
+ target.mkdir(parents=True, exist_ok=True, mode=0o700)
182
+ for item in self.manifest.files:
183
+ path = safe_file(target, "public/" + item.path)
184
+ path.parent.mkdir(parents=True, exist_ok=True)
185
+ path.write_bytes(self.content[item.path])
186
+ (target / "manifest.json").write_text(self.manifest.model_dump_json())
187
+
188
+ @classmethod
189
+ def cached(cls, root, checksum):
190
+ if len(checksum) != 64 or any(c not in "0123456789abcdef" for c in checksum):
191
+ raise PluginError("web_asset_missing", "Unknown Web asset set")
192
+ target = Path(root) / checksum
193
+ manifest = Manifest.model_validate_json((target / "manifest.json").read_bytes())
194
+ if manifest.digest != checksum:
195
+ raise ValueError("Cached asset identity mismatch")
196
+ content = {}
197
+ for item in manifest.files:
198
+ path = safe_file(target, "public/" + item.path)
199
+ if path.stat().st_size != item.size:
200
+ raise ValueError("Cached asset size mismatch")
201
+ value = path.read_bytes()
202
+ if hashlib.sha256(value).hexdigest() != item.sha256:
203
+ raise ValueError("Cached asset hash mismatch")
204
+ content[item.path] = value
205
+ return cls(manifest, content)
@@ -0,0 +1,29 @@
1
+ """Distribution authoring entries, independent of runtime plugin activation."""
2
+
3
+ from importlib.metadata import version
4
+ from importlib.resources import files
5
+
6
+ import tomlkit
7
+ from toolframe_sdk.authoring import render_plugin, write_template
8
+
9
+
10
+ def generate(*, destination, parameters):
11
+ rendered = render_plugin(
12
+ files("toolframe_web").joinpath("templates/notes"),
13
+ parameters["name"],
14
+ text_suffixes=(".toml", ".html", ".js", ".md"),
15
+ )
16
+ from pathlib import Path
17
+
18
+ project = tomlkit.parse(rendered[Path("pyproject.toml")].decode())
19
+ requirement = "toolframe-web==" + version("toolframe-web")
20
+ project["project"]["optional-dependencies"]["web"] = [requirement]
21
+ project["tool"]["toolframe"] = {"authoring": {"source": "pypi:" + requirement}}
22
+ rendered[Path("pyproject.toml")] = tomlkit.dumps(project).encode()
23
+ write_template(destination, rendered)
24
+
25
+
26
+ def prepare(*, source, destination, cache, offline):
27
+ from .prepare import stage
28
+
29
+ return stage(source, destination, cache, offline=offline)
toolframe_web/cli.py ADDED
@@ -0,0 +1,29 @@
1
+ """Private build adapter and standalone author-time staging command."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .prepare import stage
9
+
10
+
11
+ def main(argv=None):
12
+ parser = argparse.ArgumentParser(prog="toolframe-web")
13
+ commands = parser.add_subparsers(dest="command", required=True)
14
+ prepare = commands.add_parser("stage")
15
+ prepare.add_argument("source", type=Path)
16
+ prepare.add_argument("destination", type=Path)
17
+ prepare.add_argument("--cache", type=Path, required=True)
18
+ prepare.add_argument("--offline", action="store_true")
19
+ args = parser.parse_args(argv)
20
+ try:
21
+ print(
22
+ json.dumps(
23
+ stage(args.source, args.destination, args.cache, offline=args.offline)
24
+ )
25
+ )
26
+ except (OSError, ValueError) as error:
27
+ print(json.dumps({"error": str(error)}), file=sys.stderr)
28
+ return 1
29
+ return 0