Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions autotest/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 22 additions & 1 deletion docs/md/dev/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This is a living document which will be updated as development proceeds. As the
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->


- [Background](#background)
- [Objective](#objective)
- [Motivation](#motivation)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 24 additions & 1 deletion docs/md/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions modflow_devtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
mf models sync
mf models info
mf models list
mf models copy <model> <workspace>
mf models cp <model> <workspace> # cp is an alias for copy
mf programs sync
mf programs info
mf programs list
Expand Down
9 changes: 9 additions & 0 deletions modflow_devtools/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions modflow_devtools/models/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <model> <workspace>
python -m modflow_devtools.models cp <model> <workspace> # cp is an alias for copy
python -m modflow_devtools.models clear
"""

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
Loading