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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
moonlit/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m moonlit`` shim — delegates to :func:`moonlit.cli.main`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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."""
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from _bootstrap import bootstrap
3
+ sys.exit(bootstrap())