Skip to main content

dexpot

dexpot synchronous Python API framework

Part of ModePot.   Project website   Created by Tugrul Guner

Synchronous APIs. GIL or free-threaded.

A synchronous Python API framework. Its bounded runtime lets applications adapt concurrency to the interpreter while keeping plain synchronous handlers on standard GIL and free-threaded CPython. It can parse request heads in Rust through an optional PyO3 extension.

CI PyPI version Python versions Join the ModePot Discord GitHub stars MIT License

Quick start · Why dexpot · Execution model · Rust parser · Boundaries · Examples · Community · Roadmap · Contributing

Quick start · Playground · Current boundaries · Deep docs

A dexpot route compiles once into an immutable endpoint plan. Automatic mode parses each bounded request head with a compatible Rust and PyO3 dexpot-native parser or the Python reference when native is absent; Python mode forces the reference parser. Execution then uses either a connection-owned thread on free-threaded CPython or a bounded worker pool with optional process fan-out on standard GIL CPython before both paths execute the same synchronous Python handler

dexpot is interpreter-adaptive and agent-ready. The same synchronous application adapts to standard GIL and free-threaded execution, can use a compatible Rust parser on the request hot path, and ships project-local skills so coding agents understand the framework's real contract and runtime boundaries.

Why dexpot

Most Python API frameworks were designed around a permanent GIL: async I/O in one process, or several worker processes for CPU parallelism. Free-threaded CPython changes that tradeoff. Threads can execute Python simultaneously and share normal in-process state.

dexpot supports both runtimes instead of optimizing for one and treating the other as a compatibility mode. It uses bounded threads and optional process fan-out on standard GIL builds, then uses real parallel threads in one process when the GIL is disabled. The application keeps the same plain synchronous handlers in both modes:

  • Plain synchronous handlers. No async def, event loop, or coroutine bridge.
  • Compiled endpoint plans. Route matching metadata, argument sources, path conversions, and msgspec codecs are prepared when a handler is registered.
  • Interpreter-adaptive scheduling. Free-threaded builds use one process and a real thread per connection, capped at 1,024 active connections by default. GIL builds use a bounded thread pool with fast 503 overload shedding and can fan out through SO_REUSEPORT workers.
  • msgspec request bodies. JSON decoding and validation happen together in compiled C codecs.
  • Optional Rust/PyO3 request-head parser. A compatible dexpot-native installation handles request-line and header parsing while Python retains sockets, bounded accumulation, deadlines, bodies, routing, handlers, and scheduling.
  • A small, owned HTTP core. Routing, parsing, scheduling, draining, and response writes are dexpot code—not a wrapper around another web framework.

dexpot is alpha software and is not yet recommended for untrusted production traffic. Its socket core now fails closed on malformed framing and enforces request limits, but production operations such as access logging, metrics, trusted-proxy policy, TLS guidance, middleware, OpenAPI, streaming, and authentication remain on the roadmap.

Quick start

1. Install

pip install "dexpot[cli]"

2. Define an application

Create main.py:

import msgspec

from dexpot import Dex


class ItemIn(msgspec.Struct):
    name: str
    price: float


class ItemOut(msgspec.Struct):
    id: int
    name: str
    price: float


app = Dex()


@app.get("/items/{item_id}", response=ItemOut)
def get_item(item_id: int) -> ItemOut:
    return ItemOut(id=item_id, name=f"item-{item_id}", price=9.99)


@app.post("/items", body=ItemIn, response=ItemOut)
def create_item(item: ItemIn) -> tuple[int, ItemOut]:
    return 201, ItemOut(id=1, name=item.name, price=item.price)

The path capture is converted from text because item_id is annotated as int. The POST body is decoded directly into ItemIn; malformed JSON or a validation failure returns 422.

3. Serve it

dexpot serve main:app --host 127.0.0.1 --port 8000

Call the real HTTP surface:

curl -s http://127.0.0.1:8000/items/7
curl -s -X POST http://127.0.0.1:8000/items \
  -H 'Content-Type: application/json' \
  -d '{"name":"keyboard","price":79.0}'

The responses are JSON:

{"id":7,"name":"item-7","price":9.99}
{"id":1,"name":"keyboard","price":79.0}

You can also run the file directly with app.serve():

if __name__ == "__main__":
    app.serve(host="127.0.0.1", port=8000)

HTTP limits

Every connection enforces conservative defaults: an 8 KiB request line, 64 KiB request head, 100 headers, a 16 MiB body, a five-second idle read timeout, a ten-second absolute head deadline, and a thirty-second absolute body deadline. Override them as one typed, immutable policy rather than adding transport options to route decorators:

