From c964f45cec648ea3da36412d05201a42a6cdeece Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sat, 2 Aug 2025 17:48:37 +0300 Subject: [PATCH 01/17] Add FunDI > FastAPI compatibility layer --- fundi/compat/fastapi.py | 409 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 + uv.lock | 178 +++++++++++++++++ 3 files changed, 591 insertions(+) create mode 100644 fundi/compat/fastapi.py diff --git a/fundi/compat/fastapi.py b/fundi/compat/fastapi.py new file mode 100644 index 0000000..bd9b227 --- /dev/null +++ b/fundi/compat/fastapi.py @@ -0,0 +1,409 @@ +import json +import typing +import inspect +from enum import Enum, IntEnum +from collections import defaultdict +from contextlib import AsyncExitStack +from collections.abc import Coroutine, Sequence + +from fastapi.types import IncEx +from pydantic.v1.fields import Undefined +from fastapi.responses import JSONResponse +from starlette.requests import HTTPConnection +from starlette.background import BackgroundTasks +from pydantic.v1.utils import lenient_issubclass +from fastapi.dependencies.models import Dependant +from fastapi.security.oauth2 import SecurityScopes +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute, serialize_response +from fastapi._compat import ModelField, _normalize_errors # pyright: ignore[reportPrivateUsage] +from fastapi.datastructures import Default, DefaultPlaceholder +from fastapi import HTTPException, Request, Response, WebSocket, params +from starlette.routing import BaseRoute, compile_path, get_name, request_response + +from fundi import scan, ainject, CallableInfo + +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 ( + analyze_param, + get_body_field, + solve_dependencies, + add_param_to_fields, + _should_embed_body_fields, # pyright: ignore[reportPrivateUsage] + add_non_field_param_to_dependency, +) + + +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 + + +def get_request_handler( + ci: CallableInfo[typing.Any], + scope_dependant: Dependant, + scope_aliases: dict[type, set[str]], + 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: + async with AsyncExitStack() as stack: + body = await validate_body(request, stack, body_field) + + scope = await solve_dependencies( + request=request, + dependant=scope_dependant, + body=body, + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=stack, + embed_body_fields=embed_body_fields, + ) + + if scope.errors: + raise RequestValidationError(_normalize_errors(scope.errors), body=body) + + values = {**scope.values} + + for type_, names in scope_aliases.items(): + + if type_ is HTTPConnection: + value = request + elif type_ is Request: + value = request + elif type_ is WebSocket: + assert isinstance(request, WebSocket), "Not a websocket" + value = request + elif type_ is BackgroundTasks: + value = scope.background_tasks or BackgroundTasks() + elif type_ is Response: + value = scope.response + else: + value = scope_dependant.security_scopes + + values.update({name: value for name in names}) + + raw_response = await ainject(scope.values, ci, stack) + + if isinstance(raw_response, Response): + if raw_response.background is None: + raw_response.background = scope.background_tasks + + response = raw_response + else: + response_args: dict[str, typing.Any] = {"background": scope.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 = scope.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(scope.response.headers.raw) + + return response + + return app + + +def get_scope_dependant( + ci: CallableInfo[typing.Any], path_param_names: set[str], path: str +) -> Dependant: + dependant = Dependant(path=path) + + for param in ci.parameters: + if param.from_ is not None: + sub = get_scope_dependant(param.from_, path_param_names, path) + dependant.path_params.extend(sub.path_params) + dependant.query_params.extend(sub.query_params) + dependant.header_params.extend(sub.header_params) + dependant.cookie_params.extend(sub.cookie_params) + dependant.body_params.extend(sub.body_params) + 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=details.type_annotation, + dependant=dependant, + ): + assert ( + details.field is None + ), f"Cannot specify multiple FastAPI annotations for {param.name!r}" + 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) + + return dependant + + +def get_request_related_aliases(ci: CallableInfo[typing.Any]) -> dict[type, set[str]]: + aliases: defaultdict[type, set[str]] = defaultdict(set) + allowed_classes = ( + Request, + WebSocket, + HTTPConnection, + Response, + BackgroundTasks, + SecurityScopes, + ) + for parameter in ci.parameters: + if parameter.from_ is not None: + subaliases = get_request_related_aliases(parameter.from_) + for type_, aliases_ in subaliases.items(): + aliases[type_].update(aliases_) + continue + + origin = typing.get_origin(parameter.annotation) or parameter.annotation + + for type_ in allowed_classes: + if lenient_issubclass(origin, type_): + aliases[type_].add(parameter.name) + return aliases + + +@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[params.Depends] | 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 + + 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.dependencies = list(dependencies or []) + 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 + + self.dependant = get_scope_dependant( + callable_info, get_path_param_names(self.path_format), self.path_format + ) + 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, + self.dependant, + scope_aliases=get_request_related_aliases(callable_info), + 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/pyproject.toml b/pyproject.toml index ca9fb2a..80ba001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ dev = [ "pytest-asyncio>=0.26.0", { include-group = "docs" }, ] +fastapi = [ + "fastapi>=0.116.1", +] [tool.pytest.ini_options] addopts = [ @@ -44,6 +47,7 @@ filterwarnings = [ reportAny = false reportUnusedCallResult = false reportCallInDefaultInitializer = false +reportImplicitStringConcatenation = false exclude = [".venv"] ignore = ["tests"] diff --git a/uv.lock b/uv.lock index e63686a..aa2b9d8 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" @@ -204,6 +242,9 @@ 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" }, +] [package.metadata] @@ -224,6 +265,7 @@ docs = [ { name = "sphinx", specifier = ">=8.1.3" }, { name = "sphinx-copybutton", specifier = ">=0.5.2" }, ] +fastapi = [{ name = "fastapi", specifier = ">=0.116.1" }] [[package]] name = "furo" @@ -538,6 +580,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" @@ -658,6 +802,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 +971,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 +1032,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" From 4af07ec3e1d0cbde531ab65107359b96101b4289 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sat, 2 Aug 2025 21:43:15 +0300 Subject: [PATCH 02/17] Add extra dependencies support --- fundi/compat/fastapi.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/fundi/compat/fastapi.py b/fundi/compat/fastapi.py index bd9b227..3f95202 100644 --- a/fundi/compat/fastapi.py +++ b/fundi/compat/fastapi.py @@ -95,6 +95,7 @@ async def validate_body(request: Request, stack: AsyncExitStack, body_field: Mod def get_request_handler( ci: CallableInfo[typing.Any], scope_dependant: Dependant, + extra_dependencies: list[CallableInfo[typing.Any]], scope_aliases: dict[type, set[str]], body_field: ModelField | None = None, status_code: int | None = None, @@ -151,7 +152,10 @@ async def app(request: Request) -> Response: values.update({name: value for name in names}) - raw_response = await ainject(scope.values, ci, stack) + for dependency in extra_dependencies: + await ainject(values, dependency, stack) + + raw_response = await ainject(values, ci, stack) if isinstance(raw_response, Response): if raw_response.background is None: @@ -265,7 +269,7 @@ def __init__( # pyright: ignore[reportMissingSuperCall] response_model: typing.Any = Default(None), status_code: int | None = None, tags: list[str | Enum] | None = None, - dependencies: Sequence[params.Depends] | None = None, + dependencies: Sequence[typing.Callable[..., typing.Any]] | None = None, summary: str | None = None, description: str | None = None, response_description: str = "Successful Response", @@ -355,7 +359,6 @@ def __init__( # pyright: ignore[reportMissingSuperCall] self.response_field = None # type: ignore self.secure_cloned_response_field = None - self.dependencies = list(dependencies or []) self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") # if a "form feed" character (page break) is found in the description text, @@ -392,6 +395,7 @@ def __init__( # pyright: ignore[reportMissingSuperCall] get_request_handler( callable_info, self.dependant, + extra_dependencies=[scan(call) for call in (dependencies or [])[::-1]], scope_aliases=get_request_related_aliases(callable_info), body_field=self.body_field, status_code=self.status_code, From ba9cb3e028d1765a569e7d5784f284b94cb891d5 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sat, 2 Aug 2025 21:44:28 +0300 Subject: [PATCH 03/17] Remove unused introspection --- fundi/compat/fastapi.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/fundi/compat/fastapi.py b/fundi/compat/fastapi.py index 3f95202..b927340 100644 --- a/fundi/compat/fastapi.py +++ b/fundi/compat/fastapi.py @@ -35,7 +35,6 @@ solve_dependencies, add_param_to_fields, _should_embed_body_fields, # pyright: ignore[reportPrivateUsage] - add_non_field_param_to_dependency, ) @@ -215,15 +214,7 @@ def get_scope_dependant( 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=details.type_annotation, - dependant=dependant, - ): - assert ( - details.field is None - ), f"Cannot specify multiple FastAPI annotations for {param.name!r}" - continue + assert details.field is not None if isinstance(details.field.field_info, params.Body): dependant.body_params.append(details.field) From 956e845d75f520591f2b0bf0fb9b9cb6e7f13e7e Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sat, 2 Aug 2025 21:48:46 +0300 Subject: [PATCH 04/17] Skip on removed logic --- fundi/compat/fastapi.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/fundi/compat/fastapi.py b/fundi/compat/fastapi.py index b927340..c1f432a 100644 --- a/fundi/compat/fastapi.py +++ b/fundi/compat/fastapi.py @@ -38,6 +38,16 @@ ) +ALIAS_ALLOWED_CLASSES = ( + Request, + WebSocket, + HTTPConnection, + Response, + BackgroundTasks, + SecurityScopes, +) + + 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: @@ -215,6 +225,9 @@ def get_scope_dependant( is_path_param=param.name in path_param_names, ) + if details.type_annotation in ALIAS_ALLOWED_CLASSES: + continue + assert details.field is not None if isinstance(details.field.field_info, params.Body): dependant.body_params.append(details.field) @@ -226,14 +239,6 @@ def get_scope_dependant( def get_request_related_aliases(ci: CallableInfo[typing.Any]) -> dict[type, set[str]]: aliases: defaultdict[type, set[str]] = defaultdict(set) - allowed_classes = ( - Request, - WebSocket, - HTTPConnection, - Response, - BackgroundTasks, - SecurityScopes, - ) for parameter in ci.parameters: if parameter.from_ is not None: subaliases = get_request_related_aliases(parameter.from_) @@ -243,7 +248,7 @@ def get_request_related_aliases(ci: CallableInfo[typing.Any]) -> dict[type, set[ origin = typing.get_origin(parameter.annotation) or parameter.annotation - for type_ in allowed_classes: + for type_ in ALIAS_ALLOWED_CLASSES: if lenient_issubclass(origin, type_): aliases[type_].add(parameter.name) return aliases From e2dff3ab3d194cf541089564643512d5ec08c3bc Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 3 Aug 2025 23:59:50 +0300 Subject: [PATCH 05/17] divide fundi.compat.fastapi into several submodules to ensure maintainability --- fundi/compat/fastapi.py | 409 ------------------------------ fundi/compat/fastapi/__init__.py | 11 + fundi/compat/fastapi/alias.py | 24 ++ fundi/compat/fastapi/constants.py | 16 ++ fundi/compat/fastapi/dependant.py | 42 +++ fundi/compat/fastapi/handler.py | 178 +++++++++++++ fundi/compat/fastapi/route.py | 185 ++++++++++++++ 7 files changed, 456 insertions(+), 409 deletions(-) delete mode 100644 fundi/compat/fastapi.py create mode 100644 fundi/compat/fastapi/__init__.py create mode 100644 fundi/compat/fastapi/alias.py create mode 100644 fundi/compat/fastapi/constants.py create mode 100644 fundi/compat/fastapi/dependant.py create mode 100644 fundi/compat/fastapi/handler.py create mode 100644 fundi/compat/fastapi/route.py diff --git a/fundi/compat/fastapi.py b/fundi/compat/fastapi.py deleted file mode 100644 index c1f432a..0000000 --- a/fundi/compat/fastapi.py +++ /dev/null @@ -1,409 +0,0 @@ -import json -import typing -import inspect -from enum import Enum, IntEnum -from collections import defaultdict -from contextlib import AsyncExitStack -from collections.abc import Coroutine, Sequence - -from fastapi.types import IncEx -from pydantic.v1.fields import Undefined -from fastapi.responses import JSONResponse -from starlette.requests import HTTPConnection -from starlette.background import BackgroundTasks -from pydantic.v1.utils import lenient_issubclass -from fastapi.dependencies.models import Dependant -from fastapi.security.oauth2 import SecurityScopes -from fastapi.exceptions import RequestValidationError -from fastapi.routing import APIRoute, serialize_response -from fastapi._compat import ModelField, _normalize_errors # pyright: ignore[reportPrivateUsage] -from fastapi.datastructures import Default, DefaultPlaceholder -from fastapi import HTTPException, Request, Response, WebSocket, params -from starlette.routing import BaseRoute, compile_path, get_name, request_response - -from fundi import scan, ainject, CallableInfo - -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 ( - analyze_param, - get_body_field, - solve_dependencies, - add_param_to_fields, - _should_embed_body_fields, # pyright: ignore[reportPrivateUsage] -) - - -ALIAS_ALLOWED_CLASSES = ( - Request, - WebSocket, - HTTPConnection, - Response, - BackgroundTasks, - SecurityScopes, -) - - -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 - - -def get_request_handler( - ci: CallableInfo[typing.Any], - scope_dependant: Dependant, - extra_dependencies: list[CallableInfo[typing.Any]], - scope_aliases: dict[type, set[str]], - 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: - async with AsyncExitStack() as stack: - body = await validate_body(request, stack, body_field) - - scope = await solve_dependencies( - request=request, - dependant=scope_dependant, - body=body, - dependency_overrides_provider=dependency_overrides_provider, - async_exit_stack=stack, - embed_body_fields=embed_body_fields, - ) - - if scope.errors: - raise RequestValidationError(_normalize_errors(scope.errors), body=body) - - values = {**scope.values} - - for type_, names in scope_aliases.items(): - - if type_ is HTTPConnection: - value = request - elif type_ is Request: - value = request - elif type_ is WebSocket: - assert isinstance(request, WebSocket), "Not a websocket" - value = request - elif type_ is BackgroundTasks: - value = scope.background_tasks or BackgroundTasks() - elif type_ is Response: - value = scope.response - else: - value = scope_dependant.security_scopes - - values.update({name: value for name in names}) - - for dependency in extra_dependencies: - await ainject(values, dependency, stack) - - raw_response = await ainject(values, ci, stack) - - if isinstance(raw_response, Response): - if raw_response.background is None: - raw_response.background = scope.background_tasks - - response = raw_response - else: - response_args: dict[str, typing.Any] = {"background": scope.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 = scope.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(scope.response.headers.raw) - - return response - - return app - - -def get_scope_dependant( - ci: CallableInfo[typing.Any], path_param_names: set[str], path: str -) -> Dependant: - dependant = Dependant(path=path) - - for param in ci.parameters: - if param.from_ is not None: - sub = get_scope_dependant(param.from_, path_param_names, path) - dependant.path_params.extend(sub.path_params) - dependant.query_params.extend(sub.query_params) - dependant.header_params.extend(sub.header_params) - dependant.cookie_params.extend(sub.cookie_params) - dependant.body_params.extend(sub.body_params) - continue - - details = analyze_param( - param_name=param.name, - annotation=param.annotation, - value=param.default, - is_path_param=param.name in path_param_names, - ) - - if details.type_annotation in ALIAS_ALLOWED_CLASSES: - 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) - - return dependant - - -def get_request_related_aliases(ci: CallableInfo[typing.Any]) -> dict[type, set[str]]: - aliases: defaultdict[type, set[str]] = defaultdict(set) - for parameter in ci.parameters: - if parameter.from_ is not None: - subaliases = get_request_related_aliases(parameter.from_) - for type_, aliases_ in subaliases.items(): - aliases[type_].update(aliases_) - continue - - origin = typing.get_origin(parameter.annotation) or parameter.annotation - - for type_ in ALIAS_ALLOWED_CLASSES: - if lenient_issubclass(origin, type_): - aliases[type_].add(parameter.name) - return aliases - - -@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]] | 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 - - 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 - - self.dependant = get_scope_dependant( - callable_info, get_path_param_names(self.path_format), self.path_format - ) - 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, - self.dependant, - extra_dependencies=[scan(call) for call in (dependencies or [])[::-1]], - scope_aliases=get_request_related_aliases(callable_info), - 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/__init__.py b/fundi/compat/fastapi/__init__.py new file mode 100644 index 0000000..ef09ef3 --- /dev/null +++ b/fundi/compat/fastapi/__init__.py @@ -0,0 +1,11 @@ +from .route import FunDIRoute +from .handler import get_request_handler +from .dependant import get_scope_dependant +from .alias import get_request_related_aliases + +__all__ = [ + "FunDIRoute", + "get_request_handler", + "get_scope_dependant", + "get_request_related_aliases", +] diff --git a/fundi/compat/fastapi/alias.py b/fundi/compat/fastapi/alias.py new file mode 100644 index 0000000..4d1ce93 --- /dev/null +++ b/fundi/compat/fastapi/alias.py @@ -0,0 +1,24 @@ +import typing +from collections import defaultdict + +from pydantic.v1.utils import lenient_issubclass + +from fundi.types import CallableInfo +from .constants import ALIAS_ALLOWED_CLASSES + + +def get_request_related_aliases(ci: CallableInfo[typing.Any]) -> dict[type, set[str]]: + aliases: defaultdict[type, set[str]] = defaultdict(set) + for parameter in ci.parameters: + if parameter.from_ is not None: + subaliases = get_request_related_aliases(parameter.from_) + for type_, aliases_ in subaliases.items(): + aliases[type_].update(aliases_) + continue + + origin = typing.get_origin(parameter.annotation) or parameter.annotation + + for type_ in ALIAS_ALLOWED_CLASSES: + if lenient_issubclass(origin, type_): + aliases[type_].add(parameter.name) + return aliases diff --git a/fundi/compat/fastapi/constants.py b/fundi/compat/fastapi/constants.py new file mode 100644 index 0000000..9a6b5e9 --- /dev/null +++ b/fundi/compat/fastapi/constants.py @@ -0,0 +1,16 @@ +from starlette.responses import Response +from starlette.websockets import WebSocket +from starlette.background import BackgroundTasks +from fastapi.security.oauth2 import SecurityScopes +from starlette.requests import HTTPConnection, Request + +__all__ = ["ALIAS_ALLOWED_CLASSES"] + +ALIAS_ALLOWED_CLASSES = ( + Request, + WebSocket, + HTTPConnection, + Response, + BackgroundTasks, + SecurityScopes, +) diff --git a/fundi/compat/fastapi/dependant.py b/fundi/compat/fastapi/dependant.py new file mode 100644 index 0000000..9921757 --- /dev/null +++ b/fundi/compat/fastapi/dependant.py @@ -0,0 +1,42 @@ +import typing + +from fastapi import params +from fastapi.dependencies.models import Dependant +from fastapi.dependencies.utils import add_param_to_fields, analyze_param + +from fundi.types import CallableInfo +from .constants import ALIAS_ALLOWED_CLASSES + + +def get_scope_dependant( + ci: CallableInfo[typing.Any], path_param_names: set[str], path: str +) -> Dependant: + dependant = Dependant(path=path) + + for param in ci.parameters: + if param.from_ is not None: + sub = get_scope_dependant(param.from_, path_param_names, path) + dependant.path_params.extend(sub.path_params) + dependant.query_params.extend(sub.query_params) + dependant.header_params.extend(sub.header_params) + dependant.cookie_params.extend(sub.cookie_params) + dependant.body_params.extend(sub.body_params) + continue + + details = analyze_param( + param_name=param.name, + annotation=param.annotation, + value=param.default, + is_path_param=param.name in path_param_names, + ) + + if details.type_annotation in ALIAS_ALLOWED_CLASSES: + 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) + + return dependant diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py new file mode 100644 index 0000000..398341d --- /dev/null +++ b/fundi/compat/fastapi/handler.py @@ -0,0 +1,178 @@ +import json +import typing +from collections.abc import Coroutine +from contextlib import AsyncExitStack + +from fastapi import params +from fastapi.types import IncEx +from pydantic.v1.fields import Undefined +from starlette.websockets import WebSocket +from fastapi.routing import serialize_response +from starlette.exceptions import HTTPException +from starlette.background import BackgroundTasks +from fastapi.dependencies.models import Dependant +from fastapi.exceptions import RequestValidationError +from starlette.requests import HTTPConnection, Request +from starlette.responses import JSONResponse, Response +from fastapi.dependencies.utils import solve_dependencies +from fastapi.utils import is_body_allowed_for_status_code +from fastapi._compat import ModelField, _normalize_errors # pyright: ignore[reportPrivateUsage] +from fastapi.datastructures import Default, DefaultPlaceholder + +from fundi.inject import ainject +from fundi.types import CallableInfo + + +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 + + +def get_request_handler( + ci: CallableInfo[typing.Any], + scope_dependant: Dependant, + extra_dependencies: list[CallableInfo[typing.Any]], + scope_aliases: dict[type, set[str]], + 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: + async with AsyncExitStack() as stack: + body = await validate_body(request, stack, body_field) + + scope = await solve_dependencies( + request=request, + dependant=scope_dependant, + body=body, + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=stack, + embed_body_fields=embed_body_fields, + ) + + if scope.errors: + raise RequestValidationError(_normalize_errors(scope.errors), body=body) + + values = {**scope.values} + + for type_, names in scope_aliases.items(): + + if type_ is HTTPConnection: + value = request + elif type_ is Request: + value = request + elif type_ is WebSocket: + assert isinstance(request, WebSocket), "Not a websocket" + value = request + elif type_ is BackgroundTasks: + value = scope.background_tasks or BackgroundTasks() + elif type_ is Response: + value = scope.response + else: + value = scope_dependant.security_scopes + + values.update({name: value for name in names}) + + for dependency in extra_dependencies: + await ainject(values, dependency, stack) + + raw_response = await ainject(values, ci, stack) + + if isinstance(raw_response, Response): + if raw_response.background is None: + raw_response.background = scope.background_tasks + + response = raw_response + else: + response_args: dict[str, typing.Any] = {"background": scope.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 = scope.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(scope.response.headers.raw) + + return response + + return app diff --git a/fundi/compat/fastapi/route.py b/fundi/compat/fastapi/route.py new file mode 100644 index 0000000..0868aa2 --- /dev/null +++ b/fundi/compat/fastapi/route.py @@ -0,0 +1,185 @@ +import typing +import inspect +from enum import Enum, IntEnum +from collections.abc import Sequence + +from fastapi import Response +from fastapi.types import IncEx +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 .handler import get_request_handler +from .dependant import get_scope_dependant +from .alias import get_request_related_aliases + + +@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]] | 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 = dependencies + + 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 + + self.dependant = get_scope_dependant( + callable_info, get_path_param_names(self.path_format), self.path_format + ) + 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, + self.dependant, + extra_dependencies=[scan(call) for call in (dependencies or [])[::-1]], + scope_aliases=get_request_related_aliases(callable_info), + 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, + ) + ) From cfe0a982e96a6a1662288d6e94cd961e178809c0 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Mon, 4 Aug 2025 23:46:10 +0300 Subject: [PATCH 06/17] Make AsyncExitStack closing after response is sent --- fundi/compat/fastapi/handler.py | 136 ++++++++++++++++---------------- 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py index 398341d..8d0e442 100644 --- a/fundi/compat/fastapi/handler.py +++ b/fundi/compat/fastapi/handler.py @@ -101,77 +101,81 @@ def get_request_handler( actual_response_class = response_class async def app(request: Request) -> Response: - async with AsyncExitStack() as stack: - body = await validate_body(request, stack, body_field) - - scope = await solve_dependencies( - request=request, - dependant=scope_dependant, - body=body, - dependency_overrides_provider=dependency_overrides_provider, - async_exit_stack=stack, - embed_body_fields=embed_body_fields, - ) - - if scope.errors: - raise RequestValidationError(_normalize_errors(scope.errors), body=body) - - values = {**scope.values} - - for type_, names in scope_aliases.items(): - - if type_ is HTTPConnection: - value = request - elif type_ is Request: - value = request - elif type_ is WebSocket: - assert isinstance(request, WebSocket), "Not a websocket" - value = request - elif type_ is BackgroundTasks: - value = scope.background_tasks or BackgroundTasks() - elif type_ is Response: - value = scope.response - else: - value = scope_dependant.security_scopes - - values.update({name: value for name in names}) + background_tasks = BackgroundTasks() + stack = AsyncExitStack() + background_tasks.add_task(stack.aclose) + + body = await validate_body(request, stack, body_field) + + scope = await solve_dependencies( + request=request, + dependant=scope_dependant, + body=body, + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=stack, + embed_body_fields=embed_body_fields, + background_tasks=background_tasks, + ) - for dependency in extra_dependencies: - await ainject(values, dependency, stack) + if scope.errors: + raise RequestValidationError(_normalize_errors(scope.errors), body=body) - raw_response = await ainject(values, ci, stack) + values = {**scope.values} - if isinstance(raw_response, Response): - if raw_response.background is None: - raw_response.background = scope.background_tasks + for type_, names in scope_aliases.items(): - response = raw_response + if type_ is HTTPConnection: + value = request + elif type_ is Request: + value = request + elif type_ is WebSocket: + assert isinstance(request, WebSocket), "Not a websocket" + value = request + elif type_ is BackgroundTasks: + value = background_tasks + elif type_ is Response: + value = scope.response else: - response_args: dict[str, typing.Any] = {"background": scope.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 = scope.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(scope.response.headers.raw) + value = scope_dependant.security_scopes + + values.update({name: value for name in names}) + + for dependency in extra_dependencies: + await ainject(values, dependency, stack) + + raw_response = await ainject(values, ci, stack) + + 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 = scope.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(scope.response.headers.raw) return response From f496d40f62e598c8c57cbe4feb5c3519a3483ea7 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Tue, 5 Aug 2025 00:03:05 +0300 Subject: [PATCH 07/17] Separate body and response exit stacks to ensure body will be closed even if exception happens while sending response --- fundi/compat/fastapi/alias.py | 36 +++++++++++++++ fundi/compat/fastapi/handler.py | 78 ++++++++++++++++----------------- 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/fundi/compat/fastapi/alias.py b/fundi/compat/fastapi/alias.py index 4d1ce93..f16f1ea 100644 --- a/fundi/compat/fastapi/alias.py +++ b/fundi/compat/fastapi/alias.py @@ -1,7 +1,13 @@ import typing from collections import defaultdict + +from starlette.responses import Response +from starlette.websockets import WebSocket +from starlette.background import BackgroundTasks from pydantic.v1.utils import lenient_issubclass +from fastapi.security.oauth2 import SecurityScopes +from starlette.requests import HTTPConnection, Request from fundi.types import CallableInfo from .constants import ALIAS_ALLOWED_CLASSES @@ -22,3 +28,33 @@ def get_request_related_aliases(ci: CallableInfo[typing.Any]) -> dict[type, set[ if lenient_issubclass(origin, type_): aliases[type_].add(parameter.name) return aliases + + +def resolve_aliases( + scope_aliases: dict[type, set[str]], + request: Request, + background_tasks: BackgroundTasks, + response: Response, + security_scopes: SecurityScopes, +) -> dict[str, typing.Any]: + values: dict[str, typing.Any] = {} + + for type_, names in scope_aliases.items(): + + if type_ is HTTPConnection: + value = request + elif type_ is Request: + value = request + elif type_ is WebSocket: + assert isinstance(request, WebSocket), "Not a websocket" + value = request + elif type_ is BackgroundTasks: + value = background_tasks + elif type_ is Response: + value = response + else: + value = security_scopes + + values.update({name: value for name in names}) + + return values diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py index 8d0e442..3245a13 100644 --- a/fundi/compat/fastapi/handler.py +++ b/fundi/compat/fastapi/handler.py @@ -4,15 +4,15 @@ from contextlib import AsyncExitStack from fastapi import params +from fastapi.security.oauth2 import SecurityScopes from fastapi.types import IncEx +from starlette.requests import Request from pydantic.v1.fields import Undefined -from starlette.websockets import WebSocket from fastapi.routing import serialize_response from starlette.exceptions import HTTPException from starlette.background import BackgroundTasks from fastapi.dependencies.models import Dependant from fastapi.exceptions import RequestValidationError -from starlette.requests import HTTPConnection, Request from starlette.responses import JSONResponse, Response from fastapi.dependencies.utils import solve_dependencies from fastapi.utils import is_body_allowed_for_status_code @@ -22,6 +22,8 @@ from fundi.inject import ainject from fundi.types import CallableInfo +from .alias import resolve_aliases + 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) @@ -103,47 +105,41 @@ def get_request_handler( 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) - body = await validate_body(request, stack, body_field) - - scope = await solve_dependencies( - request=request, - dependant=scope_dependant, - body=body, - dependency_overrides_provider=dependency_overrides_provider, - async_exit_stack=stack, - embed_body_fields=embed_body_fields, - background_tasks=background_tasks, - ) - - if scope.errors: - raise RequestValidationError(_normalize_errors(scope.errors), body=body) - - values = {**scope.values} - - for type_, names in scope_aliases.items(): - - if type_ is HTTPConnection: - value = request - elif type_ is Request: - value = request - elif type_ is WebSocket: - assert isinstance(request, WebSocket), "Not a websocket" - value = request - elif type_ is BackgroundTasks: - value = background_tasks - elif type_ is Response: - value = scope.response - else: - value = scope_dependant.security_scopes - - values.update({name: value for name in names}) - - for dependency in extra_dependencies: - await ainject(values, dependency, stack) - - raw_response = await ainject(values, ci, stack) + body_stack = AsyncExitStack() + async with body_stack: + body = await validate_body(request, body_stack, body_field) + + scope = await solve_dependencies( + request=request, + dependant=scope_dependant, + body=body, + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=stack, + embed_body_fields=embed_body_fields, + background_tasks=background_tasks, + ) + + if scope.errors: + raise RequestValidationError(_normalize_errors(scope.errors), body=body) + + values = { + **scope.values, + **resolve_aliases( + scope_aliases, + request, + background_tasks, + scope.response, + SecurityScopes(scope_dependant.security_scopes), + ), + } + + for dependency in extra_dependencies: + await ainject(values, dependency, stack) + + raw_response = await ainject(values, ci, stack) if isinstance(raw_response, Response): if raw_response.background is None: From a97fafc94e0fb36e8b487a58ab8e322a21fed5d3 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Tue, 5 Aug 2025 00:33:31 +0300 Subject: [PATCH 08/17] Add router --- fundi/compat/fastapi/__init__.py | 2 ++ fundi/compat/fastapi/handler.py | 2 +- fundi/compat/fastapi/route.py | 26 ++++++++++++--- fundi/compat/fastapi/router.py | 56 ++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 fundi/compat/fastapi/router.py diff --git a/fundi/compat/fastapi/__init__.py b/fundi/compat/fastapi/__init__.py index ef09ef3..f825d14 100644 --- a/fundi/compat/fastapi/__init__.py +++ b/fundi/compat/fastapi/__init__.py @@ -1,10 +1,12 @@ from .route import FunDIRoute +from .router import FunDIRouter from .handler import get_request_handler from .dependant import get_scope_dependant from .alias import get_request_related_aliases __all__ = [ "FunDIRoute", + "FunDIRouter", "get_request_handler", "get_scope_dependant", "get_request_related_aliases", diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py index 3245a13..56d3cbd 100644 --- a/fundi/compat/fastapi/handler.py +++ b/fundi/compat/fastapi/handler.py @@ -4,7 +4,6 @@ from contextlib import AsyncExitStack from fastapi import params -from fastapi.security.oauth2 import SecurityScopes from fastapi.types import IncEx from starlette.requests import Request from pydantic.v1.fields import Undefined @@ -12,6 +11,7 @@ from starlette.exceptions import HTTPException from starlette.background import BackgroundTasks from fastapi.dependencies.models import Dependant +from fastapi.security.oauth2 import SecurityScopes from fastapi.exceptions import RequestValidationError from starlette.responses import JSONResponse, Response from fastapi.dependencies.utils import solve_dependencies diff --git a/fundi/compat/fastapi/route.py b/fundi/compat/fastapi/route.py index 0868aa2..530e86e 100644 --- a/fundi/compat/fastapi/route.py +++ b/fundi/compat/fastapi/route.py @@ -3,8 +3,8 @@ from enum import Enum, IntEnum from collections.abc import Sequence -from fastapi import Response 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 @@ -24,6 +24,7 @@ ) from fundi import scan +from fundi.types import CallableInfo from .handler import get_request_handler from .dependant import get_scope_dependant from .alias import get_request_related_aliases @@ -40,7 +41,10 @@ def __init__( # pyright: ignore[reportMissingSuperCall] response_model: typing.Any = Default(None), status_code: int | None = None, tags: list[str | Enum] | None = None, - dependencies: Sequence[typing.Callable[..., typing.Any]] | 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", @@ -68,7 +72,21 @@ def __init__( # pyright: ignore[reportMissingSuperCall] self.ci = callable_info self.path = path self.endpoint = endpoint - self.dependencies = dependencies + self.dependencies: list[CallableInfo[typing.Any]] = [] + + for dependency in dependencies or []: + if isinstance(dependency, params.Depends): + if dependency.dependency is None: + continue + + self.dependencies.append(scan(dependency.dependency)) + 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): @@ -167,7 +185,7 @@ def __init__( # pyright: ignore[reportMissingSuperCall] get_request_handler( callable_info, self.dependant, - extra_dependencies=[scan(call) for call in (dependencies or [])[::-1]], + extra_dependencies=self.dependencies[::-1], scope_aliases=get_request_related_aliases(callable_info), body_field=self.body_field, status_code=self.status_code, 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, + ) From 2bb21db496012687bea926eab2637df360ad2ac0 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Tue, 5 Aug 2025 00:54:37 +0300 Subject: [PATCH 09/17] Include fastapi dependency group in dev group --- pyproject.toml | 15 ++++++++------- uv.lock | 2 ++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80ba001..9aeedce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,16 +19,17 @@ docs = [ "sphinx>=8.1.3", "sphinx-copybutton>=0.5.2", ] -dev = [ - "black>=25.1.0", - "pynvim>=0.5.2", - "pytest>=8.3.5", - "pytest-asyncio>=0.26.0", - { include-group = "docs" }, -] fastapi = [ "fastapi>=0.116.1", ] +dev = [ + "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] addopts = [ diff --git a/uv.lock b/uv.lock index aa2b9d8..f7fedcd 100644 --- a/uv.lock +++ b/uv.lock @@ -226,6 +226,7 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "black" }, + { name = "fastapi" }, { name = "furo" }, { name = "myst-parser" }, { name = "pynvim" }, @@ -251,6 +252,7 @@ fastapi = [ [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" }, From a4e9b239a91fc7348cd5a64b3b43ee69e1411b98 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Tue, 5 Aug 2025 06:48:05 +0300 Subject: [PATCH 10/17] Add fastapi.security support via ``secured(dependency, scopes)`` Build metadata from undirect dependencies (set with ``router.get(dependencies=...)``) --- fundi/compat/fastapi/__init__.py | 2 + fundi/compat/fastapi/alias.py | 5 +- fundi/compat/fastapi/constants.py | 8 ++- fundi/compat/fastapi/dependant.py | 64 +++++++++++++++--- fundi/compat/fastapi/handler.py | 67 ++----------------- fundi/compat/fastapi/inject.py | 62 +++++++++++++++++ fundi/compat/fastapi/metadata.py | 51 ++++++++++++++ fundi/compat/fastapi/route.py | 17 +++-- fundi/compat/fastapi/secured.py | 26 +++++++ fundi/compat/fastapi/secured.pyi | 39 +++++++++++ fundi/compat/fastapi/validate_request_body.py | 61 +++++++++++++++++ fundi/scan.py | 39 ++++++----- 12 files changed, 339 insertions(+), 102 deletions(-) create mode 100644 fundi/compat/fastapi/inject.py create mode 100644 fundi/compat/fastapi/metadata.py create mode 100644 fundi/compat/fastapi/secured.py create mode 100644 fundi/compat/fastapi/secured.pyi create mode 100644 fundi/compat/fastapi/validate_request_body.py diff --git a/fundi/compat/fastapi/__init__.py b/fundi/compat/fastapi/__init__.py index f825d14..2eed615 100644 --- a/fundi/compat/fastapi/__init__.py +++ b/fundi/compat/fastapi/__init__.py @@ -1,3 +1,4 @@ +from .secured import secured from .route import FunDIRoute from .router import FunDIRouter from .handler import get_request_handler @@ -5,6 +6,7 @@ from .alias import get_request_related_aliases __all__ = [ + "secured", "FunDIRoute", "FunDIRouter", "get_request_handler", diff --git a/fundi/compat/fastapi/alias.py b/fundi/compat/fastapi/alias.py index f16f1ea..74a4f98 100644 --- a/fundi/compat/fastapi/alias.py +++ b/fundi/compat/fastapi/alias.py @@ -6,7 +6,6 @@ from starlette.websockets import WebSocket from starlette.background import BackgroundTasks from pydantic.v1.utils import lenient_issubclass -from fastapi.security.oauth2 import SecurityScopes from starlette.requests import HTTPConnection, Request from fundi.types import CallableInfo @@ -35,12 +34,10 @@ def resolve_aliases( request: Request, background_tasks: BackgroundTasks, response: Response, - security_scopes: SecurityScopes, ) -> dict[str, typing.Any]: values: dict[str, typing.Any] = {} for type_, names in scope_aliases.items(): - if type_ is HTTPConnection: value = request elif type_ is Request: @@ -53,7 +50,7 @@ def resolve_aliases( elif type_ is Response: value = response else: - value = security_scopes + raise RuntimeError(f"Unsupported alias type {type_!r}") values.update({name: value for name in names}) diff --git a/fundi/compat/fastapi/constants.py b/fundi/compat/fastapi/constants.py index 9a6b5e9..7282bd9 100644 --- a/fundi/compat/fastapi/constants.py +++ b/fundi/compat/fastapi/constants.py @@ -1,16 +1,18 @@ from starlette.responses import Response from starlette.websockets import WebSocket from starlette.background import BackgroundTasks -from fastapi.security.oauth2 import SecurityScopes from starlette.requests import HTTPConnection, Request __all__ = ["ALIAS_ALLOWED_CLASSES"] ALIAS_ALLOWED_CLASSES = ( Request, + Response, WebSocket, HTTPConnection, - Response, BackgroundTasks, - SecurityScopes, ) + + +METADATA_SECURITY_SCOPES = "fastapi_security_scopes" +METADATA_SCOPE_EXTRA = "scope_extra" diff --git a/fundi/compat/fastapi/dependant.py b/fundi/compat/fastapi/dependant.py index 9921757..3f6f3c8 100644 --- a/fundi/compat/fastapi/dependant.py +++ b/fundi/compat/fastapi/dependant.py @@ -1,26 +1,66 @@ import typing from fastapi import params -from fastapi.dependencies.models import Dependant +from fastapi.security.base import SecurityBase +from fastapi.security.oauth2 import SecurityScopes +from fastapi.dependencies.models import Dependant, SecurityRequirement from fastapi.dependencies.utils import add_param_to_fields, analyze_param from fundi.types import CallableInfo -from .constants import ALIAS_ALLOWED_CLASSES + +from .metadata import get_metadata +from .constants import ALIAS_ALLOWED_CLASSES, METADATA_SECURITY_SCOPES + + +def update_dependant(source: Dependant, target: Dependant): + target.path_params.extend(source.path_params) + target.query_params.extend(source.query_params) + target.header_params.extend(source.header_params) + target.cookie_params.extend(source.cookie_params) + target.body_params.extend(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.extend(source.security_scopes) def get_scope_dependant( - ci: CallableInfo[typing.Any], path_param_names: set[str], path: str + ci: CallableInfo[typing.Any], + path_param_names: set[str], + path: str, + security_scopes: list[str] | None = None, ) -> Dependant: - dependant = Dependant(path=path) + if security_scopes is None: + security_scopes = [] + + dependant = Dependant(path=path, security_scopes=security_scopes) for param in ci.parameters: if param.from_ is not None: - sub = get_scope_dependant(param.from_, path_param_names, path) - dependant.path_params.extend(sub.path_params) - dependant.query_params.extend(sub.query_params) - dependant.header_params.extend(sub.header_params) - dependant.cookie_params.extend(sub.cookie_params) - dependant.body_params.extend(sub.body_params) + subci = param.from_ + + sub = get_scope_dependant(subci, path_param_names, path, security_scopes) + update_dependant(sub, dependant) + + # This is required to pass security_scopes to dependency. + # Here parameter name and security scopes itself are set. + metadata = get_metadata(subci) + + param_scopes: SecurityScopes | None = metadata.get(METADATA_SECURITY_SCOPES, None) + + if param_scopes: + security_scopes.extend(param_scopes.scopes) + + if isinstance(subci.call, SecurityBase): + dependant.security_requirements.append( + SecurityRequirement( + subci.call, security_scopes if param_scopes is None else param_scopes.scopes + ) + ) + continue details = analyze_param( @@ -30,6 +70,10 @@ def get_scope_dependant( is_path_param=param.name in path_param_names, ) + if details.type_annotation is SecurityScopes: + dependant.security_scopes_param_name = param.name + continue + if details.type_annotation in ALIAS_ALLOWED_CLASSES: continue diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py index 56d3cbd..9cc9e36 100644 --- a/fundi/compat/fastapi/handler.py +++ b/fundi/compat/fastapi/handler.py @@ -1,17 +1,12 @@ -import json import typing from collections.abc import Coroutine from contextlib import AsyncExitStack -from fastapi import params from fastapi.types import IncEx from starlette.requests import Request -from pydantic.v1.fields import Undefined from fastapi.routing import serialize_response -from starlette.exceptions import HTTPException from starlette.background import BackgroundTasks from fastapi.dependencies.models import Dependant -from fastapi.security.oauth2 import SecurityScopes from fastapi.exceptions import RequestValidationError from starlette.responses import JSONResponse, Response from fastapi.dependencies.utils import solve_dependencies @@ -19,63 +14,11 @@ from fastapi._compat import ModelField, _normalize_errors # pyright: ignore[reportPrivateUsage] from fastapi.datastructures import Default, DefaultPlaceholder -from fundi.inject import ainject +from .inject import inject from fundi.types import CallableInfo from .alias import resolve_aliases - - -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 +from .validate_request_body import validate_body def get_request_handler( @@ -132,14 +75,13 @@ async def app(request: Request) -> Response: request, background_tasks, scope.response, - SecurityScopes(scope_dependant.security_scopes), ), } for dependency in extra_dependencies: - await ainject(values, dependency, stack) + await inject(values, dependency, stack) - raw_response = await ainject(values, ci, stack) + raw_response = await inject(values, ci, stack) if isinstance(raw_response, Response): if raw_response.background is None: @@ -151,7 +93,6 @@ async def app(request: Request) -> Response: # If status_code was set, use it, otherwise use the default from the # response class, in the case of redirect it's 307 - status = scope.response.status_code or status_code if status is not None: response_args["status_code"] = status diff --git a/fundi/compat/fastapi/inject.py b/fundi/compat/fastapi/inject.py new file mode 100644 index 0000000..905cddc --- /dev/null +++ b/fundi/compat/fastapi/inject.py @@ -0,0 +1,62 @@ +import typing +import contextlib +import collections.abc + +from fundi.types import CallableInfo +from fundi.inject import injection_impl +from fundi.util import call_async, call_sync + +from .metadata import get_metadata +from .constants import METADATA_SCOPE_EXTRA + + +async def inject( + scope: collections.abc.Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + stack: contextlib.AsyncExitStack, + cache: ( + collections.abc.MutableMapping[typing.Callable[..., typing.Any], 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) + + 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_scope, inner_info, stack, 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..2fc8e8c --- /dev/null +++ b/fundi/compat/fastapi/metadata.py @@ -0,0 +1,51 @@ +import typing + +from fastapi import params +from fastapi.security.oauth2 import SecurityScopes + +from fundi.compat.fastapi.constants import METADATA_SCOPE_EXTRA, METADATA_SECURITY_SCOPES +from fundi.types import CallableInfo + + +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]) -> None: + metadata = get_metadata(info) + security_scopes: SecurityScopes = metadata.setdefault( + METADATA_SECURITY_SCOPES, SecurityScopes([]) + ) + + for parameter in info.parameters: + if parameter.from_ is None: + if parameter.annotation is SecurityScopes: + metadata.setdefault(METADATA_SCOPE_EXTRA, {}).update( + {parameter.name: security_scopes} + ) + + continue + + subinfo = parameter.from_ + + param_metadata = get_metadata(subinfo) + + 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] + security_scopes.scopes[::] = list( + set(list(security.scopes) + security_scopes.scopes) + ) + param_metadata.update({METADATA_SECURITY_SCOPES: security_scopes}) + + build_metadata(subinfo) diff --git a/fundi/compat/fastapi/route.py b/fundi/compat/fastapi/route.py index 530e86e..3513a89 100644 --- a/fundi/compat/fastapi/route.py +++ b/fundi/compat/fastapi/route.py @@ -26,8 +26,9 @@ from fundi import scan from fundi.types import CallableInfo from .handler import get_request_handler -from .dependant import get_scope_dependant +from .dependant import get_scope_dependant, update_dependant from .alias import get_request_related_aliases +from fundi.compat.fastapi.metadata import build_metadata @typing.final @@ -171,9 +172,17 @@ def __init__( # pyright: ignore[reportMissingSuperCall] self.response_fields = response_fields - self.dependant = get_scope_dependant( - callable_info, get_path_param_names(self.path_format), self.path_format - ) + build_metadata(callable_info) + + 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: + build_metadata(ci) + 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, diff --git a/fundi/compat/fastapi/secured.py b/fundi/compat/fastapi/secured.py new file mode 100644 index 0000000..9a42266 --- /dev/null +++ b/fundi/compat/fastapi/secured.py @@ -0,0 +1,26 @@ +import typing +from collections.abc import Sequence + +from fastapi.security.oauth2 import SecurityScopes + +from fundi.scan import scan +from .metadata import get_metadata +from fundi.types import CallableInfo +from .constants import METADATA_SECURITY_SCOPES + + +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 + """ + + info = scan(dependency, caching=caching) + metadata = get_metadata(info) + metadata.update({METADATA_SECURITY_SCOPES: SecurityScopes(list(scopes))}) + return info diff --git a/fundi/compat/fastapi/secured.pyi b/fundi/compat/fastapi/secured.pyi new file mode 100644 index 0000000..8e52c06 --- /dev/null +++ b/fundi/compat/fastapi/secured.pyi @@ -0,0 +1,39 @@ +import typing +from typing import overload +from collections.abc import Generator, AsyncGenerator, Awaitable, Sequence +from contextlib import AbstractAsyncContextManager, AbstractContextManager + +R = typing.TypeVar("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/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..d37219d 100644 --- a/fundi/scan.py +++ b/fundi/scan.py @@ -1,4 +1,5 @@ from dataclasses import replace +from types import FunctionType import typing import inspect @@ -65,32 +66,34 @@ def scan(call: typing.Callable[..., R], caching: bool = True) -> CallableInfo[R] info = typing.cast(CallableInfo[typing.Any], getattr(call, "__fundi_info__")) return replace(info, use_cache=caching) - signature = inspect.signature(call) + if isinstance(call, (FunctionType, type)): + truecall = call + else: + truecall = call.__call__ - generator = inspect.isgeneratorfunction(call) - async_generator = inspect.isasyncgenfunction(call) + signature = inspect.signature(truecall) - context = hasattr(call, "__enter__") and hasattr(call, "__exit__") - async_context = hasattr(call, "__aenter__") and hasattr(call, "__aexit__") + generator = inspect.isgeneratorfunction(truecall) + async_generator = inspect.isasyncgenfunction(truecall) - async_ = inspect.iscoroutinefunction(call) or async_generator or async_context + context = hasattr(truecall, "__enter__") and hasattr(truecall, "__exit__") + async_context = hasattr(truecall, "__aenter__") and hasattr(truecall, "__aexit__") + + 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: From ea2393dce9cc3a73e0ace6b0fe7e06c4ab8a6b4b Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Tue, 12 Aug 2025 15:02:14 +0300 Subject: [PATCH 11/17] Generate dependency scope using it's own dependant, not flat dependant to ensure it has the right scope values --- fundi/compat/fastapi/dependant.py | 35 ++++++++++++----- fundi/compat/fastapi/handler.py | 62 +++++++++++++++---------------- fundi/compat/fastapi/inject.py | 56 +++++++++++++++++++++++++++- fundi/compat/fastapi/route.py | 1 - fundi/compat/fastapi/types.py | 8 ++++ pyproject.toml | 1 + tests/compat/fastapi/dependant.py | 56 ++++++++++++++++++++++++++++ uv.lock | 17 ++++++++- 8 files changed, 190 insertions(+), 46 deletions(-) create mode 100644 fundi/compat/fastapi/types.py create mode 100644 tests/compat/fastapi/dependant.py diff --git a/fundi/compat/fastapi/dependant.py b/fundi/compat/fastapi/dependant.py index 3f6f3c8..351ad95 100644 --- a/fundi/compat/fastapi/dependant.py +++ b/fundi/compat/fastapi/dependant.py @@ -1,6 +1,7 @@ import typing from fastapi import params +from fastapi._compat import ModelField from fastapi.security.base import SecurityBase from fastapi.security.oauth2 import SecurityScopes from fastapi.dependencies.models import Dependant, SecurityRequirement @@ -11,13 +12,24 @@ from .metadata import get_metadata from .constants import ALIAS_ALLOWED_CLASSES, METADATA_SECURITY_SCOPES +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): - target.path_params.extend(source.path_params) - target.query_params.extend(source.query_params) - target.header_params.extend(source.header_params) - target.cookie_params.extend(source.cookie_params) - target.body_params.extend(source.body_params) + 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: @@ -36,14 +48,17 @@ def get_scope_dependant( if security_scopes is None: security_scopes = [] - dependant = Dependant(path=path, security_scopes=security_scopes) + dependant = Dependant(path=path) + get_metadata(ci).update(__dependant__=dependant) + + flat_dependant = Dependant(path=path, security_scopes=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, security_scopes) - update_dependant(sub, dependant) + update_dependant(sub, flat_dependant) # This is required to pass security_scopes to dependency. # Here parameter name and security scopes itself are set. @@ -55,7 +70,7 @@ def get_scope_dependant( security_scopes.extend(param_scopes.scopes) if isinstance(subci.call, SecurityBase): - dependant.security_requirements.append( + flat_dependant.security_requirements.append( SecurityRequirement( subci.call, security_scopes if param_scopes is None else param_scopes.scopes ) @@ -83,4 +98,6 @@ def get_scope_dependant( else: add_param_to_fields(field=details.field, dependant=dependant) - return dependant + update_dependant(dependant, flat_dependant) + + return flat_dependant diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py index 9cc9e36..9b5c622 100644 --- a/fundi/compat/fastapi/handler.py +++ b/fundi/compat/fastapi/handler.py @@ -4,26 +4,21 @@ 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 fastapi.dependencies.models import Dependant -from fastapi.exceptions import RequestValidationError from starlette.responses import JSONResponse, Response -from fastapi.dependencies.utils import solve_dependencies from fastapi.utils import is_body_allowed_for_status_code -from fastapi._compat import ModelField, _normalize_errors # pyright: ignore[reportPrivateUsage] from fastapi.datastructures import Default, DefaultPlaceholder from .inject import inject from fundi.types import CallableInfo -from .alias import resolve_aliases from .validate_request_body import validate_body def get_request_handler( ci: CallableInfo[typing.Any], - scope_dependant: Dependant, extra_dependencies: list[CallableInfo[typing.Any]], scope_aliases: dict[type, set[str]], body_field: ModelField | None = None, @@ -51,37 +46,38 @@ async def app(request: Request) -> Response: # 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) - scope = await solve_dependencies( - request=request, - dependant=scope_dependant, - body=body, - dependency_overrides_provider=dependency_overrides_provider, - async_exit_stack=stack, - embed_body_fields=embed_body_fields, - background_tasks=background_tasks, - ) - - if scope.errors: - raise RequestValidationError(_normalize_errors(scope.errors), body=body) - - values = { - **scope.values, - **resolve_aliases( - scope_aliases, + for dependency in extra_dependencies: + await inject( + dependency, + stack, request, + body, + dependency_overrides_provider, + embed_body_fields, background_tasks, - scope.response, - ), - } - - for dependency in extra_dependencies: - await inject(values, dependency, stack) - - raw_response = await inject(values, ci, stack) + scope_aliases, + response, + ) + + raw_response = await inject( + ci, + stack, + request, + body, + dependency_overrides_provider, + embed_body_fields, + background_tasks, + scope_aliases, + response, + ) if isinstance(raw_response, Response): if raw_response.background is None: @@ -93,7 +89,7 @@ async def app(request: Request) -> Response: # If status_code was set, use it, otherwise use the default from the # response class, in the case of redirect it's 307 - status = scope.response.status_code or status_code + status = response.status_code or status_code if status is not None: response_args["status_code"] = status @@ -112,7 +108,7 @@ async def app(request: Request) -> Response: if not is_body_allowed_for_status_code(response.status_code): response.body = b"" - response.headers.raw.extend(scope.response.headers.raw) + response.headers.raw.extend(response.headers.raw) return response diff --git a/fundi/compat/fastapi/inject.py b/fundi/compat/fastapi/inject.py index 905cddc..e4cc0de 100644 --- a/fundi/compat/fastapi/inject.py +++ b/fundi/compat/fastapi/inject.py @@ -2,18 +2,34 @@ 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.types import CallableInfo from fundi.inject import injection_impl from fundi.util import call_async, call_sync +from .alias import resolve_aliases from .metadata import get_metadata from .constants import METADATA_SCOPE_EXTRA +from .types import DependencyOverridesProvider async def inject( - scope: collections.abc.Mapping[str, typing.Any], 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, + scope_aliases: dict[type, set[str]], + response: Response, cache: ( collections.abc.MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None ) = None, @@ -34,6 +50,30 @@ async def inject( metadata = get_metadata(info) + fastapi_params = await solve_dependencies( + request=request, + dependant=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, + **resolve_aliases( + scope_aliases, + request, + background_tasks, + response, + ), + } + scope_extra: collections.abc.Mapping[str, typing.Any] = metadata.get(METADATA_SCOPE_EXTRA, {}) if scope_extra: @@ -48,7 +88,19 @@ async def inject( inner_scope, inner_info, more = gen.send(value) if more: - value = await inject(inner_scope, inner_info, stack, cache, override) + value = await inject( + inner_info, + stack, + request, + body, + dependency_overrides_provider, + embed_body_fields, + background_tasks, + scope_aliases, + response, + cache, + override, + ) continue if info.async_: diff --git a/fundi/compat/fastapi/route.py b/fundi/compat/fastapi/route.py index 3513a89..7dc26c3 100644 --- a/fundi/compat/fastapi/route.py +++ b/fundi/compat/fastapi/route.py @@ -193,7 +193,6 @@ def __init__( # pyright: ignore[reportMissingSuperCall] self.app = request_response( get_request_handler( callable_info, - self.dependant, extra_dependencies=self.dependencies[::-1], scope_aliases=get_request_related_aliases(callable_info), body_field=self.body_field, 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/pyproject.toml b/pyproject.toml index 9aeedce..9bd4b2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ docs = [ ] fastapi = [ "fastapi>=0.116.1", + "python-multipart>=0.0.20", ] dev = [ "black>=25.1.0", diff --git a/tests/compat/fastapi/dependant.py b/tests/compat/fastapi/dependant.py new file mode 100644 index 0000000..0da69ce --- /dev/null +++ b/tests/compat/fastapi/dependant.py @@ -0,0 +1,56 @@ +import pytest +from fundi import 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" diff --git a/uv.lock b/uv.lock index f7fedcd..26288d6 100644 --- a/uv.lock +++ b/uv.lock @@ -232,6 +232,7 @@ dev = [ { 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" }, @@ -245,6 +246,7 @@ docs = [ ] fastapi = [ { name = "fastapi" }, + { name = "python-multipart" }, ] [package.metadata] @@ -258,6 +260,7 @@ dev = [ { 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" }, ] @@ -267,7 +270,10 @@ docs = [ { name = "sphinx", specifier = ">=8.1.3" }, { name = "sphinx-copybutton", specifier = ">=0.5.2" }, ] -fastapi = [{ name = "fastapi", specifier = ">=0.116.1" }] +fastapi = [ + { name = "fastapi", specifier = ">=0.116.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, +] [[package]] name = "furo" @@ -736,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" From 2ad9bda4bcd92bd99bca4e2a6be136aaa8cfd382 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Tue, 12 Aug 2025 15:30:52 +0300 Subject: [PATCH 12/17] Store request related aliases inside callable info metadata --- fundi/compat/fastapi/__init__.py | 4 +-- fundi/compat/fastapi/alias.py | 21 ++++++++----- fundi/compat/fastapi/constants.py | 4 ++- fundi/compat/fastapi/dependant.py | 4 +-- fundi/compat/fastapi/handler.py | 3 -- fundi/compat/fastapi/inject.py | 6 ++-- fundi/compat/fastapi/route.py | 5 +-- tests/compat/fastapi/dependant.py | 12 +++++++- tests/compat/fastapi/metadata.py | 51 +++++++++++++++++++++++++++++++ 9 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 tests/compat/fastapi/metadata.py diff --git a/fundi/compat/fastapi/__init__.py b/fundi/compat/fastapi/__init__.py index 2eed615..1208fda 100644 --- a/fundi/compat/fastapi/__init__.py +++ b/fundi/compat/fastapi/__init__.py @@ -1,15 +1,15 @@ from .secured import secured from .route import FunDIRoute from .router import FunDIRouter +from .alias import init_aliases from .handler import get_request_handler from .dependant import get_scope_dependant -from .alias import get_request_related_aliases __all__ = [ "secured", "FunDIRoute", "FunDIRouter", + "init_aliases", "get_request_handler", "get_scope_dependant", - "get_request_related_aliases", ] diff --git a/fundi/compat/fastapi/alias.py b/fundi/compat/fastapi/alias.py index 74a4f98..ddf4b33 100644 --- a/fundi/compat/fastapi/alias.py +++ b/fundi/compat/fastapi/alias.py @@ -8,25 +8,30 @@ from pydantic.v1.utils import lenient_issubclass from starlette.requests import HTTPConnection, Request +from .metadata import get_metadata from fundi.types import CallableInfo -from .constants import ALIAS_ALLOWED_CLASSES +from .constants import ALIAS_ALLOWED_CLASSES, METADATA_ALIASES -def get_request_related_aliases(ci: CallableInfo[typing.Any]) -> dict[type, set[str]]: +def init_aliases(ci: CallableInfo[typing.Any]) -> None: + metadata = get_metadata(ci) + aliases: defaultdict[type, set[str]] = defaultdict(set) + metadata[METADATA_ALIASES] = aliases + for parameter in ci.parameters: if parameter.from_ is not None: - subaliases = get_request_related_aliases(parameter.from_) - for type_, aliases_ in subaliases.items(): - aliases[type_].update(aliases_) + init_aliases(parameter.from_) continue origin = typing.get_origin(parameter.annotation) or parameter.annotation for type_ in ALIAS_ALLOWED_CLASSES: - if lenient_issubclass(origin, type_): - aliases[type_].add(parameter.name) - return aliases + if not lenient_issubclass(origin, type_): + continue + + aliases[type_].add(parameter.name) + break def resolve_aliases( diff --git a/fundi/compat/fastapi/constants.py b/fundi/compat/fastapi/constants.py index 7282bd9..bca5fe4 100644 --- a/fundi/compat/fastapi/constants.py +++ b/fundi/compat/fastapi/constants.py @@ -6,13 +6,15 @@ __all__ = ["ALIAS_ALLOWED_CLASSES"] ALIAS_ALLOWED_CLASSES = ( + WebSocket, Request, Response, - WebSocket, HTTPConnection, BackgroundTasks, ) METADATA_SECURITY_SCOPES = "fastapi_security_scopes" +METADATA_DEPENDANT = "fastapi_dependant" METADATA_SCOPE_EXTRA = "scope_extra" +METADATA_ALIASES = "fastapi_aliases" diff --git a/fundi/compat/fastapi/dependant.py b/fundi/compat/fastapi/dependant.py index 351ad95..53c7623 100644 --- a/fundi/compat/fastapi/dependant.py +++ b/fundi/compat/fastapi/dependant.py @@ -10,7 +10,7 @@ from fundi.types import CallableInfo from .metadata import get_metadata -from .constants import ALIAS_ALLOWED_CLASSES, METADATA_SECURITY_SCOPES +from .constants import ALIAS_ALLOWED_CLASSES, METADATA_DEPENDANT, METADATA_SECURITY_SCOPES MF = typing.TypeVar("MF", bound=ModelField) @@ -49,7 +49,7 @@ def get_scope_dependant( security_scopes = [] dependant = Dependant(path=path) - get_metadata(ci).update(__dependant__=dependant) + get_metadata(ci).update({METADATA_DEPENDANT: dependant}) flat_dependant = Dependant(path=path, security_scopes=security_scopes) diff --git a/fundi/compat/fastapi/handler.py b/fundi/compat/fastapi/handler.py index 9b5c622..e35c1d1 100644 --- a/fundi/compat/fastapi/handler.py +++ b/fundi/compat/fastapi/handler.py @@ -20,7 +20,6 @@ def get_request_handler( ci: CallableInfo[typing.Any], extra_dependencies: list[CallableInfo[typing.Any]], - scope_aliases: dict[type, set[str]], body_field: ModelField | None = None, status_code: int | None = None, response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), @@ -63,7 +62,6 @@ async def app(request: Request) -> Response: dependency_overrides_provider, embed_body_fields, background_tasks, - scope_aliases, response, ) @@ -75,7 +73,6 @@ async def app(request: Request) -> Response: dependency_overrides_provider, embed_body_fields, background_tasks, - scope_aliases, response, ) diff --git a/fundi/compat/fastapi/inject.py b/fundi/compat/fastapi/inject.py index e4cc0de..32a48a9 100644 --- a/fundi/compat/fastapi/inject.py +++ b/fundi/compat/fastapi/inject.py @@ -16,7 +16,7 @@ from .alias import resolve_aliases from .metadata import get_metadata -from .constants import METADATA_SCOPE_EXTRA +from .constants import METADATA_ALIASES, METADATA_SCOPE_EXTRA from .types import DependencyOverridesProvider @@ -28,7 +28,6 @@ async def inject( dependency_overrides_provider: DependencyOverridesProvider | None, embed_body_fields: bool, background_tasks: BackgroundTasks, - scope_aliases: dict[type, set[str]], response: Response, cache: ( collections.abc.MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None @@ -67,7 +66,7 @@ async def inject( scope = { **fastapi_params.values, **resolve_aliases( - scope_aliases, + metadata[METADATA_ALIASES], request, background_tasks, response, @@ -96,7 +95,6 @@ async def inject( dependency_overrides_provider, embed_body_fields, background_tasks, - scope_aliases, response, cache, override, diff --git a/fundi/compat/fastapi/route.py b/fundi/compat/fastapi/route.py index 7dc26c3..a3b6f6a 100644 --- a/fundi/compat/fastapi/route.py +++ b/fundi/compat/fastapi/route.py @@ -24,10 +24,10 @@ ) from fundi import scan +from .alias import init_aliases from fundi.types import CallableInfo from .handler import get_request_handler from .dependant import get_scope_dependant, update_dependant -from .alias import get_request_related_aliases from fundi.compat.fastapi.metadata import build_metadata @@ -173,12 +173,14 @@ def __init__( # pyright: ignore[reportMissingSuperCall] self.response_fields = response_fields build_metadata(callable_info) + init_aliases(callable_info) 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: build_metadata(ci) + init_aliases(ci) update_dependant( get_scope_dependant(ci, path_param_names, self.path_format), self.dependant ) @@ -194,7 +196,6 @@ def __init__( # pyright: ignore[reportMissingSuperCall] get_request_handler( callable_info, extra_dependencies=self.dependencies[::-1], - scope_aliases=get_request_related_aliases(callable_info), body_field=self.body_field, status_code=self.status_code, response_class=self.response_class, diff --git a/tests/compat/fastapi/dependant.py b/tests/compat/fastapi/dependant.py index 0da69ce..05bc27c 100644 --- a/tests/compat/fastapi/dependant.py +++ b/tests/compat/fastapi/dependant.py @@ -1,5 +1,5 @@ import pytest -from fundi import scan +from fundi import from_, scan from functools import partial from starlette.requests import Request from starlette.responses import Response @@ -54,3 +54,13 @@ 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/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"}, + } From e533d1d5eedd7f92a50b1ba63b3f66c69ed4691c Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Wed, 13 Aug 2025 00:36:48 +0300 Subject: [PATCH 13/17] Add injection tests --- build/lib/fundi/__init__.py | 41 +++ build/lib/fundi/compat/fastapi/__init__.py | 15 + build/lib/fundi/compat/fastapi/alias.py | 62 +++++ build/lib/fundi/compat/fastapi/constants.py | 20 ++ build/lib/fundi/compat/fastapi/dependant.py | 103 +++++++ build/lib/fundi/compat/fastapi/handler.py | 112 ++++++++ build/lib/fundi/compat/fastapi/inject.py | 112 ++++++++ build/lib/fundi/compat/fastapi/metadata.py | 51 ++++ build/lib/fundi/compat/fastapi/route.py | 212 ++++++++++++++ build/lib/fundi/compat/fastapi/router.py | 56 ++++ build/lib/fundi/compat/fastapi/secured.py | 26 ++ build/lib/fundi/compat/fastapi/secured.pyi | 39 +++ build/lib/fundi/compat/fastapi/types.py | 8 + .../compat/fastapi/validate_request_body.py | 61 ++++ build/lib/fundi/configurable.py | 62 +++++ build/lib/fundi/debug.py | 73 +++++ build/lib/fundi/exceptions.py | 26 ++ build/lib/fundi/from_.py | 27 ++ build/lib/fundi/from_.pyi | 28 ++ build/lib/fundi/inject.py | 147 ++++++++++ build/lib/fundi/inject.pyi | 99 +++++++ build/lib/fundi/py.typed | 0 build/lib/fundi/resolve.py | 101 +++++++ build/lib/fundi/scan.py | 104 +++++++ build/lib/fundi/types.py | 157 +++++++++++ build/lib/fundi/util.py | 258 +++++++++++++++++ build/lib/fundi/virtual_context.py | 172 ++++++++++++ fundi/compat/fastapi/inject.py | 4 +- tests/compat/fastapi/inject.py | 263 ++++++++++++++++++ 29 files changed, 2437 insertions(+), 2 deletions(-) create mode 100644 build/lib/fundi/__init__.py create mode 100644 build/lib/fundi/compat/fastapi/__init__.py create mode 100644 build/lib/fundi/compat/fastapi/alias.py create mode 100644 build/lib/fundi/compat/fastapi/constants.py create mode 100644 build/lib/fundi/compat/fastapi/dependant.py create mode 100644 build/lib/fundi/compat/fastapi/handler.py create mode 100644 build/lib/fundi/compat/fastapi/inject.py create mode 100644 build/lib/fundi/compat/fastapi/metadata.py create mode 100644 build/lib/fundi/compat/fastapi/route.py create mode 100644 build/lib/fundi/compat/fastapi/router.py create mode 100644 build/lib/fundi/compat/fastapi/secured.py create mode 100644 build/lib/fundi/compat/fastapi/secured.pyi create mode 100644 build/lib/fundi/compat/fastapi/types.py create mode 100644 build/lib/fundi/compat/fastapi/validate_request_body.py create mode 100644 build/lib/fundi/configurable.py create mode 100644 build/lib/fundi/debug.py create mode 100644 build/lib/fundi/exceptions.py create mode 100644 build/lib/fundi/from_.py create mode 100644 build/lib/fundi/from_.pyi create mode 100644 build/lib/fundi/inject.py create mode 100644 build/lib/fundi/inject.pyi create mode 100644 build/lib/fundi/py.typed create mode 100644 build/lib/fundi/resolve.py create mode 100644 build/lib/fundi/scan.py create mode 100644 build/lib/fundi/types.py create mode 100644 build/lib/fundi/util.py create mode 100644 build/lib/fundi/virtual_context.py create mode 100644 tests/compat/fastapi/inject.py diff --git a/build/lib/fundi/__init__.py b/build/lib/fundi/__init__.py new file mode 100644 index 0000000..3989c2d --- /dev/null +++ b/build/lib/fundi/__init__.py @@ -0,0 +1,41 @@ +import typing as _typing + +from .scan import scan +from .from_ import from_ +from . import exceptions +from .resolve import resolve +from .debug import tree, order +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 .types import CallableInfo, TypeResolver, InjectionTrace, R, Parameter, DependencyConfiguration + + +FromType: _typing.TypeAlias = _typing.Annotated[R, TypeResolver] +"""Tell resolver to resolve parameter's value by its type, not name""" + +__all__ = [ + "scan", + "tree", + "order", + "from_", + "inject", + "resolve", + "ainject", + "Parameter", + "exceptions", + "CallableInfo", + "TypeResolver", + "is_configured", + "InjectionTrace", + "virtual_context", + "injection_trace", + "get_configuration", + "normalize_annotation", + "VirtualContextManager", + "DependencyConfiguration", + "configurable_dependency", + "AsyncVirtualContextManager", + "MutableConfigurationWarning", +] diff --git a/build/lib/fundi/compat/fastapi/__init__.py b/build/lib/fundi/compat/fastapi/__init__.py new file mode 100644 index 0000000..1208fda --- /dev/null +++ b/build/lib/fundi/compat/fastapi/__init__.py @@ -0,0 +1,15 @@ +from .secured import secured +from .route import FunDIRoute +from .router import FunDIRouter +from .alias import init_aliases +from .handler import get_request_handler +from .dependant import get_scope_dependant + +__all__ = [ + "secured", + "FunDIRoute", + "FunDIRouter", + "init_aliases", + "get_request_handler", + "get_scope_dependant", +] diff --git a/build/lib/fundi/compat/fastapi/alias.py b/build/lib/fundi/compat/fastapi/alias.py new file mode 100644 index 0000000..ddf4b33 --- /dev/null +++ b/build/lib/fundi/compat/fastapi/alias.py @@ -0,0 +1,62 @@ +import typing +from collections import defaultdict + + +from starlette.responses import Response +from starlette.websockets import WebSocket +from starlette.background import BackgroundTasks +from pydantic.v1.utils import lenient_issubclass +from starlette.requests import HTTPConnection, Request + +from .metadata import get_metadata +from fundi.types import CallableInfo +from .constants import ALIAS_ALLOWED_CLASSES, METADATA_ALIASES + + +def init_aliases(ci: CallableInfo[typing.Any]) -> None: + metadata = get_metadata(ci) + + aliases: defaultdict[type, set[str]] = defaultdict(set) + metadata[METADATA_ALIASES] = aliases + + for parameter in ci.parameters: + if parameter.from_ is not None: + init_aliases(parameter.from_) + continue + + origin = typing.get_origin(parameter.annotation) or parameter.annotation + + for type_ in ALIAS_ALLOWED_CLASSES: + if not lenient_issubclass(origin, type_): + continue + + aliases[type_].add(parameter.name) + break + + +def resolve_aliases( + scope_aliases: dict[type, set[str]], + request: Request, + background_tasks: BackgroundTasks, + response: Response, +) -> dict[str, typing.Any]: + values: dict[str, typing.Any] = {} + + for type_, names in scope_aliases.items(): + if type_ is HTTPConnection: + value = request + elif type_ is Request: + value = request + elif type_ is WebSocket: + assert isinstance(request, WebSocket), "Not a websocket" + value = request + elif type_ is BackgroundTasks: + value = background_tasks + elif type_ is Response: + value = response + else: + raise RuntimeError(f"Unsupported alias type {type_!r}") + + values.update({name: value for name in names}) + + return values diff --git a/build/lib/fundi/compat/fastapi/constants.py b/build/lib/fundi/compat/fastapi/constants.py new file mode 100644 index 0000000..bca5fe4 --- /dev/null +++ b/build/lib/fundi/compat/fastapi/constants.py @@ -0,0 +1,20 @@ +from starlette.responses import Response +from starlette.websockets import WebSocket +from starlette.background import BackgroundTasks +from starlette.requests import HTTPConnection, Request + +__all__ = ["ALIAS_ALLOWED_CLASSES"] + +ALIAS_ALLOWED_CLASSES = ( + WebSocket, + Request, + Response, + HTTPConnection, + BackgroundTasks, +) + + +METADATA_SECURITY_SCOPES = "fastapi_security_scopes" +METADATA_DEPENDANT = "fastapi_dependant" +METADATA_SCOPE_EXTRA = "scope_extra" +METADATA_ALIASES = "fastapi_aliases" diff --git a/build/lib/fundi/compat/fastapi/dependant.py b/build/lib/fundi/compat/fastapi/dependant.py new file mode 100644 index 0000000..53c7623 --- /dev/null +++ b/build/lib/fundi/compat/fastapi/dependant.py @@ -0,0 +1,103 @@ +import typing + +from fastapi import params +from fastapi._compat import ModelField +from fastapi.security.base import SecurityBase +from fastapi.security.oauth2 import SecurityScopes +from fastapi.dependencies.models import Dependant, SecurityRequirement +from fastapi.dependencies.utils import add_param_to_fields, analyze_param + +from fundi.types import CallableInfo + +from .metadata import get_metadata +from .constants import ALIAS_ALLOWED_CLASSES, METADATA_DEPENDANT, METADATA_SECURITY_SCOPES + +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.extend(source.security_scopes) + + +def get_scope_dependant( + ci: CallableInfo[typing.Any], + path_param_names: set[str], + path: str, + security_scopes: list[str] | None = None, +) -> Dependant: + if security_scopes is None: + security_scopes = [] + + dependant = Dependant(path=path) + get_metadata(ci).update({METADATA_DEPENDANT: dependant}) + + flat_dependant = Dependant(path=path, security_scopes=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, security_scopes) + 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) + + param_scopes: SecurityScopes | None = metadata.get(METADATA_SECURITY_SCOPES, None) + + if param_scopes: + security_scopes.extend(param_scopes.scopes) + + if isinstance(subci.call, SecurityBase): + flat_dependant.security_requirements.append( + SecurityRequirement( + subci.call, security_scopes if param_scopes is None else param_scopes.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 details.type_annotation is SecurityScopes: + dependant.security_scopes_param_name = param.name + continue + + if details.type_annotation in ALIAS_ALLOWED_CLASSES: + 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/build/lib/fundi/compat/fastapi/handler.py b/build/lib/fundi/compat/fastapi/handler.py new file mode 100644 index 0000000..e35c1d1 --- /dev/null +++ b/build/lib/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/build/lib/fundi/compat/fastapi/inject.py b/build/lib/fundi/compat/fastapi/inject.py new file mode 100644 index 0000000..32a48a9 --- /dev/null +++ b/build/lib/fundi/compat/fastapi/inject.py @@ -0,0 +1,112 @@ +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.types import CallableInfo +from fundi.inject import injection_impl +from fundi.util import call_async, call_sync + +from .alias import resolve_aliases +from .metadata import get_metadata +from .constants import METADATA_ALIASES, METADATA_SCOPE_EXTRA +from .types import DependencyOverridesProvider + + +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[typing.Callable[..., typing.Any], 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["__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, + **resolve_aliases( + metadata[METADATA_ALIASES], + request, + background_tasks, + response, + ), + } + + 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/build/lib/fundi/compat/fastapi/metadata.py b/build/lib/fundi/compat/fastapi/metadata.py new file mode 100644 index 0000000..2fc8e8c --- /dev/null +++ b/build/lib/fundi/compat/fastapi/metadata.py @@ -0,0 +1,51 @@ +import typing + +from fastapi import params +from fastapi.security.oauth2 import SecurityScopes + +from fundi.compat.fastapi.constants import METADATA_SCOPE_EXTRA, METADATA_SECURITY_SCOPES +from fundi.types import CallableInfo + + +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]) -> None: + metadata = get_metadata(info) + security_scopes: SecurityScopes = metadata.setdefault( + METADATA_SECURITY_SCOPES, SecurityScopes([]) + ) + + for parameter in info.parameters: + if parameter.from_ is None: + if parameter.annotation is SecurityScopes: + metadata.setdefault(METADATA_SCOPE_EXTRA, {}).update( + {parameter.name: security_scopes} + ) + + continue + + subinfo = parameter.from_ + + param_metadata = get_metadata(subinfo) + + 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] + security_scopes.scopes[::] = list( + set(list(security.scopes) + security_scopes.scopes) + ) + param_metadata.update({METADATA_SECURITY_SCOPES: security_scopes}) + + build_metadata(subinfo) diff --git a/build/lib/fundi/compat/fastapi/route.py b/build/lib/fundi/compat/fastapi/route.py new file mode 100644 index 0000000..a3b6f6a --- /dev/null +++ b/build/lib/fundi/compat/fastapi/route.py @@ -0,0 +1,212 @@ +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 .alias import init_aliases +from fundi.types import CallableInfo +from .handler import get_request_handler +from .dependant import get_scope_dependant, update_dependant +from fundi.compat.fastapi.metadata import build_metadata + + +@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.Depends): + if dependency.dependency is None: + continue + + self.dependencies.append(scan(dependency.dependency)) + 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 + + build_metadata(callable_info) + init_aliases(callable_info) + + 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: + build_metadata(ci) + init_aliases(ci) + 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/build/lib/fundi/compat/fastapi/router.py b/build/lib/fundi/compat/fastapi/router.py new file mode 100644 index 0000000..adc7414 --- /dev/null +++ b/build/lib/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/build/lib/fundi/compat/fastapi/secured.py b/build/lib/fundi/compat/fastapi/secured.py new file mode 100644 index 0000000..9a42266 --- /dev/null +++ b/build/lib/fundi/compat/fastapi/secured.py @@ -0,0 +1,26 @@ +import typing +from collections.abc import Sequence + +from fastapi.security.oauth2 import SecurityScopes + +from fundi.scan import scan +from .metadata import get_metadata +from fundi.types import CallableInfo +from .constants import METADATA_SECURITY_SCOPES + + +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 + """ + + info = scan(dependency, caching=caching) + metadata = get_metadata(info) + metadata.update({METADATA_SECURITY_SCOPES: SecurityScopes(list(scopes))}) + return info diff --git a/build/lib/fundi/compat/fastapi/secured.pyi b/build/lib/fundi/compat/fastapi/secured.pyi new file mode 100644 index 0000000..8e52c06 --- /dev/null +++ b/build/lib/fundi/compat/fastapi/secured.pyi @@ -0,0 +1,39 @@ +import typing +from typing import overload +from collections.abc import Generator, AsyncGenerator, Awaitable, Sequence +from contextlib import AbstractAsyncContextManager, AbstractContextManager + +R = typing.TypeVar("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/build/lib/fundi/compat/fastapi/types.py b/build/lib/fundi/compat/fastapi/types.py new file mode 100644 index 0000000..4426b94 --- /dev/null +++ b/build/lib/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/build/lib/fundi/compat/fastapi/validate_request_body.py b/build/lib/fundi/compat/fastapi/validate_request_body.py new file mode 100644 index 0000000..aa7f268 --- /dev/null +++ b/build/lib/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/build/lib/fundi/configurable.py b/build/lib/fundi/configurable.py new file mode 100644 index 0000000..099aba1 --- /dev/null +++ b/build/lib/fundi/configurable.py @@ -0,0 +1,62 @@ +import typing +import warnings +import functools + +from fundi.scan import scan +from fundi.util import callable_str +from fundi.types import R, DependencyConfiguration + +P = typing.ParamSpec("P") + + +class MutableConfigurationWarning(UserWarning): + pass + + +def configurable_dependency(configurator: typing.Callable[P, R]) -> typing.Callable[P, R]: + """ + Create dependency configurator that caches configured dependencies. + This helps FunDI cache resolver understand that dependency already executed, if it was. + + Note: Calls with mutable arguments will not be stored in cache and warning would be shown + + :param configurator: Original dependency configurator + :return: cache aware dependency configurator + """ + dependencies: dict[frozenset[tuple[str, typing.Any]], R] = {} + info = scan(configurator) + + if info.async_: + raise ValueError("Dependency configurator should not be asynchronous") + + @functools.wraps(configurator) + def cached_dependency_generator(*args: typing.Any, **kwargs: typing.Any) -> R: + use_cache = True + values = info.build_values(*args, **kwargs) + key: frozenset[tuple[str, typing.Any]] | None = None + + try: + key = frozenset(values.items()) + + if key in dependencies: + return dependencies[key] + except TypeError: + warnings.warn( + f"Can't cache dependency created via {callable_str(configurator)}: configured with unhashable arguments", + MutableConfigurationWarning, + ) + use_cache = False + + dependency = configurator(*args, **kwargs) + setattr( + dependency, + "__fundi_configuration__", + DependencyConfiguration(configurator=info, values=values), + ) + + if use_cache and key is not None: + dependencies[key] = dependency + + return dependency + + return cached_dependency_generator diff --git a/build/lib/fundi/debug.py b/build/lib/fundi/debug.py new file mode 100644 index 0000000..d66c795 --- /dev/null +++ b/build/lib/fundi/debug.py @@ -0,0 +1,73 @@ +import typing +import collections.abc + +from fundi.types import CallableInfo +from fundi.inject import injection_impl + + +def tree( + scope: collections.abc.Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + cache: ( + collections.abc.MutableMapping[ + typing.Callable[..., typing.Any], collections.abc.Mapping[str, typing.Any] + ] + | None + ) = None, +) -> collections.abc.Mapping[str, typing.Any]: + """ + Get tree of dependencies of callable. + + :param scope: container with contextual values + :param info: callable information + :param cache: tree generation cache + :return: Tree of dependencies + """ + if cache is None: + cache = {} + + gen = injection_impl(scope, info, cache, None) + + value = None + + while True: + inner_scope, inner_info, more = gen.send(value) + if not more: + return {"call": inner_info.call, "values": inner_scope} + + value = tree(inner_scope, inner_info, cache) + + +def order( + scope: collections.abc.Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + cache: ( + collections.abc.MutableMapping[ + typing.Callable[..., typing.Any], list[typing.Callable[..., typing.Any]] + ] + | None + ) = None, +) -> list[typing.Callable[..., typing.Any]]: + """ + Get resolving order of callable dependencies. + + :param info: callable information + :param scope: container with contextual values + :param cache: solvation cache + :return: order of dependencies + """ + if cache is None: + cache = {} + + gen = injection_impl(scope, info, cache, None) + + order_: list[typing.Callable[..., typing.Any]] = [] + + value = None + while True: + inner_scope, inner_info, more = gen.send(value) + if not more: + return order_ + + order_.extend(order(inner_scope, inner_info, cache)) + order_.append(inner_info.call) diff --git a/build/lib/fundi/exceptions.py b/build/lib/fundi/exceptions.py new file mode 100644 index 0000000..3aac7fb --- /dev/null +++ b/build/lib/fundi/exceptions.py @@ -0,0 +1,26 @@ +import typing +from types import FunctionType +from collections.abc import AsyncGenerator, Generator + +from fundi.util import callable_str +from fundi.types import CallableInfo + + +class ScopeValueNotFoundError(ValueError): + def __init__(self, parameter: str, info: CallableInfo[typing.Any]): + super().__init__( + f'Cannot resolve "{parameter}" for {callable_str(info.call)} - Scope does not contain required value' + ) + self.parameter: str = parameter + self.info: CallableInfo[typing.Any] = info + + +class GeneratorExitedTooEarly(Exception): + def __init__( + self, + function: FunctionType, + generator: AsyncGenerator[typing.Any] | Generator[typing.Any, None, None], + ): + super().__init__(f"Generator exited too early") + self.function: FunctionType = function + self.generator: AsyncGenerator[typing.Any] | Generator[typing.Any, None, None] = generator diff --git a/build/lib/fundi/from_.py b/build/lib/fundi/from_.py new file mode 100644 index 0000000..3a5ca68 --- /dev/null +++ b/build/lib/fundi/from_.py @@ -0,0 +1,27 @@ +import typing +from contextlib import AbstractAsyncContextManager, AbstractContextManager + +from fundi.scan import scan +from fundi.types import CallableInfo, TypeResolver + + +def from_( + dependency: type | typing.Callable[..., typing.Any], caching: bool = True +) -> TypeResolver | CallableInfo[typing.Any]: + """ + Use callable or type as dependency for parameter of function + + if dependency parameter is callable the ``fundi.scan.scan`` is used + + if dependency parameter is type the ``fundi.types.TypeResolver`` is returned (unless that type is a subclass of AbstractContextManager or AbstractAsyncContextManager) + + :param dependency: function dependency + :param caching: Whether to use cached result of this callable or not + :return: callable information + """ + if isinstance(dependency, type) and not issubclass( + dependency, (AbstractContextManager, AbstractAsyncContextManager) + ): + return TypeResolver(dependency) + + return scan(dependency, caching=caching) diff --git a/build/lib/fundi/from_.pyi b/build/lib/fundi/from_.pyi new file mode 100644 index 0000000..d65bf11 --- /dev/null +++ b/build/lib/fundi/from_.pyi @@ -0,0 +1,28 @@ +import typing +from typing import overload +from collections.abc import Generator, AsyncGenerator, Awaitable +from contextlib import AbstractAsyncContextManager, AbstractContextManager + +T = typing.TypeVar("T", bound=type) +R = typing.TypeVar("R") + +@overload +def from_( + dependency: typing.Callable[..., AbstractContextManager[R]], caching: bool = True +) -> R: ... +@overload +def from_( + dependency: typing.Callable[..., AbstractAsyncContextManager[R]], caching: bool = True +) -> R: ... +@overload +def from_(dependency: T, caching: bool = True) -> T: ... +@overload +def from_( + dependency: typing.Callable[..., Generator[R, None, None]], caching: bool = True +) -> R: ... +@overload +def from_(dependency: typing.Callable[..., AsyncGenerator[R, None]], caching: bool = True) -> R: ... +@overload +def from_(dependency: typing.Callable[..., Awaitable[R]], caching: bool = True) -> R: ... +@overload +def from_(dependency: typing.Callable[..., R], caching: bool = True) -> R: ... diff --git a/build/lib/fundi/inject.py b/build/lib/fundi/inject.py new file mode 100644 index 0000000..0e90d9e --- /dev/null +++ b/build/lib/fundi/inject.py @@ -0,0 +1,147 @@ +import typing +import contextlib +import collections.abc + +from fundi.resolve import resolve +from fundi.types import CallableInfo +from fundi.util import call_sync, call_async, add_injection_trace + + +def injection_impl( + scope: collections.abc.Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + cache: collections.abc.MutableMapping[typing.Callable[..., typing.Any], typing.Any], + override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None, +) -> collections.abc.Generator[ + tuple[collections.abc.Mapping[str, typing.Any], CallableInfo[typing.Any], bool], + typing.Any, + None, +]: + """ + Injection brain. + + Coordinates dependency resolution for a given `CallableInfo`. For each parameter: + + - If the parameter has a pre-resolved value (from scope, override, or cache) — uses it. + - If the parameter requires another dependency to be resolved: + - Yields `(scope_with_context, dependency_info, True)` to request the caller to inject it. + - Once the value is received — caches it if allowed. + + After all parameters are resolved, yields: + `(resolved_values_dict, top_level_callable_info, False)` + + If any error occurs during resolution, attaches injection trace and re-raises the exception. + """ + + values: dict[str, typing.Any] = {} + try: + for result in resolve(scope, info, cache, override): + name = result.parameter.name + value = result.value + + if not result.resolved: + dependency = result.dependency + assert dependency is not None + + value = yield {**scope, "__fundi_parameter__": result.parameter}, dependency, True + + if dependency.use_cache: + cache[dependency.call] = value + + values[name] = value + + yield values, info, False + + except Exception as exc: + add_injection_trace(exc, info, values) + raise exc + + +def inject( + scope: collections.abc.Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + stack: contextlib.ExitStack, + cache: ( + collections.abc.MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None + ) = None, + override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> typing.Any: + """ + Synchronously 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 info.async_: + raise RuntimeError("Cannot process async functions in synchronous injection") + + if cache is None: + cache = {} + + 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 = inject(inner_scope, inner_info, stack, cache, override) + continue + + return call_sync(stack, inner_info, inner_scope) + except Exception as exc: + with contextlib.suppress(StopIteration): + gen.throw(type(exc), exc, exc.__traceback__) + + raise + + +async def ainject( + scope: collections.abc.Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + stack: contextlib.AsyncExitStack, + cache: ( + collections.abc.MutableMapping[typing.Callable[..., typing.Any], 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 = {} + + 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 ainject(inner_scope, inner_info, stack, 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/build/lib/fundi/inject.pyi b/build/lib/fundi/inject.pyi new file mode 100644 index 0000000..47ad01b --- /dev/null +++ b/build/lib/fundi/inject.pyi @@ -0,0 +1,99 @@ +import typing +from typing import overload +from collections.abc import Generator, AsyncGenerator, Mapping, MutableMapping, Awaitable + +from fundi.types import CallableInfo + +from contextlib import ( + AsyncExitStack, + AbstractContextManager, + ExitStack as SyncExitStack, + AbstractAsyncContextManager, +) + +R = typing.TypeVar("R") + +ExitStack = AsyncExitStack | SyncExitStack + +def injection_impl( + scope: Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any], + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None, +) -> Generator[ + tuple[Mapping[str, typing.Any], CallableInfo[typing.Any], bool], + typing.Any, + None, +]: ... +@overload +def inject( + scope: Mapping[str, typing.Any], + info: CallableInfo[Generator[R, None, None]], + stack: ExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +def inject( + scope: Mapping[str, typing.Any], + info: CallableInfo[AbstractContextManager[R]], + stack: ExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +def inject( + scope: Mapping[str, typing.Any], + info: CallableInfo[R], + stack: ExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +async def ainject( + scope: Mapping[str, typing.Any], + info: CallableInfo[Generator[R, None, None]], + stack: AsyncExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +async def ainject( + scope: Mapping[str, typing.Any], + info: CallableInfo[AsyncGenerator[R, None]], + stack: AsyncExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +async def ainject( + scope: Mapping[str, typing.Any], + info: CallableInfo[Awaitable[R]], + stack: AsyncExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +async def ainject( + scope: Mapping[str, typing.Any], + info: CallableInfo[AbstractAsyncContextManager[R]], + stack: AsyncExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +async def ainject( + scope: Mapping[str, typing.Any], + info: CallableInfo[AbstractContextManager[R]], + stack: AsyncExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... +@overload +async def ainject( + scope: Mapping[str, typing.Any], + info: CallableInfo[R], + stack: AsyncExitStack, + cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> R: ... diff --git a/build/lib/fundi/py.typed b/build/lib/fundi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/fundi/resolve.py b/build/lib/fundi/resolve.py new file mode 100644 index 0000000..6b096f2 --- /dev/null +++ b/build/lib/fundi/resolve.py @@ -0,0 +1,101 @@ +import typing +import collections.abc + +from fundi.util import normalize_annotation +from fundi.types import CallableInfo, ParameterResult, Parameter + + +def resolve_by_dependency( + param: Parameter, + cache: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any], + override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any], +) -> ParameterResult: + dependency = param.from_ + + assert dependency is not None + + value = override.get(dependency.call) + if value is not None: + if isinstance(value, CallableInfo): + return ParameterResult( + param, None, typing.cast(CallableInfo[typing.Any], value), resolved=False + ) + + return ParameterResult(param, value, dependency, resolved=True) + + if dependency.use_cache and dependency.call in cache: + return ParameterResult(param, cache[dependency.call], dependency, resolved=True) + + return ParameterResult(param, None, dependency, resolved=False) + + +def resolve_by_type( + scope: collections.abc.Mapping[str, typing.Any], param: Parameter +) -> ParameterResult: + type_options = normalize_annotation(param.annotation) + + for value in scope.values(): + if not isinstance(value, type_options): + continue + + return ParameterResult(param, value, None, resolved=True) + + return ParameterResult(param, None, None, resolved=False) + + +def resolve( + scope: collections.abc.Mapping[str, typing.Any], + info: CallableInfo[typing.Any], + cache: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any], + override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, +) -> collections.abc.Generator[ParameterResult, None, None]: + """ + Try to resolve values from cache or scope for callable parameters + + Recommended use case:: + + values = {} + cache = {} + for result in resolve(scope, info, cache): + value = result.value + name = result.parameter_name + + if not result.resolved: + value = inject(scope, info, stack, cache) + cache[name] = value + + values[name] = value + + + :param scope: container with contextual values + :param info: callable information + :param cache: solvation cache(modify it if necessary while resolving) + :param override: override dependencies + :return: generator with solvation results + """ + from fundi.exceptions import ScopeValueNotFoundError + + if override is None: + override = {} + + for parameter in info.parameters: + if parameter.from_: + yield resolve_by_dependency(parameter, cache, override) + continue + + if parameter.resolve_by_type: + result = resolve_by_type(scope, parameter) + + if result.resolved: + yield result + continue + + elif parameter.name in scope: + yield ParameterResult(parameter, scope[parameter.name], None, resolved=True) + continue + + if parameter.has_default: + yield ParameterResult(parameter, parameter.default, None, resolved=True) + continue + + raise ScopeValueNotFoundError(parameter.name, info) diff --git a/build/lib/fundi/scan.py b/build/lib/fundi/scan.py new file mode 100644 index 0000000..d37219d --- /dev/null +++ b/build/lib/fundi/scan.py @@ -0,0 +1,104 @@ +from dataclasses import replace +from types import FunctionType +import typing +import inspect + +from fundi.util import is_configured, get_configuration +from fundi.types import R, CallableInfo, Parameter, TypeResolver + + +def _transform_parameter(parameter: inspect.Parameter) -> Parameter: + positional_varying = parameter.kind == inspect.Parameter.VAR_POSITIONAL + positional_only = parameter.kind == inspect.Parameter.POSITIONAL_ONLY + keyword_varying = parameter.kind == inspect.Parameter.VAR_KEYWORD + keyword_only = parameter.kind == inspect.Parameter.KEYWORD_ONLY + + if isinstance(parameter.default, CallableInfo): + return Parameter( + parameter.name, + parameter.annotation, + from_=typing.cast(CallableInfo[typing.Any], parameter.default), + positional_varying=positional_varying, + positional_only=positional_only, + keyword_varying=keyword_varying, + keyword_only=keyword_only, + ) + + has_default = parameter.default is not inspect.Parameter.empty + resolve_by_type = False + + annotation = parameter.annotation + if isinstance(annotation, TypeResolver): + annotation = annotation.annotation + resolve_by_type = True + + elif typing.get_origin(annotation) is typing.Annotated: + args = typing.get_args(annotation) + + if args[1] is TypeResolver: + resolve_by_type = True + + return Parameter( + parameter.name, + annotation, + from_=None, + default=parameter.default if has_default else None, + has_default=has_default, + resolve_by_type=resolve_by_type, + positional_varying=positional_varying, + positional_only=positional_only, + keyword_varying=keyword_varying, + keyword_only=keyword_only, + ) + + +def scan(call: typing.Callable[..., R], caching: bool = True) -> CallableInfo[R]: + """ + Get callable information + + :param call: callable to get information from + :param caching: whether to use cached result of this callable or not + + :return: callable information + """ + + if hasattr(call, "__fundi_info__"): + info = typing.cast(CallableInfo[typing.Any], getattr(call, "__fundi_info__")) + return replace(info, use_cache=caching) + + if isinstance(call, (FunctionType, type)): + truecall = call + else: + truecall = call.__call__ + + signature = inspect.signature(truecall) + + generator = inspect.isgeneratorfunction(truecall) + async_generator = inspect.isasyncgenfunction(truecall) + + context = hasattr(truecall, "__enter__") and hasattr(truecall, "__exit__") + async_context = hasattr(truecall, "__aenter__") and hasattr(truecall, "__aexit__") + + 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 = 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: + setattr(call, "__fundi_info__", info) + except (AttributeError, TypeError): + pass + + return info diff --git a/build/lib/fundi/types.py b/build/lib/fundi/types.py new file mode 100644 index 0000000..8dff756 --- /dev/null +++ b/build/lib/fundi/types.py @@ -0,0 +1,157 @@ +import typing +import collections +import collections.abc +from dataclasses import dataclass, field + +__all__ = [ + "R", + "Parameter", + "TypeResolver", + "CallableInfo", + "InjectionTrace", + "ParameterResult", + "DependencyConfiguration", +] + +R = typing.TypeVar("R") + + +@dataclass +class TypeResolver: + """ + Mark that tells ``fundi.scan.scan`` to set ``Parameter.resolve_by_type`` to True. + + This changes logic of ``fundi.resolve.resolve``, so it uses ``Parameter.annotation`` + to find value in scope instead of ``Parameter.name`` + """ + + annotation: type + + +@dataclass +class Parameter: + name: str + annotation: typing.Any + from_: "CallableInfo[typing.Any] | None" + default: typing.Any = None + has_default: bool = False + resolve_by_type: bool = False + positional_only: bool = False + keyword_only: bool = False + positional_varying: bool = False + keyword_varying: bool = False + + +@dataclass +class CallableInfo(typing.Generic[R]): + call: typing.Callable[..., R] + use_cache: bool + async_: bool + context: bool + generator: bool + parameters: list[Parameter] + return_annotation: typing.Any + configuration: "DependencyConfiguration | None" + named_parameters: dict[str, Parameter] = field(init=False) + + def __post_init__(self): + self.named_parameters = {p.name: p for p in self.parameters} + + def _build_values( + self, + args: tuple[typing.Any, ...], + kwargs: collections.abc.MutableMapping[str, typing.Any], + partial: bool = False, + ) -> dict[str, typing.Any]: + values: dict[str, typing.Any] = {} + + args_amount = len(args) + + ix = 0 + for parameter in self.parameters: + name = parameter.name + + if parameter.keyword_varying: + values[name] = kwargs + continue + + if name in kwargs: + values[name] = kwargs.pop(name) + continue + + if parameter.positional_varying: + values[name] = args[ix:] + ix = args_amount + continue + + if ix < args_amount: + values[name] = args[ix] + ix += 1 + continue + + if parameter.has_default: + values[name] = parameter.default + continue + + if not partial: + raise ValueError(f'Argument for parameter "{parameter.name}" not found') + + return values + + def build_values( + self, *args: typing.Any, **kwargs: typing.Any + ) -> collections.abc.Mapping[str, typing.Any]: + return self._build_values(args, kwargs) + + def partial_build_values( + self, *args: typing.Any, **kwargs: typing.Any + ) -> collections.abc.Mapping[str, typing.Any]: + return self._build_values(args, kwargs, partial=True) + + def build_arguments( + self, values: collections.abc.Mapping[str, typing.Any] + ) -> tuple[tuple[typing.Any, ...], dict[str, typing.Any]]: + positional: tuple[typing.Any, ...] = () + keyword: dict[str, typing.Any] = {} + + for parameter in self.parameters: + name = parameter.name + + if name not in values: + raise ValueError(f'Value for "{name}" parameter not found') + + value = values[name] + + if parameter.positional_only: + positional += (value,) + elif parameter.positional_varying: + positional += value + elif parameter.keyword_only: + keyword[name] = value + elif parameter.keyword_varying: + keyword.update(value) + else: + positional += (value,) + + return positional, keyword + + +@dataclass +class ParameterResult: + parameter: Parameter + value: typing.Any | None + dependency: CallableInfo[typing.Any] | None + resolved: bool + + +@dataclass +class InjectionTrace: + info: CallableInfo[typing.Any] + values: collections.abc.Mapping[str, typing.Any] + origin: "InjectionTrace | None" = None + + +@dataclass +class DependencyConfiguration: + configurator: CallableInfo[typing.Any] + values: collections.abc.Mapping[str, typing.Any] diff --git a/build/lib/fundi/util.py b/build/lib/fundi/util.py new file mode 100644 index 0000000..c5e3df1 --- /dev/null +++ b/build/lib/fundi/util.py @@ -0,0 +1,258 @@ +import types +import typing +import inspect +import warnings +import contextlib +import collections.abc +from types import TracebackType + +from fundi.types import CallableInfo, InjectionTrace, DependencyConfiguration + + +__all__ = [ + "call_sync", + "call_async", + "callable_str", + "is_configured", + "injection_trace", + "get_configuration", + "add_injection_trace", + "normalize_annotation", +] + + +def callable_str(call: typing.Callable[..., typing.Any]) -> str: + if hasattr(call, "__qualname__"): + name = call.__qualname__ + elif hasattr(call, "__name__"): + name = call.__name__ + else: + name = str(call) + + module = inspect.getmodule(call) + + module_name = "" if module is None else module.__name__ + + return f"<{name} from {module_name}>" + + +def add_injection_trace( + exception: Exception, + info: CallableInfo[typing.Any], + values: collections.abc.Mapping[str, typing.Any], +) -> None: + setattr( + exception, + "__fundi_injection_trace__", + InjectionTrace(info, values, getattr(exception, "__fundi_injection_trace__", None)), + ) + + +def call_sync( + stack: contextlib.ExitStack | contextlib.AsyncExitStack, + info: CallableInfo[typing.Any], + values: collections.abc.Mapping[str, typing.Any], +) -> typing.Any: + """ + Synchronously call dependency callable. + + :param stack: exit stack to properly handle generator dependencies + :param info: callable information + :param values: callable arguments + :return: callable result + """ + args, kwargs = info.build_arguments(values) + value = info.call(*args, **kwargs) + + if info.context: + manager: contextlib.AbstractContextManager[typing.Any] = value + value = manager.__enter__() + + def exit_context( + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool: + try: + manager.__exit__(exc_type, exc_value, tb) + except Exception as e: + # Do not include re-raise of this exception in traceback to make it cleaner + if e is exc_value: + return False + + raise + + # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS + return exc_type is None + + stack.push(exit_context) + + if info.generator: + generator: collections.abc.Generator[typing.Any, None, None] = value + value = next(generator) + + def close_generator( + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool: + try: + if exc_type is not None: + generator.throw(exc_type, exc_value, tb) + else: + next(generator) + except StopIteration: + # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS + return exc_type is None + except Exception as e: + # Do not include re-raise of this exception in traceback to make it cleaner + if e is exc_value: + return False + + raise + + warnings.warn("Generator not exited", UserWarning) + + # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS + return exc_type is None + + stack.push(close_generator) + + return value + + +async def call_async( + stack: contextlib.AsyncExitStack, + info: CallableInfo[typing.Any], + values: collections.abc.Mapping[str, typing.Any], +) -> typing.Any: + """ + Asynchronously call dependency callable. + + :param stack: exit stack to properly handle generator dependencies + :param info: callable information + :param values: callable arguments + :return: callable result + """ + args, kwargs = info.build_arguments(values) + + value = info.call(*args, **kwargs) + + if info.context: + manager: contextlib.AbstractAsyncContextManager[typing.Any] = value + value = await manager.__aenter__() + + async def exit_context( + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool: + try: + await manager.__aexit__(exc_type, exc_value, tb) + except Exception as e: + # Do not include re-raise of this exception in traceback to make it cleaner + if e is exc_value: + return False + + raise + + # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS + return exc_type is None + + stack.push_async_exit(exit_context) + + elif info.generator: + generator: collections.abc.AsyncGenerator[typing.Any] = value + value = await anext(generator) + + async def close_generator( + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool: + try: + if exc_type is not None: + await generator.athrow(exc_type, exc_value, tb) + else: + await anext(generator) + except StopAsyncIteration: + # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS + return exc_type is None + except Exception as e: + # Do not include re-raise of this exception in traceback to make it cleaner + if e is exc_value: + return False + + raise + + warnings.warn("Generator not exited", UserWarning) + + # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS + return exc_type is None + + stack.push_async_exit(close_generator) + + else: + value = await value + + return value + + +def injection_trace(exception: Exception) -> InjectionTrace: + """ + Get injection trace from exception + + :param exception: exception to get injection trace from + :return: injection trace + """ + if not hasattr(exception, "__fundi_injection_trace__"): + raise ValueError(f"Exception {exception} does not contain injection trace") + + return typing.cast(InjectionTrace, getattr(exception, "__fundi_injection_trace__")) + + +def is_configured(call: typing.Callable[..., typing.Any]) -> bool: + """ + Get whether callable is configured via @configurable_dependency + + :param call: callable to check + :return: Is this callable configured + """ + return hasattr(call, "__fundi_configuration__") + + +def get_configuration(call: typing.Callable[..., typing.Any]) -> DependencyConfiguration: + """ + Get dependency configuration. Can be useful in third-party tools that needs to know configuration + + :param call: callable to get configuration from + :return: dependency configuration + """ + if not is_configured(call): + raise ValueError(f"Callable {call} is not configured via @configurable_dependency") + + configuration: DependencyConfiguration = getattr(call, "__fundi_configuration__") + return configuration + + +def normalize_annotation(annotation: typing.Any) -> tuple[type[typing.Any], ...]: + """ + Normalize type annotation to make it easily work with + """ + type_options: tuple[type, ...] = (annotation,) + + origin = typing.get_origin(annotation) + args = typing.get_args(annotation) + + if origin is typing.Annotated: + annotation = args[0] + type_options = (annotation,) + origin = typing.get_origin(annotation) + args = typing.get_args(annotation) + + if origin is types.UnionType: + type_options = tuple(t for t in args if t is not types.NoneType) + elif origin is not None: + type_options = (origin,) + + return type_options diff --git a/build/lib/fundi/virtual_context.py b/build/lib/fundi/virtual_context.py new file mode 100644 index 0000000..bc8bae9 --- /dev/null +++ b/build/lib/fundi/virtual_context.py @@ -0,0 +1,172 @@ +""" +Virtual context managers are created to replace contextlib.contextmanager +and contextlib.asynccontextmanager decorators. +They are fully typed and distinguishable by FunDIs `scan(...)` function +""" + +import types +import typing +import inspect +import warnings +from dataclasses import replace +from collections.abc import Generator, AsyncGenerator +from contextlib import AbstractAsyncContextManager, AbstractContextManager + +from .scan import scan +from .types import CallableInfo +from .exceptions import GeneratorExitedTooEarly + +__all__ = ["VirtualContextManager", "AsyncVirtualContextManager", "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]): + """ + 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 + 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 __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 + + def __exit__( # pyright: ignore[reportImplicitOverride] + self, + exc_type: type[BaseException] | None, + 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) + else: + self.generator.send(None) + except StopIteration: + pass + except Exception as exc: + if exc is exc_value: + return False + + raise exc + else: + warnings.warn("Generator not exited", UserWarning) + return False + + +class AsyncVirtualContextManager(typing.Generic[T, P], AbstractAsyncContextManager[T]): + """ + 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 + self.generator: AsyncGenerator[T] | None = None + + def __call__(self, *args: P.args, **kwargs: P.kwargs): + self.generator = self.__wrapped__(*args, **kwargs) + return self + + 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 + + async def __aexit__( # pyright: ignore[reportImplicitOverride] + self, + exc_type: type[BaseException] | None, + 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: + await self.generator.athrow(exc_type, exc_value, traceback) + else: + await self.generator.asend(None) + except StopAsyncIteration: + pass + except Exception as exc: + if exc is exc_value: + return False + + raise exc + else: + warnings.warn("Generator not exited", UserWarning) + return False + + +@typing.overload +def virtual_context( + function: typing.Callable[P, Generator[T, None, None]], +) -> VirtualContextManager[T, P]: ... +@typing.overload +def virtual_context( + function: typing.Callable[P, AsyncGenerator[T]], +) -> AsyncVirtualContextManager[T, P]: ... +def virtual_context( + function: typing.Callable[P, Generator[T, None, None] | AsyncGenerator[T]], +) -> VirtualContextManager[T, P] | AsyncVirtualContextManager[T, P]: + """ + Define virtual context manager using decorator + + Example:: + + + @virtual_context + def file(name: str): + file_ = open(name, "r") + try: + yield file_ + finally: + file_.close() + + + with file("dontreadthis.txt") as f: + print(f.read() + + + @virtual_context + async def lock(name: str): + lock_ = locks[name] + lock_.acquire() + try: + yield + finally: + lock_.release() + + + async with lock("socket-send"): + await socket.send("wtf") + """ + if inspect.isasyncgenfunction(function): + return AsyncVirtualContextManager(function) + elif inspect.isgeneratorfunction(function): + return VirtualContextManager(function) + + raise ValueError( + f"@virtual_context expects a generator or async generator function, got {function!r}" + ) diff --git a/fundi/compat/fastapi/inject.py b/fundi/compat/fastapi/inject.py index 32a48a9..2c81d0d 100644 --- a/fundi/compat/fastapi/inject.py +++ b/fundi/compat/fastapi/inject.py @@ -16,8 +16,8 @@ from .alias import resolve_aliases from .metadata import get_metadata -from .constants import METADATA_ALIASES, METADATA_SCOPE_EXTRA from .types import DependencyOverridesProvider +from .constants import METADATA_ALIASES, METADATA_DEPENDANT, METADATA_SCOPE_EXTRA async def inject( @@ -51,7 +51,7 @@ async def inject( fastapi_params = await solve_dependencies( request=request, - dependant=metadata["__dependant__"], + dependant=metadata[METADATA_DEPENDANT], body=body, dependency_overrides_provider=dependency_overrides_provider, async_exit_stack=stack, 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 From 84dc95e9da1008e195f549273cc436c794658f5e Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 17 Aug 2025 05:24:11 +0300 Subject: [PATCH 14/17] Fix virtual context manager nesting --- fundi/virtual_context.py | 86 +++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 36 deletions(-) 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}" From 05c8b778f01ec370f464556382bbe8a5bbf810d4 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 24 Aug 2025 22:21:04 +0300 Subject: [PATCH 15/17] Rename Virtual[Async]ContextManager to Virtual[Async]ContextProvider --- fundi/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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", ] From 446d2f56f84f7eb9e33b3412e69ddb3eb4d8d973 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Wed, 3 Sep 2025 17:36:00 +0300 Subject: [PATCH 16/17] Improve scan for types --- fundi/scan.py | 60 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/fundi/scan.py b/fundi/scan.py index 39710c9..080d276 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 @@ -65,32 +81,38 @@ def scan(call: typing.Callable[..., R], caching: bool = True) -> CallableInfo[R] info = typing.cast(CallableInfo[typing.Any], getattr(call, "__fundi_info__")) return replace(info, use_cache=caching) - signature = inspect.signature(call) + if not callable(call): + raise ValueError( + f"Callable expected, got {type(call)!r}" + ) # pyright: ignore[reportUnreachable] + + truecall = call.__call__ + if isinstance(call, (FunctionType, BuiltinFunctionType, MethodType, type)): + truecall = call + + signature = inspect.signature(truecall) - generator = inspect.isgeneratorfunction(call) - async_generator = inspect.isasyncgenfunction(call) + generator = inspect.isgeneratorfunction(truecall) + async_generator = inspect.isasyncgenfunction(truecall) - context = hasattr(call, "__enter__") and hasattr(call, "__exit__") - async_context = hasattr(call, "__aenter__") and hasattr(call, "__aexit__") + context = _is_context(call) + async_context = _is_async_context(call) - async_ = inspect.iscoroutinefunction(call) or async_generator or async_context + 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: From a364aead1aa6da5ccfc3d0d7fa91fafdb20c0f23 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 17 Aug 2025 05:24:11 +0300 Subject: [PATCH 17/17] Move security_scopes and request related aliases into FastAPIs Dependant --- build/lib/fundi/__init__.py | 41 --- build/lib/fundi/compat/fastapi/__init__.py | 15 - build/lib/fundi/compat/fastapi/alias.py | 62 ----- build/lib/fundi/compat/fastapi/constants.py | 20 -- build/lib/fundi/compat/fastapi/dependant.py | 103 ------- build/lib/fundi/compat/fastapi/handler.py | 112 -------- build/lib/fundi/compat/fastapi/inject.py | 112 -------- build/lib/fundi/compat/fastapi/metadata.py | 51 ---- build/lib/fundi/compat/fastapi/route.py | 212 -------------- build/lib/fundi/compat/fastapi/router.py | 56 ---- build/lib/fundi/compat/fastapi/secured.py | 26 -- build/lib/fundi/compat/fastapi/secured.pyi | 39 --- build/lib/fundi/compat/fastapi/types.py | 8 - .../compat/fastapi/validate_request_body.py | 61 ----- build/lib/fundi/configurable.py | 62 ----- build/lib/fundi/debug.py | 73 ----- build/lib/fundi/exceptions.py | 26 -- build/lib/fundi/from_.py | 27 -- build/lib/fundi/from_.pyi | 28 -- build/lib/fundi/inject.py | 147 ---------- build/lib/fundi/inject.pyi | 99 ------- build/lib/fundi/py.typed | 0 build/lib/fundi/resolve.py | 101 ------- build/lib/fundi/scan.py | 104 ------- build/lib/fundi/types.py | 157 ----------- build/lib/fundi/util.py | 258 ------------------ build/lib/fundi/virtual_context.py | 172 ------------ fundi/compat/fastapi/__init__.py | 2 - fundi/compat/fastapi/alias.py | 62 ----- fundi/compat/fastapi/constants.py | 17 +- fundi/compat/fastapi/dependant.py | 49 ++-- fundi/compat/fastapi/inject.py | 19 +- fundi/compat/fastapi/metadata.py | 66 +++-- fundi/compat/fastapi/route.py | 29 +- fundi/compat/fastapi/secured.py | 29 +- fundi/compat/fastapi/secured.pyi | 5 + fundi/scan.py | 7 +- 37 files changed, 127 insertions(+), 2330 deletions(-) delete mode 100644 build/lib/fundi/__init__.py delete mode 100644 build/lib/fundi/compat/fastapi/__init__.py delete mode 100644 build/lib/fundi/compat/fastapi/alias.py delete mode 100644 build/lib/fundi/compat/fastapi/constants.py delete mode 100644 build/lib/fundi/compat/fastapi/dependant.py delete mode 100644 build/lib/fundi/compat/fastapi/handler.py delete mode 100644 build/lib/fundi/compat/fastapi/inject.py delete mode 100644 build/lib/fundi/compat/fastapi/metadata.py delete mode 100644 build/lib/fundi/compat/fastapi/route.py delete mode 100644 build/lib/fundi/compat/fastapi/router.py delete mode 100644 build/lib/fundi/compat/fastapi/secured.py delete mode 100644 build/lib/fundi/compat/fastapi/secured.pyi delete mode 100644 build/lib/fundi/compat/fastapi/types.py delete mode 100644 build/lib/fundi/compat/fastapi/validate_request_body.py delete mode 100644 build/lib/fundi/configurable.py delete mode 100644 build/lib/fundi/debug.py delete mode 100644 build/lib/fundi/exceptions.py delete mode 100644 build/lib/fundi/from_.py delete mode 100644 build/lib/fundi/from_.pyi delete mode 100644 build/lib/fundi/inject.py delete mode 100644 build/lib/fundi/inject.pyi delete mode 100644 build/lib/fundi/py.typed delete mode 100644 build/lib/fundi/resolve.py delete mode 100644 build/lib/fundi/scan.py delete mode 100644 build/lib/fundi/types.py delete mode 100644 build/lib/fundi/util.py delete mode 100644 build/lib/fundi/virtual_context.py delete mode 100644 fundi/compat/fastapi/alias.py diff --git a/build/lib/fundi/__init__.py b/build/lib/fundi/__init__.py deleted file mode 100644 index 3989c2d..0000000 --- a/build/lib/fundi/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -import typing as _typing - -from .scan import scan -from .from_ import from_ -from . import exceptions -from .resolve import resolve -from .debug import tree, order -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 .types import CallableInfo, TypeResolver, InjectionTrace, R, Parameter, DependencyConfiguration - - -FromType: _typing.TypeAlias = _typing.Annotated[R, TypeResolver] -"""Tell resolver to resolve parameter's value by its type, not name""" - -__all__ = [ - "scan", - "tree", - "order", - "from_", - "inject", - "resolve", - "ainject", - "Parameter", - "exceptions", - "CallableInfo", - "TypeResolver", - "is_configured", - "InjectionTrace", - "virtual_context", - "injection_trace", - "get_configuration", - "normalize_annotation", - "VirtualContextManager", - "DependencyConfiguration", - "configurable_dependency", - "AsyncVirtualContextManager", - "MutableConfigurationWarning", -] diff --git a/build/lib/fundi/compat/fastapi/__init__.py b/build/lib/fundi/compat/fastapi/__init__.py deleted file mode 100644 index 1208fda..0000000 --- a/build/lib/fundi/compat/fastapi/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from .secured import secured -from .route import FunDIRoute -from .router import FunDIRouter -from .alias import init_aliases -from .handler import get_request_handler -from .dependant import get_scope_dependant - -__all__ = [ - "secured", - "FunDIRoute", - "FunDIRouter", - "init_aliases", - "get_request_handler", - "get_scope_dependant", -] diff --git a/build/lib/fundi/compat/fastapi/alias.py b/build/lib/fundi/compat/fastapi/alias.py deleted file mode 100644 index ddf4b33..0000000 --- a/build/lib/fundi/compat/fastapi/alias.py +++ /dev/null @@ -1,62 +0,0 @@ -import typing -from collections import defaultdict - - -from starlette.responses import Response -from starlette.websockets import WebSocket -from starlette.background import BackgroundTasks -from pydantic.v1.utils import lenient_issubclass -from starlette.requests import HTTPConnection, Request - -from .metadata import get_metadata -from fundi.types import CallableInfo -from .constants import ALIAS_ALLOWED_CLASSES, METADATA_ALIASES - - -def init_aliases(ci: CallableInfo[typing.Any]) -> None: - metadata = get_metadata(ci) - - aliases: defaultdict[type, set[str]] = defaultdict(set) - metadata[METADATA_ALIASES] = aliases - - for parameter in ci.parameters: - if parameter.from_ is not None: - init_aliases(parameter.from_) - continue - - origin = typing.get_origin(parameter.annotation) or parameter.annotation - - for type_ in ALIAS_ALLOWED_CLASSES: - if not lenient_issubclass(origin, type_): - continue - - aliases[type_].add(parameter.name) - break - - -def resolve_aliases( - scope_aliases: dict[type, set[str]], - request: Request, - background_tasks: BackgroundTasks, - response: Response, -) -> dict[str, typing.Any]: - values: dict[str, typing.Any] = {} - - for type_, names in scope_aliases.items(): - if type_ is HTTPConnection: - value = request - elif type_ is Request: - value = request - elif type_ is WebSocket: - assert isinstance(request, WebSocket), "Not a websocket" - value = request - elif type_ is BackgroundTasks: - value = background_tasks - elif type_ is Response: - value = response - else: - raise RuntimeError(f"Unsupported alias type {type_!r}") - - values.update({name: value for name in names}) - - return values diff --git a/build/lib/fundi/compat/fastapi/constants.py b/build/lib/fundi/compat/fastapi/constants.py deleted file mode 100644 index bca5fe4..0000000 --- a/build/lib/fundi/compat/fastapi/constants.py +++ /dev/null @@ -1,20 +0,0 @@ -from starlette.responses import Response -from starlette.websockets import WebSocket -from starlette.background import BackgroundTasks -from starlette.requests import HTTPConnection, Request - -__all__ = ["ALIAS_ALLOWED_CLASSES"] - -ALIAS_ALLOWED_CLASSES = ( - WebSocket, - Request, - Response, - HTTPConnection, - BackgroundTasks, -) - - -METADATA_SECURITY_SCOPES = "fastapi_security_scopes" -METADATA_DEPENDANT = "fastapi_dependant" -METADATA_SCOPE_EXTRA = "scope_extra" -METADATA_ALIASES = "fastapi_aliases" diff --git a/build/lib/fundi/compat/fastapi/dependant.py b/build/lib/fundi/compat/fastapi/dependant.py deleted file mode 100644 index 53c7623..0000000 --- a/build/lib/fundi/compat/fastapi/dependant.py +++ /dev/null @@ -1,103 +0,0 @@ -import typing - -from fastapi import params -from fastapi._compat import ModelField -from fastapi.security.base import SecurityBase -from fastapi.security.oauth2 import SecurityScopes -from fastapi.dependencies.models import Dependant, SecurityRequirement -from fastapi.dependencies.utils import add_param_to_fields, analyze_param - -from fundi.types import CallableInfo - -from .metadata import get_metadata -from .constants import ALIAS_ALLOWED_CLASSES, METADATA_DEPENDANT, METADATA_SECURITY_SCOPES - -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.extend(source.security_scopes) - - -def get_scope_dependant( - ci: CallableInfo[typing.Any], - path_param_names: set[str], - path: str, - security_scopes: list[str] | None = None, -) -> Dependant: - if security_scopes is None: - security_scopes = [] - - dependant = Dependant(path=path) - get_metadata(ci).update({METADATA_DEPENDANT: dependant}) - - flat_dependant = Dependant(path=path, security_scopes=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, security_scopes) - 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) - - param_scopes: SecurityScopes | None = metadata.get(METADATA_SECURITY_SCOPES, None) - - if param_scopes: - security_scopes.extend(param_scopes.scopes) - - if isinstance(subci.call, SecurityBase): - flat_dependant.security_requirements.append( - SecurityRequirement( - subci.call, security_scopes if param_scopes is None else param_scopes.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 details.type_annotation is SecurityScopes: - dependant.security_scopes_param_name = param.name - continue - - if details.type_annotation in ALIAS_ALLOWED_CLASSES: - 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/build/lib/fundi/compat/fastapi/handler.py b/build/lib/fundi/compat/fastapi/handler.py deleted file mode 100644 index e35c1d1..0000000 --- a/build/lib/fundi/compat/fastapi/handler.py +++ /dev/null @@ -1,112 +0,0 @@ -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/build/lib/fundi/compat/fastapi/inject.py b/build/lib/fundi/compat/fastapi/inject.py deleted file mode 100644 index 32a48a9..0000000 --- a/build/lib/fundi/compat/fastapi/inject.py +++ /dev/null @@ -1,112 +0,0 @@ -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.types import CallableInfo -from fundi.inject import injection_impl -from fundi.util import call_async, call_sync - -from .alias import resolve_aliases -from .metadata import get_metadata -from .constants import METADATA_ALIASES, METADATA_SCOPE_EXTRA -from .types import DependencyOverridesProvider - - -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[typing.Callable[..., typing.Any], 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["__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, - **resolve_aliases( - metadata[METADATA_ALIASES], - request, - background_tasks, - response, - ), - } - - 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/build/lib/fundi/compat/fastapi/metadata.py b/build/lib/fundi/compat/fastapi/metadata.py deleted file mode 100644 index 2fc8e8c..0000000 --- a/build/lib/fundi/compat/fastapi/metadata.py +++ /dev/null @@ -1,51 +0,0 @@ -import typing - -from fastapi import params -from fastapi.security.oauth2 import SecurityScopes - -from fundi.compat.fastapi.constants import METADATA_SCOPE_EXTRA, METADATA_SECURITY_SCOPES -from fundi.types import CallableInfo - - -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]) -> None: - metadata = get_metadata(info) - security_scopes: SecurityScopes = metadata.setdefault( - METADATA_SECURITY_SCOPES, SecurityScopes([]) - ) - - for parameter in info.parameters: - if parameter.from_ is None: - if parameter.annotation is SecurityScopes: - metadata.setdefault(METADATA_SCOPE_EXTRA, {}).update( - {parameter.name: security_scopes} - ) - - continue - - subinfo = parameter.from_ - - param_metadata = get_metadata(subinfo) - - 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] - security_scopes.scopes[::] = list( - set(list(security.scopes) + security_scopes.scopes) - ) - param_metadata.update({METADATA_SECURITY_SCOPES: security_scopes}) - - build_metadata(subinfo) diff --git a/build/lib/fundi/compat/fastapi/route.py b/build/lib/fundi/compat/fastapi/route.py deleted file mode 100644 index a3b6f6a..0000000 --- a/build/lib/fundi/compat/fastapi/route.py +++ /dev/null @@ -1,212 +0,0 @@ -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 .alias import init_aliases -from fundi.types import CallableInfo -from .handler import get_request_handler -from .dependant import get_scope_dependant, update_dependant -from fundi.compat.fastapi.metadata import build_metadata - - -@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.Depends): - if dependency.dependency is None: - continue - - self.dependencies.append(scan(dependency.dependency)) - 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 - - build_metadata(callable_info) - init_aliases(callable_info) - - 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: - build_metadata(ci) - init_aliases(ci) - 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/build/lib/fundi/compat/fastapi/router.py b/build/lib/fundi/compat/fastapi/router.py deleted file mode 100644 index adc7414..0000000 --- a/build/lib/fundi/compat/fastapi/router.py +++ /dev/null @@ -1,56 +0,0 @@ -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/build/lib/fundi/compat/fastapi/secured.py b/build/lib/fundi/compat/fastapi/secured.py deleted file mode 100644 index 9a42266..0000000 --- a/build/lib/fundi/compat/fastapi/secured.py +++ /dev/null @@ -1,26 +0,0 @@ -import typing -from collections.abc import Sequence - -from fastapi.security.oauth2 import SecurityScopes - -from fundi.scan import scan -from .metadata import get_metadata -from fundi.types import CallableInfo -from .constants import METADATA_SECURITY_SCOPES - - -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 - """ - - info = scan(dependency, caching=caching) - metadata = get_metadata(info) - metadata.update({METADATA_SECURITY_SCOPES: SecurityScopes(list(scopes))}) - return info diff --git a/build/lib/fundi/compat/fastapi/secured.pyi b/build/lib/fundi/compat/fastapi/secured.pyi deleted file mode 100644 index 8e52c06..0000000 --- a/build/lib/fundi/compat/fastapi/secured.pyi +++ /dev/null @@ -1,39 +0,0 @@ -import typing -from typing import overload -from collections.abc import Generator, AsyncGenerator, Awaitable, Sequence -from contextlib import AbstractAsyncContextManager, AbstractContextManager - -R = typing.TypeVar("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/build/lib/fundi/compat/fastapi/types.py b/build/lib/fundi/compat/fastapi/types.py deleted file mode 100644 index 4426b94..0000000 --- a/build/lib/fundi/compat/fastapi/types.py +++ /dev/null @@ -1,8 +0,0 @@ -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/build/lib/fundi/compat/fastapi/validate_request_body.py b/build/lib/fundi/compat/fastapi/validate_request_body.py deleted file mode 100644 index aa7f268..0000000 --- a/build/lib/fundi/compat/fastapi/validate_request_body.py +++ /dev/null @@ -1,61 +0,0 @@ -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/build/lib/fundi/configurable.py b/build/lib/fundi/configurable.py deleted file mode 100644 index 099aba1..0000000 --- a/build/lib/fundi/configurable.py +++ /dev/null @@ -1,62 +0,0 @@ -import typing -import warnings -import functools - -from fundi.scan import scan -from fundi.util import callable_str -from fundi.types import R, DependencyConfiguration - -P = typing.ParamSpec("P") - - -class MutableConfigurationWarning(UserWarning): - pass - - -def configurable_dependency(configurator: typing.Callable[P, R]) -> typing.Callable[P, R]: - """ - Create dependency configurator that caches configured dependencies. - This helps FunDI cache resolver understand that dependency already executed, if it was. - - Note: Calls with mutable arguments will not be stored in cache and warning would be shown - - :param configurator: Original dependency configurator - :return: cache aware dependency configurator - """ - dependencies: dict[frozenset[tuple[str, typing.Any]], R] = {} - info = scan(configurator) - - if info.async_: - raise ValueError("Dependency configurator should not be asynchronous") - - @functools.wraps(configurator) - def cached_dependency_generator(*args: typing.Any, **kwargs: typing.Any) -> R: - use_cache = True - values = info.build_values(*args, **kwargs) - key: frozenset[tuple[str, typing.Any]] | None = None - - try: - key = frozenset(values.items()) - - if key in dependencies: - return dependencies[key] - except TypeError: - warnings.warn( - f"Can't cache dependency created via {callable_str(configurator)}: configured with unhashable arguments", - MutableConfigurationWarning, - ) - use_cache = False - - dependency = configurator(*args, **kwargs) - setattr( - dependency, - "__fundi_configuration__", - DependencyConfiguration(configurator=info, values=values), - ) - - if use_cache and key is not None: - dependencies[key] = dependency - - return dependency - - return cached_dependency_generator diff --git a/build/lib/fundi/debug.py b/build/lib/fundi/debug.py deleted file mode 100644 index d66c795..0000000 --- a/build/lib/fundi/debug.py +++ /dev/null @@ -1,73 +0,0 @@ -import typing -import collections.abc - -from fundi.types import CallableInfo -from fundi.inject import injection_impl - - -def tree( - scope: collections.abc.Mapping[str, typing.Any], - info: CallableInfo[typing.Any], - cache: ( - collections.abc.MutableMapping[ - typing.Callable[..., typing.Any], collections.abc.Mapping[str, typing.Any] - ] - | None - ) = None, -) -> collections.abc.Mapping[str, typing.Any]: - """ - Get tree of dependencies of callable. - - :param scope: container with contextual values - :param info: callable information - :param cache: tree generation cache - :return: Tree of dependencies - """ - if cache is None: - cache = {} - - gen = injection_impl(scope, info, cache, None) - - value = None - - while True: - inner_scope, inner_info, more = gen.send(value) - if not more: - return {"call": inner_info.call, "values": inner_scope} - - value = tree(inner_scope, inner_info, cache) - - -def order( - scope: collections.abc.Mapping[str, typing.Any], - info: CallableInfo[typing.Any], - cache: ( - collections.abc.MutableMapping[ - typing.Callable[..., typing.Any], list[typing.Callable[..., typing.Any]] - ] - | None - ) = None, -) -> list[typing.Callable[..., typing.Any]]: - """ - Get resolving order of callable dependencies. - - :param info: callable information - :param scope: container with contextual values - :param cache: solvation cache - :return: order of dependencies - """ - if cache is None: - cache = {} - - gen = injection_impl(scope, info, cache, None) - - order_: list[typing.Callable[..., typing.Any]] = [] - - value = None - while True: - inner_scope, inner_info, more = gen.send(value) - if not more: - return order_ - - order_.extend(order(inner_scope, inner_info, cache)) - order_.append(inner_info.call) diff --git a/build/lib/fundi/exceptions.py b/build/lib/fundi/exceptions.py deleted file mode 100644 index 3aac7fb..0000000 --- a/build/lib/fundi/exceptions.py +++ /dev/null @@ -1,26 +0,0 @@ -import typing -from types import FunctionType -from collections.abc import AsyncGenerator, Generator - -from fundi.util import callable_str -from fundi.types import CallableInfo - - -class ScopeValueNotFoundError(ValueError): - def __init__(self, parameter: str, info: CallableInfo[typing.Any]): - super().__init__( - f'Cannot resolve "{parameter}" for {callable_str(info.call)} - Scope does not contain required value' - ) - self.parameter: str = parameter - self.info: CallableInfo[typing.Any] = info - - -class GeneratorExitedTooEarly(Exception): - def __init__( - self, - function: FunctionType, - generator: AsyncGenerator[typing.Any] | Generator[typing.Any, None, None], - ): - super().__init__(f"Generator exited too early") - self.function: FunctionType = function - self.generator: AsyncGenerator[typing.Any] | Generator[typing.Any, None, None] = generator diff --git a/build/lib/fundi/from_.py b/build/lib/fundi/from_.py deleted file mode 100644 index 3a5ca68..0000000 --- a/build/lib/fundi/from_.py +++ /dev/null @@ -1,27 +0,0 @@ -import typing -from contextlib import AbstractAsyncContextManager, AbstractContextManager - -from fundi.scan import scan -from fundi.types import CallableInfo, TypeResolver - - -def from_( - dependency: type | typing.Callable[..., typing.Any], caching: bool = True -) -> TypeResolver | CallableInfo[typing.Any]: - """ - Use callable or type as dependency for parameter of function - - if dependency parameter is callable the ``fundi.scan.scan`` is used - - if dependency parameter is type the ``fundi.types.TypeResolver`` is returned (unless that type is a subclass of AbstractContextManager or AbstractAsyncContextManager) - - :param dependency: function dependency - :param caching: Whether to use cached result of this callable or not - :return: callable information - """ - if isinstance(dependency, type) and not issubclass( - dependency, (AbstractContextManager, AbstractAsyncContextManager) - ): - return TypeResolver(dependency) - - return scan(dependency, caching=caching) diff --git a/build/lib/fundi/from_.pyi b/build/lib/fundi/from_.pyi deleted file mode 100644 index d65bf11..0000000 --- a/build/lib/fundi/from_.pyi +++ /dev/null @@ -1,28 +0,0 @@ -import typing -from typing import overload -from collections.abc import Generator, AsyncGenerator, Awaitable -from contextlib import AbstractAsyncContextManager, AbstractContextManager - -T = typing.TypeVar("T", bound=type) -R = typing.TypeVar("R") - -@overload -def from_( - dependency: typing.Callable[..., AbstractContextManager[R]], caching: bool = True -) -> R: ... -@overload -def from_( - dependency: typing.Callable[..., AbstractAsyncContextManager[R]], caching: bool = True -) -> R: ... -@overload -def from_(dependency: T, caching: bool = True) -> T: ... -@overload -def from_( - dependency: typing.Callable[..., Generator[R, None, None]], caching: bool = True -) -> R: ... -@overload -def from_(dependency: typing.Callable[..., AsyncGenerator[R, None]], caching: bool = True) -> R: ... -@overload -def from_(dependency: typing.Callable[..., Awaitable[R]], caching: bool = True) -> R: ... -@overload -def from_(dependency: typing.Callable[..., R], caching: bool = True) -> R: ... diff --git a/build/lib/fundi/inject.py b/build/lib/fundi/inject.py deleted file mode 100644 index 0e90d9e..0000000 --- a/build/lib/fundi/inject.py +++ /dev/null @@ -1,147 +0,0 @@ -import typing -import contextlib -import collections.abc - -from fundi.resolve import resolve -from fundi.types import CallableInfo -from fundi.util import call_sync, call_async, add_injection_trace - - -def injection_impl( - scope: collections.abc.Mapping[str, typing.Any], - info: CallableInfo[typing.Any], - cache: collections.abc.MutableMapping[typing.Callable[..., typing.Any], typing.Any], - override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None, -) -> collections.abc.Generator[ - tuple[collections.abc.Mapping[str, typing.Any], CallableInfo[typing.Any], bool], - typing.Any, - None, -]: - """ - Injection brain. - - Coordinates dependency resolution for a given `CallableInfo`. For each parameter: - - - If the parameter has a pre-resolved value (from scope, override, or cache) — uses it. - - If the parameter requires another dependency to be resolved: - - Yields `(scope_with_context, dependency_info, True)` to request the caller to inject it. - - Once the value is received — caches it if allowed. - - After all parameters are resolved, yields: - `(resolved_values_dict, top_level_callable_info, False)` - - If any error occurs during resolution, attaches injection trace and re-raises the exception. - """ - - values: dict[str, typing.Any] = {} - try: - for result in resolve(scope, info, cache, override): - name = result.parameter.name - value = result.value - - if not result.resolved: - dependency = result.dependency - assert dependency is not None - - value = yield {**scope, "__fundi_parameter__": result.parameter}, dependency, True - - if dependency.use_cache: - cache[dependency.call] = value - - values[name] = value - - yield values, info, False - - except Exception as exc: - add_injection_trace(exc, info, values) - raise exc - - -def inject( - scope: collections.abc.Mapping[str, typing.Any], - info: CallableInfo[typing.Any], - stack: contextlib.ExitStack, - cache: ( - collections.abc.MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None - ) = None, - override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> typing.Any: - """ - Synchronously 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 info.async_: - raise RuntimeError("Cannot process async functions in synchronous injection") - - if cache is None: - cache = {} - - 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 = inject(inner_scope, inner_info, stack, cache, override) - continue - - return call_sync(stack, inner_info, inner_scope) - except Exception as exc: - with contextlib.suppress(StopIteration): - gen.throw(type(exc), exc, exc.__traceback__) - - raise - - -async def ainject( - scope: collections.abc.Mapping[str, typing.Any], - info: CallableInfo[typing.Any], - stack: contextlib.AsyncExitStack, - cache: ( - collections.abc.MutableMapping[typing.Callable[..., typing.Any], 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 = {} - - 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 ainject(inner_scope, inner_info, stack, 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/build/lib/fundi/inject.pyi b/build/lib/fundi/inject.pyi deleted file mode 100644 index 47ad01b..0000000 --- a/build/lib/fundi/inject.pyi +++ /dev/null @@ -1,99 +0,0 @@ -import typing -from typing import overload -from collections.abc import Generator, AsyncGenerator, Mapping, MutableMapping, Awaitable - -from fundi.types import CallableInfo - -from contextlib import ( - AsyncExitStack, - AbstractContextManager, - ExitStack as SyncExitStack, - AbstractAsyncContextManager, -) - -R = typing.TypeVar("R") - -ExitStack = AsyncExitStack | SyncExitStack - -def injection_impl( - scope: Mapping[str, typing.Any], - info: CallableInfo[typing.Any], - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any], - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None, -) -> Generator[ - tuple[Mapping[str, typing.Any], CallableInfo[typing.Any], bool], - typing.Any, - None, -]: ... -@overload -def inject( - scope: Mapping[str, typing.Any], - info: CallableInfo[Generator[R, None, None]], - stack: ExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -def inject( - scope: Mapping[str, typing.Any], - info: CallableInfo[AbstractContextManager[R]], - stack: ExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -def inject( - scope: Mapping[str, typing.Any], - info: CallableInfo[R], - stack: ExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -async def ainject( - scope: Mapping[str, typing.Any], - info: CallableInfo[Generator[R, None, None]], - stack: AsyncExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -async def ainject( - scope: Mapping[str, typing.Any], - info: CallableInfo[AsyncGenerator[R, None]], - stack: AsyncExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -async def ainject( - scope: Mapping[str, typing.Any], - info: CallableInfo[Awaitable[R]], - stack: AsyncExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -async def ainject( - scope: Mapping[str, typing.Any], - info: CallableInfo[AbstractAsyncContextManager[R]], - stack: AsyncExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -async def ainject( - scope: Mapping[str, typing.Any], - info: CallableInfo[AbstractContextManager[R]], - stack: AsyncExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... -@overload -async def ainject( - scope: Mapping[str, typing.Any], - info: CallableInfo[R], - stack: AsyncExitStack, - cache: MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None = None, - override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> R: ... diff --git a/build/lib/fundi/py.typed b/build/lib/fundi/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/build/lib/fundi/resolve.py b/build/lib/fundi/resolve.py deleted file mode 100644 index 6b096f2..0000000 --- a/build/lib/fundi/resolve.py +++ /dev/null @@ -1,101 +0,0 @@ -import typing -import collections.abc - -from fundi.util import normalize_annotation -from fundi.types import CallableInfo, ParameterResult, Parameter - - -def resolve_by_dependency( - param: Parameter, - cache: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any], - override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any], -) -> ParameterResult: - dependency = param.from_ - - assert dependency is not None - - value = override.get(dependency.call) - if value is not None: - if isinstance(value, CallableInfo): - return ParameterResult( - param, None, typing.cast(CallableInfo[typing.Any], value), resolved=False - ) - - return ParameterResult(param, value, dependency, resolved=True) - - if dependency.use_cache and dependency.call in cache: - return ParameterResult(param, cache[dependency.call], dependency, resolved=True) - - return ParameterResult(param, None, dependency, resolved=False) - - -def resolve_by_type( - scope: collections.abc.Mapping[str, typing.Any], param: Parameter -) -> ParameterResult: - type_options = normalize_annotation(param.annotation) - - for value in scope.values(): - if not isinstance(value, type_options): - continue - - return ParameterResult(param, value, None, resolved=True) - - return ParameterResult(param, None, None, resolved=False) - - -def resolve( - scope: collections.abc.Mapping[str, typing.Any], - info: CallableInfo[typing.Any], - cache: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any], - override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, -) -> collections.abc.Generator[ParameterResult, None, None]: - """ - Try to resolve values from cache or scope for callable parameters - - Recommended use case:: - - values = {} - cache = {} - for result in resolve(scope, info, cache): - value = result.value - name = result.parameter_name - - if not result.resolved: - value = inject(scope, info, stack, cache) - cache[name] = value - - values[name] = value - - - :param scope: container with contextual values - :param info: callable information - :param cache: solvation cache(modify it if necessary while resolving) - :param override: override dependencies - :return: generator with solvation results - """ - from fundi.exceptions import ScopeValueNotFoundError - - if override is None: - override = {} - - for parameter in info.parameters: - if parameter.from_: - yield resolve_by_dependency(parameter, cache, override) - continue - - if parameter.resolve_by_type: - result = resolve_by_type(scope, parameter) - - if result.resolved: - yield result - continue - - elif parameter.name in scope: - yield ParameterResult(parameter, scope[parameter.name], None, resolved=True) - continue - - if parameter.has_default: - yield ParameterResult(parameter, parameter.default, None, resolved=True) - continue - - raise ScopeValueNotFoundError(parameter.name, info) diff --git a/build/lib/fundi/scan.py b/build/lib/fundi/scan.py deleted file mode 100644 index d37219d..0000000 --- a/build/lib/fundi/scan.py +++ /dev/null @@ -1,104 +0,0 @@ -from dataclasses import replace -from types import FunctionType -import typing -import inspect - -from fundi.util import is_configured, get_configuration -from fundi.types import R, CallableInfo, Parameter, TypeResolver - - -def _transform_parameter(parameter: inspect.Parameter) -> Parameter: - positional_varying = parameter.kind == inspect.Parameter.VAR_POSITIONAL - positional_only = parameter.kind == inspect.Parameter.POSITIONAL_ONLY - keyword_varying = parameter.kind == inspect.Parameter.VAR_KEYWORD - keyword_only = parameter.kind == inspect.Parameter.KEYWORD_ONLY - - if isinstance(parameter.default, CallableInfo): - return Parameter( - parameter.name, - parameter.annotation, - from_=typing.cast(CallableInfo[typing.Any], parameter.default), - positional_varying=positional_varying, - positional_only=positional_only, - keyword_varying=keyword_varying, - keyword_only=keyword_only, - ) - - has_default = parameter.default is not inspect.Parameter.empty - resolve_by_type = False - - annotation = parameter.annotation - if isinstance(annotation, TypeResolver): - annotation = annotation.annotation - resolve_by_type = True - - elif typing.get_origin(annotation) is typing.Annotated: - args = typing.get_args(annotation) - - if args[1] is TypeResolver: - resolve_by_type = True - - return Parameter( - parameter.name, - annotation, - from_=None, - default=parameter.default if has_default else None, - has_default=has_default, - resolve_by_type=resolve_by_type, - positional_varying=positional_varying, - positional_only=positional_only, - keyword_varying=keyword_varying, - keyword_only=keyword_only, - ) - - -def scan(call: typing.Callable[..., R], caching: bool = True) -> CallableInfo[R]: - """ - Get callable information - - :param call: callable to get information from - :param caching: whether to use cached result of this callable or not - - :return: callable information - """ - - if hasattr(call, "__fundi_info__"): - info = typing.cast(CallableInfo[typing.Any], getattr(call, "__fundi_info__")) - return replace(info, use_cache=caching) - - if isinstance(call, (FunctionType, type)): - truecall = call - else: - truecall = call.__call__ - - signature = inspect.signature(truecall) - - generator = inspect.isgeneratorfunction(truecall) - async_generator = inspect.isasyncgenfunction(truecall) - - context = hasattr(truecall, "__enter__") and hasattr(truecall, "__exit__") - async_context = hasattr(truecall, "__aenter__") and hasattr(truecall, "__aexit__") - - 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 = 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: - setattr(call, "__fundi_info__", info) - except (AttributeError, TypeError): - pass - - return info diff --git a/build/lib/fundi/types.py b/build/lib/fundi/types.py deleted file mode 100644 index 8dff756..0000000 --- a/build/lib/fundi/types.py +++ /dev/null @@ -1,157 +0,0 @@ -import typing -import collections -import collections.abc -from dataclasses import dataclass, field - -__all__ = [ - "R", - "Parameter", - "TypeResolver", - "CallableInfo", - "InjectionTrace", - "ParameterResult", - "DependencyConfiguration", -] - -R = typing.TypeVar("R") - - -@dataclass -class TypeResolver: - """ - Mark that tells ``fundi.scan.scan`` to set ``Parameter.resolve_by_type`` to True. - - This changes logic of ``fundi.resolve.resolve``, so it uses ``Parameter.annotation`` - to find value in scope instead of ``Parameter.name`` - """ - - annotation: type - - -@dataclass -class Parameter: - name: str - annotation: typing.Any - from_: "CallableInfo[typing.Any] | None" - default: typing.Any = None - has_default: bool = False - resolve_by_type: bool = False - positional_only: bool = False - keyword_only: bool = False - positional_varying: bool = False - keyword_varying: bool = False - - -@dataclass -class CallableInfo(typing.Generic[R]): - call: typing.Callable[..., R] - use_cache: bool - async_: bool - context: bool - generator: bool - parameters: list[Parameter] - return_annotation: typing.Any - configuration: "DependencyConfiguration | None" - named_parameters: dict[str, Parameter] = field(init=False) - - def __post_init__(self): - self.named_parameters = {p.name: p for p in self.parameters} - - def _build_values( - self, - args: tuple[typing.Any, ...], - kwargs: collections.abc.MutableMapping[str, typing.Any], - partial: bool = False, - ) -> dict[str, typing.Any]: - values: dict[str, typing.Any] = {} - - args_amount = len(args) - - ix = 0 - for parameter in self.parameters: - name = parameter.name - - if parameter.keyword_varying: - values[name] = kwargs - continue - - if name in kwargs: - values[name] = kwargs.pop(name) - continue - - if parameter.positional_varying: - values[name] = args[ix:] - ix = args_amount - continue - - if ix < args_amount: - values[name] = args[ix] - ix += 1 - continue - - if parameter.has_default: - values[name] = parameter.default - continue - - if not partial: - raise ValueError(f'Argument for parameter "{parameter.name}" not found') - - return values - - def build_values( - self, *args: typing.Any, **kwargs: typing.Any - ) -> collections.abc.Mapping[str, typing.Any]: - return self._build_values(args, kwargs) - - def partial_build_values( - self, *args: typing.Any, **kwargs: typing.Any - ) -> collections.abc.Mapping[str, typing.Any]: - return self._build_values(args, kwargs, partial=True) - - def build_arguments( - self, values: collections.abc.Mapping[str, typing.Any] - ) -> tuple[tuple[typing.Any, ...], dict[str, typing.Any]]: - positional: tuple[typing.Any, ...] = () - keyword: dict[str, typing.Any] = {} - - for parameter in self.parameters: - name = parameter.name - - if name not in values: - raise ValueError(f'Value for "{name}" parameter not found') - - value = values[name] - - if parameter.positional_only: - positional += (value,) - elif parameter.positional_varying: - positional += value - elif parameter.keyword_only: - keyword[name] = value - elif parameter.keyword_varying: - keyword.update(value) - else: - positional += (value,) - - return positional, keyword - - -@dataclass -class ParameterResult: - parameter: Parameter - value: typing.Any | None - dependency: CallableInfo[typing.Any] | None - resolved: bool - - -@dataclass -class InjectionTrace: - info: CallableInfo[typing.Any] - values: collections.abc.Mapping[str, typing.Any] - origin: "InjectionTrace | None" = None - - -@dataclass -class DependencyConfiguration: - configurator: CallableInfo[typing.Any] - values: collections.abc.Mapping[str, typing.Any] diff --git a/build/lib/fundi/util.py b/build/lib/fundi/util.py deleted file mode 100644 index c5e3df1..0000000 --- a/build/lib/fundi/util.py +++ /dev/null @@ -1,258 +0,0 @@ -import types -import typing -import inspect -import warnings -import contextlib -import collections.abc -from types import TracebackType - -from fundi.types import CallableInfo, InjectionTrace, DependencyConfiguration - - -__all__ = [ - "call_sync", - "call_async", - "callable_str", - "is_configured", - "injection_trace", - "get_configuration", - "add_injection_trace", - "normalize_annotation", -] - - -def callable_str(call: typing.Callable[..., typing.Any]) -> str: - if hasattr(call, "__qualname__"): - name = call.__qualname__ - elif hasattr(call, "__name__"): - name = call.__name__ - else: - name = str(call) - - module = inspect.getmodule(call) - - module_name = "" if module is None else module.__name__ - - return f"<{name} from {module_name}>" - - -def add_injection_trace( - exception: Exception, - info: CallableInfo[typing.Any], - values: collections.abc.Mapping[str, typing.Any], -) -> None: - setattr( - exception, - "__fundi_injection_trace__", - InjectionTrace(info, values, getattr(exception, "__fundi_injection_trace__", None)), - ) - - -def call_sync( - stack: contextlib.ExitStack | contextlib.AsyncExitStack, - info: CallableInfo[typing.Any], - values: collections.abc.Mapping[str, typing.Any], -) -> typing.Any: - """ - Synchronously call dependency callable. - - :param stack: exit stack to properly handle generator dependencies - :param info: callable information - :param values: callable arguments - :return: callable result - """ - args, kwargs = info.build_arguments(values) - value = info.call(*args, **kwargs) - - if info.context: - manager: contextlib.AbstractContextManager[typing.Any] = value - value = manager.__enter__() - - def exit_context( - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - tb: TracebackType | None, - ) -> bool: - try: - manager.__exit__(exc_type, exc_value, tb) - except Exception as e: - # Do not include re-raise of this exception in traceback to make it cleaner - if e is exc_value: - return False - - raise - - # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS - return exc_type is None - - stack.push(exit_context) - - if info.generator: - generator: collections.abc.Generator[typing.Any, None, None] = value - value = next(generator) - - def close_generator( - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - tb: TracebackType | None, - ) -> bool: - try: - if exc_type is not None: - generator.throw(exc_type, exc_value, tb) - else: - next(generator) - except StopIteration: - # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS - return exc_type is None - except Exception as e: - # Do not include re-raise of this exception in traceback to make it cleaner - if e is exc_value: - return False - - raise - - warnings.warn("Generator not exited", UserWarning) - - # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS - return exc_type is None - - stack.push(close_generator) - - return value - - -async def call_async( - stack: contextlib.AsyncExitStack, - info: CallableInfo[typing.Any], - values: collections.abc.Mapping[str, typing.Any], -) -> typing.Any: - """ - Asynchronously call dependency callable. - - :param stack: exit stack to properly handle generator dependencies - :param info: callable information - :param values: callable arguments - :return: callable result - """ - args, kwargs = info.build_arguments(values) - - value = info.call(*args, **kwargs) - - if info.context: - manager: contextlib.AbstractAsyncContextManager[typing.Any] = value - value = await manager.__aenter__() - - async def exit_context( - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - tb: TracebackType | None, - ) -> bool: - try: - await manager.__aexit__(exc_type, exc_value, tb) - except Exception as e: - # Do not include re-raise of this exception in traceback to make it cleaner - if e is exc_value: - return False - - raise - - # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS - return exc_type is None - - stack.push_async_exit(exit_context) - - elif info.generator: - generator: collections.abc.AsyncGenerator[typing.Any] = value - value = await anext(generator) - - async def close_generator( - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - tb: TracebackType | None, - ) -> bool: - try: - if exc_type is not None: - await generator.athrow(exc_type, exc_value, tb) - else: - await anext(generator) - except StopAsyncIteration: - # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS - return exc_type is None - except Exception as e: - # Do not include re-raise of this exception in traceback to make it cleaner - if e is exc_value: - return False - - raise - - warnings.warn("Generator not exited", UserWarning) - - # DO NOT ALLOW LIFESPAN DEPENDENCIES TO IGNORE EXCEPTIONS - return exc_type is None - - stack.push_async_exit(close_generator) - - else: - value = await value - - return value - - -def injection_trace(exception: Exception) -> InjectionTrace: - """ - Get injection trace from exception - - :param exception: exception to get injection trace from - :return: injection trace - """ - if not hasattr(exception, "__fundi_injection_trace__"): - raise ValueError(f"Exception {exception} does not contain injection trace") - - return typing.cast(InjectionTrace, getattr(exception, "__fundi_injection_trace__")) - - -def is_configured(call: typing.Callable[..., typing.Any]) -> bool: - """ - Get whether callable is configured via @configurable_dependency - - :param call: callable to check - :return: Is this callable configured - """ - return hasattr(call, "__fundi_configuration__") - - -def get_configuration(call: typing.Callable[..., typing.Any]) -> DependencyConfiguration: - """ - Get dependency configuration. Can be useful in third-party tools that needs to know configuration - - :param call: callable to get configuration from - :return: dependency configuration - """ - if not is_configured(call): - raise ValueError(f"Callable {call} is not configured via @configurable_dependency") - - configuration: DependencyConfiguration = getattr(call, "__fundi_configuration__") - return configuration - - -def normalize_annotation(annotation: typing.Any) -> tuple[type[typing.Any], ...]: - """ - Normalize type annotation to make it easily work with - """ - type_options: tuple[type, ...] = (annotation,) - - origin = typing.get_origin(annotation) - args = typing.get_args(annotation) - - if origin is typing.Annotated: - annotation = args[0] - type_options = (annotation,) - origin = typing.get_origin(annotation) - args = typing.get_args(annotation) - - if origin is types.UnionType: - type_options = tuple(t for t in args if t is not types.NoneType) - elif origin is not None: - type_options = (origin,) - - return type_options diff --git a/build/lib/fundi/virtual_context.py b/build/lib/fundi/virtual_context.py deleted file mode 100644 index bc8bae9..0000000 --- a/build/lib/fundi/virtual_context.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Virtual context managers are created to replace contextlib.contextmanager -and contextlib.asynccontextmanager decorators. -They are fully typed and distinguishable by FunDIs `scan(...)` function -""" - -import types -import typing -import inspect -import warnings -from dataclasses import replace -from collections.abc import Generator, AsyncGenerator -from contextlib import AbstractAsyncContextManager, AbstractContextManager - -from .scan import scan -from .types import CallableInfo -from .exceptions import GeneratorExitedTooEarly - -__all__ = ["VirtualContextManager", "AsyncVirtualContextManager", "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]): - """ - 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 - 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 __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 - - def __exit__( # pyright: ignore[reportImplicitOverride] - self, - exc_type: type[BaseException] | None, - 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) - else: - self.generator.send(None) - except StopIteration: - pass - except Exception as exc: - if exc is exc_value: - return False - - raise exc - else: - warnings.warn("Generator not exited", UserWarning) - return False - - -class AsyncVirtualContextManager(typing.Generic[T, P], AbstractAsyncContextManager[T]): - """ - 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 - self.generator: AsyncGenerator[T] | None = None - - def __call__(self, *args: P.args, **kwargs: P.kwargs): - self.generator = self.__wrapped__(*args, **kwargs) - return self - - 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 - - async def __aexit__( # pyright: ignore[reportImplicitOverride] - self, - exc_type: type[BaseException] | None, - 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: - await self.generator.athrow(exc_type, exc_value, traceback) - else: - await self.generator.asend(None) - except StopAsyncIteration: - pass - except Exception as exc: - if exc is exc_value: - return False - - raise exc - else: - warnings.warn("Generator not exited", UserWarning) - return False - - -@typing.overload -def virtual_context( - function: typing.Callable[P, Generator[T, None, None]], -) -> VirtualContextManager[T, P]: ... -@typing.overload -def virtual_context( - function: typing.Callable[P, AsyncGenerator[T]], -) -> AsyncVirtualContextManager[T, P]: ... -def virtual_context( - function: typing.Callable[P, Generator[T, None, None] | AsyncGenerator[T]], -) -> VirtualContextManager[T, P] | AsyncVirtualContextManager[T, P]: - """ - Define virtual context manager using decorator - - Example:: - - - @virtual_context - def file(name: str): - file_ = open(name, "r") - try: - yield file_ - finally: - file_.close() - - - with file("dontreadthis.txt") as f: - print(f.read() - - - @virtual_context - async def lock(name: str): - lock_ = locks[name] - lock_.acquire() - try: - yield - finally: - lock_.release() - - - async with lock("socket-send"): - await socket.send("wtf") - """ - if inspect.isasyncgenfunction(function): - return AsyncVirtualContextManager(function) - elif inspect.isgeneratorfunction(function): - return VirtualContextManager(function) - - raise ValueError( - f"@virtual_context expects a generator or async generator function, got {function!r}" - ) diff --git a/fundi/compat/fastapi/__init__.py b/fundi/compat/fastapi/__init__.py index 1208fda..26e84d9 100644 --- a/fundi/compat/fastapi/__init__.py +++ b/fundi/compat/fastapi/__init__.py @@ -1,7 +1,6 @@ from .secured import secured from .route import FunDIRoute from .router import FunDIRouter -from .alias import init_aliases from .handler import get_request_handler from .dependant import get_scope_dependant @@ -9,7 +8,6 @@ "secured", "FunDIRoute", "FunDIRouter", - "init_aliases", "get_request_handler", "get_scope_dependant", ] diff --git a/fundi/compat/fastapi/alias.py b/fundi/compat/fastapi/alias.py deleted file mode 100644 index ddf4b33..0000000 --- a/fundi/compat/fastapi/alias.py +++ /dev/null @@ -1,62 +0,0 @@ -import typing -from collections import defaultdict - - -from starlette.responses import Response -from starlette.websockets import WebSocket -from starlette.background import BackgroundTasks -from pydantic.v1.utils import lenient_issubclass -from starlette.requests import HTTPConnection, Request - -from .metadata import get_metadata -from fundi.types import CallableInfo -from .constants import ALIAS_ALLOWED_CLASSES, METADATA_ALIASES - - -def init_aliases(ci: CallableInfo[typing.Any]) -> None: - metadata = get_metadata(ci) - - aliases: defaultdict[type, set[str]] = defaultdict(set) - metadata[METADATA_ALIASES] = aliases - - for parameter in ci.parameters: - if parameter.from_ is not None: - init_aliases(parameter.from_) - continue - - origin = typing.get_origin(parameter.annotation) or parameter.annotation - - for type_ in ALIAS_ALLOWED_CLASSES: - if not lenient_issubclass(origin, type_): - continue - - aliases[type_].add(parameter.name) - break - - -def resolve_aliases( - scope_aliases: dict[type, set[str]], - request: Request, - background_tasks: BackgroundTasks, - response: Response, -) -> dict[str, typing.Any]: - values: dict[str, typing.Any] = {} - - for type_, names in scope_aliases.items(): - if type_ is HTTPConnection: - value = request - elif type_ is Request: - value = request - elif type_ is WebSocket: - assert isinstance(request, WebSocket), "Not a websocket" - value = request - elif type_ is BackgroundTasks: - value = background_tasks - elif type_ is Response: - value = response - else: - raise RuntimeError(f"Unsupported alias type {type_!r}") - - values.update({name: value for name in names}) - - return values diff --git a/fundi/compat/fastapi/constants.py b/fundi/compat/fastapi/constants.py index bca5fe4..b8aecdb 100644 --- a/fundi/compat/fastapi/constants.py +++ b/fundi/compat/fastapi/constants.py @@ -1,20 +1,5 @@ -from starlette.responses import Response -from starlette.websockets import WebSocket -from starlette.background import BackgroundTasks -from starlette.requests import HTTPConnection, Request - -__all__ = ["ALIAS_ALLOWED_CLASSES"] - -ALIAS_ALLOWED_CLASSES = ( - WebSocket, - Request, - Response, - HTTPConnection, - BackgroundTasks, -) - +__all__ = ["METADATA_SECURITY_SCOPES", "METADATA_DEPENDANT", "METADATA_SCOPE_EXTRA"] METADATA_SECURITY_SCOPES = "fastapi_security_scopes" METADATA_DEPENDANT = "fastapi_dependant" METADATA_SCOPE_EXTRA = "scope_extra" -METADATA_ALIASES = "fastapi_aliases" diff --git a/fundi/compat/fastapi/dependant.py b/fundi/compat/fastapi/dependant.py index 53c7623..4f76f04 100644 --- a/fundi/compat/fastapi/dependant.py +++ b/fundi/compat/fastapi/dependant.py @@ -3,14 +3,19 @@ from fastapi import params from fastapi._compat import ModelField from fastapi.security.base import SecurityBase -from fastapi.security.oauth2 import SecurityScopes from fastapi.dependencies.models import Dependant, SecurityRequirement -from fastapi.dependencies.utils import add_param_to_fields, analyze_param +from fundi.util import callable_str from fundi.types import CallableInfo -from .metadata import get_metadata -from .constants import ALIAS_ALLOWED_CLASSES, METADATA_DEPENDANT, METADATA_SECURITY_SCOPES +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) @@ -36,44 +41,40 @@ def update_dependant(source: Dependant, target: Dependant): if target.security_scopes is None: target.security_scopes = [] - target.security_scopes.extend(source.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, - security_scopes: list[str] | None = None, ) -> Dependant: - if security_scopes is None: - security_scopes = [] + build_metadata(ci) dependant = Dependant(path=path) - get_metadata(ci).update({METADATA_DEPENDANT: dependant}) + 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=security_scopes) + 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, security_scopes) + 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) - param_scopes: SecurityScopes | None = metadata.get(METADATA_SECURITY_SCOPES, None) - - if param_scopes: - security_scopes.extend(param_scopes.scopes) - if isinstance(subci.call, SecurityBase): flat_dependant.security_requirements.append( - SecurityRequirement( - subci.call, security_scopes if param_scopes is None else param_scopes.scopes - ) + SecurityRequirement(subci.call, metadata[METADATA_SECURITY_SCOPES]) ) continue @@ -85,11 +86,13 @@ def get_scope_dependant( is_path_param=param.name in path_param_names, ) - if details.type_annotation is SecurityScopes: - dependant.security_scopes_param_name = param.name - continue + 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)}' - if details.type_annotation in ALIAS_ALLOWED_CLASSES: continue assert details.field is not None diff --git a/fundi/compat/fastapi/inject.py b/fundi/compat/fastapi/inject.py index 2c81d0d..37c3d15 100644 --- a/fundi/compat/fastapi/inject.py +++ b/fundi/compat/fastapi/inject.py @@ -10,14 +10,13 @@ from fastapi.exceptions import RequestValidationError from fastapi.dependencies.utils import solve_dependencies -from fundi.types import CallableInfo from fundi.inject import injection_impl from fundi.util import call_async, call_sync +from fundi.types import CacheKey, CallableInfo -from .alias import resolve_aliases from .metadata import get_metadata from .types import DependencyOverridesProvider -from .constants import METADATA_ALIASES, METADATA_DEPENDANT, METADATA_SCOPE_EXTRA +from .constants import METADATA_DEPENDANT, METADATA_SCOPE_EXTRA, METADATA_SECURITY_SCOPES async def inject( @@ -29,9 +28,7 @@ async def inject( embed_body_fields: bool, background_tasks: BackgroundTasks, response: Response, - cache: ( - collections.abc.MutableMapping[typing.Callable[..., typing.Any], typing.Any] | None - ) = None, + cache: collections.abc.MutableMapping[CacheKey, typing.Any] | None = None, override: collections.abc.Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, ) -> typing.Any: """ @@ -63,15 +60,7 @@ async def inject( if fastapi_params.errors: raise RequestValidationError(_normalize_errors(fastapi_params.errors), body=body) - scope = { - **fastapi_params.values, - **resolve_aliases( - metadata[METADATA_ALIASES], - request, - background_tasks, - response, - ), - } + scope = fastapi_params.values scope_extra: collections.abc.Mapping[str, typing.Any] = metadata.get(METADATA_SCOPE_EXTRA, {}) diff --git a/fundi/compat/fastapi/metadata.py b/fundi/compat/fastapi/metadata.py index 2fc8e8c..c400790 100644 --- a/fundi/compat/fastapi/metadata.py +++ b/fundi/compat/fastapi/metadata.py @@ -1,11 +1,14 @@ import typing from fastapi import params -from fastapi.security.oauth2 import SecurityScopes -from fundi.compat.fastapi.constants import METADATA_SCOPE_EXTRA, METADATA_SECURITY_SCOPES +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) @@ -16,36 +19,61 @@ def get_metadata(info: CallableInfo[typing.Any]) -> dict[str, typing.Any]: return metadata -def build_metadata(info: CallableInfo[typing.Any]) -> None: +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) - security_scopes: SecurityScopes = metadata.setdefault( - METADATA_SECURITY_SCOPES, SecurityScopes([]) - ) + metadata.setdefault(METADATA_SECURITY_SCOPES, scopes_up) for parameter in info.parameters: - if parameter.from_ is None: - if parameter.annotation is SecurityScopes: - metadata.setdefault(METADATA_SCOPE_EXTRA, {}).update( - {parameter.name: security_scopes} - ) + subinfo = parameter.from_ - continue + 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 = parameter.from_ + 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] - security_scopes.scopes[::] = list( - set(list(security.scopes) + security_scopes.scopes) - ) - param_metadata.update({METADATA_SECURITY_SCOPES: security_scopes}) - build_metadata(subinfo) + 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 index a3b6f6a..d42ba4c 100644 --- a/fundi/compat/fastapi/route.py +++ b/fundi/compat/fastapi/route.py @@ -24,11 +24,11 @@ ) from fundi import scan -from .alias import init_aliases +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 -from fundi.compat.fastapi.metadata import build_metadata @typing.final @@ -76,11 +76,25 @@ def __init__( # pyright: ignore[reportMissingSuperCall] 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): - if dependency.dependency is None: - continue + depends = dependency - self.dependencies.append(scan(dependency.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): @@ -172,15 +186,10 @@ def __init__( # pyright: ignore[reportMissingSuperCall] self.response_fields = response_fields - build_metadata(callable_info) - init_aliases(callable_info) - 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: - build_metadata(ci) - init_aliases(ci) update_dependant( get_scope_dependant(ci, path_param_names, self.path_format), self.dependant ) diff --git a/fundi/compat/fastapi/secured.py b/fundi/compat/fastapi/secured.py index 9a42266..8beb5b8 100644 --- a/fundi/compat/fastapi/secured.py +++ b/fundi/compat/fastapi/secured.py @@ -1,13 +1,29 @@ import typing from collections.abc import Sequence -from fastapi.security.oauth2 import SecurityScopes - -from fundi.scan import scan -from .metadata import get_metadata +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 @@ -20,7 +36,4 @@ def secured( :return: callable information """ - info = scan(dependency, caching=caching) - metadata = get_metadata(info) - metadata.update({METADATA_SECURITY_SCOPES: SecurityScopes(list(scopes))}) - return info + return secured_scan(dependency, scopes, caching) diff --git a/fundi/compat/fastapi/secured.pyi b/fundi/compat/fastapi/secured.pyi index 8e52c06..6b82837 100644 --- a/fundi/compat/fastapi/secured.pyi +++ b/fundi/compat/fastapi/secured.pyi @@ -3,8 +3,13 @@ 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]], diff --git a/fundi/scan.py b/fundi/scan.py index d37219d..e886afa 100644 --- a/fundi/scan.py +++ b/fundi/scan.py @@ -1,7 +1,8 @@ -from dataclasses import replace -from types import FunctionType import typing import inspect +from copy import deepcopy +from types import FunctionType +from dataclasses import replace from fundi.util import is_configured, get_configuration from fundi.types import R, CallableInfo, Parameter, TypeResolver @@ -63,7 +64,7 @@ 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__")) + info = typing.cast(CallableInfo[typing.Any], deepcopy(getattr(call, "__fundi_info__"))) return replace(info, use_cache=caching) if isinstance(call, (FunctionType, type)):