Skip to content

Commit 521abf9

Browse files
bpiwowarclaude
andcommitted
feat: add stop_tags() to prevent tag propagation from sub-configs
Introduces ConfigWrapper as a unified value wrapper with properties (tagged, stop_tags) replacing the old TaggedValue class. The stop_tags() function prevents tags from a sub-config and its descendants from propagating to the parent's tag collection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cb74f10 commit 521abf9

7 files changed

Lines changed: 189 additions & 16 deletions

File tree

src/experimaestro/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
tag,
88
tags,
99
tagspath,
10+
stop_tags,
1011
STDOUT,
1112
STDERR,
1213
deprecate,
@@ -94,6 +95,7 @@
9495
"tag",
9596
"tags",
9697
"tagspath",
98+
"stop_tags",
9799
"STDOUT",
98100
"STDERR",
99101
"deprecate",

src/experimaestro/annotations.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,29 @@ def tag(value):
108108
:param value: The value to tag (str, int, float, or bool)
109109
:return: A tagged value wrapper that preserves the original value
110110
"""
111-
return objects.TaggedValue(value)
111+
wrapper = objects.ConfigWrapper.ensure(value)
112+
wrapper.tagged = True
113+
return wrapper
114+
115+
116+
def stop_tags(value):
117+
"""Prevent tags from a sub-configuration from propagating to the parent.
118+
119+
Example::
120+
121+
config = MyConfig.C(x=stop_tags(SubConfig.C(lr=tag(0.001))))
122+
tags(config) # Will NOT include "lr"
123+
124+
Can be combined with ``tag``::
125+
126+
config = MyConfig.C(x=stop_tags(tag(SubConfig.C(lr=tag(0.001)))))
127+
128+
:param value: The value to wrap
129+
:return: A wrapped value that stops tag propagation
130+
"""
131+
wrapper = objects.ConfigWrapper.ensure(value)
132+
wrapper.stop_tags = True
133+
return wrapper
112134

113135

114136
class TagDict(SortedDict):

src/experimaestro/core/objects/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
add_to_path,
2323
ObjectStore,
2424
SealedError,
25+
ConfigWrapper,
2526
TaggedValue,
2627
)
2728

@@ -41,6 +42,7 @@
4142
"WatchedOutput",
4243
"SealedError",
4344
"DependentMarker",
45+
"ConfigWrapper",
4446
"TaggedValue",
4547
"getqualattr",
4648
"copyconfig",

src/experimaestro/core/objects/config.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from .config_utils import (
4848
getqualattr,
4949
add_to_path,
50-
TaggedValue,
50+
ConfigWrapper,
5151
ObjectStore,
5252
classproperty,
5353
)
@@ -201,6 +201,10 @@ def __init__(self, pyobject: "ConfigMixin"):
201201
self._tags: dict[str, tuple[Any, str]] = {}
202202
self._initinfo = ""
203203

204+
# Per-argument properties set by ConfigWrapper (e.g., "stop_tags")
205+
# Maps argument name -> set of property strings
206+
self._args_properties: dict[str, set[str]] = {}
207+
204208
self._taskoutput = None
205209
"""Task output (caches the value of a submit)"""
206210

@@ -402,6 +406,10 @@ def __init__(self):
402406
# Store {name: (value, source)} for conflict detection
403407
self.tags_with_source: dict[str, tuple[Any, str]] = {}
404408

409+
def should_recurse_arg(self, config, arg_name: str) -> bool:
410+
props = config.__xpm__._args_properties.get(arg_name)
411+
return props is None or "stop_tags" not in props
412+
405413
def postprocess(self, stub, config: Config, values):
406414
for name, (value, source) in config.__xpm__._tags.items():
407415
if name in self.tags_with_source:
@@ -1606,14 +1614,12 @@ def __setattr__(self, name: str, value):
16061614
# Check if this is an XPM argument
16071615
xpmtype = self.__xpmtype__
16081616
if name in xpmtype.arguments:
1609-
# Handle TaggedValue: extract value and add tag
1610-
if isinstance(value, TaggedValue):
1611-
actual_value = value.value
1617+
# Handle ConfigWrapper (tag, stop_tags, etc.)
1618+
if isinstance(value, ConfigWrapper):
16121619
source = get_caller_location(skip_frames=1)
1613-
xpm.addtag(name, actual_value, source=source)
1614-
xpm.set(name, actual_value)
1615-
else:
1616-
xpm.set(name, value)
1620+
value.apply(xpm, name, source=source)
1621+
value = value.value
1622+
xpm.set(name, value)
16171623
return
16181624

16191625
# Check for deprecated replacement warning
@@ -1671,11 +1677,10 @@ def __init__(self, **kwargs):
16711677
continue
16721678
raise ValueError("%s is not an argument for %s" % (name, xpmtype))
16731679

1674-
# Special case of a tagged value
1675-
if isinstance(value, TaggedValue):
1680+
# Handle ConfigWrapper (tag, stop_tags, etc.)
1681+
if isinstance(value, ConfigWrapper):
1682+
value.apply(xpm, name, source=xpm._initinfo)
16761683
value = value.value
1677-
# Use _initinfo as source since tag is set at config creation
1678-
self.__xpm__.addtag(name, value, source=xpm._initinfo)
16791684

16801685
# Really set the value
16811686
xpm.set(name, value)

src/experimaestro/core/objects/config_utils.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,40 @@ class SealedError(Exception):
4848
pass
4949

5050

51-
class TaggedValue:
52-
def __init__(self, value):
51+
class ConfigWrapper:
52+
"""Wraps a config value with properties that modify how the parent processes it.
53+
54+
Properties:
55+
tagged: If True, the value is tagged (appears in experiment tags)
56+
stop_tags: If True, tags from this sub-config don't propagate to parent
57+
58+
Wrappers can be nested — properties are merged automatically::
59+
60+
stop_tags(tag(value)) # both tagged=True and stop_tags=True
61+
"""
62+
63+
def __init__(self, value, *, tagged: bool = False, stop_tags: bool = False):
5364
self.value = value
65+
self.tagged = tagged
66+
self.stop_tags = stop_tags
67+
68+
@staticmethod
69+
def ensure(value) -> "ConfigWrapper":
70+
"""Return value as a ConfigWrapper, reusing it if already one."""
71+
if isinstance(value, ConfigWrapper):
72+
return value
73+
return ConfigWrapper(value)
74+
75+
def apply(self, config_info: "ConfigInformation", arg_name: str, *, source: str): # noqa: F821
76+
"""Apply all wrapper properties to the parent ConfigInformation"""
77+
if self.tagged:
78+
config_info.addtag(arg_name, self.value, source=source)
79+
if self.stop_tags:
80+
config_info._args_properties.setdefault(arg_name, set()).add("stop_tags")
81+
82+
83+
# Backwards-compatible alias
84+
TaggedValue = ConfigWrapper
5485

5586

5687
class classproperty(property):

src/experimaestro/core/objects/config_walk.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ def map(self, k: str):
9494
"""Provides a path context when processing a tree"""
9595
return self.context.push(k)
9696

97+
def should_recurse_arg(self, config, arg_name: str) -> bool:
98+
"""Whether to recurse into this argument. Override to skip."""
99+
return True
100+
97101
def stub(self, config):
98102
return config
99103

@@ -123,7 +127,7 @@ def __call__(self, x):
123127
# Process all the arguments
124128
result = {}
125129
for arg, v in info.xpmvalues():
126-
if v is not None:
130+
if v is not None and self.should_recurse_arg(x, arg.name):
127131
with self.map(arg.name):
128132
result[arg.name] = self(v)
129133
else:

src/experimaestro/tests/test_tags.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from experimaestro import (
88
tag,
9+
stop_tags,
910
LightweightTask,
1011
Config,
1112
Task,
@@ -260,3 +261,109 @@ class Outer(Config):
260261
assert any("value" in record.message for record in caplog.records)
261262
assert tags["value"] == 99 # Last value wins
262263
assert tags["x"] == 2
264+
265+
266+
# --- stop_tags tests ---
267+
268+
269+
def test_stop_tags_prevents_propagation():
270+
"""stop_tags prevents nested tags from propagating to parent"""
271+
272+
class Inner(Config):
273+
lr: Param[float]
274+
275+
class Outer(Config):
276+
x: Param[Inner]
277+
278+
inner = Inner.C(lr=tag(0.1))
279+
outer = Outer.C(x=stop_tags(inner))
280+
281+
assert inner.tags() == {"lr": 0.1}
282+
assert outer.tags() == {}
283+
284+
285+
def test_stop_tags_parent_tags_still_work():
286+
"""Tags on the parent itself still work with stop_tags on a child"""
287+
288+
class Inner(Config):
289+
lr: Param[float]
290+
291+
class Outer(Config):
292+
x: Param[Inner]
293+
y: Param[int]
294+
295+
inner = Inner.C(lr=tag(0.1))
296+
outer = Outer.C(x=stop_tags(inner), y=tag(42))
297+
298+
assert outer.tags() == {"y": 42}
299+
300+
301+
def test_stop_tags_via_setattr():
302+
"""stop_tags works when set via attribute assignment"""
303+
304+
class Inner(Config):
305+
lr: Param[float]
306+
307+
class Outer(Config):
308+
x: Param[Inner]
309+
310+
inner = Inner.C(lr=tag(0.1))
311+
outer = Outer.C(x=Inner.C(lr=0.5))
312+
outer.x = stop_tags(inner)
313+
314+
assert outer.tags() == {}
315+
316+
317+
def test_stop_tags_deeply_nested():
318+
"""stop_tags blocks tags from all descendants, not just direct child"""
319+
320+
class Deep(Config):
321+
v: Param[int]
322+
323+
class Mid(Config):
324+
d: Param[Deep]
325+
326+
class Top(Config):
327+
m: Param[Mid]
328+
label: Param[str]
329+
330+
deep = Deep.C(v=tag(1))
331+
mid = Mid.C(d=deep).tag("mid_tag", "hello")
332+
top = Top.C(m=stop_tags(mid), label=tag("test"))
333+
334+
assert top.tags() == {"label": "test"}
335+
336+
337+
def test_stop_tags_combined_with_tag():
338+
"""stop_tags and tag can be combined via nesting"""
339+
340+
class Inner(Config):
341+
lr: Param[float]
342+
343+
class Outer(Config):
344+
x: Param[Inner]
345+
346+
inner = Inner.C(lr=tag(0.1))
347+
# tag the argument itself, but stop inner tags from propagating
348+
outer = Outer.C(x=stop_tags(tag(inner)))
349+
350+
# "x" is tagged (from tag()), but "lr" is blocked (from stop_tags())
351+
assert outer.tags() == {"x": inner}
352+
353+
354+
def test_stop_tags_does_not_affect_siblings():
355+
"""stop_tags on one argument doesn't affect other arguments"""
356+
357+
class Inner(Config):
358+
v: Param[int]
359+
360+
class Outer(Config):
361+
a: Param[Inner]
362+
b: Param[Inner]
363+
364+
inner_a = Inner.C(v=tag(1))
365+
inner_b = Inner.C(v=tag(2))
366+
outer = Outer.C(a=stop_tags(inner_a), b=inner_b)
367+
368+
# Only inner_b's tag propagates
369+
assert outer.tags() == {"v": 2}

0 commit comments

Comments
 (0)