from dexpot import Dex, HttpLimits

app = Dex(
    limits=HttpLimits(
        request_line_bytes=4 * 1024,
        header_bytes=32 * 1024,
        header_count=64,
        body_bytes=2 * 1024 * 1024,
        idle_read_seconds=10.0,
        head_read_seconds=15.0,
        body_read_seconds=60.0,
    )
)

Values must be positive, and the total request-head allowance must exceed the request-line allowance. Oversized or timed-out requests receive a stable error and the connection closes.

Optional Rust/PyO3 parser

The pure-Python request-head parser remains the behavioral reference and fallback. The experimental dexpot-native subproject implements the same request-line, header, Host, framing, and keep-alive semantics in Rust through a parser-only PyO3 extension. Python still owns sockets, deadlines, bodies, pipelining, target decoding, routing, handlers, scheduling, and worker supervision.

Backend selection happens once when dexpot is imported:

DEXPOT_HTTP_PARSER=python dexpot serve main:app  # force the Python reference parser
DEXPOT_HTTP_PARSER=native dexpot serve main:app  # require dexpot-native
DEXPOT_HTTP_PARSER=auto dexpot serve main:app    # compatible native, or Python if absent

native fails clearly when the extension is unavailable. The default auto mode falls back only when the native module is absent; ABI and initialization failures remain visible. The accelerator is not yet published, so installing or building it remains the opt-in decision. Contributors can build it from native/; its wheel matrix, parity suite, and promotion gates are tracked in issue #18.

Route contract

Use @app.get, @app.post, @app.put, @app.patch, and @app.delete.

@app.post(
    "/accounts/{account_id}/items",
    body=ItemIn,
    response=ItemOut,
)
def create_for_account(item: ItemIn, account_id: int) -> ItemOut:
    return ItemOut(id=account_id, name=item.name, price=item.price)

The handler signature does not have to mirror URL order. dexpot binds path captures by name, treats the first non-path, non-Request parameter as the declared body, preserves Python signature order, and supports keyword-only parameters, callable objects, classes, and functools.partial. Class annotations use the effective constructor's namespace: metaclass __call__ first, otherwise __new__ or __init__ in MRO order (__new__ wins on the same class), including functools.partialmethod constructors. An explicit class __signature__ is a boundary; use concrete annotations or annotation_locals for its aliases. Put default-only parameters after that body parameter. Every effective handler call must remain synchronous. Registration fails before serving when:

  • the handler is a coroutine, async generator, or detectable wrapped asynchronous callable;
  • the callable signature cannot be inspected;
  • a required parameter has no matching path capture, request body, or default;
  • a path capture is not accepted by the handler;
  • the handler uses *args or **kwargs; or
  • another route already owns the same method and structural path shape.

For example, GET /users/{id} and GET /users/{name} conflict because only one can ever match a request.

A handler may return a JSON-encodable value, a msgspec struct, or (status, payload). response=T strictly validates and projects JSON onto the declared public schema, including pre-existing and nested Structs. Extra fields are removed by ordinary Struct schemas; forbid_unknown_fields=True rejects them. Invalid output produces a sanitized 500 before success headers or bytes are sent. response=None (the default) uses generic msgspec JSON encoding without validation or filtering.

Checked output uses registration-compiled, schema-directed preparation to validate and project an owned JSON representation before native encoding. Structs, records, lists, tuples, mappings and Any combine field validation, defaults and privacy checks in that preparation. Sets retain a native checked fallback for hashing and deduplication after child preparation; remaining native-only scalar schemas also retain the checked fallback. This supports msgspec constraints, aliases, defaults, tagged unions, enums, bytes (base64), datetime and UUID strings. Strict conversion follows msgspec semantics (for example, integers can satisfy floats); it does not require identical Python types. Any retains arbitrary JSON fields and is not a privacy filter. Checked output rejects non-finite floats (NaN and either infinity), including nested/optional/Any values and defaults. Schemas containing custom types or __post_init__/__attrs_post_init__ hooks are rejected at registration, including nested schemas. Invalid static Struct and dataclass defaults and known builtin factory defaults also fail at registration, including omitted defaults. Application factories are not invoked during registration; their output is checked and projected before serialization, including rejection of any Request context introduced by a factory. Unsupported schema declarations fail atomically; this is not a promise to support every Python type.

Use immutable envelopes for explicit metadata:

from dexpot import Response, RawResponse

