Skip to content

fix(traits): carry trait state and parameter callbacks through a save - #5523

Draft
collindutter wants to merge 3 commits into
mainfrom
callbacks
Draft

collindutter wants to merge 3 commits into
mainfrom
callbacks

Conversation

@collindutter

@collindutter collindutter commented Sep 9, 2026

Copy link
Copy Markdown
Member

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_options was saved
merged, which is why Options and MultiOptions mirrored their choices into the parent's
ui_options.

Closes #5440.

A trait's fields are its saved contract

BaseNodeElement is an attrs class and its metaclass makes every element
one, so a trait declares fields and gets a constructor. Those fields are what a save writes.

class Threshold(Trait):
    level: int = attrs.field(default=5, alias="threshold")                    # state, saved as data
    on_cross: Callable | None = attrs.field(default=None, metadata=BEHAVIOR)  # saved by method name
    rendered: str = attrs.field(default="", init=False)                       # neither, not saved

The declaration is checked when the class is built, so a mistake costs a library its import
rather than an artist's saved work:

class Broken(Trait):
    root: Path | None = attrs.field(default=None)        # TypeError: no saved form
    on_ping: Callable | None = attrs.field(default=None)  # TypeError: declare metadata=BEHAVIOR
    level: int = 5                                        # TypeError: an annotation is not a field

Breaking for library authors: element fields are keyword-only, so Slider(0, 100) becomes
Slider(min_val=0, max_val=100), and elements compare by identity. MIGRATION.md covers both.

A trait owns the ui_options keys it renders

The ui_options getter overlays what traits render on top of stored options,
authored_ui_options() is the stored and saved view, and update_ui_options reads that rather
than the merged getter.

# before: parameter.hide = True also stored every trait's rendered options
ui_options = self.ui_options  # merged: trait options + stored options
ui_options[key] = value
self.ui_options = ui_options  # stored: now holds the trait's options too

Narrowing a Slider moved its validator but not the slider the artist sees, so the UI offered
values the engine rejected. Every hide, hide_label, display_name, markdown, collapsed,
and orientation write 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:

parameter.update_ui_options({"slider": {"max_val": 8}})  # routed to the Slider
trait.max = 8  # same result, said directly

A trait declares what it accepts back with state_from_ui_options, the inverse of
ui_options_for_trait; Options, MultiOptions, and Slider are the three of thirteen that
need 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 on AddParameterToNodeRequest.traits and on the
traits field AlterParameterDetailsRequest has always declared but never applied.

Slider(min_val=0, max_val=8).to_state()  # {"min_val": 0, "max_val": 8}

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's
on_click survives and a field absent from an older file keeps its default. A saved trait is
paired 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_state runs once per
load, so an override may rename or convert unconditionally.

Saving writes only the options authored on the parameter, which lets both choices workarounds and
their 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 getattr on
that node at load, so the restored callback binds to the node doing the loading rather than the one
that was saved.

Button(label="Run", on_click=self.run)
# saved as: trait_callbacks={"on_click": "run"}

Parameter(name="tag", tooltip="t", converters=[self.strip_padding])
# saved as: value_callbacks={"converters": ["strip_padding"]}

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 the
only 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": {...}}           # -> Slider

Those are the only keys a save could round-trip. A missing traits field marks a file as predating
trait 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/

Comment thread tests/unit/traits/test_trait_state_round_trip.py Fixed
Comment thread tests/unit/traits/test_trait_state_round_trip.py Fixed
Comment thread tests/unit/retained_mode/managers/test_trait_state_serialization.py Fixed
Comment thread tests/unit/retained_mode/managers/test_trait_state_serialization.py Fixed
@collindutter collindutter changed the title fix(traits): stop trait UI options from becoming stored parameter state fix(traits): carry trait state and parameter callbacks through a save Sep 9, 2026
Comment thread tests/unit/traits/test_trait_state_round_trip.py Fixed
Comment thread tests/unit/retained_mode/managers/test_trait_state_pairing.py Fixed
Comment thread tests/unit/traits/test_trait_state_round_trip.py Fixed
Comment thread tests/unit/traits/test_button_callback_binding.py Fixed
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):
Comment thread tests/unit/traits/test_trait_field_declarations.py Fixed
"""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):
Comment thread src/griptape_nodes/exe_types/elements/trait.py Fixed
Comment thread src/griptape_nodes/exe_types/elements/containers.py Fixed
Comment thread src/griptape_nodes/exe_types/elements/base.py Fixed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Options.choices sync to Parameter.ui_options is a no-op against a computed property

1 participant