Skip to content

Commit 8c60139

Browse files
authored
fix: FileObject native Pydantic Core integration (#458)
File object will now serialize properly in pydantic. More complete FastAPI examples added.
1 parent 04631b4 commit 8c60139

File tree

11 files changed

+694
-73
lines changed

11 files changed

+694
-73
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
- id: unasyncd
2323
additional_dependencies: ["ruff"]
2424
- repo: https://github.com/charliermarsh/ruff-pre-commit
25-
rev: "v0.11.6"
25+
rev: "v0.11.7"
2626
hooks:
2727
# Run the linter.
2828
- id: ruff
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Internal typing helpers for file_object, handling optional Pydantic integration."""
2+
3+
from typing import Any, Protocol, TypeVar, runtime_checkable
4+
5+
# Define a generic type variable for CoreSchema placeholder if needed
6+
CoreSchemaT = TypeVar("CoreSchemaT")
7+
8+
try:
9+
# Attempt to import real Pydantic components
10+
from pydantic import GetCoreSchemaHandler # pyright: ignore
11+
from pydantic_core import core_schema # pyright: ignore
12+
13+
PYDANTIC_INSTALLED = True
14+
15+
except ImportError:
16+
PYDANTIC_INSTALLED = False # pyright: ignore
17+
18+
@runtime_checkable
19+
class GetCoreSchemaHandler(Protocol): # type: ignore[no-redef]
20+
"""Placeholder for Pydantic's GetCoreSchemaHandler."""
21+
22+
def __call__(self, source_type: Any) -> Any: ...
23+
24+
def __getattr__(self, item: str) -> Any: # Allow arbitrary attribute access
25+
return Any
26+
27+
# Define a placeholder for core_schema module
28+
class CoreSchemaModulePlaceholder:
29+
"""Placeholder for pydantic_core.core_schema module."""
30+
31+
# Define placeholder types/functions used in FileObject.__get_pydantic_core_schema__
32+
CoreSchema = Any # Placeholder for the CoreSchema type itself
33+
34+
def __getattr__(self, name: str) -> Any:
35+
"""Return a dummy function/type for any requested attribute."""
36+
37+
def dummy_schema_func(*args: Any, **kwargs: Any) -> Any: # noqa: ARG001
38+
return Any
39+
40+
return dummy_schema_func
41+
42+
core_schema = CoreSchemaModulePlaceholder() # type: ignore[assignment]
43+
44+
__all__ = ("GetCoreSchemaHandler", "core_schema")

advanced_alchemy/types/file_object/file.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# ruff: noqa: PLR0904, PLR6301
21
"""Generic unified storage protocol compatible with multiple backend implementations."""
32

43
import mimetypes
@@ -8,6 +7,8 @@
87
from sqlalchemy.ext.mutable import MutableList
98
from typing_extensions import TypeAlias
109

10+
from advanced_alchemy.exceptions import MissingDependencyError
11+
from advanced_alchemy.types.file_object._typing import PYDANTIC_INSTALLED, GetCoreSchemaHandler, core_schema
1112
from advanced_alchemy.types.file_object.base import AsyncDataLike, DataLike, StorageBackend
1213
from advanced_alchemy.types.file_object.registry import storages
1314

@@ -409,5 +410,93 @@ async def save_async(
409410

410411
return updated_self
411412

413+
@classmethod
414+
def __get_pydantic_core_schema__(
415+
cls,
416+
source_type: Any,
417+
handler: "GetCoreSchemaHandler", # Use imported GetCoreSchemaHandler
418+
) -> "core_schema.CoreSchema": # Use imported core_schema
419+
"""Get the Pydantic core schema for FileObject.
420+
421+
This method defines how Pydantic should validate and serialize FileObject instances.
422+
It creates a schema that validates dictionaries with the required fields and
423+
converts them to FileObject instances.
424+
425+
Raises:
426+
MissingDependencyError: If Pydantic is not installed when this method is called.
427+
428+
Args:
429+
source_type: The source type (FileObject)
430+
handler: The Pydantic schema handler
431+
432+
Returns:
433+
A Pydantic core schema for FileObject
434+
"""
435+
if not PYDANTIC_INSTALLED:
436+
raise MissingDependencyError(package="pydantic")
437+
438+
def validate_from_dict(data: dict[str, Any]) -> "FileObject":
439+
# We expect a dictionary derived from to_dict()
440+
# We need to resolve the backend string back to an instance if needed
441+
backend_input = data.get("backend")
442+
if backend_input is None:
443+
msg = "backend is required"
444+
raise TypeError(msg)
445+
key = backend_input if isinstance(backend_input, str) else backend_input.key
446+
return cls(
447+
backend=key,
448+
filename=data["filename"],
449+
to_filename=data.get("to_filename"),
450+
content_type=data.get("content_type"),
451+
size=data.get("size"),
452+
last_modified=data.get("last_modified"),
453+
checksum=data.get("checksum"),
454+
etag=data.get("etag"),
455+
version_id=data.get("version_id"),
456+
metadata=data.get("metadata"),
457+
)
458+
459+
typed_dict_schema = core_schema.typed_dict_schema(
460+
{
461+
"filename": core_schema.typed_dict_field(core_schema.str_schema()),
462+
"backend": core_schema.typed_dict_field(core_schema.str_schema()),
463+
"to_filename": core_schema.typed_dict_field(core_schema.str_schema(), required=False),
464+
"content_type": core_schema.typed_dict_field(core_schema.str_schema(), required=False),
465+
"size": core_schema.typed_dict_field(core_schema.int_schema(), required=False),
466+
"last_modified": core_schema.typed_dict_field(core_schema.float_schema(), required=False),
467+
"checksum": core_schema.typed_dict_field(core_schema.str_schema(), required=False),
468+
"etag": core_schema.typed_dict_field(core_schema.str_schema(), required=False),
469+
"version_id": core_schema.typed_dict_field(core_schema.str_schema(), required=False),
470+
"metadata": core_schema.typed_dict_field(
471+
core_schema.nullable_schema(
472+
core_schema.dict_schema(core_schema.str_schema(), core_schema.any_schema())
473+
),
474+
required=False,
475+
),
476+
}
477+
)
478+
479+
validation_schema = core_schema.union_schema(
480+
[
481+
core_schema.is_instance_schema(cls),
482+
core_schema.chain_schema(
483+
[
484+
typed_dict_schema,
485+
core_schema.no_info_plain_validator_function(validate_from_dict),
486+
]
487+
),
488+
]
489+
)
490+
491+
return core_schema.json_or_python_schema(
492+
json_schema=validation_schema,
493+
python_schema=validation_schema,
494+
serialization=core_schema.plain_serializer_function_ser_schema(
495+
lambda instance: instance.to_dict(), # pyright: ignore
496+
info_arg=False,
497+
return_schema=typed_dict_schema,
498+
), # pyright: ignore
499+
)
500+
412501

413502
FileObjectList: TypeAlias = MutableList[FileObject]
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# /// script
2+
# dependencies = [
3+
# "advanced_alchemy[obstore,uuid]",
4+
# "aiosqlite",
5+
# "fastapi[standard]",
6+
# "orjson"
7+
# "obstore"
8+
# ]
9+
# ///
10+
from typing import Annotated, Any, Optional, Union
11+
from uuid import UUID
12+
13+
import uvicorn
14+
from fastapi import APIRouter, Depends, FastAPI, File, Form, UploadFile
15+
from pydantic import BaseModel, Field, computed_field
16+
from sqlalchemy.orm import Mapped, mapped_column
17+
18+
from advanced_alchemy.extensions.fastapi import (
19+
AdvancedAlchemy,
20+
AsyncSessionConfig,
21+
SQLAlchemyAsyncConfig,
22+
base,
23+
filters,
24+
repository,
25+
service,
26+
)
27+
from advanced_alchemy.types import FileObject, storages
28+
from advanced_alchemy.types.file_object.backends.obstore import ObstoreBackend
29+
from advanced_alchemy.types.file_object.data_type import StoredObject
30+
31+
sqlalchemy_config = SQLAlchemyAsyncConfig(
32+
connection_string="sqlite+aiosqlite:///test.sqlite",
33+
session_config=AsyncSessionConfig(expire_on_commit=False),
34+
commit_mode="autocommit",
35+
create_all=True,
36+
)
37+
app = FastAPI()
38+
alchemy = AdvancedAlchemy(config=sqlalchemy_config, app=app)
39+
document_router = APIRouter()
40+
s3_backend = ObstoreBackend(
41+
key="local",
42+
fs="s3://static-files/",
43+
aws_endpoint="http://localhost:9000",
44+
aws_access_key_id="minioadmin",
45+
aws_secret_access_key="minioadmin", # noqa: S106
46+
)
47+
storages.register_backend(s3_backend)
48+
49+
50+
class DocumentModel(base.UUIDBase):
51+
# we can optionally provide the table name instead of auto-generating it
52+
__tablename__ = "document"
53+
name: Mapped[str]
54+
file: Mapped[FileObject] = mapped_column(StoredObject(backend="local"))
55+
56+
57+
class DocumentService(service.SQLAlchemyAsyncRepositoryService[DocumentModel]):
58+
"""Author repository."""
59+
60+
class Repo(repository.SQLAlchemyAsyncRepository[DocumentModel]):
61+
"""Author repository."""
62+
63+
model_type = DocumentModel
64+
65+
repository_type = Repo
66+
67+
68+
# Pydantic Models
69+
70+
71+
class Document(BaseModel):
72+
id: Optional[UUID]
73+
name: str
74+
file: Optional[FileObject] = Field(default=None, exclude=True)
75+
76+
@computed_field
77+
def file_url(self) -> Optional[Union[str, list[str]]]:
78+
if self.file is None:
79+
return None
80+
return self.file.sign()
81+
82+
83+
@document_router.get(path="/documents", response_model=service.OffsetPagination[Document])
84+
async def list_documents(
85+
documents_service: Annotated[
86+
DocumentService, Depends(alchemy.provide_service(DocumentService, load=[DocumentModel.file]))
87+
],
88+
filters: Annotated[
89+
list[filters.FilterTypes],
90+
Depends(
91+
alchemy.provide_filters(
92+
{
93+
"id_filter": UUID,
94+
"pagination_type": "limit_offset",
95+
"search": "name",
96+
"search_ignore_case": True,
97+
}
98+
)
99+
),
100+
],
101+
) -> service.OffsetPagination[Document]:
102+
results, total = await documents_service.list_and_count(*filters)
103+
return documents_service.to_schema(results, total, filters=filters, schema_type=Document)
104+
105+
106+
@document_router.post(path="/documents")
107+
async def create_document(
108+
documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))],
109+
name: Annotated[str, Form()],
110+
file: Annotated[Optional[UploadFile], File()] = None,
111+
) -> Document:
112+
obj = await documents_service.create(
113+
DocumentModel(
114+
name=name,
115+
file=FileObject(
116+
backend="local",
117+
filename=file.filename or "uploaded_file",
118+
content_type=file.content_type,
119+
content=await file.read(),
120+
)
121+
if file
122+
else None,
123+
)
124+
)
125+
return documents_service.to_schema(obj, schema_type=Document)
126+
127+
128+
@document_router.get(path="/documents/{document_id}")
129+
async def get_document(
130+
documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))],
131+
document_id: UUID,
132+
) -> Document:
133+
obj = await documents_service.get(document_id)
134+
return documents_service.to_schema(obj, schema_type=Document)
135+
136+
137+
@document_router.patch(path="/documents/{document_id}")
138+
async def update_document(
139+
documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))],
140+
document_id: UUID,
141+
name: Annotated[Optional[str], Form()] = None,
142+
file: Annotated[Optional[UploadFile], File()] = None,
143+
) -> Document:
144+
update_data: dict[str, Any] = {}
145+
if name is not None:
146+
update_data["name"] = name
147+
if file is not None:
148+
update_data["file"] = FileObject(
149+
backend="local",
150+
filename=file.filename or "uploaded_file",
151+
content_type=file.content_type,
152+
content=await file.read(),
153+
)
154+
155+
obj = await documents_service.update(update_data, item_id=document_id)
156+
return documents_service.to_schema(obj, schema_type=Document)
157+
158+
159+
@document_router.delete(path="/documents/{document_id}")
160+
async def delete_document(
161+
documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))],
162+
document_id: UUID,
163+
) -> None:
164+
_ = await documents_service.delete(document_id)
165+
166+
167+
app.include_router(document_router)
168+
169+
170+
if __name__ == "__main__":
171+
uvicorn.run(app, host="0.0.0.0", port=8000) # noqa: S104

0 commit comments

Comments
 (0)