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 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