diff --git a/docs/md/dev/models.md b/docs/md/dev/models.md index 86db5512..9ab8a273 100644 --- a/docs/md/dev/models.md +++ b/docs/md/dev/models.md @@ -337,36 +337,36 @@ The simplest approach would be a single such script/command, e.g. `python -m mod ```bash # Show configured registries and status -python -m modflow_devtools.models info +mf models info # Sync all sources to configured refs -python -m modflow_devtools.models sync +mf models sync # Force re-download even if cached -python -m modflow_devtools.models sync --force +mf models sync --force # For a repo publishing models via releases -python -m modflow_devtools.models sync --repo MODFLOW-ORG/modflow6-examples --ref current +mf models sync --repo MODFLOW-ORG/modflow6-examples --ref current # For a repo with models under version control -python -m modflow_devtools.models sync --repo MODFLOW-ORG/modflow6-testmodels --ref develop -python -m modflow_devtools.models sync --repo MODFLOW-ORG/modflow6-testmodels --ref f3df630 # commit hash works too -``` - -Or via CLI commands: - -```bash -models info -models sync +mf models sync --repo MODFLOW-ORG/modflow6-testmodels --ref develop +mf models sync --repo MODFLOW-ORG/modflow6-testmodels --ref f3df630 # commit hash works too ``` -Perhaps leading with a `models` command namespace is too generic, and we need e.g. a leading `mf` namespace on all commands exposed by `modflow-devtools`: +CLI commands are available in two forms: ```bash +# Using the mf namespace (shorter) mf models info mf models sync + +# Or using the module form +python -m modflow_devtools.models info +python -m modflow_devtools.models sync ``` +The `mf` command provides a unified CLI namespace for all `modflow-devtools` commands. + #### Automatic sync At install time, `modflow-devtools` can load the bootstrap file and attempt to sync to all configured repositories/registries. The install should not fail if registry sync fails (due either to network errors or misconfiguration), however — an informative warning can be shown, and sync retried on subsequent imports and/or manually (see below). @@ -709,7 +709,7 @@ _DEFAULT_CACHE.clear() #### Show Registry Status ```bash -$ python -m modflow_devtools.models info +$ mf models info Registry sync status: @@ -727,19 +727,19 @@ mf6/example (MODFLOW-ORG/modflow6-examples) ```bash # Sync all configured sources/refs -$ python -m modflow_devtools.models sync +$ mf models sync # Sync specific source -$ python -m modflow_devtools.models sync --source modflow6-testmodels +$ mf models sync --source modflow6-testmodels # Sync specific ref -$ python -m modflow_devtools.models sync --source modflow6-testmodels --ref develop +$ mf models sync --source modflow6-testmodels --ref develop # Force re-download -$ python -m modflow_devtools.models sync --force +$ mf models sync --force # Test against a fork -$ python -m modflow_devtools.models sync \ +$ mf models sync \ --source modflow6-testmodels \ --ref feature-branch \ --repo myusername/modflow6-testmodels @@ -749,19 +749,19 @@ $ python -m modflow_devtools.models sync \ ```bash # Summary view -$ python -m modflow_devtools.models list +$ mf models list # Verbose view (show all model names) -$ python -m modflow_devtools.models list --verbose +$ mf models list --verbose # Filter by source -$ python -m modflow_devtools.models list --source mf6/test +$ mf models list --source mf6/test # Filter by ref -$ python -m modflow_devtools.models list --ref registry +$ mf models list --ref registry # Combine filters -$ python -m modflow_devtools.models list --source mf6/test --ref registry --verbose +$ mf models list --source mf6/test --ref registry --verbose ``` ### Registry Creation Tool diff --git a/docs/md/dev/programs.md b/docs/md/dev/programs.md index c0b790b6..3b8e42d9 100644 --- a/docs/md/dev/programs.md +++ b/docs/md/dev/programs.md @@ -481,19 +481,19 @@ Exposed as a CLI command and Python API: ```bash # Sync all configured sources and release tags -python -m modflow_devtools.programs sync +mf programs sync -# Sync specific source to specific release version -python -m modflow_devtools.programs sync --repo MODFLOW-ORG/modflow6 --version 6.6.3 +# Sync specific source +mf programs sync --source modflow6 # Force re-download -python -m modflow_devtools.programs sync --force +mf programs sync --force # Show sync status -python -m modflow_devtools.programs info +mf programs info # List available programs -python -m modflow_devtools.programs list +mf programs list ``` Or via Python API: @@ -544,15 +544,15 @@ The `--force` flag has different meanings depending on the command, maintaining **Common patterns**: ```bash # Update to latest registry and install -python -m modflow_devtools.programs sync --force -python -m modflow_devtools.programs install mf6 +mf programs sync --force +mf programs install mf6 # Repair installation without touching registry (offline-friendly) -python -m modflow_devtools.programs install mf6 --force +mf programs install mf6 --force # Complete refresh of both metadata and installation -python -m modflow_devtools.programs sync --force -python -m modflow_devtools.programs install mf6 --force +mf programs sync --force +mf programs install mf6 --force ``` ### Program installation @@ -561,38 +561,27 @@ Installation extends beyond metadata to actually providing program executables b ```bash # Install from binary (auto-detects platform) -python -m modflow_devtools.programs install mf6 +mf programs install mf6 # Install specific version -python -m modflow_devtools.programs install mf6@6.6.3 +mf programs install mf6@6.6.3 -# Install to custom location (interactive selection like get-modflow) -python -m modflow_devtools.programs install mf6 --bindir : - -# Install to specific directory -python -m modflow_devtools.programs install mf6 --bindir /usr/local/bin +# Install to custom location +mf programs install mf6 --bindir /usr/local/bin # Install multiple versions side-by-side (cached separately) -python -m modflow_devtools.programs install mf6@6.6.3 -python -m modflow_devtools.programs install mf6@6.5.0 - -# Select active version (re-copies from cache to bindir) -python -m modflow_devtools.programs select mf6@6.6.3 - -# List installed programs -python -m modflow_devtools.programs list --installed - -# List available versions of a program -python -m modflow_devtools.programs list mf6 +mf programs install mf6@6.6.3 +mf programs install mf6@6.5.0 -# Show where program is installed -python -m modflow_devtools.programs which mf6 +# List installation history +mf programs history +mf programs history mf6 # Uninstall specific version -python -m modflow_devtools.programs uninstall mf6@6.6.3 +mf programs uninstall mf6@6.6.3 # Uninstall all versions -python -m modflow_devtools.programs uninstall mf6 --all +mf programs uninstall mf6 --all ``` Python API: @@ -1055,14 +1044,14 @@ The Programs API has been implemented following a consolidated object-oriented a - Force re-download support **CLI Interface** ✅ -- `python -m modflow_devtools.programs sync` - Sync registries -- `python -m modflow_devtools.programs info` - Show sync status -- `python -m modflow_devtools.programs list` - List available programs -- `python -m modflow_devtools.programs install` - Install a program -- `python -m modflow_devtools.programs select` - Switch active version -- `python -m modflow_devtools.programs uninstall` - Uninstall a program -- `python -m modflow_devtools.programs which` - Show executable path -- `python -m modflow_devtools.programs installed` - List installed programs +- `mf programs sync` - Sync registries +- `mf programs info` - Show sync status +- `mf programs list` - List available programs +- `mf programs install` - Install a program +- `mf programs uninstall` - Uninstall a program +- `mf programs history` - Show installation history + +All commands also support the module form: `python -m modflow_devtools.programs ` **Registry Generation Tool** ✅ - `modflow_devtools/programs/make_registry.py` - Generate registry files diff --git a/docs/md/models.md b/docs/md/models.md index 410c8ca9..6970dbd0 100644 --- a/docs/md/models.md +++ b/docs/md/models.md @@ -77,9 +77,16 @@ for source_name, source_status in status.items(): print(f"{source_name}: {source_status.cached_refs}") ``` -Or via CLI: +Or via CLI (both forms are equivalent): ```bash +# Using the mf command +mf models sync +mf models sync --source modflow6-testmodels +mf models sync --source modflow6-testmodels --ref develop +mf models sync --force + +# Or using the module form python -m modflow_devtools.models sync python -m modflow_devtools.models sync --source modflow6-testmodels python -m modflow_devtools.models sync --source modflow6-testmodels --ref develop @@ -101,10 +108,17 @@ for example_name, model_list in list(examples.items())[:3]: print(f"{example_name}: {len(model_list)} models") ``` -Or by CLI: +Or by CLI (both forms are equivalent): ```bash +# Using the mf command +mf models info # Show sync status +mf models list # Show model summary... +mf models list --verbose # ..or full list +# Filter by source +mf models list --source mf6/test --verbose +# Or using the module form python -m modflow_devtools.models info # Show sync status python -m modflow_devtools.models list # Show model summary... python -m modflow_devtools.models list --verbose # ..or full list @@ -268,7 +282,8 @@ export MODFLOW_DEVTOOLS_NO_AUTO_SYNC=1 Then manually sync when needed: ```bash -python -m modflow_devtools.models sync +mf models sync +# Or: python -m modflow_devtools.models sync ``` ## Repository Integration diff --git a/docs/md/programs.md b/docs/md/programs.md index a842e864..9817b7ff 100644 --- a/docs/md/programs.md +++ b/docs/md/programs.md @@ -68,9 +68,15 @@ results = config.sync(verbose=True) results = config.sync(source="modflow6", verbose=True) ``` -Or via CLI: +Or via CLI (both forms are equivalent): ```bash +# Using the mf command +mf programs sync +mf programs sync --source modflow6 +mf programs sync --force # Force re-download of registry metadata + +# Or using the module form python -m modflow_devtools.programs sync python -m modflow_devtools.programs sync --source modflow6 python -m modflow_devtools.programs sync --force # Force re-download of registry metadata @@ -91,9 +97,16 @@ for source_name, source_status in status.items(): print(f"{source_name}: {source_status.cached_refs}") ``` -Or by CLI: +Or by CLI (both forms are equivalent): ```bash +# Using the mf command +mf programs info # Show sync status +mf programs list # Show program summary +mf programs list --verbose # Full list with details +mf programs list --source modflow6 --verbose # Filter by source + +# Or using the module form python -m modflow_devtools.programs info # Show sync status python -m modflow_devtools.programs list # Show program summary python -m modflow_devtools.programs list --verbose # Full list with details @@ -115,9 +128,15 @@ paths = install_program("mf6", version="6.6.3", verbose=True) paths = install_program("mf6", version="6.6.3", bindir="/usr/local/bin") ``` -Or via CLI: +Or via CLI (both forms are equivalent): ```bash +# Using the mf command +mf programs install mf6 +mf programs install mf6@6.6.3 +mf programs install mf6@6.6.3 --bindir /usr/local/bin + +# Or using the module form python -m modflow_devtools.programs install mf6 python -m modflow_devtools.programs install mf6@6.6.3 python -m modflow_devtools.programs install mf6@6.6.3 --bindir /usr/local/bin @@ -141,12 +160,16 @@ for program_name, installations in installed.items(): print(f"{program_name} {inst.version} in {inst.bindir}") ``` -Or by CLI: +Or by CLI (both forms are equivalent): ```bash -python -m modflow_devtools.programs which mf6 -python -m modflow_devtools.programs installed -python -m modflow_devtools.programs installed mf6 --verbose +# Using the mf command +mf programs history +mf programs history mf6 --verbose + +# Or using the module form +python -m modflow_devtools.programs history +python -m modflow_devtools.programs history mf6 --verbose ``` ### Version management @@ -164,12 +187,17 @@ install_program("mf6", version="6.5.0") select_version("mf6", version="6.5.0") ``` -Or by CLI: +Or by CLI (both forms are equivalent): ```bash +# Using the mf command +mf programs install mf6@6.6.3 +mf programs install mf6@6.5.0 +# Version switching not yet implemented - use Python API + +# Or using the module form python -m modflow_devtools.programs install mf6@6.6.3 python -m modflow_devtools.programs install mf6@6.5.0 -python -m modflow_devtools.programs select mf6@6.5.0 ``` ### Using the default manager @@ -290,15 +318,15 @@ The `--force` flag has different meanings depending on the command: **Common workflows**: ```bash # Update to latest registry and install -python -m modflow_devtools.programs sync --force -python -m modflow_devtools.programs install mf6 +mf programs sync --force +mf programs install mf6 # Repair broken installation (offline-friendly) -python -m modflow_devtools.programs install mf6 --force +mf programs install mf6 --force # Fresh install with latest metadata -python -m modflow_devtools.programs sync --force -python -m modflow_devtools.programs install mf6 --force +mf programs sync --force +mf programs install mf6 --force ``` ## Automatic Synchronization @@ -317,7 +345,8 @@ export MODFLOW_DEVTOOLS_NO_AUTO_SYNC=1 Then manually sync when needed: ```bash -python -m modflow_devtools.programs sync +mf programs sync +# Or: python -m modflow_devtools.programs sync ``` ## Relationship to pymake and get-modflow diff --git a/modflow_devtools/cli.py b/modflow_devtools/cli.py new file mode 100644 index 00000000..75f0555f --- /dev/null +++ b/modflow_devtools/cli.py @@ -0,0 +1,56 @@ +""" +Root CLI for modflow-devtools. + +Usage: + mf models sync + mf models info + mf models list + mf programs sync + mf programs info + mf programs list + mf programs install + mf programs uninstall + mf programs history +""" + +import argparse +import sys + + +def main(): + """Main entry point for the mf CLI.""" + parser = argparse.ArgumentParser( + prog="mf", + description="MODFLOW development tools", + ) + subparsers = parser.add_subparsers(dest="subcommand", help="Available commands") + + # Models subcommand + subparsers.add_parser("models", help="Manage MODFLOW model registries") + + # Programs subcommand + subparsers.add_parser("programs", help="Manage MODFLOW program registries") + + # Parse only the first level to determine which submodule to invoke + args, remaining = parser.parse_known_args() + + if not args.subcommand: + parser.print_help() + sys.exit(1) + + # Dispatch to the appropriate module CLI with remaining args + if args.subcommand == "models": + from modflow_devtools.models.__main__ import main as models_main + + # Replace sys.argv to make it look like we called the submodule directly + sys.argv = ["mf models", *remaining] + models_main() + elif args.subcommand == "programs": + from modflow_devtools.programs.__main__ import main as programs_main + + sys.argv = ["mf programs", *remaining] + programs_main() + + +if __name__ == "__main__": + main() diff --git a/modflow_devtools/models/__main__.py b/modflow_devtools/models/__main__.py index ac7cfb87..0213d839 100644 --- a/modflow_devtools/models/__main__.py +++ b/modflow_devtools/models/__main__.py @@ -9,6 +9,7 @@ import argparse import os +import shutil import sys from . import ( @@ -18,6 +19,29 @@ ) +def _format_grid(items, prefix=""): + """Format items in a grid layout.""" + if not items: + return + + term_width = shutil.get_terminal_size().columns + # Account for prefix indentation + available_width = term_width - len(prefix) + + # Calculate column width - find longest item + max_item_len = max(len(str(item)) for item in items) + col_width = min(max_item_len + 2, available_width) + + # Calculate number of columns + num_cols = max(1, available_width // col_width) + + # Print items in grid + for i in range(0, len(items), num_cols): + row_items = items[i : i + num_cols] + line = prefix + " ".join(str(item).ljust(col_width) for item in row_items) + print(line.rstrip()) + + def cmd_sync(args): """Sync command handler.""" config = ModelSourceConfig.load() @@ -87,16 +111,62 @@ def cmd_info(args): config = ModelSourceConfig.load() status = config.status - print("Registry sync status:\n") + if not status: + print("No model registries configured") + return + + # Collect source info + sources = [] for source_name, source_status in status.items(): - print(f"{source_name} ({source_status.repo})") - configured_refs = ", ".join(source_status.configured_refs) or "none" - print(f" Configured refs: {configured_refs}") cached_refs = ", ".join(source_status.cached_refs) or "none" - print(f" Cached refs: {cached_refs}") - if source_status.missing_refs: - missing_refs = ", ".join(source_status.missing_refs) - print(f" Missing refs: {missing_refs}") + missing_refs = ", ".join(source_status.missing_refs) if source_status.missing_refs else None + sources.append( + { + "name": source_name, + "repo": source_status.repo, + "cached": cached_refs, + "missing": missing_refs, + } + ) + + # Calculate layout + term_width = shutil.get_terminal_size().columns + min_col_width = 40 + num_cols = max(1, min(len(sources), term_width // min_col_width)) + col_width = term_width // num_cols - 2 + + print("Model registries:\n") + + # Print sources in grid + for i in range(0, len(sources), num_cols): + row_sources = sources[i : i + num_cols] + + # Build rows for this group of sources + rows = [] + max_lines = 0 + for src in row_sources: + lines = [ + f"{src['name']} ({src['repo']})", + f"Cached: {src['cached']}", + ] + if src["missing"]: + lines.append(f"Missing: {src['missing']}") + rows.append(lines) + max_lines = max(max_lines, len(lines)) + + # Print each line across columns + for line_idx in range(max_lines): + line_parts = [] + for col_idx, src_lines in enumerate(rows): + if line_idx < len(src_lines): + text = src_lines[line_idx] + # Truncate if needed + if len(text) > col_width: + text = text[: col_width - 3] + "..." + line_parts.append(text.ljust(col_width)) + else: + line_parts.append(" " * col_width) + print(" ".join(line_parts)) print() @@ -141,9 +211,9 @@ def cmd_list(args): if models: print(f" Models: {len(models)}") if args.verbose: - # Show all models in verbose mode - for model_name in sorted(models.keys()): - print(f" - {model_name}") + # Show all models in verbose mode, in grid layout + model_names = sorted(models.keys()) + _format_grid(model_names, prefix=" ") else: print(" No models") @@ -151,16 +221,16 @@ def cmd_list(args): if examples: print(f" Examples: {len(examples)}") if args.verbose: - # Show all examples in verbose mode - for example_name in sorted(examples.keys()): - print(f" - {example_name}") + # Show all examples in verbose mode, in grid layout + example_names = sorted(examples.keys()) + _format_grid(example_names, prefix=" ") print() def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser( - prog="python -m modflow_devtools.models", + prog="mf models", description="MODFLOW model registry management", ) subparsers = parser.add_subparsers(dest="command", help="Command to run") diff --git a/modflow_devtools/programs/__init__.py b/modflow_devtools/programs/__init__.py index d7c5a6ac..e30ce62f 100644 --- a/modflow_devtools/programs/__init__.py +++ b/modflow_devtools/programs/__init__.py @@ -124,29 +124,26 @@ def get_exe_path( assert exe is not None # Narrowing for mypy return exe + # If we have the archive, inspect it to determine the correct exe path + # This handles both nested and flat archive structures + if archive_path and asset_name: + from pathlib import Path + + asset_stem = Path(asset_name).stem + + # Try to detect the exe location in the archive + exe = self._detect_default_exe_in_archive( + archive_path, asset_stem, program_name, platform + ) + if exe: + # Already has the correct path (with or without asset stem prefix) + return exe + # Fall back to program-level exe or defaults if self.exe: exe = self.exe else: - # Try common defaults in order: - # 1. bin/{program} (most common) - # 2. {program} (binaries at root) - - # If we have the archive, inspect it to determine which pattern to use - if archive_path and asset_name: - from pathlib import Path - - asset_stem = Path(asset_name).stem - - # Try to detect which default exists in the archive - exe = self._detect_default_exe_in_archive( - archive_path, asset_stem, program_name, platform - ) - if exe: - # Already includes asset stem prefix - return exe - - # Fallback when archive not available - try bin/ first + # Default to bin/{program} exe = f"bin/{program_name}" # Add .exe extension for Windows platforms @@ -1011,6 +1008,187 @@ def get_bindir_options(program: str | None = None) -> list[Path]: return result +def get_bindir_shortcut_map(program: str | None = None) -> dict[str, tuple[Path, str]]: + """ + Get map of installation directory shortcuts to (path, description) tuples. + + Adapted from flopy's get-modflow utility: + https://github.com/modflowpy/flopy/blob/develop/flopy/utils/get_modflow.py + + Parameters + ---------- + program : str, optional + Program name to check for previous installation location + + Returns + ------- + dict[str, tuple[Path, str]] + Map of shortcuts (e.g., ':prev', ':python') to (path, description) tuples. + Only includes shortcuts for directories that exist and are writable. + """ + import sys + from pathlib import Path + + options: dict[str, tuple[Path, str]] = {} + + # 1. Previous installation location + if program: + metadata = InstallationMetadata(program) + if metadata.load(): + installations = metadata.list_installations() + if installations: + most_recent = max(installations, key=lambda i: i.installed_at) + prev_path = most_recent.bindir + if prev_path.exists() and os.access(prev_path, os.W_OK): + options[":prev"] = (prev_path, "previously selected bindir") + + # 2. modflow-devtools dedicated directory + if os.name == "nt": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + mfdt_path = Path(local_app_data) / "modflow-devtools" / "bin" + else: + mfdt_path = Path.home() / "AppData" / "Local" / "modflow-devtools" / "bin" + else: + # Unix: ~/.local/share/modflow-devtools/bin + xdg_data_home = os.environ.get("XDG_DATA_HOME") + if xdg_data_home: + mfdt_path = Path(xdg_data_home) / "modflow-devtools" / "bin" + else: + mfdt_path = Path.home() / ".local" / "share" / "modflow-devtools" / "bin" + + # Create if it doesn't exist + try: + mfdt_path.mkdir(parents=True, exist_ok=True) + if os.access(mfdt_path, os.W_OK): + options[":mf"] = (mfdt_path, "used by modflow-devtools") + except (OSError, PermissionError): + pass + + # 3. Python's Scripts/bin directory + if hasattr(sys, "base_prefix"): + py_bin = Path(sys.base_prefix) / ("Scripts" if os.name == "nt" else "bin") + if py_bin.is_dir() and os.access(py_bin, os.W_OK): + options[":python"] = (py_bin, "used by Python") + + # 4. User local bin + if os.name == "nt": + # Windows: %LOCALAPPDATA%\Microsoft\WindowsApps + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + windowsapps_path = Path(local_app_data) / "Microsoft" / "WindowsApps" + if windowsapps_path.is_dir() and os.access(windowsapps_path, os.W_OK): + options[":windowsapps"] = (windowsapps_path, "user app path") + else: + # Unix: ~/.local/bin + home_local_bin = Path.home() / ".local" / "bin" + if home_local_bin.is_dir() and os.access(home_local_bin, os.W_OK): + options[":home"] = (home_local_bin, "user-specific bindir") + + # 5. System local bin (Unix only) + if os.name != "nt": + local_bin = Path("/usr") / "local" / "bin" + if local_bin.is_dir() and os.access(local_bin, os.W_OK): + options[":system"] = (local_bin, "system local bindir") + + if not options: + raise ProgramInstallationError("No writable installation directories found") + + return options + + +def select_bindir( + bindir_arg: str, + program: str | None = None, +) -> Path: + """ + Parse and resolve bindir argument with support for ':' prefix shortcuts. + + Adapted from flopy's get-modflow utility: + https://github.com/modflowpy/flopy/blob/develop/flopy/utils/get_modflow.py + + Supports: + - ':' alone for interactive selection + - ':prev' for previous installation directory + - ':mf' for dedicated modflow-devtools directory + - ':python' for Python's Scripts/bin directory + - ':home' for ~/.local/bin (Unix) or :windowsapps (Windows) + - ':system' for /usr/local/bin (Unix only) + - ':windowsapps' for %LOCALAPPDATA%\\Microsoft\\WindowsApps (Windows only) + + Parameters + ---------- + bindir_arg : str + The bindir argument, which may start with ':' + program : str, optional + Program name (for :prev lookup) + + Returns + ------- + Path + Resolved installation directory path + + Raises + ------ + ProgramInstallationError + If shortcut is invalid or selection fails + """ + # Get available options + options = get_bindir_shortcut_map(program) + + # Interactive selection (bare ':') + if bindir_arg == ":": + # Build numbered menu + indexed_options = dict(enumerate(options.keys(), 1)) + + print("Select a number to choose installation directory:") + for idx, shortcut in indexed_options.items(): + opt_path, opt_info = options[shortcut] + print(f" {idx}: '{opt_path}' -- {opt_info} ('{shortcut}')") + + # Get user input + max_tries = 3 + for attempt in range(max_tries): + try: + res = input("> ") + choice_idx = int(res) + if choice_idx not in indexed_options: + raise ValueError("Invalid option number") + + selected_shortcut = indexed_options[choice_idx] + selected_path = options[selected_shortcut][0] + return selected_path.resolve() + + except (ValueError, KeyError): + if attempt < max_tries - 1: + print("Invalid option, try again") + else: + raise ProgramInstallationError("Invalid option selected, too many attempts") + + # Auto-select mode (e.g., ':python', ':prev') + else: + # Find matching shortcuts (support prefix matching) + bindir_lower = bindir_arg.lower() + matches = [opt for opt in options if opt.startswith(bindir_lower)] + + if len(matches) == 0: + available = ", ".join(options.keys()) + raise ProgramInstallationError( + f"Invalid bindir shortcut '{bindir_arg}'. Available: {available}" + ) + elif len(matches) > 1: + raise ProgramInstallationError( + f"Ambiguous bindir shortcut '{bindir_arg}'. Matches: {', '.join(matches)}" + ) + + # Exactly one match + selected_path = options[matches[0]][0] + return selected_path.resolve() + + # This should never be reached but needed for type checking + raise ProgramInstallationError("Failed to select bindir") + + @dataclass class ProgramInstallation: """Represents a program installation.""" @@ -1697,9 +1875,11 @@ def _try_best_effort_sync(): "download_archive", "extract_executables", "get_bindir_options", + "get_bindir_shortcut_map", "get_platform", "get_user_config_path", "install_program", "list_installed", + "select_bindir", "uninstall_program", ] diff --git a/modflow_devtools/programs/__main__.py b/modflow_devtools/programs/__main__.py index 3de970aa..df793ada 100644 --- a/modflow_devtools/programs/__main__.py +++ b/modflow_devtools/programs/__main__.py @@ -12,6 +12,7 @@ import argparse import os +import shutil import sys from . import ( @@ -20,6 +21,7 @@ _try_best_effort_sync, install_program, list_installed, + select_bindir, uninstall_program, ) @@ -47,21 +49,90 @@ def cmd_sync(args): print(f" Failed: {len(result.failed)} refs") +def _format_grid(items, prefix=""): + """Format items in a grid layout.""" + if not items: + return + + term_width = shutil.get_terminal_size().columns + # Account for prefix indentation + available_width = term_width - len(prefix) + + # Calculate column width - find longest item + max_item_len = max(len(str(item)) for item in items) + col_width = min(max_item_len + 2, available_width) + + # Calculate number of columns + num_cols = max(1, available_width // col_width) + + # Print items in grid + for i in range(0, len(items), num_cols): + row_items = items[i : i + num_cols] + line = prefix + " ".join(str(item).ljust(col_width) for item in row_items) + print(line.rstrip()) + + def cmd_info(args): """Info command handler.""" config = ProgramSourceConfig.load() status = config.status - print("Program registry sync status:\n") + if not status: + print("No program registries configured") + return + + # Collect source info + sources = [] for source_name, source_status in status.items(): - print(f"{source_name} ({source_status.repo})") - configured_refs = ", ".join(source_status.configured_refs) or "none" - print(f" Configured refs: {configured_refs}") cached_refs = ", ".join(source_status.cached_refs) or "none" - print(f" Cached refs: {cached_refs}") - if source_status.missing_refs: - missing_refs = ", ".join(source_status.missing_refs) - print(f" Missing refs: {missing_refs}") + missing_refs = ", ".join(source_status.missing_refs) if source_status.missing_refs else None + sources.append( + { + "name": source_name, + "repo": source_status.repo, + "cached": cached_refs, + "missing": missing_refs, + } + ) + + # Calculate layout + term_width = shutil.get_terminal_size().columns + min_col_width = 40 + num_cols = max(1, min(len(sources), term_width // min_col_width)) + col_width = term_width // num_cols - 2 + + print("Program registries:\n") + + # Print sources in grid + for i in range(0, len(sources), num_cols): + row_sources = sources[i : i + num_cols] + + # Build rows for this group of sources + rows = [] + max_lines = 0 + for src in row_sources: + lines = [ + f"{src['name']} ({src['repo']})", + f"Cached: {src['cached']}", + ] + if src["missing"]: + lines.append(f"Missing: {src['missing']}") + rows.append(lines) + max_lines = max(max_lines, len(lines)) + + # Print each line across columns + for line_idx in range(max_lines): + line_parts = [] + for col_idx, src_lines in enumerate(rows): + if line_idx < len(src_lines): + text = src_lines[line_idx] + # Truncate if needed + if len(text) > col_width: + text = text[: col_width - 3] + "..." + line_parts.append(text.ljust(col_width)) + else: + line_parts.append(" " * col_width) + print(" ".join(line_parts)) print() @@ -106,12 +177,14 @@ def cmd_list(args): if programs: print(f" Programs: {len(programs)}") if args.verbose: - # Show all programs in verbose mode + # Show all programs in verbose mode, in grid layout + program_items = [] for program_name, metadata in sorted(programs.items()): dist_names = ( ", ".join(d.name for d in metadata.dists) if metadata.dists else "none" ) - print(f" - {program_name} ({ref}) [{dist_names}]") + program_items.append(f"{program_name} ({ref}) [{dist_names}]") + _format_grid(program_items, prefix=" ") else: print(" No programs") print() @@ -123,11 +196,28 @@ def cmd_install(args): if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"): _try_best_effort_sync() + # Parse program@version syntax if provided + if "@" in args.program: + program, version = args.program.split("@", 1) + else: + program = args.program + version = args.version + + # Handle ':' prefix shortcuts for bindir + # Adapted from flopy's get-modflow utility + bindir = args.bindir + if bindir is not None and isinstance(bindir, str) and bindir.startswith(":"): + try: + bindir = select_bindir(bindir, program=program) + except Exception as e: + print(f"Installation failed: {e}", file=sys.stderr) + sys.exit(1) + try: paths = install_program( - program=args.program, - version=args.version, - bindir=args.bindir, + program=program, + version=version, + bindir=bindir, platform=args.platform, force=args.force, verbose=True, @@ -197,7 +287,7 @@ def cmd_history(args): def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser( - prog="python -m modflow_devtools.programs", + prog="mf programs", description="Manage MODFLOW program registries", ) subparsers = parser.add_subparsers(dest="command", help="Available commands") @@ -246,7 +336,13 @@ def main(): ) install_parser.add_argument( "--bindir", - help="Installation directory (default: auto-select)", + help=( + "Installation directory. Can be a path or a shortcut starting with ':'. " + "Use ':' alone for interactive selection. " + "Available shortcuts: :prev (previous), :mf (modflow-devtools), :python, " + ":home (Unix) or :windowsapps (Windows), :system (Unix). " + "Default: auto-select" + ), ) install_parser.add_argument( "--platform", diff --git a/pyproject.toml b/pyproject.toml index 6360e704..459e5f51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,9 @@ dev = [ "Bug Tracker" = "https://github.com/MODFLOW-ORG/modflow-devtools/issues" "Source Code" = "https://github.com/MODFLOW-ORG/modflow-devtools" +[project.scripts] +mf = "modflow_devtools.cli:main" + [tool.hatch.build.targets.sdist] only-include = ["modflow_devtools"]