From ca06dcd0c2cae9c8c09ada73926724c89430bd56 Mon Sep 17 00:00:00 2001 From: Bonelli Date: Wed, 25 Feb 2026 11:56:12 -0500 Subject: [PATCH] feat(models): add copy command --- autotest/test_models.py | 121 ++++++++++++++++++++++++++++ docs/md/dev/models.md | 23 +++++- docs/md/models.md | 25 +++++- modflow_devtools/cli.py | 2 + modflow_devtools/models/__init__.py | 9 +++ modflow_devtools/models/__main__.py | 41 ++++++++++ 6 files changed, 219 insertions(+), 2 deletions(-) diff --git a/autotest/test_models.py b/autotest/test_models.py index 92c49d0..3f46ceb 100644 --- a/autotest/test_models.py +++ b/autotest/test_models.py @@ -514,6 +514,127 @@ def test_cli_clear(self, capsys): captured = capsys.readouterr() assert "Cleared 1 cached registry" in captured.out + def test_cli_copy(self, tmp_path): + """Test 'copy' command.""" + # Sync a registry first + _DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF) + source = ModelSourceRepo( + repo=TEST_MODELS_REPO, + name=TEST_MODELS_SOURCE_NAME, + refs=[TEST_MODELS_REF], + ) + result = source.sync(ref=TEST_MODELS_REF) + assert len(result.synced) == 1 + + # Load registry and get first model name + registry = _DEFAULT_CACHE.load(TEST_MODELS_SOURCE_NAME, TEST_MODELS_REF) + assert len(registry.models) > 0 + model_name = next(iter(registry.models.keys())) + + # Create workspace + workspace = tmp_path / "test-workspace" + + # Copy model + import argparse + + from modflow_devtools.models.__main__ import cmd_copy + + args = argparse.Namespace(model=model_name, workspace=str(workspace), verbose=True) + cmd_copy(args) + + # Verify workspace was created and contains files + assert workspace.exists() + assert len(list(workspace.rglob("*"))) > 0 + + def test_cli_copy_nonexistent_model(self, tmp_path, capsys): + """Test 'copy' command with nonexistent model.""" + # Sync a registry first + _DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF) + source = ModelSourceRepo( + repo=TEST_MODELS_REPO, + name=TEST_MODELS_SOURCE_NAME, + refs=[TEST_MODELS_REF], + ) + result = source.sync(ref=TEST_MODELS_REF) + assert len(result.synced) == 1 + + # Try to copy nonexistent model + import argparse + + from modflow_devtools.models.__main__ import cmd_copy + + workspace = tmp_path / "test-workspace" + args = argparse.Namespace( + model="nonexistent-model-12345", workspace=str(workspace), verbose=False + ) + + with pytest.raises(SystemExit): + cmd_copy(args) + + captured = capsys.readouterr() + assert "not in registry" in captured.err.lower() + + def test_cli_cp_alias(self, tmp_path): + """Test 'cp' alias for 'copy' command.""" + # Sync a registry first + _DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF) + source = ModelSourceRepo( + repo=TEST_MODELS_REPO, + name=TEST_MODELS_SOURCE_NAME, + refs=[TEST_MODELS_REF], + ) + result = source.sync(ref=TEST_MODELS_REF) + assert len(result.synced) == 1 + + # Load registry and get first model name + registry = _DEFAULT_CACHE.load(TEST_MODELS_SOURCE_NAME, TEST_MODELS_REF) + assert len(registry.models) > 0 + model_name = next(iter(registry.models.keys())) + + # Create workspace + workspace = tmp_path / "test-workspace-cp" + + # Test that cp alias works via command parsing + import argparse + + from modflow_devtools.models.__main__ import cmd_copy + + # Simulate args as if 'cp' command was used (argparse will set command to 'cp') + args = argparse.Namespace(model=model_name, workspace=str(workspace), verbose=False) + cmd_copy(args) + + # Verify workspace was created and contains files + assert workspace.exists() + assert len(list(workspace.rglob("*"))) > 0 + + def test_python_cp_alias(self, tmp_path): + """Test Python API cp() alias for copy_to().""" + # Sync a registry first + _DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF) + source = ModelSourceRepo( + repo=TEST_MODELS_REPO, + name=TEST_MODELS_SOURCE_NAME, + refs=[TEST_MODELS_REF], + ) + result = source.sync(ref=TEST_MODELS_REF) + assert len(result.synced) == 1 + + # Load registry and get first model name + registry = _DEFAULT_CACHE.load(TEST_MODELS_SOURCE_NAME, TEST_MODELS_REF) + assert len(registry.models) > 0 + model_name = next(iter(registry.models.keys())) + + # Test cp() function + from modflow_devtools.models import cp + + workspace = tmp_path / "test-workspace-python-cp" + result_path = cp(str(workspace), model_name, verbose=False) + + # Verify workspace was created and contains files + assert result_path is not None + assert workspace.exists() + assert len(list(workspace.rglob("*"))) > 0 + @pytest.mark.xdist_group("registry_cache") class TestIntegration: diff --git a/docs/md/dev/models.md b/docs/md/dev/models.md index 8c297f3..a80d85e 100644 --- a/docs/md/dev/models.md +++ b/docs/md/dev/models.md @@ -7,6 +7,7 @@ This is a living document which will be updated as development proceeds. As the + - [Background](#background) - [Objective](#objective) - [Motivation](#motivation) @@ -43,6 +44,8 @@ This is a living document which will be updated as development proceeds. As the - [Show Registry Status](#show-registry-status) - [Sync Registries](#sync-registries) - [List Available Models](#list-available-models) + - [Clear Cached Registries](#clear-cached-registries) + - [Copy Models to Workspace](#copy-models-to-workspace) - [Registry Creation Tool](#registry-creation-tool) - [User Config Overlay for Fork Testing](#user-config-overlay-for-fork-testing) - [Upstream CI Workflow Examples](#upstream-ci-workflow-examples) @@ -328,6 +331,7 @@ The simplest approach would be a single such script/command, e.g. `python -m mod - `sync`: synchronize registries for all configured source model repositories, or a specific repo - `info`: show configured registries and their sync status, or a particular registry's sync status - `list`: list available models for all registries, or for a particular registry +- `copy` (or `cp`): copy a model's input files to a workspace directory ```bash # Show configured registries and status @@ -345,6 +349,10 @@ mf models sync --repo MODFLOW-ORG/modflow6-examples --ref current # For a repo with models under version control mf models sync --repo MODFLOW-ORG/modflow6-testmodels --ref develop mf models sync --repo MODFLOW-ORG/modflow6-testmodels --ref f3df630 # commit hash works too + +# Copy a model to a workspace (cp is an alias for copy) +mf models copy mf6/test/test001a_Tharmonic ./my-workspace +mf models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose ``` CLI commands are available in two forms: @@ -544,7 +552,7 @@ The Models, Programs, and DFNs APIs share a consistent design for ease of use an 7. **Unified CLI operations**: - Sync all APIs: `python -m modflow_devtools sync --all` - Clean all caches: `python -m modflow_devtools clean --all` - - Individual API operations: `python -m modflow_devtools.{api} sync|info|list|clean` + - Individual API operations: `python -m modflow_devtools.{api} sync|info|list|copy|clean` 8. **MergedRegistry pattern**: Only used where needed - Models: Yes (essential for multi-source/multi-ref unified view) @@ -723,6 +731,19 @@ $ mf models clear --source mf6/test --ref develop $ mf models clear --force ``` +#### Copy Models to Workspace + +```bash +# Copy a model to a workspace directory +$ mf models copy mf6/test/test001a_Tharmonic ./my-workspace + +# Copy with verbose output (cp is an alias for copy) +$ mf models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose + +# Works with absolute or relative paths +$ mf models copy mf6/large/prudic2004t2 ../workspace +``` + ### Registry Creation Tool The `make_registry` tool uses a mode-based interface with **remote-first operation** by default: diff --git a/docs/md/models.md b/docs/md/models.md index ae98e73..9261abc 100644 --- a/docs/md/models.md +++ b/docs/md/models.md @@ -131,14 +131,37 @@ python -m modflow_devtools.models list --source mf6/test --verbose ### Copying models to a workspace +Models can be copied to a workspace programmatically: + ```python from tempfile import TemporaryDirectory -from modflow_devtools.models import copy_to +from modflow_devtools.models import copy_to, cp # cp is an alias with TemporaryDirectory() as workspace: model_path = copy_to(workspace, "mf6/example/ex-gwf-twri01", verbose=True) + # Or use the shorter alias + model_path = cp(workspace, "mf6/example/ex-gwf-twri01", verbose=True) +``` + +Or via CLI (both forms are equivalent): + +```bash +# Using the mf command +mf models copy mf6/test/test001a_Tharmonic ./my-workspace +mf models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose # cp is an alias + +# Or using the module form +python -m modflow_devtools.models copy mf6/test/test001a_Tharmonic ./my-workspace +python -m modflow_devtools.models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose ``` +The copy command: +- Automatically attempts to sync registries before copying (unless `MODFLOW_DEVTOOLS_NO_AUTO_SYNC=1`) +- Creates the workspace directory if it doesn't exist +- Copies all input files for the specified model +- Preserves subdirectory structure within the workspace +- Use `--verbose` or `-v` flag to see detailed progress + ### Using the default registry The module provides explicit access to the default registry used by `get_models()` etc. diff --git a/modflow_devtools/cli.py b/modflow_devtools/cli.py index a19de08..563cf61 100644 --- a/modflow_devtools/cli.py +++ b/modflow_devtools/cli.py @@ -5,6 +5,8 @@ mf models sync mf models info mf models list + mf models copy + mf models cp # cp is an alias for copy mf programs sync mf programs info mf programs list diff --git a/modflow_devtools/models/__init__.py b/modflow_devtools/models/__init__.py index 6cd0891..ca96eaa 100644 --- a/modflow_devtools/models/__init__.py +++ b/modflow_devtools/models/__init__.py @@ -1362,6 +1362,15 @@ def copy_to(workspace: str | PathLike, model_name: str, verbose: bool = False) - return get_default_registry().copy_to(workspace, model_name, verbose=verbose) +def cp(workspace: str | PathLike, model_name: str, verbose: bool = False) -> Path | None: + """ + Alias for copy_to(). + Copy the model's input files to the given workspace. + The workspace will be created if it does not exist. + """ + return copy_to(workspace, model_name, verbose=verbose) + + def __getattr__(name: str): """ Lazy module attribute access for backwards compatibility. diff --git a/modflow_devtools/models/__main__.py b/modflow_devtools/models/__main__.py index 7cf8584..49752fa 100644 --- a/modflow_devtools/models/__main__.py +++ b/modflow_devtools/models/__main__.py @@ -5,6 +5,8 @@ python -m modflow_devtools.models sync python -m modflow_devtools.models info python -m modflow_devtools.models list + python -m modflow_devtools.models copy + python -m modflow_devtools.models cp # cp is an alias for copy python -m modflow_devtools.models clear """ @@ -280,6 +282,26 @@ def cmd_clear(args): ) +def cmd_copy(args): + """Copy command handler.""" + # Attempt auto-sync before copying (unless disabled) + if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"): + _try_best_effort_sync() + + from . import copy_to + + try: + workspace = copy_to(args.workspace, args.model, verbose=args.verbose) + if workspace: + print(f"\nSuccessfully copied model '{args.model}' to: {workspace}") + else: + print(f"Error: Model '{args.model}' not found in registry", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error copying model: {e}", file=sys.stderr) + sys.exit(1) + + def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser( @@ -352,6 +374,23 @@ def main(): help="Skip confirmation prompt", ) + # Copy command (with cp alias) + copy_parser = subparsers.add_parser("copy", aliases=["cp"], help="Copy model to workspace") + copy_parser.add_argument( + "model", + help="Name of the model to copy (e.g., mf6/test/test001a_Tharmonic)", + ) + copy_parser.add_argument( + "workspace", + help="Destination workspace directory", + ) + copy_parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print detailed progress messages", + ) + args = parser.parse_args() if not args.command: @@ -367,6 +406,8 @@ def main(): cmd_list(args) elif args.command == "clear": cmd_clear(args) + elif args.command in ("copy", "cp"): + cmd_copy(args) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1)