From 181c88462cc2d705c0c14e169c2706c062f0ed9c Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 22 Jan 2024 14:38:16 -0800 Subject: [PATCH 01/15] Accumulate run signals with scoped labels not objects --- pyiron_workflow/channels.py | 12 +++++++++--- tests/unit/test_channels.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index 6648eb54..dfb2da0b 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -807,7 +807,7 @@ def __init__( callback: callable, ): super().__init__(label=label, node=node, callback=callback) - self.received_signals: set[OutputSignal] = set() + self.received_signals: set[str] = set() def __call__(self, other: OutputSignal) -> None: """ @@ -816,8 +816,14 @@ def __call__(self, other: OutputSignal) -> None: Resets the collection of received signals when firing. """ - self.received_signals.update([other]) - if len(set(self.connections).difference(self.received_signals)) == 0: + self.received_signals.update([other.scoped_label]) + if len( + set( + c.scoped_label for c in self.connections + ).difference( + self.received_signals + ) + ) == 0: self.reset() self.callback() diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index b501875b..341badc8 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -381,7 +381,7 @@ def test_aggregating_call(self): ): agg() - out2 = OutputSignal(label="out", node=DummyNode()) + out2 = OutputSignal(label="out2", node=DummyNode()) agg.connect(self.out, out2) self.assertEqual( From ffae6398aaebcd84b2cb5a1e0e76f97d14b28c88 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 23 Jan 2024 20:45:36 -0800 Subject: [PATCH 02/15] Require the callback to always be a method of the owning node And clarify in the docstring that it must have no arguments --- pyiron_workflow/channels.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index 72d9b27d..abbe6e89 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -10,6 +10,7 @@ import typing from abc import ABC, abstractmethod +import inspect from warnings import warn from pyiron_workflow.has_channel import HasChannel @@ -740,6 +741,7 @@ class SignalChannel(Channel, ABC): """ Signal channels give the option control execution flow by triggering callback functions when the channel is called. + Callbacks must be methods on the parent node that require no positional arguments. Inputs optionally accept an output signal on call, which output signals always send when they call their input connections. @@ -755,6 +757,10 @@ def __call__(self) -> None: pass +class BadCallbackError(ValueError): + pass + + class InputSignal(SignalChannel): @property def connection_partner_type(self): @@ -777,7 +783,34 @@ def __init__( object. """ super().__init__(label=label, node=node) - self.callback: callable = callback + if self._is_node_method(callback) and self._takes_zero_arguments(callback): + self.callback: callable = callback + else: + raise BadCallbackError( + f"The channel {self.label} on {self.node.label} got an unexpected " + f"callback: {callback}. " + f"Lives on node: {self._is_node_method(callback)}; " + f"take no args: {self._takes_zero_arguments(callback)} " + ) + + def _is_node_method(self, callback): + try: + return callback == getattr(self.node, callback.__name__) + except AttributeError: + return False + + def _takes_zero_arguments(self, callback): + return callable(callback) and self._no_positional_args(callback) + + @staticmethod + def _no_positional_args(func): + return sum( + 1 for parameter in inspect.signature(func).parameters.values() + if ( + parameter.default == inspect.Parameter.empty + and parameter.kind != inspect._ParameterKind.VAR_KEYWORD + ) + ) == 0 def __call__(self, other: typing.Optional[OutputSignal] = None) -> None: self.callback() From 0e66e0300b26a0509b07be22f5da4b165eb5582f Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 23 Jan 2024 20:46:31 -0800 Subject: [PATCH 03/15] Just store the name of the callback instead of the object Since we've guaranteed it lives on the owning node, this is safe now --- pyiron_workflow/channels.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index abbe6e89..bcced171 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -784,7 +784,7 @@ def __init__( """ super().__init__(label=label, node=node) if self._is_node_method(callback) and self._takes_zero_arguments(callback): - self.callback: callable = callback + self._callback: str = callback.__name__ else: raise BadCallbackError( f"The channel {self.label} on {self.node.label} got an unexpected " @@ -812,6 +812,10 @@ def _no_positional_args(func): ) ) == 0 + @property + def callback(self) -> callable: + return getattr(self.node, self._callback) + def __call__(self, other: typing.Optional[OutputSignal] = None) -> None: self.callback() From 3fa241981fe6cec829559b1d0f22db37ffb42dff Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 23 Jan 2024 20:46:43 -0800 Subject: [PATCH 04/15] Add tests --- tests/unit/test_channels.py | 51 +++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index 341badc8..05472438 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -2,7 +2,7 @@ from pyiron_workflow.channels import ( Channel, InputData, OutputData, InputSignal, AccumulatingInputSignal, OutputSignal, - NotData, ChannelConnectionError + NotData, ChannelConnectionError, BadCallbackError ) @@ -15,7 +15,6 @@ def __init__(self): def update(self): self.foo.append(self.foo[-1] + 1) - class InputChannel(Channel): """Just to de-abstract the base class""" def __str__(self): @@ -451,6 +450,54 @@ def test_aggregating_call(self): msg="All signals, including vestigial ones, should get cleared on call" ) + def test_callbacks(self): + class Extended(DummyNode): + def method_with_args(self, x): + return x + 1 + + def method_with_only_kwargs(self, x=0): + return x + 1 + + @staticmethod + def staticmethod_without_args(): + return 42 + + @staticmethod + def staticmethod_with_args(x): + return x + 1 + + @classmethod + def classmethod_without_args(cls): + return 42 + + @classmethod + def classmethod_with_args(cls, x): + return x + 1 + + def doesnt_belong_to_node(): + return 42 + + node = Extended() + with self.subTest("Callbacks that belong to the node and take no arguments"): + for callback in [ + node.update, + node.method_with_only_kwargs, + node.staticmethod_without_args, + node.classmethod_without_args + ]: + with self.subTest(callback.__name__): + InputSignal(label="inp", node=node, callback=callback) + + with self.subTest("Invalid callbacks"): + for callback in [ + node.method_with_args, + node.staticmethod_with_args, + node.classmethod_with_args, + doesnt_belong_to_node, + ]: + with self.subTest(callback.__name__): + with self.assertRaises(BadCallbackError): + InputSignal(label="inp", node=node, callback=callback) if __name__ == '__main__': unittest.main() From b93f585dd127cfae1c9f439ffc1d05a3c8a1cc8f Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 23 Jan 2024 20:46:55 -0800 Subject: [PATCH 05/15] Fix tests where callback was not owned by the node --- tests/unit/test_io.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py index efae3620..8eb85e61 100644 --- a/tests/unit/test_io.py +++ b/tests/unit/test_io.py @@ -152,14 +152,18 @@ def test_to_list(self): class TestSignalIO(unittest.TestCase): def setUp(self) -> None: - node = DummyNode() + class Extended(DummyNode): + @staticmethod + def do_nothing(): + pass + + node = Extended() + - def do_nothing(): - pass signals = Signals() - signals.input.run = InputSignal("run", node, do_nothing) - signals.input.foo = InputSignal("foo", node, do_nothing) + signals.input.run = InputSignal("run", node, node.do_nothing) + signals.input.foo = InputSignal("foo", node, node.do_nothing) signals.output.ran = OutputSignal("ran", node) signals.output.bar = OutputSignal("bar", node) From c7975f20d1464f23f1ad331c3e74d41100fcc42e Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Wed, 24 Jan 2024 17:27:30 +0000 Subject: [PATCH 06/15] Format black --- pyiron_workflow/channels.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index bcced171..b2cde355 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -804,13 +804,17 @@ def _takes_zero_arguments(self, callback): @staticmethod def _no_positional_args(func): - return sum( - 1 for parameter in inspect.signature(func).parameters.values() - if ( - parameter.default == inspect.Parameter.empty - and parameter.kind != inspect._ParameterKind.VAR_KEYWORD + return ( + sum( + 1 + for parameter in inspect.signature(func).parameters.values() + if ( + parameter.default == inspect.Parameter.empty + and parameter.kind != inspect._ParameterKind.VAR_KEYWORD + ) ) - ) == 0 + == 0 + ) @property def callback(self) -> callable: From bf3f50638a1726c5c299233e48f8b506ec65b9cd Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 24 Jan 2024 11:21:38 -0800 Subject: [PATCH 07/15] :bug: Fix typo in __getstate__ return Actually use the state we create! --- pyiron_workflow/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 8d0b615b..d73cc859 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -1073,7 +1073,7 @@ def __getstate__(self): # _but_ if the user is just passing instructions on how to _build_ an executor, # we'll trust that those serialize OK (this way we can, hopefully, eventually # support nesting executors!) - return self.__dict__ + return state def __setstate__(self, state): # Update instead of overriding in case some other attributes were added on the From d3b1a2318289ce6396f8b91415390ee1d837b1eb Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 24 Jan 2024 11:50:15 -0800 Subject: [PATCH 08/15] Fix typo (missing words) in test message --- tests/unit/test_macro.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index 4afd1f3a..afc45ac1 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -270,7 +270,7 @@ def test_with_executor(self): self.assertIs( downstream.inputs.x.connections[0], macro.outputs.three__result, - msg="The macro should still be connected to " + msg=f"The macro output should still be connected to downstream" ) sleep(0.2) # Give a moment for the ran signal to emit and downstream to run # I'm a bit surprised this sleep is necessary From e44958306ff6b7081dcac32de74ae565e2a049bb Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 24 Jan 2024 12:21:58 -0800 Subject: [PATCH 09/15] Have composite pass its entire self for remote execution Instead of just the children. In the case of macros, which are not parent-most objects, we need to be sure to restore any connections the local object had when parsing the remote result --- pyiron_workflow/composite.py | 23 +++++++++++++++------- pyiron_workflow/macro.py | 38 +++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 39e5a45f..30ec78e2 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -177,21 +177,23 @@ def on_run(self): return self.run_graph @staticmethod - def run_graph(_nodes: dict[Node], _starting_nodes: list[Node]): - for node in _starting_nodes: + def run_graph(_composite: Composite): + for node in _composite.starting_nodes: node.run() - return _nodes + return _composite @property def run_args(self) -> dict: - return {"_nodes": self.nodes, "_starting_nodes": self.starting_nodes} + return {"_composite": self} def process_run_result(self, run_output): - if run_output is not self.nodes: - # Then we probably ran on a parallel process and have an unpacked future - self._update_children(run_output) + if run_output is not self: + self._parse_remotely_executed_self(run_output) return DotDict(self.outputs.to_value_dict()) + def _parse_remotely_executed_self(self, other_self): + self.__setstate__(other_self.__getstate__()) + def _update_children(self, children_from_another_process: DotDict[str, Node]): """ If you receive a new dictionary of children, e.g. from unpacking a futures @@ -604,3 +606,10 @@ def tidy_working_directory(self): for node in self: node.tidy_working_directory() super().tidy_working_directory() + + def __setstate__(self, state): + super().__setstate__(state) + # Nodes purge their _parent information in their __getstate__ + # so return it to them: + for node in self: + node._parent = self diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index 56da65fd..84028024 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -7,7 +7,7 @@ from functools import partialmethod import inspect -from typing import get_type_hints, Literal, Optional +from typing import get_type_hints, Literal, Optional, TYPE_CHECKING from bidict import bidict @@ -17,6 +17,9 @@ from pyiron_workflow.io import Outputs, Inputs from pyiron_workflow.output_parser import ParseOutput +if TYPE_CHECKING: + from pyiron_workflow.channels import Channel + class Macro(Composite): """ @@ -471,6 +474,39 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: return self._outputs + def _parse_remotely_executed_self(self, other_self): + local_connection_data = [ + [(c, c.label, c.connections) for c in io_panel] + for io_panel + in [self.inputs, self.outputs, self.signals.input, self.signals.output] + ] + + super()._parse_remotely_executed_self(other_self) + + for old_data, io_panel in zip( + local_connection_data, + [self.inputs, self.outputs, self.signals.input, self.signals.output] + # Get fresh copies of the IO panels post-update + ): + for original_channel, label, connections in old_data: + new_channel = io_panel[label] # Fetch it from the fresh IO panel + new_channel.connections = connections + for other_channel in connections: + self._replace_connection( + other_channel, original_channel, new_channel + ) + + @staticmethod + def _replace_connection( + channel: Channel, old_connection: Channel, new_connection: Channel + ): + """Brute-force replace an old connection in a channel with a new one""" + channel.connections = [ + c if c is not old_connection + else new_connection + for c in channel + ] + def _update_children(self, children_from_another_process): super()._update_children(children_from_another_process) self._rebuild_data_io() From dc09c824d2cf4aea671cbb02b5ad3cd22b062f8a Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 24 Jan 2024 12:23:06 -0800 Subject: [PATCH 10/15] Remove unused methods We parse the whole composite now, not just its children --- pyiron_workflow/composite.py | 10 ---------- pyiron_workflow/macro.py | 4 ---- 2 files changed, 14 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 30ec78e2..a3224745 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -194,16 +194,6 @@ def process_run_result(self, run_output): def _parse_remotely_executed_self(self, other_self): self.__setstate__(other_self.__getstate__()) - def _update_children(self, children_from_another_process: DotDict[str, Node]): - """ - If you receive a new dictionary of children, e.g. from unpacking a futures - object of your own children you sent off to another process for computation, - replace your own nodes with them, and set yourself as their parent. - """ - for child in children_from_another_process.values(): - child._parent = self - self.nodes = children_from_another_process - def disconnect_run(self) -> list[tuple[Channel, Channel]]: """ Disconnect all `signals.input.run` connections on all child nodes. diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index 84028024..b64742cb 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -507,10 +507,6 @@ def _replace_connection( for c in channel ] - def _update_children(self, children_from_another_process): - super()._update_children(children_from_another_process) - self._rebuild_data_io() - def _configure_graph_execution(self): run_signals = self.disconnect_run() From 6685d7a79c508b80ab6db041f16af8375c00ef68 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Wed, 24 Jan 2024 20:28:34 +0000 Subject: [PATCH 11/15] Format black --- pyiron_workflow/macro.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index b64742cb..1790af94 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -477,8 +477,12 @@ def outputs(self) -> Outputs: def _parse_remotely_executed_self(self, other_self): local_connection_data = [ [(c, c.label, c.connections) for c in io_panel] - for io_panel - in [self.inputs, self.outputs, self.signals.input, self.signals.output] + for io_panel in [ + self.inputs, + self.outputs, + self.signals.input, + self.signals.output, + ] ] super()._parse_remotely_executed_self(other_self) @@ -502,9 +506,7 @@ def _replace_connection( ): """Brute-force replace an old connection in a channel with a new one""" channel.connections = [ - c if c is not old_connection - else new_connection - for c in channel + c if c is not old_connection else new_connection for c in channel ] def _configure_graph_execution(self): From d7369126cf40fad019696f569db3037237ab1c32 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 24 Jan 2024 13:19:39 -0800 Subject: [PATCH 12/15] Be consistent about copying and updating dict Sometimes we mess with stuff in __getstate__, e.g. to avoid reflexive relationships between nodes and channels, but getting the state should never modify the current state, so always make a fresh dictionary. Similarly, the returned state might possibly not have all the content our current state does, so use update instead of overwriting. --- pyiron_workflow/channels.py | 4 +--- pyiron_workflow/interfaces.py | 4 ++-- pyiron_workflow/io.py | 2 +- pyiron_workflow/node.py | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index b2cde355..16fd3923 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -729,11 +729,9 @@ def __round__(self): # Because we override __getattr__ we need to get and set state for serialization def __getstate__(self): - return self.__dict__ + return dict(self.__dict__) def __setstate__(self, state): - # Update instead of overriding in case some other attributes were added on the - # main process while a remote process was working away self.__dict__.update(**state) diff --git a/pyiron_workflow/interfaces.py b/pyiron_workflow/interfaces.py index 90e19338..9e30390f 100644 --- a/pyiron_workflow/interfaces.py +++ b/pyiron_workflow/interfaces.py @@ -147,10 +147,10 @@ def __getitem__(self, item): ) from e def __getstate__(self): - return self.__dict__ + return dict(self.__dict__) def __setstate__(self, state): - self.__dict__ = state + self.__dict__.update(**state) def register(self, package_identifier: str, domain: Optional[str] = None) -> None: """ diff --git a/pyiron_workflow/io.py b/pyiron_workflow/io.py index 18ed8d01..f1cf6232 100644 --- a/pyiron_workflow/io.py +++ b/pyiron_workflow/io.py @@ -157,7 +157,7 @@ def to_dict(self): def __getstate__(self): # Compatibility with python <3.11 - return self.__dict__ + return dict(self.__dict__) def __setstate__(self, state): # Because we override getattr, we need to use __dict__ assignment directly in diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index d73cc859..9bc949b1 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -1040,7 +1040,7 @@ def replace_with(self, other: Node | type[Node]): warnings.warn(f"Could not replace_node {self.label}, as it has no parent.") def __getstate__(self): - state = self.__dict__ + state = dict(self.__dict__) state["_parent"] = None # I am not at all confident that removing the parent here is the _right_ # solution. From 1ce79ce3d450991fa36c5020b89555888b6a69db Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 24 Jan 2024 13:20:03 -0800 Subject: [PATCH 13/15] Unparent local nodes before taking remote In the case of running on an executor --- pyiron_workflow/composite.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index a3224745..68a4cc2e 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -192,6 +192,9 @@ def process_run_result(self, run_output): return DotDict(self.outputs.to_value_dict()) def _parse_remotely_executed_self(self, other_self): + # Un-parent existing nodes before ditching them + for node in self: + node._parent = None self.__setstate__(other_self.__getstate__()) def disconnect_run(self) -> list[tuple[Channel, Channel]]: From 1f3893d294b8ce87bac07b57ab5fc8d737403140 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 24 Jan 2024 13:45:22 -0800 Subject: [PATCH 14/15] :bug: stop running By setting `running=False` on the local instance _before_ updating the state, we were then re-writing `running=True` when updating the state. Update the flag on the received instance as well, and add a test so the same issue doesn't crop up again. --- pyiron_workflow/composite.py | 1 + tests/unit/test_macro.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 68a4cc2e..8f4f0cd8 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -195,6 +195,7 @@ def _parse_remotely_executed_self(self, other_self): # Un-parent existing nodes before ditching them for node in self: node._parent = None + other_self.running = False # It's done now self.__setstate__(other_self.__getstate__()) def disconnect_run(self) -> list[tuple[Channel, Channel]]: diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index afc45ac1..3b9cdcb0 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -238,6 +238,10 @@ def test_with_executor(self): returned_nodes = result.result(timeout=120) # Wait for the process to finish sleep(1) + self.assertFalse( + macro.running, + msg="Macro should be done running" + ) self.assertIsNot( original_one, returned_nodes.one, From 819a8d5f1bd7544ae19b7a413cb1397885a40dfe Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Tue, 30 Jan 2024 07:58:11 -0800 Subject: [PATCH 15/15] Beautify _no_positional_args logic And make it more efficient with early stopping Co-authored-by: Sam Dareska <37879103+samwaseda@users.noreply.github.com> --- pyiron_workflow/channels.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index b2cde355..b3acca88 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -804,17 +804,11 @@ def _takes_zero_arguments(self, callback): @staticmethod def _no_positional_args(func): - return ( - sum( - 1 - for parameter in inspect.signature(func).parameters.values() - if ( - parameter.default == inspect.Parameter.empty - and parameter.kind != inspect._ParameterKind.VAR_KEYWORD - ) - ) - == 0 - ) + return all([ + parameter.default != inspect.Parameter.empty + or parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in inspect.signature(func).parameters.values() + ]) @property def callback(self) -> callable: