From cede0a18095ba1faf9b7a5b57f6adc6519791efd Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 25 Feb 2026 18:32:12 +0100 Subject: [PATCH 01/31] Current state --- flowrep/models/parsers/dependency_parser.py | 69 +++++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index 189b28e4..13cdbbb2 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -9,6 +9,13 @@ CallDependencies = dict[versions.VersionInfo, Callable] +def _get_collector(func: types.FunctionType) -> CallCollector: + tree = parser_helpers.get_ast_function_node(func) + collector = CallCollector() + collector.visit(tree) + return collector + + def get_call_dependencies( func: types.FunctionType, version_scraping: versions.VersionScrapingMap | None = None, @@ -44,9 +51,7 @@ def get_call_dependencies( visited.add(func_fqn) scope = object_scope.get_scope(func) - tree = parser_helpers.get_ast_function_node(func) - collector = CallCollector() - collector.visit(tree) + collector = _get_collector(func) for call in collector.calls: try: @@ -104,8 +109,60 @@ def split_by_version_availability( class CallCollector(ast.NodeVisitor): def __init__(self): - self.calls: list[ast.expr] = [] + self.items: list[ast.expr] = [] # To store the callers (functions being called) + self.local_vars: set[str] = set() # To track variables defined within the function + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + # Collect function arguments as local variables + for arg in node.args.args: + self.local_vars.add(arg.arg) + # Check for type hints in arguments + if arg.annotation and isinstance(arg.annotation, ast.Name): + self.items.append(arg.annotation) + + # Check for type hints in the return type + if node.returns and isinstance(node.returns, ast.Name): + self.items.append(node.returns) + + # Visit the body of the function + self.generic_visit(node) - def visit_Call(self, node: ast.Call) -> None: - self.calls.append(node.func) + # Clear local variables after leaving the function scope + self.local_vars.clear() + + def visit_Assign(self, node: ast.Assign) -> None: + # Handle multiple assignments and unpacking + for target in node.targets: + self._process_assignment_target(target) self.generic_visit(node) + + def _process_assignment_target(self, target): + # Recursively process assignment targets to handle unpacking + if isinstance(target, ast.Attribute): + if target.id not in self.local_vars: + self.items.append(target.id) + elif isinstance(target, ast.Name): + # Add the variable name to local_vars + self.local_vars.add(target.id) + elif isinstance(target, (ast.Tuple, ast.List)): + # Handle tuple or list unpacking (e.g., x, y = ...) + for element in target.elts: + self._process_assignment_target(element) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + # Handle annotated assignments (e.g., x: CustomType = 42) + if isinstance(node.target, ast.Name): + self.local_vars.add(node.target.id) + if node.annotation and isinstance(node.annotation, ast.Name): + self.items.append(node.annotation) + self.generic_visit(node) + + def visit_Name(self, node: ast.Name) -> None: + # Collect all variables that are not locally defined + if node.id not in self.local_vars: + self.items.append(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + # Collect attributes that are not locally defined + if node.value.id not in self.local_vars: + self.items.append(node) From 87604aa6dcbd73b342451b15d50b4db1a600b85f Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 25 Feb 2026 22:28:38 +0100 Subject: [PATCH 02/31] Refactor items --- flowrep/models/parsers/dependency_parser.py | 66 +++++++++++++++---- .../models/parsers/test_dependency_parser.py | 2 +- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index 13cdbbb2..f3c15ee0 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -1,4 +1,5 @@ import ast +import builtins import types from collections.abc import Callable @@ -52,10 +53,11 @@ def get_call_dependencies( scope = object_scope.get_scope(func) collector = _get_collector(func) + items = collector.items.difference(set(dir(builtins))) - for call in collector.calls: + for item in items: try: - caller = object_scope.resolve_symbol_to_object(call, scope) + caller = object_scope.resolve_attribute_to_object(item, scope) except (ValueError, TypeError): continue @@ -63,7 +65,7 @@ def get_call_dependencies( # Under remotely normal circumstances, this should be unreachable raise TypeError( f"Caller {caller} is not callable, yet was generated from the list of " - f"ast.Call calls, in particular {call}. We're expecting these to " + f"ast.Call calls, in particular {item}. We're expecting these to " f"actually connect to callables. Please raise a GitHub issue if you " f"think this is not a mistake." ) @@ -109,8 +111,13 @@ def split_by_version_availability( class CallCollector(ast.NodeVisitor): def __init__(self): - self.items: list[ast.expr] = [] # To store the callers (functions being called) - self.local_vars: set[str] = set() # To track variables defined within the function + self.items: set[str] = set() + self.local_vars: set[str] = set() + + def _append_item(self, node: ast.expr) -> None: + item = ast.unparse(node) + if item.split(".")[0] not in self.local_vars: + self.items.add(item) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # Collect function arguments as local variables @@ -118,11 +125,11 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self.local_vars.add(arg.arg) # Check for type hints in arguments if arg.annotation and isinstance(arg.annotation, ast.Name): - self.items.append(arg.annotation) + self._append_item(arg.annotation) # Check for type hints in the return type if node.returns and isinstance(node.returns, ast.Name): - self.items.append(node.returns) + self._append_item(node.returns) # Visit the body of the function self.generic_visit(node) @@ -140,7 +147,7 @@ def _process_assignment_target(self, target): # Recursively process assignment targets to handle unpacking if isinstance(target, ast.Attribute): if target.id not in self.local_vars: - self.items.append(target.id) + self._append_item(target) elif isinstance(target, ast.Name): # Add the variable name to local_vars self.local_vars.add(target.id) @@ -154,15 +161,50 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: if isinstance(node.target, ast.Name): self.local_vars.add(node.target.id) if node.annotation and isinstance(node.annotation, ast.Name): - self.items.append(node.annotation) + self._append_item(node.annotation) self.generic_visit(node) def visit_Name(self, node: ast.Name) -> None: # Collect all variables that are not locally defined if node.id not in self.local_vars: - self.items.append(node) + self._append_item(node) def visit_Attribute(self, node: ast.Attribute) -> None: # Collect attributes that are not locally defined - if node.value.id not in self.local_vars: - self.items.append(node) + self._append_item(node) + + def visit_For(self, node: ast.For) -> None: + # Handle loop variables as local variables + self._process_assignment_target(node.target) + self.generic_visit(node) + + def visit_With(self, node: ast.With) -> None: + # Handle variables defined in with statements (e.g., with open(...) as f) + for item in node.items: + if item.optional_vars and isinstance(item.optional_vars, ast.Name): + self.local_vars.add(item.optional_vars.id) + self.generic_visit(node) + + def visit_ListComp(self, node: ast.ListComp) -> None: + # Handle variables defined in list comprehensions + for generator in node.generators: + self._process_assignment_target(generator.target) + self.generic_visit(node) + + def visit_DictComp(self, node: ast.DictComp) -> None: + # Handle variables defined in dict comprehensions + for generator in node.generators: + self._process_assignment_target(generator.target) + self.generic_visit(node) + + def visit_SetComp(self, node: ast.SetComp) -> None: + # Handle variables defined in set comprehensions + for generator in node.generators: + self._process_assignment_target(generator.target) + self.generic_visit(node) + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: + # Handle variables defined in generator expressions + for generator in node.generators: + self._process_assignment_target(generator.target) + self.generic_visit(node) diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index d4b69cf9..e291a60b 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -132,7 +132,7 @@ def test_cycle_does_not_recurse_infinitely(self): def test_builtin_callable_included(self): deps = dependency_parser.get_call_dependencies(_calls_len) - self.assertIn(_fqn(len), _fqns(deps)) + self.assertEqual(_fqns(deps), set()) def test_returns_dict_type(self): deps = dependency_parser.get_call_dependencies(_leaf) From 995b9ef662ef3f0e843c8755f3d07c0ef7d949e6 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 25 Feb 2026 22:32:00 +0100 Subject: [PATCH 03/31] Rename some stuff --- flowrep/models/parsers/dependency_parser.py | 30 ++++++--------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index f3c15ee0..e7d637c7 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -57,31 +57,17 @@ def get_call_dependencies( for item in items: try: - caller = object_scope.resolve_attribute_to_object(item, scope) + obj = object_scope.resolve_attribute_to_object(item, scope) except (ValueError, TypeError): continue - if not callable(caller): # pragma: no cover - # Under remotely normal circumstances, this should be unreachable - raise TypeError( - f"Caller {caller} is not callable, yet was generated from the list of " - f"ast.Call calls, in particular {item}. We're expecting these to " - f"actually connect to callables. Please raise a GitHub issue if you " - f"think this is not a mistake." - ) - - info = versions.VersionInfo.of(caller, version_scraping=version_scraping) - # In principle, we open ourselves to overwriting an existing dependency here, - # but it would need to somehow have exactly the same version info (including - # qualname) yet be a different object. - # This ought not happen by accident, and in case it somehow does happen on - # purpose (it probably shouldn't), we just silently keep the more recent one. - - call_dependencies[info] = caller - - # Depth-first search on dependencies — only possible when we have source - if isinstance(caller, types.FunctionType): - get_call_dependencies(caller, version_scraping, call_dependencies, visited) + if callable(obj): # pragma: no cover + info = versions.VersionInfo.of(obj, version_scraping=version_scraping) + call_dependencies[info] = obj + + # Depth-first search on dependencies — only possible when we have source + if isinstance(obj, types.FunctionType): + get_call_dependencies(obj, version_scraping, call_dependencies, visited) return call_dependencies From f0b255cc7f913b9530b720b450af2c2e47a148e6 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 25 Feb 2026 22:35:45 +0100 Subject: [PATCH 04/31] Move get_dependencies --- flowrep/models/parsers/dependency_parser.py | 35 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index e7d637c7..62018677 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -31,6 +31,34 @@ def get_call_dependencies( for every resolved callee that is a :class:`~types.FunctionType` (i.e. has inspectable source), the function recurses into the callee's own scope. + Args: + func: The function whose call-graph to analyse. + version_scraping (VersionScrapingMap | None): Since some modules may store + their version in other ways, this provides an optional map between module + names and callables to leverage for extracting that module's version. + _call_dependencies: Accumulator for recursive calls — do not pass manually. + _visited: Fully-qualified names already traversed — do not pass manually. + + Returns: + A mapping from :class:`VersionInfo` to the callables found under that + identity across the entire (sub-)tree. + """ + return get_dependencies(func, version_scraping, _call_dependencies, _visited)[0] + +def get_dependencies( + func: types.FunctionType, + version_scraping: versions.VersionScrapingMap | None = None, + _call_dependencies: CallDependencies | None = None, + _visited: set[str] | None = None, +) -> CallDependencies: + """ + Recursively collect all callable dependencies of *func* via AST introspection. + + Each dependency is keyed by its :class:`~pyiron_snippets.versions.VersionInfo` + and maps to the callables instance with that identity. The search is depth-first: + for every resolved callee that is a :class:`~types.FunctionType` (i.e. has + inspectable source), the function recurses into the callee's own scope. + Args: func: The function whose call-graph to analyse. version_scraping (VersionScrapingMap | None): Since some modules may store @@ -44,11 +72,12 @@ def get_call_dependencies( identity across the entire (sub-)tree. """ call_dependencies: CallDependencies = _call_dependencies or {} + variables = [] visited: set[str] = _visited or set() func_fqn = versions.VersionInfo.of(func).fully_qualified_name if func_fqn in visited: - return call_dependencies + return call_dependencies, variables visited.add(func_fqn) scope = object_scope.get_scope(func) @@ -68,8 +97,10 @@ def get_call_dependencies( # Depth-first search on dependencies — only possible when we have source if isinstance(obj, types.FunctionType): get_call_dependencies(obj, version_scraping, call_dependencies, visited) + else: + variables.append(item) - return call_dependencies + return call_dependencies, variables def split_by_version_availability( From bb5d7a510c98b13f923cbf677855afe283211052 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 25 Feb 2026 22:57:28 +0100 Subject: [PATCH 05/31] safe guard for inline access to members --- flowrep/models/parsers/dependency_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index 62018677..bdfb72bf 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -98,7 +98,7 @@ def get_dependencies( if isinstance(obj, types.FunctionType): get_call_dependencies(obj, version_scraping, call_dependencies, visited) else: - variables.append(item) + variables.append(obj) return call_dependencies, variables @@ -134,7 +134,7 @@ def __init__(self): def _append_item(self, node: ast.expr) -> None: item = ast.unparse(node) if item.split(".")[0] not in self.local_vars: - self.items.add(item) + self.items.add(item.split("(")[0]) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # Collect function arguments as local variables From 0862a51328de667919f3452ec06945ecff1922c7 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Thu, 26 Feb 2026 09:36:18 +0100 Subject: [PATCH 06/31] Add tests and black --- flowrep/models/parsers/dependency_parser.py | 1 + .../models/parsers/test_dependency_parser.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index bdfb72bf..963b8b7a 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -45,6 +45,7 @@ def get_call_dependencies( """ return get_dependencies(func, version_scraping, _call_dependencies, _visited)[0] + def get_dependencies( func: types.FunctionType, version_scraping: versions.VersionScrapingMap | None = None, diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index e291a60b..59e8eed7 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -86,6 +86,18 @@ def _fqns(deps: dependency_parser.CallDependencies) -> set[str]: return {info.fully_qualified_name for info in deps} +MyCustomType = int | float + + +def _custom_type_used(x): + return isinstance(x, MyCustomType) + + +def _custom_type_type_hint(x: MyCustomType): + y = x + return y + + class TestGetCallDependencies(unittest.TestCase): """Tests for :func:`dependency_parser.get_call_dependencies`.""" @@ -171,6 +183,14 @@ def test_non_callable_resolved_symbol_is_skipped(self): deps = dependency_parser.get_call_dependencies(_calls_non_callable) self.assertIsInstance(deps, dict) + def test_variables(self): + self.assertEqual( + dependency_parser.get_dependencies(_custom_type_used)[1], [int | float] + ) + self.assertEqual( + dependency_parser.get_dependencies(_custom_type_type_hint)[1], [int | float] + ) + class TestSplitByVersionAvailability(unittest.TestCase): """Tests for :func:`dependency_parser.split_by_version_availability`.""" From c6c0fa13db646fb8589961036a8b25708375f349 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Thu, 26 Feb 2026 09:38:42 +0100 Subject: [PATCH 07/31] future annotation --- flowrep/models/parsers/dependency_parser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index 963b8b7a..702d1339 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import ast import builtins import types From c1c7978aa6412a0d017066d3ce2a1f52fd15668c Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Thu, 26 Feb 2026 09:41:12 +0100 Subject: [PATCH 08/31] mypy --- flowrep/models/parsers/dependency_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index 702d1339..db5611e3 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -53,7 +53,7 @@ def get_dependencies( version_scraping: versions.VersionScrapingMap | None = None, _call_dependencies: CallDependencies | None = None, _visited: set[str] | None = None, -) -> CallDependencies: +) -> tuple[CallDependencies, list[object]]: """ Recursively collect all callable dependencies of *func* via AST introspection. From 740aa5162f35fd7d0d701f98f434292a94545bfc Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Sun, 8 Mar 2026 22:01:43 +0100 Subject: [PATCH 09/31] Create find_undefined_variables --- flowrep/models/parsers/dependency_parser.py | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index db5611e3..f9255d0a 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -2,6 +2,7 @@ import ast import builtins +import inspect import types from collections.abc import Callable @@ -228,3 +229,60 @@ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: for generator in node.generators: self._process_assignment_target(generator.target) self.generic_visit(node) + + +class UndefinedVariableVisitor(ast.NodeVisitor): + def __init__(self): + self.used_vars = set() + self.defined_vars = set() + + def visit_Name(self, node): + if isinstance(node.ctx, ast.Load): # Variable is being used + self.used_vars.add(node.id) + elif isinstance(node.ctx, ast.Store): # Variable is being defined + self.defined_vars.add(node.id) + + def visit_FunctionDef(self, node): + # Add function arguments to defined variables + for arg in node.args.args: + self.defined_vars.add(arg.arg) + self.generic_visit(node) + + +def find_undefined_variables( + func_or_var: Callable | object, + version_scraping: versions.VersionScrapingMap | None = None, + _call_dependencies: CallDependencies | None = None, + _visited: set[str] | None = None, +) -> set[str]: + + call_dependencies: CallDependencies = _call_dependencies or {} + visited: set[str] = _visited or set() + + func_fqn = versions.VersionInfo.of(func_or_var).fully_qualified_name + if func_fqn in visited: + return call_dependencies + visited.add(func_fqn) + + if callable(func_or_var): + source = inspect.getsource(func_or_var) + else: + source = str(func_or_var) + tree = ast.parse(source) + + visitor = UndefinedVariableVisitor() + visitor.visit(tree) + + # Find variables that are used but not defined + scope = object_scope.get_scope(func_or_var) + undefined_vars = visitor.used_vars - visitor.defined_vars + for item in undefined_vars.difference(set(dir(builtins))): + try: + obj = object_scope.resolve_attribute_to_object(item, scope) + except (ValueError, TypeError): + continue + info = versions.VersionInfo.of(obj, version_scraping=version_scraping) + call_dependencies[info] = obj + + find_undefined_variables(obj, version_scraping, call_dependencies, visited) + return call_dependencies From ea31b27760100cb654e3c08b43f3dd2ed7011fac Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Tue, 17 Mar 2026 09:14:46 +0100 Subject: [PATCH 10/31] Do not distinguish between functions and variables --- flowrep/models/parsers/dependency_parser.py | 226 +----------- .../models/parsers/test_dependency_parser.py | 325 ++++-------------- 2 files changed, 90 insertions(+), 461 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index f9255d0a..f84adc4a 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -3,6 +3,7 @@ import ast import builtins import inspect +import textwrap import types from collections.abc import Callable @@ -13,100 +14,6 @@ CallDependencies = dict[versions.VersionInfo, Callable] -def _get_collector(func: types.FunctionType) -> CallCollector: - tree = parser_helpers.get_ast_function_node(func) - collector = CallCollector() - collector.visit(tree) - return collector - - -def get_call_dependencies( - func: types.FunctionType, - version_scraping: versions.VersionScrapingMap | None = None, - _call_dependencies: CallDependencies | None = None, - _visited: set[str] | None = None, -) -> CallDependencies: - """ - Recursively collect all callable dependencies of *func* via AST introspection. - - Each dependency is keyed by its :class:`~pyiron_snippets.versions.VersionInfo` - and maps to the callables instance with that identity. The search is depth-first: - for every resolved callee that is a :class:`~types.FunctionType` (i.e. has - inspectable source), the function recurses into the callee's own scope. - - Args: - func: The function whose call-graph to analyse. - version_scraping (VersionScrapingMap | None): Since some modules may store - their version in other ways, this provides an optional map between module - names and callables to leverage for extracting that module's version. - _call_dependencies: Accumulator for recursive calls — do not pass manually. - _visited: Fully-qualified names already traversed — do not pass manually. - - Returns: - A mapping from :class:`VersionInfo` to the callables found under that - identity across the entire (sub-)tree. - """ - return get_dependencies(func, version_scraping, _call_dependencies, _visited)[0] - - -def get_dependencies( - func: types.FunctionType, - version_scraping: versions.VersionScrapingMap | None = None, - _call_dependencies: CallDependencies | None = None, - _visited: set[str] | None = None, -) -> tuple[CallDependencies, list[object]]: - """ - Recursively collect all callable dependencies of *func* via AST introspection. - - Each dependency is keyed by its :class:`~pyiron_snippets.versions.VersionInfo` - and maps to the callables instance with that identity. The search is depth-first: - for every resolved callee that is a :class:`~types.FunctionType` (i.e. has - inspectable source), the function recurses into the callee's own scope. - - Args: - func: The function whose call-graph to analyse. - version_scraping (VersionScrapingMap | None): Since some modules may store - their version in other ways, this provides an optional map between module - names and callables to leverage for extracting that module's version. - _call_dependencies: Accumulator for recursive calls — do not pass manually. - _visited: Fully-qualified names already traversed — do not pass manually. - - Returns: - A mapping from :class:`VersionInfo` to the callables found under that - identity across the entire (sub-)tree. - """ - call_dependencies: CallDependencies = _call_dependencies or {} - variables = [] - visited: set[str] = _visited or set() - - func_fqn = versions.VersionInfo.of(func).fully_qualified_name - if func_fqn in visited: - return call_dependencies, variables - visited.add(func_fqn) - - scope = object_scope.get_scope(func) - collector = _get_collector(func) - items = collector.items.difference(set(dir(builtins))) - - for item in items: - try: - obj = object_scope.resolve_attribute_to_object(item, scope) - except (ValueError, TypeError): - continue - - if callable(obj): # pragma: no cover - info = versions.VersionInfo.of(obj, version_scraping=version_scraping) - call_dependencies[info] = obj - - # Depth-first search on dependencies — only possible when we have source - if isinstance(obj, types.FunctionType): - get_call_dependencies(obj, version_scraping, call_dependencies, visited) - else: - variables.append(obj) - - return call_dependencies, variables - - def split_by_version_availability( call_dependencies: CallDependencies, ) -> tuple[CallDependencies, CallDependencies]: @@ -130,107 +37,6 @@ def split_by_version_availability( return has_version, no_version -class CallCollector(ast.NodeVisitor): - def __init__(self): - self.items: set[str] = set() - self.local_vars: set[str] = set() - - def _append_item(self, node: ast.expr) -> None: - item = ast.unparse(node) - if item.split(".")[0] not in self.local_vars: - self.items.add(item.split("(")[0]) - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - # Collect function arguments as local variables - for arg in node.args.args: - self.local_vars.add(arg.arg) - # Check for type hints in arguments - if arg.annotation and isinstance(arg.annotation, ast.Name): - self._append_item(arg.annotation) - - # Check for type hints in the return type - if node.returns and isinstance(node.returns, ast.Name): - self._append_item(node.returns) - - # Visit the body of the function - self.generic_visit(node) - - # Clear local variables after leaving the function scope - self.local_vars.clear() - - def visit_Assign(self, node: ast.Assign) -> None: - # Handle multiple assignments and unpacking - for target in node.targets: - self._process_assignment_target(target) - self.generic_visit(node) - - def _process_assignment_target(self, target): - # Recursively process assignment targets to handle unpacking - if isinstance(target, ast.Attribute): - if target.id not in self.local_vars: - self._append_item(target) - elif isinstance(target, ast.Name): - # Add the variable name to local_vars - self.local_vars.add(target.id) - elif isinstance(target, (ast.Tuple, ast.List)): - # Handle tuple or list unpacking (e.g., x, y = ...) - for element in target.elts: - self._process_assignment_target(element) - - def visit_AnnAssign(self, node: ast.AnnAssign) -> None: - # Handle annotated assignments (e.g., x: CustomType = 42) - if isinstance(node.target, ast.Name): - self.local_vars.add(node.target.id) - if node.annotation and isinstance(node.annotation, ast.Name): - self._append_item(node.annotation) - self.generic_visit(node) - - def visit_Name(self, node: ast.Name) -> None: - # Collect all variables that are not locally defined - if node.id not in self.local_vars: - self._append_item(node) - - def visit_Attribute(self, node: ast.Attribute) -> None: - # Collect attributes that are not locally defined - self._append_item(node) - - def visit_For(self, node: ast.For) -> None: - # Handle loop variables as local variables - self._process_assignment_target(node.target) - self.generic_visit(node) - - def visit_With(self, node: ast.With) -> None: - # Handle variables defined in with statements (e.g., with open(...) as f) - for item in node.items: - if item.optional_vars and isinstance(item.optional_vars, ast.Name): - self.local_vars.add(item.optional_vars.id) - self.generic_visit(node) - - def visit_ListComp(self, node: ast.ListComp) -> None: - # Handle variables defined in list comprehensions - for generator in node.generators: - self._process_assignment_target(generator.target) - self.generic_visit(node) - - def visit_DictComp(self, node: ast.DictComp) -> None: - # Handle variables defined in dict comprehensions - for generator in node.generators: - self._process_assignment_target(generator.target) - self.generic_visit(node) - - def visit_SetComp(self, node: ast.SetComp) -> None: - # Handle variables defined in set comprehensions - for generator in node.generators: - self._process_assignment_target(generator.target) - self.generic_visit(node) - - def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: - # Handle variables defined in generator expressions - for generator in node.generators: - self._process_assignment_target(generator.target) - self.generic_visit(node) - - class UndefinedVariableVisitor(ast.NodeVisitor): def __init__(self): self.used_vars = set() @@ -251,10 +57,25 @@ def visit_FunctionDef(self, node): def find_undefined_variables( func_or_var: Callable | object, +) -> set[str]: + if callable(func_or_var): + source = textwrap.dedent(inspect.getsource(func_or_var)) + else: + source = str(func_or_var) + tree = ast.parse(source) + + visitor = UndefinedVariableVisitor() + visitor.visit(tree) + undefined_vars = visitor.used_vars - visitor.defined_vars + return undefined_vars.difference(set(dir(builtins))) + + +def get_call_dependencies( + func_or_var: Callable | object, version_scraping: versions.VersionScrapingMap | None = None, _call_dependencies: CallDependencies | None = None, _visited: set[str] | None = None, -) -> set[str]: +) -> CallDependencies: call_dependencies: CallDependencies = _call_dependencies or {} visited: set[str] = _visited or set() @@ -264,19 +85,10 @@ def find_undefined_variables( return call_dependencies visited.add(func_fqn) - if callable(func_or_var): - source = inspect.getsource(func_or_var) - else: - source = str(func_or_var) - tree = ast.parse(source) - - visitor = UndefinedVariableVisitor() - visitor.visit(tree) # Find variables that are used but not defined scope = object_scope.get_scope(func_or_var) - undefined_vars = visitor.used_vars - visitor.defined_vars - for item in undefined_vars.difference(set(dir(builtins))): + for item in find_undefined_variables(func_or_var): try: obj = object_scope.resolve_attribute_to_object(item, scope) except (ValueError, TypeError): @@ -284,5 +96,5 @@ def find_undefined_variables( info = versions.VersionInfo.of(obj, version_scraping=version_scraping) call_dependencies[info] = obj - find_undefined_variables(obj, version_scraping, call_dependencies, visited) + get_call_dependencies(obj, version_scraping, call_dependencies, visited) return call_dependencies diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index 59e8eed7..52cfe4ad 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -1,274 +1,91 @@ -import math +import ast import unittest - +from unittest.mock import MagicMock, patch +from collections.abc import Callable from pyiron_snippets import versions - -from flowrep.models.parsers import dependency_parser - -# --------------------------------------------------------------------------- -# Helper functions defined at module level so they have inspectable source, -# a proper __module__, and a stable __qualname__. -# --------------------------------------------------------------------------- - - -def _leaf(): - return 42 - - -def _single_call(): - return _leaf() - - -def _diamond_a(): - return _leaf() - - -def _diamond_b(): - return _leaf() - - -def _diamond_root(): - _diamond_a() - _diamond_b() - - -# Mutual recursion to exercise cycle detection. -def _cycle_a(): - return _cycle_b() # noqa: F821 — defined below - - -def _cycle_b(): - return _cycle_a() - - -def _no_calls(): - x = 1 + 2 - return x +from flowrep.models.parsers import object_scope, dependency_parser -def _calls_len(): - return len([1, 2, 3]) - - -def _nested_call(): - return _single_call() - - -def _multi_call(): - a = _leaf() - b = _leaf() - return a + b - - -def _attribute_access(x): - return math.sqrt(x) - - -def _nested_expression(x, y, z): - return _single_call(_leaf(x, y), z) - - -def _unresolvable_subscript(): - d = {} - return d["key"]() - - -def _calls_non_callable(): - x = 42 - return x - - -def _fqn(func) -> str: - return versions.VersionInfo.of(func).fully_qualified_name +class TestSplitByVersionAvailability(unittest.TestCase): + def test_split_by_version_availability(self): + mock_version_1 = MagicMock(version="1.0.0") + mock_version_2 = MagicMock(version=None) + mock_func_1 = MagicMock() + mock_func_2 = MagicMock() + + call_dependencies = { + mock_version_1: mock_func_1, + mock_version_2: mock_func_2, + } + has_version, no_version = dependency_parser.split_by_version_availability(call_dependencies) -def _fqns(deps: dependency_parser.CallDependencies) -> set[str]: - return {info.fully_qualified_name for info in deps} + self.assertIn(mock_version_1, has_version) + self.assertIn(mock_version_2, no_version) + self.assertNotIn(mock_version_1, no_version) + self.assertNotIn(mock_version_2, has_version) -MyCustomType = int | float +class TestUndefinedVariableVisitor(unittest.TestCase): + def test_undefined_variable_visitor(self): + source_code = """ +def test_function(a, b): + c = a + b + return d +""" + tree = ast.parse(source_code) + visitor = dependency_parser.UndefinedVariableVisitor() + visitor.visit(tree) + self.assertIn("d", visitor.used_vars) + self.assertIn("a", visitor.defined_vars) + self.assertIn("b", visitor.defined_vars) + self.assertIn("c", visitor.defined_vars) + self.assertNotIn("d", visitor.defined_vars) -def _custom_type_used(x): - return isinstance(x, MyCustomType) +class TestFindUndefinedVariables(unittest.TestCase): + def test_find_undefined_variables(self): + def test_function(a, b): + c = a + b + return d # 'd' is undefined -def _custom_type_type_hint(x: MyCustomType): - y = x - return y + undefined_vars = dependency_parser.find_undefined_variables(test_function) + self.assertIn("d", undefined_vars) + self.assertNotIn("a", undefined_vars) + self.assertNotIn("b", undefined_vars) + self.assertNotIn("c", undefined_vars) class TestGetCallDependencies(unittest.TestCase): - """Tests for :func:`dependency_parser.get_call_dependencies`.""" - - # --- basic behaviour --- - - def test_no_calls_returns_empty(self): - deps = dependency_parser.get_call_dependencies(_no_calls) - self.assertEqual(deps, {}) - - def test_single_direct_call(self): - deps = dependency_parser.get_call_dependencies(_single_call) - self.assertIn(_fqn(_leaf), _fqns(deps)) - - def test_transitive_dependencies(self): - deps = dependency_parser.get_call_dependencies(_nested_call) - fqns = _fqns(deps) - # Should find both _single_call and _leaf - self.assertIn(_fqn(_single_call), fqns) - self.assertIn(_fqn(_leaf), fqns) - - def test_diamond_dependency_no_duplicate_keys(self): - """ - _diamond_root -> _diamond_a -> _leaf AND _diamond_root -> _diamond_b -> _leaf. - _leaf's VersionInfo should appear exactly once as a key. - """ - deps = dependency_parser.get_call_dependencies(_diamond_root) - matching = [info for info in deps if info.fully_qualified_name == _fqn(_leaf)] - self.assertEqual(len(matching), 1) - - def test_duplicate_call_deduplicated_by_version_info(self): - """Calling the same function twice yields a single key, not two.""" - deps = dependency_parser.get_call_dependencies(_multi_call) - matching = [info for info in deps if info.fully_qualified_name == _fqn(_leaf)] - self.assertEqual(len(matching), 1) - - # --- cycle safety --- - - def test_cycle_does_not_recurse_infinitely(self): - # Should terminate without RecursionError - deps = dependency_parser.get_call_dependencies(_cycle_a) - self.assertIn(_fqn(_cycle_b), _fqns(deps)) - - # --- builtins / non-FunctionType callables --- - - def test_builtin_callable_included(self): - deps = dependency_parser.get_call_dependencies(_calls_len) - self.assertEqual(_fqns(deps), set()) - - def test_returns_dict_type(self): - deps = dependency_parser.get_call_dependencies(_leaf) - self.assertIsInstance(deps, dict) - - # --- attribute access (module.func) --- - - def test_attribute_access_dependency(self): - """Functions called via attribute access (e.g. math.sqrt) are tracked.""" - deps = dependency_parser.get_call_dependencies(_attribute_access) - self.assertIn(_fqn(math.sqrt), _fqns(deps)) - - # --- nested expressions --- - - def test_nested_expression_collects_all_calls(self): - """All calls in a nested expression like f(g(x), y) are collected.""" - deps = dependency_parser.get_call_dependencies(_nested_expression) - fqns = _fqns(deps) - self.assertIn(_fqn(_single_call), fqns) - self.assertIn(_fqn(_leaf), fqns) - - # --- unresolvable / non-callable targets (coverage for `continue` branches) --- - - def test_unresolvable_call_target_is_skipped(self): - """Calls that resolve_symbol_to_object cannot handle are silently skipped.""" - # _unresolvable_subscript contains d["key"]() which is an ast.Subscript, - # triggering a TypeError in resolve_symbol_to_object - deps = dependency_parser.get_call_dependencies(_unresolvable_subscript) - # Should not raise; the unresolvable call is simply absent - self.assertIsInstance(deps, dict) - - def test_non_callable_resolved_symbol_is_skipped(self): - """Symbols that resolve to non-callable objects are silently skipped.""" - # _calls_non_callable doesn't actually have a call in its AST that resolves - # to a non-callable, but we can verify the function itself is crawlable - deps = dependency_parser.get_call_dependencies(_calls_non_callable) - self.assertIsInstance(deps, dict) - - def test_variables(self): - self.assertEqual( - dependency_parser.get_dependencies(_custom_type_used)[1], [int | float] - ) - self.assertEqual( - dependency_parser.get_dependencies(_custom_type_type_hint)[1], [int | float] + @patch("flowrep.models.parsers.object_scope.get_scope") + @patch("flowrep.models.parsers.object_scope.resolve_attribute_to_object") + @patch("pyiron_snippets.versions.VersionInfo.of") + def test_get_call_dependencies( + self, mock_version_info_of, mock_resolve_attribute_to_object, mock_get_scope + ): + mock_func = MagicMock() + mock_version_info = MagicMock() + mock_version_info.fully_qualified_name = "mock_func" + mock_version_info_of.return_value = mock_version_info + mock_resolve_attribute_to_object.return_value = mock_func + + mock_scope = MagicMock() + mock_get_scope.return_value = mock_scope + + with patch( + "flowrep.models.parsers.dependency_parser.find_undefined_variables" + ) as mock_find_undefined: + mock_find_undefined.return_value = {"undefined_var"} + call_dependencies = dependency_parser.get_call_dependencies(mock_func) + + self.assertIn(mock_version_info, call_dependencies) + self.assertEqual(call_dependencies[mock_version_info], mock_func) + mock_get_scope.assert_called_once_with(mock_func) + mock_resolve_attribute_to_object.assert_called_once_with( + "undefined_var", mock_scope ) -class TestSplitByVersionAvailability(unittest.TestCase): - """Tests for :func:`dependency_parser.split_by_version_availability`.""" - - @staticmethod - def _make_info( - module: str, qualname: str, version: str | None = None - ) -> versions.VersionInfo: - return versions.VersionInfo( - module=module, - qualname=qualname, - version=version, - ) - - def test_empty_input(self): - has, no = dependency_parser.split_by_version_availability({}) - self.assertEqual(has, {}) - self.assertEqual(no, {}) - - def test_all_versioned(self): - info_a = self._make_info("pkg", "a", "1.0") - info_b = self._make_info("pkg", "b", "2.0") - deps: dependency_parser.CallDependencies = {info_a: _leaf, info_b: _leaf} - - has, no = dependency_parser.split_by_version_availability(deps) - self.assertEqual(len(has), 2) - self.assertEqual(len(no), 0) - - def test_all_unversioned(self): - info_a = self._make_info("local", "a") - info_b = self._make_info("local", "b") - deps: dependency_parser.CallDependencies = {info_a: _leaf, info_b: _leaf} - - has, no = dependency_parser.split_by_version_availability(deps) - self.assertEqual(len(has), 0) - self.assertEqual(len(no), 2) - - def test_mixed(self): - versioned = self._make_info("pkg", "x", "3.1") - unversioned = self._make_info("local", "y") - deps: dependency_parser.CallDependencies = { - versioned: _leaf, - unversioned: _single_call, - } - - has, no = dependency_parser.split_by_version_availability(deps) - self.assertIn(versioned, has) - self.assertIn(unversioned, no) - self.assertNotIn(versioned, no) - self.assertNotIn(unversioned, has) - - def test_partition_is_exhaustive_and_disjoint(self): - """Every key in the input appears in exactly one partition.""" - infos = [ - self._make_info("pkg", "a", "1.0"), - self._make_info("local", "b"), - self._make_info("pkg", "c", "0.1"), - self._make_info("local", "d"), - ] - deps: dependency_parser.CallDependencies = {info: _leaf for info in infos} - - has, no = dependency_parser.split_by_version_availability(deps) - self.assertEqual(set(has) | set(no), set(deps)) - self.assertTrue(set(has).isdisjoint(set(no))) - - def test_version_none_vs_empty_string(self): - """Only ``None`` counts as unversioned; an empty string is still 'versioned'.""" - none_version = self._make_info("local", "f", None) - empty_version = self._make_info("local", "g", "") - deps: dependency_parser.CallDependencies = { - none_version: _leaf, - empty_version: _leaf, - } - - has, no = dependency_parser.split_by_version_availability(deps) - self.assertIn(none_version, no) - self.assertIn(empty_version, has) - - if __name__ == "__main__": unittest.main() From 50503f6c5f6f70f23294d6474cde52081b153502 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 18 Mar 2026 10:37:11 +0100 Subject: [PATCH 11/31] go recursive only if there is no version --- flowrep/models/parsers/dependency_parser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index f84adc4a..7b04dfb1 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -96,5 +96,6 @@ def get_call_dependencies( info = versions.VersionInfo.of(obj, version_scraping=version_scraping) call_dependencies[info] = obj - get_call_dependencies(obj, version_scraping, call_dependencies, visited) + if info.version is None: + get_call_dependencies(obj, version_scraping, call_dependencies, visited) return call_dependencies From 9e6ee43c4b6a220fdb6fadccdf4720f898bedbd7 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 18 Mar 2026 10:39:58 +0100 Subject: [PATCH 12/31] black --- flowrep/models/parsers/dependency_parser.py | 1 - tests/unit/models/parsers/test_dependency_parser.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index 7b04dfb1..df4826b2 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -85,7 +85,6 @@ def get_call_dependencies( return call_dependencies visited.add(func_fqn) - # Find variables that are used but not defined scope = object_scope.get_scope(func_or_var) for item in find_undefined_variables(func_or_var): diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index 52cfe4ad..e95d1900 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -18,7 +18,9 @@ def test_split_by_version_availability(self): mock_version_2: mock_func_2, } - has_version, no_version = dependency_parser.split_by_version_availability(call_dependencies) + has_version, no_version = dependency_parser.split_by_version_availability( + call_dependencies + ) self.assertIn(mock_version_1, has_version) self.assertIn(mock_version_2, no_version) From ee1c5ceeb45d902dc9024ed9318957731594ed57 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 18 Mar 2026 11:16:23 +0100 Subject: [PATCH 13/31] Add one more test --- tests/unit/models/parsers/test_dependency_parser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index e95d1900..c096ab8c 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -31,7 +31,7 @@ def test_split_by_version_availability(self): class TestUndefinedVariableVisitor(unittest.TestCase): def test_undefined_variable_visitor(self): source_code = """ -def test_function(a, b): +def test_function(a: int, b): c = a + b return d """ @@ -40,6 +40,7 @@ def test_function(a, b): visitor.visit(tree) self.assertIn("d", visitor.used_vars) + self.assertIn("int", visitor.used_vars) self.assertIn("a", visitor.defined_vars) self.assertIn("b", visitor.defined_vars) self.assertIn("c", visitor.defined_vars) From e2b19d5043650676603dd6f9f5715444aa9906b2 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Wed, 18 Mar 2026 10:16:44 +0000 Subject: [PATCH 14/31] [dependabot skip] Update env file --- .binder/environment.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.binder/environment.yml b/.binder/environment.yml index 13867887..e69de29b 100644 --- a/.binder/environment.yml +++ b/.binder/environment.yml @@ -1,10 +0,0 @@ -channels: -- conda-forge -dependencies: -- hatchling =1.29.0 -- hatch-vcs =0.5.0 -- python >=3.11, <3.14 -- networkx =3.6.1 -- pydantic =2.12.5 -- pyiron_snippets =1.2.1 -- python-workflow-definition =0.1.4 From 923e300c917cdc3c233001c3876dc59146715e80 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 18 Mar 2026 11:26:19 +0100 Subject: [PATCH 15/31] Align code --- tests/unit/models/parsers/test_dependency_parser.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index c096ab8c..bd8d163f 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -1,4 +1,5 @@ import ast +import textwrap import unittest from unittest.mock import MagicMock, patch from collections.abc import Callable @@ -31,11 +32,11 @@ def test_split_by_version_availability(self): class TestUndefinedVariableVisitor(unittest.TestCase): def test_undefined_variable_visitor(self): source_code = """ -def test_function(a: int, b): - c = a + b - return d -""" - tree = ast.parse(source_code) + def test_function(a: int, b): + c = a + b + return d + """ + tree = ast.parse(textwrap.dedent(source_code)) visitor = dependency_parser.UndefinedVariableVisitor() visitor.visit(tree) @@ -51,7 +52,7 @@ class TestFindUndefinedVariables(unittest.TestCase): def test_find_undefined_variables(self): def test_function(a, b): c = a + b - return d # 'd' is undefined + return d undefined_vars = dependency_parser.find_undefined_variables(test_function) self.assertIn("d", undefined_vars) From 0663d05ec04ac7727e70f796be8774c23fc3407d Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 18 Mar 2026 11:37:30 +0100 Subject: [PATCH 16/31] ruff --- flowrep/models/parsers/dependency_parser.py | 3 +-- tests/unit/models/parsers/test_dependency_parser.py | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index df4826b2..b128074f 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -4,12 +4,11 @@ import builtins import inspect import textwrap -import types from collections.abc import Callable from pyiron_snippets import versions -from flowrep.models.parsers import object_scope, parser_helpers +from flowrep.models.parsers import object_scope CallDependencies = dict[versions.VersionInfo, Callable] diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index bd8d163f..e93a691b 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -2,9 +2,8 @@ import textwrap import unittest from unittest.mock import MagicMock, patch -from collections.abc import Callable -from pyiron_snippets import versions -from flowrep.models.parsers import object_scope, dependency_parser + +from flowrep.models.parsers import dependency_parser class TestSplitByVersionAvailability(unittest.TestCase): @@ -50,12 +49,13 @@ def test_function(a: int, b): class TestFindUndefinedVariables(unittest.TestCase): def test_find_undefined_variables(self): + x = 1 def test_function(a, b): - c = a + b - return d + c = a + b + x + return c undefined_vars = dependency_parser.find_undefined_variables(test_function) - self.assertIn("d", undefined_vars) + self.assertIn("x", undefined_vars) self.assertNotIn("a", undefined_vars) self.assertNotIn("b", undefined_vars) self.assertNotIn("c", undefined_vars) From 0f301b7533e9eb41feab01e2d2b31d3f6b7e63a8 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Thu, 19 Mar 2026 16:51:46 +0100 Subject: [PATCH 17/31] Remove try except and add tests --- flowrep/models/parsers/dependency_parser.py | 5 +---- tests/unit/models/parsers/test_dependency_parser.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index b128074f..db77011e 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -87,10 +87,7 @@ def get_call_dependencies( # Find variables that are used but not defined scope = object_scope.get_scope(func_or_var) for item in find_undefined_variables(func_or_var): - try: - obj = object_scope.resolve_attribute_to_object(item, scope) - except (ValueError, TypeError): - continue + obj = object_scope.resolve_attribute_to_object(item, scope) info = versions.VersionInfo.of(obj, version_scraping=version_scraping) call_dependencies[info] = obj diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index e93a691b..2904f24a 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -1,6 +1,8 @@ import ast +import numpy as np import textwrap import unittest +from pyiron_snippets import versions from unittest.mock import MagicMock, patch from flowrep.models.parsers import dependency_parser @@ -50,6 +52,7 @@ def test_function(a: int, b): class TestFindUndefinedVariables(unittest.TestCase): def test_find_undefined_variables(self): x = 1 + def test_function(a, b): c = a + b + x return c @@ -90,6 +93,15 @@ def test_get_call_dependencies( "undefined_var", mock_scope ) + def test_type_hints(self): + def test_function(a: np.array, b: np.array): + return a + b + + self.assertDictEqual( + {versions.VersionInfo.of(np): np}, + dependency_parser.get_call_dependencies(test_function), + ) + if __name__ == "__main__": unittest.main() From 777a55827ebffec6c9e119ac1f648ddefe4deb59 Mon Sep 17 00:00:00 2001 From: Sam Dareska <37879103+samwaseda@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:12:44 +0100 Subject: [PATCH 18/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- flowrep/models/parsers/dependency_parser.py | 27 +++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index db77011e..d4697d94 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -57,11 +57,28 @@ def visit_FunctionDef(self, node): def find_undefined_variables( func_or_var: Callable | object, ) -> set[str]: - if callable(func_or_var): - source = textwrap.dedent(inspect.getsource(func_or_var)) - else: - source = str(func_or_var) - tree = ast.parse(source) + """ + Find variables that are used but not defined in the source of *func_or_var*. + + If the source code for *func_or_var* cannot be retrieved or parsed (e.g., + for certain built-in objects or when no source is available), this + function returns an empty set instead of raising an exception. + """ + try: + # Prefer actual source code over string representations for both + # callables and other inspectable objects (e.g. classes, modules). + raw_source = inspect.getsource(func_or_var) + except (OSError, TypeError): + # No reliable source available; treat as having no undefined variables. + return set() + + source = textwrap.dedent(raw_source) + + try: + tree = ast.parse(source) + except SyntaxError: + # Source could not be parsed as Python code; fail gracefully. + return set() visitor = UndefinedVariableVisitor() visitor.visit(tree) From 56aa4f2ecd4bc91679e3ac6744c431300c7bf78e Mon Sep 17 00:00:00 2001 From: Sam Dareska <37879103+samwaseda@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:13:53 +0100 Subject: [PATCH 19/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- flowrep/models/parsers/dependency_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index d4697d94..aa6e114e 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -10,7 +10,7 @@ from flowrep.models.parsers import object_scope -CallDependencies = dict[versions.VersionInfo, Callable] +CallDependencies = dict[versions.VersionInfo, object] def split_by_version_availability( From 3de03c5c6dafed1a5bbe940ca84b4318815515ba Mon Sep 17 00:00:00 2001 From: Sam Dareska <37879103+samwaseda@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:51:16 +0100 Subject: [PATCH 20/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/unit/models/parsers/test_dependency_parser.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index 2904f24a..6b1ba337 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -1,5 +1,4 @@ import ast -import numpy as np import textwrap import unittest from pyiron_snippets import versions From dcd510baf0294a6c4835575d5b74f6f5957f7742 Mon Sep 17 00:00:00 2001 From: Sam Dareska <37879103+samwaseda@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:52:25 +0100 Subject: [PATCH 21/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- flowrep/models/parsers/dependency_parser.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index aa6e114e..f725e581 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -48,11 +48,26 @@ def visit_Name(self, node): self.defined_vars.add(node.id) def visit_FunctionDef(self, node): + # Add the function name itself to defined variables + self.defined_vars.add(node.name) # Add function arguments to defined variables for arg in node.args.args: self.defined_vars.add(arg.arg) self.generic_visit(node) + def visit_AsyncFunctionDef(self, node): + # Add the async function name itself to defined variables + self.defined_vars.add(node.name) + # Add async function arguments to defined variables + for arg in node.args.args: + self.defined_vars.add(arg.arg) + self.generic_visit(node) + + def visit_ClassDef(self, node): + # Add the class name itself to defined variables + self.defined_vars.add(node.name) + self.generic_visit(node) + def find_undefined_variables( func_or_var: Callable | object, From 2d48634dea2ca1190f7f565da1a1119a9718ee47 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Thu, 19 Mar 2026 18:56:10 +0100 Subject: [PATCH 22/31] Remove tests for now --- tests/unit/models/parsers/test_dependency_parser.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index 6b1ba337..533e2bd0 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -92,15 +92,6 @@ def test_get_call_dependencies( "undefined_var", mock_scope ) - def test_type_hints(self): - def test_function(a: np.array, b: np.array): - return a + b - - self.assertDictEqual( - {versions.VersionInfo.of(np): np}, - dependency_parser.get_call_dependencies(test_function), - ) - if __name__ == "__main__": unittest.main() From bc0accd23c13b38bab8bac09f2c5958a5d3d8247 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:06:15 +0000 Subject: [PATCH 23/31] Initial plan From dbb1188843f9170ef5832e6e12a6b454bfa47a02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:10:27 +0000 Subject: [PATCH 24/31] Rewrite UndefinedVariableVisitor to forbid local function definitions Co-authored-by: samwaseda <37879103+samwaseda@users.noreply.github.com> --- flowrep/models/parsers/dependency_parser.py | 69 ++++++++++++++----- .../models/parsers/test_dependency_parser.py | 36 ++++++++++ 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/flowrep/models/parsers/dependency_parser.py b/flowrep/models/parsers/dependency_parser.py index f725e581..9f75215d 100644 --- a/flowrep/models/parsers/dependency_parser.py +++ b/flowrep/models/parsers/dependency_parser.py @@ -37,34 +37,67 @@ def split_by_version_availability( class UndefinedVariableVisitor(ast.NodeVisitor): + """AST visitor that collects used and locally-defined variable names. + + Local (nested) function definitions inside the analysed function body are + **not** supported: encountering one raises :exc:`NotImplementedError` so + that callers fail fast with a clear message instead of silently producing + wrong dependency results. + + Class definitions at any nesting level are tracked in :attr:`defined_vars` + so that class names used later in the same scope are not reported as + undefined symbols. + """ + def __init__(self): - self.used_vars = set() - self.defined_vars = set() + self.used_vars: set[str] = set() + self.defined_vars: set[str] = set() + self._nesting_depth: int = 0 - def visit_Name(self, node): - if isinstance(node.ctx, ast.Load): # Variable is being used + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, ast.Load): self.used_vars.add(node.id) - elif isinstance(node.ctx, ast.Store): # Variable is being defined + elif isinstance(node.ctx, ast.Store): self.defined_vars.add(node.id) - def visit_FunctionDef(self, node): - # Add the function name itself to defined variables + def _visit_function_def( + self, node: ast.FunctionDef | ast.AsyncFunctionDef + ) -> None: + if self._nesting_depth > 0: + keyword = ( + "async def" if isinstance(node, ast.AsyncFunctionDef) else "def" + ) + raise NotImplementedError( + f"Local function definitions are not supported: " + f"'{keyword} {node.name}' inside a function body cannot be " + "analysed for dependencies." + ) + # Register the function name and all of its parameters so that + # recursive calls and uses of any argument inside the body are not + # reported as undefined external symbols. self.defined_vars.add(node.name) - # Add function arguments to defined variables - for arg in node.args.args: + all_args = ( + node.args.posonlyargs + + node.args.args + + node.args.kwonlyargs + ) + for arg in all_args: self.defined_vars.add(arg.arg) + if node.args.vararg: + self.defined_vars.add(node.args.vararg.arg) + if node.args.kwarg: + self.defined_vars.add(node.args.kwarg.arg) + self._nesting_depth += 1 self.generic_visit(node) + self._nesting_depth -= 1 - def visit_AsyncFunctionDef(self, node): - # Add the async function name itself to defined variables - self.defined_vars.add(node.name) - # Add async function arguments to defined variables - for arg in node.args.args: - self.defined_vars.add(arg.arg) - self.generic_visit(node) + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function_def(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function_def(node) - def visit_ClassDef(self, node): - # Add the class name itself to defined variables + def visit_ClassDef(self, node: ast.ClassDef) -> None: self.defined_vars.add(node.name) self.generic_visit(node) diff --git a/tests/unit/models/parsers/test_dependency_parser.py b/tests/unit/models/parsers/test_dependency_parser.py index 533e2bd0..31d6b5bb 100644 --- a/tests/unit/models/parsers/test_dependency_parser.py +++ b/tests/unit/models/parsers/test_dependency_parser.py @@ -47,6 +47,42 @@ def test_function(a: int, b): self.assertIn("c", visitor.defined_vars) self.assertNotIn("d", visitor.defined_vars) + def test_all_argument_kinds_are_defined(self): + source_code = """ + def test_function(posonly, /, regular, *args, kw_only, **kwargs): + return posonly + regular + kw_only + """ + tree = ast.parse(textwrap.dedent(source_code)) + visitor = dependency_parser.UndefinedVariableVisitor() + visitor.visit(tree) + + for name in ("posonly", "regular", "args", "kw_only", "kwargs"): + self.assertIn(name, visitor.defined_vars) + + def test_local_function_definition_raises(self): + source_code = """ + def outer(x): + def helper(y): + return y + return helper(x) + """ + tree = ast.parse(textwrap.dedent(source_code)) + visitor = dependency_parser.UndefinedVariableVisitor() + with self.assertRaises(NotImplementedError): + visitor.visit(tree) + + def test_local_async_function_definition_raises(self): + source_code = """ + def outer(x): + async def helper(y): + return y + return helper(x) + """ + tree = ast.parse(textwrap.dedent(source_code)) + visitor = dependency_parser.UndefinedVariableVisitor() + with self.assertRaises(NotImplementedError): + visitor.visit(tree) + class TestFindUndefinedVariables(unittest.TestCase): def test_find_undefined_variables(self): From d62ecc81c793913d7a0e006a319a14df5ddc3cd5 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 26 Mar 2026 10:54:04 -0700 Subject: [PATCH 25/31] Fix module path in patch calls Signed-off-by: liamhuber --- tests/unit/parsers/test_dependency_parser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/parsers/test_dependency_parser.py b/tests/unit/parsers/test_dependency_parser.py index 57cf61a5..80e40f68 100644 --- a/tests/unit/parsers/test_dependency_parser.py +++ b/tests/unit/parsers/test_dependency_parser.py @@ -99,8 +99,8 @@ def test_function(a, b): class TestGetCallDependencies(unittest.TestCase): - @patch("flowrep.models.parsers.object_scope.get_scope") - @patch("flowrep.models.parsers.object_scope.resolve_attribute_to_object") + @patch("flowrep.parsers.object_scope.get_scope") + @patch("flowrep.parsers.object_scope.resolve_attribute_to_object") @patch("pyiron_snippets.versions.VersionInfo.of") def test_get_call_dependencies( self, mock_version_info_of, mock_resolve_attribute_to_object, mock_get_scope @@ -115,7 +115,7 @@ def test_get_call_dependencies( mock_get_scope.return_value = mock_scope with patch( - "flowrep.models.parsers.dependency_parser.find_undefined_variables" + "flowrep.parsers.dependency_parser.find_undefined_variables" ) as mock_find_undefined: mock_find_undefined.return_value = {"undefined_var"} call_dependencies = dependency_parser.get_call_dependencies(mock_func) From 69ac4e378ebdccf577030293633ca20ed3035618 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 1 Apr 2026 16:48:57 +0200 Subject: [PATCH 26/31] Include imports --- src/flowrep/parsers/dependency_parser.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/flowrep/parsers/dependency_parser.py b/src/flowrep/parsers/dependency_parser.py index 519def8c..607825db 100644 --- a/src/flowrep/parsers/dependency_parser.py +++ b/src/flowrep/parsers/dependency_parser.py @@ -53,6 +53,8 @@ def __init__(self): self.used_vars: set[str] = set() self.defined_vars: set[str] = set() self._nesting_depth: int = 0 + self.imports: list[ast.Import] = [] + self.import_froms: list[ast.ImportFrom] = [] def visit_Name(self, node: ast.Name) -> None: if isinstance(node.ctx, ast.Load): @@ -93,6 +95,12 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self.defined_vars.add(node.name) self.generic_visit(node) + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + self.import_froms.append(node) + + def visit_Import(self, node: ast.Import) -> None: + self.imports.append(node) + def find_undefined_variables( func_or_var: Callable | object, From 94ffe706d90a7aaf729635c46968472e24dcac97 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 1 Apr 2026 20:14:37 +0200 Subject: [PATCH 27/31] Restructure find_undefined_raviables --- src/flowrep/parsers/dependency_parser.py | 20 +++++++++------- tests/unit/parsers/test_dependency_parser.py | 24 ++++++++++++-------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/flowrep/parsers/dependency_parser.py b/src/flowrep/parsers/dependency_parser.py index 607825db..0e7f4e3a 100644 --- a/src/flowrep/parsers/dependency_parser.py +++ b/src/flowrep/parsers/dependency_parser.py @@ -104,7 +104,7 @@ def visit_Import(self, node: ast.Import) -> None: def find_undefined_variables( func_or_var: Callable | object, -) -> set[str]: +) -> dict[str, object]: """ Find variables that are used but not defined in the source of *func_or_var*. @@ -118,7 +118,7 @@ def find_undefined_variables( raw_source = inspect.getsource(func_or_var) except (OSError, TypeError): # No reliable source available; treat as having no undefined variables. - return set() + return {} source = textwrap.dedent(raw_source) @@ -126,12 +126,18 @@ def find_undefined_variables( tree = ast.parse(source) except SyntaxError: # Source could not be parsed as Python code; fail gracefully. - return set() + return {} visitor = UndefinedVariableVisitor() visitor.visit(tree) - undefined_vars = visitor.used_vars - visitor.defined_vars - return undefined_vars.difference(set(dir(builtins))) + undefined_vars = (visitor.used_vars - visitor.defined_vars).difference( + set(dir(builtins)) + ) + scope = object_scope.get_scope(func_or_var) + return { + item: object_scope.resolve_attribute_to_object(item, scope) + for item in undefined_vars + } def get_call_dependencies( @@ -150,9 +156,7 @@ def get_call_dependencies( visited.add(func_fqn) # Find variables that are used but not defined - scope = object_scope.get_scope(func_or_var) - for item in find_undefined_variables(func_or_var): - obj = object_scope.resolve_attribute_to_object(item, scope) + for obj in find_undefined_variables(func_or_var).values(): info = versions.VersionInfo.of(obj, version_scraping=version_scraping) call_dependencies[info] = obj diff --git a/tests/unit/parsers/test_dependency_parser.py b/tests/unit/parsers/test_dependency_parser.py index 80e40f68..c1afd0d3 100644 --- a/tests/unit/parsers/test_dependency_parser.py +++ b/tests/unit/parsers/test_dependency_parser.py @@ -83,14 +83,16 @@ async def helper(y): visitor.visit(tree) -class TestFindUndefinedVariables(unittest.TestCase): - def test_find_undefined_variables(self): - x = 1 +x = 1 + + +def test_function(a, b): + c = a + b + x + return c - def test_function(a, b): - c = a + b + x - return c +class TestFindUndefinedVariables(unittest.TestCase): + def test_find_undefined_variables(self): undefined_vars = dependency_parser.find_undefined_variables(test_function) self.assertIn("x", undefined_vars) self.assertNotIn("a", undefined_vars) @@ -114,17 +116,21 @@ def test_get_call_dependencies( mock_scope = MagicMock() mock_get_scope.return_value = mock_scope + mock_undefined_var = "undefined_var" + mock_resolved_obj = MagicMock() + mock_resolve_attribute_to_object.return_value = mock_resolved_obj + with patch( "flowrep.parsers.dependency_parser.find_undefined_variables" ) as mock_find_undefined: - mock_find_undefined.return_value = {"undefined_var"} + mock_find_undefined.return_value = {mock_undefined_var: mock_resolved_obj} call_dependencies = dependency_parser.get_call_dependencies(mock_func) self.assertIn(mock_version_info, call_dependencies) - self.assertEqual(call_dependencies[mock_version_info], mock_func) + self.assertEqual(call_dependencies[mock_version_info], mock_resolved_obj) mock_get_scope.assert_called_once_with(mock_func) mock_resolve_attribute_to_object.assert_called_once_with( - "undefined_var", mock_scope + mock_undefined_var, mock_scope ) From 4bc617cd68c57b9f36e4f15e3b66dc3856380426 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 1 Apr 2026 20:58:21 +0200 Subject: [PATCH 28/31] Remove tests to make copilot write it --- tests/unit/parsers/test_dependency_parser.py | 34 -------------------- 1 file changed, 34 deletions(-) diff --git a/tests/unit/parsers/test_dependency_parser.py b/tests/unit/parsers/test_dependency_parser.py index c1afd0d3..826e0e73 100644 --- a/tests/unit/parsers/test_dependency_parser.py +++ b/tests/unit/parsers/test_dependency_parser.py @@ -100,39 +100,5 @@ def test_find_undefined_variables(self): self.assertNotIn("c", undefined_vars) -class TestGetCallDependencies(unittest.TestCase): - @patch("flowrep.parsers.object_scope.get_scope") - @patch("flowrep.parsers.object_scope.resolve_attribute_to_object") - @patch("pyiron_snippets.versions.VersionInfo.of") - def test_get_call_dependencies( - self, mock_version_info_of, mock_resolve_attribute_to_object, mock_get_scope - ): - mock_func = MagicMock() - mock_version_info = MagicMock() - mock_version_info.fully_qualified_name = "mock_func" - mock_version_info_of.return_value = mock_version_info - mock_resolve_attribute_to_object.return_value = mock_func - - mock_scope = MagicMock() - mock_get_scope.return_value = mock_scope - - mock_undefined_var = "undefined_var" - mock_resolved_obj = MagicMock() - mock_resolve_attribute_to_object.return_value = mock_resolved_obj - - with patch( - "flowrep.parsers.dependency_parser.find_undefined_variables" - ) as mock_find_undefined: - mock_find_undefined.return_value = {mock_undefined_var: mock_resolved_obj} - call_dependencies = dependency_parser.get_call_dependencies(mock_func) - - self.assertIn(mock_version_info, call_dependencies) - self.assertEqual(call_dependencies[mock_version_info], mock_resolved_obj) - mock_get_scope.assert_called_once_with(mock_func) - mock_resolve_attribute_to_object.assert_called_once_with( - mock_undefined_var, mock_scope - ) - - if __name__ == "__main__": unittest.main() From 924362450f90f68d991875a2ea96f6c0c514d7a2 Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 1 Apr 2026 21:01:42 +0200 Subject: [PATCH 29/31] ruff-check --- tests/unit/parsers/test_dependency_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/parsers/test_dependency_parser.py b/tests/unit/parsers/test_dependency_parser.py index 826e0e73..ddfa7ace 100644 --- a/tests/unit/parsers/test_dependency_parser.py +++ b/tests/unit/parsers/test_dependency_parser.py @@ -1,7 +1,7 @@ import ast import textwrap import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from flowrep.parsers import dependency_parser From 1e85c8b155fa7ef3d07817ebc9d9f82d4da32f59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:07:35 +0000 Subject: [PATCH 30/31] Fix mypy type annotation issues in dependency_parser.py Agent-Logs-Url: https://github.com/pyiron/flowrep/sessions/8c405509-6e9f-4dd2-98f3-08f895990401 Co-authored-by: samwaseda <37879103+samwaseda@users.noreply.github.com> --- src/flowrep/parsers/dependency_parser.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/flowrep/parsers/dependency_parser.py b/src/flowrep/parsers/dependency_parser.py index 0e7f4e3a..046b3d77 100644 --- a/src/flowrep/parsers/dependency_parser.py +++ b/src/flowrep/parsers/dependency_parser.py @@ -5,6 +5,7 @@ import inspect import textwrap from collections.abc import Callable +from typing import Any from pyiron_snippets import versions @@ -103,7 +104,7 @@ def visit_Import(self, node: ast.Import) -> None: def find_undefined_variables( - func_or_var: Callable | object, + func_or_var: Callable[..., Any] | type[Any], ) -> dict[str, object]: """ Find variables that are used but not defined in the source of *func_or_var*. @@ -141,7 +142,7 @@ def find_undefined_variables( def get_call_dependencies( - func_or_var: Callable | object, + func_or_var: Callable[..., Any] | type[Any], version_scraping: versions.VersionScrapingMap | None = None, _call_dependencies: CallDependencies | None = None, _visited: set[str] | None = None, @@ -160,6 +161,7 @@ def get_call_dependencies( info = versions.VersionInfo.of(obj, version_scraping=version_scraping) call_dependencies[info] = obj - if info.version is None: - get_call_dependencies(obj, version_scraping, call_dependencies, visited) + if callable(obj) or isinstance(obj, type): + if info.version is None: + get_call_dependencies(obj, version_scraping, call_dependencies, visited) return call_dependencies From 5cfaab2208b911f6094429a0e3e3cd78549fecff Mon Sep 17 00:00:00 2001 From: Sam Waseda Date: Wed, 1 Apr 2026 21:12:52 +0200 Subject: [PATCH 31/31] ruff check --- src/flowrep/parsers/dependency_parser.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/flowrep/parsers/dependency_parser.py b/src/flowrep/parsers/dependency_parser.py index 046b3d77..f6c4067a 100644 --- a/src/flowrep/parsers/dependency_parser.py +++ b/src/flowrep/parsers/dependency_parser.py @@ -161,7 +161,6 @@ def get_call_dependencies( info = versions.VersionInfo.of(obj, version_scraping=version_scraping) call_dependencies[info] = obj - if callable(obj) or isinstance(obj, type): - if info.version is None: - get_call_dependencies(obj, version_scraping, call_dependencies, visited) + if (callable(obj) or isinstance(obj, type)) and info.version is None: + get_call_dependencies(obj, version_scraping, call_dependencies, visited) return call_dependencies