Skip to content

Commit c430e8b

Browse files
authored
feat(models): add copy command (#295)
this was already in the python API just needed a CLI command too
1 parent 9dfa972 commit c430e8b

6 files changed

Lines changed: 219 additions & 2 deletions

File tree

autotest/test_models.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,127 @@ def test_cli_clear(self, capsys):
514514
captured = capsys.readouterr()
515515
assert "Cleared 1 cached registry" in captured.out
516516

517+
def test_cli_copy(self, tmp_path):
518+
"""Test 'copy' command."""
519+
# Sync a registry first
520+
_DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF)
521+
source = ModelSourceRepo(
522+
repo=TEST_MODELS_REPO,
523+
name=TEST_MODELS_SOURCE_NAME,
524+
refs=[TEST_MODELS_REF],
525+
)
526+
result = source.sync(ref=TEST_MODELS_REF)
527+
assert len(result.synced) == 1
528+
529+
# Load registry and get first model name
530+
registry = _DEFAULT_CACHE.load(TEST_MODELS_SOURCE_NAME, TEST_MODELS_REF)
531+
assert len(registry.models) > 0
532+
model_name = next(iter(registry.models.keys()))
533+
534+
# Create workspace
535+
workspace = tmp_path / "test-workspace"
536+
537+
# Copy model
538+
import argparse
539+
540+
from modflow_devtools.models.__main__ import cmd_copy
541+
542+
args = argparse.Namespace(model=model_name, workspace=str(workspace), verbose=True)
543+
cmd_copy(args)
544+
545+
# Verify workspace was created and contains files
546+
assert workspace.exists()
547+
assert len(list(workspace.rglob("*"))) > 0
548+
549+
def test_cli_copy_nonexistent_model(self, tmp_path, capsys):
550+
"""Test 'copy' command with nonexistent model."""
551+
# Sync a registry first
552+
_DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF)
553+
source = ModelSourceRepo(
554+
repo=TEST_MODELS_REPO,
555+
name=TEST_MODELS_SOURCE_NAME,
556+
refs=[TEST_MODELS_REF],
557+
)
558+
result = source.sync(ref=TEST_MODELS_REF)
559+
assert len(result.synced) == 1
560+
561+
# Try to copy nonexistent model
562+
import argparse
563+
564+
from modflow_devtools.models.__main__ import cmd_copy
565+
566+
workspace = tmp_path / "test-workspace"
567+
args = argparse.Namespace(
568+
model="nonexistent-model-12345", workspace=str(workspace), verbose=False
569+
)
570+
571+
with pytest.raises(SystemExit):
572+
cmd_copy(args)
573+
574+
captured = capsys.readouterr()
575+
assert "not in registry" in captured.err.lower()
576+
577+
def test_cli_cp_alias(self, tmp_path):
578+
"""Test 'cp' alias for 'copy' command."""
579+
# Sync a registry first
580+
_DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF)
581+
source = ModelSourceRepo(
582+
repo=TEST_MODELS_REPO,
583+
name=TEST_MODELS_SOURCE_NAME,
584+
refs=[TEST_MODELS_REF],
585+
)
586+
result = source.sync(ref=TEST_MODELS_REF)
587+
assert len(result.synced) == 1
588+
589+
# Load registry and get first model name
590+
registry = _DEFAULT_CACHE.load(TEST_MODELS_SOURCE_NAME, TEST_MODELS_REF)
591+
assert len(registry.models) > 0
592+
model_name = next(iter(registry.models.keys()))
593+
594+
# Create workspace
595+
workspace = tmp_path / "test-workspace-cp"
596+
597+
# Test that cp alias works via command parsing
598+
import argparse
599+
600+
from modflow_devtools.models.__main__ import cmd_copy
601+
602+
# Simulate args as if 'cp' command was used (argparse will set command to 'cp')
603+
args = argparse.Namespace(model=model_name, workspace=str(workspace), verbose=False)
604+
cmd_copy(args)
605+
606+
# Verify workspace was created and contains files
607+
assert workspace.exists()
608+
assert len(list(workspace.rglob("*"))) > 0
609+
610+
def test_python_cp_alias(self, tmp_path):
611+
"""Test Python API cp() alias for copy_to()."""
612+
# Sync a registry first
613+
_DEFAULT_CACHE.clear(source=TEST_MODELS_SOURCE_NAME, ref=TEST_MODELS_REF)
614+
source = ModelSourceRepo(
615+
repo=TEST_MODELS_REPO,
616+
name=TEST_MODELS_SOURCE_NAME,
617+
refs=[TEST_MODELS_REF],
618+
)
619+
result = source.sync(ref=TEST_MODELS_REF)
620+
assert len(result.synced) == 1
621+
622+
# Load registry and get first model name
623+
registry = _DEFAULT_CACHE.load(TEST_MODELS_SOURCE_NAME, TEST_MODELS_REF)
624+
assert len(registry.models) > 0
625+
model_name = next(iter(registry.models.keys()))
626+
627+
# Test cp() function
628+
from modflow_devtools.models import cp
629+
630+
workspace = tmp_path / "test-workspace-python-cp"
631+
result_path = cp(str(workspace), model_name, verbose=False)
632+
633+
# Verify workspace was created and contains files
634+
assert result_path is not None
635+
assert workspace.exists()
636+
assert len(list(workspace.rglob("*"))) > 0
637+
517638

