Skip to content

Commit 8a50e8c

Browse files
committed
Enhance regex support in search criteria and documentation updates for clarity
1 parent b0b0ba1 commit 8a50e8c

6 files changed

Lines changed: 251 additions & 25 deletions

File tree

README-mathref.md

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ Each expression variable is a short alias resolved by catalog attribute criteria
179179
variable: precipitation
180180
interval: hourly
181181
182-
# Multi-station mean: _require_single: false concatenates ALL matches into a DataFrame
182+
# Multi-station mean: match_all: true concatenates ALL matches into a DataFrame
183183
- name: mean_wind_speed__all_stations__hourly
184184
expression: ws.mean(axis=1)
185185
variable: mean_wind_speed
@@ -189,10 +189,109 @@ Each expression variable is a short alias resolved by catalog attribute criteria
189189
ws:
190190
variable: wind_speed
191191
interval: hourly
192-
_require_single: false
192+
match_all: true
193193
```
194194

195-
> **`_require_single: false`** — when set inside a `search_map` criteria block, *all* catalog entries matching those criteria are fetched and concatenated column-wise (axis=1 join by timestamp index) so the alias resolves to a DataFrame. Use this for multi-station aggregates such as `ws.mean(axis=1)`.
195+
> **`match_all: true`** — when set inside a `search_map` criteria block, *all* catalog entries matching those criteria are fetched and concatenated column-wise (axis=1 join by timestamp index) so the alias resolves to a DataFrame. Use this for multi-station aggregates such as `ws.mean(axis=1)`.
196+
197+
### Regex matching in criteria
198+
199+
By default, every `attr=value` criterion in a `search_map` block (or in a `catalog.search()` call) performs an **exact equality check**. You can switch to a **case-insensitive regular expression** by using `~` instead of `=` as the operator, or by passing a tilde-prefixed string in code.
200+
201+
#### Rules
202+
203+
| Rule | Detail |
204+
|---|---|
205+
| Operator | `~` in the editor/YAML; `"~pattern"` (tilde-prefixed string) in Python |
206+
| Matching | `re.fullmatch` — the pattern must match the **entire** attribute value |
207+
| Case | Case-insensitive (`re.IGNORECASE`) |
208+
| Partial match | Use `.*` to allow leading/trailing characters (e.g. `~EC.*`) |
209+
210+
#### YAML examples
211+
212+
```yaml
213+
search_map:
214+
# Exact match (default) — only "wind_speed" qualifies, not "wind_speed_mph"
215+
obs:
216+
variable: wind_speed
217+
interval: hourly
218+
219+
# Regex fullmatch — matches "wind_speed" AND "wind_speed_mph" (starts with "wind_speed")
220+
obs_any:
221+
variable: ~wind_speed.*
222+
interval: hourly
223+
224+
# Case-insensitive — "EC", "ec", "Ec" all match
225+
salinity:
226+
variable: ~ec
227+
interval: ~15min.*
228+
229+
# Match several known prefixes with alternation
230+
flow_or_stage:
231+
variable: ~(flow|stage)
232+
interval: hourly
233+
```
234+
235+
#### Python `set_search()` / `search_map` dict examples
236+
237+
```python
238+
# Regex via set_search() — matches "EC", "ec_hourly", "EC_daily", etc.
239+
m = (MathDataReference("obs * 1.0", name="ec_copy")
240+
.set_search("obs", variable="~EC.*", interval="hourly")
241+
.set_catalog(catalog))
242+
243+
# Regex in a search_map dict passed to the constructor
244+
m = MathDataReference(
245+
"a + b",
246+
search_map={
247+
"a": {"variable": "~flow.*", "location": "upstream"},
248+
"b": {"variable": "~flow.*", "location": "downstream"},
249+
},
250+
name="net_flow",
251+
)
252+
253+
# catalog.search() with regex — find all refs whose name starts with "station"
254+
refs = catalog.search(name="~station.*")
255+
# catalog.search() with regex attribute — find all EC-family variables
256+
refs = catalog.search(variable="~EC.*")
257+
```
258+
259+
#### Using regex in the editor
260+
261+
Type criteria using `~` instead of `=` to indicate regex:
262+
263+
```
264+
variable~EC.*
265+
variable~EC.*, interval=hourly
266+
```
267+
268+
The **`+attr`** picker always appends `attr=`. To switch to regex, manually replace `=` with `~` in the criteria field after picking the attribute name.
269+
270+
#### Partial vs full match
271+
272+
`re.fullmatch` is used, so the pattern must match the **entire** attribute value, not just a substring:
273+
274+
| Pattern | Attribute value | Matches? |
275+
|---|---|---|
276+
| `~EC` | `"EC"` | ✅ yes — exact fullmatch |
277+
| `~EC` | `"EC_daily"` | ❌ no — `EC` alone doesn't cover `_daily` |
278+
| `~EC.*` | `"EC_daily"` | ✅ yes — `.*` absorbs the suffix |
279+
| `~ec` | `"EC"` | ✅ yes — case-insensitive |
280+
| `~ec` | `"EC_DAILY"` | ❌ no — same fullmatch rule applies |
281+
| `~ec.*` | `"EC_DAILY"` | ✅ yes |
282+
| `~(flow\|stage)` | `"flow"` | ✅ yes |
283+
| `~(flow\|stage)` | `"velocity"` | ❌ no |
284+
285+
#### Limitation — literal `~` values
286+
287+
If a metadata attribute value legitimately starts with `~` (unusual), it **cannot** be matched using `=` with a tilde-prefixed expected value, because any value beginning with `~` is interpreted as a regex pattern. As a workaround, use a callable predicate in Python:
288+
289+
```python
290+
# Match the literal value "~special" exactly
291+
refs = catalog.search(variable=lambda v: v == "~special")
292+
```
293+
294+
This limitation does not apply to the YAML or editor format, where you would simply use `variable=~special` to trigger regex mode.
196295

197296
### Chaining Math References
198297

@@ -278,8 +377,8 @@ One row per expression variable. Rows can be added with **+ Add variable** and r
278377
| Field | Purpose |
279378
|---|---|
280379
| **Alias** | Short identifier used in the Expression (e.g. `obs`) |
281-
| **Match all** | When checked, all matching catalog entries are concatenated into a DataFrame (equivalent to `_require_single: false`) |
282-
| **Catalog criteria** | Comma-separated `attr=val` pairs used to search the catalog (e.g. `variable=wind_speed, interval=hourly`) |
380+
| **Match all** | When checked, all matching catalog entries are concatenated into a DataFrame (equivalent to `match_all: true` in YAML) |
381+
| **Catalog criteria** | Comma-separated `attr=val` (exact) or `attr~regex` (regex fullmatch) pairs used to search the catalog (e.g. `variable=wind_speed, interval=hourly` or `variable~EC.*, interval=hourly`) |
283382
| **+ attr** | Drop-down picker; selecting an attribute name appends `attr=` to the criteria field so you only need to fill in the value |
284383
| **▶** | Per-row test button — runs the criteria against the live catalog and shows an inline badge: `✅ 1 match`, `⚠️ N matches`, or `❌ 0 matches`. If exactly one match is found and the **Attributes** field is empty, it is auto-filled with the match's identifying attributes |
285384

dvue/catalog.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,33 @@ def _resolve_class(fqcn: str) -> type:
296296
return getattr(module, class_name)
297297

298298

299+
def _criterion_matches(actual: Any, expected: Any) -> bool:
300+
"""Return ``True`` if *actual* satisfies the *expected* criterion.
301+
302+
* ``expected`` is a **string starting with "~"** — treated as a
303+
case-insensitive regular expression; ``re.fullmatch`` is used so the
304+
pattern must cover the entire attribute value. Use ``~EC.*`` (not
305+
``~EC``) to match values that *start with* ``EC``.
306+
* ``expected`` is a **callable** — delegated to the caller (not handled
307+
here; callers should check ``callable(expected)`` first).
308+
* Otherwise — exact equality with transparent type coercion when scalar
309+
types differ (e.g. string ``'0'`` vs integer ``0``).
310+
"""
311+
if isinstance(expected, str) and expected.startswith("~"):
312+
pattern = expected[1:]
313+
return bool(re.fullmatch(pattern, str(actual) if actual is not None else "", re.IGNORECASE))
314+
if actual != expected:
315+
# Try type coercion when scalar types differ.
316+
try:
317+
if type(actual) is not type(expected):
318+
if actual == type(actual)(expected):
319+
return True
320+
except (ValueError, TypeError):
321+
pass
322+
return False
323+
return True
324+
325+
299326
# ---------------------------------------------------------------------------
300327
# DataReference
301328
# ---------------------------------------------------------------------------
@@ -519,6 +546,8 @@ def matches(self, **criteria: Any) -> bool:
519546
Each criterion value can be:
520547
521548
* a **scalar** – exact equality check.
549+
* a **string starting with "~"** – case-insensitive regex fullmatch
550+
(e.g. ``variable="~EC.*"`` matches ``"EC_daily"`` or ``"ec_hourly"``).
522551
* a **callable** ``f(value) -> bool`` – custom predicate.
523552
"""
524553
for key, expected in criteria.items():
@@ -530,14 +559,7 @@ def matches(self, **criteria: Any) -> bool:
530559
if callable(expected):
531560
if not expected(actual):
532561
return False
533-
elif actual != expected:
534-
# Try type coercion when scalar types differ
535-
try:
536-
if type(actual) is not type(expected):
537-
if actual == type(actual)(expected):
538-
continue
539-
except (ValueError, TypeError):
540-
pass
562+
elif not _criterion_matches(actual, expected):
541563
return False
542564
return True
543565

@@ -1078,12 +1100,17 @@ def search(self, **criteria: Any) -> List[DataReference]:
10781100
Each criterion value may be:
10791101
10801102
* a **scalar** – exact equality check.
1103+
* a **string starting with "~"** – case-insensitive regex fullmatch
1104+
(e.g. ``name="~station.*"`` matches any name beginning with
1105+
``station``, case-insensitively).
10811106
* a **callable** ``f(value) -> bool`` – custom predicate.
10821107
10831108
Examples
10841109
--------
10851110
>>> catalog.search(variable="temperature")
10861111
>>> catalog.search(name="my_ref")
1112+
>>> catalog.search(name="~station.*") # regex on name
1113+
>>> catalog.search(variable="~EC.*") # regex on attribute
10871114
>>> catalog.search(year=lambda y: int(y) >= 2020)
10881115
>>> catalog.search(variable="temperature", unit="degC")
10891116
"""
@@ -1097,7 +1124,7 @@ def search(self, **criteria: Any) -> List[DataReference]:
10971124
if callable(name_criterion):
10981125
if not name_criterion(r.name):
10991126
continue
1100-
elif r.name != name_criterion:
1127+
elif not _criterion_matches(r.name, name_criterion):
11011128
continue
11021129
if r.matches(**raw_criteria):
11031130
results.append(r)

dvue/math_ref_editor.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ def _parse_attrs(text: str) -> Dict[str, str]:
112112

113113
@staticmethod
114114
def _parse_search_map(text: str):
115-
"""Parse ``var[multi]: key=val, key=val`` lines.
115+
"""Parse ``var[multi]: key=val, key~regex, …`` lines.
116+
117+
Supports two operators per attribute token:
118+
119+
* ``attr=value`` — exact match (stored as the plain string ``value``).
120+
* ``attr~pattern`` — regex fullmatch, case-insensitive (stored as
121+
``"~pattern"`` so the tilde prefix is visible in round-trips).
116122
117123
Returns
118124
-------
@@ -135,7 +141,15 @@ def _parse_search_map(text: str):
135141
criteria: Dict[str, str] = {}
136142
for part in criteria_str.split(","):
137143
part = part.strip()
138-
if "=" in part:
144+
# Detect operator: ~ (regex) takes priority over = (exact).
145+
tilde_pos = part.find("~")
146+
eq_pos = part.find("=")
147+
if tilde_pos != -1 and (eq_pos == -1 or tilde_pos < eq_pos):
148+
k, _, v = part.partition("~")
149+
k = k.strip()
150+
if k:
151+
criteria[k] = "~" + v.strip()
152+
elif eq_pos != -1:
139153
k, _, v = part.partition("=")
140154
criteria[k.strip()] = v.strip()
141155
if var and criteria:
@@ -145,12 +159,22 @@ def _parse_search_map(text: str):
145159

146160
@staticmethod
147161
def _render_search_map(search_map: Dict[str, Any], req: Dict[str, bool]) -> str:
148-
"""Render ``search_map`` + ``search_require_single`` to editor text."""
162+
"""Render ``search_map`` + ``search_require_single`` to editor text.
163+
164+
Regex criteria (stored with a ``~`` prefix) are emitted as
165+
``attr~pattern``; exact criteria are emitted as ``attr=value``.
166+
"""
149167
lines = []
150168
for var, criteria in search_map.items():
151169
require_single = req.get(var, True)
152170
tag = "" if require_single else "[multi]"
153-
criteria_str = ", ".join(f"{k}={v}" for k, v in criteria.items())
171+
parts = []
172+
for k, v in criteria.items():
173+
if isinstance(v, str) and v.startswith("~"):
174+
parts.append(f"{k}{v}") # e.g. "variable~EC.*"
175+
else:
176+
parts.append(f"{k}={v}")
177+
criteria_str = ", ".join(parts)
154178
lines.append(f"{var}{tag}: {criteria_str}")
155179
return "\n".join(lines)
156180

@@ -306,7 +330,7 @@ def _on_insert(ev: Any) -> None:
306330
pn.pane.Markdown("**Alias**", width=100, margin=(0, 4, 0, 4)),
307331
pn.pane.Markdown("**Match all**", width=80, margin=(0, 4, 0, 4)),
308332
pn.pane.Markdown(
309-
"**Catalog criteria** (`attr=val, attr=val …`)",
333+
"**Catalog criteria** (`attr=val` exact · `attr~regex` pattern)",
310334
sizing_mode="stretch_width",
311335
margin=(0, 4, 0, 4),
312336
),
@@ -426,11 +450,19 @@ def _on_row_test(ev: Any) -> None:
426450
criteria: Dict[str, str] = {}
427451
for part in crit_text.split(","):
428452
part = part.strip()
429-
if "=" in part:
453+
# Detect operator: ~ (regex) takes priority over = (exact).
454+
tilde_pos = part.find("~")
455+
eq_pos = part.find("=")
456+
if tilde_pos != -1 and (eq_pos == -1 or tilde_pos < eq_pos):
457+
k, _, v = part.partition("~")
458+
k = k.strip()
459+
if k:
460+
criteria[k] = "~" + v.strip()
461+
elif eq_pos != -1:
430462
k, _, v = part.partition("=")
431463
criteria[k.strip()] = v.strip()
432464
if not criteria:
433-
_row_result_md.object = "⚠️ No valid `attr=val` pairs found."
465+
_row_result_md.object = "⚠️ No valid `attr=val` or `attr~regex` pairs found."
434466
return
435467
dataui.set_progress(-1)
436468
try:

examples/data/math_refs.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959

6060
# -- 4. Multi-station mean wind speed (all matching stations) -----------------
6161
#
62-
# _require_single: false tells the resolver to concat all matching references
62+
# match_all: true tells the resolver to concat all matching references
6363
# into a DataFrame. ws.mean(axis=1) then computes the column-wise mean so
6464
# the result is a single Series regardless of how many stations match.
6565
- name: mean_wind_speed__all_stations__hourly
@@ -71,4 +71,4 @@
7171
ws:
7272
variable: wind_speed
7373
interval: hourly
74-
_require_single: false
74+
match_all: true

examples/data/math_refs_search_map.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
variable: precipitation
7777
interval: hourly
7878

79-
# -- Example 4: Multi-station mean with _require_single: false ----------------
79+
# -- Example 4: Multi-station mean with match_all: true ----------------------
8080
# ws resolves to ALL catalog entries where variable=wind_speed + interval=hourly
8181
# (one per station). They are concatenated into a DataFrame (axis=1 join by
8282
# timestamp index) so ws.mean(axis=1) produces the mean across all stations.
@@ -93,7 +93,7 @@
9393
ws:
9494
variable: wind_speed
9595
interval: hourly
96-
_require_single: false
96+
match_all: true
9797

9898
# -- Example 5: Chain via catalog name (MathDataReference → MathDataReference) --
9999
# References wind_speed_mph__A__hourly, itself a MathDataReference defined

0 commit comments

Comments
 (0)