Skip to content

Commit b9336ba

Browse files
committed
make Component.load a classmethod
1 parent 937b6e2 commit b9336ba

File tree

6 files changed

+653
-20
lines changed

6 files changed

+653
-20
lines changed

flopy4/mf6/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,20 @@ class WriteError(Exception):
2525
pass
2626

2727

28-
def _load_mf6(path: Path) -> Component:
28+
def _load_mf6(cls, path: Path) -> Component:
29+
"""Load MF6 format file into a component instance."""
2930
with open(path, "r") as fp:
3031
return structure(load_mf6(fp), path)
3132

3233

33-
def _load_json(path: Path) -> Component:
34+
def _load_json(cls, path: Path) -> Component:
35+
"""Load JSON format file into a component instance."""
3436
with open(path, "r") as fp:
3537
return structure(load_json(fp), path)
3638

3739

38-
def _load_toml(path: Path) -> Component:
40+
def _load_toml(cls, path: Path) -> Component:
41+
"""Load TOML format file into a component instance."""
3942
with open(path, "rb") as fp:
4043
return structure(load_toml(fp), path)
4144

flopy4/mf6/component.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from abc import ABC
22
from collections.abc import MutableMapping
3+
from os import PathLike
34
from pathlib import Path
45
from typing import Any, ClassVar, Optional
56

@@ -132,16 +133,12 @@ def get_dfn(cls) -> Dfn:
132133
blocks=blocks,
133134
)
134135

135-
def load(self, format: str = MF6) -> None:
136+
@classmethod
137+
def load(cls, path: str | PathLike, format: str = MF6) -> None:
136138
"""Load the component and any children."""
137-
# TODO: setting filename is a temp hack to get the parent's
138-
# name as this component's filename stem, if it has one. an
139-
# actual solution is to auto-set the filename when children
140-
# are attached to parents.
141-
self.filename = self.filename or self.default_filename()
142-
self._load(format=format)
139+
self = cls._load(path, format=format) # Get the instance
143140
for child in self.children.values(): # type: ignore
144-
child.load(format=format)
141+
child.__class__.load(child.path, format=format)
145142

146143
def write(self, format: str = MF6, context: Optional[WriteContext] = None) -> None:
147144
"""

flopy4/mf6/context.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,21 @@ def path(self) -> Path:
4848
self.filename = self.filename or self.default_filename()
4949
return self.workspace / self.filename
5050

51-
def load(self, format=MF6):
52-
with cd(self.workspace):
53-
super().load(format=format)
51+
@classmethod
52+
def load(cls, path, format=MF6):
53+
"""
54+
Load the context component and children.
55+
56+
Children are loaded relative to the parent's workspace directory,
57+
so their paths are resolved within that workspace.
58+
"""
59+
# Load the instance first
60+
instance = cls._load(path, format=format)
61+
62+
# Load children within the workspace context
63+
with cd(instance.workspace):
64+
for child in instance.children.values(): # type: ignore
65+
child.__class__.load(child.path, format=format)
5466

5567
def write(self, format=MF6, context=None):
5668
with cd(self.workspace):

flopy4/uio.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ def register_writer(self, cls, format, function):
5555
raise ValueError(f"Writer for format {format} already registered.")
5656
self._writers[cls, format] = function
5757

58-
def load(self, cls, instance, *args, format=None, **kwargs):
58+
def load(self, cls, *args, format=None, **kwargs):
5959
_load = self.get_loader(cls, format)
60-
return _load(instance, *args, **kwargs)
60+
return _load(cls, *args, **kwargs)
6161

6262
def write(self, cls, instance, *args, format=None, **kwargs):
6363
_write = self.get_writer(cls, format)
@@ -76,16 +76,16 @@ class IO(property):
7676
See the `astropy` source for more details/motivation.
7777
"""
7878

79-
def __get__(self, instance, owner_cls):
80-
return self.fget(instance, owner_cls)
79+
def __get__(self, instance, cls):
80+
return self.fget(instance, cls)
8181

8282

8383
class IODescriptor:
8484
"""Base class for file IO operation descriptors."""
8585

8686
def __init__(self, instance, cls, op: Op, registry: Registry | None = None):
8787
self._registry = registry or DEFAULT_REGISTRY
88-
self._instance = instance
88+
self._instance = instance # None for loaders
8989
self._cls = cls
9090
self._op: Op = op
9191

@@ -111,7 +111,7 @@ def __init__(self, instance, cls):
111111
super().__init__(instance, cls, "load", registry=DEFAULT_REGISTRY)
112112

113113
def __call__(self, *args, **kwargs):
114-
return self.registry.load(self._cls, self._instance, *args, **kwargs)
114+
return self.registry.load(self._cls, *args, **kwargs)
115115

116116

117117
class Writer(IODescriptor):

test/IO_PLUMBING_TESTS_README.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# IO Plumbing Tests for Classmethod Load
2+
3+
## Overview
4+
5+
This test suite (`test_io_plumbing.py`) validates the IO plumbing layer for the refactored `Component.load()` classmethod. The tests ensure that:
6+
7+
1. IO descriptors work correctly in classmethod contexts
8+
2. The registry can register and lookup loaders/writers
9+
3. Loaders can be invoked from class methods (not just instances)
10+
4. The plumbing layer properly supports inheritance
11+
5. Child components are loaded recursively
12+
6. Context components load children relative to their workspace
13+
14+
## Test Coverage (16 Tests)
15+
16+
### Core Descriptor Tests
17+
18+
- **`test_io_descriptor_access_from_class()`**: Verifies that `_load` descriptor can be accessed from a class (without an instance)
19+
- **`test_io_descriptor_access_from_instance()`**: Verifies that `_load` descriptor still works with instances
20+
- **`test_io_descriptor_callable()`**: Confirms the descriptor returns a callable Loader object
21+
22+
### Registry Tests
23+
24+
- **`test_loader_registry_lookup()`**: Tests basic loader registration and lookup
25+
- **`test_loader_registry_subclass_lookup()`**: Verifies that loaders registered for base classes work with subclasses
26+
- **`test_multiple_format_registrations()`**: Tests that different loaders can be registered for different formats
27+
28+
### Classmethod Integration Tests
29+
30+
- **`test_classmethod_load_with_mock_loader()`**: Full integration test showing loader invocation from classmethod context
31+
- **`test_classmethod_load_signature()`**: Validates the `load()` method signature
32+
- **`test_classmethod_load_should_return_instance()`**: Demonstrates expected behavior (loader should return an instance)
33+
- **`test_loader_can_be_called_directly_from_class()`**: Tests the exact pattern used in `Component.load()`
34+
- **`test_component_load_classmethod_calls_loader()`**: Verifies that `Component.load()` correctly invokes registered loaders
35+
- **`test_component_load_with_children()`**: Tests that children are loaded recursively
36+
37+
### Context/Workspace Tests
38+
39+
- **`test_context_load_with_workspace()`**: Verifies that `Context.load()` loads children relative to the parent's workspace directory
40+
41+
### Writer Tests
42+
43+
- **`test_writer_descriptor_requires_instance()`**: Verifies writers work correctly with instances
44+
- **`test_registry_write_with_mock_writer()`**: Tests writer registration and invocation
45+
46+
### Type Annotation Tests
47+
48+
- **`test_load_return_type()`**: Validates the return type annotation of the `load()` method
49+
50+
## Issues Fixed
51+
52+
### ✅ Loader Signature Mismatch (flopy4/mf6/__init__.py)
53+
54+
**Problem**: Loader functions had old signature without `cls` parameter:
55+
```python
56+
def _load_mf6(path: Path) -> Component: # ❌ Missing cls parameter
57+
```
58+
59+
**Fixed**: Updated all loaders to accept `cls` as first parameter:
60+
```python
61+
def _load_mf6(cls, path: Path) -> Component: # ✅ Correct signature
62+
def _load_json(cls, path: Path) -> Component:
63+
def _load_toml(cls, path: Path) -> Component:
64+
```
65+
66+
### ✅ Bug in Component.load() (flopy4/mf6/component.py:139-140)
67+
68+
**Problem**: Used undefined `self` in classmethod context:
69+
```python
70+
@classmethod
71+
def load(cls, path: str | PathLike, format: str = MF6) -> None:
72+
cls._load(path, format=format)
73+
for child in self.children.values(): # ❌ self doesn't exist
74+
```
75+
76+
**Fixed**: Assign loader result to `self`:
77+
```python
78+
@classmethod
79+
def load(cls, path: str | PathLike, format: str = MF6) -> None:
80+
self = cls._load(path, format=format) # ✅ Get the instance
81+
for child in self.children.values():
82+
child.__class__.load(child.path, format=format)
83+
```
84+
85+
### ✅ Context.load() Workspace Support (flopy4/mf6/context.py)
86+
87+
**Problem**: `Context.load()` was an instance method and didn't use workspace for children:
88+
```python
89+
def load(self, format=MF6): # ❌ Instance method
90+
with cd(self.workspace):
91+
super().load(format=format)
92+
```
93+
94+
**Fixed**: Made it a classmethod that loads children within workspace:
95+
```python
96+
@classmethod
97+
def load(cls, path, format=MF6): # ✅ Classmethod
98+
"""Load context and children relative to workspace."""
99+
instance = cls._load(path, format=format)
100+
101+
# Load children within the workspace context
102+
with cd(instance.workspace):
103+
for child in instance.children.values():
104+
child.__class__.load(child.path, format=format)
105+
```
106+
107+
Now child components are loaded with paths relative to the parent's workspace directory, not the current working directory.
108+
109+
## Running the Tests
110+
111+
```bash
112+
pixi run -e dev pytest test/test_io_plumbing.py -v
113+
```
114+
115+
All 16 tests should pass ✅
116+
117+
## Summary
118+
119+
The IO plumbing is now fully functional for classmethod-based loading:
120+
121+
1. ✅ Loader functions have correct signature with `cls` parameter
122+
2.`Component.load()` correctly loads parent and children
123+
3.`Context.load()` loads children relative to parent's workspace
124+
4. ✅ Registry properly looks up loaders for classes and subclasses
125+
5. ✅ All descriptors work in both class and instance contexts
126+
127+
## Next Steps
128+
129+
1. Wire up the actual loader implementations to the converter/transformer layers
130+
2. Add integration tests with real MF6 files
131+
3. Consider whether `load()` should return the loaded instance for user convenience

0 commit comments

Comments
 (0)