Skip to content

Commit df888f1

Browse files
committed
Implement rename method in DataCatalog and add corresponding tests
1 parent 79f9c00 commit df888f1

4 files changed

Lines changed: 224 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,23 +79,80 @@ def _build_catalog(self):
7979

8080
## Implementation Gotchas For Subclasses
8181

82-
### Mixed catalogs: math references alongside raw references
82+
### `name` is the catalog key — never mutate it directly
8383

84-
When a `DataCatalog` contains both raw `DataReference` entries and `MathDataReference` entries (mixed catalog), rows produced by `to_dataframe().reset_index()` will have `NaN` in the `source` column for math references, because derived references have no file source.
84+
`DataCatalog._references` is an `OrderedDict` keyed by `ref.name`. If you mutate
85+
`ref.name` directly the dictionary key becomes stale and every subsequent
86+
`catalog.get(ref.name)` raises `KeyError`.
8587

86-
**`get_data_reference` must guard against NaN source**
88+
**Always rename via `catalog.rename(old_name, new_name)`**, which atomically updates
89+
both the key and `ref.name`:
8790

88-
Raw entries are keyed by their auto-derived name (from `primary_key` values). Math entries are keyed by their `name` alone. The safe pattern:
91+
```python
92+
catalog.rename("JER__tf", "JER_tidal_filtered") # chainable, raises KeyError / ValueError on conflicts
93+
```
94+
95+
`rename()` does **not** affect `_source_index` — the source (file path) is unchanged.
96+
97+
### `name` vs display label in mixed catalogs
98+
99+
`name` serves two roles that can conflict:
100+
101+
| Role | Raw DSS example | Transform ref example |
102+
|------|-----------------|----------------------|
103+
| Catalog lookup key | `"study.dss::/A/JER/EC//15MIN/F/"` | `"JER__tf"` |
104+
| User-visible display | *ugly — should be hidden* | *clean — should be shown* |
105+
106+
**General rule** (handled automatically by `get_table_column_width_map()`):
107+
108+
When math refs are present the table automatically gains a `name` column so users
109+
can see what their transform refs are called. For managers whose raw-ref names
110+
are human-readable (e.g. `"RSAC075_flow"`) this is sufficient.
111+
112+
**For managers with ugly raw-ref names** (e.g. `DSSDataUIManager` where the raw-ref
113+
key is `"filename::pathname"`), override `get_data_catalog()` to inject a `label`
114+
column:
115+
116+
```python
117+
def get_data_catalog(self):
118+
df = self._dvue_catalog.to_dataframe().reset_index()
119+
# blank for raw refs (identified by domain columns); catalog key for derived refs
120+
df["label"] = df.apply(
121+
lambda r: r["name"] if r.get("ref_type", "raw") != "raw" else "",
122+
axis=1,
123+
)
124+
return df
125+
```
126+
127+
Add `"label"` to `_get_table_column_width_map()` so it appears first in the table.
128+
When a `label` column is present the framework suppresses the generic `name`
129+
injection, so the two columns never double-up.
130+
131+
### `get_data_reference` — always use `row["name"]`
132+
133+
The safest lookup pattern works for **both** raw and math refs:
89134

90135
```python
91136
def get_data_reference(self, row):
92-
source = row.get("source", None)
93-
if pd.isna(source):
94-
return self._dvue_catalog.get(row["name"]) # math ref
95-
return self._dvue_catalog.get(row["name"]) # raw ref — name is always reliable
137+
if "name" in row.index:
138+
return self._dvue_catalog.get(row["name"])
139+
return self._dvue_catalog.get(self.build_ref_key(row)) # fallback without reset_index()
96140
```
97141

98-
Failing to guard against NaN produces a `KeyError: "No DataReference named 'nan::...' in catalog."`.
142+
**Never** branch on `pd.isna(row["filename"])` to detect math refs.
143+
`TransformToCatalogAction` copies *all* original-ref attributes (including
144+
`filename`, `source`, domain columns) into the new `MathDataReference`, so the
145+
NaN guard silently falls through to a wrong key for every transform ref.
146+
147+
The `name` column is always present in the DataFrame returned by
148+
`get_data_catalog()` because the base implementation calls
149+
`catalog.to_dataframe().reset_index()`, which promotes the catalog-key index into
150+
a regular column.
151+
152+
### Mixed catalogs: NaN source for math references
153+
154+
Rows produced by `to_dataframe().reset_index()` have `NaN` in the `source` column
155+
for math references, because derived references carry `source="transform"` or `""`.
99156