# The body still follows the route's response=T policy.
created = Response({"name": "alice"}, status=201, headers=[("Set-Cookie", "a=1"),
                                                        ("Set-Cookie", "b=2")])
# RawResponse requires response=None and an immutable bytes body.
text = RawResponse(b"hello\n", content_type="text/plain; charset=utf-8")
empty = Response(status=204)

Headers are copied into an immutable snapshot; repeated pairs preserve duplicate headers. Envelope immutability is shallow: a JSON body may still be mutable. Status must be an integer (not bool) from 200 through 599. Headers are limited to 64 pairs and 16,384 encoded bytes; names must be HTTP tokens and values Latin-1 without control characters. Framing and server headers cannot be overridden: Content-Length, Transfer-Encoding, Connection, Keep-Alive, Upgrade, Trailer, TE, Server and Proxy-Connection are reserved. Content-Type is also reserved; JSON uses application/json and raw output uses content_type (nonempty, at most 256 characters). Invalid envelopes raised inside handlers return sanitized 500 errors.

204 and 304 omit body bytes and Content-Length; 205 sends an empty body with Content-Length: 0. Response(status=204) (also 205/304) explicitly skips the checked body requirement; otherwise JSON bodies are validated before bodyless status suppression. RawResponse() sends zero bytes with status 200. Raw output cannot bypass a checked route, even with a bodyless status. Parsed HEAD responses remain bodyless; automatic GET-to-HEAD routing is not provided. Existing (status, payload) returns retain their meaning and use the same checked policy. The declared schema applies to returned payloads at every status, including error statuses. Use response=None when returning differently shaped bodies that do not share a supported schema.

Annotation namespaces

Route decorators never read caller locals. Postponed annotations resolve against the original handler's module globals and captured closure bindings, including handlers using functools.wraps. For annotation-only aliases local to a factory or class, pass an explicit namespace on any route decorator:

from __future__ import annotations
from dexpot import Dex


def build():
    from dexpot import Request as Context

    app = Dex()

    @app.get("/context", annotation_locals={"Context": Context})
    def context(request: Context):
        return {"method": request.method}

    return app

annotation_locals is shallow-copied when the decorator is created. Pass only the needed bindings, not an entire frame's locals(). For delayed registration, retain and pass the original alias bindings; Python does not preserve annotation-only locals after a factory returns. Captured closure bindings take precedence over the supplied namespace, which takes precedence over module globals. Unresolved parameter annotations fail registration with an annotation_locals error, even when the parameter has a default. An explicit body= schema continues to bind the first non-path, non-Request parameter without needing its annotation. Annotations are resolved afresh for each registration, and body=/response= belong to that route declaration rather than the handler function, so one handler can be reused safely across applications with different route contracts.

Request context

Annotate a handler parameter with the public Request type to receive the parsed request through the endpoint's precompiled direct-call path:

from dexpot import Request


@app.post("/accounts/{account_id}/items", body=ItemIn)
def create_with_context(item: ItemIn, account_id: int, *, request: Request) -> dict:
    return {
        "account_id": request.params["account_id"],
        "method": request.method,
        "query": request.query,
        "same_body": request.body is item,
    }

request.method and request.path are decoded routing values. request.params contains the decoded path-capture strings before handler type conversion, while request.query is the raw query string and request.headers contains lowercase header names. request.raw_body retains the received bytes; request.body is the same validated object passed to the declared body parameter. The request object is frozen and GC-tracked. Handlers without a Request annotation do not allocate one. Freezing prevents attribute reassignment; it does not deep-freeze the request-local params, headers, or validated body values. Returning a Request directly is always rejected. On Request-aware and checked routes, nested Request values in supported response containers are also rejected. Explicit extraction of sensitive fields and nested, manually created Request values returned by unchecked requestless handlers are outside the recursive guard.

Execution model

dexpot chooses its scheduler once when the module is imported.

Runtime Default serving model Overload behavior
Free-threaded CPython (sys._is_gil_enabled() == False) One process; each admitted connection owns a thread Active connections capped at 1,024 by default; excess connections receive 503 before thread creation
Standard GIL CPython Bounded pool of CPU * 2 + 2 connection-owning threads Queue capped at 2 * pool; excess connections receive 503
Standard GIL CPython with DEXPOT_WORKERS>1 POSIX SO_REUSEPORT processes, each with its own bounded pool Each worker sheds independently

A worker owns a keep-alive connection until it closes. This avoids putting idle keep-alive sockets back into a shared queue, where they can consume admission capacity and stall a worker waiting for the next request.

