From ee0a1b7f3e2399ae18c80a2d25eef1c42de61e00 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 26 Mar 2024 14:29:09 -0700 Subject: [PATCH 1/9] Make many AbstractFunction methods classmethods --- pyiron_workflow/function.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index f664af11..c02f83e2 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -363,7 +363,8 @@ def _type_hints(cls) -> dict: """The result of :func:`typing.get_type_hints` on the :meth:`node_function`.""" return get_type_hints(cls.node_function) - def _get_output_labels(self, output_labels: str | list[str] | tuple[str] | None): + @classmethod + def _get_output_labels(cls, output_labels: str | list[str] | tuple[str] | None): """ If output labels are provided, turn convert them to a list if passed as a string and return them, else scrape them from the source channel. @@ -372,13 +373,14 @@ def _get_output_labels(self, output_labels: str | list[str] | tuple[str] | None) responsibility that these are correct, e.g. in terms of quantity, order, etc. """ if output_labels is None: - return self._scrape_output_labels() + return cls._scrape_output_labels() elif isinstance(output_labels, str): return [output_labels] else: return output_labels - def _scrape_output_labels(self): + @classmethod + def _scrape_output_labels(cls): """ Inspect the source code to scrape out strings representing the returned values. _Only_ works for functions with a single `return` expression in their body. @@ -387,12 +389,12 @@ def _scrape_output_labels(self): create well-named variables and return those so that the output labels stay dot-accessible. """ - parsed_outputs = ParseOutput(self.node_function).output + parsed_outputs = ParseOutput(cls.node_function).output return [None] if parsed_outputs is None else parsed_outputs - @property - def _input_args(self): - return inspect.signature(self.node_function).parameters + @classmethod + def _input_args(cls): + return inspect.signature(cls.node_function).parameters @property def inputs(self) -> Inputs: @@ -409,8 +411,7 @@ def outputs(self) -> Outputs: def _build_input_channels(self): channels = [] type_hints = self._type_hints() - - for ii, (label, value) in enumerate(self._input_args.items()): + for ii, (label, value) in enumerate(self._input_args().items()): is_self = False if label == "self": # `self` is reserved for the node object if ii == 0: @@ -422,12 +423,12 @@ def _build_input_channels(self): " argument. If it is to be treated as the node object," " use it as a first argument" ) - if label in self._init_keywords: + elif label in self._init_keywords(): # We allow users to parse arbitrary kwargs as channel initialization # So don't let them choose bad channel names raise ValueError( f"The Input channel name {label} is not valid. Please choose a " - f"name _not_ among {self._init_keywords}" + f"name _not_ among {self._init_keywords()}" ) try: @@ -455,9 +456,9 @@ def _build_input_channels(self): ) return channels - @property - def _init_keywords(self): - return list(inspect.signature(self.__init__).parameters.keys()) + @classmethod + def _init_keywords(cls): + return list(inspect.signature(cls.__init__).parameters.keys()) def _build_output_channels(self, *return_labels: str): try: @@ -501,7 +502,7 @@ def on_run(self): @property def run_args(self) -> dict: kwargs = self.inputs.to_value_dict() - if "self" in self._input_args: + if "self" in self._input_args(): if self.executor: raise ValueError( f"Function node {self.label} uses the `self` argument, but this " @@ -522,7 +523,7 @@ def process_run_result(self, function_output: Any | tuple) -> Any | tuple: return function_output def _convert_input_args_and_kwargs_to_input_kwargs(self, *args, **kwargs): - reverse_keys = list(self._input_args.keys())[::-1] + reverse_keys = list(self._input_args().keys())[::-1] if len(args) > len(reverse_keys): raise ValueError( f"Received {len(args)} positional arguments, but the node {self.label}" From 8956924269927cf4ea789fb09a95eb879eaafa80 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 27 Mar 2024 10:23:23 -0700 Subject: [PATCH 2/9] Make output channel info available as a Function class attribute So channel labels and type hints (and later any ontological hints) can be viewed without ever instantiating a node. That means `output_labels` is no longer available at the class instance level, but only when defining a new class! --- pyiron_workflow/function.py | 118 +++++++++++++---------- pyiron_workflow/meta.py | 42 ++++---- pyiron_workflow/node_library/standard.py | 5 +- tests/integration/test_workflow.py | 8 +- tests/unit/test_function.py | 54 +++++++++++ 5 files changed, 144 insertions(+), 83 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index c02f83e2..556e8d90 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -325,6 +325,7 @@ class AbstractFunction(Node, ABC): raise an error when combined with an executor, and otherwise behaviour is not guaranteed. """ + _provided_output_labels: tuple[str] | None = None def __init__( self, @@ -335,7 +336,6 @@ def __init__( run_after_init: bool = False, storage_backend: Optional[Literal["h5io", "tinybase"]] = None, save_after_run: bool = False, - output_labels: Optional[str | list[str] | tuple[str]] = None, **kwargs, ): super().__init__( @@ -348,7 +348,7 @@ def __init__( self._inputs = None self._outputs = None - self._output_labels = self._get_output_labels(output_labels) + self._output_labels = self._get_output_labels() # TODO: Parse output labels from the node function in case output_labels is None self.set_input_values(*args, **kwargs) @@ -364,34 +364,65 @@ def _type_hints(cls) -> dict: return get_type_hints(cls.node_function) @classmethod - def _get_output_labels(cls, output_labels: str | list[str] | tuple[str] | None): + def _get_output_labels(cls): """ - If output labels are provided, turn convert them to a list if passed as a - string and return them, else scrape them from the source channel. + Return output labels provided on the class if not None, else scrape them from + :meth:`node_function`. Note: When the user explicitly provides output channels, they are taking responsibility that these are correct, e.g. in terms of quantity, order, etc. """ - if output_labels is None: + if cls._provided_output_labels is None: return cls._scrape_output_labels() - elif isinstance(output_labels, str): - return [output_labels] else: - return output_labels + return cls._provided_output_labels @classmethod def _scrape_output_labels(cls): """ - Inspect the source code to scrape out strings representing the returned values. - _Only_ works for functions with a single `return` expression in their body. + Inspect :meth:`node_function` to scrape out strings representing the + returned values. + + _Only_ works for functions with a single `return` expression in their body. - Will return expressions and function calls just fine, thus best practice is to - create well-named variables and return those so that the output labels stay + It will return expressions and function calls just fine, thus good practice is + to create well-named variables and return those so that the output labels stay dot-accessible. """ parsed_outputs = ParseOutput(cls.node_function).output return [None] if parsed_outputs is None else parsed_outputs + @classmethod + def preview_output_channels(cls) -> dict[str, Any]: + """ + Get a dictionary of output channel labels and their corresponding type hints. + """ + labels = cls._get_output_labels() + try: + type_hints = cls._type_hints()["return"] + if len(labels) > 1: + type_hints = get_args(type_hints) + if not isinstance(type_hints, tuple): + raise TypeError( + f"With multiple return labels expected to get a tuple of type " + f"hints, but got type {type(type_hints)}" + ) + if len(type_hints) != len(labels): + raise ValueError( + f"Expected type hints and return labels to have matching " + f"lengths, but got {len(type_hints)} hints and " + f"{len(labels)} labels: {type_hints}, {labels}" + ) + else: + # If there's only one hint, wrap it in a tuple, so we can zip it with + # *return_labels and iterate over both at once + type_hints = (type_hints,) + except KeyError: # If there are no return hints + type_hints = [None] * len(labels) + # Note that this nicely differs from `NoneType`, which is the hint when + # `None` is actually the hint! + return {label: hint for label, hint in zip(labels, type_hints)} + @classmethod def _input_args(cls): return inspect.signature(cls.node_function).parameters @@ -405,7 +436,7 @@ def inputs(self) -> Inputs: @property def outputs(self) -> Outputs: if self._outputs is None: - self._outputs = Outputs(*self._build_output_channels(*self._output_labels)) + self._outputs = Outputs(*self._build_output_channels()) return self._outputs def _build_input_channels(self): @@ -460,40 +491,15 @@ def _build_input_channels(self): def _init_keywords(cls): return list(inspect.signature(cls.__init__).parameters.keys()) - def _build_output_channels(self, *return_labels: str): - try: - type_hints = self._type_hints()["return"] - if len(return_labels) > 1: - type_hints = get_args(type_hints) - if not isinstance(type_hints, tuple): - raise TypeError( - f"With multiple return labels expected to get a tuple of type " - f"hints, but got type {type(type_hints)}" - ) - if len(type_hints) != len(return_labels): - raise ValueError( - f"Expected type hints and return labels to have matching " - f"lengths, but got {len(type_hints)} hints and " - f"{len(return_labels)} labels: {type_hints}, {return_labels}" - ) - else: - # If there's only one hint, wrap it in a tuple so we can zip it with - # *return_labels and iterate over both at once - type_hints = (type_hints,) - except KeyError: - type_hints = [None] * len(return_labels) - - channels = [] - for label, hint in zip(return_labels, type_hints): - channels.append( - OutputDataWithInjection( - label=label, - owner=self, - type_hint=hint, - ) + def _build_output_channels(self): + return [ + OutputDataWithInjection( + label=label, + owner=self, + type_hint=hint, ) - - return channels + for label, hint in self.preview_output_channels().items() + ] @property def on_run(self): @@ -604,7 +610,7 @@ def __new__( run_after_init: bool = False, storage_backend: Optional[Literal["h5io", "tinybase"]] = None, save_after_run: bool = False, - output_labels: Optional[str | list[str] | tuple[str]] = None, + output_labels: Optional[str | tuple[str]] = None, **kwargs, ): if not callable(node_function): @@ -656,17 +662,23 @@ def function_node(*output_labels: str): # also slap them on as a class-level attribute. These get safely packed and returned # when (de)pickling so we can keep processing type hints without trouble. def as_node(node_function: callable): - return type( + node_class = type( node_function.__name__, (AbstractFunction,), # Define parentage { - "__init__": partialmethod( - AbstractFunction.__init__, - output_labels=output_labels, - ), "node_function": staticmethod(node_function), + "_provided_output_labels": output_labels, "__module__": node_function.__module__, }, ) + try: + node_class.preview_output_channels() + except ValueError as e: + raise ValueError( + f"Failed to create a new {AbstractFunction.__name__} child class " + f"dynamically from {node_function.__name__} -- probably due to a " + f"mismatch among output labels, returned values, and return type hints." + ) from e + return node_class return as_node diff --git a/pyiron_workflow/meta.py b/pyiron_workflow/meta.py index 8e21a2e8..5512e580 100644 --- a/pyiron_workflow/meta.py +++ b/pyiron_workflow/meta.py @@ -22,14 +22,16 @@ def list_to_output(length: int, **node_class_kwargs) -> type[Function]: def _list_to_many(length: int): template = f""" -def __list_to_many(l: list): - {"; ".join([f"out{i} = l[{i}]" for i in range(length)])} - return [{", ".join([f"out{i}" for i in range(length)])}] +def __list_to_many(input_list: list): + {"; ".join([f"out{i} = input_list[{i}]" for i in range(length)])} + return {", ".join([f"out{i}" for i in range(length)])} """ exec(template) return locals()["__list_to_many"] - return function_node(**node_class_kwargs)(_list_to_many(length=length)) + return function_node(*(f"output{n}" for n in range(length)))( + _list_to_many(length=length), **node_class_kwargs + ) def input_to_list(length: int, **node_class_kwargs) -> type[Function]: @@ -46,7 +48,9 @@ def __many_to_list({", ".join([f"inp{i}=None" for i in range(length)])}): exec(template) return locals()["__many_to_list"] - return function_node(**node_class_kwargs)(_many_to_list(length=length)) + return function_node("output_list")( + _many_to_list(length=length), **node_class_kwargs + ) def for_loop( @@ -127,50 +131,42 @@ def make_loop(macro): interface = list_to_output(length)( parent=macro, label=label.upper(), - output_labels=[ - f"{loop_body_class.__name__}__{inp.label}_{i}" - for i in range(length) - ], - l=[inp.default] * length, + input_list=[inp.default] * length, ) - # Connect each body node input to the input interface's respective output + # Connect each body node input to the input interface's respective + # output for body_node, out in zip(body_nodes, interface.outputs): body_node.inputs[label] = out - macro.inputs_map[f"{interface.label}__l"] = interface.label - # TODO: Don't hardcode __l - # Or distribute the same input to each node equally + macro.inputs_map[ + interface.inputs.input_list.scoped_label + ] = interface.label + # Or broadcast the same input to each node equally else: interface = macro.create.standard.UserInput( label=label, - output_labels=label, user_input=inp.default, parent=macro, ) for body_node in body_nodes: body_node.inputs[label] = interface - macro.inputs_map[f"{interface.label}__user_input"] = interface.label - # TODO: Don't hardcode __user_input + macro.inputs_map[interface.scoped_label] = interface.label # Make output interface: outputs to lists for label, out in body_nodes[0].outputs.items(): interface = input_to_list(length)( parent=macro, label=label.upper(), - output_labels=f"{loop_body_class.__name__}__{label}", ) # Connect each body node output to the output interface's respective input for body_node, inp in zip(body_nodes, interface.inputs): inp.connect(body_node.outputs[label]) - if body_node.executor: + if body_node.executor is not None: raise NotImplementedError( "Right now the output interface gets run after each body node," "if the body nodes can run asynchronously we need something " "more clever than that!" ) - macro.outputs_map[ - f"{interface.label}__{loop_body_class.__name__}__{label}" - ] = interface.label - # TODO: Don't manually copy the output label construction + macro.outputs_map[interface.scoped_label] = interface.label return macro_node()(make_loop) diff --git a/pyiron_workflow/node_library/standard.py b/pyiron_workflow/node_library/standard.py index abd9ddff..32185299 100644 --- a/pyiron_workflow/node_library/standard.py +++ b/pyiron_workflow/node_library/standard.py @@ -22,7 +22,7 @@ class If(AbstractFunction): """ def __init__(self, **kwargs): - super().__init__(output_labels="truth", **kwargs) + super().__init__(**kwargs) self.signals.output.true = OutputSignal("true", self) self.signals.output.false = OutputSignal("false", self) @@ -32,7 +32,8 @@ def node_function(condition): raise TypeError( f"Logic 'If' node expected data other but got NOT_DATA as input." ) - return bool(condition) + truth = bool(condition) + return truth def process_run_result(self, function_output): """ diff --git a/tests/integration/test_workflow.py b/tests/integration/test_workflow.py index 28619abe..12fcc631 100644 --- a/tests/integration/test_workflow.py +++ b/tests/integration/test_workflow.py @@ -32,16 +32,14 @@ class GreaterThanLimitSwitch(AbstractFunction): """ def __init__(self, **kwargs): - super().__init__( - output_labels="value_gt_limit", - **kwargs - ) + super().__init__(**kwargs) self.signals.output.true = OutputSignal("true", self) self.signals.output.false = OutputSignal("false", self) @staticmethod def node_function(value, limit=10): - return value > limit + value_gt_limit = value > limit + return value_gt_limit def process_run_result(self, function_output): """ diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 645cfd6e..c1fb705a 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -159,6 +159,60 @@ def bilinear(x, y): "use at the class level" ) + def test_preview_output_channels(self): + @function_node() + def Foo(x): + return x + + self.assertDictEqual( + {"x": None}, + Foo.preview_output_channels(), + msg="Should parse without label or hint." + ) + + @function_node("y") + def Foo(x) -> None: + return x + + self.assertDictEqual( + {"y": type(None)}, + Foo.preview_output_channels(), + msg="Should parse with label and hint." + ) + + with self.assertRaises( + ValueError, + msg="Should fail when scraping incommensurate hints and returns" + ): + @function_node() + def Foo(x) -> int: + y, z = 5.0, 5 + return x, y, z + + with self.assertRaises( + ValueError, + msg="Should fail when provided labels are incommensurate with hints" + ): + @function_node("xo", "yo", "zo") + def Foo(x) -> int: + y, z = 5.0, 5 + return x, y, z + + @function_node("xo", "yo") + def Foo(x) -> tuple[int, float]: + y, z = 5.0, 5 + return x + + self.assertDictEqual( + {"xo": int, "yo": float}, + Foo.preview_output_channels(), + msg="The user carries extra responsibility if they specify return values " + "-- we don't even try scraping the returned stuff and it's up to them " + "to make sure everything is commensurate! This is necessary so that " + "source code scraping can get bypassed sometimes (e.g. for dynamically " + "generated code that is only in memory and thus not inspectable)" + ) + def test_statuses(self): n = Function(plus_one) self.assertTrue(n.ready) From 3bb6d1ef3dc0bd81659aa5f88ae50565f211ade6 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 27 Mar 2024 11:14:05 -0700 Subject: [PATCH 3/9] Make input channel info available as a Function class attribute This was much easier because we never need to inspect the source code manually (as we do sometimes to check return values). --- pyiron_workflow/function.py | 35 ++++++++++++++++++++--------------- tests/unit/test_function.py | 12 ++++++++++++ 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 556e8d90..b0491868 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -439,10 +439,11 @@ def outputs(self) -> Outputs: self._outputs = Outputs(*self._build_output_channels()) return self._outputs - def _build_input_channels(self): - channels = [] - type_hints = self._type_hints() - for ii, (label, value) in enumerate(self._input_args().items()): + @classmethod + def preview_input_channels(cls) -> dict[str, tuple[Any, Any]]: + type_hints = cls._type_hints() + scraped: dict[str, tuple[Any, Any]] = {} + for ii, (label, value) in enumerate(cls._input_args().items()): is_self = False if label == "self": # `self` is reserved for the node object if ii == 0: @@ -454,12 +455,12 @@ def _build_input_channels(self): " argument. If it is to be treated as the node object," " use it as a first argument" ) - elif label in self._init_keywords(): + elif label in cls._init_keywords(): # We allow users to parse arbitrary kwargs as channel initialization # So don't let them choose bad channel names raise ValueError( f"The Input channel name {label} is not valid. Please choose a " - f"name _not_ among {self._init_keywords()}" + f"name _not_ among {cls._init_keywords()}" ) try: @@ -477,15 +478,19 @@ def _build_input_channels(self): default = value.default if not is_self: - channels.append( - InputData( - label=label, - owner=self, - default=default, - type_hint=type_hint, - ) - ) - return channels + scraped[label] = (type_hint, default) + return scraped + + def _build_input_channels(self): + return [ + InputData( + label=label, + owner=self, + default=default, + type_hint=type_hint, + ) + for label, (type_hint, default) in self.preview_input_channels().items() + ] @classmethod def _init_keywords(cls): diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index c1fb705a..73b9fa34 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -213,6 +213,18 @@ def Foo(x) -> tuple[int, float]: "generated code that is only in memory and thus not inspectable)" ) + def test_preview_input_channels(self): + @function_node() + def Foo(x, y: int = 42): + return x + y + + self.assertDictEqual( + {"x": (None, NOT_DATA), "y": (int, 42)}, + Foo.preview_input_channels(), + msg="Input specifications should be available at the class level, with or " + "without type hints and/or defaults provided." + ) + def test_statuses(self): n = Function(plus_one) self.assertTrue(n.ready) From 2ff196b0bbcde04677151c3ca225d71415130957 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 27 Mar 2024 11:47:20 -0700 Subject: [PATCH 4/9] Add/extend public method docstrings --- pyiron_workflow/function.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index b0491868..965931ae 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -395,7 +395,11 @@ def _scrape_output_labels(cls): @classmethod def preview_output_channels(cls) -> dict[str, Any]: """ - Get a dictionary of output channel labels and their corresponding type hints. + Gives a class-level peek at the expected output channels. + + Returns: + dict[str, tuple[Any, Any]]: The channel name and its corresponding type + hint. """ labels = cls._get_output_labels() try: @@ -441,6 +445,13 @@ def outputs(self) -> Outputs: @classmethod def preview_input_channels(cls) -> dict[str, tuple[Any, Any]]: + """ + Gives a class-level peek at the expected input channels. + + Returns: + dict[str, tuple[Any, Any]]: The channel name and a tuple of its + corresponding type hint and default value. + """ type_hints = cls._type_hints() scraped: dict[str, tuple[Any, Any]] = {} for ii, (label, value) in enumerate(cls._input_args().items()): From 2e9e2f626a8e5914ba36f3f2001828fe681d4c9b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 27 Mar 2024 11:49:55 -0700 Subject: [PATCH 5/9] Refactor: slide --- pyiron_workflow/function.py | 100 ++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 965931ae..1aebca12 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -363,35 +363,6 @@ def _type_hints(cls) -> dict: """The result of :func:`typing.get_type_hints` on the :meth:`node_function`.""" return get_type_hints(cls.node_function) - @classmethod - def _get_output_labels(cls): - """ - Return output labels provided on the class if not None, else scrape them from - :meth:`node_function`. - - Note: When the user explicitly provides output channels, they are taking - responsibility that these are correct, e.g. in terms of quantity, order, etc. - """ - if cls._provided_output_labels is None: - return cls._scrape_output_labels() - else: - return cls._provided_output_labels - - @classmethod - def _scrape_output_labels(cls): - """ - Inspect :meth:`node_function` to scrape out strings representing the - returned values. - - _Only_ works for functions with a single `return` expression in their body. - - It will return expressions and function calls just fine, thus good practice is - to create well-named variables and return those so that the output labels stay - dot-accessible. - """ - parsed_outputs = ParseOutput(cls.node_function).output - return [None] if parsed_outputs is None else parsed_outputs - @classmethod def preview_output_channels(cls) -> dict[str, Any]: """ @@ -428,14 +399,33 @@ def preview_output_channels(cls) -> dict[str, Any]: return {label: hint for label, hint in zip(labels, type_hints)} @classmethod - def _input_args(cls): - return inspect.signature(cls.node_function).parameters + def _get_output_labels(cls): + """ + Return output labels provided on the class if not None, else scrape them from + :meth:`node_function`. - @property - def inputs(self) -> Inputs: - if self._inputs is None: - self._inputs = Inputs(*self._build_input_channels()) - return self._inputs + Note: When the user explicitly provides output channels, they are taking + responsibility that these are correct, e.g. in terms of quantity, order, etc. + """ + if cls._provided_output_labels is None: + return cls._scrape_output_labels() + else: + return cls._provided_output_labels + + @classmethod + def _scrape_output_labels(cls): + """ + Inspect :meth:`node_function` to scrape out strings representing the + returned values. + + _Only_ works for functions with a single `return` expression in their body. + + It will return expressions and function calls just fine, thus good practice is + to create well-named variables and return those so that the output labels stay + dot-accessible. + """ + parsed_outputs = ParseOutput(cls.node_function).output + return [None] if parsed_outputs is None else parsed_outputs @property def outputs(self) -> Outputs: @@ -443,6 +433,16 @@ def outputs(self) -> Outputs: self._outputs = Outputs(*self._build_output_channels()) return self._outputs + def _build_output_channels(self): + return [ + OutputDataWithInjection( + label=label, + owner=self, + type_hint=hint, + ) + for label, hint in self.preview_output_channels().items() + ] + @classmethod def preview_input_channels(cls) -> dict[str, tuple[Any, Any]]: """ @@ -492,29 +492,29 @@ def preview_input_channels(cls) -> dict[str, tuple[Any, Any]]: scraped[label] = (type_hint, default) return scraped - def _build_input_channels(self): - return [ - InputData( - label=label, - owner=self, - default=default, - type_hint=type_hint, - ) - for label, (type_hint, default) in self.preview_input_channels().items() - ] + @classmethod + def _input_args(cls): + return inspect.signature(cls.node_function).parameters @classmethod def _init_keywords(cls): return list(inspect.signature(cls.__init__).parameters.keys()) - def _build_output_channels(self): + @property + def inputs(self) -> Inputs: + if self._inputs is None: + self._inputs = Inputs(*self._build_input_channels()) + return self._inputs + + def _build_input_channels(self): return [ - OutputDataWithInjection( + InputData( label=label, owner=self, - type_hint=hint, + default=default, + type_hint=type_hint, ) - for label, hint in self.preview_output_channels().items() + for label, (type_hint, default) in self.preview_input_channels().items() ] @property From 0dd34c657d97198e537fbfc9320b169dd97f2d7f Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 27 Mar 2024 11:51:21 -0700 Subject: [PATCH 6/9] Remove unused private attribute --- pyiron_workflow/function.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 1aebca12..ce8f4a87 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -348,8 +348,6 @@ def __init__( self._inputs = None self._outputs = None - self._output_labels = self._get_output_labels() - # TODO: Parse output labels from the node function in case output_labels is None self.set_input_values(*args, **kwargs) From fe829362ae94ab3720d14e69e9860b90881d1a52 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Wed, 27 Mar 2024 19:01:38 +0000 Subject: [PATCH 7/9] Format black --- pyiron_workflow/function.py | 1 + pyiron_workflow/meta.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index c0962c62..fef00bbe 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -307,6 +307,7 @@ class AbstractFunction(Node, ABC): raise an error when combined with an executor, and otherwise behaviour is not guaranteed. """ + _provided_output_labels: tuple[str] | None = None def __init__( diff --git a/pyiron_workflow/meta.py b/pyiron_workflow/meta.py index 5512e580..9fb324d3 100644 --- a/pyiron_workflow/meta.py +++ b/pyiron_workflow/meta.py @@ -137,9 +137,9 @@ def make_loop(macro): # output for body_node, out in zip(body_nodes, interface.outputs): body_node.inputs[label] = out - macro.inputs_map[ - interface.inputs.input_list.scoped_label - ] = interface.label + macro.inputs_map[interface.inputs.input_list.scoped_label] = ( + interface.label + ) # Or broadcast the same input to each node equally else: interface = macro.create.standard.UserInput( From a203b211fe76b5908de4ad92a0978c83cfca3a4b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 27 Mar 2024 15:28:25 -0700 Subject: [PATCH 8/9] Remove unused imports Just some housekeeping in the files we're touching anyhow --- pyiron_workflow/function.py | 4 +--- pyiron_workflow/node_library/standard.py | 2 -- tests/unit/test_function.py | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index fef00bbe..67533f2f 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -3,11 +3,9 @@ from abc import ABC, abstractmethod import inspect import warnings -from functools import partialmethod from typing import Any, get_args, get_type_hints, Literal, Optional, TYPE_CHECKING -from pyiron_workflow.channels import InputData, OutputData, NOT_DATA -from pyiron_workflow.has_interface_mixins import HasChannel +from pyiron_workflow.channels import InputData, NOT_DATA from pyiron_workflow.injection import OutputDataWithInjection from pyiron_workflow.io import Inputs, Outputs from pyiron_workflow.node import Node diff --git a/pyiron_workflow/node_library/standard.py b/pyiron_workflow/node_library/standard.py index 32185299..964ece37 100644 --- a/pyiron_workflow/node_library/standard.py +++ b/pyiron_workflow/node_library/standard.py @@ -4,8 +4,6 @@ from __future__ import annotations -from inspect import isclass - from pyiron_workflow.channels import NOT_DATA, OutputSignal from pyiron_workflow.function import AbstractFunction, function_node diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 73b9fa34..31f6ca8b 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -2,7 +2,7 @@ import unittest import warnings -from pyiron_workflow.channels import NOT_DATA, ChannelConnectionError +from pyiron_workflow.channels import NOT_DATA from pyiron_workflow.function import Function, function_node from pyiron_workflow.io import ConnectionCopyError, ValueCopyError from pyiron_workflow.create import Executor From a5b96e8a536f270a36160ed06775713bb527ba13 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 28 Mar 2024 13:29:16 -0700 Subject: [PATCH 9/9] Fix docstring --- pyiron_workflow/function.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 67533f2f..42c13885 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -140,7 +140,7 @@ class AbstractFunction(Node, ABC): variety of common use cases. Note that getting "good" (i.e. dot-accessible) output labels can be achieved by using good variable names and returning those variables instead of using - :attr:`output_labels`. + :param:`output_labels`. If we try to assign a value of the wrong type, it will raise an error: >>> from typing import Union @@ -194,11 +194,13 @@ class AbstractFunction(Node, ABC): both that you are likely to have particular nodes that get heavily re-used, and that you need the nodes to pass data to each other. - For reusable nodes, we want to create a sub-class of :class:`Function` that fixes some - of the node behaviour -- usually the :meth:`node_function` and :attr:`output_labels`. + For reusable nodes, we want to create a sub-class of :class:`AbstractFunction` + that fixes some of the node behaviour -- i.e. the :meth:`node_function`. - This can be done most easily with the :func:`function_node` decorator, which takes a function - and returns a node class: + This can be done most easily with the :func:`function_node` decorator, which + takes a function and returns a node class. It also allows us to provide labels + for the return values, :param:output_labels, which are otherwise scraped from + the text of the function definition: >>> from pyiron_workflow.function import function_node >>>