ghc-compiler-python 9.4.8__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.
@@ -0,0 +1,13 @@
1
+ """ghc-compiler-python: Native GHC and Cabal packaged as a Python Wheel."""
2
+
3
+ __version__ = "9.4.8"
4
+ __ghc_version__ = "9.4.8"
5
+ __cabal_version__ = "3.10.3.0"
6
+ __author__ = "ghc-compiler-python contributors"
7
+ __license__ = "MIT"
8
+
9
+ __all__ = [
10
+ "__version__",
11
+ "__ghc_version__",
12
+ "__cabal_version__",
13
+ ]
@@ -0,0 +1,417 @@
1
+ # ghc_compiler_python/bootstrap.py
2
+ """
3
+ First-run acquisition of the native GHC/Cabal payload.
4
+
5
+ This package ships in two tiers:
6
+
7
+ * **thin** — the PyPI wheel (``py3-none-any``). Pure Python; the native
8
+ toolchain is absent and is fetched from GitHub Releases on first use.
9
+ * **offline** — the platform-tagged wheels published on GitHub Releases.
10
+ The toolchain is bundled; this module is never invoked.
11
+
12
+ The tier is not recorded anywhere: it is *observed*. If a bundled toolchain is
13
+ present the wrapper uses it, otherwise it asks this module for a payload root.
14
+
15
+ Design constraints, in order of precedence:
16
+
17
+ 1. Never half-install. Download, hash-verify, and extract happen out of line
18
+ and are promoted into place with a single atomic rename. A cache directory
19
+ that exists is a cache directory that is complete.
20
+ 2. Never silently substitute. A payload whose SHA-256 does not match the
21
+ manifest shipped in this wheel is a hard failure, not a warning.
22
+ 3. Never leave the user guessing. Failures name the offline wheel that would
23
+ have made the network unnecessary.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import shutil
32
+ import sys
33
+ import tarfile
34
+ import tempfile
35
+ import time
36
+ import urllib.error
37
+ import urllib.request
38
+ import zipfile
39
+ from pathlib import Path
40
+ from typing import NoReturn, Optional
41
+
42
+ GHC_VERSION = "9.4.8"
43
+
44
+ #: Release assets are addressed by tag, so a wheel always fetches the payload
45
+ #: built alongside it rather than whatever happens to be newest.
46
+ _RELEASE_BASE = (
47
+ "https://github.com/Saimonokuma/GHC-COMPILER-PYTHON/releases/download"
48
+ f"/v{GHC_VERSION}"
49
+ )
50
+
51
+ _HASH_MANIFEST = Path(__file__).resolve().parent / "payload_hashes.json"
52
+
53
+ _ENV_HOME = "GHC_COMPILER_PYTHON_HOME"
54
+ _ENV_OFFLINE = "GHC_COMPILER_PYTHON_OFFLINE"
55
+
56
+ _DOWNLOAD_TIMEOUT = 60
57
+ _LOCK_TIMEOUT = 900
58
+ _CHUNK = 1 << 20
59
+
60
+
61
+ class BootstrapError(RuntimeError):
62
+ """Raised when the native payload cannot be made available."""
63
+
64
+
65
+ def _die(msg: str) -> NoReturn:
66
+ raise BootstrapError(msg)
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # platform identity
71
+ # --------------------------------------------------------------------------
72
+
73
+ def platform_tag() -> str:
74
+ """Return the payload tag for the running interpreter.
75
+
76
+ These strings are the *payload* identifiers and deliberately mirror the
77
+ wheel platform tags used for the offline tier, so a user who hits a network
78
+ failure can map the error message straight onto a release asset.
79
+ """
80
+ machine = (os.uname().machine if hasattr(os, "uname") else os.environ.get(
81
+ "PROCESSOR_ARCHITECTURE", "AMD64")).lower()
82
+
83
+ if sys.platform.startswith("linux"):
84
+ if machine in ("x86_64", "amd64"):
85
+ return "manylinux_2_39_x86_64"
86
+ if machine in ("aarch64", "arm64"):
87
+ return "manylinux_2_39_aarch64"
88
+ elif sys.platform == "darwin":
89
+ if machine in ("arm64", "aarch64"):
90
+ return "macosx_11_0_arm64"
91
+ if machine == "x86_64":
92
+ return "macosx_10_9_x86_64"
93
+ elif sys.platform == "win32":
94
+ if machine in ("amd64", "x86_64"):
95
+ return "win_amd64"
96
+
97
+ _die(
98
+ f"Unsupported platform: {sys.platform}/{machine}. "
99
+ f"GHC {GHC_VERSION} payloads are published for Linux x86_64, "
100
+ "macOS arm64/x86_64 and Windows x86_64."
101
+ )
102
+
103
+
104
+ def _archive_suffix() -> str:
105
+ # Windows runners produce a zip; the Unix payloads are xz tarballs.
106
+ return ".zip" if sys.platform == "win32" else ".tar.xz"
107
+
108
+
109
+ def payload_name() -> str:
110
+ return f"ghc-payload-{GHC_VERSION}-{platform_tag()}{_archive_suffix()}"
111
+
112
+
113
+ def payload_url() -> str:
114
+ return f"{_RELEASE_BASE}/{payload_name()}"
115
+
116
+
117
+ # --------------------------------------------------------------------------
118
+ # cache location
119
+ # --------------------------------------------------------------------------
120
+
121
+ def cache_root() -> Path:
122
+ """Directory holding extracted payloads, one subdirectory per version.
123
+
124
+ ``GHC_COMPILER_PYTHON_HOME`` overrides the default for users who cannot
125
+ write to the standard location (CI images, locked-down hosts, or anyone who
126
+ simply wants the toolchain on a different volume).
127
+ """
128
+ override = os.environ.get(_ENV_HOME)
129
+ if override:
130
+ return Path(override).expanduser().resolve()
131
+
132
+ if sys.platform == "win32":
133
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
134
+ return Path(base) / "ghc-compiler-python" / "Cache"
135
+ if sys.platform == "darwin":
136
+ return Path.home() / "Library" / "Caches" / "ghc-compiler-python"
137
+
138
+ xdg = os.environ.get("XDG_CACHE_HOME")
139
+ base = Path(xdg) if xdg else Path.home() / ".cache"
140
+ return base / "ghc-compiler-python"
141
+
142
+
143
+ def payload_root() -> Path:
144
+ """Where this version's toolchain lives once installed."""
145
+ return cache_root() / GHC_VERSION / platform_tag()
146
+
147
+
148
+ def is_installed() -> bool:
149
+ """True when a complete payload is present.
150
+
151
+ Completion is signalled by a stamp written *after* the atomic rename, so a
152
+ directory that exists without a stamp is treated as absent and replaced.
153
+ """
154
+ return (payload_root() / ".complete").is_file()
155
+
156
+
157
+ # --------------------------------------------------------------------------
158
+ # integrity
159
+ # --------------------------------------------------------------------------
160
+
161
+ def _expected_digest() -> str:
162
+ if not _HASH_MANIFEST.is_file():
163
+ _die(
164
+ "Payload hash manifest is missing from the installed package "
165
+ f"({_HASH_MANIFEST}). This wheel was built incorrectly; reinstall "
166
+ "from PyPI or use an offline wheel from the releases page."
167
+ )
168
+ try:
169
+ manifest = json.loads(_HASH_MANIFEST.read_text(encoding="utf-8"))
170
+ except (OSError, ValueError) as exc:
171
+ _die(f"Payload hash manifest is unreadable: {exc}")
172
+
173
+ digest = manifest.get(payload_name())
174
+ if not digest:
175
+ _die(
176
+ f"No SHA-256 recorded for {payload_name()} in the hash manifest. "
177
+ "This platform was not published for this version."
178
+ )
179
+ return str(digest).lower()
180
+
181
+
182
+ def _digest_file(path: Path) -> str:
183
+ h = hashlib.sha256()
184
+ with path.open("rb") as fh:
185
+ for block in iter(lambda: fh.read(_CHUNK), b""):
186
+ h.update(block)
187
+ return h.hexdigest()
188
+
189
+
190
+ # --------------------------------------------------------------------------
191
+ # acquisition
192
+ # --------------------------------------------------------------------------
193
+
194
+ def _offline_hint() -> str:
195
+ return (
196
+ "If this machine has no network access, install the self-contained "
197
+ "wheel instead — it bundles the toolchain and never downloads:\n"
198
+ f" {_RELEASE_BASE}/"
199
+ f"ghc_compiler_python-{GHC_VERSION}-py3-none-{platform_tag()}.whl"
200
+ )
201
+
202
+
203
+ def _download(url: str, dest: Path, quiet: bool) -> None:
204
+ if not quiet:
205
+ sys.stderr.write(f"Fetching GHC {GHC_VERSION} payload ({platform_tag()})\n")
206
+ sys.stderr.write(f" from {url}\n")
207
+
208
+ request = urllib.request.Request(
209
+ url, headers={"User-Agent": f"ghc-compiler-python/{GHC_VERSION}"}
210
+ )
211
+ try:
212
+ with urllib.request.urlopen(request, timeout=_DOWNLOAD_TIMEOUT) as response:
213
+ total = int(response.headers.get("Content-Length") or 0)
214
+ seen = 0
215
+ last = 0.0
216
+ with dest.open("wb") as out:
217
+ while True:
218
+ chunk = response.read(_CHUNK)
219
+ if not chunk:
220
+ break
221
+ out.write(chunk)
222
+ seen += len(chunk)
223
+ now = time.monotonic()
224
+ if not quiet and total and now - last > 0.5:
225
+ last = now
226
+ pct = seen * 100 // total
227
+ sys.stderr.write(
228
+ f"\r {pct:3d}% {seen >> 20} / {total >> 20} MiB"
229
+ )
230
+ sys.stderr.flush()
231
+ if not quiet and total:
232
+ sys.stderr.write("\r 100% done \n")
233
+ except urllib.error.HTTPError as exc:
234
+ _die(
235
+ f"Download failed with HTTP {exc.code} for {url}\n"
236
+ f"{_offline_hint()}"
237
+ )
238
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
239
+ _die(f"Download failed: {exc}\n{_offline_hint()}")
240
+
241
+
242
+ def _is_within(base: Path, target: Path) -> bool:
243
+ try:
244
+ target.resolve().relative_to(base.resolve())
245
+ return True
246
+ except ValueError:
247
+ return False
248
+
249
+
250
+ def _extract(archive: Path, dest: Path) -> None:
251
+ """Extract the payload, refusing entries that escape the destination.
252
+
253
+ Release assets are our own, but an archive member is still untrusted input:
254
+ a traversal entry would write outside the cache. Python 3.12 grew a
255
+ ``filter`` argument for exactly this; older interpreters are checked by hand
256
+ so the guarantee does not depend on the runtime version.
257
+ """
258
+ dest.mkdir(parents=True, exist_ok=True)
259
+
260
+ if archive.suffix == ".zip":
261
+ with zipfile.ZipFile(archive) as zf:
262
+ for member in zf.namelist():
263
+ if not _is_within(dest, dest / member):
264
+ _die(f"Refusing archive entry outside destination: {member}")
265
+ zf.extractall(dest)
266
+ return
267
+
268
+ with tarfile.open(archive, "r:xz") as tf:
269
+ if hasattr(tarfile, "data_filter"):
270
+ tf.extractall(dest, filter="data")
271
+ else:
272
+ for member in tf.getmembers():
273
+ if member.issym() or member.islnk():
274
+ link = dest / Path(member.name).parent / member.linkname
275
+ if not _is_within(dest, link):
276
+ _die(f"Refusing link escaping destination: {member.name}")
277
+ elif not _is_within(dest, dest / member.name):
278
+ _die(f"Refusing archive entry outside destination: {member.name}")
279
+ tf.extractall(dest)
280
+
281
+
282
+ def _restore_exec_bits(root: Path) -> None:
283
+ """Re-assert the executable bit on ``bin/`` after extraction.
284
+
285
+ Zip carries no POSIX mode, and a payload unpacked from one on a Unix host
286
+ would otherwise yield a toolchain that cannot be run.
287
+ """
288
+ if sys.platform == "win32":
289
+ return
290
+ for directory in (root / "bin", root / "lib"):
291
+ if not directory.is_dir():
292
+ continue
293
+ for path in directory.rglob("*"):
294
+ if path.is_file() and not path.is_symlink():
295
+ mode = path.stat().st_mode
296
+ if mode & 0o111 or path.suffix in ("", ".so"):
297
+ path.chmod(mode | 0o755)
298
+
299
+
300
+ class _DirectoryLock:
301
+ """Cross-process lock built on atomic ``mkdir``.
302
+
303
+ Two interpreters importing the package at once must not both download
304
+ 350 MB. ``mkdir`` is atomic on every filesystem we target, which makes it a
305
+ more portable primitive here than ``fcntl`` or ``msvcrt`` locking.
306
+ """
307
+
308
+ def __init__(self, path: Path) -> None:
309
+ self._path = path
310
+ self._held = False
311
+
312
+ def __enter__(self) -> "_DirectoryLock":
313
+ self._path.parent.mkdir(parents=True, exist_ok=True)
314
+ deadline = time.monotonic() + _LOCK_TIMEOUT
315
+ announced = False
316
+ while True:
317
+ try:
318
+ self._path.mkdir()
319
+ self._held = True
320
+ return self
321
+ except FileExistsError:
322
+ if is_installed():
323
+ # Whoever held the lock finished the job for us.
324
+ return self
325
+ if time.monotonic() > deadline:
326
+ _die(
327
+ f"Timed out waiting for another process to finish the "
328
+ f"GHC download (lock: {self._path}). If no other "
329
+ "install is running, remove that directory and retry."
330
+ )
331
+ if not announced:
332
+ announced = True
333
+ sys.stderr.write(
334
+ "Waiting for a concurrent GHC installation to finish...\n"
335
+ )
336
+ time.sleep(0.5)
337
+
338
+ def __exit__(self, *_exc: object) -> None:
339
+ if self._held:
340
+ shutil.rmtree(self._path, ignore_errors=True)
341
+
342
+
343
+ def ensure_payload(quiet: bool = False) -> Path:
344
+ """Return the payload root, installing it first if necessary.
345
+
346
+ Idempotent and safe to call from every entry point on every invocation:
347
+ the fast path is a single ``stat`` of the completion stamp.
348
+ """
349
+ root = payload_root()
350
+ if is_installed():
351
+ return root
352
+
353
+ if os.environ.get(_ENV_OFFLINE, "").strip().lower() in ("1", "true", "yes"):
354
+ _die(
355
+ f"{_ENV_OFFLINE} is set and no toolchain is installed at {root}.\n"
356
+ f"{_offline_hint()}"
357
+ )
358
+
359
+ expected = _expected_digest()
360
+
361
+ with _DirectoryLock(cache_root() / f".lock-{GHC_VERSION}-{platform_tag()}"):
362
+ if is_installed():
363
+ return root
364
+
365
+ root.parent.mkdir(parents=True, exist_ok=True)
366
+ staging = Path(tempfile.mkdtemp(prefix=".ghc-staging-", dir=str(root.parent)))
367
+ archive = staging / payload_name()
368
+ try:
369
+ _download(payload_url(), archive, quiet)
370
+
371
+ actual = _digest_file(archive)
372
+ if actual != expected:
373
+ _die(
374
+ "Payload integrity check FAILED — refusing to install.\n"
375
+ f" expected SHA-256: {expected}\n"
376
+ f" actual SHA-256: {actual}\n"
377
+ "The download was corrupted or the release asset was "
378
+ "modified. Nothing has been installed."
379
+ )
380
+
381
+ unpacked = staging / "root"
382
+ _extract(archive, unpacked)
383
+ archive.unlink(missing_ok=True)
384
+
385
+ # GHC tarballs carry a single top-level directory; flatten it so the
386
+ # payload root always has bin/ and lib/ directly beneath it.
387
+ entries = [p for p in unpacked.iterdir()]
388
+ if len(entries) == 1 and entries[0].is_dir():
389
+ unpacked = entries[0]
390
+
391
+ _restore_exec_bits(unpacked)
392
+ (unpacked / ".complete").write_text(
393
+ f"{GHC_VERSION} {platform_tag()}\n", encoding="utf-8"
394
+ )
395
+
396
+ if root.exists():
397
+ shutil.rmtree(root, ignore_errors=True)
398
+ os.replace(unpacked, root)
399
+ finally:
400
+ shutil.rmtree(staging, ignore_errors=True)
401
+
402
+ if not quiet:
403
+ sys.stderr.write(f"GHC {GHC_VERSION} installed to {root}\n")
404
+ return root
405
+
406
+
407
+ def find_installed_root() -> Optional[Path]:
408
+ """Return the cached payload root, or ``None`` — never downloads.
409
+
410
+ Used by the wrapper to decide whether a bundled toolchain or a previously
411
+ bootstrapped one should win, without triggering acquisition as a side
412
+ effect of merely asking.
413
+ """
414
+ try:
415
+ return payload_root() if is_installed() else None
416
+ except BootstrapError:
417
+ return None
@@ -0,0 +1,5 @@
1
+ {
2
+ "ghc-payload-9.4.8-macosx_11_0_arm64.tar.xz": "eb826736bb3fa238603183089872fced21231660bc5c1e74067c77a7a77965f9",
3
+ "ghc-payload-9.4.8-manylinux_2_39_x86_64.tar.xz": "09b4127c288f0c08f77ec4bfbcd2dbc4192566d84b0db4d7c16942dc53d7fd72",
4
+ "ghc-payload-9.4.8-win_amd64.zip": "31629e8a008d3adf83e0869463c1a3bdf696ae02bf5be51fa110cc8cba484d1d"
5
+ }
File without changes