Skip to main content

Python adapter

from callix import PythonAdapter

graph = PythonAdapter().analyze("path/to/project")
PythonAdapter(resolve=False).analyze(root) # structure only
PropertyValue
Language idpython
Project markerspyproject.toml (with a [project] section), setup.cfg, setup.py
Extensions.py, .pyi
Resolverembedded ty
External requirementsnone

What it extracts​

Modules as dotted names, classes, functions and methods, module- and class-level variables, attributes assigned through self or cls (one ATTRIBUTE of the class, however many methods assign it), type aliases, parameters, and imports — absolute and relative alike, with from . import x resolved against the importing module's package, wherever they are written: in a function body, or under if TYPE_CHECKING: or try:.

A lambda is a function nobody named: a FUNCTION node called <lambdaN> in the scope it is written in, numbered in the order that scope meets them (app.<lambda1>, app.make.<lambda2>), with metadata["lambda"] = true. Its parameters are PARAMETER nodes and its body belongs to it, so what a lambda calls is its own edge. A lambda that is assigned, passed as an argument or returned is followed to where it is called (resolution: "flow").

obj.x = v declares no local: it reads obj and writes the attribute x, which resolves to the class that defines it. Every target of a chained assignment (a = b = v) is declared. At module level the targets of for and except ... as are module variables, because other modules can import them.

Inside a function, a loop variable is a VARIABLE node only when the body of the loop calls it (for f in handlers: f()): a node per loop counter would bury the graph. That call is followed to what the iterable hands out: the elements of a container, what a generator yields (read like a return), what an iterator's __next__ returns.

Beyond ty, a flow pass follows a function carried in a value (see Accuracy): decorators, receivers, containers read by key, unpacking, parameter defaults, call results and a closed list of library functions that call an argument (map, filter, sorted(key=), …). Every such edge is marked resolution: "flow", with the holder it went through in via.

Occurrences are recorded for calls, identifier reads and writes, type annotations, and base classes; resolution turns them into CALLS, REFERENCES, HAS_TYPE and INHERITS_FROM.

Every name in an annotation is a use, not only the first: list[Item], Optional["Item"], dict[str, Item] | None, Callable[[A], R], a quoted forward reference, and annotated variables (module, class field, local, self.x: T) all produce a HAS_TYPE from the parameter, function, variable or attribute. In a qualified name such as models.Item the type is the last name: the HAS_TYPE goes to the class and models is a REFERENCES to the module. Literal[...] arguments and the metadata of Annotated[T, ...] are values, not types, and a string annotation that does not parse as a single expression is ignored.

The name of a keyword argument in a call references the project field it sets (Item(name=...) on a dataclass, a NamedTuple, a TypedDict, or a dataclass_transform model such as a pydantic one), with access: "read" and resolution: "definition". The keywords of an ordinary function or of a hand-written __init__, **kwargs and unknown names produce no edge.

Projects inside the directory​

A directory can hold Python projects of its own - repo/backend with a pyproject.toml and a src/ layout is the usual one - and each is imported from its own source root (repo/backend/src), which is not the directory analysed. callix hands those roots to the type checker, so from service.group_member import create resolves whether repo or repo/backend is passed. Node names follow the source root: backend/src/api/group_member.py is the module api.group_member.

from pkg import name brings in what pkg/__init__.py binds to name when it binds one - the function of a submodule of the same name, for instance - and not the submodule. The import then depends on the package, and the call is an edge to the function.

Project detection​

A pyproject.toml counts only when it has a [project] section — otherwise a Rust project keeping one for tooling would be mistaken for a Python project. Failing every marker, the adapter falls back to "is there any .py here", which is what makes Python subpackages inside a polyglot monorepo visible.

Dependency classification​

Third-party names come from pyproject.toml (PEP 621 and Poetry), requirements*.txt (with one level of -r expansion) and setup.cfg. Dev and test groups are included on purpose: without them, imports that appear only in tests would classify as unknown rather than third_party.

Standard-library names come from sys.stdlib_module_names of the running interpreter — a property of the Python you are using, not of the build, and not of the project being analysed. A module added to the standard library after your interpreter was released classifies as unknown rather than stdlib.

Boundaries​

FastAPI, Flask and Starlette routes; requests, httpx and session calls; gRPC servicer classes and stubs; Temporal and DBOS activities; publish / subscribe.

The embedded ty​

The resolver calls ty as a library rather than spawning ty server, which removes JSON-RPC and serialization from the hot path. Positions are counted in UTF-16, matching the LSP default the Python client used to negotiate.

One known divergence from ty server: goto_definition occasionally returns a file inside the bundled typeshed where the server would give a path in the project, and that occurrence falls through to an EXTERNAL_SYMBOL. The cause is that ty server marks files as open while a directly instantiated database does not. Measured on apache/superset: 22 nodes out of 225,706 — 0.01% — with edges and the resolved share matching exactly.