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.
- ghc_compiler_python/__init__.py +13 -0
- ghc_compiler_python/bootstrap.py +417 -0
- ghc_compiler_python/payload_hashes.json +5 -0
- ghc_compiler_python/py.typed +0 -0
- ghc_compiler_python/wrapper.py +730 -0
- ghc_compiler_python-9.4.8.dist-info/METADATA +208 -0
- ghc_compiler_python-9.4.8.dist-info/RECORD +10 -0
- ghc_compiler_python-9.4.8.dist-info/WHEEL +4 -0
- ghc_compiler_python-9.4.8.dist-info/entry_points.txt +11 -0
- ghc_compiler_python-9.4.8.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
# ghc_compiler_python/wrapper.py
|
|
2
|
+
"""
|
|
3
|
+
Subprocess proxy wrappers for GHC and Cabal binaries.
|
|
4
|
+
|
|
5
|
+
Provides hermetic execution isolation, environment sterilization,
|
|
6
|
+
pre-flight C-linker validation, runtime path resolution, and process proxying.
|
|
7
|
+
|
|
8
|
+
FIX v5: Fixed LD_LIBRARY_PATH propagation to ghc-pkg recache.
|
|
9
|
+
Added platform-specific lib subdir to LD_LIBRARY_PATH on Linux.
|
|
10
|
+
Thread sterilized environment through _resolve_runtime_paths → _rebuild_package_cache → _ghc_pkg_recache.
|
|
11
|
+
FIX v4: Added ghc-pkg recache after @GHC_PREFIX@ replacement to regenerate package.cache.
|
|
12
|
+
FIX v3: Fixed platform-specific path detection for settings and package.conf.d.
|
|
13
|
+
FIX v2: Added DYLD_LIBRARY_PATH for macOS runtime library resolution.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
import functools
|
|
22
|
+
import mmap
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, List, NoReturn, Optional, Type
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
GHC_VERSION = "9.4.8"
|
|
29
|
+
CABAL_VERSION = "3.10.3.0"
|
|
30
|
+
|
|
31
|
+
HASKELL_POLLUTION_VARS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"GHC_PACKAGE_PATH",
|
|
34
|
+
"GHC_ENVIRONMENT",
|
|
35
|
+
"CABAL_DIR",
|
|
36
|
+
"CABAL_CONFIG",
|
|
37
|
+
"HASKELL_DIST_DIR",
|
|
38
|
+
"HASKELL_PACKAGE_SANDBOX",
|
|
39
|
+
"HASKELL_PACKAGE_SANDBOXES",
|
|
40
|
+
"STACK_ROOT",
|
|
41
|
+
"STACK_YAML",
|
|
42
|
+
"GHCRTS",
|
|
43
|
+
"GHCRTS_OPTS",
|
|
44
|
+
"HEAPSIZE",
|
|
45
|
+
"HOME",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_HOME_ORIGINAL: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _die(msg: str) -> NoReturn:
|
|
53
|
+
sys.stderr.write(f"{msg}\n")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Toolchain root resolution
|
|
59
|
+
#
|
|
60
|
+
# The toolchain reaches a machine one of two ways:
|
|
61
|
+
#
|
|
62
|
+
# offline wheel — platform-tagged, toolchain bundled under sys.prefix
|
|
63
|
+
# thin wheel — pure Python, toolchain fetched to a user cache on first use
|
|
64
|
+
#
|
|
65
|
+
# Nothing records which tier is installed; it is observed. A bundled toolchain
|
|
66
|
+
# always wins, so an offline install never touches the network even if a cached
|
|
67
|
+
# payload happens to exist alongside it.
|
|
68
|
+
#
|
|
69
|
+
# Note that not every sys.prefix reference means "the GHC root". The writable
|
|
70
|
+
# HOME and the script directory are properties of the *environment* and stay on
|
|
71
|
+
# sys.prefix regardless of where GHC itself lives.
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _looks_like_ghc_root(base: Path) -> bool:
|
|
75
|
+
"""True when `base` contains a usable GHC installation."""
|
|
76
|
+
if not base.is_dir():
|
|
77
|
+
return False
|
|
78
|
+
bin_dir = base / ("bin" if (base / "bin").is_dir() else "Scripts")
|
|
79
|
+
exe = "ghc.exe" if sys.platform == "win32" else "ghc"
|
|
80
|
+
if (bin_dir / exe).exists():
|
|
81
|
+
return True
|
|
82
|
+
# A staged install may expose ghc only under lib/ghc-<version>/bin.
|
|
83
|
+
return (base / "lib" / f"ghc-{GHC_VERSION}" / "bin" / exe).exists()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _bundled_root() -> Optional[Path]:
|
|
87
|
+
"""Return the bundled toolchain root, or None if this is a thin install."""
|
|
88
|
+
for candidate in (
|
|
89
|
+
Path(sys.prefix),
|
|
90
|
+
Path(__file__).resolve().parent.parent,
|
|
91
|
+
):
|
|
92
|
+
if _looks_like_ghc_root(candidate):
|
|
93
|
+
return candidate
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ghc_root_if_present() -> Optional[Path]:
|
|
98
|
+
"""Return an already-available toolchain root, or None. Never acquires.
|
|
99
|
+
|
|
100
|
+
Everything that merely *describes* the toolchain -- library search paths,
|
|
101
|
+
PATH construction, resource location -- uses this. Building an environment
|
|
102
|
+
must not trigger a download as a side effect; only executing a tool may,
|
|
103
|
+
and by then _resolve_binary has already acquired it.
|
|
104
|
+
"""
|
|
105
|
+
bundled = _bundled_root()
|
|
106
|
+
if bundled is not None:
|
|
107
|
+
return bundled
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
from . import bootstrap
|
|
111
|
+
return bootstrap.find_installed_root()
|
|
112
|
+
except ImportError: # pragma: no cover - defensive
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ghc_root_or_prefix() -> Path:
|
|
117
|
+
"""Non-acquiring root with a harmless fallback for path construction."""
|
|
118
|
+
return _ghc_root_if_present() or Path(sys.prefix)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ghc_root() -> Path:
|
|
122
|
+
"""Return the active toolchain root, acquiring it if absent.
|
|
123
|
+
|
|
124
|
+
Only the execution path calls this. Not cached: `ensure_payload` is already
|
|
125
|
+
idempotent and cheap once installed (a single stat), and caching a failure
|
|
126
|
+
would strand a process that could otherwise retry.
|
|
127
|
+
"""
|
|
128
|
+
present = _ghc_root_if_present()
|
|
129
|
+
if present is not None:
|
|
130
|
+
return present
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
from . import bootstrap
|
|
134
|
+
except ImportError: # pragma: no cover - defensive
|
|
135
|
+
_die(
|
|
136
|
+
"FATAL ERROR: no bundled GHC toolchain and the bootstrap module is "
|
|
137
|
+
"unavailable. Reinstall ghc-compiler-python."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
return bootstrap.ensure_payload()
|
|
142
|
+
except bootstrap.BootstrapError as exc:
|
|
143
|
+
_die(f"FATAL ERROR: {exc}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_text_file(filepath: Path) -> bool:
|
|
147
|
+
"""Check if a file is a text file by looking for null bytes in the first 1024 bytes."""
|
|
148
|
+
try:
|
|
149
|
+
with filepath.open("rb") as f:
|
|
150
|
+
chunk = f.read(1024)
|
|
151
|
+
return b"\0" not in chunk
|
|
152
|
+
except OSError:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _search_roots() -> List[Path]:
|
|
157
|
+
"""Roots to probe for native binaries, in priority order.
|
|
158
|
+
|
|
159
|
+
Deliberately does NOT call _ghc_root(): probing must not trigger a download
|
|
160
|
+
as a side effect of merely asking whether something is present.
|
|
161
|
+
"""
|
|
162
|
+
roots: List[Path] = []
|
|
163
|
+
bundled = _bundled_root()
|
|
164
|
+
if bundled is not None:
|
|
165
|
+
roots.append(bundled)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
from . import bootstrap
|
|
169
|
+
cached = bootstrap.find_installed_root()
|
|
170
|
+
if cached is not None:
|
|
171
|
+
roots.append(cached)
|
|
172
|
+
except ImportError: # pragma: no cover - defensive
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
roots.append(Path(sys.prefix))
|
|
176
|
+
roots.append(Path(__file__).resolve().parent.parent)
|
|
177
|
+
return roots
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _try_resolve_binary(name: str) -> Optional[str]:
|
|
181
|
+
"""Resolve a native binary from what is already installed, or None.
|
|
182
|
+
|
|
183
|
+
Never acquires. Callers that require the binary go through _resolve_binary.
|
|
184
|
+
"""
|
|
185
|
+
binary_name = f"{name}.exe" if sys.platform == "win32" else name
|
|
186
|
+
|
|
187
|
+
for root in _search_roots():
|
|
188
|
+
for bin_dir in ("bin", "Scripts"):
|
|
189
|
+
candidate = root / bin_dir / binary_name
|
|
190
|
+
if candidate.exists():
|
|
191
|
+
return str(candidate)
|
|
192
|
+
nested = root / "lib" / f"ghc-{GHC_VERSION}" / "bin" / binary_name
|
|
193
|
+
if nested.exists():
|
|
194
|
+
return str(nested)
|
|
195
|
+
|
|
196
|
+
# Deliberately NOT falling back to shutil.which().
|
|
197
|
+
#
|
|
198
|
+
# This package exists to provide a hermetic, pinned GHC 9.4.8. Resolving
|
|
199
|
+
# through PATH would silently hand the caller whatever GHC happens to be
|
|
200
|
+
# installed system-wide -- a different version, or a broken one -- while
|
|
201
|
+
# still reporting success. A machine with GHC 9.6 on PATH would never
|
|
202
|
+
# download our toolchain and would compile against the wrong compiler.
|
|
203
|
+
# Absent means absent; the caller acquires it.
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_binary(name: str) -> str:
|
|
208
|
+
"""Resolve a native binary, acquiring the toolchain if it is absent."""
|
|
209
|
+
resolved = _try_resolve_binary(name)
|
|
210
|
+
if resolved:
|
|
211
|
+
return resolved
|
|
212
|
+
|
|
213
|
+
# Nothing installed yet: materialise the toolchain, then look again.
|
|
214
|
+
root = _ghc_root()
|
|
215
|
+
binary_name = f"{name}.exe" if sys.platform == "win32" else name
|
|
216
|
+
for bin_dir in ("bin", "Scripts"):
|
|
217
|
+
candidate = root / bin_dir / binary_name
|
|
218
|
+
if candidate.exists():
|
|
219
|
+
return str(candidate)
|
|
220
|
+
|
|
221
|
+
return _try_resolve_binary(name) or _die(
|
|
222
|
+
f"FATAL ERROR: compiler binary '{name}' could not be located under {root}."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_c_linker() -> None:
|
|
227
|
+
"""Pre-flight validation: assert the existence of a host C-linker."""
|
|
228
|
+
if not shutil.which("gcc") and not shutil.which("clang"):
|
|
229
|
+
_die("FATAL ERROR: The GHC compiler requires a host C-linker (gcc or clang).")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _find_platform_lib_subdir() -> str:
|
|
233
|
+
"""Find the platform-specific library subdirectory inside the GHC lib directory.
|
|
234
|
+
|
|
235
|
+
On Linux: lib/ghc-9.4.8/lib/x86_64-linux-ghc-9.4.8/
|
|
236
|
+
On macOS: lib/ghc-9.4.8/lib/aarch64-osx-ghc-9.4.8/ (or similar)
|
|
237
|
+
On Windows: Does not exist (DLLs are in mingw/bin/)
|
|
238
|
+
"""
|
|
239
|
+
ghc_lib_dir = _ghc_root_or_prefix() / "lib" / f"ghc-{GHC_VERSION}" / "lib"
|
|
240
|
+
if not ghc_lib_dir.is_dir():
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
# 🧪 Alchemist: Generator expression with next() replaces manual iteration loop
|
|
244
|
+
return next((str(c) for c in ghc_lib_dir.iterdir() if c.is_dir() and c.name.endswith(f"-ghc-{GHC_VERSION}")), "")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _sterilize_environment() -> dict:
|
|
248
|
+
"""Create a sterilized subprocess environment with proper library paths."""
|
|
249
|
+
global _HOME_ORIGINAL
|
|
250
|
+
env = {k: v for k, v in os.environ.items() if k not in HASKELL_POLLUTION_VARS}
|
|
251
|
+
|
|
252
|
+
_HOME_ORIGINAL = os.environ.get("HOME", os.environ.get("USERPROFILE", ""))
|
|
253
|
+
|
|
254
|
+
def _get_home_path() -> Path:
|
|
255
|
+
try:
|
|
256
|
+
return Path.home() / ".ghc-compiler-python-home"
|
|
257
|
+
except RuntimeError:
|
|
258
|
+
return Path()
|
|
259
|
+
|
|
260
|
+
def _try_mkdir(path: Path) -> Optional[Path]:
|
|
261
|
+
try:
|
|
262
|
+
if str(path) != ".":
|
|
263
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
return path
|
|
265
|
+
except (OSError, RuntimeError):
|
|
266
|
+
pass
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# 🧪 Alchemist: Declarative fallback chain replaces nested try-except blocks.
|
|
270
|
+
# Lazily evaluate Path.home() to prevent premature RuntimeError.
|
|
271
|
+
safe_home = (
|
|
272
|
+
_try_mkdir(Path(sys.prefix) / ".ghc-compiler-python-home") or
|
|
273
|
+
_try_mkdir(_get_home_path()) or
|
|
274
|
+
Path(tempfile.mkdtemp(prefix="ghc-compiler-python-home-"))
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
env["HOME"] = str(safe_home)
|
|
278
|
+
|
|
279
|
+
# The toolchain's own bin/ must lead PATH so GHC finds its sibling tools
|
|
280
|
+
# (ghc-pkg, hsc2hs, the mingw toolchain on Windows). When the toolchain is
|
|
281
|
+
# bundled these are the same directory; when bootstrapped they are not, and
|
|
282
|
+
# only the cache root holds the binaries.
|
|
283
|
+
bin_dir = "Scripts" if sys.platform == "win32" else "bin"
|
|
284
|
+
path_entries = []
|
|
285
|
+
ghc_root = _ghc_root_or_prefix()
|
|
286
|
+
for root in (ghc_root, Path(sys.prefix)):
|
|
287
|
+
for candidate in (root / "bin", root / bin_dir):
|
|
288
|
+
if candidate.is_dir() and str(candidate) not in path_entries:
|
|
289
|
+
path_entries.append(str(candidate))
|
|
290
|
+
if sys.platform == "win32":
|
|
291
|
+
mingw_bin = ghc_root / "mingw" / "bin"
|
|
292
|
+
if mingw_bin.is_dir():
|
|
293
|
+
path_entries.append(str(mingw_bin))
|
|
294
|
+
|
|
295
|
+
current_path = env.get("PATH", "")
|
|
296
|
+
env["PATH"] = os.pathsep.join([*path_entries, current_path]) if current_path \
|
|
297
|
+
else os.pathsep.join(path_entries)
|
|
298
|
+
|
|
299
|
+
# 🧪 Alchemist: Structural pattern matching replaces lambda-based dictionary lookup
|
|
300
|
+
match sys.platform:
|
|
301
|
+
case "darwin":
|
|
302
|
+
candidates = [
|
|
303
|
+
ghc_root / "lib" / f"ghc-{GHC_VERSION}" / "lib",
|
|
304
|
+
ghc_root / "lib",
|
|
305
|
+
]
|
|
306
|
+
vars_to_update = ["DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"]
|
|
307
|
+
case "linux":
|
|
308
|
+
candidates = [
|
|
309
|
+
ghc_root / "lib" / f"ghc-{GHC_VERSION}",
|
|
310
|
+
ghc_root / "lib" / f"ghc-{GHC_VERSION}" / "lib",
|
|
311
|
+
Path(_find_platform_lib_subdir() or "."),
|
|
312
|
+
# auditwheel vendors shared objects here on the offline wheel.
|
|
313
|
+
Path(__file__).resolve().parent.parent / "ghc_compiler_python.libs",
|
|
314
|
+
]
|
|
315
|
+
vars_to_update = ["LD_LIBRARY_PATH"]
|
|
316
|
+
case _:
|
|
317
|
+
candidates, vars_to_update = [], []
|
|
318
|
+
|
|
319
|
+
if lib_dirs_str := os.pathsep.join(
|
|
320
|
+
str(p) for p in candidates if p.is_dir() and str(p) != "."
|
|
321
|
+
):
|
|
322
|
+
for var in vars_to_update:
|
|
323
|
+
env[var] = (
|
|
324
|
+
f"{lib_dirs_str}{os.pathsep}{env[var]}"
|
|
325
|
+
if env.get(var)
|
|
326
|
+
else lib_dirs_str
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return env
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class BaseResource:
|
|
334
|
+
"""Base class for all GHC resource locators and patchers."""
|
|
335
|
+
name: str = ""
|
|
336
|
+
is_dir: bool = False
|
|
337
|
+
registry = []
|
|
338
|
+
|
|
339
|
+
def __init_subclass__(cls, **kwargs):
|
|
340
|
+
super().__init_subclass__(**kwargs)
|
|
341
|
+
cls.registry.append(cls)
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
@functools.lru_cache(maxsize=None)
|
|
345
|
+
def locate(cls, base: Optional[str] = None, version: str = GHC_VERSION) -> List[Path]:
|
|
346
|
+
"""Locate all instances of this resource relative to a base directory.
|
|
347
|
+
|
|
348
|
+
`base` defaults to None rather than sys.prefix because a default
|
|
349
|
+
argument is bound once at import time: with sys.prefix baked in, a
|
|
350
|
+
bootstrapped toolchain would be searched for in the venv and never
|
|
351
|
+
found. Resolving inside the call also keeps the lru_cache keyed on the
|
|
352
|
+
root that was actually used.
|
|
353
|
+
"""
|
|
354
|
+
base_path = Path(base) if base is not None else _ghc_root_or_prefix()
|
|
355
|
+
candidates = cls.get_candidates(base_path, version)
|
|
356
|
+
|
|
357
|
+
# Check explicit candidates first
|
|
358
|
+
for c in candidates:
|
|
359
|
+
# 🧪 Alchemist: Ternary conditional combines file and directory checks
|
|
360
|
+
if (c.is_dir() if cls.is_dir else c.is_file()) and cls.validate(c):
|
|
361
|
+
return [c]
|
|
362
|
+
|
|
363
|
+
# Dynamic fallback
|
|
364
|
+
found = []
|
|
365
|
+
if base_path.exists():
|
|
366
|
+
lib_dir = base_path / "lib"
|
|
367
|
+
search_dir = lib_dir if lib_dir.exists() else base_path
|
|
368
|
+
|
|
369
|
+
for root, dirs, files in os.walk(search_dir):
|
|
370
|
+
# ⚡ Bolt: Prune os.walk to prevent recursion into massive Python directories.
|
|
371
|
+
# Modifying `dirs` in place avoids walking into these branches entirely.
|
|
372
|
+
dirs[:] = [
|
|
373
|
+
d for d in dirs
|
|
374
|
+
if d not in {"site-packages", "dist-packages"}
|
|
375
|
+
and not d.startswith(("python", "pypy"))
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
if cls.is_dir:
|
|
379
|
+
if cls.name in dirs:
|
|
380
|
+
p = Path(root) / cls.name
|
|
381
|
+
if cls.validate(p):
|
|
382
|
+
found.append(p)
|
|
383
|
+
else:
|
|
384
|
+
if cls.name in files:
|
|
385
|
+
p = Path(root) / cls.name
|
|
386
|
+
if cls.validate(p):
|
|
387
|
+
found.append(p)
|
|
388
|
+
return found
|
|
389
|
+
|
|
390
|
+
@classmethod
|
|
391
|
+
def get_candidates(cls, base: Path, version: str) -> List[Path]:
|
|
392
|
+
return []
|
|
393
|
+
|
|
394
|
+
@classmethod
|
|
395
|
+
def validate(cls, path: Path) -> bool:
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
@classmethod
|
|
399
|
+
def extract_targets(cls, path: Path) -> List[str]:
|
|
400
|
+
"""Extract actual file targets to be patched at runtime from the located resource."""
|
|
401
|
+
if not cls.is_dir:
|
|
402
|
+
return [str(path)]
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
@classmethod
|
|
406
|
+
def patch_build_time(cls, path: Path, version: str, placeholder: str) -> int:
|
|
407
|
+
"""Patch the resource for relocatability during build."""
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class SettingsResource(BaseResource):
|
|
412
|
+
name = "settings"
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def get_candidates(cls, base: Path, version: str) -> List[Path]:
|
|
416
|
+
return [
|
|
417
|
+
base / "lib" / f"ghc-{version}" / "lib" / "settings",
|
|
418
|
+
base / "lib" / "settings",
|
|
419
|
+
base / "settings"
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
@classmethod
|
|
423
|
+
def validate(cls, path: Path) -> bool:
|
|
424
|
+
try:
|
|
425
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
426
|
+
return '"C compiler command"' in content or '"C preprocessor command"' in content
|
|
427
|
+
except OSError:
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
@classmethod
|
|
431
|
+
def patch_build_time(cls, path: Path, version: str, placeholder: str) -> int:
|
|
432
|
+
try:
|
|
433
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
434
|
+
# 🧪 Alchemist: Combine regex patterns into a single pass using alternation
|
|
435
|
+
pattern = re.compile(
|
|
436
|
+
r"/(?:usr/local/lib|usr/lib|opt|ghc-prefix)/ghc(?:-|/)" + re.escape(version) + r"|/ghc-prefix"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def repl(m: re.Match) -> str:
|
|
440
|
+
match = m.group(0)
|
|
441
|
+
if match == "/ghc-prefix":
|
|
442
|
+
return placeholder
|
|
443
|
+
return f"{placeholder}/lib/ghc-{version}"
|
|
444
|
+
|
|
445
|
+
new_content = pattern.sub(repl, content)
|
|
446
|
+
if new_content != content:
|
|
447
|
+
path.write_text(new_content, encoding="utf-8")
|
|
448
|
+
return 1
|
|
449
|
+
except OSError as e:
|
|
450
|
+
sys.stderr.write(f"WARNING: Failed to patch {path}: {e}\n")
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class PackageDBResource(BaseResource):
|
|
455
|
+
name = "package.conf.d"
|
|
456
|
+
is_dir = True
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def get_candidates(cls, base: Path, version: str) -> List[Path]:
|
|
460
|
+
return [
|
|
461
|
+
base / "lib" / f"ghc-{version}" / "lib" / "package.conf.d",
|
|
462
|
+
base / "lib" / "package.conf.d",
|
|
463
|
+
base / "package.conf.d"
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
@classmethod
|
|
467
|
+
def validate(cls, path: Path) -> bool:
|
|
468
|
+
try:
|
|
469
|
+
return any(f.name.endswith(".conf") for f in path.iterdir())
|
|
470
|
+
except OSError:
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
@classmethod
|
|
474
|
+
def extract_targets(cls, path: Path) -> List[str]:
|
|
475
|
+
try:
|
|
476
|
+
return [str(f) for f in path.iterdir() if f.name.endswith(".conf") and not f.is_symlink()]
|
|
477
|
+
except OSError:
|
|
478
|
+
return []
|
|
479
|
+
|
|
480
|
+
@classmethod
|
|
481
|
+
def patch_build_time(cls, path: Path, version: str, placeholder: str) -> int:
|
|
482
|
+
patched_count = 0
|
|
483
|
+
for conf_file in path.glob("*.conf"):
|
|
484
|
+
try:
|
|
485
|
+
original = conf_file.read_text(encoding="utf-8", errors="replace")
|
|
486
|
+
# 🧪 Alchemist: Combine regex patterns into a single pass using alternation
|
|
487
|
+
pattern = re.compile(
|
|
488
|
+
r"(dynamic-library-dirs:\s*|library-dirs:\s*|include-dirs:\s*)/[^\s]+|/ghc-prefix/lib/ghc-" + re.escape(version) + r"|/ghc-prefix"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def repl(m: re.Match) -> str:
|
|
492
|
+
g1 = m.group(1)
|
|
493
|
+
if g1:
|
|
494
|
+
return f"{g1}{placeholder}/lib/ghc-{version}{'/include' if 'include' in g1 else ''}"
|
|
495
|
+
return placeholder if m.group(0) == "/ghc-prefix" else f"{placeholder}/lib/ghc-{version}"
|
|
496
|
+
|
|
497
|
+
content = pattern.sub(repl, original)
|
|
498
|
+
|
|
499
|
+
if content != original:
|
|
500
|
+
conf_file.write_text(content, encoding="utf-8")
|
|
501
|
+
patched_count += 1
|
|
502
|
+
except OSError as e:
|
|
503
|
+
sys.stderr.write(f"WARNING: Failed to patch {conf_file}: {e}\n")
|
|
504
|
+
|
|
505
|
+
# 🧪 Alchemist: Walrus operator (:=) consolidates variable assignment and existence check
|
|
506
|
+
try:
|
|
507
|
+
(path / "package.cache").unlink(missing_ok=True)
|
|
508
|
+
except OSError as e:
|
|
509
|
+
sys.stderr.write(f"WARNING: Failed to unlink {path / 'package.cache'}: {e}\n")
|
|
510
|
+
return patched_count
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class BinWrappersResource(BaseResource):
|
|
514
|
+
name = "bin"
|
|
515
|
+
is_dir = True
|
|
516
|
+
|
|
517
|
+
@classmethod
|
|
518
|
+
def get_candidates(cls, base: Path, version: str) -> List[Path]:
|
|
519
|
+
return [
|
|
520
|
+
base / ("Scripts" if sys.platform == "win32" else "bin"),
|
|
521
|
+
base / "lib" / f"ghc-{version}" / "bin",
|
|
522
|
+
base / "bin",
|
|
523
|
+
base / "lib" / "bin"
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def validate(cls, path: Path) -> bool:
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
@classmethod
|
|
531
|
+
def extract_targets(cls, path: Path) -> List[str]:
|
|
532
|
+
try:
|
|
533
|
+
return [
|
|
534
|
+
str(f) for f in path.iterdir()
|
|
535
|
+
if f.is_file() and not f.is_symlink() and not f.name.endswith(".exe") and _is_text_file(f)
|
|
536
|
+
]
|
|
537
|
+
except OSError:
|
|
538
|
+
return []
|
|
539
|
+
|
|
540
|
+
@classmethod
|
|
541
|
+
def patch_build_time(cls, path: Path, version: str, placeholder: str) -> int:
|
|
542
|
+
patched = 0
|
|
543
|
+
for script in path.iterdir():
|
|
544
|
+
if not script.is_file() or script.is_symlink() or script.name.endswith(".exe") or not _is_text_file(script):
|
|
545
|
+
continue
|
|
546
|
+
try:
|
|
547
|
+
content = script.read_text(encoding="utf-8", errors="replace")
|
|
548
|
+
original = content
|
|
549
|
+
|
|
550
|
+
staging_dir = path.parent.parent if path.parent.name == f"ghc-{version}" else path.parent
|
|
551
|
+
abs_staging = staging_dir.absolute().as_posix()
|
|
552
|
+
abs_staging_win = str(staging_dir.absolute()).replace("/", "\\")
|
|
553
|
+
|
|
554
|
+
# 🧪 Alchemist: Combine regex patterns into a single pass using alternation
|
|
555
|
+
pattern = re.compile(
|
|
556
|
+
r"/usr/local/lib/ghc-" + re.escape(version) + r"|/ghc-prefix|" +
|
|
557
|
+
re.escape(abs_staging) + r"|" + re.escape(abs_staging_win)
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def repl(m: re.Match) -> str:
|
|
561
|
+
return f"{placeholder}/lib/ghc-{version}" if m.group(0).startswith(f"/usr/local/lib/ghc-{version}") else placeholder
|
|
562
|
+
|
|
563
|
+
content = pattern.sub(repl, content)
|
|
564
|
+
|
|
565
|
+
if content != original:
|
|
566
|
+
script.write_text(content, encoding="utf-8")
|
|
567
|
+
patched += 1
|
|
568
|
+
except OSError as e:
|
|
569
|
+
sys.stderr.write(f"WARNING: Failed to patch {script}: {e}\n")
|
|
570
|
+
return patched
|
|
571
|
+
def _resolve_runtime_paths(env: dict) -> None:
|
|
572
|
+
"""Dynamically replace @GHC_PREFIX@ with the active sys.prefix at runtime,
|
|
573
|
+
then regenerate package.cache.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
env: The sterilized environment dict with proper LD_LIBRARY_PATH set.
|
|
577
|
+
"""
|
|
578
|
+
# @GHC_PREFIX@ must resolve to wherever the toolchain actually lives. For a
|
|
579
|
+
# bundled install that is sys.prefix; for a bootstrapped one it is the cache
|
|
580
|
+
# root, and writing sys.prefix here would point GHC at a directory holding
|
|
581
|
+
# no compiler at all.
|
|
582
|
+
ghc_root = _ghc_root_or_prefix()
|
|
583
|
+
prefix_clean = str(ghc_root).replace("\\", "/")
|
|
584
|
+
|
|
585
|
+
# ⚡ Bolt: Fast-path to avoid scanning and patching on every invocation.
|
|
586
|
+
# If the marker file exists and contains the current prefix, we are already patched.
|
|
587
|
+
marker_file = ghc_root / "lib" / f".ghc_patched_{GHC_VERSION}.txt"
|
|
588
|
+
try:
|
|
589
|
+
if marker_file.is_file() and marker_file.read_text(encoding="utf-8") == prefix_clean:
|
|
590
|
+
return
|
|
591
|
+
except OSError:
|
|
592
|
+
pass
|
|
593
|
+
|
|
594
|
+
# 🐍 Ouroboros: Iterate over the BaseResource registry to locate all path targets dynamically
|
|
595
|
+
# 🧪 Alchemist: List comprehension condenses nested loops for dynamic target extraction
|
|
596
|
+
targets = [
|
|
597
|
+
target for resource_cls in BaseResource.registry
|
|
598
|
+
for resource_path in resource_cls.locate()
|
|
599
|
+
for target in resource_cls.extract_targets(resource_path)
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
# Replace @GHC_PREFIX@ in all target files
|
|
603
|
+
prefix_clean_bytes = prefix_clean.encode("utf-8")
|
|
604
|
+
patched_any_conf = False
|
|
605
|
+
for target in set(targets): # 🧪 Alchemist: Deduplicate targets in a single pass
|
|
606
|
+
target_path = Path(target)
|
|
607
|
+
try:
|
|
608
|
+
# ⚡ Bolt: Use mmap to efficiently search for @GHC_PREFIX@ without loading
|
|
609
|
+
# the entire binary into memory. Drastically reduces I/O latency for large binaries.
|
|
610
|
+
content_to_write = None
|
|
611
|
+
with target_path.open("rb") as f:
|
|
612
|
+
try:
|
|
613
|
+
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as m:
|
|
614
|
+
if m.find(b"@GHC_PREFIX@") != -1:
|
|
615
|
+
f.seek(0)
|
|
616
|
+
content_to_write = f.read()
|
|
617
|
+
except (ValueError, OSError):
|
|
618
|
+
# mmap throws ValueError for empty files, OSError for unmappable ones
|
|
619
|
+
f.seek(0)
|
|
620
|
+
content_to_write = f.read()
|
|
621
|
+
if b"@GHC_PREFIX@" not in content_to_write:
|
|
622
|
+
content_to_write = None
|
|
623
|
+
|
|
624
|
+
if content_to_write is not None:
|
|
625
|
+
# 🧪 Alchemist: Native byte regex replaces verbose decode/encode logic
|
|
626
|
+
if b" " in prefix_clean_bytes and b"\0" not in content_to_write:
|
|
627
|
+
content_to_write = re.sub(rb'(?<!")(@GHC_PREFIX@[^\s"]+)', rb'"\1"', content_to_write)
|
|
628
|
+
with target_path.open("wb") as out:
|
|
629
|
+
out.write(content_to_write.replace(b"@GHC_PREFIX@", prefix_clean_bytes))
|
|
630
|
+
if target.endswith(".conf"):
|
|
631
|
+
patched_any_conf = True
|
|
632
|
+
except OSError as e:
|
|
633
|
+
sys.stderr.write(f"WARNING: Failed to resolve runtime paths for {target_path}: {e}\n")
|
|
634
|
+
|
|
635
|
+
# 🧪 Alchemist: any() replaces manual flag variables and loops for succinct boolean reduction
|
|
636
|
+
if patched_any_conf or any(
|
|
637
|
+
not (pkg_db / "package.cache").exists()
|
|
638
|
+
for pkg_db in PackageDBResource.locate()
|
|
639
|
+
):
|
|
640
|
+
for pkg_db in PackageDBResource.locate():
|
|
641
|
+
_ghc_pkg_recache(str(pkg_db), env)
|
|
642
|
+
|
|
643
|
+
# ⚡ Bolt: Write marker file to indicate this prefix has been successfully patched
|
|
644
|
+
try:
|
|
645
|
+
marker_file.parent.mkdir(parents=True, exist_ok=True)
|
|
646
|
+
marker_file.write_text(prefix_clean, encoding="utf-8")
|
|
647
|
+
except OSError:
|
|
648
|
+
pass
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _ghc_pkg_recache(pkg_db_dir: str, env: dict) -> None:
|
|
652
|
+
"""Run ghc-pkg recache for the given package database directory.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
pkg_db_dir: Path to the package.conf.d directory.
|
|
656
|
+
env: The sterilized environment dict with proper LD_LIBRARY_PATH set.
|
|
657
|
+
"""
|
|
658
|
+
ghc_pkg = _try_resolve_binary("ghc-pkg")
|
|
659
|
+
if not ghc_pkg:
|
|
660
|
+
return # Can't recache without ghc-pkg
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
# Use the sterilized environment which has LD_LIBRARY_PATH properly set
|
|
664
|
+
# 🧪 Alchemist: Dictionary merge operator (|) replaces unpacking
|
|
665
|
+
subprocess.run(
|
|
666
|
+
[ghc_pkg, "recache", "--package-db", pkg_db_dir],
|
|
667
|
+
env=env | {"GHC_PACKAGE_PATH": pkg_db_dir},
|
|
668
|
+
stdout=subprocess.DEVNULL,
|
|
669
|
+
stderr=subprocess.DEVNULL,
|
|
670
|
+
timeout=30,
|
|
671
|
+
check=True,
|
|
672
|
+
)
|
|
673
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
674
|
+
sys.stderr.write(f"WARNING: ghc-pkg recache failed for {pkg_db_dir}: {e}\n")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _execute_tool(tool_name: str, extra_args: Optional[List[str]] = None) -> NoReturn:
|
|
678
|
+
"""Generic subprocess proxy for bundled Haskell tooling."""
|
|
679
|
+
_validate_c_linker()
|
|
680
|
+
env = _sterilize_environment()
|
|
681
|
+
_resolve_runtime_paths(env)
|
|
682
|
+
binary_path = _resolve_binary(tool_name)
|
|
683
|
+
|
|
684
|
+
cmd = [binary_path]
|
|
685
|
+
if extra_args:
|
|
686
|
+
cmd.extend(extra_args)
|
|
687
|
+
cmd.extend(sys.argv[1:])
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
# 🧪 Alchemist: On POSIX systems, os.execve replaces the Python interpreter entirely.
|
|
691
|
+
# This eliminates the need for subprocess.run(), manual exit code forwarding,
|
|
692
|
+
# and signal handlers, freeing the memory of the wrapper script completely.
|
|
693
|
+
# However, Windows lacks native exec() and implements it by creating a new process
|
|
694
|
+
# and terminating the current one, which breaks shell wait() expectations.
|
|
695
|
+
if sys.platform != "win32":
|
|
696
|
+
os.execve(binary_path, cmd, env)
|
|
697
|
+
else:
|
|
698
|
+
sys.exit(subprocess.run(cmd, env=env).returncode)
|
|
699
|
+
except FileNotFoundError:
|
|
700
|
+
_die(f"FATAL ERROR: Binary not found at '{binary_path}'.")
|
|
701
|
+
except KeyboardInterrupt:
|
|
702
|
+
sys.exit(130)
|
|
703
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
704
|
+
_die(f"FATAL ERROR: Execution failed: {e}")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def __getattr__(name: str) -> Any:
|
|
708
|
+
"""Dynamic console script entry point generator.
|
|
709
|
+
|
|
710
|
+
Generates execution closures dynamically for any binary requested via entry points
|
|
711
|
+
(e.g., execute_ghc, execute_cabal, execute_haddock).
|
|
712
|
+
"""
|
|
713
|
+
if name.startswith("execute_"):
|
|
714
|
+
tool_name = name[8:].replace("_", "-")
|
|
715
|
+
extra_args = ["-v0"] if tool_name == "ghc" else None
|
|
716
|
+
|
|
717
|
+
def executor() -> NoReturn:
|
|
718
|
+
_execute_tool(tool_name, extra_args=extra_args)
|
|
719
|
+
|
|
720
|
+
executor.__name__ = name
|
|
721
|
+
return executor
|
|
722
|
+
|
|
723
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def __dir__() -> List[str]:
|
|
727
|
+
"""Provide explicit autocompletion for common dynamically generated entry points."""
|
|
728
|
+
base_dir = list(globals().keys())
|
|
729
|
+
dynamic_tools = ["execute_ghc", "execute_ghci", "execute_cabal"]
|
|
730
|
+
return base_dir + dynamic_tools
|