518639
@pytest.mark.xdist_group("registry_cache")
519640
class TestIntegration:

docs/md/dev/models.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This is a living document which will be updated as development proceeds. As the
77
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
88
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
99

10+
1011
- [Background](#background)
1112
- [Objective](#objective)
1213
- [Motivation](#motivation)
@@ -43,6 +44,8 @@ This is a living document which will be updated as development proceeds. As the
4344
- [Show Registry Status](#show-registry-status)
4445
- [Sync Registries](#sync-registries)
4546
- [List Available Models](#list-available-models)
47+
- [Clear Cached Registries](#clear-cached-registries)
48+
- [Copy Models to Workspace](#copy-models-to-workspace)
4649
- [Registry Creation Tool](#registry-creation-tool)
4750
- [User Config Overlay for Fork Testing](#user-config-overlay-for-fork-testing)
4851
- [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
328331
- `sync`: synchronize registries for all configured source model repositories, or a specific repo
329332
- `info`: show configured registries and their sync status, or a particular registry's sync status
330333
- `list`: list available models for all registries, or for a particular registry
334+
- `copy` (or `cp`): copy a model's input files to a workspace directory
331335

332336
```bash
333337
# Show configured registries and status
@@ -345,6 +349,10 @@ mf models sync --repo MODFLOW-ORG/modflow6-examples --ref current
345349
# For a repo with models under version control
346350
mf models sync --repo MODFLOW-ORG/modflow6-testmodels --ref develop
347351
mf models sync --repo MODFLOW-ORG/modflow6-testmodels --ref f3df630 # commit hash works too
352+
353+
# Copy a model to a workspace (cp is an alias for copy)
354+
mf models copy mf6/test/test001a_Tharmonic ./my-workspace
355+
mf models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose
348356
```
349357

350358
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
544552
7. **Unified CLI operations**:
545553
- Sync all APIs: `python -m modflow_devtools sync --all`
546554
- Clean all caches: `python -m modflow_devtools clean --all`
547-
- Individual API operations: `python -m modflow_devtools.{api} sync|info|list|clean`
555+
- Individual API operations: `python -m modflow_devtools.{api} sync|info|list|copy|clean`
548556

549557
8. **MergedRegistry pattern**: Only used where needed
550558
- Models: Yes (essential for multi-source/multi-ref unified view)
@@ -723,6 +731,19 @@ $ mf models clear --source mf6/test --ref develop
723731
$ mf models clear --force
724732
```
725733

734+
#### Copy Models to Workspace
735+
736+
```bash
737+
# Copy a model to a workspace directory
738+
$ mf models copy mf6/test/test001a_Tharmonic ./my-workspace
739+
740+
# Copy with verbose output (cp is an alias for copy)
741+
$ mf models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose
742+
743+
# Works with absolute or relative paths
744+
$ mf models copy mf6/large/prudic2004t2 ../workspace
745+
```
746+
726747
### Registry Creation Tool
727748

728749
The `make_registry` tool uses a mode-based interface with **remote-first operation** by default:

docs/md/models.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,37 @@ python -m modflow_devtools.models list --source mf6/test --verbose
131131

132132
### Copying models to a workspace
133133

134+
Models can be copied to a workspace programmatically:
135+
134136
```python
135137
from tempfile import TemporaryDirectory
136-
from modflow_devtools.models import copy_to
138+
from modflow_devtools.models import copy_to, cp # cp is an alias
137139

138140
with TemporaryDirectory() as workspace:
139141
model_path = copy_to(workspace, "mf6/example/ex-gwf-twri01", verbose=True)
142+
# Or use the shorter alias
143+
model_path = cp(workspace, "mf6/example/ex-gwf-twri01", verbose=True)
144+
```
145+
146+
Or via CLI (both forms are equivalent):
147+
148+
```bash
149+
# Using the mf command
150+
mf models copy mf6/test/test001a_Tharmonic ./my-workspace
151+
mf models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose # cp is an alias
152+
153+
# Or using the module form
154+
python -m modflow_devtools.models copy mf6/test/test001a_Tharmonic ./my-workspace
155+
python -m modflow_devtools.models cp mf6/example/ex-gwf-twri01 /path/to/workspace --verbose
140156
```
141157

158+
The copy command:
159+
- Automatically attempts to sync registries before copying (unless `MODFLOW_DEVTOOLS_NO_AUTO_SYNC=1`)
160+
- Creates the workspace directory if it doesn't exist
161+
- Copies all input files for the specified model
162+
- Preserves subdirectory structure within the workspace
163+
- Use `--verbose` or `-v` flag to see detailed progress
164+
142165
### Using the default registry
143166

144167
The module provides explicit access to the default registry used by `get_models()` etc.

modflow_devtools/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
mf models sync
66
mf models info
77
mf models list
8+
mf models copy <model> <workspace>
9+
mf models cp <model> <workspace> # cp is an alias for copy
810
mf programs sync
911
mf programs info
1012
mf programs list

modflow_devtools/models/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,15 @@ def copy_to(workspace: str | PathLike, model_name: str, verbose: bool = False) -
13621362
return get_default_registry().copy_to(workspace, model_name, verbose=verbose)
13631363

13641364

1365+
def cp(workspace: str | PathLike, model_name: str, verbose: bool = False) -> Path | None:
1366+
"""
1367+
Alias for copy_to().
1368+
Copy the model's input files to the given workspace.
1369+
The workspace will be created if it does not exist.
1370+
"""
1371+
return copy_to(workspace, model_name, verbose=verbose)
1372+
1373+
13651374
def __getattr__(name: str):
13661375
"""
13671376
Lazy module attribute access for backwards compatibility.

modflow_devtools/models/__main__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
python -m modflow_devtools.models sync
66
python -m modflow_devtools.models info
77
python -m modflow_devtools.models list
8+
python -m modflow_devtools.models copy <model> <workspace>
9+
python -m modflow_devtools.models cp <model> <workspace> # cp is an alias for copy
810
python -m modflow_devtools.models clear
911
"""
1012

@@ -280,6 +282,26 @@ def cmd_clear(args):
280282
)
281283

282284

285+
def cmd_copy(args):
286+
"""Copy command handler."""
287+
# Attempt auto-sync before copying (unless disabled)
288+
if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"):
289+
_try_best_effort_sync()
290+
291+
from . import copy_to
292+
293+
try:
294+
workspace = copy_to(args.workspace, args.model, verbose=args.verbose)
295+
if workspace:
296+
print(f"\nSuccessfully copied model '{args.model}' to: {workspace}")
297+
else:
298+
print(f"Error: Model '{args.model}' not found in registry", file=sys.stderr)
299+
sys.exit(1)
300+
except Exception as e:
301+
print(f"Error copying model: {e}", file=sys.stderr)
302+
sys.exit(1)
303+
304+
283305
def main():
284306
"""Main CLI entry point."""
285307
parser = argparse.ArgumentParser(
@@ -352,6 +374,23 @@ def main():
352374
help="Skip confirmation prompt",
353375
)
354376

377+
# Copy command (with cp alias)
378+
copy_parser = subparsers.add_parser("copy", aliases=["cp"], help="Copy model to workspace")
379+
copy_parser.add_argument(
380+
"model",
381+
help="Name of the model to copy (e.g., mf6/test/test001a_Tharmonic)",
382+
)
383+
copy_parser.add_argument(
384+
"workspace",
385+
help="Destination workspace directory",
386+
)
387+
copy_parser.add_argument(
388+
"--verbose",
389+
"-v",
390+
action="store_true",
391+
help="Print detailed progress messages",
392+
)
393+
355394
args = parser.parse_args()
356395

357396
if not args.command:
@@ -367,6 +406,8 @@ def main():
367406
cmd_list(args)
368407
elif args.command == "clear":
369408
cmd_clear(args)
409+
elif args.command in ("copy", "cp"):
410+
cmd_copy(args)
370411
except Exception as e:
371412
print(f"Error: {e}", file=sys.stderr)
372413
sys.exit(1)

0 commit comments

Comments
 (0)