Skip to content

Interop with Python

Typhon’s whole pitch is “static safety on top of Python’s runtime.” That means talking to Python is a first-class concern, not an edge case. This section maps every way .ty and .py interact.

The interop surface

DirectionMechanismPage
Typhon imports PythonTreat as unsafe boundary; or write a .dty stubCalling Python
Python imports TyphonRead the emitted .py and .pyi.pyi Emission
Stubs for third-party Python.dty files compile to .pyiWriting .dty Stubs
Drift between stub and impltyc check --stubs AST diff; tyc stubtest runtime probeStub Drift
HTTP / file / queue inputsmodel Pydantic emissionPydantic Boundary Models
Framework base classesclass! escape hatchClass Escape Hatches

Calling Python from Typhon

What happens when you import a Python module — and how to make it strict via .dty stubs.

Writing .dty Stubs

The Typhon-flavoured stub format. Strict dialect, lowers to PEP 561 .pyi.

Pydantic Boundary Models

model emission for data crossing trust boundaries. extra="forbid" always.

Class Escape Hatches

class! patterns for torch.nn.Module, Enum, unittest.TestCase, Django, SQLAlchemy.

Layers of third-party type-checking

When your code calls into a third-party Python dependency, Typhon checks that call through a stack of mechanisms, each broader in coverage than the last. They compose: for any given name, the most precise source that defines it wins, and the rest fill the gaps. From strongest-and-narrowest to broadest-and-most-permissive:

LayerSourceCoverageAuthoring
1Authored .dty stubFull Typhon-dialect types — strongest and most preciseYou write it
0Compiler-bundled .dty stubCurated, embedded stubs for libraries whose packaging defeats introspection (httpx, requests)None — shipped with tyc
2Venv signature introspectionArg type and arity of fully-typed pure-Python depsNone
3ty typeshed passC-extension and stdlib APIs introspection can’t reachOpt-in flag

For one name, an authored .dty wins, then the bundle, then venv introspection, then ty.

Layer 1 — Authored .dty stub

Full Typhon-dialect types (T?, Result[T, E], sealed unions, interfaces) — the strongest and most precise surface. Write these for long-lived dependencies you call a lot. An authored stub overrides a bundled stub for the same module. See Writing .dty Stubs.

Layer 0 — Compiler-bundled .dty stubs

tyc ships curated, embedded .dty stubs for popular libraries whose packaging defeats venv introspection — httpx and requests to start. tyc check, tyc build, and the LSP seed them into the project shape map before venv introspection runs, so the library is shaped out of the box: its construction is type-checked and its unintrospectable-dependency warning is suppressed — with no .venv and no tyc sync required.

Bundled stubs are gap-fill only: an authored project .dty/.ty for the same module wins over the bundle, and the bundle in turn beats venv introspection. Class shapes are marked partial, so members the stub omits stay lenient (no false attribute_not_found). Request methods take **kwargs: object; client constructors enumerate the common kwargs as optional fields. This covers the head of the dependency distribution; the long tail stays best-effort.

Layer 2 — Venv signature introspection

tyc runs inspect.signature over the installed package and recovers parameter and return annotations — scalars (int/str/bool/float/bytes/None), nullable (Optional[X] / X | None), parametric containers (list[X]/set[X]/frozenset[X]/dict[K, V]), and fixed-arity tuple[...], recursively. This catches wrong-type and wrong-arity arguments to fully-typed pure-Python deps — both function and constructor calls — through the same tyc::type_mismatch machinery your own code uses, with zero authoring.

Introspection degrades to a permissive Unknown on anything it can’t model, so it only ever adds true positives. A stub-only library like requests (typed via typeshed’s types-requests, not in its own source) degrades to Unknown here — which is exactly why it now ships as a bundled stub (Layer 0) or is caught by the ty pass (Layer 3).

If a declared, imported dependency can’t be introspected, tyc surfaces the unintrospectable-dependency warning rather than silently skipping the check. The introspection also runs live in tyc lsp (persistent per-project cache, invalidated when .venv/pyvenv.cfg changes).

Layer 3 — ty typeshed pass

Enabled with [checker] external = "ty" or --with-ty, this is the only path that sees typeshed, so it covers C-extension and stdlib APIs that introspection can’t reach — os.path.join(1, 2), numpy/pandas signatures, and the like. It runs as a subprocess over the emitted Python, and errors are re-attributed back to the .ty source via the .py.map. See tyc ty and the [checker] configuration.

Qualified ↔ bare class identity

A module names its own classes bare in its signatures (def get(...) -> Response), but you usually reference them qualified at the use site. As of v0.15.0 these unify:

import httpx
let r: httpx.Response = client.get("https://example.com") # ✅ no mismatch

The same holds for any project module — import lib; let x: lib.Foo = lib.make() checks cleanly. Checker::is_assignable matches two class types by their final .-separated segment when at least one side is bare. Two different qualified classes stay distinct (httpx.Response is not assignable to requests.Response — the bundled stubs qualify their own return types, so a cross-module mix-up is still caught), and a genuine mismatch (Response vs int) is still caught.

One deliberate exception guards the user/library boundary: a bare name that refers to a class declared in the module being checked does not unify with a same-named class another module provably declares. A class statement always creates a fresh class, so declaring your own class Response: and passing it where httpx.Response is expected is a real mismatch — and is reported as one. The guard is evidence-gated: it fires only when both declarations resolve through exact module keys and their shapes differ, so an __init__.ty facade re-export (same shape) or a bare name of unknown origin keeps unifying as before.

Mixed-language projects

.ty and .py interoperate freely. You can:

  • Migrate a single file at a time.
  • Keep generated code (protobuf, gRPC) as .py.
  • Use .ty for new code, .py for legacy.

Plain .py files in src/ are copied to build/ unchanged. They can import from emitted modules and vice versa. Imports from a plain .py are treated as if they crossed an unsafe: boundary unless an authored .dty overrides them.

Where next