Added file upload support
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
|
||||
|
||||
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
216
cores/mizan-python/src/mizan_core/upload.py
Normal file
216
cores/mizan-python/src/mizan_core/upload.py
Normal 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
|
||||
Reference in New Issue
Block a user