pocket-python 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.
- pocket_python/__init__.py +3 -0
- pocket_python/__main__.py +3 -0
- pocket_python/assets.py +141 -0
- pocket_python/bundles.py +81 -0
- pocket_python/cli.py +53 -0
- pocket_python/py.typed +0 -0
- pocket_python/templates.py +140 -0
- pocket_python/web.py +134 -0
- pocket_python-0.1.0.dist-info/METADATA +129 -0
- pocket_python-0.1.0.dist-info/RECORD +13 -0
- pocket_python-0.1.0.dist-info/WHEEL +4 -0
- pocket_python-0.1.0.dist-info/entry_points.txt +3 -0
- pocket_python-0.1.0.dist-info/licenses/LICENSE +21 -0
pocket_python/assets.py
ADDED
|
@@ -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)
|
pocket_python/bundles.py
ADDED
|
@@ -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)
|
pocket_python/cli.py
ADDED
|
@@ -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
|
pocket_python/py.typed
ADDED
|
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>'''
|
pocket_python/web.py
ADDED
|
@@ -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
|
|
@@ -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,13 @@
|
|
|
1
|
+
pocket_python/__init__.py,sha256=HTLylU9ygzS13S36QUJVGMswsCUUY2l4IqfWKxLsTgY,87
|
|
2
|
+
pocket_python/__main__.py,sha256=6i6qmAKhAb2321DBOGHX2XgRu0eZmiqgRcYYWK4hqHM,61
|
|
3
|
+
pocket_python/assets.py,sha256=mkMH7oSFmQBKEioRglA2uZWU2dj6N9v0zFN1WRKZKCc,6354
|
|
4
|
+
pocket_python/bundles.py,sha256=N2TB3pP8Ti_kM7QTeJlwT_fds9HeGVOqW09Ze3E2pdU,5044
|
|
5
|
+
pocket_python/cli.py,sha256=8jpXPFyaeAQRtVCacbUrxZO4ON52i7ETZ8jYoifw2eU,2891
|
|
6
|
+
pocket_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pocket_python/templates.py,sha256=MDo3LVhhEbvvoNMmIHKSyZTzpCEiHKFuc9O1tC2kVMg,6342
|
|
8
|
+
pocket_python/web.py,sha256=jxTpKT6eFHzhO0TjyAVXnBy0tDw9n3fkw_kmQtUk4xk,6855
|
|
9
|
+
pocket_python-0.1.0.dist-info/licenses/LICENSE,sha256=MJiaArfnOql8NJRA2TI9JIYDOoiis3Rh8xQruzBRXoU,1066
|
|
10
|
+
pocket_python-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
11
|
+
pocket_python-0.1.0.dist-info/entry_points.txt,sha256=wxpru1o_kLWsJbXFxij4ArPLhh2JQfJT3lMD_zyHDds,58
|
|
12
|
+
pocket_python-0.1.0.dist-info/METADATA,sha256=l_Uhl4LjI-MTeBhCSWpjHrbeFYIuffGL_7Mdg0dwnJ4,6090
|
|
13
|
+
pocket_python-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|