Skip to main content

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 (graph, reach, and impact run only the project root's git to read revision).

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. project is its POSIX realpath and every location.path is relative to it.
  • --framework: auto (default) detects Django (a DJANGO_SETTINGS_MODULE default in manage.py, wsgi.py, or asgi.py, or --settings) and Flask (a Flask(...) or Blueprint(...) 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 under tests/ or test/) with testSource: true and sourceSets.tests: "included".
  • --dispatch specificity: declare specificity for a Django project and omit order (see Decisions).
  • --generated-at: a fixed generatedAt for byte-identical output.
  • Exit codes: 0 success (zero facts is still success, not proof of completeness), 2 unreadable 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), 64 usage error. 1 is 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_URLCONF from the settings module (star imports of project settings modules are followed), urlpatterns built 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_path regular expressions converted from their syntax tree when possible, function views (ANY, narrowed by require_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, @action with detail, methods, url_path, and .mapping), the DefaultRouter API root and format-suffix variants, format_suffix_patterns, APIView, generic views, and viewsets.
  • Flask: Flask(...) and Blueprint(...) 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, and merge_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) is dynamic with a route-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. HEAD next to GET and automatic OPTIONS are not emitted (isthmus matches them with head-as-get and options-any); an explicitly declared OPTIONS is.
  • paramConstraints: int, slug, uuid, path (for {**}), or regex with the pattern.
  • trailingSlash: Django and strict Flask rules are strict; /? in a regex and Flask strict_slashes=False are optional; omitted after {**}.
  • order (Django): {group: "django:<ROOT_URLCONF>", index: <depth-first position>}.
  • location: the route string argument (Django path()/re_path(), Flask decorator or add_url_rule), the router.register() prefix for DRF routes, or the @action decorator 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:.

Decisions

  • Django is registration-order, Flask is specificity, as verified from the sources. isthmus releases before f9dcd1d reject registration-order; --dispatch specificity declares specificity for Django and omits order. 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/AsyncClient with base_url), aiohttp 3.x (ClientSession with base_url, aiohttp.request), and urllib.request.urlopen (with Request(method=) and data). 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 /x stays under the base path), aiohttp-base-url (RFC 3986: /x replaces 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.urljoin has 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, no global or module-attribute writes), 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 is dynamic with a masked channelPrefix (channel is null, so no raw URL text leaves the tool). A literal host gives root plus authority; a dynamic host or an unknown base gives base.
  • Wrappers (--wrappers, isthmus http-wrappers v1): "language": "python" entries; owner is 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. label is a keyword argument, index a positional argument (receiver excluded). Unknown fields and malformed entries exit 64; declarations that match nothing report http-wrapper-unresolved:.
  • 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), and http-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, Meta inheritance) → tables (<app_label>_<model> truncated by truncate_name for the backend's max_name_length, or Meta.db_table), fields → columns (db_column, <name>_id for foreign keys, many-to-many tables and their columns), app labels from INSTALLED_APPS/AppConfig, the DATABASES backend, and django.contrib models. Uses: managers and QuerySet chains, lookups (author__profile__city, reverse relations, attname, pk), values/order_by/F/Q/aggregates, create/update keywords, 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.Model with its snake_case names), mixins, single- and joined-table inheritance, __table_args__/MetaData schemas, Core Table, 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, and text().
  • 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).
  • channel is the relation name as written or mapped (schema.table only 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 as routes. A name that depends on an unknown backend, app label, or Flask-SQLAlchemy version, an unresolved model or lookup, and SQL built at runtime become dynamic facts with a dynamic-relation-names: limitation instead of guesses.
  • Test sources (unless --include-tests) and migrations (Django migrations/, Alembic versions/) 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, and dispatch/framework (framework dispatch). Resolution follows imports (absolute, relative, aliases, __init__ re-exports, *), module attributes, constructors, self and super() 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 for self, cls, and annotated receivers are candidate. A method call on a receiver of unknown or overridable type gets bound edges (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 no bound edge: public functions, classes, and module globals of a library (a project root with setup.py/setup.cfg or a pyproject.toml [project]/[tool.poetry] table), framework-invoked entry points (functions passed as values, decorated functions, classes with framework bases), computed getattr/setattr and module namespaces, *args/ **kwargs spreads, 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 under bound-assumptions:). The default stays direct: 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's unresolvedCalls. 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 ast from the installed Django 5.2.17, DRF 3.18.1, and Flask 3.1.3 sources links the project hooks that the as_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 as framework-callback unresolved calls.
  • Snapshot size: the graph snapshot 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); reach and impact keep 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 dispatch declaration, per-root lower-bound evidence, unresolvedCalls, one multi-root pass (compared with a per-root oracle on random graphs), --max-depth/--max-reached truncation, and rootsTruncated. Roots that are not graph nodes are listed without symbol; the document is written and the command exits 64. revision is --revision or git HEAD when the work tree is clean; graphRevision is 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.

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).

License

MIT

Metadata

Release files for pythograph 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pythograph 0.2.0
File Size Uploaded
pythograph-0.2.0.tar.gz 285.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pythograph 0.2.0
File Interpreter ABI Platform
pythograph-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 611.3 kB

Release files / pythograph-0.2.0.tar.gz

Download URL pythograph-0.2.0.tar.gz
Size 285.6 kB
Tags Source
SHA-256 checksum
How to use checksums
46760fd062d62974612b5f958785750ac64fa151df019842207e85aae30483a3
BLAKE2b-256 checksum
How to use checksums
caf692cfa15636747596f9ff33e1d71b94a84e2a28df0ceec2e1df66280996c1
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 Sep 30, 2026.

Transparency log

Release files / pythograph-0.2.0-py3-none-any.whl

Download URL pythograph-0.2.0-py3-none-any.whl
Size 325.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b3bb558953a0fe542fe115027343d7b54779e0212089dcfa1f226a990fae1afd
BLAKE2b-256 checksum
How to use checksums
a072499d0796889092ae0dd6793d7c580fe292fa2b41b4fb5cfe17e4e188fcc7
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 Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.1

2 release files

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page