Skip to content

Module-scope calls to library targets are dropped from the call graph #131

Closed

Description

@rahlk

Problem

At -a 2, one decorator on a function produces a call-graph edge and another on the same
function produces none. Verified on main (6f02581) with a fixture whose module-level
plain_function carries @trace('hot') and @functools.lru_cache(maxsize=128):

app      -> trace(tag)   ['pycg']
Account  -> trace(tag)   ['pycg']
deco(fn) -> wraps        ['jedi', 'pycg']

functools.wraps gets an edge. functools.lru_cache — same module, same file, same decorator
position — gets none, and never appears in external_symbols either.

This was found while investigating #129. That issue closed because decoration is being made
recoverable through qualified_name rather than through edges (see
the spec, D5),
but the missing edge is a separate defect that survives that decision: it is a hole in the call
graph, not a question about how decoration is modelled.

Scope boundary

Diagnoses and fixes why this call edge is missing. Does not change how decoration is
represented (that is #128), does not add a decoration edge family, and does not re-attribute the
existing app -> trace(tag) edges — those are correct for evaluation order.

Goals

  • Root-cause why lru_cache is dropped where wraps is not — written down, not just patched
  • Determine whether the cause is specific to decorators-with-arguments, or affects ordinary
    calls too; if the latter, the fix belongs at the shared site, not the decorator path
  • Missing edge emitted, with its target homed in external_symbols
  • Regression test with a hand-written expected edge set

Caveats and known risks

  • Fix the cause, not the symptom. If PyCG mishandles a broader class of calls, patching only
    the decorator path leaves every sibling caller still broken.
  • PyCG is archived upstream (Nov 2023) and already carries local patches in
    semantic_analysis/pycg/pycg_analysis.py. A fix likely lands as another local patch, which is
    maintenance debt worth naming rather than absorbing silently.
  • L2 contract. New edges at L2 are additive and safe for L1 ⊆ L2. Changing existing edge
    endpoints is not, and is out of scope.
  • The Jedi backend may already resolve this correctly — worth checking whether the gap is
    PyCG-only before patching PyCG.

Definition of done

  • The root cause is stated in the PR description, not just fixed
  • @functools.lru_cache(maxsize=128) produces a call edge whose target is homed in
    external_symbols
  • The expected edge set for the fixture is written by hand first, then matched exactly
  • analysis.json(-a 1) ⊆ analysis.json(-a 2) still holds

Activity

  1. changed the title [-]PyCG drops the call edge for decorators with arguments (functools.lru_cache)[/-] [+]Module-scope calls to library targets are dropped from the call graph[/+] on Aug 20, 2026
  2. rahlk commented on Aug 20, 2026

    @rahlk
    ContributorAuthor

    Diagnosed — the original title was wrong on both counts, so I have retitled it.

    PyCG drops nothing. Its raw output for the fixture below contains both missing edges:

    app -> functools.cache
    app -> functools.lru_cache
    

    They are discarded downstream, in filter_external_edges (semantic_analysis/call_graph.py). That function keeps an edge when either endpoint is an app symbol, but builds app_symbols from callables and classes only — never modules. PyCG attributes a call made in module scope to the MODULE, so app -> functools.reduce has a module name on one end and a library on the other, matches neither set, and is dropped as if both ends were third-party.

    Not about arguments. A matrix over scope × called-or-bare × first-party-or-stdlib:

    decorator scope target edge?
    @local_bare module first-party ✅
    @local_factory("x") module first-party ✅
    @functools.cache module stdlib ❌
    @functools.lru_cache(maxsize=128) module stdlib ❌
    @functools.wraps(outer) inside a function stdlib ✅

    @functools.cache takes no arguments and fails; @local_factory("x") takes one and works.

    Not about decorators either. A plain module-level call with no decorator anywhere is lost identically:

    TOTAL = functools.reduce(lambda a, b: a + b, [1, 2, 3])   # no edge
    def in_a_function():
        return functools.reduce(lambda a, b: a + b, [4, 5])   # edge

    So every module-level call into stdlib or a dependency was missing from the call graph of every analyzed project. Decorators were only how it surfaced — which is why this issue asked for a root cause rather than a patch to the decorator path.

    Fixed in #143 by adding module names to app_symbols. They come from the analyzed symbol table rather than a denylist, so a first-party module shadowing a library name is still an app symbol (tested). The filter's real purpose is unchanged — a genuine library-to-library edge is still dropped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions