Source code for rgpycrumbs.cli

import contextlib
import importlib.util
import logging
import os
import shutil
import site
import subprocess
import sys
from pathlib import Path

import click

# Configure logging to output to stderr
logging.basicConfig(level=logging.INFO, format="%(message)s")

# The directory where cli.py is located
[docs] PACKAGE_ROOT = Path(__file__).parent.resolve()
# Modules that signal a usable in-env eOn/plot stack (readcon-native CON I/O).
[docs] _IN_ENV_STACK_MODULES = ("readcon", "matplotlib", "polars", "ase", "chemparseplot")
[docs] def _env_flag(name: str) -> bool: return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
[docs] def _in_env_stack_ready() -> bool: """True when the active interpreter already has the eOn plot/readcon stack. Prefer this over ``uv run`` isolation: a host ``uv`` cache can pull a different Python (e.g. 3.12) while site-packages still hold 3.13 extension modules (numpy), which breaks plotting of readcon-core CON outputs from eOn 2.16+. """ for mod in _IN_ENV_STACK_MODULES: try: importlib.import_module(mod) except ImportError: return False return True
[docs] def _prefer_in_env_interpreter( is_dev: bool, *, force_uv: bool | None = None, ) -> bool: """Decide whether to run scripts with ``sys.executable`` instead of ``uv run``.""" if is_dev or _env_flag("RGPYCRUMBS_DEV"): return True # force_uv: CLI/env/config layer (None → re-read env only for back-compat) if force_uv is True or (force_uv is None and _env_flag("RGPYCRUMBS_FORCE_UV")): return False if force_uv is False: # config explicitly disabled force_uv; still allow stack-ready in-env pass if shutil.which("uv") is None: return True # Active env already has readcon + plot deps: use it for CON metadata fidelity. return _in_env_stack_ready()
[docs] def _normalize_dist_name(name: str) -> str: """PEP 503-ish normalize for comparing package names.""" return name.strip().lower().replace("_", "-")
[docs] def _pyproject_project_name(pyproject: Path) -> str | None: """Return ``[project].name`` from a pyproject.toml, if present.""" try: text = pyproject.read_text(encoding="utf-8") except OSError: return None # Prefer tomllib when available (3.11+); fall back to a tiny scan. try: import tomllib except ImportError: # pragma: no cover - py310 tomllib = None # type: ignore[assignment] if tomllib is not None: try: data = tomllib.loads(text) except Exception: data = None if isinstance(data, dict): project = data.get("project") if isinstance(project, dict): name = project.get("name") if isinstance(name, str) and name.strip(): return name.strip() # Minimal fallback: first bare name = "..." under [project] in_project = False for line in text.splitlines(): stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): in_project = stripped == "[project]" continue if in_project and stripped.startswith("name"): _, _, rhs = stripped.partition("=") name = rhs.strip().strip("\"'") if name: return name return None
[docs] def _find_editable_source(package_name: str) -> Path | None: """Return the local project root for an *editable* install of *package_name*. Wheel installs under ``site-packages`` must never map to an unrelated monorepo ``pyproject.toml`` above the env (e.g. eOn root named ``eon-akmc`` when looking up ``chemparseplot``). Only return a path when: 1. The module lives outside site-/dist-packages (true editable / src layout), and 2. The nearest ``pyproject.toml`` has ``[project].name`` matching *package_name*. eOn tooling only needs ``eon-schema`` + ``pyeonclient``; never imply ``eon-akmc``. """ try: spec = importlib.util.find_spec(package_name) except (ImportError, ModuleNotFoundError, ValueError): return None if spec is None: return None want = _normalize_dist_name(package_name) candidates: list[Path] = [] if spec.origin: candidates.append(Path(spec.origin).resolve()) if spec.submodule_search_locations: candidates.extend(Path(loc).resolve() for loc in spec.submodule_search_locations) for candidate in candidates: search_root = candidate if candidate.is_dir() else candidate.parent # Regular wheels live under site-packages / dist-packages — not editable. if any(part in {"site-packages", "dist-packages"} for part in search_root.parts): continue for parent in (search_root, *search_root.parents): pyproject = parent / "pyproject.toml" if not pyproject.is_file(): continue proj_name = _pyproject_project_name(pyproject) if proj_name is None: return None if _normalize_dist_name(proj_name) == want: return parent # First pyproject is a different project — do not climb further. return None return None
[docs] def _uv_editable_sources() -> list[Path]: """Return local editable roots that should satisfy script dependencies. Only true editable checkouts of chemparseplot (name-matched). Never the eOn monorepo / eon-akmc tree. """ sources: list[Path] = [] for package_name in ("chemparseplot",): source = _find_editable_source(package_name) if source is not None: sources.append(source) return sources
[docs] def _get_scripts_in_folder(folder_name: str) -> list[str]: """Returns a sorted list of CLI script names (without extension) in a folder. Excludes library modules (hdf5_io, plotting) and internal files (__init__, _*). Includes actual CLI entry point scripts. """ folder_path = PACKAGE_ROOT / folder_name if not folder_path.is_dir(): return [] # Library modules to exclude library_modules = {"hdf5_io", "plotting", "utils", "helpers", "seed_dimers"} scripts = [] for f in folder_path.glob("*.py"): if f.name.startswith("_"): continue stem = f.stem # Skip library modules and __init__ if stem in library_modules or stem == "__init__": continue # Strip 'cli_' prefix if present for cleaner command names if stem.startswith("cli_"): stem = stem[4:] scripts.append(stem) return sorted(scripts)
[docs] def _load_lock_pins_or_exit(lock_path: str | Path) -> dict[str, str]: """Load PyPI pins from uv.lock / pylock / CycloneDX; exit on failure.""" from rgpycrumbs.locks import detect_lock_format, load_pypi_pins path_str = str(lock_path) try: pins = load_pypi_pins(path_str) except FileNotFoundError as exc: click.echo(f"Error: {exc}", err=True) sys.exit(1) except ValueError as exc: click.echo(f"Error: invalid lock/SBOM: {exc}", err=True) sys.exit(1) except ImportError as exc: click.echo(f"Error: {exc}", err=True) sys.exit(1) fmt = detect_lock_format(path_str) click.echo( f"--> Lock ({fmt.value}) {path_str}: {len(pins)} PyPI pin(s) for constraints", err=True, ) return pins
[docs] def _dispatch( group: str, script_name: str, script_args: tuple, is_dev: bool = False, is_verbose: bool = False, lock_path: str | None = None, sbom_path: str | None = None, ): """ Sets up the environment and runs the target script via 'uv run'. Preferred entry is this dispatcher (``rgpycrumbs`` / ``python -m rgpycrumbs.cli``). Raw ``uv run <script.py>`` is not the primary path. Pins/lock resolution (highest wins): CLI ``--lock``/``--sbom`` → env ``RGPYCRUMBS_LOCK``/``_SBOM`` → project ``rgpycrumbs.toml`` → user ``~/.config/rgpycrumbs/config.toml``. Formats: uv.lock, PEP 751 pylock, CycloneDX. TOML ``[pins.packages]`` merge on top of the lock file. """ import json import tempfile from rgpycrumbs.config import ( load_config, resolve_auto_deps_default, resolve_force_uv, resolve_lock_path_layered, ) from rgpycrumbs.locks import ( PINS_ENV, SBOM_PINS_ENV, normalize_pypi_name, pins_to_constraint_lines, ) # Convert script-name to filename (e.g., plt-neb -> plt_neb.py) filename = f"{script_name.replace('-', '_')}.py" script_path = PACKAGE_ROOT / group / filename if not script_path.is_file(): click.echo(f"Error: Script not found at '{script_path}'", err=True) sys.exit(1) try: cfg = load_config() except (FileNotFoundError, ValueError, ImportError) as exc: click.echo(f"Error: config: {exc}", err=True) sys.exit(1) if is_verbose and cfg.sources: click.echo( "VERBOSE: config sources -> " + ", ".join(str(p) for p in cfg.sources), err=True, ) resolved_lock = resolve_lock_path_layered( cli_lock=lock_path, cli_sbom=sbom_path, config=cfg, ) pins: dict[str, str] = {} if resolved_lock is not None: pins = _load_lock_pins_or_exit(resolved_lock) # [pins.packages] from TOML override lock-file versions for name, ver in cfg.merged_package_pins_normalized().items(): pins[name] = ver if cfg.package_pins and is_verbose: click.echo( f"VERBOSE: TOML package pins -> {cfg.merged_package_pins_normalized()}", err=True, ) # --- SETUP ENVIRONMENT --- env = os.environ.copy() # Fallback imports try: site_packages = list(site.getsitepackages()) user_site = site.getusersitepackages() if isinstance(user_site, str): site_packages.append(user_site) else: site_packages.extend(user_site) env["RGPYCRUMBS_PARENT_SITE_PACKAGES"] = os.pathsep.join(site_packages) except (AttributeError, ImportError): pass # Add parent dir to PYTHONPATH for internal imports (e.g. rgpycrumbs._aux) project_root = str(PACKAGE_ROOT.parent) current_pythonpath = env.get("PYTHONPATH", "") env["PYTHONPATH"] = f"{project_root}{os.pathsep}{current_pythonpath}" # CLI owns dependency resolution for dispatched scripts: # - uv run → PEP 723 header deps (+ optional lock/SBOM constraints) # - in-env → ensure_import cache installs for heavies (jax, adjustText) if env.get("RGPYCRUMBS_AUTO_DEPS", "").strip() == "": env["RGPYCRUMBS_AUTO_DEPS"] = resolve_auto_deps_default(config=cfg) if pins: # ensure_import expects normalized keys; write all known env aliases from rgpycrumbs.locks import PINS_ENV_LEGACY pin_json = json.dumps({normalize_pypi_name(k): v for k, v in pins.items()}) env[PINS_ENV] = pin_json env[PINS_ENV_LEGACY] = pin_json env[SBOM_PINS_ENV] = pin_json force_uv = resolve_force_uv(is_dev=is_dev, config=cfg) use_in_env = _prefer_in_env_interpreter(is_dev, force_uv=force_uv) constraints_path: Path | None = None if use_in_env: command = [sys.executable, str(script_path), *script_args] if is_verbose or (not is_dev and _in_env_stack_ready()): click.echo( "--> Using active interpreter (readcon/plot stack present or --dev)", err=True, ) else: command = ["uv", "run"] if pins: # Ephemeral constraints for uv resolver; cleaned after process exits. fd, tmp_name = tempfile.mkstemp(prefix="rgpycrumbs-lock-", suffix=".txt") os.close(fd) constraints_path = Path(tmp_name) constraints_path.write_text( "\n".join(pins_to_constraint_lines(pins)) + "\n", encoding="utf-8", ) command.extend(["--constraints", str(constraints_path)]) env["UV_CONSTRAINT"] = str(constraints_path) for source in _uv_editable_sources(): command.extend(["--with-editable", str(source)]) command.extend([str(script_path), *script_args]) if is_verbose: click.echo(f"VERBOSE: Resolved script path -> {script_path}", err=True) click.echo(f"VERBOSE: Constructed command -> {' '.join(command)}", err=True) click.echo(f"--> Dispatching to: {' '.join(command)}") try: subprocess.run(command, check=True, env=env) # noqa: S603 except FileNotFoundError: click.echo("Error: 'uv' command not found. Is it installed?", err=True) sys.exit(1) except subprocess.CalledProcessError as e: sys.exit(e.returncode) except KeyboardInterrupt: sys.exit(130) finally: if constraints_path is not None: with contextlib.suppress(OSError): constraints_path.unlink(missing_ok=True)
[docs] def _make_script_command(group_name: str, script_stem: str) -> click.Command: """Creates a click command that dispatches to a PEP 723 script. For full option help, run the script directly: python -m rgpycrumbs.<group>.<script> --help """ display_name = script_stem.replace("_", "-") @click.command( name=display_name, context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, add_help_option=False, # Pass --help to underlying script ) @click.pass_context def cmd(ctx): # Retrieve flags safely from the parent context is_dev = ctx.obj.get("is_dev", False) if ctx.obj else False is_verbose = ctx.obj.get("is_verbose", False) if ctx.obj else False lock_path = ctx.obj.get("lock_path") if ctx.obj else None sbom_path = ctx.obj.get("sbom_path") if ctx.obj else None # Pass through --help to underlying script if "--help" in ctx.args or "-h" in ctx.args: # Run script with --help to show actual options _dispatch( group_name, display_name, tuple(ctx.args), is_dev=is_dev, is_verbose=False, # Don't add verbose noise to help output lock_path=lock_path, sbom_path=sbom_path, ) return _dispatch( group_name, display_name, tuple(ctx.args), is_dev=is_dev, is_verbose=is_verbose, lock_path=lock_path, sbom_path=sbom_path, ) cmd.help = f"""Run the {display_name} script. For full option documentation, run: python -m rgpycrumbs.{group_name}.{display_name} --help Or use --help flag which will be passed to the script: rgpycrumbs {group_name} {display_name} --help """ return cmd
@click.group() @click.option( "--dev", is_flag=True, help="Run using sys.executable instead of 'uv run' for local development.", ) @click.option( "--verbose", "-v", is_flag=True, help="Print script paths and constructed commands before execution.", ) @click.option( "--lock", "lock_path", type=click.Path(path_type=str), default=None, help=( "Optional lock path: uv.lock, PEP 751 pylock.toml, or CycloneDX JSON. " "Precedence: CLI > env RGPKGS_LOCK > project rgpkgs.toml > " "~/.config/rgpkgs/config.toml (suite-wide). Missing path fails clearly." ), ) @click.option( "--sbom", "sbom_path", type=click.Path(path_type=str), default=None, help=( "Alias for --lock (CycloneDX JSON, e.g. eb-stack --sbom-out). " "Also set via RGPYCRUMBS_SBOM." ), ) @click.version_option(package_name="rgpycrumbs") @click.pass_context
[docs] def main(ctx, dev, verbose, lock_path, sbom_path): """Dispatcher for PEP 723 scripts (preferred entry; not raw uv run). Each tool is a self-contained script. Prefer:: rgpycrumbs eon plt-neb ... python -m rgpycrumbs.cli eon plt-neb ... Suite config (shared with chemparseplot and other rgpkgs): project ``rgpkgs.toml`` and user ``~/.config/rgpkgs/config.toml`` for lock pins and dispatch defaults. Legacy ``rgpycrumbs.toml`` paths still work. """ # Ensure ctx.obj is a dictionary so we can store state in it ctx.ensure_object(dict) ctx.obj["is_dev"] = dev ctx.obj["is_verbose"] = verbose ctx.obj["lock_path"] = lock_path ctx.obj["sbom_path"] = sbom_path
# --- DYNAMIC DISCOVERY --- # Scan the package directory for subfolders (groups) and register them
[docs] _valid_groups = sorted( d.name for d in PACKAGE_ROOT.iterdir() if d.is_dir() and not d.name.startswith(("_", ".")) )
for _group in _valid_groups:
[docs] _file_stems = _get_scripts_in_folder(_group)
if not _file_stems: continue # Create a click group for this category _group_cmd = click.Group(name=_group, help=f"Tools in the '{_group}' category.") for _stem in _file_stems: _group_cmd.add_command(_make_script_command(_group, _stem)) main.add_command(_group_cmd) @main.command("config") @click.argument("action", default="show", type=click.Choice(["show"]))
[docs] def config_cmd(action: str) -> None: """Show merged suite config (rgpkgs.toml + ~/.config/rgpkgs/…).""" from rgpycrumbs.config import ( load_config, resolve_auto_deps_default, resolve_force_uv, resolve_lock_path_layered, user_config_path, ) if action != "show": return try: cfg = load_config() except (FileNotFoundError, ValueError, ImportError) as exc: click.echo(f"Error: {exc}", err=True) raise SystemExit(1) from exc lock = resolve_lock_path_layered(config=cfg) click.echo(f"user_config: {user_config_path()}") click.echo(f"sources: {', '.join(str(p) for p in cfg.sources) or '(none)'}") click.echo(f"lock_path: {lock or '(none)'}") click.echo(f"force_uv: {resolve_force_uv(is_dev=False, config=cfg)}") click.echo(f"auto_deps_def: {resolve_auto_deps_default(config=cfg)}") pins = cfg.merged_package_pins_normalized() click.echo(f"package_pins: {pins or '{}'}") if cfg.tool_tables: click.echo(f"tool_tables: {list(cfg.tool_tables)}")
if __name__ == "__main__": main()