diff --git a/fundi/__init__.py b/fundi/__init__.py index 3989c2d..62061c4 100644 --- a/fundi/__init__.py +++ b/fundi/__init__.py @@ -8,7 +8,7 @@ from .inject import inject, ainject from .configurable import configurable_dependency, MutableConfigurationWarning from .util import injection_trace, is_configured, get_configuration, normalize_annotation -from .virtual_context import virtual_context, VirtualContextManager, AsyncVirtualContextManager +from .virtual_context import virtual_context, VirtualContextProvider, AsyncVirtualContextProvider from .types import CallableInfo, TypeResolver, InjectionTrace, R, Parameter, DependencyConfiguration @@ -33,9 +33,9 @@ "injection_trace", "get_configuration", "normalize_annotation", - "VirtualContextManager", + "VirtualContextProvider", "DependencyConfiguration", "configurable_dependency", - "AsyncVirtualContextManager", + "AsyncVirtualContextProvider", "MutableConfigurationWarning", ] diff --git a/fundi/compat/fastapi/__init__.py b/fundi/compat/fastapi/__init__.py new file mode 100644 index 0000000..26e84d9 --- /dev/null +++ b/fundi/compat/fastapi/__init__.py @@ -0,0 +1,13 @@ +from .secured import secured +from .route import FunDIRoute +from .router import FunDIRouter +from .handler import get_request_handler +from .dependant import get_scope_dependant + +__all__ = [ + "secured", + "FunDIRoute", + "FunDIRouter", + "get_request_handler", + "get_scope_dependant", +] diff --git a/fundi/compat/fastapi/constants.py b/fundi/compat/fastapi/constants.py new file mode 100644 index 0000000..b8aecdb --- /dev/null +++ b/fundi/compat/fastapi/constants.py @@ -0,0 +1,5 @@ +__all__ = ["METADATA_SECURITY_SCOPES", "METADATA_DEPENDANT", "METADATA_SCOPE_EXTRA"] + +METADATA_SECURITY_SCOPES = "fastapi_security_scopes" +METADATA_DEPENDANT = "fastapi_dependant" +METADATA_SCOPE_EXTRA = "scope_extra" diff --git a/fundi/compat/fastapi/dependant.py b/fundi/compat/fastapi/dependant.py new file mode 100644 index 0000000..4f76f04 --- /dev/null +++ b/fundi/compat/fastapi/dependant.py @@ -0,0 +1,106 @@ +import typing + +from fastapi import params +from fastapi._compat import ModelField +from fastapi.security.base import SecurityBase +from fastapi.dependencies.models import Dependant, SecurityRequirement + +from fundi.util import callable_str +from fundi.types import CallableInfo + +from .metadata import build_metadata, get_metadata +from .constants import METADATA_DEPENDANT, METADATA_SECURITY_SCOPES + +from fastapi.dependencies.utils import ( + analyze_param, + add_param_to_fields, + add_non_field_param_to_dependency, +) + +MF = typing.TypeVar("MF", bound=ModelField) + + +def merge(into: list[MF], from_: list[MF]): + names = {field.name for field in into} + + for field in from_: + if field.name not in names: + into.append(field) + + +def update_dependant(source: Dependant, target: Dependant): + merge(target.path_params, source.path_params) + merge(target.query_params, source.query_params) + merge(target.header_params, source.header_params) + merge(target.cookie_params, source.cookie_params) + merge(target.body_params, source.body_params) + + target.security_requirements.extend(source.security_requirements) + target.dependencies.extend(source.dependencies) + if source.security_scopes: + if target.security_scopes is None: + target.security_scopes = [] + + target.security_scopes[::] = set().union(target.security_scopes, source.security_scopes) + + +def get_scope_dependant( + ci: CallableInfo[typing.Any], + path_param_names: set[str], + path: str, +) -> Dependant: + build_metadata(ci) + + dependant = Dependant(path=path) + dependant_metadata = get_metadata(ci) + dependant.security_scopes = dependant_metadata[METADATA_SECURITY_SCOPES] + + dependant_metadata.update({METADATA_DEPENDANT: dependant}) + + flat_dependant = Dependant( + path=path, security_scopes=dependant_metadata[METADATA_SECURITY_SCOPES] + ) + + for param in ci.parameters: + if param.from_ is not None: + subci = param.from_ + + sub = get_scope_dependant(subci, path_param_names, path) + update_dependant(sub, flat_dependant) + + # This is required to pass security_scopes to dependency. + # Here parameter name and security scopes itself are set. + metadata = get_metadata(subci) + + if isinstance(subci.call, SecurityBase): + flat_dependant.security_requirements.append( + SecurityRequirement(subci.call, metadata[METADATA_SECURITY_SCOPES]) + ) + + continue + + details = analyze_param( + param_name=param.name, + annotation=param.annotation, + value=param.default, + is_path_param=param.name in path_param_names, + ) + + if add_non_field_param_to_dependency( + param_name=param.name, type_annotation=param.annotation, dependant=dependant + ): + assert ( + details.field is None + ), f'Non-field parameter shouldn\'t have field: error caused by analysis of the parameter "{param.name}" in {callable_str(ci.call)}' + + continue + + assert details.field is not None + if isinstance(details.field.field_info, params.Body): + dependant.body_params.append(details.field) + else: + add_param_to_fields(field=details.field, dependant=dependant) + + update_dependant(dependant, flat_dependant) + + return flat_dependant diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py new file mode 100644 index 0000000..e35c1d1 --- /dev/null +++ b/fundi/compat/fastapi/handler.py @@ -0,0 +1,112 @@ +import typing +from collections.abc import Coroutine +from contextlib import AsyncExitStack + +from fastapi.types import IncEx +from starlette.requests import Request +from fastapi._compat import ModelField +from fastapi.routing import serialize_response +from starlette.background import BackgroundTasks +from starlette.responses import JSONResponse, Response +from fastapi.utils import is_body_allowed_for_status_code +from fastapi.datastructures import Default, DefaultPlaceholder + +from .inject import inject +from fundi.types import CallableInfo + +from .validate_request_body import validate_body + + +def get_request_handler( + ci: CallableInfo[typing.Any], + extra_dependencies: list[CallableInfo[typing.Any]], + body_field: ModelField | None = None, + status_code: int | None = None, + response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), + response_field: ModelField | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + dependency_overrides_provider: typing.Any | None = None, + embed_body_fields: bool = False, +) -> typing.Callable[[Request], Coroutine[typing.Any, typing.Any, Response]]: + + if isinstance(response_class, DefaultPlaceholder): + actual_response_class: type[Response] = response_class.value + else: + actual_response_class = response_class + + async def app(request: Request) -> Response: + background_tasks = BackgroundTasks() + stack = AsyncExitStack() + # Close exit stack at after the response is sent + background_tasks.add_task(stack.aclose) + + response = Response() + del response.headers["content-length"] + response.status_code = None # pyright: ignore[reportAttributeAccessIssue] + + body_stack = AsyncExitStack() + async with body_stack: + body = await validate_body(request, body_stack, body_field) + + for dependency in extra_dependencies: + await inject( + dependency, + stack, + request, + body, + dependency_overrides_provider, + embed_body_fields, + background_tasks, + response, + ) + + raw_response = await inject( + ci, + stack, + request, + body, + dependency_overrides_provider, + embed_body_fields, + background_tasks, + response, + ) + + if isinstance(raw_response, Response): + if raw_response.background is None: + raw_response.background = background_tasks + + return raw_response + + response_args: dict[str, typing.Any] = {"background": background_tasks} + + # If status_code was set, use it, otherwise use the default from the + # response class, in the case of redirect it's 307 + status = response.status_code or status_code + if status is not None: + response_args["status_code"] = status + + content = await serialize_response( + field=response_field, + response_content=raw_response, + include=response_model_include, + exclude=response_model_exclude, + by_alias=response_model_by_alias, + exclude_unset=response_model_exclude_unset, + exclude_defaults=response_model_exclude_defaults, + exclude_none=response_model_exclude_none, + is_coroutine=ci.async_, + ) + response = actual_response_class(content, **response_args) + if not is_body_allowed_for_status_code(response.status_code): + response.body = b"" + + response.headers.raw.extend(response.headers.raw) + + return response + + return app diff --git a/fundi/compat/fastapi/inject.py b/fundi/compat/fastapi/inject.py new file mode 100644 index 0000000..37c3d15 --- /dev/null +++ b/fundi/compat/fastapi/inject.py @@ -0,0 +1,101 @@ +import typing +import contextlib +import collections.abc + +from starlette.requests import Request +from starlette.responses import Response +from starlette.datastructures import FormData +from fastapi._compat import _normalize_errors # pyright: ignore[reportPrivateUsage] +from starlette.background import BackgroundTasks +from fastapi.exceptions import RequestValidationError +from fastapi.dependencies.utils import solve_dependencies + +from fundi.inject import injection_impl +from fundi.util import call_async, call_sync +from fundi.types import CacheKey, CallableInfo + +from .metadata import get_metadata +from .types import DependencyOverridesProvider +from .constants import METADATA_DEPENDANT, METADATA_SCOPE_EXTRA, METADATA_SECURITY_SCOPES + + +async def inject( + info: CallableInfo[typing.Any], + stack: contextlib.AsyncExitStack, + request: Request, + body: FormData | typing.Any | None, + dependency_overrides_provider: DependencyOverridesProvider | None, + embed_body_fields: bool, + background_tasks: BackgroundTasks, + response: Response, + cache: collections.abc.MutableMapping[CacheKey, typing.Any] | None = None, + override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> typing.Any: + """ + Asynchronously inject dependencies into callable. + + :param scope: container with contextual values + :param info: callable information + :param stack: exit stack to properly handle generator dependencies + :param cache: dependency cache + :param override: override dependencies + :return: result of callable + """ + if cache is None: + cache = {} + + metadata = get_metadata(info) + + fastapi_params = await solve_dependencies( + request=request, + dependant=metadata[METADATA_DEPENDANT], + body=body, + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=stack, + embed_body_fields=embed_body_fields, + background_tasks=background_tasks, + response=response, + ) + + if fastapi_params.errors: + raise RequestValidationError(_normalize_errors(fastapi_params.errors), body=body) + + scope = fastapi_params.values + + scope_extra: collections.abc.Mapping[str, typing.Any] = metadata.get(METADATA_SCOPE_EXTRA, {}) + + if scope_extra: + scope = {**scope, **scope_extra} + + gen = injection_impl(scope, info, cache, override) + + value: typing.Any | None = None + + try: + while True: + inner_scope, inner_info, more = gen.send(value) + + if more: + value = await inject( + inner_info, + stack, + request, + body, + dependency_overrides_provider, + embed_body_fields, + background_tasks, + response, + cache, + override, + ) + continue + + if info.async_: + return await call_async(stack, inner_info, inner_scope) + + return call_sync(stack, inner_info, inner_scope) + except Exception as exc: + with contextlib.suppress(StopIteration): + gen.throw(type(exc), exc, exc.__traceback__) + + raise diff --git a/fundi/compat/fastapi/metadata.py b/fundi/compat/fastapi/metadata.py new file mode 100644 index 0000000..c400790 --- /dev/null +++ b/fundi/compat/fastapi/metadata.py @@ -0,0 +1,79 @@ +import typing + +from fastapi import params + +from fundi import scan +from fundi.util import callable_str +from fundi.types import CallableInfo + +from .secured import secured_scan +from .constants import METADATA_SECURITY_SCOPES + + +def get_metadata(info: CallableInfo[typing.Any]) -> dict[str, typing.Any]: + metadata: dict[str, typing.Any] | None = getattr(info, "metadata", None) + if metadata is None: + metadata = {} + setattr(info, "metadata", metadata) + + return metadata + + +def build_metadata( + info: CallableInfo[typing.Any], *, security_scopes: list[str] | None = None +) -> None: + security_scopes = security_scopes or [] + + scopes_up = security_scopes + scopes_down = security_scopes.copy() + + metadata = get_metadata(info) + metadata.setdefault(METADATA_SECURITY_SCOPES, scopes_up) + + for parameter in info.parameters: + subinfo = parameter.from_ + + if subinfo is None: + if isinstance(parameter.default, params.Security): + security = parameter.default + assert ( + security.dependency + ), f"Parameter {parameter.name} in {callable_str(info.call)} doesn't have dependency setup" + + subinfo = secured_scan(security.dependency, security.scopes, security.use_cache) + + elif isinstance(parameter.default, params.Depends): + depends = parameter.default + assert ( + depends.dependency + ), f"Parameter {parameter.name} in {callable_str(info.call)} doesn't have dependency setup" + + subinfo = scan(depends.dependency, depends.use_cache) + + else: + continue + + parameter.from_ = subinfo + + param_metadata = get_metadata(subinfo) + + security: params.Security | None = None + + if typing.get_origin(parameter.annotation) is typing.Annotated: + args = typing.get_args(parameter.annotation) + presence: tuple[params.Security] | tuple[()] = tuple( + filter(lambda x: isinstance(x, params.Security), args) + ) + if presence: + security = presence[0] + + if security is not None: + scopes_down[::] = set().union(security.scopes, scopes_down) + + elif METADATA_SECURITY_SCOPES in param_metadata: + scopes_down[::] = set().union(param_metadata[METADATA_SECURITY_SCOPES], scopes_down) + + build_metadata(subinfo, security_scopes=scopes_down) + scopes_up[::] = set().union(scopes_up, scopes_down) + + info.key.add(tuple(scopes_down)) diff --git a/fundi/compat/fastapi/route.py b/fundi/compat/fastapi/route.py new file mode 100644 index 0000000..d42ba4c --- /dev/null +++ b/fundi/compat/fastapi/route.py @@ -0,0 +1,221 @@ +import typing +import inspect +from enum import Enum, IntEnum +from collections.abc import Sequence + +from fastapi.types import IncEx +from fastapi import Response, params +from fastapi.routing import APIRoute +from fastapi._compat import ModelField +from fastapi.responses import JSONResponse +from pydantic.v1.utils import lenient_issubclass +from fastapi.datastructures import Default, DefaultPlaceholder +from starlette.routing import BaseRoute, compile_path, get_name, request_response + +from fastapi.utils import ( + create_model_field, # pyright: ignore[reportUnknownVariableType] + generate_unique_id, + get_path_param_names, + is_body_allowed_for_status_code, +) +from fastapi.dependencies.utils import ( + get_body_field, + _should_embed_body_fields, # pyright: ignore[reportPrivateUsage] +) + +from fundi import scan +from fundi.util import callable_str +from fundi.types import CallableInfo +from fundi.compat.fastapi import secured +from .handler import get_request_handler +from .dependant import get_scope_dependant, update_dependant + + +@typing.final +class FunDIRoute(APIRoute): + + def __init__( # pyright: ignore[reportMissingSuperCall] + self, + path: str, + endpoint: typing.Callable[..., typing.Any], + *, + response_model: typing.Any = Default(None), + status_code: int | None = None, + tags: list[str | Enum] | None = None, + dependencies: ( + Sequence[typing.Callable[..., typing.Any] | params.Depends | CallableInfo[typing.Any]] + | None + ) = None, + summary: str | None = None, + description: str | None = None, + response_description: str = "Successful Response", + responses: dict[int | str, dict[str, typing.Any]] | None = None, + deprecated: bool | None = None, + name: str | None = None, + methods: set[str] | list[str] | None = None, + operation_id: str | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + include_in_schema: bool = True, + response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), + dependency_overrides_provider: typing.Any | None = None, + callbacks: list[BaseRoute] | None = None, + openapi_extra: dict[str, typing.Any] | None = None, + generate_unique_id_function: ( + typing.Callable[[APIRoute], str] | DefaultPlaceholder + ) = Default(generate_unique_id), + ) -> None: + callable_info = scan(endpoint) + self.ci = callable_info + self.path = path + self.endpoint = endpoint + self.dependencies: list[CallableInfo[typing.Any]] = [] + + for dependency in dependencies or []: + if isinstance(dependency, params.Security): + security = dependency + assert ( + security.dependency is not None + ), f"Dependency defined in endpoint {callable_str(endpoint)} doesn't have callable" + + self.dependencies.append( + secured(security.dependency, security.scopes, security.use_cache) + ) + continue + + if isinstance(dependency, params.Depends): + depends = dependency + + assert ( + depends.dependency is not None + ), f"Dependency defined in endpoint {callable_str(endpoint)} doesn't have callable" + + self.dependencies.append(scan(depends.dependency, depends.use_cache)) + continue + + if isinstance(dependency, CallableInfo): + self.dependencies.append(dependency) + continue + + self.dependencies.append(scan(dependency)) + + if isinstance(response_model, DefaultPlaceholder): + if not lenient_issubclass(callable_info.return_annotation, Response): + response_model = None + else: + response_model = callable_info.return_annotation + + self.response_model = response_model + self.summary = summary + self.response_description = response_description + self.deprecated = deprecated + self.operation_id = operation_id + self.response_model_include = response_model_include + self.response_model_exclude = response_model_exclude + self.response_model_by_alias = response_model_by_alias + self.response_model_exclude_unset = response_model_exclude_unset + self.response_model_exclude_defaults = response_model_exclude_defaults + self.response_model_exclude_none = response_model_exclude_none + self.include_in_schema = include_in_schema + self.response_class = response_class + self.dependency_overrides_provider = dependency_overrides_provider + self.callbacks = callbacks + self.openapi_extra = openapi_extra + self.generate_unique_id_function = generate_unique_id_function + self.tags = tags or [] + self.responses = responses or {} + self.name = get_name(endpoint) if name is None else name + self.path_regex, self.path_format, self.param_convertors = compile_path(path) + + if methods is None: + methods = ["GET"] + self.methods: set[str] = {method.upper() for method in methods} + + if isinstance(generate_unique_id_function, DefaultPlaceholder): + current_generate_unique_id: typing.Callable[[APIRoute], str] = ( + generate_unique_id_function.value + ) + else: + current_generate_unique_id = generate_unique_id_function + + self.unique_id = self.operation_id or current_generate_unique_id(self) + # normalize enums e.g. http.HTTPStatus + if isinstance(status_code, IntEnum): + status_code = int(status_code) + + self.status_code = status_code + + if self.response_model: + assert is_body_allowed_for_status_code( + status_code + ), f"Status code {status_code} must not have a response body" + response_name = "Response_" + self.unique_id + self.response_field = create_model_field( + name=response_name, + type_=self.response_model, + mode="serialization", + ) + self.secure_cloned_response_field = None + else: + self.response_field = None # type: ignore + self.secure_cloned_response_field = None + + self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") + + # if a "form feed" character (page break) is found in the description text, + # truncate description text to the content preceding the first "form feed" + self.description = self.description.split("\f")[0].strip() + + response_fields: dict[int | str, ModelField] = {} + for additional_status_code, response in self.responses.items(): + assert isinstance(response, dict), "An additional response must be a dict" + model = response.get("model") + if model: + assert is_body_allowed_for_status_code( + additional_status_code + ), f"Status code {additional_status_code} must not have a response body" + response_name = f"Response_{additional_status_code}_{self.unique_id}" + response_field = create_model_field( + name=response_name, type_=model, mode="serialization" + ) + response_fields[additional_status_code] = response_field + + self.response_fields = response_fields + + path_param_names = get_path_param_names(self.path_format) + self.dependant = get_scope_dependant(callable_info, path_param_names, self.path_format) + + for ci in self.dependencies: + update_dependant( + get_scope_dependant(ci, path_param_names, self.path_format), self.dependant + ) + + self._embed_body_fields = _should_embed_body_fields(self.dependant.body_params) + self.body_field = get_body_field( + flat_dependant=self.dependant, + name=self.unique_id, + embed_body_fields=self._embed_body_fields, + ) + + self.app = request_response( + get_request_handler( + callable_info, + extra_dependencies=self.dependencies[::-1], + body_field=self.body_field, + status_code=self.status_code, + response_class=self.response_class, + response_field=self.secure_cloned_response_field, + response_model_include=self.response_model_include, + response_model_exclude=self.response_model_exclude, + response_model_by_alias=self.response_model_by_alias, + response_model_exclude_unset=self.response_model_exclude_unset, + response_model_exclude_defaults=self.response_model_exclude_defaults, + response_model_exclude_none=self.response_model_exclude_none, + dependency_overrides_provider=self.dependency_overrides_provider, + embed_body_fields=self._embed_body_fields, + ) + ) diff --git a/fundi/compat/fastapi/router.py b/fundi/compat/fastapi/router.py new file mode 100644 index 0000000..adc7414 --- /dev/null +++ b/fundi/compat/fastapi/router.py @@ -0,0 +1,56 @@ +import typing +from enum import Enum +from collections.abc import Sequence +from fastapi.routing import APIRoute +from fastapi import APIRouter, params +from starlette.routing import BaseRoute +from starlette.responses import Response +from fastapi.datastructures import Default +from fastapi.responses import JSONResponse +from fastapi.utils import generate_unique_id +from starlette.types import ASGIApp, Lifespan + +from fundi.compat.fastapi.route import FunDIRoute + + +class FunDIRouter(APIRouter): + def __init__( + self, + *, + prefix: str = "", + tags: list[str | Enum] | None = None, + dependencies: Sequence[params.Depends] | None = None, + default_response_class: type[Response] = Default(JSONResponse), + responses: dict[int | str, dict[str, typing.Any]] | None = None, + callbacks: list[BaseRoute] | None = None, + routes: list[BaseRoute] | None = None, + redirect_slashes: bool = True, + default: ASGIApp | None = None, + dependency_overrides_provider: typing.Any = None, + route_class: type[APIRoute] = FunDIRoute, + on_startup: Sequence[typing.Callable[[], typing.Any]] | None = None, + on_shutdown: Sequence[typing.Callable[[], typing.Any]] | None = None, + lifespan: Lifespan[typing.Any] | None = None, + deprecated: bool | None = None, + include_in_schema: bool = True, + generate_unique_id_function: typing.Callable[[APIRoute], str] = Default(generate_unique_id), + ) -> None: + super().__init__( + prefix=prefix, + tags=tags, + dependencies=dependencies, + default_response_class=default_response_class, + responses=responses, + callbacks=callbacks, + routes=routes, + redirect_slashes=redirect_slashes, + default=default, + dependency_overrides_provider=dependency_overrides_provider, + route_class=route_class, + on_startup=on_startup, + on_shutdown=on_shutdown, + lifespan=lifespan, + deprecated=deprecated, + include_in_schema=include_in_schema, + generate_unique_id_function=generate_unique_id_function, + ) diff --git a/fundi/compat/fastapi/secured.py b/fundi/compat/fastapi/secured.py new file mode 100644 index 0000000..8beb5b8 --- /dev/null +++ b/fundi/compat/fastapi/secured.py @@ -0,0 +1,39 @@ +import typing +from collections.abc import Sequence + +from fundi import scan +from fundi.types import CallableInfo + +from .constants import METADATA_SECURITY_SCOPES + +__all__ = ["secured"] + + +def secured_scan( + dependency: typing.Callable[..., typing.Any], scopes: Sequence[str], caching: bool = True +) -> CallableInfo[typing.Any]: + """ + Scan dependency and setup it's security scopes + """ + from .metadata import get_metadata + + info = scan(dependency, caching=caching) + + metadata = get_metadata(info) + metadata.update({METADATA_SECURITY_SCOPES: list(scopes)}) + + return info + + +def secured( + dependency: typing.Callable[..., typing.Any], scopes: Sequence[str], caching: bool = True +) -> CallableInfo[typing.Any]: + """ + Use callable dependency for parameter of function + + :param dependency: function dependency + :param caching: Whether to use cached result of this callable or not + :return: callable information + """ + + return secured_scan(dependency, scopes, caching) diff --git a/fundi/compat/fastapi/secured.pyi b/fundi/compat/fastapi/secured.pyi new file mode 100644 index 0000000..6b82837 --- /dev/null +++ b/fundi/compat/fastapi/secured.pyi @@ -0,0 +1,44 @@ +import typing +from typing import overload +from collections.abc import Generator, AsyncGenerator, Awaitable, Sequence +from contextlib import AbstractAsyncContextManager, AbstractContextManager + +from fundi.types import CallableInfo + +R = typing.TypeVar("R") + +def secured_scan( + dependency: typing.Callable[..., R], scopes: Sequence[str], caching: bool = True +) -> CallableInfo[R]: ... +@overload +def secured( + dependency: typing.Callable[..., AbstractContextManager[R]], + scopes: Sequence[str], + caching: bool = True, +) -> R: ... +@overload +def secured( + dependency: typing.Callable[..., AbstractAsyncContextManager[R]], + scopes: Sequence[str], + caching: bool = True, +) -> R: ... +@overload +def secured( + dependency: typing.Callable[..., Generator[R, None, None]], + scopes: Sequence[str], + caching: bool = True, +) -> R: ... +@overload +def secured( + dependency: typing.Callable[..., AsyncGenerator[R, None]], + scopes: Sequence[str], + caching: bool = True, +) -> R: ... +@overload +def secured( + dependency: typing.Callable[..., Awaitable[R]], scopes: Sequence[str], caching: bool = True +) -> R: ... +@overload +def secured( + dependency: typing.Callable[..., R], scopes: Sequence[str], caching: bool = True +) -> R: ... diff --git a/fundi/compat/fastapi/types.py b/fundi/compat/fastapi/types.py new file mode 100644 index 0000000..4426b94 --- /dev/null +++ b/fundi/compat/fastapi/types.py @@ -0,0 +1,8 @@ +from collections.abc import Mapping +import typing + + +class DependencyOverridesProvider(typing.Protocol): + dependency_overrides: Mapping[ + typing.Callable[..., typing.Any], typing.Callable[..., typing.Any] + ] diff --git a/fundi/compat/fastapi/validate_request_body.py b/fundi/compat/fastapi/validate_request_body.py new file mode 100644 index 0000000..aa7f268 --- /dev/null +++ b/fundi/compat/fastapi/validate_request_body.py @@ -0,0 +1,61 @@ +import json +import typing +from contextlib import AsyncExitStack +from starlette.requests import Request +from fastapi._compat import ModelField +from pydantic.v1.fields import Undefined +from fastapi import HTTPException, params +from fastapi.exceptions import RequestValidationError + + +async def validate_body(request: Request, stack: AsyncExitStack, body_field: ModelField | None): + is_body_form = body_field and isinstance(body_field.field_info, params.Form) + try: + if body_field: + if is_body_form: + form = await request.form() + stack.push_async_callback(form.close) + return form + + body_bytes = await request.body() + if body_bytes: + json_body: typing.Any = Undefined + content_type_value = request.headers.get("content-type") + + if not content_type_value: + json_body = await request.json() + + else: + if content_type_value.count("/") != 1: + content_type_value = "text/plain" + + maintype, subtype = content_type_value.split("/", 1) + + if maintype == "application": + if subtype == "json" or subtype.endswith("+json"): + json_body = await request.json() + + if json_body != Undefined: + return json_body + else: + return typing.cast(typing.Any, body_bytes) + except json.JSONDecodeError as e: + validation_error = RequestValidationError( + [ + { + "type": "json_invalid", + "loc": ("body", e.pos), + "msg": "JSON decode error", + "input": {}, + "ctx": {"error": e.msg}, + } + ], + body=e.doc, + ) + raise validation_error from e + except HTTPException: + # If a middleware raises an HTTPException, it should be raised again + raise + except Exception as e: + http_error = HTTPException(status_code=400, detail="There was an error parsing the body") + raise http_error from e diff --git a/fundi/scan.py b/fundi/scan.py index 39710c9..2c8e68c 100644 --- a/fundi/scan.py +++ b/fundi/scan.py @@ -1,6 +1,8 @@ -from dataclasses import replace import typing import inspect +from dataclasses import replace +from types import BuiltinFunctionType, FunctionType, MethodType +from contextlib import AbstractAsyncContextManager, AbstractContextManager from fundi.util import is_configured, get_configuration from fundi.types import R, CallableInfo, Parameter, TypeResolver @@ -51,6 +53,20 @@ def _transform_parameter(parameter: inspect.Parameter) -> Parameter: ) +def _is_context(call: typing.Any): + if isinstance(call, type): + return issubclass(call, AbstractContextManager) + else: + return isinstance(call, AbstractContextManager) + + +def _is_async_context(call: typing.Any): + if isinstance(call, type): + return issubclass(call, AbstractAsyncContextManager) + else: + return isinstance(call, AbstractAsyncContextManager) + + def scan(call: typing.Callable[..., R], caching: bool = True) -> CallableInfo[R]: """ Get callable information @@ -63,34 +79,40 @@ def scan(call: typing.Callable[..., R], caching: bool = True) -> CallableInfo[R] if hasattr(call, "__fundi_info__"): info = typing.cast(CallableInfo[typing.Any], getattr(call, "__fundi_info__")) - return replace(info, use_cache=caching) + return _copy_info(info, use_cache=caching) - signature = inspect.signature(call) + if not callable(call): + raise ValueError( + f"Callable expected, got {type(call)!r}" + ) # pyright: ignore[reportUnreachable] - generator = inspect.isgeneratorfunction(call) - async_generator = inspect.isasyncgenfunction(call) + truecall = call.__call__ + if isinstance(call, (FunctionType, BuiltinFunctionType, MethodType, type)): + truecall = call - context = hasattr(call, "__enter__") and hasattr(call, "__exit__") - async_context = hasattr(call, "__aenter__") and hasattr(call, "__aexit__") + signature = inspect.signature(truecall) - async_ = inspect.iscoroutinefunction(call) or async_generator or async_context + generator = inspect.isgeneratorfunction(truecall) + async_generator = inspect.isasyncgenfunction(truecall) + + context = _is_context(call) + async_context = _is_async_context(call) + + async_ = inspect.iscoroutinefunction(truecall) or async_generator or async_context generator = generator or async_generator context = context or async_context parameters = [_transform_parameter(parameter) for parameter in signature.parameters.values()] - info = typing.cast( - CallableInfo[R], - CallableInfo( - call=call, - use_cache=caching, - async_=async_, - context=context, - generator=generator, - parameters=parameters, - return_annotation=signature.return_annotation, - configuration=get_configuration(call) if is_configured(call) else None, - ), + info = CallableInfo( + call=call, + use_cache=caching, + async_=async_, + context=context, + generator=generator, + parameters=parameters, + return_annotation=signature.return_annotation, + configuration=get_configuration(call) if is_configured(call) else None, ) try: @@ -99,3 +121,14 @@ def scan(call: typing.Callable[..., R], caching: bool = True) -> CallableInfo[R] pass return info + + +def _copy_info(info: CallableInfo[R], **update: typing.Any) -> CallableInfo[R]: + return replace( + info, + **{ + "parameters": list(map(replace, info.parameters)), + "configuration": info.configuration and replace(info.configuration), + **update, + }, + ) diff --git a/fundi/virtual_context.py b/fundi/virtual_context.py index bc8bae9..a167c7a 100644 --- a/fundi/virtual_context.py +++ b/fundi/virtual_context.py @@ -16,35 +16,28 @@ from .types import CallableInfo from .exceptions import GeneratorExitedTooEarly -__all__ = ["VirtualContextManager", "AsyncVirtualContextManager", "virtual_context"] +__all__ = ["VirtualContextProvider", "AsyncVirtualContextProvider", "virtual_context"] T = typing.TypeVar("T") P = typing.ParamSpec("P") F = typing.TypeVar("F", bound=types.FunctionType) -class VirtualContextManager(typing.Generic[T, P], AbstractContextManager[T]): +@typing.final +class _VirtualContextManager(typing.Generic[T], AbstractContextManager[T]): """ - Synchronous virtual context manager + Virtual context manager implementation """ - def __init__(self, function: typing.Callable[P, Generator[T, None, None]]): - info = replace(scan(function), generator=False, context=True, call=self) - self.__fundi_info__: CallableInfo[typing.Any] = info - - self.__wrapped__: typing.Callable[P, Generator[T, None, None]] = function - self.generator: Generator[T, None, None] | None = None - - def __call__(self, *args: P.args, **kwargs: P.kwargs): - self.generator = self.__wrapped__(*args, **kwargs) - return self + def __init__(self, generator: Generator[T, None, None], origin: types.FunctionType) -> None: + self.generator = generator + self.origin = origin def __enter__(self) -> T: # pyright: ignore[reportMissingSuperCall, reportImplicitOverride] - assert self.generator is not None, "Generator not initialized, call __call__ method first" try: return self.generator.send(None) except StopIteration as exc: - raise GeneratorExitedTooEarly(self.__wrapped__, self.generator) from exc + raise GeneratorExitedTooEarly(self.origin, self.generator) from exc def __exit__( # pyright: ignore[reportImplicitOverride] self, @@ -52,8 +45,6 @@ def __exit__( # pyright: ignore[reportImplicitOverride] exc_value: BaseException | None, traceback: types.TracebackType | None, ) -> bool: - assert self.generator is not None, "Generator not initialized, call __call__ method first" - try: if exc_type is not None: self.generator.throw(exc_type, exc_value, traceback) @@ -71,28 +62,21 @@ def __exit__( # pyright: ignore[reportImplicitOverride] return False -class AsyncVirtualContextManager(typing.Generic[T, P], AbstractAsyncContextManager[T]): +@typing.final +class _VirtualAsyncContextManager(typing.Generic[T], AbstractAsyncContextManager[T]): """ - Asynchronous virtual context manager + Virtual context manager implementation """ - def __init__(self, function: typing.Callable[P, AsyncGenerator[T]]): - info = replace(scan(function), generator=False, context=True, call=self) - self.__fundi_info__: CallableInfo[typing.Any] = info - - self.__wrapped__: typing.Callable[P, AsyncGenerator[T]] = function - self.generator: AsyncGenerator[T] | None = None - - def __call__(self, *args: P.args, **kwargs: P.kwargs): - self.generator = self.__wrapped__(*args, **kwargs) - return self + def __init__(self, generator: AsyncGenerator[T, None], origin: types.FunctionType) -> None: + self.generator = generator + self.origin = origin async def __aenter__(self) -> T: # pyright: ignore[reportImplicitOverride] - assert self.generator is not None, "Generator not initialized, call __call__ method first" try: return await self.generator.asend(None) except StopAsyncIteration as exc: - raise GeneratorExitedTooEarly(self.__wrapped__, self.generator) from exc + raise GeneratorExitedTooEarly(self.origin, self.generator) from exc async def __aexit__( # pyright: ignore[reportImplicitOverride] self, @@ -119,17 +103,47 @@ async def __aexit__( # pyright: ignore[reportImplicitOverride] return False +class VirtualContextProvider(typing.Generic[T, P]): + """ + Synchronous virtual context manager + """ + + def __init__(self, function: typing.Callable[P, Generator[T, None, None]]): + info = replace(scan(function), generator=False, context=True, call=self) + self.__fundi_info__: CallableInfo[typing.Any] = info + + self.__wrapped__: typing.Callable[P, Generator[T, None, None]] = function + + def __call__(self, *args: P.args, **kwargs: P.kwargs): + return _VirtualContextManager(self.__wrapped__(*args, **kwargs), self.__wrapped__) + + +class AsyncVirtualContextProvider(typing.Generic[T, P]): + """ + Asynchronous virtual context manager + """ + + def __init__(self, function: typing.Callable[P, AsyncGenerator[T]]): + info = replace(scan(function), generator=False, context=True, call=self) + self.__fundi_info__: CallableInfo[typing.Any] = info + + self.__wrapped__: typing.Callable[P, AsyncGenerator[T]] = function + + def __call__(self, *args: P.args, **kwargs: P.kwargs): + return _VirtualAsyncContextManager(self.__wrapped__(*args, **kwargs), self.__wrapped__) + + @typing.overload def virtual_context( function: typing.Callable[P, Generator[T, None, None]], -) -> VirtualContextManager[T, P]: ... +) -> VirtualContextProvider[T, P]: ... @typing.overload def virtual_context( function: typing.Callable[P, AsyncGenerator[T]], -) -> AsyncVirtualContextManager[T, P]: ... +) -> AsyncVirtualContextProvider[T, P]: ... def virtual_context( function: typing.Callable[P, Generator[T, None, None] | AsyncGenerator[T]], -) -> VirtualContextManager[T, P] | AsyncVirtualContextManager[T, P]: +) -> VirtualContextProvider[T, P] | AsyncVirtualContextProvider[T, P]: """ Define virtual context manager using decorator @@ -163,9 +177,9 @@ async def lock(name: str): await socket.send("wtf") """ if inspect.isasyncgenfunction(function): - return AsyncVirtualContextManager(function) + return AsyncVirtualContextProvider(function) elif inspect.isgeneratorfunction(function): - return VirtualContextManager(function) + return VirtualContextProvider(function) raise ValueError( f"@virtual_context expects a generator or async generator function, got {function!r}" diff --git a/pyproject.toml b/pyproject.toml index ca9fb2a..9bd4b2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,12 +19,17 @@ docs = [ "sphinx>=8.1.3", "sphinx-copybutton>=0.5.2", ] +fastapi = [ + "fastapi>=0.116.1", + "python-multipart>=0.0.20", +] dev = [ - "black>=25.1.0", - "pynvim>=0.5.2", - "pytest>=8.3.5", - "pytest-asyncio>=0.26.0", - { include-group = "docs" }, + "black>=25.1.0", + "pynvim>=0.5.2", + "pytest>=8.3.5", + "pytest-asyncio>=0.26.0", + { include-group = "docs" }, + { include-group = "fastapi"} ] [tool.pytest.ini_options] @@ -44,6 +49,7 @@ filterwarnings = [ reportAny = false reportUnusedCallResult = false reportCallInDefaultInitializer = false +reportImplicitStringConcatenation = false exclude = [".venv"] ignore = ["tests"] diff --git a/tests/compat/fastapi/dependant.py b/tests/compat/fastapi/dependant.py new file mode 100644 index 0000000..05bc27c --- /dev/null +++ b/tests/compat/fastapi/dependant.py @@ -0,0 +1,66 @@ +import pytest +from fundi import from_, scan +from functools import partial +from starlette.requests import Request +from starlette.responses import Response +from fastapi import Body, Cookie, Form, Header, Query +from fundi.compat.fastapi.dependant import get_scope_dependant + + +get_dependant = partial(get_scope_dependant, path_param_names=set(), path="/") + + +def test_query_param(): + def homepage(name: str = Query()): ... + + dependant = get_dependant(scan(homepage)) + + assert dependant.query_params[0].name == "name" + + +def test_header_param(): + def homepage(token: str = Header()): ... + + dependant = get_dependant(scan(homepage)) + + assert dependant.header_params[0].name == "token" + + +def test_path_param(): + def homepage(type: str): ... + + dependant = get_dependant(scan(homepage), path_param_names={"type"}, path="/{type}") + + assert dependant.path_params[0].name == "type" + + +def test_cookie_param(): + def homepage(token: str = Cookie()): ... + + dependant = get_dependant(scan(homepage)) + + assert dependant.cookie_params[0].name == "token" + + +def test_body_param(): + def homepage(name: str = Form()): ... + + dependant = get_dependant(scan(homepage)) + + assert dependant.body_params[0].name == "name" + + def homepage(username: str = Body()): ... + + dependant = get_dependant(scan(homepage)) + + assert dependant.body_params[0].name == "username" + + +def test_multi_param_same_name(): + def dependency(token: str = Header()): ... + def homepage(token: str = Query(), authtoken=from_(dependency)): ... + + dependant = get_dependant(scan(homepage)) + + assert dependant.query_params[0].name == "token" + assert dependant.header_params[0].name == "token" diff --git a/tests/compat/fastapi/inject.py b/tests/compat/fastapi/inject.py new file mode 100644 index 0000000..982a558 --- /dev/null +++ b/tests/compat/fastapi/inject.py @@ -0,0 +1,263 @@ +from contextlib import AsyncExitStack +from starlette.background import BackgroundTasks +from fastapi import Body, Cookie, Form, Header, Path, Query, Request, Response +from starlette.datastructures import FormData + +from fundi import scan + +from fundi.compat.fastapi.inject import inject +from fundi.compat.fastapi.alias import init_aliases +from fundi.compat.fastapi.metadata import build_metadata +from fundi.compat.fastapi.dependant import get_scope_dependant + + +async def test_request(): + response = "response" + + def homepage(req: Request): + assert isinstance(req, Request) + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request({"type": "http", "headers": (), "query_string": ""}) + + res = Response() + tasks = BackgroundTasks() + + result = await inject(info, stack, req, None, None, False, tasks, res) + + assert result == response + + +async def test_query(): + response = "response" + + def homepage(query: int = Query()): + assert isinstance(query, int) + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request({"type": "http", "query_string": "query=101", "headers": ()}) + + res = Response() + tasks = BackgroundTasks() + + result = await inject(info, stack, req, None, None, False, tasks, res) + + assert result == response + + +async def test_path(): + response = "response" + + def homepage(path: bool = Path()): + assert isinstance(path, bool) + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request( + {"type": "http", "headers": (), "path_params": {"path": "true"}, "query_string": ""} + ) + + res = Response() + tasks = BackgroundTasks() + + result = await inject(info, stack, req, None, None, False, tasks, res) + + assert result == response + + +async def test_cookie(): + response = "response" + + def homepage(cookie: str = Cookie()): + assert isinstance(cookie, str) + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request( + { + "type": "http", + "headers": ((b"cookie", b"cookie=some;"),), + "query_string": "", + } + ) + + res = Response() + tasks = BackgroundTasks() + + result = await inject(info, stack, req, None, None, False, tasks, res) + + assert result == response + + +async def test_header(): + response = "response" + + def homepage(header: float = Header()): + assert isinstance(header, float) + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request( + { + "type": "http", + "headers": ((b"header", b"0.3"),), + "query_string": "", + } + ) + + res = Response() + tasks = BackgroundTasks() + + result = await inject(info, stack, req, None, None, False, tasks, res) + + assert result == response + + +async def test_form(): + response = "response" + + def homepage(username: str = Form(), password: str = Form()): + assert isinstance(username, str) + assert isinstance(password, str) + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request( + { + "type": "http", + "headers": (), + "query_string": "", + } + ) + + res = Response() + tasks = BackgroundTasks() + + result = await inject( + info, + stack, + req, + FormData(username="Kuyugama", password="Kuyugama the best"), + None, + False, + tasks, + res, + ) + + assert result == response + + +async def test_json(): + response = "response" + + def homepage(username: str = Body(), password: str = Body()): + assert isinstance(username, str) + assert isinstance(password, str) + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request( + { + "type": "http", + "headers": (), + "query_string": "", + } + ) + + res = Response() + tasks = BackgroundTasks() + + result = await inject( + info, + stack, + req, + dict(username="Kuyugama", password="Kuyugama the best"), + None, + False, + tasks, + res, + ) + + +async def test_pydantic_model(): + from pydantic import BaseModel + + class Signin(BaseModel): + username: str + password: str + + response = "response" + + def homepage(body: Signin): + assert isinstance(body.username, str) + assert isinstance(body.password, str) + assert isinstance(body, Signin) + + yield response + + info = scan(homepage) + build_metadata(info) + init_aliases(info) + _flat_dependant = get_scope_dependant(info, {"path"}, "/{path}") + + async with AsyncExitStack() as stack: + req = Request( + { + "type": "http", + "headers": (), + "query_string": "", + } + ) + + res = Response() + tasks = BackgroundTasks() + + result = await inject( + info, + stack, + req, + dict(username="Kuyugama", password="Kuyugama the best"), + None, + False, + tasks, + res, + ) + + assert result == response diff --git a/tests/compat/fastapi/metadata.py b/tests/compat/fastapi/metadata.py new file mode 100644 index 0000000..ce92214 --- /dev/null +++ b/tests/compat/fastapi/metadata.py @@ -0,0 +1,51 @@ +from fastapi import Request +from starlette.websockets import WebSocket +from starlette.requests import HTTPConnection +from fastapi.security.oauth2 import SecurityScopes + +from fundi import scan +from fundi.compat.fastapi import constants, secured +from fundi.compat.fastapi.alias import init_aliases +from fundi.compat.fastapi.dependant import get_scope_dependant +from fundi.compat.fastapi.metadata import get_metadata, build_metadata + + +def test_security_scopes(): + def dependency(sec: SecurityScopes): ... + + def homepage(_=secured(dependency, ["scope"])): ... + + info = scan(homepage) + build_metadata(info) + + metadata = get_metadata(info.named_parameters["_"].from_) + + assert metadata[constants.METADATA_SECURITY_SCOPES].scopes == ["scope"] + assert "sec" in metadata[constants.METADATA_SCOPE_EXTRA] + + +def test_dependant(): + def homepage(): ... + + info = scan(homepage) + build_metadata(info) + get_scope_dependant(info, set(), "/") + + metadata = get_metadata(info) + + assert metadata[constants.METADATA_DEPENDANT] + + +def test_aliases(): + def homepage(req: Request, ws: WebSocket, conn: HTTPConnection): ... + + info = scan(homepage) + init_aliases(info) + + metadata = get_metadata(info) + + assert metadata[constants.METADATA_ALIASES] == { + Request: {"req"}, + WebSocket: {"ws"}, + HTTPConnection: {"conn"}, + } diff --git a/uv.lock b/uv.lock index e63686a..26288d6 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -180,6 +204,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, ] +[[package]] +name = "fastapi" +version = "0.116.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, +] + [[package]] name = "fundi" version = "1.2.5" @@ -188,11 +226,13 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "black" }, + { name = "fastapi" }, { name = "furo" }, { name = "myst-parser" }, { name = "pynvim" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "python-multipart" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-copybutton" }, @@ -204,17 +244,23 @@ docs = [ { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-copybutton" }, ] +fastapi = [ + { name = "fastapi" }, + { name = "python-multipart" }, +] [package.metadata] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=25.1.0" }, + { name = "fastapi", specifier = ">=0.116.1" }, { name = "furo", specifier = ">=2024.8.6" }, { name = "myst-parser", specifier = ">=4.0.1" }, { name = "pynvim", specifier = ">=0.5.2" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "sphinx", specifier = ">=8.1.3" }, { name = "sphinx-copybutton", specifier = ">=0.5.2" }, ] @@ -224,6 +270,10 @@ docs = [ { name = "sphinx", specifier = ">=8.1.3" }, { name = "sphinx-copybutton", specifier = ">=0.5.2" }, ] +fastapi = [ + { name = "fastapi", specifier = ">=0.116.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, +] [[package]] name = "furo" @@ -538,6 +588,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -590,6 +742,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -658,6 +819,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -818,6 +988,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "starlette" +version = "0.47.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -866,6 +1049,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683, upload-time = "2025-03-26T03:49:40.35Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + [[package]] name = "urllib3" version = "2.3.0"