Tune admission before the process imports dexpot:

# Set the free-threaded process-wide active-connection cap.
DEXPOT_MAX_CONNECTIONS=512 dexpot serve main:app

# Set the GIL thread pool and queue.
DEXPOT_POOL=16 DEXPOT_MAX_QUEUE=32 dexpot serve main:app

Use process fan-out on supported POSIX systems:

DEXPOT_WORKERS=4 dexpot serve main:app

DEXPOT_WORKERS>1 requires POSIX fork and SO_REUSEPORT; dexpot rejects that setting on unsupported platforms rather than pretending multiprocess serving is active. Free-threaded builds intentionally remain single-process because their threads can execute Python in parallel.

SIGINT and SIGTERM stop admission and allow active connections up to five seconds to drain. The GIL supervisor restarts a worker that exits unexpectedly.

Current architecture

Registration builds immutable EndpointPlan objects for handler binding, capture conversion, and body and response codecs. When serving starts, dexpot freezes those endpoints into one ApplicationPlan and a RouterPlan grouped by path length with parameter positions already classified. Compilation happens before opening a listener, and late route registration fails instead of silently diverging from the plan used by traffic.

Request processing consumes the same plan for literal and parameterized matching, 404/405 resolution, direct handler invocation, and response encoding without inspecting the handler again.

Parser selection is orthogonal to scheduling: each bounded request head uses a compatible Rust/PyO3 dexpot-native installation in automatic mode, or the Python reference when the native module is absent or Python mode is forced. A selected native adapter also preserves the Python path for configured head limits beyond Rust's usize range. Incompatible or broken native installations fail visibly. Python continues to own sockets, deadlines, bodies, and routing.

The interpreter changes admission and scheduling, not application code. Free-threaded CPython gives each admitted connection its own thread in one process and sheds above the configured process-wide cap. Standard GIL CPython uses a bounded pool, sheds above its queue limit, and can add POSIX process fan-out. Both paths enforce the same HTTP limits and execute the same compiled route, synchronous handler, and msgspec response pipeline shown above.

Protocol and configuration policy

Once a request reaches parsing, HEAD responses never include body bytes, including parser and handler errors; admission can reject an unread connection before its method is known. Automatic GET-to-HEAD routing is not provided. Requests with an Expect header are rejected with 417 and connection close before reading their body. Retry without Expect when appropriate.

DEXPOT_POOL=0 retains automatic sizing. Negative pool sizes and nonpositive DEXPOT_MAX_QUEUE or DEXPOT_MAX_CONNECTIONS values fail at import, before a listener can open; zero queue or connection capacity is not a supported no-wait mode. These validation rules apply in both interpreter modes.

Current boundaries

The current alpha release has these boundaries:

  • HTTP/1.0 and HTTP/1.1 requests with validated Content-Length framing are supported; transfer encodings, including chunked request bodies, are rejected and the connection closes.
  • Request-line, total-header, header-count, and body limits plus idle and absolute head/body deadlines are enforced before request data can accumulate or drip indefinitely.
  • Request targets are currently origin-form only (/path?query); absolute-form proxy targets are rejected during this alpha milestone.
  • The optional native request-head parser is experimental. Automatic detection is the default, but it activates Rust only when a compatible separately installed extension is present; Python remains the reference and fallback.
  • Routing distinguishes 404 from 405, returns Allow for method mismatches, percent-decodes UTF-8 paths safely, and treats duplicate or trailing slashes as distinct paths.
  • Query strings and headers are parsed internally but are not yet injectable handler parameters.
  • There is no middleware, OpenAPI generation, authentication, TLS termination, streaming, WebSocket support, or proxy-header policy.
  • Checked response=T validates output; response=None retains generic JSON encoding.
  • Uncaught handler exceptions produce a stable public 500 body while the traceback is logged server-side. Structured logging and request IDs remain production-operations work.
  • Multiprocess serving is POSIX-only. Windows users must use one process in the current release.

These are explicit roadmap items, not hidden features. See ROADMAP.md for the implementation order.

Give your coding agent dexpot context

Install project-local guidance for Claude Code, Cursor, Windsurf, GitHub Copilot, Cline, or OpenAI Codex:

# Auto-detect agents already configured in the project
dexpot add skills

# Or target one explicitly
dexpot add skills --agent claude
dexpot add skills --agent cursor
dexpot add skills --agent windsurf
dexpot add skills --agent copilot
dexpot add skills --agent cline
dexpot add skills --agent codex

