Added file upload support

This commit is contained in:
2026-06-04 04:20:05 -04:00
parent 4effcc7597
commit 67ad91b673
23 changed files with 665 additions and 20 deletions

View File

@@ -6,6 +6,7 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
requires-python = ">=3.10"
dependencies = [
"PyJWT>=2.0",
"pydantic>=2.0",
]
[project.optional-dependencies]

View File

@@ -0,0 +1,3 @@
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]

View File

@@ -487,8 +487,10 @@ def _create_server_function(
# Use function name directly
name = fn.__name__
# Extract type hints and signature
hints = get_type_hints(fn)
# Extract type hints and signature. include_extras keeps `Annotated[...]`
# metadata (e.g. the `File(...)` marker on an Upload field) intact so it
# survives into the generated Input model.
hints = get_type_hints(fn, include_extras=True)
sig = inspect.signature(fn)
params = list(sig.parameters.items())

View File

@@ -17,6 +17,7 @@ KDL grammar — locked contract:
| list { <type-child> }
| optional { <type-child> }
| enum "<v1>" "<v2>" ...
| upload max-size=<int>? { content-type "<mime>" ... }
}
...
}
@@ -72,6 +73,7 @@ from pydantic_core import PydanticUndefined
from mizan_core.registry import get_all_functions, get_context_groups, get_function
from mizan_core.type_utils import extract_list_element, extract_optional
from mizan_core.upload import File, classify_upload
__all__ = ["build_ir"]
@@ -244,6 +246,34 @@ def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]
_emit_type_child(alias_block, annotation, named_types)
def _emit_upload_node(block: _Block, spec: File | None) -> None:
"""Emit the `upload` type-child, with optional `max-size` + `content-type`s."""
props: dict[str, str] = {}
if spec is not None and spec.max_size is not None:
props["max-size"] = repr(spec.max_size)
if spec is not None and spec.content_types:
with block.node("upload", **props) as up:
for ct in spec.content_types:
up.leaf("content-type", _kdl_string(ct))
else:
block.leaf("upload", **props)
def _emit_upload_child(block: _Block, is_list: bool, is_optional: bool, spec: File | None) -> None:
"""Emit an Upload type-child, wrapped in `optional`/`list` to match the field."""
if is_optional and is_list:
with block.node("optional") as opt, opt.node("list") as lst:
_emit_upload_node(lst, spec)
elif is_optional:
with block.node("optional") as opt:
_emit_upload_node(opt, spec)
elif is_list:
with block.node("list") as lst:
_emit_upload_node(lst, spec)
else:
_emit_upload_node(block, spec)
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
"""Emit a `struct { field ... }` block for a Pydantic model."""
with block.node("struct") as struct_block:
@@ -259,7 +289,11 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s
props["default"] = _kdl_value(default)
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
_emit_type_child(field_block, field_info.annotation, named_types)
is_upload, is_list, is_optional, spec = classify_upload(field_info)
if is_upload:
_emit_upload_child(field_block, is_list, is_optional, spec)
else:
_emit_type_child(field_block, field_info.annotation, named_types)
class _StructShape:

View File

