From 1e8f35ef58cd739069a36258cc2dd68a06daa64c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 10:23:37 -0400 Subject: [PATCH 1/2] fix(python): resolve module-level functions in get_method (#246) get_method(scope, name) delegated straight to get_all_methods_in_class, so any scope naming a module rather than a class came back empty. Since get_all_callers/get_all_callees call get_method internally, they silently reported the false-empty {"caller_details": []} / {"callee_details": []} for module-level functions even when the call graph knew the true edge. Local backend: resolve scope the same way get_all_methods_in_application already does (class signature or module name as the outer key). Neo4j backend: try the class path first (get_class), and fall back to a new targeted _get_module_functions query scoped by module_name so the fix stays as cheap as the existing class lookup instead of paying a whole-symbol-table fan-out per call. Miss-shape semantics (None / empty caller_details) are unchanged. --- .../python/codeanalyzer/codeanalyzer.py | 20 +- cldk/analysis/python/neo4j/neo4j_backend.py | 23 +- .../python/test_python_method_lookup.py | 232 ++++++++++++++++++ .../python/test_python_neo4j_backend.py | 18 ++ 4 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 tests/analysis/python/test_python_method_lookup.py diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index 703afca..ff7539d 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -468,21 +468,29 @@ def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, PyCal return dict(cls.methods) if cls else {} def get_method(self, qualified_class_name: str, qualified_method_name: str) -> PyCallable | None: - """Return a specific method by class and method name. + """Return a specific method or module-level function by scope and name. - Supports both fully qualified method names and simple method names. - When a simple name is provided, falls back to matching by the - method's ``name`` attribute. + ``qualified_class_name`` is looked up the same way as + :meth:`get_all_methods_in_application`'s outer keys: a class signature resolves to that + class's methods, and a module name (``PyModule.module_name``) resolves to that module's + top-level functions. Supports both fully qualified method names and simple method names; + when a simple name is provided, falls back to matching by the callable's ``name`` + attribute. + + Note: + Callables nested inside another callable (``inner_callables``) are not reachable via + this lookup — only top-level class methods and top-level module functions are. Args: - qualified_class_name: The fully qualified class name. + qualified_class_name: The fully qualified class name, or a module name for + module-level functions. qualified_method_name: The method name or signature to find. Returns: The :class:`~cldk.models.python.PyCallable` object, or ``None`` if not found. """ - methods = self.get_all_methods_in_class(qualified_class_name) + methods = self.get_all_methods_in_application().get(qualified_class_name, {}) if qualified_method_name in methods: return methods[qualified_method_name] # Fallback: match by short name when only the simple name is given. diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index 260d355..e01e817 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -436,8 +436,29 @@ def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, PyCal cls = self.get_class(qualified_class_name) return dict(cls.methods) if cls else {} + def _get_module_functions(self, module_name: str) -> Dict[str, PyCallable]: + """Fetch a module's top-level functions by ``module_name`` (not ``file_key``) — the scope + key ``get_method`` accepts for module-level lookups, mirroring + ``get_all_methods_in_application``'s module outer key. A single scoped query, so it stays + as cheap as the class path instead of paying the whole-symbol-table fan-out. + """ + rows = self._run( + "MATCH (m:PyModule {module_name: $name})-[:PY_DECLARES]->(f:PyCallable) " + "WHERE m.file_key IN $mods RETURN properties(f) AS p", + name=module_name, + mods=self._modules, + ) + return {fn.name: fn for fn in (self._callable_full(r["p"]) for r in rows)} + def get_method(self, qualified_class_name: str, qualified_method_name: str) -> PyCallable | None: - methods = self.get_all_methods_in_class(qualified_class_name) + """Return a specific method or module-level function by scope and name (see + :meth:`PythonAnalysisBackend.get_method`). + + ``qualified_class_name`` resolves as a class signature first; if no such class exists it + is treated as a module name and resolved against that module's top-level functions. + """ + cls = self.get_class(qualified_class_name) + methods = dict(cls.methods) if cls is not None else self._get_module_functions(qualified_class_name) if qualified_method_name in methods: return methods[qualified_method_name] for sig, callable_ in methods.items(): diff --git a/tests/analysis/python/test_python_method_lookup.py b/tests/analysis/python/test_python_method_lookup.py new file mode 100644 index 0000000..a242c1f --- /dev/null +++ b/tests/analysis/python/test_python_method_lookup.py @@ -0,0 +1,232 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Regression tests for issue #246: ``get_method`` was blind to module-level functions. + +``get_method(scope, name)`` used to delegate straight to ``get_all_methods_in_class(scope)``, so +any ``scope`` that names a *module* rather than a *class* came back empty — and since +``get_all_callers`` / ``get_all_callees`` call ``get_method`` internally, they silently reported +``{"caller_details": []}`` / ``{"callee_details": []}`` for module-level functions even when the +call graph knew the true edge. + +These tests build a tiny fixture with a ``pkg.mod.entry -> pkg.mod.helper`` call edge and exercise +both backends: + +* :class:`PyCodeanalyzer` — a hand-built in-memory ``PyApplication`` attached to a bare instance + (same pattern as ``test_python_bulk_accessors.py``), no analyzer run needed. +* :class:`PyNeo4jBackend` — a bare instance with ``_run`` monkeypatched to a tiny in-memory Cypher + stub, since no live Neo4j server is available in this environment. This exercises the backend's + real dispatch/query-construction logic (which query gets issued and how the row is turned back + into a ``PyCallable``); it does not touch the neo4j driver or a real graph. +""" + +from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyCallEdge, PyClass, PyModule + +from cldk.analysis.python.codeanalyzer.codeanalyzer import PyCodeanalyzer +from cldk.analysis.python.neo4j import PyNeo4jBackend + +# ---------------------------------------------------------------------------------------------- +# Shared fixture data: module "pkg.mod" declares two top-level functions, entry -> helper. +# ---------------------------------------------------------------------------------------------- +MODULE_NAME = "pkg.mod" +ENTRY_SIG = "pkg.mod.entry" +HELPER_SIG = "pkg.mod.helper" + + +def _local_backend(): + """A PyCodeanalyzer wired to a hand-built application, bypassing the analyzer run.""" + entry = PyCallable(name="entry", path="pkg/mod.py", signature=ENTRY_SIG, code="helper()") + helper = PyCallable(name="helper", path="pkg/mod.py", signature=HELPER_SIG, code="return 1") + module = PyModule( + file_path="pkg/mod.py", + module_name=MODULE_NAME, + functions={"entry": entry, "helper": helper}, + ) + app = PyApplication( + symbol_table={"pkg/mod.py": module}, + call_graph=[PyCallEdge(source=ENTRY_SIG, target=HELPER_SIG)], + ) + + backend = object.__new__(PyCodeanalyzer) + backend.application = app + backend.call_graph = None + return backend + + +def _fake_cypher(classes, methods, modules, call_edges): + """A minimal in-memory Cypher stub matching the query shapes PyNeo4jBackend issues. + + ``classes``: {signature: props} (top-level classes, matched via ``PyModule)-[:PY_DECLARES]-> + (c:PyClass``). ``methods``: {class_signature: [props, ...]}. ``modules``: {module_name: + {"file_key": ..., "functions": [props, ...]}}. Anything else (attributes, inner + classes/callables, call sites, local variables) yields no rows, matching a fixture with no + such children — each check below is ordered most-specific first so it never falls through to + a broader, wrong match. + """ + + def run(query, **params): + if "PyModule)-[:PY_DECLARES]->(c:PyClass {signature: $sig})" in query: # get_class (top-level only) + props = classes.get(params["sig"]) + return [{"p": props}] if props else [] + if "PY_HAS_METHOD" in query: # class -> methods + return [{"p": p} for p in methods.get(params["sig"], [])] + if "PyModule {module_name: $name})-[:PY_DECLARES]->(f:PyCallable)" in query: # module -> functions + mod = modules.get(params["name"]) + return [{"p": p} for p in mod["functions"]] if mod else [] + if "PY_CALLS" in query: + return [{"src": e[0], "tgt": e[1], "p": {"weight": 1, "provenance": []}} for e in call_edges] + return [] # attributes / inner classes / inner callables / call sites / local vars: none in this fixture + + return run + + +def _neo4j_backend(): + """A bare PyNeo4jBackend with ``_run`` stubbed (no live server, no __init__ side effects).""" + entry_props = {"name": "entry", "signature": ENTRY_SIG, "path": "pkg/mod.py", "code": "helper()"} + helper_props = {"name": "helper", "signature": HELPER_SIG, "path": "pkg/mod.py", "code": "return 1"} + modules = {MODULE_NAME: {"file_key": "pkg/mod.py", "functions": [entry_props, helper_props]}} + call_edges = [(ENTRY_SIG, HELPER_SIG)] + + backend = object.__new__(PyNeo4jBackend) + backend.application_name = "test_app" + backend._database = None + backend._driver = None + backend._session_obj = None + backend._modules = ["pkg/mod.py"] + backend._call_graph = None + backend._run = _fake_cypher(classes={}, methods={}, modules=modules, call_edges=call_edges) + return backend + + +BACKEND_FACTORIES = {"local": _local_backend, "neo4j": _neo4j_backend} + + +# ---------------------------------------------------------------------------------------------- +# get_method +# ---------------------------------------------------------------------------------------------- +def test_get_method_resolves_module_level_function_local(): + backend = _local_backend() + method = backend.get_method(MODULE_NAME, "helper") + assert method is not None + assert method.signature == HELPER_SIG + + +def test_get_method_resolves_module_level_function_neo4j(): + backend = _neo4j_backend() + method = backend.get_method(MODULE_NAME, "helper") + assert method is not None + assert method.signature == HELPER_SIG + + +def test_get_method_missing_module_function_returns_none(): + """Miss-shape semantics are unchanged: a genuinely absent function is still None.""" + for factory in BACKEND_FACTORIES.values(): + backend = factory() + assert backend.get_method(MODULE_NAME, "does_not_exist") is None + assert backend.get_method("no.such.module", "helper") is None + + +# ---------------------------------------------------------------------------------------------- +# get_all_callers / get_all_callees +# ---------------------------------------------------------------------------------------------- +def test_get_all_callers_finds_true_predecessor_for_module_function_local(): + backend = _local_backend() + result = backend.get_all_callers(MODULE_NAME, "helper") + assert result["target_method"] == HELPER_SIG + assert [c["caller_signature"] for c in result["caller_details"]] == [ENTRY_SIG] + + +def test_get_all_callers_finds_true_predecessor_for_module_function_neo4j(): + backend = _neo4j_backend() + result = backend.get_all_callers(MODULE_NAME, "helper") + assert result["target_method"] == HELPER_SIG + assert [c["caller_signature"] for c in result["caller_details"]] == [ENTRY_SIG] + + +def test_get_all_callees_finds_true_successor_for_module_function_local(): + backend = _local_backend() + result = backend.get_all_callees(MODULE_NAME, "entry") + assert result["source_method"] == ENTRY_SIG + assert [c["callee_signature"] for c in result["callee_details"]] == [HELPER_SIG] + + +def test_get_all_callees_finds_true_successor_for_module_function_neo4j(): + backend = _neo4j_backend() + result = backend.get_all_callees(MODULE_NAME, "entry") + assert result["source_method"] == ENTRY_SIG + assert [c["callee_signature"] for c in result["callee_details"]] == [HELPER_SIG] + + +def test_get_all_callers_missing_method_stays_false_empty(): + """Miss-shape semantics are unchanged: a genuinely absent method still yields the empty shape.""" + for factory in BACKEND_FACTORIES.values(): + backend = factory() + assert backend.get_all_callers(MODULE_NAME, "does_not_exist") == {"caller_details": []} + assert backend.get_all_callees(MODULE_NAME, "does_not_exist") == {"callee_details": []} + + +# ---------------------------------------------------------------------------------------------- +# backend parity (fix contract #3: both backends fixed identically) +# ---------------------------------------------------------------------------------------------- +def test_backend_parity_for_module_level_lookup(): + local, neo4j = _local_backend(), _neo4j_backend() + + assert local.get_method(MODULE_NAME, "helper").signature == neo4j.get_method(MODULE_NAME, "helper").signature + assert local.get_all_callers(MODULE_NAME, "helper") == neo4j.get_all_callers(MODULE_NAME, "helper") + assert local.get_all_callees(MODULE_NAME, "entry") == neo4j.get_all_callees(MODULE_NAME, "entry") + + +# ---------------------------------------------------------------------------------------------- +# regression: class-scoped lookup keeps working (get_method must not become module-only) +# ---------------------------------------------------------------------------------------------- +def test_get_method_still_resolves_class_methods_local(): + greet = PyCallable(name="greet", path="pkg/models.py", signature="pkg.models.Entity.greet", code="...") + entity = PyClass(name="Entity", signature="pkg.models.Entity", methods={"greet": greet}) + module = PyModule(file_path="pkg/models.py", module_name="pkg.models", classes={"pkg.models.Entity": entity}) + app = PyApplication(symbol_table={"pkg/models.py": module}) + + backend = object.__new__(PyCodeanalyzer) + backend.application = app + backend.call_graph = None + + method = backend.get_method("pkg.models.Entity", "greet") + assert method is not None + assert method.signature == "pkg.models.Entity.greet" + assert backend.get_method("pkg.models.Entity", "nope") is None + + +def test_get_method_still_resolves_class_methods_neo4j(): + entity_props = {"name": "Entity", "signature": "pkg.models.Entity", "code": "class Entity: ..."} + greet_props = {"name": "greet", "signature": "pkg.models.Entity.greet", "path": "pkg/models.py", "code": "..."} + + backend = object.__new__(PyNeo4jBackend) + backend.application_name = "test_app" + backend._database = None + backend._driver = None + backend._session_obj = None + backend._modules = ["pkg/models.py"] + backend._call_graph = None + backend._run = _fake_cypher( + classes={"pkg.models.Entity": entity_props}, + methods={"pkg.models.Entity": [greet_props]}, + modules={}, + call_edges=[], + ) + + method = backend.get_method("pkg.models.Entity", "greet") + assert method is not None + assert method.signature == "pkg.models.Entity.greet" + assert backend.get_method("pkg.models.Entity", "nope") is None diff --git a/tests/analysis/python/test_python_neo4j_backend.py b/tests/analysis/python/test_python_neo4j_backend.py index 68991ef..28fec0e 100644 --- a/tests/analysis/python/test_python_neo4j_backend.py +++ b/tests/analysis/python/test_python_neo4j_backend.py @@ -79,6 +79,14 @@ def _decorate(s): return s.upper() return _decorate(f"hi {who}") + + +def helper(x: str) -> str: + return x.upper() + + +def entry(x: str) -> str: + return helper(x) ''' SERVICE_PY = '''\ @@ -250,3 +258,13 @@ def edgeset(g): assert ref.get_all_callers("pkg.models.User", "describe") == neo.get_all_callers("pkg.models.User", "describe") assert ref.get_all_callees("pkg.models.User", "describe") == neo.get_all_callees("pkg.models.User", "describe") assert set(map(tuple, ref.get_class_call_graph("pkg.models.User"))) == set(map(tuple, neo.get_class_call_graph("pkg.models.User"))) + + # Regression (#246): get_method / get_all_callers / get_all_callees must resolve module-level + # functions too, scoped by module name rather than class name — "pkg.models.entry" calls + # "pkg.models.helper". + assert ref.get_method("pkg.models", "helper").signature == neo.get_method("pkg.models", "helper").signature == "pkg.models.helper" + callers_ref = ref.get_all_callers("pkg.models", "helper") + callers_neo = neo.get_all_callers("pkg.models", "helper") + assert callers_ref == callers_neo + assert [c["caller_signature"] for c in callers_ref["caller_details"]] == ["pkg.models.entry"] + assert ref.get_all_callees("pkg.models", "entry") == neo.get_all_callees("pkg.models", "entry") From 0385257832afda5ac80061848078bd9e46bf90c9 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 11:40:54 -0400 Subject: [PATCH 2/2] docs(python): document module-level get_method semantics (#246) --- cldk/analysis/python/backend.py | 4 +++- cldk/analysis/python/codeanalyzer/codeanalyzer.py | 6 ++++++ cldk/analysis/python/python_analysis.py | 12 +++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index b80948e..c0c64ad 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -128,7 +128,9 @@ def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, PyCal @abstractmethod def get_method(self, qualified_class_name: str, qualified_method_name: str) -> PyCallable | None: - """A single method of a class.""" + """A single method or module-level function. ``qualified_class_name`` accepts either a + class signature (resolving to that class's methods) or a module name (resolving to that + module's top-level functions); returns ``None`` if neither resolves.""" @abstractmethod def get_method_parameters(self, qualified_class_name: str, qualified_method_name: str) -> List[str]: diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index ff7539d..8ddaced 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -481,6 +481,12 @@ def get_method(self, qualified_class_name: str, qualified_method_name: str) -> P Callables nested inside another callable (``inner_callables``) are not reachable via this lookup — only top-level class methods and top-level module functions are. + Note: + If a class signature ever equals a module's name (pathological but constructible, + e.g. class ``pkg.User`` vs file ``pkg/User.py``), this backend merges both under one + key in :meth:`get_all_methods_in_application`, while the Neo4j backend resolves + class-first — a resolution-order asymmetry between the two backends. + Args: qualified_class_name: The fully qualified class name, or a module name for module-level functions. diff --git a/cldk/analysis/python/python_analysis.py b/cldk/analysis/python/python_analysis.py index 590c7dd..3262309 100644 --- a/cldk/analysis/python/python_analysis.py +++ b/cldk/analysis/python/python_analysis.py @@ -612,21 +612,27 @@ def get_methods_in_class(self, qualified_class_name: str) -> Dict[str, PyCallabl def get_method( self, qualified_class_name: str, qualified_method_name: str ) -> PyCallable | None: - """Return a specific method by class and method name. + """Return a specific method or module-level function by scope and name. Retrieves detailed information about a single method, including its signature, parameters, return type, decorators, and body. + ``qualified_class_name`` is looked up the same way as + :meth:`get_all_methods_in_application`'s outer keys: a class signature resolves to that + class's methods, and a module name (``PyModule.module_name``) resolves to that module's + top-level functions. + Args: qualified_class_name: The fully qualified name of the class - containing the method (e.g., ``"mypackage.models.User"``). + containing the method (e.g., ``"mypackage.models.User"``), or a module name for + module-level functions. qualified_method_name: The name of the method to retrieve (e.g., ``"save"`` or ``"__init__"``). Returns: A :class:`~cldk.models.python.PyCallable` object containing all analyzed information about the method, or ``None`` if - the method is not found. + neither a matching class nor a matching module resolves. See Also: :meth:`get_methods_in_class`: For all methods of a class.