# Install into another project
dexpot add skills --path ./my-api

The installed skill teaches the shipped route contract, msgspec body model, concurrency modes, operational boundaries, and verification requirements. Shared Copilot and Codex instruction files use a bounded managed block, so existing project guidance is preserved.

CLI reference

dexpot serve <module:attribute> [--host HOST] [--port PORT]
dexpot add skills [--agent AGENT] [--path DIRECTORY]
dexpot version
dexpot --version

The CLI extra is optional, so applications that call Dex.serve() directly do not need Typer:

pip install dexpot          # framework runtime
pip install "dexpot[cli]"   # framework runtime + dexpot command

Examples

The runnable progression under examples/ exercises the shipped framework through its real socket server:

  • response_policy.py: checked projection, invalid output, custom headers, raw bytes, empty responses, and existing tuple returns;
  • minimal.py: typed path capture and response;
  • typed_crud.py: thread-safe shared state, typed JSON writes, and a complete create/read/update/delete lifecycle; and
  • bounded_api.py: custom HttpLimits, 413/422 failures, 405 method handling, and /context with an explicit factory-local annotation namespace.

Each example runs directly with uv run python examples/<name>.py, and the test suite launches every example as a subprocess and validates its public HTTP behavior. Examples will continue to grow only as middleware, schemas, deployment support, and other roadmap capabilities ship.

Roadmap

The shipped foundation now includes the HTTP-hardening gate and the optional native request-head seam. Remaining work is organized around four gates:

  1. Complete request-aware response handling and output policy, then extend the shipped request context with decoded query parameters, cookies, and client metadata, and complete middleware and schemas;
  2. publish reproducible GIL and free-threaded benchmarks with correctness parity; and
  3. add production operations without replacing the synchronous execution model; and
  4. grow runnable examples, testing support, deployment guidance, and stable extension points.

The detailed milestones and non-goals live in ROADMAP.md.

Community

The ModePot Discord is the shared community for dexpot, intpot, summonpot, and the rest of the project family. Join to discuss synchronous API design, free-threaded Python, implementation questions, and real application use cases.

Use GitHub issues for reproducible bugs and scoped feature proposals. Use Discussions for durable project Q&A, and Discord for exploratory design, early ideas, and cross-project help.

Contributing

Start with CONTRIBUTING.md, then read docs/reviewing.md before opening a change. Useful contributions include:

  • executable examples under examples/;
  • real-client HTTP acceptance tests and malformed-request coverage;
  • reproducible wrk benchmarks that compare successful equivalent responses;
  • parser, shutdown, and overload correctness;
  • roadmap features with a focused issue and end-to-end tests; and
  • documentation that clearly separates current behavior from planned architecture.

Open an issue before substantial handler, routing, parser, scheduler, protocol, or execution contract work. Focused fixes, tests, documentation, and maintenance may be direct pull requests when they do not introduce a new public contract.

Set up and run the complete local gate with:

uv sync --all-extras
make check

User-facing changes require a Towncrier fragment. See changelog.d/README.md.

Support dexpot

  • Run one of the checked-in examples and report any friction through the issue forms.
  • Share a real synchronous API or free-threaded Python use case in Discussions or the ModePot Discord.
  • If dexpot's execution model is useful to you, use the Star control on the repository so other Python developers can find it.

License

MIT — see LICENSE.

Metadata

Release files for dexpot 0.7.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 dexpot 0.7.0
File Size Uploaded
dexpot-0.7.0.tar.gz 1.2 MB Details

Built distribution (wheel)

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

Total release size: 1.3 MB

Release files / dexpot-0.7.0.tar.gz

Download URL dexpot-0.7.0.tar.gz
Size 1.2 MB
Tags Source
SHA-256 checksum
How to use checksums
8b66b4fcff0b5a7bc4b6627e606389202e6d7f1c106866bb959458aff83bc86a
BLAKE2b-256 checksum
How to use checksums
94829de32201c7fd9e4c47c26709e8c3573be4b54756f71ea3142584bf5ecf5f
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 10, 2026.

Transparency log

Release files / dexpot-0.7.0-py3-none-any.whl

Download URL dexpot-0.7.0-py3-none-any.whl
Size 56.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
46adf77e5946adf2a71b4dfe9b0aeee86e305a015f39868bdc59e21af372de48
BLAKE2b-256 checksum
How to use checksums
f816bc903ab6ada7ff105e8102f5f2488a493734c8a03bb71a9a4b1e16bb8b23
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 10, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.1

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