hepyy 0.2.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.
- hepyy/__init__.py +89 -0
- hepyy/_autoload.py +98 -0
- hepyy/builder.py +436 -0
- hepyy/cli.py +749 -0
- hepyy/config.py +86 -0
- hepyy/cppyy_fix.py +258 -0
- hepyy/exceptions.py +18 -0
- hepyy/kernel.py +237 -0
- hepyy/loader.py +434 -0
- hepyy/recipe.py +157 -0
- hepyy/recipe_sources.py +163 -0
- hepyy/recipes/__init__.py +0 -0
- hepyy/recipes/fastjet/3.5.1.yaml +27 -0
- hepyy/recipes/fjcontrib/1.102.sh +47 -0
- hepyy/recipes/fjcontrib/1.102.yaml +28 -0
- hepyy/recipes/hepmc3/3.3.1.yaml +17 -0
- hepyy/recipes/jewel/2.2.0.sh +11 -0
- hepyy/recipes/jewel/2.2.0.yaml +9 -0
- hepyy/recipes/jewel/2.4.0.sh +11 -0
- hepyy/recipes/jewel/2.4.0.yaml +9 -0
- hepyy/recipes/lhapdf/6.5.4.sh +11 -0
- hepyy/recipes/lhapdf/6.5.4.yaml +14 -0
- hepyy/recipes/lhapdf/6.5.5.sh +11 -0
- hepyy/recipes/lhapdf/6.5.5.yaml +14 -0
- hepyy/recipes/pythia8/8.317.sh +10 -0
- hepyy/recipes/pythia8/8.317.yaml +14 -0
- hepyy/registry.py +79 -0
- hepyy/shell.py +238 -0
- hepyy-0.2.0.dist-info/METADATA +842 -0
- hepyy-0.2.0.dist-info/RECORD +33 -0
- hepyy-0.2.0.dist-info/WHEEL +4 -0
- hepyy-0.2.0.dist-info/entry_points.txt +4 -0
- hepyy-0.2.0.dist-info/licenses/LICENSE +21 -0
hepyy/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from .loader import get_loader
|
|
2
|
+
from .registry import get_registry
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _ModuleProxy:
|
|
8
|
+
"""Mirrors the HPC `module` command for HEP C++ packages."""
|
|
9
|
+
|
|
10
|
+
def load(
|
|
11
|
+
self,
|
|
12
|
+
name: str,
|
|
13
|
+
version: str = None,
|
|
14
|
+
install_if_missing: bool = False,
|
|
15
|
+
verbose: bool = False,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Load a package and make it available via cppyy and `import <name>`."""
|
|
18
|
+
get_loader().load(
|
|
19
|
+
name,
|
|
20
|
+
version=version,
|
|
21
|
+
install_if_missing=install_if_missing,
|
|
22
|
+
verbose=verbose,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def unload(self, name: str) -> None:
|
|
26
|
+
"""Warn that cppyy cannot unload at runtime; use shell `module unload` instead."""
|
|
27
|
+
get_loader().unload(name)
|
|
28
|
+
|
|
29
|
+
def list(self):
|
|
30
|
+
"""Return names of packages loaded in this Python session."""
|
|
31
|
+
return get_loader().loaded_names()
|
|
32
|
+
|
|
33
|
+
def avail(self):
|
|
34
|
+
"""Return names of packages registered in the registry."""
|
|
35
|
+
return list(get_registry().all_packages().keys())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
module = _ModuleProxy()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load(name, **kwargs) -> None:
|
|
42
|
+
"""Shorthand for hepyy.module.load(name).
|
|
43
|
+
|
|
44
|
+
Accepts a single package name or a list of names loaded in order.
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(name, (list, tuple)):
|
|
47
|
+
for n in name:
|
|
48
|
+
module.load(n, **kwargs)
|
|
49
|
+
else:
|
|
50
|
+
module.load(name, **kwargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def gSystem_load(name: str) -> None:
|
|
54
|
+
"""Load a hepyy-installed package into ROOT via ROOT.gSystem.Load().
|
|
55
|
+
|
|
56
|
+
Use this in ROOT-first sessions (Pattern C) to make a package's C++ symbols
|
|
57
|
+
available through ROOT's own cling, without invoking pip-cppyy.
|
|
58
|
+
|
|
59
|
+
Example::
|
|
60
|
+
|
|
61
|
+
import hepyy
|
|
62
|
+
hepyy.load('root')
|
|
63
|
+
import ROOT
|
|
64
|
+
hepyy.gSystem_load('fastjet')
|
|
65
|
+
hepyy.gSystem_load('pythia8')
|
|
66
|
+
p = ROOT.Pythia8.Pythia()
|
|
67
|
+
j = ROOT.fastjet.PseudoJet(1, 0, 1, 1.4)
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
import ROOT
|
|
71
|
+
except ImportError:
|
|
72
|
+
raise ImportError("ROOT is not importable — run hepyy.load('root') and import ROOT first")
|
|
73
|
+
|
|
74
|
+
reg = get_registry()
|
|
75
|
+
rec = reg.get(name)
|
|
76
|
+
if rec is None:
|
|
77
|
+
raise KeyError(f"Package '{name}' is not installed. Run: heyy install {name}")
|
|
78
|
+
|
|
79
|
+
import pathlib
|
|
80
|
+
lib_dir = pathlib.Path(rec["lib_dir"])
|
|
81
|
+
loaded = []
|
|
82
|
+
for lib in lib_dir.glob(f"lib{name}.*"):
|
|
83
|
+
if lib.suffix in (".so", ".dylib") and ".so." not in lib.name:
|
|
84
|
+
ret = ROOT.gSystem.Load(str(lib))
|
|
85
|
+
if ret in (0, 1): # 0 = loaded, 1 = already loaded
|
|
86
|
+
loaded.append(lib.name)
|
|
87
|
+
break
|
|
88
|
+
if not loaded:
|
|
89
|
+
raise FileNotFoundError(f"No shared library found for '{name}' in {lib_dir}")
|
hepyy/_autoload.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Auto-loaded via hepyy_autoload.pth at Python startup.
|
|
2
|
+
|
|
3
|
+
Installs a lazy MetaPathFinder for every package whose HEPYY_LOADED_*
|
|
4
|
+
env var is set (populated by 'module load <pkg>' via Lmod/Environment Modules).
|
|
5
|
+
|
|
6
|
+
The finder defers the actual hepyy.load() call — which imports cppyy and
|
|
7
|
+
triggers its PCH build — to the moment user code first does `import <name>`.
|
|
8
|
+
This keeps Python startup instant even when modules are loaded, which matters
|
|
9
|
+
on HPC systems where the cppyy PCH build on a network filesystem can hang.
|
|
10
|
+
"""
|
|
11
|
+
import os as _os
|
|
12
|
+
import sys as _sys
|
|
13
|
+
|
|
14
|
+
if any(k.startswith("HEPYY_LOADED_") for k in _os.environ):
|
|
15
|
+
# Set CPPYY_API_PATH before the MetaPathFinder is installed so that user
|
|
16
|
+
# code which does a bare `import cppyy` (before `import fastjet`) picks up
|
|
17
|
+
# the correct CPyCppyy C-level API directory. Without this, cppyy's
|
|
18
|
+
# string_meta is initialised without npos and calling any C++ function that
|
|
19
|
+
# returns std::string raises AttributeError at runtime.
|
|
20
|
+
# pip '--target' installs put the headers at:
|
|
21
|
+
# {cppyy_prefix}/include/site/python<ver>/CPyCppyy/
|
|
22
|
+
if 'CPPYY_API_PATH' not in _os.environ and 'cppyy' not in _sys.modules:
|
|
23
|
+
try:
|
|
24
|
+
import glob as _glob
|
|
25
|
+
import pathlib as _pathlib
|
|
26
|
+
from hepyy.registry import get_registry as _gr_early
|
|
27
|
+
_rec_early = _gr_early().get('cppyy')
|
|
28
|
+
if _rec_early:
|
|
29
|
+
_prefix_early = _pathlib.Path(_rec_early['prefix'])
|
|
30
|
+
_cands_early = _glob.glob(
|
|
31
|
+
str(_prefix_early / 'include' / 'site' / 'python*' / 'CPyCppyy')
|
|
32
|
+
)
|
|
33
|
+
if _cands_early:
|
|
34
|
+
_os.environ['CPPYY_API_PATH'] = _cands_early[0]
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
import importlib.abc as _abc
|
|
40
|
+
import importlib.machinery as _machinery
|
|
41
|
+
import hepyy as _h
|
|
42
|
+
from hepyy.registry import get_registry as _gr
|
|
43
|
+
|
|
44
|
+
_names = [n for n in _gr().all_packages()
|
|
45
|
+
if _os.environ.get("HEPYY_LOADED_" + n.upper().replace("-", "_"))]
|
|
46
|
+
# ROOT must go first so its lib/ lands in sys.path before any
|
|
47
|
+
# `import cppyy` — that way ROOT's bundled cppyy wins over pip-cppyy.
|
|
48
|
+
if "root" in _names:
|
|
49
|
+
_names.remove("root")
|
|
50
|
+
_names.insert(0, "root")
|
|
51
|
+
|
|
52
|
+
# Only intercept imports for packages without a real Python module.
|
|
53
|
+
# If a package IS a real Python module (cppyy, lhapdf SWIG bindings,
|
|
54
|
+
# etc.), intercepting its import causes hepyy.load() to run while
|
|
55
|
+
# that module is still being initialised by Python's import machinery.
|
|
56
|
+
# The nested `import cppyy` inside _setup_cppyy() then gets a
|
|
57
|
+
# partially-initialised module back, breaking string_meta (npos) and
|
|
58
|
+
# all cppyy C++ type wrappers. Let real Python packages load normally.
|
|
59
|
+
import importlib.util as _ilu
|
|
60
|
+
_pending = [n for n in _names if _ilu.find_spec(n) is None]
|
|
61
|
+
|
|
62
|
+
class _HeppyyierLazyLoader(_abc.Loader):
|
|
63
|
+
def __init__(self, mod):
|
|
64
|
+
self._mod = mod
|
|
65
|
+
|
|
66
|
+
def create_module(self, spec):
|
|
67
|
+
return self._mod # reuse the proxy _h.load() already placed in sys.modules
|
|
68
|
+
|
|
69
|
+
def exec_module(self, module):
|
|
70
|
+
pass # nothing to execute; module is already fully set up
|
|
71
|
+
|
|
72
|
+
class _HeppyyierFinder(_abc.MetaPathFinder):
|
|
73
|
+
def find_spec(self, fullname, path, target=None):
|
|
74
|
+
if fullname not in _pending or fullname in _sys.modules:
|
|
75
|
+
return None
|
|
76
|
+
# One-shot: load all pending packages in order, then uninstall.
|
|
77
|
+
to_load = list(_pending)
|
|
78
|
+
_pending.clear()
|
|
79
|
+
_sys.meta_path[:] = [f for f in _sys.meta_path
|
|
80
|
+
if not isinstance(f, _HeppyyierFinder)]
|
|
81
|
+
for _name in to_load:
|
|
82
|
+
try:
|
|
83
|
+
_h.load(_name)
|
|
84
|
+
except Exception as _e:
|
|
85
|
+
print(f"[hepyy] lazy load failed for {_name!r}: {_e}",
|
|
86
|
+
file=_sys.stderr)
|
|
87
|
+
if fullname in _sys.modules:
|
|
88
|
+
return _machinery.ModuleSpec(
|
|
89
|
+
fullname,
|
|
90
|
+
_HeppyyierLazyLoader(_sys.modules[fullname]),
|
|
91
|
+
origin="hepyy",
|
|
92
|
+
)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
_sys.meta_path.insert(0, _HeppyyierFinder())
|
|
96
|
+
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
hepyy/builder.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from tqdm import tqdm
|
|
13
|
+
|
|
14
|
+
from .config import get_build_dir, get_log_dir
|
|
15
|
+
from .exceptions import BuildError
|
|
16
|
+
from .recipe import Recipe
|
|
17
|
+
from .shell import generate_env_scripts, write_tcl_modulefile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_lib_dir(prefix: pathlib.Path) -> pathlib.Path:
|
|
21
|
+
"""Return the library directory under prefix, preferring lib64 when it exists and lib does not."""
|
|
22
|
+
lib = prefix / "lib"
|
|
23
|
+
lib64 = prefix / "lib64"
|
|
24
|
+
if not lib.exists() and lib64.exists():
|
|
25
|
+
return lib64
|
|
26
|
+
return lib
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PackageBuilder:
|
|
30
|
+
def __init__(self, recipe: Recipe, verbose: bool = False, extra_vars: dict = None):
|
|
31
|
+
self.recipe = recipe
|
|
32
|
+
self.verbose = verbose
|
|
33
|
+
self.extra_vars = extra_vars or {} # --set KEY=VALUE overrides for Jinja2 scripts
|
|
34
|
+
self._build_dir = get_build_dir()
|
|
35
|
+
self._log_dir = get_log_dir()
|
|
36
|
+
self._clean_build = False
|
|
37
|
+
|
|
38
|
+
def build(self, version: Optional[str] = None, force: bool = False, redownload: bool = False, clean: bool = False) -> dict:
|
|
39
|
+
version = version or self.recipe.version
|
|
40
|
+
prefix = (self._build_dir / self.recipe.name / version).resolve()
|
|
41
|
+
|
|
42
|
+
# --clean removes build artifacts (the cmake build dir, or 'make clean'
|
|
43
|
+
# for in-source autotools builds) without touching the extracted source
|
|
44
|
+
# tree. --force additionally re-extracts the source. Either way, treat
|
|
45
|
+
# the build as "dirty" so stale build artifacts get cleaned.
|
|
46
|
+
self._clean_build = clean or force
|
|
47
|
+
|
|
48
|
+
# On --force or --clean, wipe the install prefix so stale files from a
|
|
49
|
+
# previous partial build can't interfere. Critical for FUSE-mounted
|
|
50
|
+
# filesystems (e.g. Google Drive) where a failed 'make install' can
|
|
51
|
+
# leave corrupted .so files that break libtool's relink step on the
|
|
52
|
+
# next attempt.
|
|
53
|
+
if self._clean_build and prefix.exists():
|
|
54
|
+
print(f"Removing stale prefix: {prefix}")
|
|
55
|
+
shutil.rmtree(prefix)
|
|
56
|
+
|
|
57
|
+
prefix.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
log_path = self._log_dir / f"{self.recipe.name}-{version}-build.log"
|
|
61
|
+
|
|
62
|
+
print(f"Building {self.recipe.name} {version} → {prefix}")
|
|
63
|
+
print(f"Log: {log_path}")
|
|
64
|
+
|
|
65
|
+
src_dir = self._download_and_extract(version, force=force, redownload=redownload)
|
|
66
|
+
|
|
67
|
+
if self.recipe.build_script:
|
|
68
|
+
self._run_custom_script(src_dir, prefix, version, log_path)
|
|
69
|
+
elif self.recipe.build_system == "autotools":
|
|
70
|
+
build_dir = self._configure_autotools(src_dir, prefix, log_path)
|
|
71
|
+
self._make(build_dir, log_path)
|
|
72
|
+
self._install(build_dir, log_path)
|
|
73
|
+
elif self.recipe.build_system == "cmake":
|
|
74
|
+
build_dir = self._configure_cmake(src_dir, prefix, log_path)
|
|
75
|
+
self._make(build_dir, log_path)
|
|
76
|
+
self._install(build_dir, log_path)
|
|
77
|
+
else:
|
|
78
|
+
raise BuildError(f"Unknown build_system: {self.recipe.build_system}")
|
|
79
|
+
|
|
80
|
+
self._verify(prefix)
|
|
81
|
+
generate_env_scripts(self.recipe.name, version, prefix,
|
|
82
|
+
python_paths=self.recipe.python_paths)
|
|
83
|
+
if self.recipe.generate_modulefile:
|
|
84
|
+
write_tcl_modulefile(self.recipe.name, version, prefix,
|
|
85
|
+
python_paths=self.recipe.python_paths,
|
|
86
|
+
depends_on=self.recipe.depends_on)
|
|
87
|
+
return self._make_registry_record(prefix, version, log_path)
|
|
88
|
+
|
|
89
|
+
def _base_env(self) -> dict:
|
|
90
|
+
env = os.environ.copy()
|
|
91
|
+
env["CXX"] = env.get("CXX", "c++")
|
|
92
|
+
env["CC"] = env.get("CC", "cc")
|
|
93
|
+
if sys.platform == "darwin":
|
|
94
|
+
if "MACOSX_DEPLOYMENT_TARGET" not in env:
|
|
95
|
+
ver = platform.mac_ver()[0]
|
|
96
|
+
major_minor = ".".join(ver.split(".")[:2]) if ver else "11.0"
|
|
97
|
+
env["MACOSX_DEPLOYMENT_TARGET"] = major_minor
|
|
98
|
+
# Avoid conda/ROOT interference
|
|
99
|
+
for key in ("CONDA_PREFIX", "ROOT_PATH", "ROOTSYS"):
|
|
100
|
+
env.pop(key, None)
|
|
101
|
+
return env
|
|
102
|
+
|
|
103
|
+
def _download_and_extract(self, version: str, force: bool = False, redownload: bool = False) -> pathlib.Path:
|
|
104
|
+
src_base = self._build_dir / "src"
|
|
105
|
+
src_base.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
if not self.recipe.url:
|
|
108
|
+
# No tarball — build_script is responsible for fetching its own source
|
|
109
|
+
extract_dir = src_base / f"{self.recipe.name}-{version}-src"
|
|
110
|
+
if force and extract_dir.exists():
|
|
111
|
+
shutil.rmtree(extract_dir)
|
|
112
|
+
extract_dir.mkdir(exist_ok=True)
|
|
113
|
+
return extract_dir
|
|
114
|
+
|
|
115
|
+
url = self.recipe.resolved_url(version=version)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Handle HepForge-style query-string URLs: .../downloads/?f=Pkg-1.0.tar.gz
|
|
119
|
+
_last = url.split("/")[-1]
|
|
120
|
+
if _last.startswith("?"):
|
|
121
|
+
from urllib.parse import parse_qs
|
|
122
|
+
_params = parse_qs(_last[1:])
|
|
123
|
+
filename = _params.get("f", [_last])[0]
|
|
124
|
+
else:
|
|
125
|
+
filename = _last
|
|
126
|
+
dest = src_base / filename
|
|
127
|
+
extract_dir = src_base / f"{self.recipe.name}-{version}-src"
|
|
128
|
+
|
|
129
|
+
if redownload and dest.exists():
|
|
130
|
+
print(f"Removing cached tarball: {dest.name}")
|
|
131
|
+
dest.unlink()
|
|
132
|
+
|
|
133
|
+
if force and extract_dir.exists():
|
|
134
|
+
print(f"Removing cached source tree: {extract_dir.name}")
|
|
135
|
+
shutil.rmtree(extract_dir)
|
|
136
|
+
|
|
137
|
+
if not dest.exists():
|
|
138
|
+
print(f"Downloading {url} ...")
|
|
139
|
+
response = requests.get(url, stream=True, timeout=60)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
total = int(response.headers.get("content-length", 0))
|
|
142
|
+
tmp_dest = dest.with_suffix(dest.suffix + ".part")
|
|
143
|
+
try:
|
|
144
|
+
with open(tmp_dest, "wb") as f, tqdm(
|
|
145
|
+
total=total, unit="B", unit_scale=True, desc=filename
|
|
146
|
+
) as bar:
|
|
147
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
148
|
+
f.write(chunk)
|
|
149
|
+
bar.update(len(chunk))
|
|
150
|
+
tmp_dest.rename(dest)
|
|
151
|
+
except Exception:
|
|
152
|
+
tmp_dest.unlink(missing_ok=True)
|
|
153
|
+
raise
|
|
154
|
+
else:
|
|
155
|
+
print(f"Using cached tarball: {dest}")
|
|
156
|
+
|
|
157
|
+
if not extract_dir.exists():
|
|
158
|
+
print(f"Extracting {dest.name} ...")
|
|
159
|
+
try:
|
|
160
|
+
with tarfile.open(dest) as tf:
|
|
161
|
+
tf.extractall(src_base)
|
|
162
|
+
members = tf.getnames()
|
|
163
|
+
top = members[0].split("/")[0]
|
|
164
|
+
extracted = src_base / top
|
|
165
|
+
if extracted != extract_dir:
|
|
166
|
+
extracted.rename(extract_dir)
|
|
167
|
+
except (tarfile.TarError, Exception) as exc:
|
|
168
|
+
dest.unlink(missing_ok=True)
|
|
169
|
+
raise BuildError(
|
|
170
|
+
f"Failed to extract {dest.name} (file may be corrupt): {exc}\n"
|
|
171
|
+
"Re-run with --force to download a fresh copy."
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
return extract_dir
|
|
175
|
+
|
|
176
|
+
def _run(self, cmd: list, cwd: pathlib.Path, log_path: pathlib.Path, env: dict | None = None) -> None:
|
|
177
|
+
run_env = env if env is not None else self._base_env()
|
|
178
|
+
mode = "a"
|
|
179
|
+
with open(log_path, mode) as log:
|
|
180
|
+
log.write(f"\n$ {' '.join(str(c) for c in cmd)}\n")
|
|
181
|
+
log.flush()
|
|
182
|
+
if self.verbose:
|
|
183
|
+
proc = subprocess.run(
|
|
184
|
+
cmd, cwd=cwd, env=run_env, check=False
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
proc = subprocess.run(
|
|
188
|
+
cmd,
|
|
189
|
+
cwd=cwd,
|
|
190
|
+
env=run_env,
|
|
191
|
+
stdout=log,
|
|
192
|
+
stderr=subprocess.STDOUT,
|
|
193
|
+
check=False,
|
|
194
|
+
)
|
|
195
|
+
if proc.returncode != 0:
|
|
196
|
+
if len(cmd) >= 3 and cmd[0] == "bash" and cmd[1] == "-c":
|
|
197
|
+
cmd_repr = "bash -c [build script]"
|
|
198
|
+
else:
|
|
199
|
+
cmd_repr = " ".join(str(c) for c in cmd)
|
|
200
|
+
raise BuildError(
|
|
201
|
+
f"Command failed (exit {proc.returncode}): {cmd_repr}\n"
|
|
202
|
+
f"See log: {log_path}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _configure_autotools(
|
|
206
|
+
self, src_dir: pathlib.Path, prefix: pathlib.Path, log_path: pathlib.Path
|
|
207
|
+
) -> pathlib.Path:
|
|
208
|
+
configure = src_dir / "configure"
|
|
209
|
+
if not configure.exists():
|
|
210
|
+
raise BuildError(f"No configure script found in {src_dir}")
|
|
211
|
+
if self._clean_build and (src_dir / "Makefile").exists():
|
|
212
|
+
print("Cleaning previous build artifacts (make clean) ...")
|
|
213
|
+
self._run(["make", "clean"], src_dir, log_path)
|
|
214
|
+
cmd = [str(configure), f"--prefix={prefix}"] + self.recipe.configure_args
|
|
215
|
+
print(f"Configuring (autotools) ...")
|
|
216
|
+
self._run(cmd, src_dir, log_path)
|
|
217
|
+
return src_dir
|
|
218
|
+
|
|
219
|
+
def _configure_cmake(
|
|
220
|
+
self, src_dir: pathlib.Path, prefix: pathlib.Path, log_path: pathlib.Path
|
|
221
|
+
) -> pathlib.Path:
|
|
222
|
+
build_dir = src_dir.parent / f"{src_dir.name}-cmake-build"
|
|
223
|
+
if self._clean_build and build_dir.exists():
|
|
224
|
+
print(f"Removing stale build directory: {build_dir}")
|
|
225
|
+
shutil.rmtree(build_dir)
|
|
226
|
+
build_dir.mkdir(exist_ok=True)
|
|
227
|
+
cmd = [
|
|
228
|
+
"cmake",
|
|
229
|
+
str(src_dir),
|
|
230
|
+
f"-DCMAKE_INSTALL_PREFIX={prefix}",
|
|
231
|
+
] + self.recipe.configure_args
|
|
232
|
+
print(f"Configuring (cmake) ...")
|
|
233
|
+
self._run(cmd, build_dir, log_path)
|
|
234
|
+
return build_dir
|
|
235
|
+
|
|
236
|
+
def _make(self, build_dir: pathlib.Path, log_path: pathlib.Path) -> None:
|
|
237
|
+
print(f"Building (make -j{self.recipe.make_jobs}) ...")
|
|
238
|
+
self._run(["make", f"-j{self.recipe.make_jobs}"], build_dir, log_path)
|
|
239
|
+
|
|
240
|
+
def _install(self, build_dir: pathlib.Path, log_path: pathlib.Path) -> None:
|
|
241
|
+
print("Installing ...")
|
|
242
|
+
self._run(["make", "install"], build_dir, log_path)
|
|
243
|
+
|
|
244
|
+
def _run_custom_script(
|
|
245
|
+
self,
|
|
246
|
+
src_dir: pathlib.Path,
|
|
247
|
+
prefix: pathlib.Path,
|
|
248
|
+
version: str,
|
|
249
|
+
log_path: pathlib.Path,
|
|
250
|
+
) -> None:
|
|
251
|
+
from .registry import get_registry
|
|
252
|
+
|
|
253
|
+
configure_args_str = " ".join(self.recipe.configure_args)
|
|
254
|
+
env = self._base_env()
|
|
255
|
+
|
|
256
|
+
# Inject {name}_prefix for every package in the registry, not just
|
|
257
|
+
# depends_on — this lets build scripts use optional packages via shell
|
|
258
|
+
# conditionals without declaring a hard dependency.
|
|
259
|
+
pkg_vars: dict = {}
|
|
260
|
+
registry = get_registry()
|
|
261
|
+
for pkg_name, rec in registry.all_packages().items():
|
|
262
|
+
pkg_vars[f"{pkg_name}_prefix"] = rec["prefix"]
|
|
263
|
+
|
|
264
|
+
# Add depends_on packages' bin/ and lib/ dirs to PATH and library path
|
|
265
|
+
# so that tools like lhapdf-config are available during the build.
|
|
266
|
+
lib_path_key = "DYLD_LIBRARY_PATH" if sys.platform == "darwin" else "LD_LIBRARY_PATH"
|
|
267
|
+
for dep in self.recipe.depends_on:
|
|
268
|
+
dep_rec = registry.get(dep)
|
|
269
|
+
if dep_rec is None:
|
|
270
|
+
continue
|
|
271
|
+
dep_prefix = pathlib.Path(dep_rec["prefix"])
|
|
272
|
+
bin_dir = str(dep_prefix / "bin")
|
|
273
|
+
lib_dir = str(_resolve_lib_dir(dep_prefix))
|
|
274
|
+
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
|
|
275
|
+
env[lib_path_key] = lib_dir + os.pathsep + env.get(lib_path_key, "")
|
|
276
|
+
env["LIBRARY_PATH"] = lib_dir + os.pathsep + env.get("LIBRARY_PATH", "")
|
|
277
|
+
|
|
278
|
+
class _Default(dict):
|
|
279
|
+
"""Return empty string for any key not in the dict."""
|
|
280
|
+
def __missing__(self, key: str) -> str:
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
fmt = _Default(
|
|
284
|
+
prefix=prefix,
|
|
285
|
+
version=version,
|
|
286
|
+
srcdir=src_dir,
|
|
287
|
+
builddir=src_dir,
|
|
288
|
+
n_cores=self.recipe.make_jobs,
|
|
289
|
+
configure_args=configure_args_str,
|
|
290
|
+
CXX=env.get("CXX", "c++"),
|
|
291
|
+
CC=env.get("CC", "cc"),
|
|
292
|
+
**pkg_vars,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if self.recipe.build_script_is_jinja:
|
|
296
|
+
import platform as _plat
|
|
297
|
+
jinja_ctx = dict(fmt)
|
|
298
|
+
jinja_ctx.update(
|
|
299
|
+
platform=_plat.system().lower(), # "darwin" / "linux"
|
|
300
|
+
arch=_plat.machine().lower(), # "arm64" / "x86_64"
|
|
301
|
+
python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
302
|
+
python_major=sys.version_info.major,
|
|
303
|
+
python_minor=sys.version_info.minor,
|
|
304
|
+
)
|
|
305
|
+
jinja_ctx.update(self.extra_vars or {}) # --set KEY=VALUE overrides
|
|
306
|
+
import jinja2
|
|
307
|
+
j2_env = jinja2.Environment(undefined=jinja2.Undefined)
|
|
308
|
+
script = j2_env.from_string(self.recipe.build_script).render(**jinja_ctx)
|
|
309
|
+
else:
|
|
310
|
+
script = self.recipe.build_script.format_map(fmt)
|
|
311
|
+
|
|
312
|
+
print("Running custom build script ...")
|
|
313
|
+
self._run(["bash", "-c", script], src_dir, log_path, env=env)
|
|
314
|
+
|
|
315
|
+
def _verify(self, prefix: pathlib.Path) -> None:
|
|
316
|
+
if self.recipe.verify_binary:
|
|
317
|
+
binary = prefix / "bin" / self.recipe.verify_binary
|
|
318
|
+
if not binary.exists():
|
|
319
|
+
raise BuildError(
|
|
320
|
+
f"Verification failed: {binary} not found after install"
|
|
321
|
+
)
|
|
322
|
+
if self.recipe.cppyy_libraries:
|
|
323
|
+
lib_dir = _resolve_lib_dir(prefix)
|
|
324
|
+
if not any(lib_dir.glob("lib*")):
|
|
325
|
+
raise BuildError(
|
|
326
|
+
f"Verification failed: no libraries found in {lib_dir}"
|
|
327
|
+
)
|
|
328
|
+
print("Verification passed.")
|
|
329
|
+
|
|
330
|
+
def _make_registry_record(
|
|
331
|
+
self, prefix: pathlib.Path, version: str, log_path: pathlib.Path
|
|
332
|
+
) -> dict:
|
|
333
|
+
builtin_dir = pathlib.Path(__file__).parent / "recipes"
|
|
334
|
+
src = self.recipe.source_path
|
|
335
|
+
recipe_path = (
|
|
336
|
+
str(src) if src and not str(src).startswith(str(builtin_dir)) else None
|
|
337
|
+
)
|
|
338
|
+
return {
|
|
339
|
+
"version": version,
|
|
340
|
+
"prefix": str(prefix),
|
|
341
|
+
"include_dir": str(prefix / "include"),
|
|
342
|
+
"lib_dir": str(_resolve_lib_dir(prefix)),
|
|
343
|
+
"depends_on": self.recipe.depends_on,
|
|
344
|
+
"python_paths": self.recipe.python_paths,
|
|
345
|
+
"build_log": str(log_path),
|
|
346
|
+
"recipe_path": recipe_path,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def build_package(
|
|
351
|
+
name: str,
|
|
352
|
+
version: Optional[str] = None,
|
|
353
|
+
recipe_path: Optional[str] = None,
|
|
354
|
+
force: bool = False,
|
|
355
|
+
redownload: bool = False,
|
|
356
|
+
verbose: bool = False,
|
|
357
|
+
njobs: Optional[int] = None,
|
|
358
|
+
clean: bool = False,
|
|
359
|
+
extra_vars: dict = None,
|
|
360
|
+
) -> dict:
|
|
361
|
+
from .recipe import find_recipe
|
|
362
|
+
from .registry import get_registry
|
|
363
|
+
|
|
364
|
+
recipe = find_recipe(name, version=version, recipe_path=recipe_path)
|
|
365
|
+
if njobs is not None:
|
|
366
|
+
recipe.make_jobs = njobs
|
|
367
|
+
reg = get_registry()
|
|
368
|
+
|
|
369
|
+
if reg.is_installed(recipe.name) and not force and not clean:
|
|
370
|
+
existing = reg.get(recipe.name)
|
|
371
|
+
print(
|
|
372
|
+
f"{recipe.name} {existing['version']} already installed. "
|
|
373
|
+
"Use --force or --clean to rebuild."
|
|
374
|
+
)
|
|
375
|
+
return existing
|
|
376
|
+
|
|
377
|
+
# Auto-install any depends_on packages that are not yet in the registry.
|
|
378
|
+
for dep in recipe.depends_on:
|
|
379
|
+
if not reg.is_installed(dep):
|
|
380
|
+
print(f"[{name}] Installing dependency: {dep}")
|
|
381
|
+
build_package(dep, verbose=verbose, njobs=njobs)
|
|
382
|
+
|
|
383
|
+
builder = PackageBuilder(recipe, verbose=verbose, extra_vars=extra_vars)
|
|
384
|
+
record = builder.build(version=version or recipe.version, force=force, redownload=redownload, clean=clean)
|
|
385
|
+
# Re-read registry from disk before writing: a build script may have called
|
|
386
|
+
# 'heyy install <dep>' as a subprocess, whose writes are on disk but not in
|
|
387
|
+
# the in-memory 'reg' object loaded above. Reloading prevents those entries
|
|
388
|
+
# from being silently dropped when we write this package's record.
|
|
389
|
+
reg = get_registry()
|
|
390
|
+
reg.register(recipe.name, record)
|
|
391
|
+
print(f"\n{recipe.name} {record['version']} installed at {record['prefix']}")
|
|
392
|
+
return record
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def register_package(
|
|
396
|
+
name: str,
|
|
397
|
+
prefix: str,
|
|
398
|
+
recipe_path: Optional[str] = None,
|
|
399
|
+
version: Optional[str] = None,
|
|
400
|
+
) -> dict:
|
|
401
|
+
from .recipe import find_recipe
|
|
402
|
+
from .registry import get_registry
|
|
403
|
+
from .shell import generate_env_scripts, write_tcl_modulefile
|
|
404
|
+
|
|
405
|
+
recipe = find_recipe(name, version=version, recipe_path=recipe_path)
|
|
406
|
+
prefix_path = pathlib.Path(prefix).resolve()
|
|
407
|
+
|
|
408
|
+
if not prefix_path.is_dir():
|
|
409
|
+
raise BuildError(f"Prefix directory does not exist: {prefix_path}")
|
|
410
|
+
|
|
411
|
+
ver = version or recipe.version
|
|
412
|
+
builtin_dir = pathlib.Path(__file__).parent / "recipes"
|
|
413
|
+
src = recipe.source_path
|
|
414
|
+
stored_recipe_path = (
|
|
415
|
+
str(src) if src and not str(src).startswith(str(builtin_dir)) else None
|
|
416
|
+
)
|
|
417
|
+
record = {
|
|
418
|
+
"version": ver,
|
|
419
|
+
"prefix": str(prefix_path),
|
|
420
|
+
"include_dir": str(prefix_path / "include"),
|
|
421
|
+
"lib_dir": str(_resolve_lib_dir(prefix_path)),
|
|
422
|
+
"depends_on": recipe.depends_on,
|
|
423
|
+
"python_paths": recipe.python_paths,
|
|
424
|
+
"build_log": None,
|
|
425
|
+
"recipe_path": stored_recipe_path,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
generate_env_scripts(recipe.name, ver, prefix_path,
|
|
429
|
+
python_paths=recipe.python_paths)
|
|
430
|
+
if recipe.generate_modulefile:
|
|
431
|
+
write_tcl_modulefile(recipe.name, ver, prefix_path,
|
|
432
|
+
python_paths=recipe.python_paths,
|
|
433
|
+
depends_on=recipe.depends_on)
|
|
434
|
+
get_registry().register(recipe.name, record)
|
|
435
|
+
print(f"Registered {recipe.name} {ver} from {prefix_path}")
|
|
436
|
+
return record
|