Python is a joy to work with and a challenge to analyze statically. The language was designed to be dynamic — import is a first-class runtime operation, modules can be loaded based on environment variables, and packages routinely ship with optional backends that only activate when certain extras are installed. For a vulnerability scanner that builds its reachability model purely from static code analysis, this is a catalog of edge cases waiting to produce wrong answers.
I've spent the better part of the past year thinking about how to get Python call-graph analysis right — not perfectly (that's not achievable), but accurately enough that the false positive rate doesn't erode engineer trust in the tool. Here are the patterns that create the most significant gaps, and how we handle each one in Patchlynx.
Conditional Imports and Optional Extras
The most common Python dependency pattern that breaks naive static analysis is the conditional import — code guarded by a try/except or by a boolean feature check:
try:
import ujson as json
except ImportError:
import json
This pattern appears constantly in packages that want to use a fast optional backend when available, falling back to stdlib. A static analyzer that doesn't understand the runtime context will either always treat ujson as reachable (because the import exists in the source) or always treat it as unreachable (because the try block might fail). Both are wrong in specific environments.
The same issue appears with pip extras. A package like requests has optional extras — requests[security] pulls in pyOpenSSL and cryptography. Whether those packages are in your environment depends entirely on how you invoked pip, not on what the source code says. A CVE in pyOpenSSL is only relevant if you installed the security extra.
Patchlynx resolves this by reading your actual installed environment — the pip freeze output or your requirements.txt alongside the lock file — not just the package's declared dependencies. If ujson is not in your installed packages, we remove it from the reachability graph regardless of what the try/except says. If it is installed, we include it and walk its call graph.
importlib and Dynamic Dispatch
Python's importlib module lets you load packages at runtime with string names:
backend = importlib.import_module(f"myapp.backends.{config.STORAGE_BACKEND}")
This is a legitimate and common pattern for plugin architectures, storage backend selection, and multi-cloud adapters. It's also a call-graph dead end for static analysis — without knowing what config.STORAGE_BACKEND resolves to at runtime, you can't know which backend module gets loaded.
We handle this with a conservative approximation: when we encounter a dynamic import whose module name we can't resolve at static analysis time, we enumerate all modules in the directory or package namespace that could match the pattern. If the pattern is myapp.backends.{X}, we include all modules under myapp/backends/ as potentially reachable.
This is a deliberate over-approximation. It means we might report a backend as reachable when in practice a specific deployment only ever uses one of three backends. We'd rather surface a possible false positive than miss a real one — and we surface it clearly in the output with a note that the import is dynamically resolved, so you can apply your own judgment about which backend actually runs in production.
__init__.py Side Effects and Implicit Imports
Python packages frequently use __init__.py to re-export symbols from submodules:
# mypackage/__init__.py
from mypackage.core import process
from mypackage.utils import validate
When you write from mypackage import process, you're importing from __init__.py — but the actual function lives in mypackage.core. Naive static analysis that follows import statements without resolving through __init__.py will build an incomplete call graph.
More problematic: some packages register hooks, patch global state, or load plugins in their __init__.py as a side effect of import. import mypackage might trigger calls to three other packages before any user code runs. These implicit edges are invisible to line-by-line call-graph walkers but represent real code execution in your process.
We resolve this by treating __init__.py as a special traversal node — we execute its import chain in our static model, following all re-exports and side-effect calls before marking the package as "analyzed." It adds analysis time but prevents the silent gap where a vulnerable function is reached only through an init-time hook.
Metaclasses, Descriptors, and Runtime Method Binding
Python's data model allows packages to define descriptors and metaclasses that bind methods at class definition time — not at call time. A method like myobj.save() might resolve to a function in an ORM's metaclass machinery that in turn calls a database adapter's serialization code. Tracing that call chain requires understanding Python's descriptor protocol, not just following explicit function calls.
We're not going to pretend we solve this perfectly. Heavy use of metaclass-based frameworks — SQLAlchemy's declarative base, Django's ORM, some parts of Pydantic — creates call-graph edges that pure static analysis can only approximate. For these frameworks we maintain a set of framework-specific model files: manually curated call-path maps for the entry points that matter most (query execution, serialization, authentication decorators). It's not elegant, but it's accurate for the 80% case.
We're working on a dynamic analysis layer — running your test suite under instrumentation to capture actual call paths — that would fill some of these gaps. That's not in the current version, and we're being honest about it here rather than pretending the static model is complete.
requirements.txt Splitting and Environment Markers
Many Python projects split their requirements across multiple files: requirements.txt, requirements-dev.txt, requirements-test.txt, sometimes requirements-prod.txt. PEP 508 also supports environment markers:
pywin32>=1.0; sys_platform == "win32"
uvloop>=0.17; sys_platform != "win32"
An SCA tool that scans requirements.txt without resolving environment markers will produce different results depending on where it runs. Scanning a Linux CI environment will miss the Windows-specific packages; scanning without a production environment marker filter will include packages that only install in dev.
Patchlynx reads the environment markers from your requirements files and resolves them against the target environment you specify (or infer from your CI configuration). We also respect the requirements-prod.txt scope convention — if you point us at the production requirements file, we scope the analysis to it. This sounds obvious, but it requires the tool to know which file is authoritative for production, which most teams haven't thought to configure.
What This Means for Your Python Triage Queue
Python's dynamism means reachability analysis for Python codebases will always have a larger uncertainty band than it does for statically-typed languages like Go or Java. We're not saying Python SCA is impossible to get right — we're saying the accuracy of any reachability tool on a Python codebase depends heavily on how it handles the patterns above.
Questions worth asking of any Python SCA tool you evaluate:
- Does it resolve installed extras against your actual pip freeze output, or against declared package extras?
- How does it handle
importlib.import_module()with variable names? - Does it follow
__init__.pyre-exports and side effects? - How does it handle environment markers in requirements files?
If the answer to any of those is "it doesn't" or "we treat everything as reachable to be safe," you're either getting false negatives (missed real risks) or false positives (noise that erodes trust), respectively. The goal is accurate reachability, not conservative reachability.
For the patterns where our analysis goes conservative — dynamic imports, metaclass-mediated calls — we surface that uncertainty in the output. You'll see a REACHABLE (approximate) label versus REACHABLE (confirmed) on findings where we made approximations. That distinction matters when you're deciding how urgently to schedule a patch.