100157
**`get_unique_short_names` must not receive NaN paths**
101158

@@ -109,6 +166,55 @@ valid_sources = [s for s in df["source"].unique() if not pd.isna(s)]
109166
short_names = get_unique_short_names(valid_sources)
110167
```
111168

169+
### Time-range-aware readers — contract and efficiency
170+
171+
`DataReference.getData(time_range=...)` passes `time_range` through `_load_data` into
172+
`reader.load(time_range=..., **attrs)` as a keyword argument alongside all other reference
173+
attributes. Whether the reader uses it efficiently depends entirely on the backend API:
174+
175+
| Backend API | Time-range behaviour |
176+
|-------------|----------------------|
177+
| `DSSFile.read_rts / read_its(path, start, end)` | **File-level windowing** — only the requested bytes are read |
178+
| HDF5 `get_data_for_catalog_entry(entry, time_window)` | **File-level windowing** |
179+
| `pyhecdss.get_ts(filename, path)` | No time-range parameter — full series loaded, slice in memory |
180+
| `pyhecdss.get_matching_ts(filename, path)` | No time-range parameter — full series loaded, slice in memory |
181+
| In-memory / callable readers | By definition full series — slice in memory |
182+
183+
**Rule**: if the backend API supports native time windowing (e.g. `DSSFile.read_rts`), use
184+
it so that only the requested bytes are read from disk. If the backend has no such API
185+
(e.g. high-level `pyhecdss.get_ts`), read the full series and slice by `time_range` before
186+
returning — do not skip the slice:
187+
188+
```python
189+
def load(self, **attributes) -> pd.DataFrame:
190+
time_range = attributes.get("time_range")
191+
df = _load_full_series(attributes) # only option with pyhecdss.get_ts
192+
if time_range is not None:
193+
start, end = pd.Timestamp(time_range[0]), pd.Timestamp(time_range[1])
194+
df = df.loc[start:end]
195+
return df
196+
```
197+
198+
The `DataReference` cache is keyed by `(start, end)` so each unique window is stored
199+
independently and subsequent calls for the same window are served from memory.
200+
201+
### `get_data_for_time_range` is a legacy hook — do not implement in new subclasses
202+
203+
The `TimeSeriesDataUIManager.get_data(df)` method chooses its data-loading path based on
204+
whether `data_catalog` is set:
205+
206+
```python
207+
if data_catalog is not None:
208+
data = self.get_data_reference(r).getData(time_range=self.time_range) # preferred
209+
else:
210+
data, _, _ = self.get_data_for_time_range(r, self.time_range) # legacy only
211+
```
212+
213+
Any manager that sets `data_catalog` (via the `data_catalog` property) **never calls
214+
`get_data_for_time_range`**. Do not override it in new subclasses — it is dead code
215+
once a catalog is wired up. Remove any existing overrides that only duplicated the
216+
`getData(time_range=...)` logic.
217+
112218
## Subclass Migration Guide — primary_key / source_num Redesign
113219

114220
This is a **breaking** redesign. The following concepts have been removed:

dvue/catalog.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,45 @@ def remove(self, name: str) -> "DataCatalog":
10701070
del self._references[name]
10711071
return self
10721072

1073+
def rename(self, old_name: str, new_name: str) -> "DataCatalog":
1074+
"""Rename a reference in the catalog (chainable).
1075+
1076+
Updates both the internal dictionary key and the reference's own
1077+
``name`` attribute atomically. Does not affect ``_source_index`` —
1078+
the source (file path) is unchanged by a rename.
1079+
1080+
Use this instead of mutating ``ref.name`` directly — direct mutation
1081+
leaves the dictionary key stale and causes ``catalog.get(ref.name)``
1082+
to raise ``KeyError``.
1083+
1084+
Parameters
1085+
----------
1086+
old_name : str
1087+
Current name of the reference.
1088+
new_name : str
1089+
New name for the reference.
1090+
1091+
Raises
1092+
------
1093+
KeyError
1094+
If no reference with ``old_name`` exists in the catalog.
1095+
ValueError
1096+
If ``new_name`` is already taken by a different reference.
1097+
"""
1098+
if old_name not in self._references:
1099+
raise KeyError(f"No DataReference named {old_name!r} in catalog.")
1100+
if new_name != old_name and new_name in self._references:
1101+
raise ValueError(
1102+
f"Cannot rename {old_name!r} to {new_name!r}: a reference "
1103+
f"named {new_name!r} already exists in the catalog."
1104+
)
1105+
if old_name == new_name:
1106+
return self
1107+
ref = self._references.pop(old_name)
1108+
ref.name = new_name
1109+
self._references[new_name] = ref
1110+
return self
1111+
10731112
def get(self, name: Optional[str] = None, **pk_kwargs: Any) -> DataReference:
10741113
"""Retrieve a :class:`DataReference` by name or by primary-key keyword arguments.
10751114

dvue/tsdataui.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,13 @@ def get_table_column_width_map(self):
615615
cat = getattr(self, "data_catalog", None)
616616
if cat is not None and self._has_math_refs():
617617
df = cat.to_dataframe()
618+
# Show the catalog key ('name') as a visible column so users can
619+
# see what their transform/math refs are called. Skip it when the
620+
# subclass already provides a 'label' column as a cleaner display
621+
# substitute (e.g. DSSDataUIManager injects label="" for raw refs
622+
# and label=name for derived refs so the ugly raw DSS path is hidden).
623+
if "name" not in column_width_map and "label" not in column_width_map:
624+
column_width_map["name"] = "15%"
618625
for col in df.columns:
619626
if col not in column_width_map and col not in ("geometry", "source"):
620627
column_width_map[col] = "10%"

tests/test_catalog.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,69 @@ def test_remove_nonexistent_raises(self):
10051005
with pytest.raises(KeyError):
10061006
cat.remove("nope")
10071007

1008+
# ------------------------------------------------------------------
1009+
# rename()
1010+
# ------------------------------------------------------------------
1011+
1012+
def test_rename_updates_key(self, simple_df):
1013+
cat = DataCatalog(primary_key=["name"])
1014+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df), name="old"))
1015+
cat.rename("old", "new")
1016+
assert "new" in cat
1017+
assert "old" not in cat
1018+
1019+
def test_rename_updates_ref_name_attribute(self, simple_df):
1020+
cat = DataCatalog(primary_key=["name"])
1021+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df), name="old"))
1022+
cat.rename("old", "new")
1023+
assert cat.get("new").name == "new"
1024+
1025+
def test_rename_is_chainable(self, simple_df):
1026+
cat = DataCatalog(primary_key=["name"])
1027+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df), name="old"))
1028+
result = cat.rename("old", "new")
1029+
assert result is cat
1030+
1031+
def test_rename_nonexistent_raises_key_error(self):
1032+
cat = DataCatalog(primary_key=["name"])
1033+
with pytest.raises(KeyError, match="nope"):
1034+
cat.rename("nope", "something")
1035+
1036+
def test_rename_to_existing_name_raises_value_error(self, simple_df):
1037+
cat = DataCatalog(primary_key=["name"])
1038+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df), name="a"))
1039+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df.copy()), name="b"))
1040+
with pytest.raises(ValueError, match="already exists"):
1041+
cat.rename("a", "b")
1042+
1043+
def test_rename_to_same_name_is_noop(self, simple_df):
1044+
cat = DataCatalog(primary_key=["name"])
1045+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df), name="a"))
1046+
cat.rename("a", "a") # no error, no change
1047+
assert cat.get("a").name == "a"
1048+
1049+
def test_rename_preserves_source_index(self, simple_df):
1050+
cat = DataCatalog(primary_key=["name"])
1051+
cat.add(DataReference(source="f1.csv", reader=InMemoryDataReferenceReader(simple_df), name="a"))
1052+
cat.add(DataReference(source="f2.csv", reader=InMemoryDataReferenceReader(simple_df.copy()), name="b"))
1053+
cat.rename("a", "a_new")
1054+
df = cat.to_dataframe()
1055+
assert "source_num" in df.columns # two distinct sources still tracked
1056+
1057+
def test_rename_data_still_loadable(self, simple_df):
1058+
cat = DataCatalog(primary_key=["name"])
1059+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df), name="old"))
1060+
cat.rename("old", "new")
1061+
result = cat.get("new").getData()
1062+
assert isinstance(result, pd.DataFrame)
1063+
1064+
def test_rename_then_get_old_raises(self, simple_df):
1065+
cat = DataCatalog(primary_key=["name"])
1066+
cat.add(DataReference(source="", reader=InMemoryDataReferenceReader(simple_df), name="old"))
1067+
cat.rename("old", "new")
1068+
with pytest.raises(KeyError):
1069+
cat.get("old")
1070+
10081071
def test_get_nonexistent_raises(self):
10091072
cat = DataCatalog(primary_key=["name"])
10101073
with pytest.raises(KeyError):

0 commit comments

Comments
 (0)