fix(traits): carry trait state and parameter callbacks through a save - #5523
Draft
collindutter wants to merge 3 commits into
Draft
collindutter wants to merge 3 commits into
collindutter wants to merge 3 commits into
Conversation
collindutter
force-pushed
the
callbacks
branch
from
September 9, 2026 20:36
8554c41 to
0bba688
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
collindutter
force-pushed
the
callbacks
branch
from
September 10, 2026 22:54
7dfe724 to
29e7ae5
Compare
| def test_a_callback_declared_as_state_is_refused(self) -> None: | ||
| with pytest.raises(TypeError, match="metadata=BEHAVIOR"): | ||
|
|
||
| class Handler(Trait): |
| def test_a_state_type_no_saved_workflow_can_hold_is_refused(self) -> None: | ||
| with pytest.raises(TypeError, match="Path cannot be written"): | ||
|
|
||
| class Rooted(Trait): |
| def test_an_unsaveable_type_inside_a_container_is_refused(self) -> None: | ||
| with pytest.raises(TypeError, match="Path cannot be written"): | ||
|
|
||
| class ManyRoots(Trait): |
| def test_a_bare_annotated_attribute_is_refused(self) -> None: | ||
| with pytest.raises(TypeError, match="annotates 'threshold' but never declares it"): | ||
|
|
||
| class Bare(Trait): |
| def test_an_annotation_with_no_value_is_refused(self) -> None: | ||
| with pytest.raises(TypeError, match=r"attrs\.field"): | ||
|
|
||
| class Undeclared(Trait): |
| """Callers compare the callback by identity, so reading it must not rebuild it.""" | ||
| button = Button(label="Docs", button_link="https://example.test") | ||
|
|
||
| assert button.on_click_callback is button.on_click_callback |
| """ | ||
| with pytest.raises(TypeError, match="annotates 'DEFAULTS' but never declares it"): | ||
|
|
||
| class Stringified(Trait): |
collindutter
force-pushed
the
callbacks
branch
from
September 17, 2026 17:21
d7d8d96 to
2e99573
Compare
collindutter
force-pushed
the
callbacks
branch
from
September 17, 2026 20:15
47672d3 to
2e99573
Compare
Trait fields are the saved contract: normal fields save as data, metadata=BEHAVIOR fields save as an owning-node method name, init=False fields do not save. Elements become attrs classes so a trait's constructor declares that contract. ui_options gains an authored view. A trait owns the keys it renders, so those are subtracted at the save boundary and overlaid on read, and every write routes through one place that hands a trait-owned key to its trait.
…nership Covers the trait constructor contract, which values a save can hold, and state_from_ui_options for accepting editor and saved-file writes.
collindutter
force-pushed
the
callbacks
branch
from
September 17, 2026 21:40
2e99573 to
d6f8a14
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A parameter created at run time was saved as scalar fields only, so its traits and their
callbacks were dropped: a dynamically added dropdown reloaded as a bare field, a button reloaded
inert. Trait state on a declared parameter survived only because
Parameter.ui_optionswas savedmerged, which is why
OptionsandMultiOptionsmirrored their choices into the parent'sui_options.Closes #5440.
A trait's fields are its saved contract
BaseNodeElementis an attrs class and its metaclass makes every elementone, so a trait declares fields and gets a constructor. Those fields are what a save writes.
The declaration is checked when the class is built, so a mistake costs a library its import
rather than an artist's saved work:
Breaking for library authors: element fields are keyword-only, so
Slider(0, 100)becomesSlider(min_val=0, max_val=100), and elements compare by identity.MIGRATION.mdcovers both.A trait owns the
ui_optionskeys it rendersThe
ui_optionsgetter overlays what traits render on top of stored options,authored_ui_options()is the stored and saved view, andupdate_ui_optionsreads that ratherthan the merged getter.
Narrowing a
Slidermoved its validator but not the slider the artist sees, so the UI offeredvalues the engine rejected. Every
hide,hide_label,display_name,markdown,collapsed,and
orientationwrite went through that path.A write arriving from the editor or a saved file is routed to the trait that owns the key, since
neither writer knows which keys a trait owns:
A trait declares what it accepts back with
state_from_ui_options, the inverse ofui_options_for_trait;Options,MultiOptions, andSliderare the three of thirteen thatneed it. A write to a rendered key no trait accepts is logged, since it is neither applied nor
saved.
Serialize trait identity and state
to_state/from_state/apply_state, carried onAddParameterToNodeRequest.traitsand on thetraitsfieldAlterParameterDetailsRequesthas always declared but never applied.Restoring goes through the real constructor, so whatever invariants it enforces still hold, and it
updates the trait the node's
__init__already built instead of replacing it, so a button'son_clicksurvives and a field absent from an older file keeps its default. A saved trait ispaired with its class by module and name, so two libraries can ship a trait of the same name; a
library's process-local module name is saved as its stable namespace.
migrate_stateruns once perload, so an override may rename or convert unconditionally.
Saving writes only the options authored on the parameter, which lets both
choicesworkarounds andtheir
CRITICAL:comment blocks go.Carry callbacks by method name
A callback is recorded as the name of a method on the owning node and resolved with a
getattronthat node at load, so the restored callback binds to the node doing the loading rather than the one
that was saved.
A lambda has no name to resolve, so it is refused rather than guessed at, and reported at save
time. Each list is all-or-nothing: restoring a subset of a converter chain would run a different
pipeline than the one that was saved while appearing to work.
Degrade instead of failing
An unresolvable trait name, saved state missing a required constructor argument, and a trait that
cannot account for one of its own arguments are each logged and skipped. All three are
library-authoring mistakes an artist cannot fix, so losing one control beats failing the whole
load, or the whole save.
Workflows already on disk
A file that predates trait state carries a dropdown's choices in
ui_options, since that was theonly field a save wrote, and they are adopted onto the trait the node built. A parameter the node
created at run time has no trait to adopt them onto, so it is rebuilt from them:
{"simple_dropdown": [...]} # or enum_choices, its older spelling -> Options {"multi_options": {...}} # -> MultiOptions {"slider": {...}} # -> SliderThose are the only keys a save could round-trip. A missing
traitsfield marks a file as predatingtrait state; an empty one is a current save saying the parameter has no traits.
📚 Documentation preview 📚: https://griptape-nodes--5523.org.readthedocs.build/en/5523/