diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index a0ee290a..fd418750 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -14,8 +14,8 @@ from pyiron_snippets.singleton import Singleton -from pyiron_workflow.mixin.has_interface_mixins import HasChannel, HasLabel, UsesState -from pyiron_workflow.mixin.has_to_dict import HasToDict +from pyiron_workflow.mixin.has_interface_mixins import HasChannel, HasLabel +from pyiron_workflow.mixin.has_to_dict import HasStateDisplay from pyiron_workflow.type_hinting import ( valid_value, type_hint_is_as_or_more_specific_than, @@ -29,7 +29,7 @@ class ChannelConnectionError(Exception): pass -class Channel(UsesState, HasChannel, HasLabel, HasToDict, ABC): +class Channel(HasChannel, HasLabel, HasStateDisplay, ABC): """ Channels facilitate the flow of information (data or control signals) into and out of :class:`HasIO` objects (namely nodes). @@ -220,13 +220,6 @@ def copy_connections(self, other: Channel) -> None: self.disconnect(*new_connections) raise e - def to_dict(self) -> dict: - return { - "label": self.label, - "connected": self.connected, - "connections": [f"{c.owner.label}.{c.label}" for c in self.connections], - } - def __getstate__(self): state = super().__getstate__() state["connections"] = [] @@ -235,6 +228,12 @@ def __getstate__(self): # bloat the data being sent cross-process if the owner is shipped off return state + def display_state(self, state=None, ignore_private=True): + state = dict(self.__getstate__()) if state is None else state + state["owner"] = state["owner"].full_label # JSON can't handle recursion + state["connections"] = [c.full_label for c in self.connections] + return super().display_state(state=state, ignore_private=ignore_private) + class NotData(metaclass=Singleton): """ @@ -468,13 +467,6 @@ def _figure_out_who_is_who(self, other: DataChannel) -> (OutputData, InputData): def __str__(self): return str(self.value) - def to_dict(self) -> dict: - d = super().to_dict() - d["value"] = repr(self.value) - d["ready"] = self.ready - d["type_hint"] = str(self.type_hint) - return d - def activate_strict_hints(self) -> None: self.strict_hints = True @@ -488,6 +480,11 @@ def __getstate__(self): # owning macro's responsibility return state + def display_state(self, state=None, ignore_private=True): + state = dict(self.__getstate__()) if state is None else state + self._make_entry_public(state, "_value", "value") + return super().display_state(state=state, ignore_private=ignore_private) + class InputData(DataChannel): @property @@ -623,11 +620,6 @@ def __call__(self, other: typing.Optional[OutputSignal] = None) -> None: def __str__(self): return f"{self.label} runs {self.callback.__name__}" - def to_dict(self) -> dict: - d = super().to_dict() - d["callback"] = self.callback.__name__ - return d - def _connect_output_signal(self, signal: OutputSignal): self.connect(signal) diff --git a/pyiron_workflow/io.py b/pyiron_workflow/io.py index 8ac0e437..c3ca7d08 100644 --- a/pyiron_workflow/io.py +++ b/pyiron_workflow/io.py @@ -28,12 +28,11 @@ HasChannel, HasLabel, HasRun, - UsesState, ) -from pyiron_workflow.mixin.has_to_dict import HasToDict +from pyiron_workflow.mixin.has_to_dict import HasStateDisplay -class IO(HasToDict, ABC): +class IO(HasStateDisplay, ABC): """ IO is a convenience layer for holding and accessing multiple input/output channels. It allows key and dot-based access to the underlying channels. @@ -156,14 +155,6 @@ def __dir__(self): def __str__(self): return f"{self.__class__.__name__} {self.labels}" - def to_dict(self): - return { - "label": self.__class__.__name__, - "connected": self.connected, - "fully_connected": self.fully_connected, - "channels": {l: c.to_dict() for l, c in self.channel_dict.items()}, - } - def __getstate__(self): # Compatibility with python <3.11 return dict(self.__dict__) @@ -173,6 +164,13 @@ def __setstate__(self, state): # __setstate__ the same way we need it in __init__ self.__dict__["channel_dict"] = state["channel_dict"] + def display_state(self, state=None, ignore_private=True): + state = dict(self.__getstate__()) if state is None else state + for k, v in state["channel_dict"].items(): + state[k] = v + del state["channel_dict"] + return super().display_state(state=state, ignore_private=ignore_private) + class DataIO(IO, ABC): def _assign_a_non_channel_value(self, channel: DataChannel, value) -> None: @@ -189,11 +187,6 @@ def to_list(self): def ready(self): return all([c.ready for c in self]) - def to_dict(self): - d = super().to_dict() - d["ready"] = self.ready - return d - def activate_strict_hints(self): [c.activate_strict_hints() for c in self] @@ -251,7 +244,7 @@ def _channel_class(self) -> type(OutputSignal): return OutputSignal -class Signals: +class Signals(HasStateDisplay): """ A meta-container for input and output signal IO containers. @@ -285,17 +278,11 @@ def connected(self): def fully_connected(self): return self.input.fully_connected and self.output.fully_connected - def to_dict(self): - return { - "input": self.input.to_dict(), - "output": self.output.to_dict(), - } - def __str__(self): return f"{str(self.input)}\n{str(self.output)}" -class HasIO(UsesState, HasLabel, HasRun, ABC): +class HasIO(HasStateDisplay, HasLabel, HasRun, ABC): """ A mixin for classes that provide data and signal IO. diff --git a/pyiron_workflow/mixin/has_to_dict.py b/pyiron_workflow/mixin/has_to_dict.py index a78bc427..60c195ad 100644 --- a/pyiron_workflow/mixin/has_to_dict.py +++ b/pyiron_workflow/mixin/has_to_dict.py @@ -1,17 +1,76 @@ -from abc import ABC, abstractmethod +from abc import ABC from json import dumps +from pyiron_workflow.mixin.has_interface_mixins import UsesState -class HasToDict(ABC): - @abstractmethod - def to_dict(self): - pass - def _repr_json_(self): - return self.to_dict() +class HasStateDisplay(UsesState, ABC): + """ + A mixin that leverages :meth:`__getstate__` to automatically build a half-decent + JSON-compatible representation dictionary. + + Child classes can over-ride :meth:`display_state` to add or remove elements from + the display dictionary, e.g. to (optionally) expose state elements that would + otherwise be private or to show properties that are computed and not stored in + state, or (mandatory -- JSON demands it) remove recursion from the state. + + Provides a :meth:`_repr_json_` method leveraging this beautified state dictionary + to give a standard JSON representation in Jupyter notebooks. + """ + + def display_state( + self, state: dict | None = None, ignore_private: bool = True + ) -> dict: + """ + A dictionary of JSON-compatible objects based on the object state (plus + whatever modifications to the state the class designer has chosen to make). + + Anything that fails to dump to JSON gets cast as a string and then dumped. + + Args: + state (dict|None): The starting state. Default is None which uses + `__getstate__`, but available in case child classes want to first + sanitize the state values. + ignore_private (bool): Whether to ignore or include any state element + whose key starts with `'_'`. Default is True, only show public data. - def info(self): - print(dumps(self.to_dict(), indent=2)) + Returns: + dict: + """ + display = dict(self.__getstate__()) if state is None else state + to_del = [] + for k, v in display.items(): + if ignore_private and k.startswith("_"): + to_del.append(k) + + if isinstance(v, HasStateDisplay): + display[k] = v.display_state(ignore_private=ignore_private) + else: + try: + display[k] = dumps(v) + except TypeError: + display[k] = dumps(str(v)) + + for k in to_del: + del display[k] + + return display + + def _repr_json_(self): + return self.display_state() - def __str__(self): - return str(self.to_dict()) + @staticmethod + def _make_entry_public(state: dict, private_key: str, public_key: str): + if private_key not in state.keys(): + raise ValueError( + f"Can't make {private_key} public, it was not found among " + f"{list(state.keys())}" + ) + if public_key in state.keys(): + raise ValueError( + f"Can't make {private_key} public, {public_key} is already a key in" + f" the dict!" + ) + state[public_key] = state[private_key] + del state[private_key] + return state diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 2168d711..8053ee39 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -17,7 +17,6 @@ from pyiron_workflow.draw import Node as GraphvizNode from pyiron_workflow.logging import logger -from pyiron_workflow.mixin.has_to_dict import HasToDict from pyiron_workflow.mixin.injection import HasIOWithInjection from pyiron_workflow.mixin.run import Runnable, ReadinessError from pyiron_workflow.mixin.semantics import Semantic @@ -38,10 +37,9 @@ class Node( - HasToDict, + HasIOWithInjection, Semantic, Runnable, - HasIOWithInjection, ExploitsSingleOutput, ABC, ): @@ -937,3 +935,11 @@ def report_import_readiness(self, tabs=0, report_so_far=""): report_so_far + f"{newline}{tabspace}{self.label}: " f"{'ok' if self.import_ready else 'NOT IMPORTABLE'}" ) + + def display_state(self, state=None, ignore_private=True): + state = dict(self.__getstate__()) if state is None else state + if self.parent is not None: + state["parent"] = self.parent.full_label + if len(state["_user_data"]) > 0: + self._make_entry_public(state, "_user_data", "user_data") + return super().display_state(state=state, ignore_private=ignore_private) diff --git a/pyiron_workflow/nodes/composite.py b/pyiron_workflow/nodes/composite.py index 9c45d397..cc048790 100644 --- a/pyiron_workflow/nodes/composite.py +++ b/pyiron_workflow/nodes/composite.py @@ -137,12 +137,6 @@ def deactivate_strict_hints(self): for node in self: node.deactivate_strict_hints() - def to_dict(self): - return { - "label": self.label, - "nodes": {n.label: n.to_dict() for n in self.children.values()}, - } - def on_run(self): # Reset provenance and run status trackers self.provenance_by_execution = [] diff --git a/pyiron_workflow/nodes/function.py b/pyiron_workflow/nodes/function.py index 96d9ac57..fafc3612 100644 --- a/pyiron_workflow/nodes/function.py +++ b/pyiron_workflow/nodes/function.py @@ -337,17 +337,6 @@ def _outputs_to_run_return(self): output = output[0] return output - def to_dict(self): - return { - "label": self.label, - "ready": self.ready, - "connected": self.connected, - "fully_connected": self.fully_connected, - "inputs": self.inputs.to_dict(), - "outputs": self.outputs.to_dict(), - "signals": self.signals.to_dict(), - } - @property def color(self) -> str: """For drawing the graph""" diff --git a/pyiron_workflow/nodes/static_io.py b/pyiron_workflow/nodes/static_io.py index d4e97d85..a2b592b7 100644 --- a/pyiron_workflow/nodes/static_io.py +++ b/pyiron_workflow/nodes/static_io.py @@ -119,3 +119,10 @@ def _guarantee_names_are_input_channels(self, presumed_input_keys: tuple[str]): f"{self.full_label} cannot iterate on {non_input_kwargs} because " f"they are not among input channels {self.inputs.labels}" ) + + def display_state(self, state=None, ignore_private=True): + state = dict(self.__getstate__()) if state is None else state + self._make_entry_public(state, "_inputs", "inputs") + self._make_entry_public(state, "_outputs", "outputs") + self._make_entry_public(state, "_signals", "signals") + return super().display_state(state=state, ignore_private=ignore_private) diff --git a/pyiron_workflow/nodes/transform.py b/pyiron_workflow/nodes/transform.py index ecd929b1..c1342aaf 100644 --- a/pyiron_workflow/nodes/transform.py +++ b/pyiron_workflow/nodes/transform.py @@ -23,9 +23,6 @@ class Transformer(StaticNode, ABC): into a single output or vice-versa. """ - def to_dict(self): - pass # Vestigial abstract method - class FromManyInputs(Transformer, ABC): _output_name: ClassVar[str] # Mandatory attribute for non-abstract subclasses