@@ -0,0 +1,216 @@
"""
Mizan Upload — first-class binary input for ``@client`` functions.
``Upload`` is a Pydantic-composable field type. Declaring an Upload-typed
parameter makes a function multipart-aware end to end: the generated client
switches that call to ``multipart/form-data``, and each backend adapter parses
the file part and binds a uniform :class:`UploadedFile` into the function's
``Input``. Constraints declared via :class:`File` are enforced at dispatch.
from typing import Annotated
from mizan import client, Upload, File
@client(affects=UserContext)
def set_avatar(
request,
user_id: int,
avatar: Annotated[Upload, File(max_size="5MB", content_types=["image/png"])],
) -> dict:
avatar.save(f"/media/{user_id}.png")
return {"ok": True}
Bare ``Upload`` is an unconstrained file; ``Upload | None`` is optional;
``list[Upload]`` accepts multiple. The :class:`File` marker is optional and
carries the declarative constraints.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
from mizan_core.type_utils import extract_list_element, extract_optional
__all__ = [
"Upload",
"UploadedFile",
"File",
"parse_size",
"validate_upload",
"classify_upload",
"upload_fields",
"bind_uploads",
]
_SIZE_UNITS = {"GB": 1024**3, "MB": 1024**2, "KB": 1024, "B": 1}
def parse_size(value: int | str) -> int:
"""Parse a byte count. Accepts an int (bytes) or a string like ``"5MB"``."""
if isinstance(value, int):
return value
s = value.strip().upper()
for unit, mult in _SIZE_UNITS.items():
if s.endswith(unit):
return int(float(s[: -len(unit)].strip()) * mult)
return int(s)
@dataclass(frozen=True)
class File:
"""Declarative constraints for an ``Upload`` field, placed in ``Annotated``.
``max_size`` accepts an int (bytes) or a human string (``"5MB"``).
``content_types`` is a list of allowed MIME types; an entry ending in
``/*`` (e.g. ``"image/*"``) matches any subtype.
"""
max_size: int | str | None = None
content_types: tuple[str, ...] | None = None
def __post_init__(self) -> None:
if self.max_size is not None:
object.__setattr__(self, "max_size", parse_size(self.max_size))
if self.content_types is not None and not isinstance(self.content_types, tuple):
object.__setattr__(self, "content_types", tuple(self.content_types))
class UploadedFile:
"""Uniform file handle handed to ``@client`` functions, adapter-agnostic.
Backends construct this from their native upload object (Django
``UploadedFile``, Starlette ``UploadFile``) so a function body stays
portable across adapters.
"""
__slots__ = ("filename", "content_type", "_data")
def __init__(self, filename: str | None, content_type: str | None, data: bytes):
self.filename = filename
self.content_type = content_type
self._data = data
@property
def size(self) -> int:
return len(self._data)
def read(self) -> bytes:
return self._data
def save(self, path: str | os.PathLike) -> None:
with open(path, "wb") as fh:
fh.write(self._data)
class Upload:
"""Pydantic-composable marker type for a binary file input.
At validation time it accepts any :class:`UploadedFile` (the adapter has
already bound the multipart part). The IR emitter recognizes Upload-typed
fields and emits an ``upload`` node.
"""
@classmethod
def __get_pydantic_core_schema__(
cls, source: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.no_info_plain_validator_function(cls._validate)
@staticmethod
def _validate(value: Any) -> UploadedFile:
if isinstance(value, UploadedFile):
return value
raise ValueError("expected an uploaded file")
def _content_type_allowed(content_type: str | None, allowed: tuple[str, ...]) -> bool:
if not content_type:
return False
for ct in allowed:
if ct == content_type:
return True
if ct.endswith("/*") and content_type.startswith(ct[:-1]):
return True
return False
def validate_upload(file: UploadedFile, spec: File | None) -> str | None:
"""Enforce declared constraints. Returns an error message, or ``None`` if ok."""
if spec is None:
return None
if spec.max_size is not None and file.size > spec.max_size:
return f"file exceeds max size {spec.max_size} bytes (got {file.size})"
if spec.content_types and not _content_type_allowed(file.content_type, spec.content_types):
return f"content-type {file.content_type!r} not allowed (expected one of {list(spec.content_types)})"
return None
# ─── Field classification + binding (shared by every backend adapter) ─────────
def _strip_annotated_meta(annotation: Any) -> tuple[Any, File | None]:
"""Unwrap a ``typing.Annotated``, returning ``(base_type, File-marker-or-None)``."""
if hasattr(annotation, "__metadata__"):
spec = next((m for m in annotation.__metadata__ if isinstance(m, File)), None)
return annotation.__origin__, spec
return annotation, None
def classify_upload(field_info: Any) -> tuple[bool, bool, bool, File | None]:
"""Detect an ``Upload``-typed field → ``(is_upload, is_list, is_optional, spec)``.
Composes through ``Optional[...]``, ``list[...]``, and
``Annotated[..., File(...)]`` in any order, gathering the ``File`` marker
wherever it appears (Pydantic lifts a top-level marker into
``field_info.metadata``; nested markers stay inside the annotation).
"""
spec = next((m for m in getattr(field_info, "metadata", None) or [] if isinstance(m, File)), None)
ann = field_info.annotation
ann, s = _strip_annotated_meta(ann); spec = spec or s
ann, is_optional = extract_optional(ann)
ann, s = _strip_annotated_meta(ann); spec = spec or s
elem = extract_list_element(ann)
is_list = elem is not None
if is_list:
ann, s = _strip_annotated_meta(elem); spec = spec or s
return ann is Upload, is_list, is_optional, spec
def upload_fields(model: Any) -> dict[str, tuple[bool, File | None]]:
"""Map each ``Upload``-typed field of a Pydantic model → ``(is_list, spec)``."""
out: dict[str, tuple[bool, File | None]] = {}
for name, field_info in model.model_fields.items():
is_upload, is_list, _is_opt, spec = classify_upload(field_info)
if is_upload:
out[name] = (is_list, spec)
return out
def bind_uploads(
input_cls: Any,
args: dict[str, Any],
files: dict[str, list[UploadedFile]],
) -> str | None:
"""Place uploaded files into ``args`` by field name, enforcing constraints.
Mutates ``args`` in place. ``files`` maps a field name to the parts received
for it (an array field receives several). Returns an error message on the
first constraint violation, else ``None``. Absent files are left for
Pydantic's required/optional handling.
"""
for fname, (is_list, spec) in upload_fields(input_cls).items():
bucket = files.get(fname) or []
if not bucket:
continue
for f in bucket:
err = validate_upload(f, spec)
if err is not None:
return f"{fname}: {err}"
args[fname] = list(bucket) if is_list else bucket[0]
return None