pocket-python 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ella Inng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: pocket-python
3
+ Version: 0.1.0
4
+ Summary: Auditable Python runtime assets and offline MicroPython application bundles
5
+ Author: ellainng
6
+ Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Software Development :: Build Tools
14
+ Requires-Python: >=3.14
15
+ Project-URL: Repository, https://github.com/outside-labs/pocket-python
16
+ Project-URL: Issues, https://github.com/outside-labs/pocket-python/issues
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Pocket-python
20
+
21
+ Pocket-python packages explicit Python runtime assets and Shimify source
22
+ bundles into offline MicroPython browser applications. Python runs on the
23
+ client through WebAssembly, with a worker for execution and a service worker
24
+ for cached reloads. No application server executes the Python code.
25
+
26
+ Requires Python 3.14+ to build artifacts; the generated application must also
27
+ be compatible with the selected MicroPython runtime. The tool has zero runtime
28
+ dependencies and uses the MIT license. Version 0.1.0 is experimental.
29
+
30
+ ```sh
31
+ python -m pip install pocket-python==0.1.0 shimify==0.2.0
32
+ pocket-python --help
33
+ ```
34
+
35
+ ## Build a browser application
36
+
37
+ First create a portable source bundle with
38
+ [Shimify](https://github.com/outside-labs/shimify). Supply a MicroPython
39
+ WebAssembly distribution containing `micropython.mjs`, `micropython.wasm` and
40
+ its license text; use the official
41
+ [WebAssembly port](https://github.com/micropython/micropython/tree/master/ports/webassembly)
42
+ or the official npm package described in
43
+ [PyScript’s offline guide](https://docs.pyscript.net/2026.7.3/user-guide/offline/).
44
+
45
+ ```sh
46
+ shimify modules path/to/app.py --output build/source
47
+ pocket-python inspect build/source
48
+ pocket-python lock-runtime path/to/runtime --runtime-version VERSION \
49
+ --source-url https://example.org/upstream-runtime-release \
50
+ --file micropython.mjs --file micropython.wasm --license-file LICENSE \
51
+ --output build/runtime-lock.json
52
+ pocket-python web build/source --runtime path/to/runtime \
53
+ --runtime-lock build/runtime-lock.json --output build/web --title "My Python App"
54
+ python -m http.server 8000 --bind 127.0.0.1 --directory build/web
55
+ ```
56
+
57
+ Replace VERSION and the source URL with the identity of the supplied upstream
58
+ release. Open `http://localhost:8000`, let the app finish installing its cache,
59
+ and wait for **Offline ready on this device** before disconnecting. Run starts
60
+ a fresh worker; Stop terminates it, including an infinite computation. Python
61
+ output is rendered as text. Deploy the generated directory to a static HTTPS
62
+ server; relative URLs support deployment under a subdirectory. `file://`
63
+ execution is unsupported.
64
+
65
+ `web` writes to a new directory outside input roots. It preserves only recorded
66
+ source/runtime files and notices and adds `POCKET-PYTHON.json` with hashes,
67
+ runtime identity and separate source/runtime/total byte counts. Generation
68
+ does not run application code. The published tool contains no interpreter
69
+ binary and does not download one automatically.
70
+
71
+ ## Reproduce the pinned example
72
+
73
+ In a checkout, the build-only helper downloads and checks the official
74
+ MicroPython `1.29.0-6` fixture and upstream license. The committed input is a
75
+ real Shimify 0.2 bundle with a local helper import:
76
+
77
+ ```sh
78
+ uv sync --locked
79
+ uv run python scripts/prepare_runtime.py
80
+ uv run pocket-python inspect tests/fixtures/hello
81
+ uv run pocket-python web tests/fixtures/hello --runtime build/runtime \
82
+ --runtime-lock build/runtime/runtime-lock.json --output build/demo
83
+ python -m http.server 8000 --bind 127.0.0.1 --directory build/demo
84
+ ```
85
+
86
+ The demonstrated PWA totals 578,168 uncompressed bytes, including 563,092 bytes
87
+ of runtime assets/license and 102 bytes of application source. These measurements
88
+ describe the tested fixture; source reduction, compressed transfer size, startup
89
+ time and memory are separate quantities.
90
+
91
+ ## Development and verification
92
+
93
+ ```sh
94
+ uv sync --locked --group browser-tests
95
+ uv run python scripts/prepare_runtime.py
96
+ POCKET_PYTHON_RUNTIME=build/runtime uv run python -m unittest discover -s tests -v
97
+ uv run python -m playwright install chromium
98
+ uv run --group browser-tests python scripts/browser_smoke.py
99
+ uv build
100
+ uv run python scripts/check_distributions.py
101
+ ```
102
+
103
+ On PowerShell set `$env:POCKET_PYTHON_RUNTIME = "build/runtime"` before tests.
104
+ Without an explicit runtime fixture path, the real-WASM test is skipped.
105
+ Use `--channel chrome` with the browser script to test an installed Chrome.
106
+ Playwright is a pinned build-only group; it is not a package runtime dependency.
107
+ CI runs real-runtime tests on Linux, macOS and Windows and offline Chromium
108
+ tests on Linux. Distribution checks verify allowlists, metadata, source parity
109
+ and both installed wheel/source artifacts outside the checkout.
110
+
111
+ ## Compatibility and roadmap
112
+
113
+ MicroPython provides a subset of Python and its standard library. Test every
114
+ application against the actual selected runtime: a valid Shimify CPython source
115
+ bundle does not establish browser compatibility. Native extensions, OS process
116
+ APIs, resources and dynamic imports need explicit adapters or a different
117
+ runtime profile. The initial console has no interactive stdin or DOM bridge.
118
+ Browser cache persistence and PWA installation affordances vary by platform.
119
+
120
+ The first small-runtime route is MicroPython WebAssembly. Native relocatable
121
+ CPython assembly and subsequent bootstrap-aware trimming are planned separately;
122
+ 0.1 does not trim/rebuild CPython. Broader CPython browser compatibility through
123
+ Pyodide and optional PyScript integration are later profiles.
124
+
125
+ See [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md),
126
+ [release notes](docs/release-0.1.0.md), and the
127
+ [GitHub Project](https://github.com/orgs/outside-labs/projects/13).
128
+ Runtime and dependency licenses remain applicable; preserve and review their
129
+ upstream notices before distributing generated applications.
@@ -0,0 +1,111 @@
1
+ # Pocket-python
2
+
3
+ Pocket-python packages explicit Python runtime assets and Shimify source
4
+ bundles into offline MicroPython browser applications. Python runs on the
5
+ client through WebAssembly, with a worker for execution and a service worker
6
+ for cached reloads. No application server executes the Python code.
7
+
8
+ Requires Python 3.14+ to build artifacts; the generated application must also
9
+ be compatible with the selected MicroPython runtime. The tool has zero runtime
10
+ dependencies and uses the MIT license. Version 0.1.0 is experimental.
11
+
12
+ ```sh
13
+ python -m pip install pocket-python==0.1.0 shimify==0.2.0
14
+ pocket-python --help
15
+ ```
16
+
17
+ ## Build a browser application
18
+
19
+ First create a portable source bundle with
20
+ [Shimify](https://github.com/outside-labs/shimify). Supply a MicroPython
21
+ WebAssembly distribution containing `micropython.mjs`, `micropython.wasm` and
22
+ its license text; use the official
23
+ [WebAssembly port](https://github.com/micropython/micropython/tree/master/ports/webassembly)
24
+ or the official npm package described in
25
+ [PyScript’s offline guide](https://docs.pyscript.net/2026.7.3/user-guide/offline/).
26
+
27
+ ```sh
28
+ shimify modules path/to/app.py --output build/source
29
+ pocket-python inspect build/source
30
+ pocket-python lock-runtime path/to/runtime --runtime-version VERSION \
31
+ --source-url https://example.org/upstream-runtime-release \
32
+ --file micropython.mjs --file micropython.wasm --license-file LICENSE \
33
+ --output build/runtime-lock.json
34
+ pocket-python web build/source --runtime path/to/runtime \
35
+ --runtime-lock build/runtime-lock.json --output build/web --title "My Python App"
36
+ python -m http.server 8000 --bind 127.0.0.1 --directory build/web
37
+ ```
38
+
39
+ Replace VERSION and the source URL with the identity of the supplied upstream
40
+ release. Open `http://localhost:8000`, let the app finish installing its cache,
41
+ and wait for **Offline ready on this device** before disconnecting. Run starts
42
+ a fresh worker; Stop terminates it, including an infinite computation. Python
43
+ output is rendered as text. Deploy the generated directory to a static HTTPS
44
+ server; relative URLs support deployment under a subdirectory. `file://`
45
+ execution is unsupported.
46
+
47
+ `web` writes to a new directory outside input roots. It preserves only recorded
48
+ source/runtime files and notices and adds `POCKET-PYTHON.json` with hashes,
49
+ runtime identity and separate source/runtime/total byte counts. Generation
50
+ does not run application code. The published tool contains no interpreter
51
+ binary and does not download one automatically.
52
+
53
+ ## Reproduce the pinned example
54
+
55
+ In a checkout, the build-only helper downloads and checks the official
56
+ MicroPython `1.29.0-6` fixture and upstream license. The committed input is a
57
+ real Shimify 0.2 bundle with a local helper import:
58
+
59
+ ```sh
60
+ uv sync --locked
61
+ uv run python scripts/prepare_runtime.py
62
+ uv run pocket-python inspect tests/fixtures/hello
63
+ uv run pocket-python web tests/fixtures/hello --runtime build/runtime \
64
+ --runtime-lock build/runtime/runtime-lock.json --output build/demo
65
+ python -m http.server 8000 --bind 127.0.0.1 --directory build/demo
66
+ ```
67
+
68
+ The demonstrated PWA totals 578,168 uncompressed bytes, including 563,092 bytes
69
+ of runtime assets/license and 102 bytes of application source. These measurements
70
+ describe the tested fixture; source reduction, compressed transfer size, startup
71
+ time and memory are separate quantities.
72
+
73
+ ## Development and verification
74
+
75
+ ```sh
76
+ uv sync --locked --group browser-tests
77
+ uv run python scripts/prepare_runtime.py
78
+ POCKET_PYTHON_RUNTIME=build/runtime uv run python -m unittest discover -s tests -v
79
+ uv run python -m playwright install chromium
80
+ uv run --group browser-tests python scripts/browser_smoke.py
81
+ uv build
82
+ uv run python scripts/check_distributions.py
83
+ ```
84
+
85
+ On PowerShell set `$env:POCKET_PYTHON_RUNTIME = "build/runtime"` before tests.
86
+ Without an explicit runtime fixture path, the real-WASM test is skipped.
87
+ Use `--channel chrome` with the browser script to test an installed Chrome.
88
+ Playwright is a pinned build-only group; it is not a package runtime dependency.
89
+ CI runs real-runtime tests on Linux, macOS and Windows and offline Chromium
90
+ tests on Linux. Distribution checks verify allowlists, metadata, source parity
91
+ and both installed wheel/source artifacts outside the checkout.
92
+
93
+ ## Compatibility and roadmap
94
+
95
+ MicroPython provides a subset of Python and its standard library. Test every
96
+ application against the actual selected runtime: a valid Shimify CPython source
97
+ bundle does not establish browser compatibility. Native extensions, OS process
98
+ APIs, resources and dynamic imports need explicit adapters or a different
99
+ runtime profile. The initial console has no interactive stdin or DOM bridge.
100
+ Browser cache persistence and PWA installation affordances vary by platform.
101
+
102
+ The first small-runtime route is MicroPython WebAssembly. Native relocatable
103
+ CPython assembly and subsequent bootstrap-aware trimming are planned separately;
104
+ 0.1 does not trim/rebuild CPython. Broader CPython browser compatibility through
105
+ Pyodide and optional PyScript integration are later profiles.
106
+
107
+ See [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md),
108
+ [release notes](docs/release-0.1.0.md), and the
109
+ [GitHub Project](https://github.com/orgs/outside-labs/projects/13).
110
+ Runtime and dependency licenses remain applicable; preserve and review their
111
+ upstream notices before distributing generated applications.
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "pocket-python"
3
+ version = "0.1.0"
4
+ description = "Auditable Python runtime assets and offline MicroPython application bundles"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3 :: Only",
13
+ "Programming Language :: Python :: 3.14",
14
+ "Topic :: Software Development :: Build Tools",
15
+ ]
16
+ dependencies = []
17
+
18
+ [[project.authors]]
19
+ name = "ellainng"
20
+ email = "299958882+ellainng@users.noreply.github.com"
21
+
22
+ [project.scripts]
23
+ pocket-python = "pocket_python.cli:main"
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/outside-labs/pocket-python"
27
+ Issues = "https://github.com/outside-labs/pocket-python/issues"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.12.19,<0.13.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [dependency-groups]
34
+ browser-tests = ["playwright==1.64.0"]
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "pocket-python"
3
+ version = "0.1.0"
4
+ description = "Auditable Python runtime assets and offline MicroPython application bundles"
5
+ readme = "README.md"
6
+ authors = [{ name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }]
7
+ requires-python = ">=3.14"
8
+ license = "MIT"
9
+ license-files = ["LICENSE"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3 :: Only",
14
+ "Programming Language :: Python :: 3.14",
15
+ "Topic :: Software Development :: Build Tools",
16
+ ]
17
+ dependencies = []
18
+
19
+ [project.scripts]
20
+ pocket-python = "pocket_python.cli:main"
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/outside-labs/pocket-python"
24
+ Issues = "https://github.com/outside-labs/pocket-python/issues"
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.12.19,<0.13.0"]
28
+ build-backend = "uv_build"
29
+
30
+ [dependency-groups]
31
+ browser-tests = [
32
+ "playwright==1.64.0",
33
+ ]
@@ -0,0 +1,3 @@
1
+ """Auditable runtime assets and offline Python applications."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from pocket_python.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,141 @@
1
+ """Read explicit, hashed runtime assets without loading executable code."""
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path, PurePosixPath
7
+ import re
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ class ArtifactError(ValueError):
12
+ """An unsupported, changed, or unsafe artifact input."""
13
+
14
+
15
+ def relative_path(value: str) -> str:
16
+ if not isinstance(value, str):
17
+ raise ArtifactError("Artifact paths must be strings.")
18
+ path = PurePosixPath(value)
19
+ reserved = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}
20
+ if not path.parts or path.is_absolute() or ".." in path.parts or str(path) != value:
21
+ raise ArtifactError(f"Unsafe artifact path: {value!r}")
22
+ for part in path.parts:
23
+ if part.split(".")[0].upper() in reserved or part.endswith((".", " ")) or any(ord(c) < 32 or c in '\\:*?"<>|#%' for c in part):
24
+ raise ArtifactError(f"Nonportable artifact path: {value!r}")
25
+ return value
26
+
27
+
28
+ def read_asset(root: Path, name: str) -> bytes:
29
+ relative_path(name)
30
+ current = root
31
+ if root.is_symlink() or not root.is_dir():
32
+ raise ArtifactError("Asset root must be an ordinary directory.")
33
+ for part in PurePosixPath(name).parts:
34
+ current = current / part
35
+ if current.is_symlink():
36
+ raise ArtifactError(f"Symlink asset is unsupported: {name}")
37
+ if not current.is_file() or not current.resolve().is_relative_to(root.resolve()):
38
+ raise ArtifactError(f"Asset is missing or escapes its root: {name}")
39
+ return current.read_bytes()
40
+
41
+
42
+ def file_record(name: str, content: bytes) -> dict:
43
+ return {"path": name, "sha256": hashlib.sha256(content).hexdigest(), "size_bytes": len(content)}
44
+
45
+
46
+ def check_names(names):
47
+ seen = set()
48
+ for name in names:
49
+ key = relative_path(name).casefold()
50
+ if key in seen or any(key.startswith(other + "/") or other.startswith(key + "/") for other in seen):
51
+ raise ArtifactError(f"Artifact path collision: {name}")
52
+ seen.add(key)
53
+
54
+
55
+ def verified_files(root: Path, records: list) -> dict[str, bytes]:
56
+ if not isinstance(records, list) or not records:
57
+ raise ArtifactError("Expected a nonempty file record list.")
58
+ if any(not isinstance(record, dict) or not isinstance(record.get("path"), str) for record in records):
59
+ raise ArtifactError("Invalid file record.")
60
+ check_names(record["path"] for record in records)
61
+ files = {}
62
+ for record in records:
63
+ digest, size = record.get("sha256"), record.get("size_bytes")
64
+ if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) or type(size) is not int or size < 0:
65
+ raise ArtifactError("Invalid file hash/size record.")
66
+ content = read_asset(root, record["path"])
67
+ if file_record(record["path"], content) != {key: record[key] for key in ["path", "sha256", "size_bytes"]}:
68
+ raise ArtifactError(f"Asset hash or size mismatch: {record['path']}")
69
+ files[record["path"]] = content
70
+ return files
71
+
72
+
73
+ def json_object(content: bytes) -> dict:
74
+ try:
75
+ value = json.loads(content)
76
+ except (ValueError, UnicodeError) as error:
77
+ raise ArtifactError(f"Invalid JSON: {error}") from error
78
+ if not isinstance(value, dict):
79
+ raise ArtifactError("Expected a JSON object.")
80
+ return value
81
+
82
+
83
+ def json_bytes(value: dict) -> bytes:
84
+ return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
85
+
86
+
87
+ def runtime_lock(root: str | Path, *, version: str, source_url: str, files: list[str], license_files: list[str]) -> dict:
88
+ if not isinstance(version, str) or not version.strip():
89
+ raise ArtifactError("Runtime version must be nonempty.")
90
+ if not isinstance(source_url, str):
91
+ raise ArtifactError("Runtime source must be an HTTPS URL.")
92
+ source = urlparse(source_url)
93
+ if source.scheme != "https" or not source.netloc or source.username or source.password:
94
+ raise ArtifactError("Runtime source must be an HTTPS URL without credentials.")
95
+ if not license_files:
96
+ raise ArtifactError("At least one runtime license file is required.")
97
+ names = sorted(set(files) | set(license_files))
98
+ check_names(names)
99
+ content = {name: read_asset(Path(root), name) for name in names}
100
+ for name in license_files:
101
+ if not content[name].strip():
102
+ raise ArtifactError(f"Empty runtime license file: {name}")
103
+ if not {"micropython.mjs", "micropython.wasm"} <= content.keys():
104
+ raise ArtifactError("MicroPython requires micropython.mjs and micropython.wasm.")
105
+ return {
106
+ "schema_version": 1,
107
+ "kind": "pocket-python-runtime",
108
+ "implementation": "micropython",
109
+ "version": version,
110
+ "source_url": source_url,
111
+ "entrypoint": "micropython.mjs",
112
+ "license_files": sorted(set(license_files)),
113
+ "files": [file_record(name, data) for name, data in sorted(content.items())],
114
+ }
115
+
116
+
117
+ @dataclass
118
+ class RuntimeAssets:
119
+ root: Path
120
+ lock: dict
121
+ files: dict[str, bytes]
122
+
123
+
124
+ def read_runtime(root: str | Path, lock_path: str | Path) -> RuntimeAssets:
125
+ path = Path(lock_path)
126
+ if path.is_symlink():
127
+ raise ArtifactError("Runtime lock must not be a symlink.")
128
+ lock = json_object(path.read_bytes())
129
+ if type(lock.get("schema_version")) is not int or lock["schema_version"] != 1 or lock.get("kind") != "pocket-python-runtime":
130
+ raise ArtifactError("Unsupported runtime lock schema.")
131
+ if lock.get("implementation") != "micropython" or lock.get("entrypoint") != "micropython.mjs":
132
+ raise ArtifactError("Only the MicroPython WebAssembly profile is supported.")
133
+ root = Path(root).absolute()
134
+ files = verified_files(root, lock.get("files"))
135
+ licenses = lock.get("license_files")
136
+ if not isinstance(licenses, list) or not licenses or any(not isinstance(name, str) or name not in files or not files[name].strip() for name in licenses):
137
+ raise ArtifactError("Missing or empty locked runtime license files.")
138
+ expected = runtime_lock(root, version=lock.get("version"), source_url=lock.get("source_url", ""), files=list(files), license_files=licenses)
139
+ if lock != expected:
140
+ raise ArtifactError("Runtime lock differs from the supported schema.")
141
+ return RuntimeAssets(root, lock, files)
@@ -0,0 +1,81 @@
1
+ """Validate Shimify source-bundle snapshots without executing them."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from pocket_python.assets import ArtifactError, file_record, json_object, read_asset, verified_files
7
+
8
+
9
+ @dataclass
10
+ class SourceBundle:
11
+ root: Path
12
+ manifest: dict
13
+ files: dict[str, bytes]
14
+
15
+ def report(self) -> dict:
16
+ return {
17
+ "schema_version": 1,
18
+ "kind": "pocket-python-source-inspection",
19
+ "entrypoint": self.manifest["entrypoint"],
20
+ "source_modules": len(self.manifest["sources"]),
21
+ "source_size_bytes": sum(item["size_bytes"] for item in self.manifest["sources"]),
22
+ "bundle_size_bytes": sum(map(len, self.files.values())),
23
+ "direct_stdlib_modules": self.manifest["stdlib_modules"],
24
+ "runtime_closure_complete": False,
25
+ "browser_compatibility": "unverified; test against the selected runtime",
26
+ }
27
+
28
+
29
+ def read_bundle(directory: str | Path) -> SourceBundle:
30
+ root = Path(directory).absolute()
31
+ raw = read_asset(root, "SHIMIFY-MANIFEST.json")
32
+ manifest = json_object(raw)
33
+ if type(manifest.get("schema_version")) is not int or manifest["schema_version"] != 1 or manifest.get("mode") != "modules":
34
+ raise ArtifactError("Expected a Shimify schema-1 modules bundle.")
35
+ files = verified_files(root, manifest.get("files"))
36
+ if any(name != "run.py" and not name.startswith(("_sources/", "LICENSES/")) for name in files):
37
+ raise ArtifactError("Bundle contains files outside the supported source/notices layout.")
38
+ entry = manifest.get("entrypoint")
39
+ if not isinstance(entry, str) or not entry.startswith("_sources/") or not entry.endswith(".py") or entry.count("/") != 1 or entry not in files or manifest.get("launcher") != "run.py" or "run.py" not in files:
40
+ raise ArtifactError("Invalid bundle entrypoint or launcher.")
41
+ sources = manifest.get("sources")
42
+ if not isinstance(sources, list) or not sources or any(not isinstance(item, dict) for item in sources):
43
+ raise ArtifactError("Expected source records.")
44
+ names, outputs = set(), set()
45
+ for item in sources:
46
+ name, output = item.get("module"), item.get("output")
47
+ if not isinstance(name, str) or not name or not all(part.isidentifier() for part in name.split(".")) or name in names or not isinstance(output, str) or output in outputs or output not in files:
48
+ raise ArtifactError("Invalid or duplicate source mapping.")
49
+ expected = {entry} if name == "__main__" else {"_sources/" + name.replace(".", "/") + suffix for suffix in [".py", "/__init__.py"]}
50
+ if output not in expected:
51
+ raise ArtifactError("Source mapping differs from the module layout.")
52
+ record = file_record(output, files[output])
53
+ if item.get("sha256") != record["sha256"] or type(item.get("size_bytes")) is not int or item["size_bytes"] != record["size_bytes"]:
54
+ raise ArtifactError("Source hash/size differs from the file record.")
55
+ names.add(name)
56
+ outputs.add(output)
57
+ if "__main__" not in names or outputs != {name for name in files if name.startswith("_sources/")}:
58
+ raise ArtifactError("Bundle has missing or unrecorded source files.")
59
+ stdlib = manifest.get("stdlib_modules")
60
+ if not isinstance(stdlib, list) or any(not isinstance(name, str) or not all(part.isidentifier() for part in name.split(".")) for name in stdlib):
61
+ raise ArtifactError("Invalid standard-library requirements.")
62
+ # Validate attribution references before copying. Hash verification covers the
63
+ # bytes, and required paths must also be explicitly listed in the manifest.
64
+ distributions = manifest.get("distributions")
65
+ if not isinstance(distributions, list):
66
+ raise ArtifactError("Invalid distribution provenance.")
67
+ identities = set()
68
+ for distribution in distributions:
69
+ if not isinstance(distribution, dict) or not isinstance(distribution.get("id"), str) or distribution["id"] in identities:
70
+ raise ArtifactError("Invalid distribution identity.")
71
+ identities.add(distribution["id"])
72
+ licenses = distribution.get("license_files")
73
+ metadata = distribution.get("metadata")
74
+ if not isinstance(licenses, list) or not licenses or any(not isinstance(path, str) or not path.startswith("LICENSES/") or path not in files or not files[path].strip() for path in licenses):
75
+ raise ArtifactError("Distribution license material is missing.")
76
+ if not isinstance(metadata, str) or not metadata.startswith("LICENSES/") or metadata not in files:
77
+ raise ArtifactError("Distribution metadata is missing.")
78
+ if any(item.get("distribution") is not None and (not isinstance(item["distribution"], str) or item["distribution"] not in identities) for item in sources):
79
+ raise ArtifactError("Source refers to missing distribution provenance.")
80
+ files["SHIMIFY-MANIFEST.json"] = raw
81
+ return SourceBundle(root, manifest, files)
@@ -0,0 +1,53 @@
1
+ """Command-line interface for explicit runtime assets and source inspection."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from pocket_python import __version__
9
+ from pocket_python.assets import ArtifactError, json_bytes, read_runtime, runtime_lock
10
+ from pocket_python.bundles import read_bundle
11
+ from pocket_python.web import build_web
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(prog="pocket-python", description="Audit runtime assets and assemble offline Python applications.")
16
+ parser.add_argument("--version", action="version", version=f"pocket-python {__version__}")
17
+ commands = parser.add_subparsers(dest="command", required=True)
18
+ lock = commands.add_parser("lock-runtime", help="Hash an explicit MicroPython runtime asset allowlist.")
19
+ lock.add_argument("root", type=Path)
20
+ lock.add_argument("--runtime-version", required=True)
21
+ lock.add_argument("--source-url", required=True)
22
+ lock.add_argument("--file", action="append", required=True, default=[])
23
+ lock.add_argument("--license-file", action="append", required=True, default=[])
24
+ lock.add_argument("--output", type=Path, required=True)
25
+ inspect = commands.add_parser("inspect", help="Validate and describe a Shimify source bundle.")
26
+ inspect.add_argument("bundle", type=Path)
27
+ web = commands.add_parser("web", help="Build a local-asset MicroPython PWA in a new directory.")
28
+ web.add_argument("bundle", type=Path)
29
+ web.add_argument("--runtime", type=Path, required=True)
30
+ web.add_argument("--runtime-lock", type=Path, required=True)
31
+ web.add_argument("--output", type=Path, required=True)
32
+ web.add_argument("--title", default="Pocket Python")
33
+ args = parser.parse_args(argv)
34
+ try:
35
+ if args.command == "lock-runtime":
36
+ result = runtime_lock(args.root, version=args.runtime_version, source_url=args.source_url, files=args.file, license_files=args.license_file)
37
+ args.output.parent.mkdir(parents=True, exist_ok=True)
38
+ with args.output.open("xb") as stream:
39
+ stream.write(json_bytes(result))
40
+ print(f"Created {args.output.resolve()}")
41
+ elif args.command == "web":
42
+ manifest = build_web(read_bundle(args.bundle), read_runtime(args.runtime, args.runtime_lock), args.output, title=args.title)
43
+ print(f"Created {args.output.resolve()} ({manifest['size']['artifact_bytes']} bytes)")
44
+ print("Serve over HTTPS or localhost; test this application against MicroPython.")
45
+ else:
46
+ print(json.dumps(read_bundle(args.bundle).report(), indent=2, sort_keys=True))
47
+ except ArtifactError as error:
48
+ print(f"pocket-python: {error}", file=sys.stderr)
49
+ return 1
50
+ except (OSError, ValueError) as error:
51
+ print(f"pocket-python: {error}", file=sys.stderr)
52
+ return 2
53
+ return 0
File without changes
@@ -0,0 +1,140 @@
1
+ """Local-asset browser shell and MicroPython worker templates."""
2
+
3
+ PAGE = '''<!doctype html>
4
+ <html lang="en">
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <meta name="theme-color" content="#213b45">
8
+ <link rel="manifest" href="./manifest.webmanifest">
9
+ <link rel="icon" href="./icon.svg" type="image/svg+xml">
10
+ <title>{title}</title>
11
+ <style>
12
+ :root {{ color-scheme: light dark; font: 17px/1.6 system-ui,sans-serif; }}
13
+ body {{ max-width: 52rem; margin: 8vh auto; padding: 0 1.5rem; }}
14
+ h1 {{ line-height: 1.2; }}
15
+ button {{ font: inherit; padding: .35rem 1rem; margin-right: .5rem; cursor: pointer; }}
16
+ pre {{ min-height: 10rem; padding: 1.25rem; border: 1px solid #789; border-radius: .5rem; white-space: pre-wrap; overflow-wrap: anywhere; }}
17
+ small {{ display: block; margin-top: 1rem; }}
18
+ </style>
19
+ <main>
20
+ <h1>{title}</h1>
21
+ <p>Python runs on your device.</p>
22
+ <p id="status" role="status">Ready</p>
23
+ <button id="run" type="button">Run</button><button id="stop" type="button" disabled>Stop</button>
24
+ <pre id="output" aria-label="Python output"></pre>
25
+ <small id="offline" role="status">Preparing offline files…</small>
26
+ </main>
27
+ <script type="module" src="./app.js"></script>
28
+ </html>
29
+ '''
30
+
31
+ APP = '''const output = document.querySelector("#output");
32
+ const status = document.querySelector("#status");
33
+ const run = document.querySelector("#run");
34
+ const stop = document.querySelector("#stop");
35
+ const offline = document.querySelector("#offline");
36
+ let worker;
37
+ function finish(message) {
38
+ if (worker) worker.terminate();
39
+ worker = undefined;
40
+ run.disabled = false;
41
+ stop.disabled = true;
42
+ status.textContent = message;
43
+ }
44
+ function start() {
45
+ if (worker) worker.terminate();
46
+ output.textContent = "";
47
+ status.textContent = "Running…";
48
+ run.disabled = true;
49
+ stop.disabled = false;
50
+ worker = new Worker(new URL("./worker.js", import.meta.url), {type: "module"});
51
+ worker.onmessage = ({data}) => {
52
+ if (data.type === "stdout" || data.type === "stderr") output.textContent += data.text;
53
+ if (data.type === "error") { output.textContent += "\\n" + data.text; finish("Error"); }
54
+ if (data.type === "done") finish("Finished");
55
+ };
56
+ worker.onerror = (event) => { output.textContent += "\\n" + event.message; finish("Error"); };
57
+ worker.postMessage({type: "run"});
58
+ }
59
+ run.addEventListener("click", start);
60
+ stop.addEventListener("click", () => finish("Stopped"));
61
+ async function prepareOffline() {
62
+ if (!("serviceWorker" in navigator)) throw new Error("Service workers require HTTPS or localhost.");
63
+ const registration = await navigator.serviceWorker.register(new URL("./service-worker.js", import.meta.url), {scope: "./"});
64
+ const installing = registration.installing || registration.waiting;
65
+ if (installing && installing.state !== "activated") {
66
+ await new Promise((resolve, reject) => {
67
+ function changed() {
68
+ if (installing.state === "activated") resolve();
69
+ if (installing.state === "redundant") reject(new Error("Offline installation failed."));
70
+ }
71
+ installing.addEventListener("statechange", changed);
72
+ changed();
73
+ });
74
+ }
75
+ await navigator.serviceWorker.ready;
76
+ offline.textContent = "Offline ready on this device";
77
+ }
78
+ prepareOffline().catch(error => { offline.textContent = "Offline unavailable: " + error.message; });
79
+ start();
80
+ '''
81
+
82
+ WORKER = '''import {loadMicroPython} from "./runtime/micropython.mjs";
83
+ let started = false;
84
+ async function get(path) {
85
+ const url = new URL(path.split("/").map(encodeURIComponent).join("/"), import.meta.url);
86
+ const response = await fetch(url);
87
+ if (!response.ok) throw new Error("Could not load " + path + ": " + response.status);
88
+ return response;
89
+ }
90
+ self.onmessage = async ({data}) => {
91
+ if (started || data.type !== "run") return;
92
+ started = true;
93
+ const stdout = new TextDecoder();
94
+ const stderr = new TextDecoder();
95
+ try {
96
+ const config = await (await get("app-config.json")).json();
97
+ const mp = await loadMicroPython({
98
+ url: new URL("./runtime/micropython.wasm", import.meta.url).href,
99
+ linebuffer: false,
100
+ stdout: bytes => postMessage({type: "stdout", text: stdout.decode(bytes, {stream: true})}),
101
+ stderr: bytes => postMessage({type: "stderr", text: stderr.decode(bytes, {stream: true})})
102
+ });
103
+ let entry;
104
+ for (const source of config.sources) {
105
+ const bytes = new Uint8Array(await (await get(source.url)).arrayBuffer());
106
+ const path = "/app/" + source.path;
107
+ mp.FS.mkdirTree(path.slice(0, path.lastIndexOf("/")));
108
+ mp.FS.writeFile(path, bytes);
109
+ if (source.path === config.entrypoint) entry = new TextDecoder("utf-8", {fatal: true}).decode(bytes);
110
+ }
111
+ if (entry === undefined) throw new Error("Application entrypoint is missing.");
112
+ mp.globals.set("__file__", "/app/" + config.entrypoint);
113
+ mp.runPython("import sys\\nsys.path.insert(0, '/app')\\n");
114
+ mp.runPython(entry);
115
+ postMessage({type: "stdout", text: stdout.decode()});
116
+ postMessage({type: "stderr", text: stderr.decode()});
117
+ postMessage({type: "done"});
118
+ } catch (error) {
119
+ postMessage({type: "error", text: (error.type || error.name || "Error") + ": " + error.message});
120
+ }
121
+ };
122
+ '''
123
+
124
+ SERVICE_WORKER = '''const FILES = __FILES__;
125
+ const PREFIX = "pocket-python:" + self.registration.scope + ":";
126
+ const CACHE = PREFIX + "__VERSION__";
127
+ self.addEventListener("install", event => {
128
+ event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES.map(path => new URL(path, self.registration.scope).href))).then(() => self.skipWaiting()));
129
+ });
130
+ self.addEventListener("activate", event => {
131
+ event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()));
132
+ });
133
+ self.addEventListener("fetch", event => {
134
+ const url = new URL(event.request.url);
135
+ if (event.request.method !== "GET" || url.origin !== self.location.origin || !url.href.startsWith(self.registration.scope)) return;
136
+ event.respondWith(caches.open(CACHE).then(async cache => (await cache.match(event.request)) || fetch(event.request)));
137
+ });
138
+ '''
139
+
140
+ ICON = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192"><rect width="192" height="192" rx="40" fill="#213b45"/><path d="M57 49h48a30 30 0 0 1 0 60H81v34H57zM81 73v12h24a6 6 0 0 0 0-12z" fill="#f4da8b"/></svg>'''
@@ -0,0 +1,134 @@
1
+ """Assemble verified sources and a local MicroPython runtime as a PWA."""
2
+
3
+ import hashlib
4
+ import html
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import tempfile
9
+
10
+ from pocket_python import __version__
11
+ from pocket_python.assets import ArtifactError, RuntimeAssets, check_names, file_record, json_bytes, json_object
12
+ from pocket_python.bundles import SourceBundle
13
+ from pocket_python.templates import APP, ICON, PAGE, SERVICE_WORKER, WORKER
14
+
15
+
16
+ def _publish(destination: Path, files: dict[str, bytes]):
17
+ check_names(files)
18
+ destination.parent.mkdir(parents=True, exist_ok=True)
19
+ with tempfile.TemporaryDirectory(prefix=".pocket-python-", dir=destination.parent) as temporary:
20
+ stage = Path(temporary)
21
+ for name, content in files.items():
22
+ path = stage / name
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ path.write_bytes(content)
25
+ destination.mkdir()
26
+ written, directories = [], {destination}
27
+ try:
28
+ for name in sorted(files):
29
+ path = destination / name
30
+ parents = []
31
+ parent = path.parent
32
+ while parent != destination:
33
+ parents.append(parent)
34
+ parent = parent.parent
35
+ for parent in reversed(parents):
36
+ if parent not in directories:
37
+ parent.mkdir()
38
+ directories.add(parent)
39
+ os.link(stage / name, path)
40
+ written.append(path)
41
+ except BaseException:
42
+ for path in reversed(written):
43
+ path.unlink(missing_ok=True)
44
+ for path in sorted(directories, key=lambda p: len(p.parts), reverse=True):
45
+ try:
46
+ path.rmdir()
47
+ except OSError:
48
+ pass
49
+ raise
50
+
51
+
52
+ def build_web(bundle: SourceBundle, runtime: RuntimeAssets, output: str | Path, *, title: str = "Pocket Python") -> dict:
53
+ if json_object(bundle.files["SHIMIFY-MANIFEST.json"]) != bundle.manifest:
54
+ raise ArtifactError("Source manifest snapshot changed after validation.")
55
+ for records, snapshot, extra in [
56
+ (bundle.manifest["files"], bundle.files, {"SHIMIFY-MANIFEST.json"}),
57
+ (runtime.lock["files"], runtime.files, set()),
58
+ ]:
59
+ if set(snapshot) != {record["path"] for record in records} | extra:
60
+ raise ArtifactError("Snapshot files changed after validation.")
61
+ for record in records:
62
+ if file_record(record["path"], snapshot[record["path"]]) != {key: record[key] for key in ["path", "sha256", "size_bytes"]}:
63
+ raise ArtifactError("Snapshot hash or size changed after validation.")
64
+ requested = Path(output).absolute()
65
+ if requested.exists() or requested.is_symlink():
66
+ raise ArtifactError("Output already exists; choose a new directory.")
67
+ destination = requested.resolve()
68
+ for root in [bundle.root.resolve(), runtime.root.resolve()]:
69
+ if destination.is_relative_to(root) or root.is_relative_to(destination):
70
+ raise ArtifactError("Output overlaps an input root.")
71
+ if not isinstance(title, str) or not title.strip():
72
+ raise ArtifactError("Application title must be nonempty.")
73
+ sources = []
74
+ for source in bundle.manifest["sources"]:
75
+ name = source["output"]
76
+ try:
77
+ bundle.files[name].decode("utf-8")
78
+ except UnicodeError as error:
79
+ raise ArtifactError("The MicroPython profile requires UTF-8 application sources.") from error
80
+ sources.append({"path": name.removeprefix("_sources/"), "url": "application/" + name})
81
+ files = {"application/" + name: data for name, data in bundle.files.items()}
82
+ files.update({"runtime/" + name: data for name, data in runtime.files.items()})
83
+ files["runtime-lock.json"] = json_bytes(runtime.lock)
84
+ files["index.html"] = PAGE.format(title=html.escape(title, quote=True)).encode()
85
+ files["app.js"] = APP.encode()
86
+ files["worker.js"] = WORKER.encode()
87
+ files["icon.svg"] = ICON.encode()
88
+ files["app-config.json"] = json_bytes({"entrypoint": bundle.manifest["entrypoint"].removeprefix("_sources/"), "sources": sorted(sources, key=lambda s: s["path"])})
89
+ files["manifest.webmanifest"] = json_bytes({
90
+ "name": title, "short_name": title[:24], "id": "./", "start_url": "./", "scope": "./",
91
+ "display": "standalone", "theme_color": "#213b45", "background_color": "#213b45",
92
+ "icons": [{"src": "./icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}],
93
+ })
94
+ records = [file_record(name, content) for name, content in sorted(files.items())]
95
+ cache_version = hashlib.sha256(json_bytes({"files": records})).hexdigest()
96
+ precache = ["./", *sorted(files), "service-worker.js", "POCKET-PYTHON.json"]
97
+ files["service-worker.js"] = SERVICE_WORKER.replace("__FILES__", json.dumps(precache)).replace("__VERSION__", cache_version).encode()
98
+ manifest = {
99
+ "schema_version": 1,
100
+ "kind": "pocket-python-web",
101
+ "pocket_python_version": __version__,
102
+ "title": title,
103
+ "entrypoint": "index.html",
104
+ "runtime": {key: runtime.lock[key] for key in ["implementation", "version", "source_url", "license_files"]},
105
+ "source_bundle": "application/SHIMIFY-MANIFEST.json",
106
+ "runtime_lock": "runtime-lock.json",
107
+ "cache_version": cache_version,
108
+ "size": {
109
+ "application_source_bytes": bundle.report()["source_size_bytes"],
110
+ "source_bundle_bytes": sum(map(len, bundle.files.values())),
111
+ "runtime_asset_bytes": sum(map(len, runtime.files.values())),
112
+ "artifact_bytes": 0,
113
+ },
114
+ "files": [file_record(name, content) for name, content in sorted(files.items())],
115
+ "direct_stdlib_modules": bundle.manifest["stdlib_modules"],
116
+ "limitations": [
117
+ "MicroPython compatibility must be tested for this application; CPython syntax and stdlib APIs may differ.",
118
+ "Supplied runtime assets are preserved, not automatically rebuilt or trimmed.",
119
+ "Offline use requires initial installation over HTTPS/localhost and retained browser cache.",
120
+ "License material is preserved; redistribution compliance is not certified.",
121
+ ],
122
+ }
123
+ # The manifest excludes its own file hash, but its total byte count includes
124
+ # itself. Iterate until the decimal length of the total is stable.
125
+ payload_size = sum(map(len, files.values()))
126
+ while True:
127
+ content = json_bytes(manifest)
128
+ total = payload_size + len(content)
129
+ if manifest["size"]["artifact_bytes"] == total:
130
+ break
131
+ manifest["size"]["artifact_bytes"] = total
132
+ files["POCKET-PYTHON.json"] = content
133
+ _publish(destination, files)
134
+ return manifest