pythograph
Static facts for Python services (Django, Django REST framework, Flask, SQLAlchemy), emitted in the isthmus bridge-facts exchange format.
pythograph is the Python member of a family of static-analysis CLIs (tsograph for TypeScript/JavaScript, cartograph for Swift, kartograph for Kotlin, dartograph for Dart, gartograph for Go, rustograph for Rust, schemagraph for SQL). Each tool reports only what it observes in its own language; isthmus joins the documents.
The analyzed project is parsed with the standard-library ast only. It is never imported or executed,
and pythograph has no runtime dependencies and uses no network. The only program it starts is git at the project root,
to read revision for graph, reach, and impact; git runs without the filter commands that the repository's git
configuration defines, never fetches the objects that a partial clone lacks, and never runs inside submodules (see
Security).
Status
| Area | State |
|---|---|
pythograph routes --role server: Django URLconf, Django REST framework routers and views, Flask/Werkzeug rules → route-decl facts |
Implemented |
pythograph schema: Django models and QuerySets, SQLAlchemy 2.x / Flask-SQLAlchemy 3 mappings and queries, SQL text → persistence relation-use facts |
Implemented |
pythograph graph / reach / impact: Python call graph → isthmus language-traversal v1 (evidence tiers direct/bound/candidate, unresolvedCalls, Django/DRF/Flask dispatch) |
Implemented |
pythograph routes --role client: requests, httpx, aiohttp, and urllib calls plus declared HTTP wrappers → route-call facts |
Implemented |
isthmus-cli 0.10.0 (npm) is the first release that accepts platform: "python" http documents (server route-decl
including registration-order, and client route-call), persistence documents, and python language-traversal
analyses (see isthmus compatibility).
Requirements and installation
- Python 3.10 or newer (Django 5.x needs 3.10+, and pythograph parses the analyzed code with the running interpreter, so run it with the same or a newer Python than the project).
Install from PyPI:
uv tool install pythograph
# or
pipx install pythograph
pythograph --version
To try unreleased changes, install from GitHub (uv tool install git+https://github.com/ictechgy/pythograph, or pin a
release tag such as @v0.1.0). A wheel built from a checkout (uv build) installs the same way:
uv tool install dist/pythograph-<version>-py3-none-any.whl. The release steps are in
RELEASING.md (Korean).
pythograph routes --role server
pythograph routes --role server --project <root> [--service <name>] [--include-tests]
[--framework auto|django|flask] [--settings <module>]
[--dispatch specificity] [--generated-at <timestamp>] [--format json]
Writes a bridge-facts v1 document to stdout: platform: "python", target: "http", roles: ["server"],
dispatch, sourceSets, and one route-decl fact per (route, HTTP method).
--project(required): the project root.projectis its POSIX realpath and everylocation.pathis relative to it.--framework:auto(default) detects Django (aDJANGO_SETTINGS_MODULEdefault inmanage.py,wsgi.py, orasgi.py, or--settings) and Flask (aFlask(...)orBlueprint(...)object). A project with both is a usage error until you pick one.--settings: the Django settings module when the entry files do not name it.--include-tests: also emit routes declared in test sources (test_*.py,*_test.py,tests.py,conftest.py, files undertests/ortest/) withtestSource: trueandsourceSets.tests: "included".--dispatch specificity: declarespecificityfor a Django project and omitorder(see Decisions).--generated-at: a fixedgeneratedAtfor byte-identical output.- Exit codes:
0success (zero facts is still success, not proof of completeness),2unreadable project, more than 100,000 facts, output over 16 Mi characters, or an internal error (the message states the cause and a fix, never source text or absolute paths),64usage error.1is reserved.
Example (synthetic, compacted; the real output is key-sorted JSON with two-space indentation):
{
"dispatch": "registration-order",
"facts": [
{
"channel": "/catalog/items/{}/edit/",
"dynamic": false,
"kind": "route-decl",
"location": { "column": 10, "line": 20, "path": "catalog/urls.py" },
"method": "POST",
"order": { "group": "django:shop.urls", "index": 7 },
"paramConstraints": [{ "kind": "int", "segment": 2 }],
"pathAnchor": "root",
"symbol": { "qualifiedName": "catalog/views.py#ItemEditView.post", "usr": "catalog/views.py#ItemEditView.post" },
"trailingSlash": "strict"
}
],
"format": "bridge-facts",
"platform": "python",
"roles": ["server"],
"target": "http",
"tool": { "name": "pythograph", "version": "0.2.0" },
"version": 1
}
What is modeled
Every rule was checked against the installed package sources (Django 5.2.17, djangorestframework 3.18.1, Flask 3.1.3, Werkzeug 3.1.9). The full table with source files is in docs/HTTP-ROUTES.md (Korean).
- Django:
ROOT_URLCONFfrom the settings module (star imports of project settings modules are followed),urlpatternsbuilt with lists,+,+=,.append,.extend,.insert,path(),re_path(),include()(module strings, module objects, lists,(patterns, app_name)tuples, nested), default and registered path converters,re_pathregular expressions converted from their syntax tree when possible, function views (ANY, narrowed byrequire_http_methods/require_GET/require_POST/require_safe/api_view), class views (as_view(), handlers along the class chain,http_method_names), and the registration order (first match wins). - Django REST framework:
SimpleRouter/DefaultRouter(trailing_slash,use_regex_path, lookup settings,@actionwithdetail,methods,url_path, and.mapping), theDefaultRouterAPI root and format-suffix variants,format_suffix_patterns,APIView, generic views, and viewsets. - Flask:
Flask(...)andBlueprint(...)objects at module level or in app factories,@route,@get/@post/@put/@delete/@patch,add_url_rule,register_blueprint(nested,url_prefix),MethodView/View, Werkzeug converters (string,int,float,uuid,path,any, custom),strict_slashes, andmerge_slashes.
How facts are built
- channel: the canonical path template. A parameter filling a whole segment is
{}, a partial segment keeps its literal skeleton (/files/{}.json), a final parameter that can match/is{**}, and anything else that cannot be proven (a segment with two parameters, a middle catch-all, lookarounds, unanchored regular expressions) isdynamicwith aroute-coverage:limitation. Literals are in the decoded path space, so non-pchar characters are UTF-8 percent-encoded and%becomes%25. - method: uppercase verbs or
ANY.HEADnext toGETand automaticOPTIONSare not emitted (isthmus matches them withhead-as-getandoptions-any); an explicitly declaredOPTIONSis. Verbs outside the exchange method set (GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,TRACE), such asPURGEin Flaskmethodsor Djangohttp_method_names, are not emitted and not turned intoANY; the registration gets aroute-coverage:limitation scoped to its templates when they are proven. - paramConstraints:
int,slug,uuid,path(for{**}), orregexwith the pattern. The informationalpatternis left out when the regular expression text contains characters the exchange format forbids (control characters, U+2028/U+2029, or an unpaired surrogate). - trailingSlash: Django and strict Flask rules are
strict;/?in a regex and Flaskstrict_slashes=Falseareoptional; omitted after{**}. - order (Django):
{group: "django:<ROOT_URLCONF>", index: <depth-first position>}. - location: the route string argument (Django
path()/re_path(), Flask decorator oradd_url_rule), therouter.register()prefix for DRF routes, or the@actiondecorator for extra actions; 1-based line and 1-based UTF-8 byte column.
Symbol ids
symbol.usr is <project-relative POSIX path>#<lexical dotted name>, outermost declaration first and
without <locals>: catalog/views.py#item_list, blog/__init__.py#create_app.index,
catalog/views.py#ItemEditView.get, orders/views.py#OrderViewSet.list. Class handlers are named after the
class registered in the URL even when the method is inherited; the call graph (pythograph graph) has an
inherited-member node with the same id. Views defined outside the project have no usr and are counted under
missing-route-usrs:.
Limitations
When a value cannot be proven, pythograph does not guess: it emits dynamic, pathAnchor: "base", or a
limitation with one of the contract's prefixes, and adds limitationScopes only when it can prove an upper
bound. Examples: conditional registrations (if settings.DEBUG:) become route-coverage: scoped to their
templates; unresolved includes and third-party URL modules are scoped to their include prefix; the Django admin,
static(), django.contrib.staticfiles, and Flask static files are framework-provided-routes: with prefix
scopes (Flask static also with GET/HEAD); FORCE_SCRIPT_NAME, i18n_patterns, and blueprints whose
registration is not visible use a base anchor with unresolved-route-prefix:; a project that does not pin
Django 5, DRF 3, or Flask 3 gets route-framework-version-unknown:. A module constant or setting that is not
provable (see Bounded source scanning: bound twice, +=, assigned inside an if, loop,
try, with, or match block, rebound through global, or changed in place such as INSTALLED_APPS.append(...))
is unknown: a route string built from it is dynamic, a method list from it leaves the view with its known methods
or ANY and a scoped route-coverage:, a conditional FORCE_SCRIPT_NAME gives the base anchor, and an
INSTALLED_APPS changed this way gives an unscoped framework-provided-routes: limitation. A Flask app or blueprint
bound to such a name may be a different object at run time, so the rules and blueprint registrations that go through
that name get the base anchor with unresolved-route-prefix:.
Decisions
- Django is
registration-order, Flask isspecificity, as verified from the sources. isthmus releases beforef9dcd1drejectregistration-order;--dispatch specificitydeclares specificity for Django and omitsorder. That approximation can only produce false matches (a shadowed pattern matched), never false errors, because isthmus filters by method first and reports a method mismatch only when no candidate accepts the method, which is also when Django answers 405. - Shadowed patterns are still declarations; shadowing is the consumer's judgement from
order. - Conditional registrations are scoped limitations, not declarations.
pythograph routes --role client
pythograph routes --role client --project <root> [--wrappers <file>] [--service <name>]
[--include-tests] [--generated-at <timestamp>] [--format json]
Writes a bridge-facts v1 document with platform: "python", target: "http", roles: ["client"], and one
route-call fact per HTTP request expression. The full rule table with the verified library sources, the oracle
recording, and the end-to-end trace is in
docs/HTTP-CLIENTS.md (Korean).
- Libraries (resolved by name, never by a same-named project function): requests 2.34 (top-level functions and
Session), httpx 0.28 (top-level functions,Client/AsyncClientwithbase_url), aiohttp 3.x (ClientSessionwithbase_url,aiohttp.request), andurllib.request.urlopen(withRequest(method=)anddata). Clients are followed through single-assignment locals,with/async with, module variables, instance fields and class attributes whose every assignment is a client, client-typed annotations, and project subclasses of a client class. - Base joins use the isthmus style names:
httpx-base-url(the base always ends in/and every leading/of the path is stripped, so/xstays under the base path),aiohttp-base-url(RFC 3986:/xreplaces the base path; path-bearing bases and relative paths need aiohttp 3.11+, absolute URLs on a base session need 3.12+, proven from the project's lock/requirement files), and none for requests and urllib.urllib.parse.urljoinhas no vector yet, so only its absolute-URL and/-rooted forms are claimed. - URL strings: f-strings,
+,%formatting,str.format, provable module constants (bound exactly once, never rebound, noglobalor module-attribute writes, no same-named attribute write on an unknown receiver such assys.modules[__name__].API_ROOT = …, and not in a module that callsglobals()or module-levelvars()/locals()), class attributes and__init__fields with one literal value, and proven query-tail locals. An interpolation becomes{}only when it fills a whole segment; otherwise the fact isdynamicwith a maskedchannelPrefix(channelisnull, so no raw URL text leaves the tool). A literal host givesrootplusauthority; a dynamic host or an unknown base givesbase. - Wrappers (
--wrappers, isthmushttp-wrappersv1):"language": "python"entries;owneris a pythograph class id (api/client.py#Gateway) for methods and constructors (name: "__init__", dataclasses included) or a module path (api/net.py) for module functions.labelis a keyword argument,indexa positional argument (receiver excluded). Unknown fields and malformed entries exit 64; declarations that match nothing reporthttp-wrapper-unresolved:. A wrapper'sservicegoes on its facts in place of--service. When it differs from--service, the document-levelserviceis omitted (isthmus rejects a fact service that differs from the document's) and--servicestays on the other facts. - symbol.usr is the enclosing function, method, class body, or module id — the same ids as
graph/reach/impact. - Limitations:
route-call-coverage:(unmodeled request APIs such as urllib3,http.client,send/build_request, URL literals passed to receivers whose client type is unknown, scan gaps),ambiguous-base-join:,http-wrapper-undeclared:(functions that pipe a parameter into a request URL), andhttp-wrapper-unresolved:.
pythograph schema
pythograph schema --project <root> [--include-tests] [--settings <module>] [--generated-at <timestamp>] [--format json]
Writes a bridge-facts v1 document with platform: "python", target: "persistence" (or null when there are no
facts), and one relation-use fact per observed relation or column reference. isthmus joins it with a
platform: "sql" document (schemagraph facts --document <catalog>) under the persistence rules of
docs/GRAPH-EXCHANGE.md. The full rule table with source files is in docs/PERSISTENCE.md
(Korean).
- Django: model classes (abstract, proxy, multi-table inheritance,
Metainheritance) → tables (<app_label>_<model>truncated bytruncate_namefor the backend'smax_name_length, orMeta.db_table), fields → columns (db_column,<name>_idfor foreign keys, many-to-many tables and their columns), app labels fromINSTALLED_APPS/AppConfig, theDATABASESbackend, and django.contrib models. Uses: managers and QuerySet chains, lookups (author__profile__city, reverse relations,attname,pk),values/order_by/F/Q/aggregates,create/updatekeywords, related managers and forward relations on proven instances,raw(),RawSQL,extra(tables=), and cursor SQL. - SQLAlchemy 2.x / Flask-SQLAlchemy 3: Declarative classes (
DeclarativeBase,declarative_base(),db.Modelwith its snake_case names), mixins, single- and joined-table inheritance,__table_args__/MetaDataschemas, CoreTable,ForeignKey("t.c"),relationship(secondary=). Uses: statement entities (select,insert,update,delete,session.query,session.get,join),Model.column,Model.relationship,Model.query,filter_by, constructors,table.c.name, andtext(). - SQL text: the family's shared lexical extractor (the same vectors as tsograph, dartograph, cartograph, and
kartograph) for explicit SQL arguments, and uppercase SQL literals elsewhere (docstrings are skipped). Following the
rules shared by the persistence producers, an unqualified name that is a CTE of the same
;-separated statement, the operand of aFROMinside function arguments (EXTRACT(YEAR FROM created_at),TRIM,SUBSTRING; a subquery there still counts), and the operand ofIS [NOT] DISTINCT FROMare not relation uses. The rules apply as written: a CTE named like a real table also hides that table in the same statement (theusersread byWITH users AS (SELECT * FROM users …), anINSERT INTO userstarget), and a DMLFROMin parentheses after a word that is not a clause keyword (COPY (DELETE FROM t …)) reads as a function argument. - channel is the relation name as written or mapped (
schema.tableonly when qualified; no default schema is guessed), method is the column, and symbol.usr is the enclosing function, method, or model class with the same ids asroutes. A name that depends on an unknown backend, app label, or Flask-SQLAlchemy version, an unresolved model or lookup, a module constant that is not provable (db_table = TABLEafterTABLE += ..., a__tablename__built from a conditionally reassigned prefix), a CoreTableobject reached through a name that is not provable (select(users),__table__ = users, orsecondary=userswhenusersis reassigned), and SQL built at runtime becomedynamicfacts with adynamic-relation-names:limitation instead of guesses; such a constant passed toexecute()is counted underunresolved-sql-arguments:. - Test sources (unless
--include-tests) and migrations (Djangomigrations/, Alembicversions/) are not scanned; migrations describe past schemas.
pythograph graph / reach / impact
pythograph graph --project <root> [--include-tests] [--revision <id>] [--generated-at <timestamp>]
pythograph reach --project <root> [--dispatch direct|bound|candidates] [--max-depth <n>] [--max-reached <n>]
[--roots-from <file|->] [--include-tests] [--revision <id>] [--generated-at <timestamp>] [--] <id>...
pythograph impact (same options as reach)
Builds the project's Python call graph with the standard-library ast. graph writes pythograph's own snapshot
(pythograph-graph v1); reach writes the symbols the roots depend on (dependencies) and impact the symbols that
depend on them (dependents) as isthmus
language-traversal v1. Ids are the same
strings as symbol.usr in routes and schema. The full rules are in docs/GRAPH.md (Korean).
- Nodes: modules (
<path>#<module>), functions, methods, classes, nested definitions, and inherited members (<registered class>.<member>, for view handlers and for inherited members called on exact receivers). - Edges:
call,new(the project__init__, else the class),callback,reference,decorator,attribute(class-body attributes),inherit, anddispatch/framework(framework dispatch). Resolution follows imports (absolute, relative, aliases,__init__re-exports,*), module attributes, constructors,selfandsuper()through the C3 MRO of project classes, annotated and return-annotated receivers, module-level instances, and properties. - Evidence tiers: statically resolved edges are
direct; overrides in project subclasses forself,cls, and annotated receivers arecandidate. A method call on a receiver of unknown or overridable type getsboundedges (followed by--dispatch bound) only when every value observed flowing into the receiver is a project class instance: constructors, module-level instances, attributes assigned in__init__from constructor parameters (DI), function parameters whose call sites are all in the project, and factory return values. Open slots get noboundedge: public functions, classes, and module globals of a library (a project root withsetup.py/setup.cfgor apyproject.toml[project]/[tool.poetry]table), framework-invoked entry points (functions passed as values, decorated functions, classes with framework bases), computedgetattr/setattrand module namespaces,*args/**kwargsspreads, wrapping decorators, monkeypatching writes whose value is unknown,self/cls, and test sources (a separate program). Values that leave through library code and computed-name writes to receivers of unknown type are not modeled (reported underbound-assumptions:). The default staysdirect: bound linked no calls in the four dogfood apps. Module globals and class-body attributes are exact receivers only when nothing rewrites them. - No guessing: calls without a known target are counted by reason (
parameter,untyped-receiver,dynamic-attribute,getattr,dynamic-callee,unresolved-import,framework-callback, …) as each symbol'sunresolvedCalls. A method call on an untyped receiver is proven external only when no project class, module, or attribute write defines that name. - Framework dispatch: a table read with
astfrom the installed Django 5.2.17, DRF 3.18.1, and Flask 3.1.3 sources links the project hooks that theas_view()dispatch path (dispatch,initial, permission checks,__init__) and framework implementations (ModelViewSet.retrieve→get_object→get_queryset,ModelSerializer.save→create) call. Objects the framework builds from class attributes (serializer_class,permission_classes) count asframework-callbackunresolved calls. - Snapshot size: the
graphsnapshot is not an isthmus input, so it may be up to 256 Mi characters (about 2 bytes of extra memory per output character while encoding);reachandimpactkeep the 16 Mi isthmus input limit. The encoding is unchanged, so raising the cap alone changes no snapshot under 16 Mi characters. - Traversal documents: a
dispatchdeclaration, per-root lower-boundevidence,unresolvedCalls, one multi-root pass (compared with a per-root oracle on random graphs),--max-depth/--max-reachedtruncation, androotsTruncated. Roots that are not graph nodes are listed withoutsymbol; the document is written and the command exits64.revisionis--revisionor gitHEADwhen the work tree is clean (omitted, with arevision-unverified:limitation in the snapshot and the traversal documents, when a clean tree cannot be checked without running the repository's git configuration: a work tree with submodules, or a partial clone that lacks objects the check reads, see Security);graphRevisionis a SHA-256 of the graph content.
Validation
The oracle harness in experiments/oracle/ imports the synthetic fixtures in a scratch virtual environment
and compares pythograph's facts with Django's resolver traversal, DRF routers, and Flask's url_map
(tests/test_fixtures.py replays the recorded results offline):
| Target | Precision | Recall |
|---|---|---|
fixtures/django/drf-shop |
69/69 | 58/59 (one intentional dynamic lookahead pattern) |
fixtures/flask/blog-app |
28/28 | 27/27 |
HackSoftware/Django-Styleguide-Example a70ef43 (MIT, scratch clone) |
21/21 | 21/22 (the DEBUG-only static() route) |
Dogfooding on four public apps (Django-Styleguide-Example, babybuddy, microblog, and netbox, cloned only into a scratch directory) measured route precision against each framework's resolver, relation-use join rates through schemagraph and isthmus, handler-to-relation reachability, unresolved-call reasons, and runtime; the results and the fixed issues are in DOGFOOD.md (Korean).
The isthmus shared conformance vectors (conformance/, vendored from isthmus 2954375 and locked in
conformance.lock) pass 100% of the producer and producer:pythograph cases (135: template.grammar,
template.normalize, scope.validate, scope.applies, dispatch.validate, and 57 url-compose cases for query tails,
interpolation, normalization, stripping, masking, the rfc3986/httpx-base-url/aiohttp-base-url joins, and wrapper
argument binding); the dispatch.validate checker also runs on the routes golden output.
Client mock-server oracle. experiments/client_oracle/ runs the synthetic client fixtures/client/shop-client
in a scratch environment against a local http.server on 127.0.0.1 (name resolution and connects are redirected, no
external traffic) and records what requests, httpx, aiohttp, and urllib actually send. Recorded 2026-09-30: 35
scenarios, 31 matches, 4 dynamic, 0 mismatches (tests/test_client_oracle.py replays it offline).
Python client × Django server trace. experiments/client_e2e/ joins a synthetic Python client
(fixtures/e2e/py-client) with the Phase 6 Django server recording through isthmus trace (main 3a45450): all four
selected routes attach the Python call site (exact match) to the server handler, reach the calling screen functions,
and continue to the relation uses and tables (tests/test_client_e2e.py).
Phase 6 exit criterion (Django backend × iOS/Android chain). experiments/e2e/ joins a synthetic Django + DRF
server (fixtures/e2e/shop-api), a schemagraph catalog of its Django DDL, and route-calls plus reverse traversals of
synthetic iOS (cartograph) and Android (kartograph) clients with isthmus trace (workspace). The expected paths of the
three questions match: (a) API → DB tables + DB dependents, (b) API → client call sites → affected client symbols, and
(c) table → API → client. The Android recording needs kartograph 4c09d91 or later (Retrofit route-call usrs and
baseUrl joins), which attaches the Android order and checkout calls to the chain. tests/test_e2e_trace.py re-checks
the recorded inputs and outputs offline (the table is in
docs/GRAPH.md).
Persistence naming vectors (fixtures/persistence-naming/vectors.json) are recorded by importing synthetic models
with the real ORMs in a scratch environment (experiments/persistence/run_naming.py): Django 5.2.17 _meta names
quoted by each backend's connection.ops (sqlite3, postgresql, mysql, oracle), and SQLAlchemy 2.0.54 /
Flask-SQLAlchemy 3.1.1 mappers. pythograph matches 100% (Django 136/136 model-backend pairs, SQLAlchemy 9/9 and
Flask-SQLAlchemy 10/10 classes, all tables and columns). Joining pythograph schema output for the two persistence
fixtures with schemagraph catalogs of the DDL the ORMs create (experiments/persistence/run_e2e.py) gives no isthmus
errors (41 and 20 matches).
isthmus compatibility
isthmus-cli 0.10.0 (npm, npm install --global isthmus-cli@0.10.0) is the released version that accepts
platform: "python": http route-decl facts (Django's registration-order and order, with shadowing diagnostics),
client route-call facts (routes --role client), persistence relation-use facts, and python forward/reverse
analyses (language-traversal v1) in trace. Earlier isthmus releases reject python route-decl and route-call
documents as input errors. --dispatch specificity remains for those older releases.
Bounded source scanning
Source reads request at most 4 MiB plus one overflow byte. Directory enumeration consumes at most 200,000 entries
plus one overflow entry, with a maximum depth of 64. An oversized directory is omitted as a whole rather than selecting
an arbitrary filesystem-order subset; files from previously completed directories remain available. Unreadable directory
entries are counted in limitations for routes, schema, and graph output, and prevent closed-world bound proofs that
would otherwise rely on missing modules. A successful command with these limitations is still an incomplete analysis.
File paths become every fact's location.path and symbol id, so a Python file whose project-relative path is not
valid UTF-8 or contains characters the exchange format forbids (C0/C1 control characters including NEL, U+2028, or
U+2029) is not analyzed. It is counted in limitations instead (scan-incomplete: for graph, route-coverage: or
route-call-coverage: for routes, unreadable-sources: for schema) rather than producing a document that isthmus
rejects as a whole.
Directories that contain manage.py are module roots, because Django puts them on sys.path: imports resolve against
them (shallowest first), then the project root, then src/, and a module is named from the deepest of these roots
that contains it. At most 256 manage.py directories, the shallowest by depth and then path, become module roots.
Imports that only the remaining directories would resolve are treated as external, so those directories are counted in
limitations (scan-incomplete: for graph, route-coverage: or route-call-coverage: for routes, scan-truncated:
for schema) and prevent closed-world bound proofs; pass a narrower --project to analyze them. Each module root's
entries are read once, so resolving a name touches the filesystem only under roots that contain its first component.
Modules whose syntax tree nests deeper than 128 levels for graph, reach, and impact, or 256 levels for routes
and schema (whose analysis recurses less), for example very long operator, attribute, call, or subscript chains, or
long elif ladders, are not analyzed, so that one generated or crafted file cannot exhaust Python's recursion limit
and abort the whole command. They are reported like files that fail to parse — unparsed-files: in
graph output, route-coverage: for routes, unreadable-sources: for schema — with the reason "source nesting too
deep to parse". Public packages rarely nest beyond 100 levels. Python 3.10's parser crashes the process on extremely
long chains instead of raising an error, so on 3.10 a file with a logical line of more than 50,000 chain tokens (binary
operators, ., (, [) is reported the same way without being parsed, even when the line is a flat list. Chains
inside f-string replacement fields count toward their line (every chain character of the f-string is counted). String
annotations (forward references such as "Mapped[str]" or "Client | None") are parsed under the same limits; one
that cannot be parsed (including one with a NUL character) or that nests too deeply is treated as an unknown
annotation.
One run parses at most 64 MiB of Python source in total, counting only the modules it parses successfully. A module
that would push the total past this budget is not parsed and is reported like a file that fails to parse
(unparsed-files: in graph output, route-coverage: or route-call-coverage: for routes, unreadable-sources: for
schema) with the reason "the run's total parsed source would exceed its 64 MiB budget; pass a narrower --project";
the command still succeeds with the other modules. Each command parses in a fixed order (path order, with modules
reached through imports parsed when first needed), so the same input skips the same modules on every run. Parsed syntax
trees stay in memory until the command ends, because name resolution can return to any module: ordinary code peaks
at about 30–55 bytes of memory per source byte (graph on sympy, 26 MB of source in 1,532 files, takes about 1 GB),
so a run at the budget needs about 2–4 GB. A module whose syntax tree has more than 1,000,000 nodes (counting context
and operator leaves) is not kept either: it is reported the same way with the reason "syntax tree larger than
1,000,000 nodes". Among the large files measured in installed packages (sympy, torch, NumPy, SciPy, faker, and
others) and the standard library, the largest real module has about 200,000 nodes, while a 4 MiB module of x = 1
lines has about 2.8 million and kept about 0.9 GB. So one module keeps at most about 0.45 GB, and parsing one module
peaks at about 2–3 GB, bounded by the 4 MiB file limit. Literal-heavy modules below the node limit (large list
literals, generated tables, thousands of x = 1 lines) still keep about 220–350 bytes per source byte, so a crafted
repository that spreads such data over many modules can still use 15–25 GB within the budget; pythograph does not
bound that case yet. Analyze untrusted repositories under an operating-system memory limit (a cgroup or container
memory limit, or ulimit -v on Linux).
Constant evaluation follows f-strings, +, list literals, and module constants for route strings, settings, SQL text,
and client URLs. A short chain that references the same constant many times (B = f"{A}{A}…", C = f"{B}{B}…")
would otherwise grow exponentially, so repeated references are expanded once, values built this way are capped at
64 KiB (characters for strings; elements plus their characters for lists), and client URL expressions at 4,096 parts.
A larger value is treated as unknown: a dynamic route or route-call fact, an unread setting, or for SQL text a
skipped-sql-literals: limitation. A single string literal is limited only by the 4 MiB file limit. Route strings
longer than 8 KiB (4 times the 2,048-character template limit; Flask rules after merging slashes, Django path() and
re_path() strings joined with their include prefixes), and shorter ones whose literal characters alone (outside Flask
converters, outside Django path() converters, outside re_path groups) exceed the template limit, are not converted
and become dynamic facts like any over-long template.
Route strings, settings, SQL text, and schema names follow only provable module constants, by the same binding rule as
the call graph's stable globals and routes --role client: a name bound more than once at module level (+=, a second
assignment, a loop variable, an import), bound inside an if/try/for/while/with/match block, declared
global in a function, or defined in a module that calls globals() (or module-level vars()/locals()) is unknown
even when it also has an unconditional assignment. They also treat a name changed in place anywhere in its module
(NAME.append(...), NAME[k] = ...) as unknown and check every module on an import, star-import, or module-alias
chain, so a settings module that changes a star-imported setting (from .base import * followed by
INSTALLED_APPS += [...]) makes that setting unknown.
Class hierarchies (view classes for routes and graph, SQLAlchemy mappings for schema) are followed at most 32 levels
deep, and each class is analyzed once per depth, so a diamond-shaped hierarchy costs time proportional to its classes
rather than to its exponentially many paths. A static inheritance cycle, which Python itself cannot build, makes the
result depend on the path taken; when one view class would need more than 1,024 such re-linearizations, its remaining
bases are treated as unknown. Flask blueprint registrations that close a cycle (Flask fails to register them) are not
followed and count as route-coverage:. Registering the same blueprints along many paths is expanded per path as
Flask does, up to 200,000 nested registrations (route-coverage: beyond that); the expansion stops early once more
than 100,000 route-decl facts make the document impossible, ending with the usual fact-limit error (exit code 2).
Flask-SQLAlchemy 2's automatic table name rule (([A-Z]+)(?=[a-z0-9]), kept exactly as Flask-SQLAlchemy writes it)
backtracks over a long run of capitals in time quadratic in the class name's length, so it is not applied to class
names longer than 1,000 characters. When major version 2 is a candidate (declared, or the version is not limited to one
major), such a class's automatic table name becomes a dynamic fact with a flask-sqlalchemy-naming-unverified:
limitation; set __tablename__ to name it. The linear Flask-SQLAlchemy 3 rule applies at any length.
Every project-relative path component is checked for symbolic links, including module/package resolution and dependency
manifests. Links to locations inside the project are rejected too; the skipped-link count counts each encountered path
once. The directory walk counts only links whose name could hide sources it would otherwise read, judged from the
name alone without touching the target: links named *.py (including broken ones) and extensionless links with a
name the walk would enter. Links such as CLAUDE.md -> AGENTS.md, README.md, or .venv therefore do not become
limitations, and the count never depends on what exists on the analyzing machine. A directory link with an extension
(conf.d) is not counted and an extensionless file link (LICENSE) is. Links met during module resolution or
manifest reads always count.
On systems supporting descriptor-relative O_NOFOLLOW, directory scans and source reads open each component
without following links, and verify the opened file before reading. Other systems recheck components and file identity
before reading, but those checks are not atomic. Analyze a stable project tree; this is not a filesystem snapshot or a
sandbox against concurrent changes to the filesystem. The analyzed Python code is never imported or executed.
Security
pythograph starts one external program: git, only to read the revision of graph, reach, and impact when
--revision is not given and the project root has .git (git config, git ls-files, git status, and
git rev-parse, run at the project root). It runs the first git in an absolute PATH entry and skips one that
lives inside the project or directly in the current directory. A .git directory that arrives with an archive or a
copy can define commands that git runs while it reads the work tree (see "SECURITY" in git(1)). pythograph closes the
ways it knows these git calls can reach such commands:
- Right before each call, pythograph lists the filter drivers and promisor settings git sees
(
git config -z --includes --get-regexpforfilter.<driver>.clean,smudge, andprocess,extensions.partialClone, andremote.<name>.promisorandpartialCloneFilter, which covers the repository, its includes, your own configuration, and the system configuration) and overrides each driver with emptyclean,smudge, andprocesscommands andrequired=false, so git runs none of them. This holds for drivers named in.gitattributesand in.git/info/attributes. - It never lets git fetch missing objects. A partial clone (a repository with a promisor remote) fetches objects it
lacks on demand, and that fetch follows the remote's configuration (
remote.<name>.uploadpack,core.sshCommand, and so on). Every call setsGIT_NO_LAZY_FETCH=1, and when the listing shows a promisor setting, every call except the listing itself also passes--no-lazy-fetch. Both need git 2.45 or later: an older git ignores the variable but rejects the option before it starts, so it reads no partial clone. - It turns off
core.fsmonitor, the untracked cache,log.showSignature, and optional locks (GIT_OPTIONAL_LOCKS=0, so the index is never rewritten). pythograph runs nogit difforgit log. - It never starts git inside a submodule:
git statusruns with--ignore-submodules=all, and does not run at all when the index has a submodule. - When these settings cannot be listed, or a driver name contains
=, a control character, or bytes that are not UTF-8 (git's-csplits at the first=, so such a driver cannot be overridden), no other git command runs.
What this changes:
- A file that a filter normalizes (git-lfs and the like) is compared as raw content. When git's stat cache is stale
for it (a copied or extracted tree, a touched file), the work tree counts as changed and no
revisionis recorded, rather than claiming a cleanHEADthat could not be verified. In a checkout you trust, rungit statusfirst to refresh git's stat cache. - A work tree with submodules gets no automatic
revision: pythograph also analyzes the sources inside them, and git cannot tell whether they are clean without following each submodule's own configuration. A driver name that cannot be overridden stops git altogether. Both cases add arevision-unverified:limitation to thegraphsnapshot and to thereachandimpactdocuments. - A partial clone is read only from the objects it already has. When git needs a missing object to check the work
tree, or is older than 2.45, no
revisionis recorded and the samerevision-unverified:limitation says why. A partial clone that has every object the check reads keeps itsrevisionwith git 2.45 or later. - The rest of the git configuration still decides what git reports (for example
core.worktree), and pythograph trusts every setting outside the paths above, so an automaticrevisionis only as trustworthy as the.gitdirectory. For a tree whose.gityou do not trust, pass--revision <id>: git then does not run at all.
Development
uv sync
uv run ruff check src tests && uv run ruff format --check src tests
uv run mypy
uv run pytest --cov # line and branch coverage gate: 90%
uv run python scripts/verify_cli_contract.py
The framework hook table is regenerated from a scratch virtual environment with Django 5.2.17, DRF 3.18.1, and
Flask 3.1.3 installed: python experiments/graph/dump_framework_hooks.py --site-packages <path> (--check compares
only).
Analyze an actual change
Run pythograph change-impact --project . --base HEAD~1 --head HEAD, or omit --head for working-tree
edits. Before/after source captures retain deleted callers and separate propagation paths, with content
digests, file seed reasons, connected review/test candidates, unmapped files and limits. Results are always
incomplete; they do not predict failures or authorize skipping tests. Existing impact <id> is unchanged.
Nested traversals preserve the original project's canonical identity. Test candidates use the same filename
policy as ordinary graphs; capture omissions keep source uncertainty open, and working reads and Git output
are checked while reading. These boundaries and explicit omissions are documented below.
Captures use Git-selected inputs; bound assumes those selected sources form the whole program. Ignored
runtime writers may therefore change ordinary graph results. Materialization rejects paths that alias on
the destination filesystem instead of merging their identities.
See change analysis for the capture budget and remaining framework gaps.
License
MIT
Metadata
Release files for pythograph 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| pythograph-0.3.0.tar.gz | 389.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pythograph-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 790.3 kB
Release files / pythograph-0.3.0.tar.gz
| Download URL | pythograph-0.3.0.tar.gz |
|---|---|
| Size | 389.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2046cd555a2b0236165d181eb81ac9dda20572d6e1e8785566cdccd89ab7239a
|
|
BLAKE2b-256 checksum How to use checksums |
47f172042bb82f4ceba7aa4c018c9ddd681aaea642dec77e4dd41257d90a5976
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logRelease files / pythograph-0.3.0-py3-none-any.whl
| Download URL | pythograph-0.3.0-py3-none-any.whl |
|---|---|
| Size | 400.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3bf65448f05115d96fb6c70c2a43ea6bc67d47862fb6a74c2f4aea1899d41bae
|
|
BLAKE2b-256 checksum How to use checksums |
250f2836eee00f84d739a90ec54fcb0b38fbbfff0597db0c10fa1121a859a855
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logSecurity for pythograph 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for pythograph-0.3.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.