hyperfly (Python)
Binary compression for typed APIs at the edge of entropy.
Your Pydantic models already fix every field name, type, and bound. Your traffic already reveals what the values usually look like. Hyperfly compiles both into a binary protocol for one exact route — and speaks JSON to anything that hasn't been told.
The public API and wire format are stable for 1.x per STABILITY.md.
pip install "hyperfly[pydantic]"
Two lines at the boundary
from enum import Enum
from pydantic import BaseModel, Field
from hyperfly.pydantic import compile
class Status(str, Enum):
ok = "ok"
degraded = "degraded"
class Event(BaseModel):
id: int = Field(ge=0)
at_ms: int = Field(ge=0)
status: Status
message: str | None = None
class EventResponse(BaseModel):
events: list[Event]
cursor: str | None = None
codec = compile(EventResponse, validate=True)
response = EventResponse(events=[Event(id=1, at_ms=1_792_454_400_000, status=Status.ok)])
data = codec.encode(response)
value = codec.decode(data) # an EventResponse; without validate=True, plain dicts
Anything the model already settles — field names, types, enum members, bounds, optionality — never reaches the wire. Models it cannot encode fail loudly at compile time, with the path:
class Model(BaseModel):
meta: dict[str, int]
compile(Model) # UnsupportedSchemaError: $.meta: type dict[str, int] has no v0 encoding
Without pydantic
The schema is plain JSON data, the IR defined in
wire v0, and
compile_ir takes it directly:
from hyperfly import compile_ir, validate_ir
ir = {
"kind": "struct",
"fields": [
{"name": "id", "type": {"kind": "int", "min": 0}},
{"name": "name", "type": {"kind": "string"}},
{"name": "status", "type": {"kind": "enum", "members": ["ok", "degraded"]}},
{"name": "note", "type": {"kind": "string"}, "nullable": True},
{"name": "tags", "type": {"kind": "array", "element": {"kind": "string"}}},
],
}
validate_ir(ir) # raises HyperflyError code "ir" with the failing path
codec = compile_ir(ir)
value = {"id": 7, "name": "edge-1", "status": "ok", "note": None, "tags": ["eu", "edge"]}
assert codec.decode(codec.encode(value)) == value
Every field is required unless it is marked "optional": true; "nullable": true
only admits None. An int field or int literal also accepts an integral float such
as 1.0 and decodes it as an int. The integer domain is checked before the bounds, so
11.0 against "max": 10 fails with range and 1.5 with type.
Pydantic types
compile and to_ir map annotations as follows:
| Annotation | IR |
|---|---|
bool |
bool |
int, bounded by ge/gt/le/lt from Field or annotated-types |
int with min/max |
float |
float64 |
str, bytes |
string, bytes |
Enum with string values, Literal["a", "b", ...] |
enum |
Literal[x] with a string, bool, None or safe int |
literal |
list[T] |
array |
T | None, Optional[T] |
nullable (a nullable field) |
a BaseModel subclass |
struct |
Integer bounds inside Annotated apply at any level: list[Annotated[int, Field(ge=1)]]
bounds the elements. String and list length constraints are not part of the IR, so a
codec without validate=True does not check them. A field with a default is still
required on the wire. A field
needs one wire name, so use alias=; differing serialization and validation names
or AliasChoices/AliasPath are refused. Everything else (dict, tuple, set,
Any, datetime, Decimal, other unions, extra="allow", RootModel, recursive
models) raises UnsupportedSchemaError at compile time with the path.
Int bounds follow the model's own pydantic core schema. A field-level after, wrap or
plain validator (decorated or Annotated, at any container level) drops the bounds
beneath it. Values that bypass field validation (unvalidated defaults, model
validators, assignment, model_construct) keep the declared bounds, so encoding an
out-of-bounds one raises range.
compile returns a ModelCodec, a Codec subclass that registers in a
CodecRegistry like any other. Without validate=True it encodes models or plain
dicts (which must carry every field) and decodes to plain dicts. With it, values pass
through model_validate both ways, decoding returns models, and a ValidationError
is raised as EncodeError or DecodeError with code validation.
Columns and profiles
codec = compile(EventResponse, plan="columnar", validate=True)
The columnar plan lays an array of records out field by field. It turns timestamps into deltas, exact-decimal numbers into integer mantissas, enums into indices and booleans into bitmaps, and deflates text columns together. Row and columnar codecs of the same model have different fingerprints, so a server can register both.
from hyperfly import train
from hyperfly.pydantic import to_ir
samples = [r.model_dump(mode="json", by_alias=True) for r in recent_responses]
profile = train(to_ir(EventResponse), samples)
codec = compile(EventResponse, plan="columnar", profile=profile, validate=True)
A profile adds what only traffic can teach: the values that recur across
different responses, which a compressor never sees because it only ever holds one.
train returns None when the samples have nothing worth a profile. A value enters
a dictionary after min_occurrences (default 2, and 0 counts as 1) and a dictionary
holds at most max_entries (capped at 16383). A new profile produces a new
fingerprint, so register the previous codec next to the new one while clients move
over.
Serving it
from hyperfly import CodecRegistry
from hyperfly.http import negotiate, encode_for, serve_artifact
registry = CodecRegistry([codec, previous_codec])
decision = negotiate(
request.headers.get("hyperfly-accept"),
registry,
http_accept=request.headers.get("accept"),
)
body, headers = encode_for(decision, payload, on_fallback=lambda reason, error: log.warning("%s: %s", reason, error))
Accept: application/vnd.hyperfly;q=0 opts a client out of binary whatever it
advertises. A value the codec refuses under its limits is answered in JSON with the
same headers a fallback gets, and on_fallback hears about it. Every decision
carries Vary: Hyperfly-Accept, Accept. A JSON answer from negotiate offers the
first registered codec in Hyperfly-Offer; offer= names another, offer="" sends
none, and enabled=False never offers. An encoder-limit fallback always offers the
selected codec, whatever offer was.
Request bodies go through decode_request, which takes a bytes body or an
iterable of chunks and stops reading once the body passes max_bytes (1 MiB by
default):
from hyperfly.http import RequestError, decode_request
try:
kind, value = decode_request(
request.headers.get("content-type"),
request.headers.get("hyperfly-codec"),
request.stream(),
registry,
content_length=request.headers.get("content-length"),
)
except RequestError as error:
return Response(status=error.status, headers=error.headers)
error.status is 415 for a content type that is neither hyperfly nor JSON, 413 past
max_bytes (a Content-Length above it is refused before reading), 400 for a
malformed envelope, a contradicting Hyperfly-Codec header or a body that fails to
decode, and 415 with Hyperfly-Offer for a fingerprint the registry does not hold.
error.reason names the rule for logs. read_body applies the same bound on its own
and returns bytes.
A client that holds nothing gets JSON plus a Hyperfly-Offer naming an artifact it
could fetch from serve_artifact; once it has it, the same route answers in binary.
There is no failure mode where the response is unreadable.
The JSON fallback writes pydantic models with model_dump(mode="json", by_alias=True).
Bytes at declared bytes positions are written as standard padded base64; untyped
positions (Any, extras, computed fields, sets, dicts) follow pydantic's JSON mode,
including ser_json_bytes, and custom field serializers own their output. Plain
dicts, lists and tuples write every bytes value as base64, enums as their values
and non-finite floats as null, as JSON.stringify does. A value that cannot be
serialized raises EncodeError with code json.
Registering the outgoing codec alongside the incoming one is what makes retraining safe: a new profile is a new fingerprint, so a deployment holding only one codec turns every rollout into a cutover.
Clients
from hyperfly import CodecRegistry
from hyperfly.http import accept_header, decode_response
registry = CodecRegistry([codec])
headers = {"Hyperfly-Accept": accept_header(registry.fingerprints)}
reply = http.get(url, headers=headers)
kind, value = decode_response(reply.headers.get("content-type"), reply.content, registry)
kind is "hyperfly" for a binary body decoded by the matching codec (a model under
validate=True), "json" for a JSON body, or "unknown-codec" for a binary body
from a codec the registry does not hold, with the envelope's fingerprint as value.
JSON bodies, here and in decode_request, are converted and checked against the
registry's first codec: booleans, ints (integral numbers, within bounds), finite
float64 numbers, strings, enum members, literals, canonical padded base64 into
bytes, null only where nullable or a null literal (Literal[None]), fixed array
lengths and required fields. Unknown keys are dropped and results stay plain, even
for a validating codec. A mismatch raises DecodeError code json with the path
(400 malformed-json from decode_request). An empty registry returns the parsed
JSON unchanged.
API
Names outside each module's __all__, and underscore modules, are private.
hyperfly
Codec: compiled codec, generic in its value type;encode,decode,encode_body,decode_body,fingerprint,artifact,plan,ir,profilecompile_ir(ir, *, plan, limits, pack, profile): compile an IR schema;Codec(ir, ...)takes the same keyword optionsCodecRegistry: codecs by fingerprint;add,remove,get,select,artifact,fingerprintstrain(ir, samples, *, min_occurrences, max_entries): a columnar profile, orNonevalidate_ir(ir),validate_profile(ir, profile): raise codeiron invalid inputserialize_node(node),serialize_artifact(ir, layout, profile): canonical JSONfingerprint_of(artifact): the 16 fingerprint bytes of canonical artifact JSONLimits,DEFAULT_LIMITS: resource bounds and their defaultsPackHooks:deflateandinflatehooks for packed text columnsHyperflyError,EncodeError,DecodeError,FingerprintMismatchError,UnsupportedSchemaError: see ErrorsINT_MIN,INT_MAX: the safe integer domain, ±(2^53 - 1)MAGIC,WIRE_VERSION,HEADER_SIZE: the envelope's magic bytes, version byte and size
hyperfly.pydantic
compile(model, *, plan, validate, limits, pack, profile): aModelCodecModelCodec: aCodecfor a pydantic model, withmodelandvalidateto_ir(model): the model's IR
hyperfly.http
negotiate(accept, registry, *, http_accept, offer, enabled): aNegotiationencode_for(decision, value, *, on_fallback):(body, headers)serve_artifact(pathname, registry): anArtifactResponse, orNoneoff the well-known pathdecode_request(content_type, codec_header, body, registry, *, max_bytes, content_length): aDecodedRequestread_body(body, *, max_bytes, content_length): the body asbytes;max_bytesdefaults toDEFAULT_MAX_BODY_BYTES, 1 MiBaccept_header(fingerprints),parse_accept(header): write and readHyperfly-Acceptdecode_response(content_type, body, registry):(kind, value)Negotiation,ArtifactResponse,DecodedRequest: results of the functions aboveRequestError: a refused request, withstatus,reasonandheadersFallbackReason: why a response fell back to JSON, thereasonof aNegotiationHYPERFLY_MEDIA_TYPE,ACCEPT_HEADER,CODEC_HEADER,OFFER_HEADER,WELL_KNOWN_PREFIX: media type, header names and artifact path
Limits
compile_ir(ir, limits=Limits(...)) bounds what one body can make a peer decode
(wire v0 §6).
A body past a limit fails with code limit (depth for max_depth), and the
encoder refuses a value its own decoder would reject. Limits are not negotiated: a
route that legitimately ships denser payloads raises them on both peers.
| Field | Default | Bounds |
|---|---|---|
max_depth |
64 | struct, array and nullable nesting |
max_items |
2^24 | items in one array |
max_byte_length |
2^28 | bytes in one string or bytes value |
max_amplification |
32 | items in one array: at most max_amplification × (remaining bytes + 1), or one item per remaining bit instead for a row-encoded array of non-zero-bit elements |
max_total_items |
2^24 | items across every array in one body, and at most max_amplification × (body length + 1) |
max_total_bytes |
2^26 | decoded bytes of every string and bytes value in one body, counting literals, enum members and dictionary, grammar and derived values |
max_byte_amplification |
64 | those decoded bytes per body byte: at most max_byte_amplification × (body length + 1) |
Schemas and profiles need no stack sizing: one whose artifact nests up to the
512-level limit compiles, validates and trains without depending on Python's
recursion limit, and a deeper one fails with ir. Value nesting is bounded by
max_depth and reported as depth. Values are decoded recursively: on Python 3.10
or 3.11, under the default recursion limit of 1000, a value nested more than about
490 levels deep fails with depth even when max_depth allows it, and sooner when
the call starts deep in the stack; sys.setrecursionlimit raises it. to_ir walks
models recursively, so a model chain too deep for the stack raises
UnsupportedSchemaError.
Errors
Every failure the package raises on purpose is a HyperflyError with a stable
code, under the same class names as the TypeScript package. A wrapped exception
stays reachable through __cause__.
| Class | Codes |
|---|---|
EncodeError |
type, range, float, utf8, required, depth, limit, validation, json |
DecodeError |
header, truncated, trailing, varint, bitmap, marker, float, utf8, range, depth, limit, packed, unsupported, json, validation |
FingerprintMismatchError (a DecodeError) |
fingerprint |
UnsupportedSchemaError |
unsupported |
HyperflyError |
ir, options |
json is a JSON body that decode_response or decode_request cannot parse (the
json.JSONDecodeError, UnicodeDecodeError or RecursionError is its __cause__)
or that does not match the codec's schema (the message names the path), or a JSON
fallback value encode_for cannot serialize. validation is a pydantic
ValidationError from a codec compiled with validate=True.
options is a malformed compile option, raised by compile_ir, Codec and the
pydantic compile before the schema is read: a limits that is neither None nor a
Limits, or has a field that is not an integer from 0 to 2^53 - 1; a plan other
than "row" or "columnar"; a pack other than None, False or a mapping whose
deflate and inflate are each a callable, None or absent; a validate other than
True or False; or an option that raises when read (the error is its __cause__).
Each option is read once. train raises it before reading the schema for samples
that are not a list or tuple, or a min_occurrences or max_entries that is not a
non-negative integer or integral float; read_body and decode_request before
reading the body for a max_bytes that is not an integer from 0 to 2^53 - 1.
RequestError is separate: it is not a HyperflyError and has no code. It is how
read_body and decode_request refuse a request, carrying status (400, 413 or
415), a stable reason and the response headers. Only a 400 wraps a cause: the
DecodeError for a body that fails to decode (so a JSON parse error is the
__cause__ of that DecodeError, one hop further down), or the stream or buffer
error for an unreadable body. A 413 or 415 has no __cause__.
Compatibility
The package supports Python 3.10 to 3.14, ships py.typed and type-checks under
mypy --strict. The public names in __all__ and the
wire format follow
semantic versioning.
Differences from the TypeScript package
- No counterparts to
loadArtifactandfetchArtifactyet; they are planned for a 1.x release. A Python client compiles the codec from its own model or registers one. - No Fetch API helpers (
respond,discovery), since Python has no single request type;negotiate,encode_forandserve_artifactcover the same ground. - Option names are snake_case:
max_depthformaxDepth,min_occurrencesforminOccurrences. limitstakes aLimitsinstance rather than a partial mapping.
Conformance
The authorities are the specifications, not this implementation. This package runs the same golden vectors as the TypeScript and Rust implementations, and CI runs a TypeScript server against this Python client over real HTTP on every push.
pip install ./python[test] && pytest python/tests -q
License
Metadata
Release files for hyperfly 1.0.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 | |
|---|---|---|---|---|
| hyperfly-1.0.0.tar.gz | 75.8 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hyperfly-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 155.3 kB
Release files / hyperfly-1.0.0.tar.gz
| Download URL | hyperfly-1.0.0.tar.gz |
|---|---|
| Size | 75.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d85e41fbc674a36c9b7db97d36762779a3df158dde67ca1909b6d24811d900bd
|
|
BLAKE2b-256 checksum How to use checksums |
1d47a5a482d225f65694e156680dd7ffd75ff9c0f67168bd0308ce466bcd1dbc
|
| 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 21, 2026.
Transparency logRelease files / hyperfly-1.0.0-py3-none-any.whl
| Download URL | hyperfly-1.0.0-py3-none-any.whl |
|---|---|
| Size | 79.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e7faf19bc89052ed62b0f9b184ca61d2977de2c16fa83217bcb72dada1ff25db
|
|
BLAKE2b-256 checksum How to use checksums |
4716a867fe27a6af6c6097e213a0a72003c2892c13131ed5dfb4c01a99a5b914
|
| 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 21, 2026.
Transparency logSecurity for hyperfly 1.0.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 hyperfly-1.0.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 21, 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.