diff --git a/.github/workflows/tests-on-pr.yml b/.github/workflows/tests-on-pr.yml index e8ef0fc..4ac6831 100644 --- a/.github/workflows/tests-on-pr.yml +++ b/.github/workflows/tests-on-pr.yml @@ -15,9 +15,11 @@ jobs: set -Eeuo pipefail echo "Test cmds" cmi -h + cmi info + cmi info packs + cmi info profiles + cmi info examples cmi env - cmi pack list - cmi profile list cmi install plotting if [ "${RUNNER_OS}" != "Windows" ]; then conda list | grep -i ipympl diff --git a/news/cli-cmds.rst b/news/cli-cmds.rst new file mode 100644 index 0000000..c839a92 --- /dev/null +++ b/news/cli-cmds.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``print_profiles`` function. + +**Changed:** + +* Changed the cli syntax. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/cmi/cli.py b/src/diffpy/cmi/cli.py index 539b249..9917de9 100644 --- a/src/diffpy/cmi/cli.py +++ b/src/diffpy/cmi/cli.py @@ -15,107 +15,18 @@ import argparse from pathlib import Path -from shutil import copytree from typing import List, Optional, Tuple from diffpy.cmi import __version__ from diffpy.cmi.conda import env_info from diffpy.cmi.log import plog, set_log_mode -from diffpy.cmi.packsmanager import PacksManager, get_package_dir +from diffpy.cmi.packsmanager import PacksManager from diffpy.cmi.profilesmanager import ProfilesManager -# Examples -def _get_examples_dir() -> Path: - """Return the absolute path to the installed examples directory. - - Returns - ------- - pathlib.Path - Directory containing shipped examples. - - Raises - ------ - FileNotFoundError - If the examples directory cannot be located in the installation. - """ - with get_package_dir() as pkgdir: - pkg = Path(pkgdir).resolve() - for c in ( - pkg / "docs" / "examples", - pkg.parents[2] / "docs" / "examples", - ): - if c.is_dir(): - return c - raise FileNotFoundError( - "Could not locate requirements/packs. Check your installation." - ) - - -def map_pack_to_examples() -> dict[str, List[str]]: - """Return a dictionary mapping pack name -> list of example - subdirectories. - - Returns - ------- - dict: - pack name -> list of example subdirectory names - """ - root = _get_examples_dir() - if not root.exists(): - return {} - examples_by_pack = {} - for pack_dir in sorted(root.iterdir()): - if pack_dir.is_dir(): - exdirs = sorted(p.name for p in pack_dir.iterdir() if p.is_dir()) - examples_by_pack[pack_dir.name] = exdirs - return examples_by_pack - - -def copy_example(pack_example: str) -> Path: - """Copy an example into the current working directory. - - Parameters - ---------- - pack_example : str - Pack and example name in the form ``/``. - - Returns - ------- - pathlib.Path - Destination path created under the current working directory. - - Raises - ------ - ValueError - If the format is invalid (missing pack or example). - FileNotFoundError - If the example directory does not exist. - FileExistsError - If the destination directory already exists. - """ - if "/" not in pack_example or pack_example.count("/") != 1: - raise ValueError("Example must be specified as /") - pack, exdir = pack_example.split("/", 1) - if not pack or not exdir: - raise ValueError( - f"Invalid format for example '{pack_example}'. " - "Must be '/'" - ) - src = _get_examples_dir() / pack / exdir - if not src.exists() or not src.is_dir(): - raise FileNotFoundError(f"Example not found: {pack_example}") - dest = Path.cwd() / exdir - if dest.exists(): - raise FileExistsError(f"Destination {dest} already exists") - copytree(src, dest) - return dest - - # Manual def open_manual_and_exit() -> None: - """Open the installed manual or fall back to the online version, - then exit. + """Open the manual in a web browser and exit. Notes ----- @@ -123,17 +34,7 @@ def open_manual_and_exit() -> None: """ import webbrowser - v = __version__.split(".post")[0] - webdocbase = "https://www.diffpy.org/doc/cmi/" + v - with get_package_dir() as packagedir: - localpath = Path(packagedir) / "docs" / "build" / "html" / "index.html" - url = ( - localpath.resolve().as_uri() - if localpath.is_file() - else f"{webdocbase}/index.html" - ) - if not localpath.is_file(): - plog.info("Manual files not found, falling back to online version.") + url = "https://www.diffpy.org/products/diffpycmi" plog.info("Opening manual at %s", url) webbrowser.open(url) raise SystemExit(0) @@ -151,74 +52,75 @@ def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="cmi", description=( - "Welcome to diffpy.cmi, a complex modeling infrastructure " - "for multi-modal analysis of scientific data.\n\n" + """\ +Welcome to diffpy.cmi, a complex modeling infrastructure for +multi-modal analysis of scientific data. + +Diffpy.cmi is designed as an extensible complex modeling +infrastructure. Users and developers can readily integrate +novel data types and constraints into custom workflows. While +widely used for advanced analysis of structural data, the +framework is general and can be applied to any problem where +model parameters are refined to fit calculated quantities to +data. + +Diffpy.cmi is comprised of modular units called 'packs' and +'profiles' that facilitate tailored installations for specific +scientific applications. Run 'cmi info -h' for more details. +""" ), - formatter_class=argparse.ArgumentDefaultsHelpFormatter, + formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument( "-v", "--verbose", action="store_true", help="Enable debug logging." ) p.add_argument( - "-V", "--version", action="version", version=f"%(prog)s {__version__}" + "-V", + "--version", + action="version", + version=f"diffpy.cmi {__version__}", ) p.add_argument( - "--manual", action="store_true", help="Open manual and exit." + "--manual", + action="store_true", + help="Open online documentation and exit.", ) - p.set_defaults(_parser=p) - + p.set_defaults() sub = p.add_subparsers(dest="cmd", metavar="") - # example - p_example = sub.add_parser("example", help="List or copy an example") - p_example.set_defaults(_parser=p_example) - sub_ex = p_example.add_subparsers( - dest="example_cmd", metavar="" - ) - sub_ex.add_parser("list", help="List examples").set_defaults( - _parser=p_example - ) - p_example_copy = sub_ex.add_parser("copy", help="Copy an example to CWD") - p_example_copy.add_argument( - "name", metavar="EXAMPLE", help="Example name /" - ) - p_example_copy.set_defaults(_parser=p_example) - p_example.set_defaults(example_cmd=None) - - # pack - p_pack = sub.add_parser("pack", help="List packs or show a pack file") - p_pack.set_defaults(_parser=p_pack) - sub_pack = p_pack.add_subparsers(dest="pack_cmd", metavar="") - sub_pack.add_parser( - "list", help="List packs (Installed vs Available)" - ).set_defaults(_parser=p_pack) - p_pack_show = sub_pack.add_parser( - "show", help="Show a pack (by base name)" - ) - p_pack_show.add_argument("name", metavar="PACK", help="Pack base name") - p_pack_show.set_defaults(_parser=p_pack) - p_pack.set_defaults(pack_cmd=None) - - # profile - p_prof = sub.add_parser( - "profile", help="List profiles or show a profile file" - ) - p_prof.set_defaults(_parser=p_prof) - sub_prof = p_prof.add_subparsers(dest="profile_cmd", metavar="") - sub_prof.add_parser( - "list", help="List profiles (Installed vs Available)" - ).set_defaults(_parser=p_prof) - p_prof_show = sub_prof.add_parser( - "show", help="Show a profile (by base name)" - ) - p_prof_show.add_argument( - "name", metavar="PROFILE", help="Profile base name" + p_info = sub.add_parser( + "info", + help=("Prints info about packs, profiles, and examples.\n "), + description=( + """ +Definitions: +pack: A collection of data processing routines, models, and examples. + For example, the 'pdf' pack contains packages used for modeling + and refinement of the Atomic Pair Distribution Function (PDF). + +profile: A set of pre-defined packs or configurations for a specific + scientific workflow. Profiles can be installed or customized + for different use cases. + +examples: Example scripts or folders that can be copied locally using + 'cmi copy '. + """ + ), + formatter_class=argparse.RawDescriptionHelpFormatter, ) - p_prof_show.set_defaults(_parser=p_prof) - p_prof.set_defaults(profile_cmd=None) - + p_info.set_defaults() + sub_info = p_info.add_subparsers(dest="info_cmd", metavar="") + sub_info.add_parser( + "packs", help="Show available and installed packs." + ).set_defaults() + sub_info.add_parser( + "profiles", help="Show available and installed profiles." + ).set_defaults() + sub_info.add_parser( + "examples", help="Show available examples to copy." + ).set_defaults() # install (multiple targets) - p_install = sub.add_parser("install", help="Install packs/profiles") + p_install = sub.add_parser("install", help="Install packs/profiles.") p_install.add_argument( "targets", nargs="*", @@ -233,67 +135,40 @@ def _build_parser() -> argparse.ArgumentParser: help="Default conda channel for packages \ without explicit per-line channel.", ) - p_install.set_defaults(_parser=p_install) - + p_install.set_defaults() + + p_copy = sub.add_parser( + "copy", + help="Copy example directories.", + description="Copy example directories to the current " + "or specified location.", + usage="cmi copy [-h] [-t DIR] [-f] ...", + ) + p_copy.add_argument( + "name", + nargs="+", + help="Example name(s) to copy. Use `cmi info examples` to list.", + ) + p_copy.add_argument( + "-t", + "--target-dir", + dest="target_dir", + metavar="DIR", + help="Target directory to copy examples into. " + "Defaults to current working directory.", + ) + p_copy.add_argument( + "-f", + "--force", + action="store_true", + help="Force overwrite existing files and merge directories.", + ) + p_copy.set_defaults(func=_cmd_copy) # env sub.add_parser("env", help="Show basic conda environment info") - return p -# Helpers -def _installed_pack_path(mgr: PacksManager, name: str) -> Path: - """Return the absolute path to an installed pack file. - - Parameters - ---------- - mgr : PacksManager - Packs manager instance. - name : str - Pack basename (without ``.txt``). - - Returns - ------- - pathlib.Path - Absolute path to the pack file. - - Raises - ------ - FileNotFoundError - If the pack cannot be found. - """ - path = mgr.packs_dir / f"{name}.txt" - if not path.is_file(): - raise FileNotFoundError(f"Pack not found: {name} ({path})") - return path - - -def _installed_profile_path(name: str) -> Path: - """Return the absolute path to an installed profile file by - basename. - - Parameters - ---------- - name : str - Profile basename (without extension). - - Returns - ------- - pathlib.Path - Absolute path to the profile file. - - Raises - ------ - FileNotFoundError - If the profile cannot be found under the installed profiles directory. - """ - base = ProfilesManager().profiles_dir - for cand in (base / f"{name}.yml", base / f"{name}.yaml"): - if cand.is_file(): - return cand - raise FileNotFoundError(f"Profile not found: {name} (under {base})") - - def _resolve_target_for_install(s: str) -> Tuple[str, Path]: """Return ('pack'|'profile', absolute path) for a single install target. @@ -328,133 +203,9 @@ def _resolve_target_for_install(s: str) -> Tuple[str, Path]: return "pack", pack_path if profile_path: return "profile", profile_path - raise FileNotFoundError(f"No installed pack or profile named '{s}' found.") -def _cmd_example(ns: argparse.Namespace) -> int: - """Handle `cmi example` subcommands. - - Parameters - ---------- - ns : argparse.Namespace - Parsed arguments for the example subparser. - - Returns - ------- - int - Exit code (``0`` on success; non-zero on failure). - """ - if ns.example_cmd in (None, "copy"): - name = getattr(ns, "name", None) - if not name: - plog.error( - "Missing example name. Use `cmi example list` to see options." - ) - ns._parser.print_help() - return 1 - out = copy_example(name) - print(f"Example copied to: {out}") - return 0 - if ns.example_cmd == "list": - for pack, examples in map_pack_to_examples().items(): - print(f"{pack}:") - for ex in examples: - print(f" - {ex}") - return 0 - plog.error("Unknown example subcommand.") - ns._parser.print_help() - return 2 - - -def _cmd_pack(ns: argparse.Namespace) -> int: - """Handle `cmi pack` subcommands. - - Parameters - ---------- - ns : argparse.Namespace - Parsed arguments for the pack subparser. - - Returns - ------- - int - Exit code (``0`` on success; non-zero on failure). - """ - mgr = PacksManager() - if ns.pack_cmd == "list": - names = mgr.available_packs() - installed, available = [], [] - for nm in names: - (installed if mgr.check_pack(nm) else available).append(nm) - - def dump(title: str, arr: List[str]) -> None: - print(title + ":") - if not arr: - print(" (none)") - else: - for n in arr: - print(f" - {n}") - - dump("Installed", installed) - dump("Available to install", available) - return 0 - - name = getattr(ns, "name", None) or getattr(ns, "pack_cmd", None) - if not name or name == "show": - plog.error("Usage: cmi pack (or: cmi pack show )") - ns._parser.print_help() - return 1 - - path = _installed_pack_path(mgr, name) - print(f"# pack: {name}\n# path: {path}\n") - print(path.read_text(encoding="utf-8")) - return 0 - - -def _cmd_profile(ns: argparse.Namespace) -> int: - """Handle `cmi profile` subcommands. - - Parameters - ---------- - ns : argparse.Namespace - Parsed arguments for the profile subparser. - - Returns - ------- - int - Exit code (``0`` on success; non-zero on failure). - """ - if ns.profile_cmd == "list": - pm = ProfilesManager() - names = pm.list_profiles() - installed, available = [], [] - for nm in names: - (installed if pm.check_profile(nm) else available).append(nm) - - def dump(title: str, arr: List[str]) -> None: - print(title + ":") - if not arr: - print(" (none)") - else: - for n in arr: - print(f" - {n}") - - dump("Installed", installed) - dump("Available to install", available) - return 0 - - name = getattr(ns, "name", None) or getattr(ns, "profile_cmd", None) - if not name or name == "show": - plog.error("Usage: cmi profile (or: cmi profile show )") - ns._parser.print_help() - return 1 - - path = _installed_profile_path(name) - print(f"# profile: {name}\n# path: {path}\n") - print(path.read_text(encoding="utf-8")) - return 0 - - def _cmd_install(ns: argparse.Namespace) -> int: """Handle `cmi install` subcommand for packs and profiles. @@ -475,7 +226,6 @@ def _cmd_install(ns: argparse.Namespace) -> int: ) ns._parser.print_help() return 1 - rc = 0 mgr = PacksManager() pm = ProfilesManager() @@ -523,6 +273,71 @@ def _cmd_env(_: argparse.Namespace) -> int: return 0 +def _cmd_info(ns: argparse.Namespace) -> int: + """Handle `cmi info` subcommands. + + Parameters + ---------- + ns : argparse.Namespace + Parsed arguments for the info subparser. + + Returns + ------- + int + Exit code (``0`` on success; non-zero on failure). + """ + packsmanager = PacksManager() + profilemanager = ProfilesManager() + if ns.info_cmd is None: + packsmanager.print_info() + print("\nINFO: Run `cmi info -h` for more options.") + return 0 + if ns.info_cmd == "packs": + packsmanager.print_packs() + return 0 + if ns.info_cmd == "profiles": + profilemanager.print_profiles() + return 0 + if ns.info_cmd == "examples": + packsmanager.print_examples() + return 0 + ns._parser.print_help() + return 2 + + +def _cmd_copy(ns: argparse.Namespace) -> int: + """Handle `cmi copy` subcommand for copying example directories. + + Parameters + ---------- + ns : argparse.Namespace + Parsed arguments for the copy subparser. + + Returns + ------- + int + Exit code (``0`` on success; non-zero on failure). + """ + names = getattr(ns, "name", None) + target_dir = getattr(ns, "target_dir", None) + force = getattr(ns, "force", False) + + if not names: + plog.error( + "Missing example name(s). Use `cmi info examples` to see options." + ) + ns._parser.print_help() + return 1 + + try: + pkm = PacksManager() + pkm.copy_examples(names, target_dir=target_dir, force=force) + return 0 + except FileNotFoundError as e: + plog.error("%s", e) + return 1 + + def main(argv: Optional[List[str]] = None) -> int: """Run the CMI CLI. @@ -538,27 +353,20 @@ def main(argv: Optional[List[str]] = None) -> int: """ parser = _build_parser() ns = parser.parse_args(argv) - set_log_mode(ns.verbose) - if ns.manual: open_manual_and_exit() - if ns.cmd is None: parser.print_help() return 2 - - if ns.cmd == "example": - return _cmd_example(ns) - if ns.cmd == "pack": - return _cmd_pack(ns) - if ns.cmd == "profile": - return _cmd_profile(ns) + if ns.cmd == "info": + return _cmd_info(ns) + if ns.cmd == "copy": + return _cmd_copy(ns) if ns.cmd == "install": return _cmd_install(ns) if ns.cmd == "env": return _cmd_env(ns) - plog.error("Unknown command: %s", ns.cmd) return 2 diff --git a/src/diffpy/cmi/packsmanager.py b/src/diffpy/cmi/packsmanager.py index 4c9d08e..e85a3e4 100644 --- a/src/diffpy/cmi/packsmanager.py +++ b/src/diffpy/cmi/packsmanager.py @@ -136,7 +136,7 @@ def available_examples(self) -> dict[str, List[tuple[str, Path]]]: def copy_examples( self, examples_to_copy: List[str], - target_dir: Path = None, + target_dir: Union[Path | str] = None, force: bool = False, ) -> None: """Copy examples or packs into the target or current working @@ -146,7 +146,7 @@ def copy_examples( ---------- examples_to_copy : list of str User-specified pack(s), example(s), or "all" to copy all. - target_dir : pathlib.Path, optional + target_dir : pathlib.Path or str, optional Target directory to copy examples into. Defaults to current working directory. force : bool, optional @@ -154,6 +154,8 @@ def copy_examples( overwritten and directories are merged (extra files in the target are preserved). """ + if isinstance(target_dir, str): + target_dir = Path(target_dir) self._target_dir = target_dir.resolve() if target_dir else Path.cwd() self._force = force @@ -348,24 +350,48 @@ def install_pack(self, identifier: str | Path) -> None: else: plog.error("Pack '%s' installation failed.", path.stem) - def print_info(self) -> None: - """Print information about available packs and examples.""" - uninstalled_packs = [] - installed_packs = [] + def print_packs(self) -> None: + """Print information about available packs.""" + uninstalled_packs, installed_packs = [], [] for pack in self.available_packs(): if self.check_pack(pack): installed_packs.append(pack) else: uninstalled_packs.append(pack) print("Installed Packs:") + print("----------------") for pack in installed_packs: - print(f" {pack}") - print("\nAvailable Packs to Install:") - for pack in uninstalled_packs: - print(f" {pack}") + if not installed_packs: + print(" (none)") + else: + print(f" {pack}") + print("\nAvailable Packs:") + print("----------------") + if not uninstalled_packs: + print(" (all packs installed)") + else: + for pack in uninstalled_packs: + print(f" {pack}") + + def print_examples(self) -> None: + """Print information about available examples.""" print("\nExamples:") + print("---------") examples_dict = self.available_examples() for pack, examples in examples_dict.items(): print(f" {pack}:") for ex_name, _ in examples: print(f" - {ex_name}") + + def print_info(self) -> None: + """Print information about available packs, profiles, and + examples.""" + # packs + self.print_packs() + # profiles + from diffpy.cmi.profilesmanager import ProfilesManager + + prm = ProfilesManager() + prm.print_profiles() + # examples + self.print_examples() diff --git a/src/diffpy/cmi/profilesmanager.py b/src/diffpy/cmi/profilesmanager.py index 533b239..93c94d5 100644 --- a/src/diffpy/cmi/profilesmanager.py +++ b/src/diffpy/cmi/profilesmanager.py @@ -149,7 +149,7 @@ def load(self, identifier: Union[str, Path]) -> Profile: name = data.get("name") or path.stem return Profile(name=name, packs=packs, extras=extras, source=path) - def list_profiles(self) -> List[str]: + def available_profiles(self) -> List[str]: """Return available installed profiles by basename. Returns @@ -198,3 +198,26 @@ def install(self, identifier: Union[str, Path]) -> None: plog.error("Profile '%s' installation failed.", prof.name) return exit_code + + def print_profiles(self) -> None: + """Print available and installed profiles.""" + installed_profiles, uninstalled_profiles = [], [] + for profile_name in self.available_profiles(): + if self.check_profile(profile_name): + installed_profiles.append(profile_name) + else: + uninstalled_profiles.append(profile_name) + print("\nInstalled Profiles:") + print("-------------------") + if not installed_profiles: + print(" (none)") + else: + for profile in installed_profiles: + print(f" {profile}") + print("\nAvailable Profiles:") + print("-------------------") + if not uninstalled_profiles: + print(" (all profiles installed)") + else: + for profile in uninstalled_profiles: + print(f" {profile}") diff --git a/tests/conftest.py b/tests/conftest.py index af72bd5..b52f7e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,26 @@ import json +import subprocess from pathlib import Path import matplotlib import pytest +@pytest.fixture(scope="function") +def conda_env(tmp_path): + env_dir = tmp_path / "fake_env" + env_dir_str = env_dir.as_posix() + subprocess.run( + ["conda", "create", "-y", "-p", env_dir_str], + check=True, + capture_output=True, + ) + yield env_dir_str + subprocess.run( + ["conda", "env", "remove", "-p", env_dir_str, "-y"], check=True + ) + + @pytest.fixture(scope="function") def example_cases(tmp_path_factory): """Copy the entire examples tree into a temp directory once per test diff --git a/tests/test_packsmanager.py b/tests/test_packsmanager.py index ffdc78a..c0adf49 100644 --- a/tests/test_packsmanager.py +++ b/tests/test_packsmanager.py @@ -350,12 +350,15 @@ def test_copy_examples_force(example_cases, expected_paths, force): # expected: print_info output showing packA installed but not packB ("packA",), """Installed Packs: +---------------- packA -Available Packs to Install: +Available Packs: +---------------- packB Examples: +--------- packA: - ex1 - ex2 @@ -368,20 +371,12 @@ def test_copy_examples_force(example_cases, expected_paths, force): @pytest.mark.parametrize("packs_to_install,expected", install_params) -def test_print_info(packs_to_install, expected, example_cases, capsys): - case5dir = example_cases / "case5" - env_dir = case5dir / "fake_env" - req_dir = case5dir / "requirements" / "packs" - # Handle Windows path format - env_dir_str = env_dir.as_posix() +def test_print_packs_and_examples( + packs_to_install, expected, example_cases, capsys, conda_env +): + env_dir_str = Path(conda_env).as_posix() shell = os.name == "nt" - subprocess.run( - ["conda", "create", "-y", "-p", env_dir_str], - check=True, - capture_output=True, - text=True, - shell=shell, - ) + req_dir = example_cases / "case5" / "requirements" / "packs" for pack in packs_to_install: req_file = (req_dir / f"{pack}.txt").as_posix() subprocess.run( @@ -391,8 +386,9 @@ def test_print_info(packs_to_install, expected, example_cases, capsys): text=True, shell=shell, ) - pm = PacksManager(root_path=case5dir) - pm.print_info() + pm = PacksManager(root_path=example_cases / "case5") + pm.print_packs() + pm.print_examples() captured = capsys.readouterr() actual = captured.out assert actual.strip() == expected.strip()