moonlit 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.
- moonlit/__init__.py +1 -0
- moonlit/__main__.py +6 -0
- moonlit/_bootstrap/__init__.py +78 -0
- moonlit/_bootstrap/environment.py +170 -0
- moonlit/_bootstrap/errors.py +48 -0
- moonlit/_bootstrap/extract.py +202 -0
- moonlit/_bootstrap/locking.py +64 -0
- moonlit/_bootstrap/runner.py +84 -0
- moonlit/_templates/__init__.py +1 -0
- moonlit/_templates/main_py.tmpl +3 -0
- moonlit/builder.py +415 -0
- moonlit/cli.py +246 -0
- moonlit/errors.py +69 -0
- moonlit/hashing.py +34 -0
- moonlit/resolver.py +141 -0
- moonlit/workspace.py +158 -0
- moonlit-0.1.0.dist-info/METADATA +163 -0
- moonlit-0.1.0.dist-info/RECORD +21 -0
- moonlit-0.1.0.dist-info/WHEEL +4 -0
- moonlit-0.1.0.dist-info/entry_points.txt +2 -0
- moonlit-0.1.0.dist-info/licenses/LICENSE +21 -0
moonlit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
moonlit/__main__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""moonlit zipapp bootstrap (stdlib-only, D7).
|
|
2
|
+
|
|
3
|
+
Shipped verbatim into every produced .pyz; runs before staged site-packages
|
|
4
|
+
reaches sys.path. The :func:`bootstrap` orchestrator below is invoked by the
|
|
5
|
+
generated ``__main__.py`` inside the .pyz:
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from _bootstrap import bootstrap
|
|
9
|
+
sys.exit(bootstrap())
|
|
10
|
+
|
|
11
|
+
Contract: specs/03-bootstrap-runtime.md §2 (operations), §3 (cache root),
|
|
12
|
+
§10 (error model). Exit codes per D3 runtime enumeration.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import traceback
|
|
18
|
+
import zipfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import environment, extract, runner
|
|
22
|
+
from .errors import ArchiveError, BootstrapError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bootstrap() -> int:
|
|
26
|
+
"""Bootstrap entry point. Returns an int in [0, 255] for ``sys.exit``."""
|
|
27
|
+
try:
|
|
28
|
+
return _do_bootstrap()
|
|
29
|
+
except BootstrapError as exc:
|
|
30
|
+
print(f"moonlit: {exc}", file=sys.stderr)
|
|
31
|
+
if _debug():
|
|
32
|
+
traceback.print_exc(file=sys.stderr)
|
|
33
|
+
return exc.exit_code
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------- internal orchestration, in bootstrap()'s call order ----------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _do_bootstrap() -> int:
|
|
40
|
+
archive = _resolve_archive()
|
|
41
|
+
env = environment.load(archive)
|
|
42
|
+
cache_root = _resolve_cache_root()
|
|
43
|
+
_ensure_cache_root_exists(cache_root)
|
|
44
|
+
site_dir = extract.materialize(env, cache_root, archive)
|
|
45
|
+
return runner.run(env, site_dir)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_archive() -> str:
|
|
49
|
+
if not sys.argv or not sys.argv[0]:
|
|
50
|
+
raise ArchiveError("cannot locate zipapp (sys.argv[0] is empty)")
|
|
51
|
+
archive = os.path.abspath(sys.argv[0])
|
|
52
|
+
if not zipfile.is_zipfile(archive):
|
|
53
|
+
raise ArchiveError(f"not a moonlit zipapp: {archive}")
|
|
54
|
+
return archive
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_cache_root() -> Path:
|
|
58
|
+
# D16: present and non-empty after os.environ.get is truthy.
|
|
59
|
+
override = os.environ.get("MOONLIT_ROOT", "")
|
|
60
|
+
if override:
|
|
61
|
+
return Path(override).expanduser().resolve()
|
|
62
|
+
if os.name == "nt":
|
|
63
|
+
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
|
64
|
+
if local_app_data:
|
|
65
|
+
return Path(local_app_data) / "moonlit"
|
|
66
|
+
return Path.home() / ".moonlit"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ensure_cache_root_exists(cache_root: Path) -> None:
|
|
70
|
+
try:
|
|
71
|
+
os.makedirs(cache_root, exist_ok=True)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise BootstrapError(f"cannot create cache root {cache_root}: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _debug() -> bool:
|
|
77
|
+
# D16: present and non-empty after os.environ.get is truthy.
|
|
78
|
+
return bool(os.environ.get("MOONLIT_DEBUG", ""))
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Validate and load env.json from a moonlit zipapp.
|
|
2
|
+
|
|
3
|
+
Implements the D8 9-step ordered validation (specs/05-env-json-schema.md §4)
|
|
4
|
+
and the per-field format checks (§3). Every failure raises EnvJsonError; the
|
|
5
|
+
bootstrap entry point translates it to runtime exit code 1 (D3).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import zipfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .errors import EnvJsonError
|
|
16
|
+
|
|
17
|
+
_REQUIRED_FIELDS: tuple[str, ...] = (
|
|
18
|
+
"name",
|
|
19
|
+
"build_id",
|
|
20
|
+
"entry_point",
|
|
21
|
+
"built_at",
|
|
22
|
+
"moonlit_version",
|
|
23
|
+
"python_shebang",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# D11: PEP 508 name regex. The re.IGNORECASE flag is mandatory.
|
|
27
|
+
_PEP508_NAME = re.compile(
|
|
28
|
+
r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_BUILD_ID = re.compile(r"^[0-9a-f]{64}$")
|
|
33
|
+
|
|
34
|
+
# entry_point each side: dotted Python identifier; no whitespace.
|
|
35
|
+
_ENTRY_POINT_SIDE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Environment:
|
|
40
|
+
schema_version: int
|
|
41
|
+
name: str
|
|
42
|
+
build_id: str
|
|
43
|
+
entry_point: str
|
|
44
|
+
built_at: str
|
|
45
|
+
moonlit_version: str
|
|
46
|
+
python_shebang: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load(archive_path: str | Path) -> Environment:
|
|
50
|
+
"""Read env.json from ``archive_path``, validate per D8, return Environment."""
|
|
51
|
+
raw = _read_env_bytes(archive_path)
|
|
52
|
+
text = _decode_utf8(raw)
|
|
53
|
+
parsed = _parse_json(text)
|
|
54
|
+
_ensure_dict(parsed)
|
|
55
|
+
_check_schema_version(parsed)
|
|
56
|
+
_check_required_fields_present(parsed)
|
|
57
|
+
_check_required_field_types(parsed)
|
|
58
|
+
_check_field_formats(parsed)
|
|
59
|
+
return Environment(
|
|
60
|
+
schema_version=parsed["schema_version"],
|
|
61
|
+
name=parsed["name"],
|
|
62
|
+
build_id=parsed["build_id"],
|
|
63
|
+
entry_point=parsed["entry_point"],
|
|
64
|
+
built_at=parsed["built_at"],
|
|
65
|
+
moonlit_version=parsed["moonlit_version"],
|
|
66
|
+
python_shebang=parsed["python_shebang"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------- D8 steps, in load()'s call order ----------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_env_bytes(archive_path: str | Path) -> bytes:
|
|
74
|
+
try:
|
|
75
|
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
76
|
+
try:
|
|
77
|
+
return zf.read("env.json")
|
|
78
|
+
except KeyError as exc:
|
|
79
|
+
raise EnvJsonError("env.json missing from archive") from exc
|
|
80
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
81
|
+
raise EnvJsonError(f"archive unreadable: {archive_path}") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _decode_utf8(raw: bytes) -> str:
|
|
85
|
+
try:
|
|
86
|
+
return raw.decode("utf-8")
|
|
87
|
+
except UnicodeDecodeError as exc:
|
|
88
|
+
raise EnvJsonError("env.json is not valid UTF-8") from exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_json(text: str) -> object:
|
|
92
|
+
try:
|
|
93
|
+
return json.loads(text)
|
|
94
|
+
except json.JSONDecodeError as exc:
|
|
95
|
+
raise EnvJsonError("env.json is not valid JSON") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ensure_dict(parsed: object) -> None:
|
|
99
|
+
if not isinstance(parsed, dict):
|
|
100
|
+
raise EnvJsonError("env.json must be a JSON object")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_schema_version(parsed: dict) -> None:
|
|
104
|
+
if "schema_version" not in parsed:
|
|
105
|
+
raise EnvJsonError("env.json: schema_version missing or not an integer")
|
|
106
|
+
v = parsed["schema_version"]
|
|
107
|
+
# bool is a subclass of int in Python; reject explicitly per D8 step 5.
|
|
108
|
+
if not isinstance(v, int) or isinstance(v, bool):
|
|
109
|
+
raise EnvJsonError("env.json: schema_version missing or not an integer")
|
|
110
|
+
if v != 1:
|
|
111
|
+
raise EnvJsonError(
|
|
112
|
+
f"env.json: unsupported schema_version {v}; "
|
|
113
|
+
f"upgrade moonlit to a version that supports env.json schema version {v}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _check_required_fields_present(parsed: dict) -> None:
|
|
118
|
+
for field in _REQUIRED_FIELDS:
|
|
119
|
+
if field not in parsed:
|
|
120
|
+
raise EnvJsonError(f"env.json: missing required field '{field}'")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_required_field_types(parsed: dict) -> None:
|
|
124
|
+
for field in _REQUIRED_FIELDS:
|
|
125
|
+
value = parsed[field]
|
|
126
|
+
# bool is rejected here because every string-typed field below
|
|
127
|
+
# is declared "string" in spec §2; True/False are not strings.
|
|
128
|
+
if not isinstance(value, str):
|
|
129
|
+
raise EnvJsonError(f"env.json: field '{field}' has wrong type (expected string)")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_field_formats(parsed: dict) -> None:
|
|
133
|
+
if not _PEP508_NAME.match(parsed["name"]):
|
|
134
|
+
raise EnvJsonError("env.json: field 'name' failed validation")
|
|
135
|
+
if not _BUILD_ID.fullmatch(parsed["build_id"]):
|
|
136
|
+
raise EnvJsonError("env.json: field 'build_id' failed validation")
|
|
137
|
+
if not _is_valid_entry_point(parsed["entry_point"]):
|
|
138
|
+
raise EnvJsonError("env.json: field 'entry_point' failed validation")
|
|
139
|
+
if not _is_valid_built_at(parsed["built_at"]):
|
|
140
|
+
raise EnvJsonError("env.json: field 'built_at' failed validation")
|
|
141
|
+
if not parsed["moonlit_version"]:
|
|
142
|
+
raise EnvJsonError("env.json: field 'moonlit_version' failed validation")
|
|
143
|
+
if not _is_valid_shebang(parsed["python_shebang"]):
|
|
144
|
+
raise EnvJsonError("env.json: field 'python_shebang' failed validation")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_valid_entry_point(value: str) -> bool:
|
|
148
|
+
parts = value.split(":")
|
|
149
|
+
if len(parts) != 2:
|
|
150
|
+
return False
|
|
151
|
+
lhs, rhs = parts
|
|
152
|
+
return bool(_ENTRY_POINT_SIDE.fullmatch(lhs) and _ENTRY_POINT_SIDE.fullmatch(rhs))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_valid_built_at(value: str) -> bool:
|
|
156
|
+
try:
|
|
157
|
+
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
|
158
|
+
except ValueError:
|
|
159
|
+
return False
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_valid_shebang(value: str) -> bool:
|
|
164
|
+
if not value:
|
|
165
|
+
return False
|
|
166
|
+
if "\n" in value:
|
|
167
|
+
return False
|
|
168
|
+
if value.startswith("#!"):
|
|
169
|
+
return False
|
|
170
|
+
return True
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Error hierarchy for the bootstrap.
|
|
2
|
+
|
|
3
|
+
Each subclass carries a stable ``exit_code`` per the D3 runtime enumeration
|
|
4
|
+
(specs/03-bootstrap-runtime.md §0). The bootstrap entry point catches
|
|
5
|
+
BootstrapError and translates ``exit_code`` into the process exit status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BootstrapError(Exception):
|
|
10
|
+
"""Base class for bootstrap-internal failures."""
|
|
11
|
+
|
|
12
|
+
exit_code: int = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EnvJsonError(BootstrapError):
|
|
16
|
+
"""env.json missing, archive unreadable, or D8 validation failure (exit 1)."""
|
|
17
|
+
|
|
18
|
+
exit_code = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LockTimeoutError(BootstrapError):
|
|
22
|
+
"""Lock acquisition exceeded the wall-clock timeout (exit 3)."""
|
|
23
|
+
|
|
24
|
+
exit_code = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExtractionError(BootstrapError):
|
|
28
|
+
"""Archive extraction failure or unsafe arcname (exit 1)."""
|
|
29
|
+
|
|
30
|
+
exit_code = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CollisionError(BootstrapError):
|
|
34
|
+
"""``_bootstrap`` collision in the staged site-packages tree (exit 1)."""
|
|
35
|
+
|
|
36
|
+
exit_code = 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EntryPointError(BootstrapError):
|
|
40
|
+
"""Entry-point parse, import, attribute, or return-value coercion failure (exit 2)."""
|
|
41
|
+
|
|
42
|
+
exit_code = 2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ArchiveError(BootstrapError):
|
|
46
|
+
"""Archive resolution failed: empty sys.argv[0] or path is not a zipfile (exit 1)."""
|
|
47
|
+
|
|
48
|
+
exit_code = 1
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Extract archive site-packages into the per-build cache.
|
|
2
|
+
|
|
3
|
+
Implements the D14 fast-path / slow-path protocol, the D4 atomic-replace
|
|
4
|
+
directory swap, the D5 cache-key derivation, and the D1 arcname rule
|
|
5
|
+
(specs/03-bootstrap-runtime.md §6).
|
|
6
|
+
|
|
7
|
+
The single function that calls os.rename is named ``atomic_replace_dir``;
|
|
8
|
+
the D7 stdlib-only gate's allowlist is keyed on that exact name.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import posixpath
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from time import sleep
|
|
18
|
+
|
|
19
|
+
from .environment import Environment
|
|
20
|
+
from .errors import ExtractionError
|
|
21
|
+
from .locking import lock
|
|
22
|
+
|
|
23
|
+
_OS_REPLACE_RETRIES = 3
|
|
24
|
+
_OS_REPLACE_BACKOFF_S = 0.1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def materialize(
|
|
28
|
+
env: Environment,
|
|
29
|
+
cache_root: Path,
|
|
30
|
+
archive_path: str | Path,
|
|
31
|
+
) -> Path:
|
|
32
|
+
"""Ensure the cache for ``env`` is populated; return the site-packages dir.
|
|
33
|
+
|
|
34
|
+
Implements the D14 protocol: an unsynchronized fast-path read for the
|
|
35
|
+
common cache-hit case, falling back to a locked slow path that extracts
|
|
36
|
+
under the D4 atomic-replace dance and sweeps stale ``.old.<pid>`` siblings.
|
|
37
|
+
"""
|
|
38
|
+
cache_key = _cache_key(env)
|
|
39
|
+
site_parent = cache_root / cache_key
|
|
40
|
+
site_dir = site_parent / "site-packages"
|
|
41
|
+
|
|
42
|
+
# D14 fast path: no lock if cache hit and FORCE_EXTRACT unset.
|
|
43
|
+
if site_dir.is_dir() and not _force_extract():
|
|
44
|
+
return site_dir
|
|
45
|
+
|
|
46
|
+
lock_path = cache_root / f"{cache_key}.lock"
|
|
47
|
+
with lock(lock_path):
|
|
48
|
+
# Re-check inside the lock; a sibling may have just won the race.
|
|
49
|
+
if site_dir.is_dir() and not _force_extract():
|
|
50
|
+
return site_dir
|
|
51
|
+
|
|
52
|
+
tmp_dir = cache_root / f".{cache_key}.tmp.{os.getpid()}"
|
|
53
|
+
try:
|
|
54
|
+
_extract_to(archive_path, tmp_dir)
|
|
55
|
+
atomic_replace_dir(tmp_dir, site_parent, os.getpid())
|
|
56
|
+
finally:
|
|
57
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
58
|
+
|
|
59
|
+
_sweep_old_siblings(site_parent)
|
|
60
|
+
|
|
61
|
+
return site_dir
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def atomic_replace_dir(src: Path, dst: Path, pid: int) -> None:
|
|
65
|
+
"""D4 protocol: rename existing ``dst`` aside, replace, clean up.
|
|
66
|
+
|
|
67
|
+
The ``os.rename`` calls in this function are intentional and audited; the
|
|
68
|
+
bootstrap stdlib gate (test_bootstrap_stdlib_only.py) keys its allowlist
|
|
69
|
+
on this exact function name.
|
|
70
|
+
"""
|
|
71
|
+
old_path: Path | None = None
|
|
72
|
+
if dst.exists():
|
|
73
|
+
old_path = dst.with_name(f"{dst.name}.old.{pid}")
|
|
74
|
+
os.rename(dst, old_path)
|
|
75
|
+
try:
|
|
76
|
+
_replace_with_retry(src, dst)
|
|
77
|
+
except Exception:
|
|
78
|
+
if old_path is not None and old_path.exists():
|
|
79
|
+
os.rename(old_path, dst)
|
|
80
|
+
raise
|
|
81
|
+
if old_path is not None:
|
|
82
|
+
shutil.rmtree(old_path, ignore_errors=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------- private helpers, in materialize()'s call order ----------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cache_key(env: Environment) -> str:
|
|
89
|
+
# D5: PEP 503 normalize the raw env.json name; build_id is already
|
|
90
|
+
# lowercase hex per spec 05 §3.3.
|
|
91
|
+
normalized = re.sub(r"[-_.]+", "-", env.name).lower()
|
|
92
|
+
return f"{normalized}_{env.build_id}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _force_extract() -> bool:
|
|
96
|
+
# D16: present and non-empty after os.environ.get is truthy.
|
|
97
|
+
# MOONLIT_FORCE_EXTRACT="0" is non-empty hence truthy (spec 03 §9).
|
|
98
|
+
return bool(os.environ.get("MOONLIT_FORCE_EXTRACT", ""))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extract_to(archive_path: str | Path, tmp_dir: Path) -> None:
|
|
102
|
+
if tmp_dir.exists():
|
|
103
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
104
|
+
tmp_site_packages = tmp_dir / "site-packages"
|
|
105
|
+
tmp_site_packages.mkdir(parents=True, exist_ok=False)
|
|
106
|
+
|
|
107
|
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
108
|
+
for info in zf.infolist():
|
|
109
|
+
arcname = info.filename
|
|
110
|
+
if not arcname.startswith("site-packages/"):
|
|
111
|
+
continue # D1: only the site-packages/ prefix is extracted.
|
|
112
|
+
rel = arcname[len("site-packages/") :]
|
|
113
|
+
if not rel:
|
|
114
|
+
continue # bare directory marker
|
|
115
|
+
normalized = posixpath.normpath(rel)
|
|
116
|
+
_reject_unsafe_path(arcname, normalized)
|
|
117
|
+
dest = tmp_site_packages / normalized
|
|
118
|
+
_extract_one(zf, info, dest)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reject_unsafe_path(arcname: str, normalized: str) -> None:
|
|
122
|
+
if normalized.startswith("/") or ".." in normalized.split("/"):
|
|
123
|
+
raise ExtractionError(
|
|
124
|
+
f"archive entry has unsafe path after normalization: {arcname!r} -> {normalized!r}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extract_one(zf: zipfile.ZipFile, info: zipfile.ZipInfo, dest: Path) -> None:
|
|
129
|
+
if info.is_dir():
|
|
130
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
mode = info.external_attr >> 16
|
|
135
|
+
is_symlink = (mode & 0o170000) == 0o120000
|
|
136
|
+
|
|
137
|
+
if is_symlink:
|
|
138
|
+
_extract_symlink(zf, info, dest)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
with zf.open(info, "r") as src, open(dest, "wb") as out:
|
|
142
|
+
shutil.copyfileobj(src, out)
|
|
143
|
+
if mode and os.name != "nt":
|
|
144
|
+
try:
|
|
145
|
+
os.chmod(dest, mode & 0o7777)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass # best-effort per spec §11
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_symlink(zf: zipfile.ZipFile, info: zipfile.ZipInfo, dest: Path) -> None:
|
|
151
|
+
target = zf.read(info).decode("utf-8")
|
|
152
|
+
if os.name != "nt":
|
|
153
|
+
os.symlink(target, dest)
|
|
154
|
+
return
|
|
155
|
+
# Windows: follow the link target inside the archive and write resolved
|
|
156
|
+
# bytes as a regular file (spec 03 §6 step 2).
|
|
157
|
+
resolved = _resolve_archive_path(info.filename, target)
|
|
158
|
+
try:
|
|
159
|
+
target_info = zf.getinfo(resolved)
|
|
160
|
+
except KeyError as exc:
|
|
161
|
+
raise ExtractionError(
|
|
162
|
+
f"symlink target not in archive: {info.filename!r} -> {target!r}"
|
|
163
|
+
) from exc
|
|
164
|
+
with zf.open(target_info, "r") as src, open(dest, "wb") as out:
|
|
165
|
+
shutil.copyfileobj(src, out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_archive_path(src_filename: str, target: str) -> str:
|
|
169
|
+
if posixpath.isabs(target):
|
|
170
|
+
return target.lstrip("/")
|
|
171
|
+
return posixpath.normpath(posixpath.join(posixpath.dirname(src_filename), target))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _replace_with_retry(src: Path, dst: Path) -> None:
|
|
175
|
+
"""3-attempt retry around ``os.replace`` to ride out transient holds.
|
|
176
|
+
|
|
177
|
+
On Windows, AV/EDR may briefly hold extracted DLLs open (spec §6 step 4).
|
|
178
|
+
"""
|
|
179
|
+
last: OSError | None = None
|
|
180
|
+
for attempt in range(_OS_REPLACE_RETRIES):
|
|
181
|
+
try:
|
|
182
|
+
os.replace(src, dst)
|
|
183
|
+
return
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
last = exc
|
|
186
|
+
if attempt < _OS_REPLACE_RETRIES - 1:
|
|
187
|
+
sleep(_OS_REPLACE_BACKOFF_S)
|
|
188
|
+
raise ExtractionError(
|
|
189
|
+
f"os.replace({src} -> {dst}) failed after {_OS_REPLACE_RETRIES} "
|
|
190
|
+
f"attempts: errno={last.errno if last else '?'} {last}"
|
|
191
|
+
) from last
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _sweep_old_siblings(site_parent: Path) -> None:
|
|
195
|
+
"""Best-effort cleanup of stale ``.old.<pid>`` siblings (D4)."""
|
|
196
|
+
parent = site_parent.parent
|
|
197
|
+
if not parent.is_dir():
|
|
198
|
+
return
|
|
199
|
+
prefix = f"{site_parent.name}.old."
|
|
200
|
+
for entry in parent.iterdir():
|
|
201
|
+
if entry.name.startswith(prefix):
|
|
202
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Sentinel-file locking for the bootstrap (D13).
|
|
2
|
+
|
|
3
|
+
A lock acquired via O_CREAT|O_EXCL|O_RDWR with polling. Timeout is 60 s
|
|
4
|
+
wall clock; poll interval is 50 ms; the first attempt has no preceding
|
|
5
|
+
sleep (specs/03-bootstrap-runtime.md §5).
|
|
6
|
+
|
|
7
|
+
Stale-lock recovery is manual: ``rm <lock_path>``. A real flock /
|
|
8
|
+
msvcrt.LK_NBLCK implementation is deferred to v0.2.
|
|
9
|
+
|
|
10
|
+
``sleep`` and ``monotonic`` are imported as bare names so tests can
|
|
11
|
+
monkeypatch them on this module without affecting the rest of the process.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from time import monotonic, sleep
|
|
19
|
+
|
|
20
|
+
from .errors import LockTimeoutError
|
|
21
|
+
|
|
22
|
+
_TIMEOUT_S: float = 60.0
|
|
23
|
+
_POLL_INTERVAL_S: float = 0.050
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def acquire(lock_path: str | Path) -> int:
|
|
27
|
+
"""Acquire the sentinel lock at ``lock_path``; return the open fd.
|
|
28
|
+
|
|
29
|
+
Polls with O_CREAT|O_EXCL until the file can be created or the timeout
|
|
30
|
+
elapses. Raises :class:`LockTimeoutError` after the wall-clock timeout.
|
|
31
|
+
"""
|
|
32
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_RDWR
|
|
33
|
+
deadline = monotonic() + _TIMEOUT_S
|
|
34
|
+
while True:
|
|
35
|
+
try:
|
|
36
|
+
return os.open(lock_path, flags, 0o600)
|
|
37
|
+
except FileExistsError:
|
|
38
|
+
if monotonic() >= deadline:
|
|
39
|
+
# `from None` because the FileExistsError is the expected polling
|
|
40
|
+
# signal, not an underlying cause to surface to the user.
|
|
41
|
+
raise LockTimeoutError(
|
|
42
|
+
f"lock acquisition timed out ({_TIMEOUT_S:g}s) at {lock_path}; "
|
|
43
|
+
f"remove this file or set MOONLIT_FORCE_EXTRACT=1"
|
|
44
|
+
) from None
|
|
45
|
+
sleep(_POLL_INTERVAL_S)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def release(fd: int, lock_path: str | Path) -> None:
|
|
49
|
+
"""Close ``fd`` and unlink ``lock_path``; tolerate an already-removed file."""
|
|
50
|
+
os.close(fd)
|
|
51
|
+
try:
|
|
52
|
+
os.unlink(lock_path)
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextlib.contextmanager
|
|
58
|
+
def lock(lock_path: str | Path) -> Iterator[int]:
|
|
59
|
+
"""Context-manager wrapping :func:`acquire` / :func:`release`."""
|
|
60
|
+
fd = acquire(lock_path)
|
|
61
|
+
try:
|
|
62
|
+
yield fd
|
|
63
|
+
finally:
|
|
64
|
+
release(fd, lock_path)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""sys.path setup, entry-point resolution, and return-value coercion.
|
|
2
|
+
|
|
3
|
+
Implements specs/03-bootstrap-runtime.md §7 (collision check, addsitedir)
|
|
4
|
+
and §8 (entry-point parsing, import, getattr walk, invocation, return-value
|
|
5
|
+
coercion). Failures from this module raise either CollisionError (exit 1)
|
|
6
|
+
or EntryPointError (exit 2) per the D3 runtime enumeration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import os
|
|
11
|
+
import site
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .environment import Environment
|
|
15
|
+
from .errors import CollisionError, EntryPointError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(env: Environment, site_dir: Path) -> int:
|
|
19
|
+
"""Set up sys.path, resolve and invoke the entry point, coerce its return.
|
|
20
|
+
|
|
21
|
+
Returns an int in [0, 255] suitable for ``sys.exit``.
|
|
22
|
+
"""
|
|
23
|
+
_check_no_bootstrap_collision(site_dir)
|
|
24
|
+
site.addsitedir(str(site_dir))
|
|
25
|
+
entry_point_str = _resolve_entry_point_string(env)
|
|
26
|
+
module_name, attr = _parse_entry_point(entry_point_str)
|
|
27
|
+
obj = _import_and_resolve(module_name, attr)
|
|
28
|
+
return _coerce_return(obj())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------- private helpers, in run()'s call order ----------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_no_bootstrap_collision(site_dir: Path) -> None:
|
|
35
|
+
# spec §7: case-fold compare for Windows / HFS+ case-insensitive filesystems.
|
|
36
|
+
for entry in os.listdir(site_dir):
|
|
37
|
+
if entry.casefold() == "_bootstrap":
|
|
38
|
+
raise CollisionError("_bootstrap collision in staged tree")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_entry_point_string(env: Environment) -> str:
|
|
42
|
+
# D16: empty (after os.environ.get default) is treated as unset.
|
|
43
|
+
override = os.environ.get("MOONLIT_ENTRY_POINT", "")
|
|
44
|
+
return override if override else env.entry_point
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_entry_point(value: str) -> tuple[str, str]:
|
|
48
|
+
parts = value.split(":")
|
|
49
|
+
if len(parts) != 2:
|
|
50
|
+
raise EntryPointError(f"invalid entry point: {value}")
|
|
51
|
+
module, attr = parts
|
|
52
|
+
if not module or not attr:
|
|
53
|
+
raise EntryPointError(f"invalid entry point: {value}")
|
|
54
|
+
if "" in attr.split("."):
|
|
55
|
+
raise EntryPointError(f"invalid entry point: {value}")
|
|
56
|
+
return module, attr
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _import_and_resolve(module_name: str, attr: str) -> object:
|
|
60
|
+
try:
|
|
61
|
+
module = importlib.import_module(module_name)
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
raise EntryPointError(f"cannot import {module_name}: {exc}") from exc
|
|
64
|
+
obj: object = module
|
|
65
|
+
for part in attr.split("."):
|
|
66
|
+
try:
|
|
67
|
+
obj = getattr(obj, part)
|
|
68
|
+
except AttributeError as exc:
|
|
69
|
+
raise EntryPointError(f"attribute {attr} not found on {module_name}") from exc
|
|
70
|
+
return obj
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _coerce_return(result: object) -> int:
|
|
74
|
+
if result is None:
|
|
75
|
+
return 0
|
|
76
|
+
# bool is int in Python; True → 1, False → 0 fall through here (spec §8).
|
|
77
|
+
if isinstance(result, int):
|
|
78
|
+
return result & 0xFF
|
|
79
|
+
try:
|
|
80
|
+
return int(result) & 0xFF
|
|
81
|
+
except (TypeError, ValueError) as exc:
|
|
82
|
+
raise EntryPointError(
|
|
83
|
+
f"entry point returned uncoercible value: {type(result).__name__}"
|
|
84
|
+
) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Build-time template resources copied verbatim into produced .pyz files."""
|