From 63f910393252e5bba957b3ec01cd4e829de40475 Mon Sep 17 00:00:00 2001 From: Mattt Date: Wed, 23 Oct 2024 07:12:29 -0700 Subject: [PATCH] Extend Hype to support creating FastAPI apps for functions (#2) * Add http extras Install http extra by default * Add Accept header parsing for content negotiation * Add Prefer header parsing for request preferences * Add HTTP Problem Details object and FastAPI exception handling * Add blocking HTTP request preference tests * Add create_fastapi_app function * Use hype.up consistently Rename export to wrap Rename from_function to validate * Add missing type annotation for json_schema method * Raise explicitly from None to silence warnings * Remove unused imports * Update uv.lock * Replace testing group with dev dependencies * Add missing python-multipart dependency * Add Python 3.13 to test matrix --- .github/workflows/ci.yaml | 1 + examples/tool_use.py | 6 +- pyproject.toml | 14 +- src/hype/__init__.py | 4 +- src/hype/function.py | 23 +- src/hype/http/__init__.py | 166 ++++++++++++++ src/hype/http/accept.py | 193 ++++++++++++++++ src/hype/http/prefer.py | 104 +++++++++ src/hype/http/problem.py | 108 +++++++++ src/hype/task.py | 30 +++ src/hype/tools/__init__.py | 11 +- tests/test_app.py | 298 ++++++++++++++++++++++++ tests/test_content_negotiation.py | 256 +++++++++++++++++++++ tests/test_function.py | 4 +- tests/test_problem_details.py | 195 ++++++++++++++++ tests/test_request_preference.py | 345 ++++++++++++++++++++++++++++ uv.lock | 364 +++++++++++++++++++++++++++++- 17 files changed, 2099 insertions(+), 23 deletions(-) create mode 100644 src/hype/http/__init__.py create mode 100644 src/hype/http/accept.py create mode 100644 src/hype/http/prefer.py create mode 100644 src/hype/http/problem.py create mode 100644 src/hype/task.py create mode 100644 tests/test_app.py create mode 100644 tests/test_content_negotiation.py create mode 100644 tests/test_problem_details.py create mode 100644 tests/test_request_preference.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8db2c6c..e711d79 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,6 +20,7 @@ jobs: - "3.10" - "3.11" - "3.12" + - "3.13" steps: - uses: actions/checkout@v4 diff --git a/examples/tool_use.py b/examples/tool_use.py index 771e2ba..360564e 100644 --- a/examples/tool_use.py +++ b/examples/tool_use.py @@ -25,13 +25,13 @@ import anthropic -from hype.function import export +import hype from hype.tools.anthropic import create_anthropic_tools Number = TypeVar("Number", int, float) -@export +@hype.up def calculate(expression: str) -> Number: """ A simple calculator that performs basic arithmetic operations. @@ -65,7 +65,7 @@ def evaluate(node: ast.AST) -> Number: return evaluate(tree.body) -@export +@hype.up def prime_factors(n: int) -> list[int]: """ Calculate the prime factors of a given number efficiently. diff --git a/pyproject.toml b/pyproject.toml index c143fe7..a4b2658 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,24 @@ requires-python = ">=3.10" dependencies = ["pydantic>=2.0", "docstring-parser>=0.16"] [project.optional-dependencies] -testing = ["pytest"] +http = [ + "fastapi>=0.100.0", + "httpx>=0.27.2", + "opentelemetry-instrumentation-fastapi", + "python-multipart", + "uvicorn", +] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" +[tool.uv] +dev-dependencies = ["pytest>=8.3.3", "ruff>=0.7.0"] + +[tool.uv.pip] +extra = ["http"] + [tool.pylint.main] disable = [ "C0114", # Missing module docstring diff --git a/src/hype/__init__.py b/src/hype/__init__.py index 8a6e9fe..7f45be8 100644 --- a/src/hype/__init__.py +++ b/src/hype/__init__.py @@ -1,10 +1,12 @@ -from hype.function import export as up +from hype.function import wrap as up +from hype.http import create_fastapi_app from hype.tools.anthropic import create_anthropic_tools from hype.tools.ollama import create_ollama_tools from hype.tools.openai import create_openai_tools __all__ = [ "up", + "create_fastapi_app", "create_anthropic_tools", "create_openai_tools", "create_ollama_tools", diff --git a/src/hype/function.py b/src/hype/function.py index 28072d4..a1275d6 100644 --- a/src/hype/function.py +++ b/src/hype/function.py @@ -22,7 +22,7 @@ validate_call, ) from pydantic.fields import FieldInfo -from pydantic.json_schema import models_json_schema +from pydantic.json_schema import JsonSchemaValue, models_json_schema Input = ParamSpec("Input") Output = TypeVar("Output") @@ -38,13 +38,18 @@ class Function(BaseModel, Generic[Input, Output]): output: type[BaseModel] @classmethod - def from_function(cls, func: Callable[Input, Output]) -> "Function[Input, Output]": - name = func.__name__ + def validate(cls, value: Callable[Input, Output]) -> "Function[Input, Output]": + if isinstance(value, Function): + return value + if not callable(value): + raise TypeError("value must be callable") - docstring = parse_docstring(func.__doc__ or "") + name = value.__name__ + + docstring = parse_docstring(value.__doc__ or "") description = docstring.description - input, output = input_and_output_types(func, docstring) + input, output = input_and_output_types(value, docstring) function = cls( name=name, @@ -52,7 +57,7 @@ def from_function(cls, func: Callable[Input, Output]) -> "Function[Input, Output input=input, output=output, ) - function._wrapped = func + function._wrapped = value return function def __call__(self, *args: Input.args, **kwargs: Input.kwargs) -> Output: # pylint: disable=no-member @@ -67,7 +72,7 @@ def output_schema(self) -> dict[str, Any]: return self.output.model_json_schema() @property - def json_schema(self, title: str | None = None): + def json_schema(self, title: str | None = None) -> JsonSchemaValue: _, top_level_schema = models_json_schema( [(self.input, "validation"), (self.output, "validation")], title=title or self.name, @@ -141,5 +146,5 @@ class Output(RootModel[T]): # pylint: disable=redefined-outer-name return input, output -def export(func: Callable[Input, Output]) -> Function[Input, Output]: - return Function.from_function(func) +def wrap(function: Callable[Input, Output]) -> Function[Input, Output]: + return Function.validate(function) diff --git a/src/hype/http/__init__.py b/src/hype/http/__init__.py new file mode 100644 index 0000000..5ec06a3 --- /dev/null +++ b/src/hype/http/__init__.py @@ -0,0 +1,166 @@ +import asyncio +import warnings +from contextlib import asynccontextmanager +from typing import Annotated + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + + +from docstring_parser import parse as parse_docstring +from fastapi import APIRouter, FastAPI, File, Header, HTTPException, UploadFile +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, create_model + +from hype.function import Function +from hype.http.prefer import parse_prefer_headers +from hype.http.problem import Problem, problem_exception_handler +from hype.task import Tasks + + +class FileUploadRequest(BaseModel): + file: UploadFile + + +class FileUploadResponse(BaseModel): + ok: bool + + +def create_file_upload_callback_router(source_operation_id: str) -> APIRouter: + router = APIRouter() + + @router.put( + "{$callback_url}/files/{$request.body.id}", + response_model=FileUploadResponse, + operation_id=f"{source_operation_id}_file_upload_callback", + summary="File upload callback endpoint", + ) + def upload_file( + request: FileUploadRequest = File(...), # pylint: disable=unused-argument + ) -> FileUploadResponse: + return FileUploadResponse(ok=True) + + return router + + +def add_fastapi_endpoint( + app: FastAPI, + func: Function, +) -> None: + path = f"/{func.name}" + + # Create a new input model with a unique name + + name = func.name + docstring = parse_docstring(func._wrapped.__doc__ or "") # pylint: disable=protected-access + summary = docstring.short_description + description = docstring.long_description + operation_id = func.name + + input = create_model( + f"{operation_id}_Input", + __base__=func.input, + ) + + output = create_model( + f"{operation_id}_Output", + __base__=func.output, + ) + + @app.post( + path, + name=name, + summary=summary, + description=description, + operation_id=operation_id, + callbacks=create_file_upload_callback_router(operation_id).routes, + responses={ + "default": {"model": Problem, "description": "Default error response"} + }, + ) + async def endpoint( + input: input, # type: ignore + prefer: Annotated[list[str] | None, Header()] = None, + ) -> output: # type: ignore + preferences = parse_prefer_headers(prefer) + + input_dict = input.model_dump(mode="python") + if asyncio.iscoroutinefunction(func): + task = asyncio.create_task(func(**input_dict)) + else: + coroutine = asyncio.to_thread(func, **input_dict) + task = asyncio.create_task(coroutine) + + id = app.state.tasks.defer(task) + done, _ = await asyncio.wait( + [task], + timeout=preferences.wait, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + return done.pop().result() + else: + # If task was not completed within `wait` seconds, return the 202 response. + return JSONResponse( + status_code=202, content=None, headers={"Location": f"/tasks/{id}"} + ) + + +def create_fastapi_app( + functions: list[Function], + title: str = "Hype API", + summary: str | None = None, + description: str = "", + version: str = "0.1.0", +) -> FastAPI: + @asynccontextmanager + async def lifespan(app: FastAPI): # noqa: ANN202 + app.state.tasks = Tasks() + + for function in functions: + add_fastapi_endpoint(app, function) + + yield + + await app.state.tasks.wait_until_empty() + + app = FastAPI( + title=title, + summary=summary, + description=description, + version=version, + lifespan=lifespan, + ) + + FastAPIInstrumentor.instrument_app(app) + + app.add_exception_handler(ValueError, problem_exception_handler) + app.add_exception_handler(HTTPException, problem_exception_handler) + app.add_exception_handler(RequestValidationError, problem_exception_handler) + + @app.get("/tasks/{id}", include_in_schema=False) + def get_task(id: str) -> JSONResponse: + task = app.state.tasks.get(id) + + if task is None: + raise HTTPException(status_code=404, detail="Task not found") from None + + return JSONResponse(status_code=200, content=task.to_dict()) + + @app.post("/tasks/{id}/cancel", include_in_schema=False) + def cancel_task(id: str) -> JSONResponse: + task = app.state.tasks.get(id) + + if task is None: + raise HTTPException(status_code=404, detail="Task not found") from None + + task.cancel() + return JSONResponse(status_code=200, content=task.to_dict()) + + @app.get("/openapi.json", include_in_schema=False) + def get_openapi_schema() -> dict: + return app.openapi() + + return app diff --git a/src/hype/http/accept.py b/src/hype/http/accept.py new file mode 100644 index 0000000..0e1c854 --- /dev/null +++ b/src/hype/http/accept.py @@ -0,0 +1,193 @@ +import re +from typing import Any + +from pydantic import BaseModel, Field + + +class MediaRange(BaseModel): + """ + Represents an HTTP Accept header media range with type, subtype, and quality value. + + This class handles parsing and matching of media types like "text/html" or "application/*", + along with their parameters and quality values (q-values). + + For more details, see [RFC 7231, section 5.3.2](https://www.rfc-editor.org/rfc/rfc7231.html#section-5.3.2) + """ + + type: str = Field( + description="The primary type", examples=["text", "application", "*"] + ) + """The primary type (e.g., "text", "application", "*")""" + + subtype: str = Field(description="The subtype", examples=["html", "json", "*"]) + """The subtype (e.g., "html", "json", "*")""" + + parameters: dict[str, str] = Field( + default_factory=dict, + description="Optional media type parameters excluding q-value", + examples=[{"charset": "utf-8"}], + ) + """Optional media type parameters excluding q-value""" + + q: float = Field( + ge=0, + le=1, + default=1.0, + description="Quality value between 0 and 1", + examples=[0.5, 1.0], + ) + """Quality value between 0 and 1, defaults to 1.0.""" + + @classmethod + def validate(cls, value: Any) -> "MediaRange": + """Validates and converts a string or MediaRange object into a MediaRange instance. + + Args: + value: String (e.g., "text/html;q=0.9") or MediaRange object to validate + + Returns: + MediaRange: A validated MediaRange instance + + Raises: + ValueError: If value is not a string or has invalid media type format + """ + if isinstance(value, cls): + return value + if not isinstance(value, str): + raise ValueError(f"Expected str, got {type(value)}") + + parts = value.split(";") + type_part = parts[0].strip() + params = {} + q = 1.0 + + for param in parts[1:]: + param = param.strip() + if "=" in param: + key, val = param.split("=", 1) + key, val = key.strip(), val.strip() + if key == "q": + q = float(val) + else: + params[key] = val + + type_match = re.match(r"([^/]+)/([^/]+)", type_part) + if not type_match: + raise ValueError(f"Invalid media type: {type_part}") + + type_, subtype = type_match.groups() + return cls(type=type_, subtype=subtype, parameters=params, q=q) + + def __contains__(self, pattern: Any) -> bool: + """Implements pattern matching using the 'in' operator. + + Checks if this media range matches a given pattern. + Handles wildcard matching (e.g., "*/*" matches anything) + and parameter matching. + + Args: + pattern: String or MediaRange to match against + + Returns: + bool: True if this media range matches the pattern, False otherwise + """ + if isinstance(pattern, str): + pattern = MediaRange.validate(pattern) + if isinstance(pattern, MediaRange): + return ( + (self.type == pattern.type or self.type == "*") + and (self.subtype == pattern.subtype or self.subtype == "*") + and all( + self.parameters.get(key) == value # pylint: disable=no-member + for key, value in pattern.parameters.items() + ) + ) + return False + + def __eq__(self, other: Any) -> bool: + """Implements equality comparison between MediaRange objects.""" + + if not isinstance(other, MediaRange): + return NotImplemented + return ( + self.type == other.type + and self.subtype == other.subtype + and self.parameters == other.parameters + and self.q == other.q + ) + + def __lt__(self, other: "MediaRange") -> bool: + """Implements media range precedence ordering. + + Ordering is based on: + 1. Quality value (higher q values have higher precedence) + 2. Specificity (specific types have higher precedence than wildcards) + 3. Number of parameters (more parameters have higher precedence) + + Args: + other: MediaRange to compare against + + Returns: + bool: True if this media range has lower precedence than other + """ + + if not isinstance(other, MediaRange): + return NotImplemented + + # Compare q-values first + if self.q != other.q: + return self.q < other.q + + # Then compare specificity + if (self.type == "*") != (other.type == "*"): + return self.type == "*" + if (self.subtype == "*") != (other.subtype == "*"): + return self.subtype == "*" + + # Finally compare number of parameters + return len(self.parameters) < len(other.parameters) + + def __str__(self) -> str: + """Returns the string representation of the media range in Accept header format.""" + + parts = [f"{self.type}/{self.subtype}"] + + # Add parameters except q + for key, value in self.parameters.items(): + parts.append(f"{key}={value}") + + # Add q-value if not default + if self.q != 1.0: + parts.append(f"q={self.q}") + + return ";".join(parts) + + def __hash__(self) -> int: + """Makes MediaRange hashable for use in sets and as dict keys.""" + + return hash( + (self.type, self.subtype, frozenset(self.parameters.items()), self.q) + ) + + +def parse_accept_headers(value: list[str] | None) -> list[MediaRange]: + """ + Parses and sorts HTTP Accept headers into a list of MediaRange objects. + + Args: + value: List of Accept header strings, or None + + Returns: + A list of MediaRange objects sorted in descending precedence order. + + Example: + >>> parse_accept_headers(["text/html,application/xml;q=0.9"]) + [MediaRange(type='text', subtype='html', q=1.0), + MediaRange(type='application', subtype='xml', q=0.9)] + """ + + preferences = [] + for header in value or []: + for item in header.split(","): + preferences.append(MediaRange.validate(item)) + return sorted(preferences, reverse=True) diff --git a/src/hype/http/prefer.py b/src/hype/http/prefer.py new file mode 100644 index 0000000..a9585c0 --- /dev/null +++ b/src/hype/http/prefer.py @@ -0,0 +1,104 @@ +from typing import Literal + +from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError + + +class RequestPreferences(BaseModel): + """ + The preferences for a request, as parsed from the `Prefer` header. + + See: https://www.rfc-editor.org/rfc/rfc7240.html + """ + + respond_async: bool | None = Field(alias="respond-async", default=None) + """ + The "respond-async" preference indicates that the client prefers the + server to respond asynchronously to a response. + """ + + return_: Literal["representation", "minimal"] | None = Field( + alias="return", default=None + ) + """ + The "return=representation" preference indicates that the client prefers + that the server include an entity representing the current state of the + resource in the response to a successful request. + + The "return=minimal" preference indicates that the client wishes the server + to return only a minimal response to a successful request. + """ + + wait: int | None = Field(gt=0, default=None) + """ + The "wait" preference can be used to establish an upper bound on the + length of time, in seconds, the client expects it will take the server + to process the request once it has been received. + """ + + handling: Literal["strict", "lenient"] | None = Field(default=None) + """ + The "handling=strict" and "handling=lenient" preferences indicate how + the client wishes the server to handle potential error conditions that + can arise in the processing of a request. + """ + + model_config = {"extra": "forbid", "populate_by_name": True} + + @field_validator("wait", mode="before") + @classmethod + def validate_wait(cls, v: int | str) -> int: + if isinstance(v, str): + try: + return int(v) + except ValueError: + raise PydanticCustomError( # pylint: disable=raise-missing-from + "invalid_wait", "Invalid value for wait: {v}", {"v": v} + ) from None + return v + + def update(self, other: "RequestPreferences") -> None: + """ + Update the preferences with the values from another preferences object. + """ + for key, value in dict(other).items(): + if value is not None: + setattr(self, key, value) + + @classmethod + def parse(cls, value: str | None) -> "RequestPreferences": + if not value: + return cls() + + preferences = {} + for token in value.lower().split(","): + parts = [part.strip() for part in token.split("=", 1)] + key, val = (parts[0], parts[1]) if len(parts) == 2 else (parts[0], True) + + if key == "respond-async": + preferences[key] = True + else: + preferences[key] = val + + return cls.model_validate(preferences) + + +def parse_prefer_headers(value: list[str] | None) -> RequestPreferences: + """ + Parse a list of `Prefer` headers into a single `RequestPreferences` object. + + If multiple `Prefer` headers are present, their preferences are combined + into a single `RequestPreferences` object. In case of conflicting + preferences, the last occurrence takes precedence. For example, if + multiple headers specify different 'wait' times, the value from the + last header in the list will be used. + + Non-conflicting preferences from different headers are merged. For instance, + if one header specifies 'respond-async' and another specifies 'wait=100', + both preferences will be included in the final `RequestPreferences` object. + """ + + preferences = RequestPreferences() + for header in value or []: + preferences.update(RequestPreferences.parse(header)) + return preferences diff --git a/src/hype/http/problem.py b/src/hype/http/problem.py new file mode 100644 index 0000000..ac18b1f --- /dev/null +++ b/src/hype/http/problem.py @@ -0,0 +1,108 @@ +from typing import Any + +from fastapi import HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from pydantic import AnyUrl, BaseModel, Field, field_validator + + +class Problem(BaseModel): + """ + A description of an error that occurred while processing a request. + + See: https://datatracker.ietf.org/doc/html/rfc9457 + """ + + type: AnyUrl | str = Field(default="about:blank") + """A URI reference that identifies the problem type.""" + + title: str | None = None + """A short, human-readable summary of the problem type.""" + + status: int | None = Field(ge=100, le=599, default=None) + """The HTTP status code generated by the origin server for this occurrence of the problem.""" + + detail: str | None = None + """A human-readable explanation specific to this occurrence of the problem.""" + + instance: AnyUrl | str | None = None + """A URI reference that identifies the specific occurrence of the problem. + Can be an absolute URI or a relative URI. + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + @field_validator("type", mode="before") + @classmethod + def validate_type(cls, value: Any) -> AnyUrl | str: + if value is None: + return "about:blank" + if isinstance(value, AnyUrl | str): + return value + return str(value) + + @field_validator("instance", mode="before") + @classmethod + def validate_instance(cls, value: Any) -> AnyUrl | str | None: + if value is None: + return None + if isinstance(value, AnyUrl): + return value + if isinstance(value, str): + if value.startswith("/"): + return value + return AnyUrl(value) + + @classmethod + def validate(cls, value: dict[str, Any]) -> "Problem": + if isinstance(value, cls): + return value + if not isinstance(value, dict): + raise ValueError(f"Expected a dictionary, got {type(value)}") + return cls.model_validate(value) + + +class ProblemResponse(Response): + media_type: str = "application/problem+json" + + def render(self, content: Any) -> bytes: + problem: Problem + + if isinstance(content, Problem): + problem = content + elif isinstance(content, dict): + problem = Problem.model_validate(content) + else: + problem = Problem( + status=500, + title="Application Error", + detail=str(content), + ) + + self.status_code = problem.status or 500 # pylint: disable=attribute-defined-outside-init + + return problem.model_dump_json(exclude_none=True).encode("utf-8") + + +async def problem_exception_handler( + _request: Request, exc: Exception +) -> ProblemResponse: + if isinstance(exc, HTTPException): + content = Problem( + status=exc.status_code, + detail=exc.detail, + ) + elif isinstance(exc, RequestValidationError): + content = Problem( + status=400, + title="Bad Request", + detail=str(exc), + errors=exc.errors(), # type: ignore + ) + else: + content = Problem( + status=500, + title="Application Error", + detail=str(exc), + ) + + return ProblemResponse(content=content) diff --git a/src/hype/task.py b/src/hype/task.py new file mode 100644 index 0000000..adf5a45 --- /dev/null +++ b/src/hype/task.py @@ -0,0 +1,30 @@ +import asyncio +from uuid import UUID, uuid4 + + +class Tasks: + _tasks: dict[UUID, asyncio.Task] + + def __init__(self) -> None: + self._tasks = {} + + def defer(self, task: asyncio.Task) -> UUID: + id = uuid4() + self._tasks[id] = task + task.add_done_callback(lambda t: self._tasks.pop(id)) + return id + + def cancel(self, id: UUID) -> None: + task = self._tasks.get(id) + if task: + task.cancel() + + def get(self, id: UUID) -> asyncio.Task | None: + return self._tasks.get(id) + + def is_empty(self) -> bool: + return not self._tasks + + async def wait_until_empty(self) -> None: + while self._tasks: + await asyncio.wait(self._tasks.values()) diff --git a/src/hype/tools/__init__.py b/src/hype/tools/__init__.py index 3e7ee70..7bab98a 100644 --- a/src/hype/tools/__init__.py +++ b/src/hype/tools/__init__.py @@ -1,10 +1,11 @@ import warnings -from abc import ABC, abstractmethod -from collections.abc import Container, Iterable, Iterator +from abc import ABC +from collections.abc import Iterable from concurrent.futures import Future -from typing import Any, Generic, TypeVar +from typing import Generic, TypeVar -from hype.function import Function, export +import hype +from hype.function import Function def create_capture_function(dtype: type) -> tuple[Function, Future]: @@ -16,7 +17,7 @@ def create_capture_function(dtype: type) -> tuple[Function, Future]: future = Future() - @export + @hype.up def capture(value: dtype) -> None: """ Returns structured output back to the user. diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..ab2841a --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,298 @@ +import json +from typing import Annotated, cast + +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient +from pydantic import Field + +import hype +from hype import create_fastapi_app + + +def test_app_with_empty_functions(): + app = create_fastapi_app([]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + response = client.get("/openapi.json") + assert response.status_code == 200 + assert "3" in response.json()["openapi"] + + +def test_app_with_single_function(): + @hype.up + def add(x: int, y: int) -> int: + return x + y + + app = create_fastapi_app([add]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + if route := next( + (r for r in cast(list[APIRoute], app.routes) if r.path == "/add"), None + ): + assert route.methods == {"POST"} + assert route.name == "add" + else: + pytest.fail("Route not found: /add") + + response = client.post("/add", json={"x": 1, "y": 2}) + assert response.status_code == 200 + assert response.json() == 3 + + +def test_app_with_multiple_functions(): + @hype.up + def add(x: int, y: int) -> int: + return x + y + + @hype.up + def multiply(x: int, y: int) -> int: + return x * y + + @hype.up + def abs(x: int) -> int: + return x if x > 0 else -x + + app = create_fastapi_app([add, multiply, abs]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + if route := next( + (r for r in cast(list[APIRoute], app.routes) if r.path == "/add"), None + ): + assert route.methods == {"POST"} + assert route.name == "add" + else: + pytest.fail("Route not found: /add") + + if route := next( + (r for r in cast(list[APIRoute], app.routes) if r.path == "/multiply"), None + ): + assert route.methods == {"POST"} + assert route.name == "multiply" + else: + pytest.fail("Route not found: /multiply") + + if route := next( + (r for r in cast(list[APIRoute], app.routes) if r.path == "/abs"), None + ): + assert route.methods == {"POST"} + assert route.name == "abs" + else: + pytest.fail("Route not found: /abs") + + response = client.post("/add", json={"x": 1, "y": 2}) + assert response.status_code == 200 + assert response.json() == 3 + + response = client.post("/multiply", json={"x": 2, "y": 3}) + assert response.status_code == 200 + assert response.json() == 6 + + response = client.post("/abs", json={"x": -1}) + assert response.status_code == 200 + assert response.json() == 1 + + response = client.get("/openapi.json") + assert response.status_code == 200 + + schema = response.json() + print(json.dumps(schema, indent=2)) + operation = schema["paths"]["/add"]["post"] + assert operation["operationId"] == "add" + + input = schema["components"]["schemas"]["add_Input"] + assert input["properties"]["x"]["type"] == "integer" + assert input["properties"]["y"]["type"] == "integer" + + output = schema["components"]["schemas"]["add_Output"] + assert output["type"] == "integer" + + +def test_app_with_function_with_rest_docstring(): + @hype.up + def subtract(x: int, y: int) -> int: + """ + Subtracts two numbers + :param x: The first number + :param y: The second number + :return: The result of the subtraction + """ + return x - y + + app = create_fastapi_app([subtract]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + response = client.get("/openapi.json") + assert response.status_code == 200 + + schema = response.json() + operation = schema["paths"]["/subtract"]["post"] + assert operation["operationId"] == "subtract" + assert "Subtracts two numbers" in operation["summary"] + + input = schema["components"]["schemas"]["subtract_Input"] + assert input["properties"]["x"]["type"] == "integer" + assert input["properties"]["x"]["description"] == "The first number" + assert input["properties"]["y"]["type"] == "integer" + assert input["properties"]["y"]["description"] == "The second number" + + output = schema["components"]["schemas"]["subtract_Output"] + assert output["type"] == "integer" + assert output["description"] == "The result of the subtraction" + + +def test_app_with_function_numpy_style_docstring(): + @hype.up + def increment(x: int) -> int: + """ + Increments a number by 1. + + Parameters + ---------- + x : int + The number to increment. + + Returns + ------- + int + The incremented value. + """ + return x + 1 + + app = create_fastapi_app([increment]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + response = client.get("/openapi.json") + schema = response.json() + operation = schema["paths"]["/increment"]["post"] + + assert operation["summary"] == "Increments a number by 1." + assert ( + schema["components"]["schemas"]["increment_Input"]["properties"]["x"][ + "description" + ] + == "The number to increment." + ) + assert ( + schema["components"]["schemas"]["increment_Output"]["description"] + == "The incremented value." + ) + + +def test_app_with_function_epydoc_style_docstring(): + @hype.up + def negate(x: int) -> int: + """ + Negates a number. + + @param x: The number to negate. + @type x: int + @return: The negated value. + @rtype: int + """ + return -x + + app = create_fastapi_app([negate]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + response = client.get("/openapi.json") + schema = response.json() + operation = schema["paths"]["/negate"]["post"] + + assert operation["summary"] == "Negates a number." + assert ( + schema["components"]["schemas"]["negate_Input"]["properties"]["x"][ + "description" + ] + == "The number to negate." + ) + assert ( + schema["components"]["schemas"]["negate_Output"]["description"] + == "The negated value." + ) + + +def test_app_with_function_google_style_docstring(): + @hype.up + def decrement(x: int) -> int: + """Decrements a number by 1. + + Args: + x: The number to decrement. + + Returns: + The decremented value. + """ + return x - 1 + + app = create_fastapi_app([decrement]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + response = client.get("/openapi.json") + schema = response.json() + operation = schema["paths"]["/decrement"]["post"] + + assert operation["summary"] == "Decrements a number by 1." + assert ( + schema["components"]["schemas"]["decrement_Input"]["properties"]["x"][ + "description" + ] + == "The number to decrement." + ) + assert ( + schema["components"]["schemas"]["decrement_Output"]["description"] + == "The decremented value." + ) + + +def test_app_with_function_with_docstring_and_field_info(): + @hype.up + def divide( + x: int = Field(..., description="The dividend"), + y: int = Field(..., description="The divisor", gt=0), + ) -> Annotated[int, Field(description="The quotient")]: + """ + Divides two numbers + :param x: (This description is overwritten by the field info) + :param y: (This description is overwritten by the field info) + :return: (This description is overwritten by the field info) + """ + return x // y + + app = create_fastapi_app([divide]) + with TestClient(app) as client: + assert app is not None + assert isinstance(app, FastAPI) + + response = client.get("/openapi.json") + assert response.status_code == 200 + + schema = response.json() + operation = schema["paths"]["/divide"]["post"] + assert operation["operationId"] == "divide" + assert "Divides two numbers" in operation["summary"] + + input = schema["components"]["schemas"]["divide_Input"] + assert input["properties"]["x"]["type"] == "integer" + assert input["properties"]["x"]["description"] == "The dividend" + assert input["properties"]["y"]["type"] == "integer" + assert input["properties"]["y"]["description"] == "The divisor" + + output = schema["components"]["schemas"]["divide_Output"] + assert output["type"] == "integer" + assert output["description"] == "The quotient" diff --git a/tests/test_content_negotiation.py b/tests/test_content_negotiation.py new file mode 100644 index 0000000..31850fb --- /dev/null +++ b/tests/test_content_negotiation.py @@ -0,0 +1,256 @@ +import base64 +import json +from typing import Annotated + +import pytest +from fastapi import FastAPI, Header, Response +from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.testclient import TestClient + +from hype.http.accept import MediaRange, parse_accept_headers + + +def test_media_range_validation(): + # Basic media types + media = MediaRange.validate("text/plain") + assert media.type == "text" + assert media.subtype == "plain" + + # With parameters + media = MediaRange.validate("text/plain; charset=utf-8; format=compact") + assert media.parameters == {"charset": "utf-8", "format": "compact"} + + # With q-value + media = MediaRange.validate("text/plain;q=0.8") + assert media.q == 0.8 + + # Invalid formats + with pytest.raises(ValueError): + MediaRange.validate("invalid") + with pytest.raises(ValueError): + MediaRange.validate("text") + with pytest.raises(ValueError): + MediaRange.validate("text/") + + +def test_media_range_matching(): + # Exact matches + assert "text/plain" in MediaRange.validate("text/plain") + assert "text/plain; charset=utf-8" in MediaRange.validate( + "text/plain; charset=utf-8" + ) + + # Wildcard matches + assert "text/plain" in MediaRange.validate("*/*") + assert "text/plain" in MediaRange.validate("text/*") + + # Parameter matching + media = MediaRange.validate("text/plain; charset=utf-8") + assert "text/plain" in media + assert "text/plain; charset=ascii" not in media + + # Non-matches + assert "image/png" not in MediaRange.validate("text/plain") + assert "text/html" not in MediaRange.validate("text/plain") + + +def test_media_range_ordering(): + # Test complete ordering + ranges = [ + MediaRange.validate(value) + for value in [ + "*/*; q=0.1", + "*/*; q=0.5", + "text/*; q=0.5", + "text/plain; q=0.5", + "text/plain; q=0.8", + "*/*", + "text/*", + "text/plain", + "text/plain; charset=utf-8", + ] + ] + assert sorted(ranges) == ranges # Verify complete ordering + + +def test_parse_accept_headers(): + headers = ["text/plain, text/html;q=0.8, */*;q=0.1"] + preferences = parse_accept_headers(headers) + + assert len(preferences) == 3 + assert preferences[0].type == "text" + assert preferences[0].subtype == "plain" + assert preferences[0].q == 1.0 + + assert preferences[1].type == "text" + assert preferences[1].subtype == "html" + assert preferences[1].q == 0.8 + + assert preferences[2].type == "*" + assert preferences[2].subtype == "*" + assert preferences[2].q == 0.1 + + # Empty or None input + assert parse_accept_headers([]) == [] + assert parse_accept_headers(None) == [] + + +def test_negotiate_text_response(): + app = FastAPI() + + @app.post("/greet") + def greet( + accept: Annotated[list[str] | None, Header()] = None, + ) -> Response: + for preference in parse_accept_headers(accept): + if "text/plain; format=compact" in preference: + return PlainTextResponse(status_code=201, content="Hi!") + if "text/plain" in preference: + return PlainTextResponse(status_code=201, content="Hello, world!") + + return JSONResponse(status_code=406, content={"message": "Not Acceptable"}) + + client = TestClient(app) + + response = client.post("/greet", headers={"Accept": "text/plain;format=compact"}) + assert response.status_code == 201 + assert response.text == "Hi!" + + response = client.post("/greet", headers={"Accept": "text/plain;charset=utf-8"}) + assert response.status_code == 201 + assert response.text == "Hello, world!" + + response = client.post("/greet", headers={"Accept": "text/plain"}) + assert response.status_code == 201 + assert response.text == "Hello, world!" + + response = client.post("/greet", headers={"Accept": "text/plain"}) + assert response.status_code == 201 + assert response.text == "Hello, world!" + + response = client.post("/greet", headers={"Accept": "*/*"}) + assert response.status_code == 201 + assert response.text == "Hello, world!" + + client.post("/greet", headers={}) + assert response.status_code == 201 + assert response.text == "Hello, world!" + + with pytest.raises(ValueError): + client.post("/greet", headers={"Accept": ""}) + + response = client.post("/greet", headers={"Accept": "image/png"}) + assert response.status_code == 406 + assert response.json() == {"message": "Not Acceptable"} + + +def test_negotiate_image_response(): + image_data = { + "webp": b"WEBP_IMAGE_DATA", + "png": b"PNG_IMAGE_DATA", + "jpeg": b"JPEG_IMAGE_DATA", + } + + image_metadata = { + "width": 1920, + "height": 1080, + "quality": 85, + } + + app = FastAPI() + + @app.post("/generate") + def generate( + accept: Annotated[list[str] | None, Header()] = None, + ) -> Response: + for preference in parse_accept_headers(accept): + if "image/webp; disposition=inline" in preference: + data = base64.b64encode(image_data["webp"]).decode("utf-8") + return Response( + content=f"data:image/webp;base64,{data}", + media_type="text/uri-list", + headers={"Content-Disposition": "inline; filename=image.webp"}, + ) + elif "image/webp" in preference: + return Response( + content=image_data["webp"], + media_type="image/webp", + headers={"Content-Disposition": "attachment; filename=image.webp"}, + ) + elif "image/png" in preference: + return Response( + content=image_data["png"], + media_type="image/png", + headers={"Content-Disposition": "attachment; filename=image.png"}, + ) + elif "image/jpeg" in preference: + return Response( + content=image_data["jpeg"], + media_type="image/jpeg", + headers={"Content-Disposition": "attachment; filename=image.jpeg"}, + ) + elif "multipart/related+webp" in preference: + boundary = "boundary" + body = ( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="metadata"\r\n' + f"Content-Type: application/json\r\n\r\n" + f"{json.dumps(image_metadata)}\r\n" + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="image"; filename="image.webp"\r\n' + f"Content-Type: image/webp\r\n\r\n" + ).encode() + + image_data["webp"] + + f"\r\n--{boundary}--".encode() + ) + return Response( + content=body, + media_type=f"multipart/related; boundary={boundary}", + headers={"Content-Type": f"multipart/related; boundary={boundary}"}, + ) + + return JSONResponse(status_code=406, content={"message": "Not Acceptable"}) + + client = TestClient(app) + + response = client.post("/generate", headers={"Accept": "image/webp"}) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "image/webp" + assert response.content == image_data["webp"] + + response = client.post("/generate", headers={"Accept": "image/png"}) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "image/png" + assert response.headers["Content-Disposition"] == "attachment; filename=image.png" + assert response.content == image_data["png"] + + response = client.post("/generate", headers={"Accept": "image/jpeg"}) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "image/jpeg" + assert response.content == image_data["jpeg"] + + response = client.post( + "/generate", headers={"Accept": "image/webp; disposition=inline"} + ) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "text/uri-list; charset=utf-8" + assert response.headers["Content-Disposition"] == "inline; filename=image.webp" + assert response.text.startswith("data:image/webp;base64,") + assert base64.b64decode(response.text.split(",")[1]) == image_data["webp"] + + response = client.post("/generate", headers={"Accept": "multipart/related+webp"}) + assert response.status_code == 200 + assert response.headers["Content-Type"].startswith("multipart/related; boundary=") + + response = client.post("/generate", headers={"Accept": "*/*"}) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "image/webp" + assert response.content == image_data["webp"] + + with pytest.raises(ValueError): + client.post("/generate", headers={"Accept": ""}) + + response = client.post("/generate", headers={"Accept": "image/bmp"}) + assert response.status_code == 406 + assert response.json() == {"message": "Not Acceptable"} diff --git a/tests/test_function.py b/tests/test_function.py index 43f6337..1c89939 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -5,10 +5,10 @@ import pydantic import pytest -from hype.function import export +import hype -@export +@hype.up def f( x: Literal[1, 2, 3], y: int = pydantic.Field(..., description="The second number (from field)"), diff --git a/tests/test_problem_details.py b/tests/test_problem_details.py new file mode 100644 index 0000000..269636e --- /dev/null +++ b/tests/test_problem_details.py @@ -0,0 +1,195 @@ +from typing import Annotated + +import pytest +from fastapi import FastAPI, Header, HTTPException +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient +from pydantic import AnyUrl, BaseModel, ValidationError + +from hype.http.problem import Problem, ProblemResponse, problem_exception_handler + + +def test_problem_default_values(): + problem = Problem() + assert problem.type == "about:blank" + assert problem.title is None + assert problem.status is None + assert problem.detail is None + assert problem.instance is None + + +def test_problem_with_values(): + problem = Problem( + type=AnyUrl("https://example.com/problems/out-of-stock"), + title="Out of Stock", + status=400, + detail="The requested item is currently out of stock.", + instance="/orders/12345", + ) + assert str(problem.type) == "https://example.com/problems/out-of-stock" + assert problem.title == "Out of Stock" + assert problem.status == 400 + assert problem.detail == "The requested item is currently out of stock." + assert problem.instance == "/orders/12345" + + +def test_problem_type_validation(): + with pytest.raises(ValidationError): + Problem(type=AnyUrl("not a valid URL")) + + +def test_problem_status_validation(): + with pytest.raises(ValidationError): + Problem(status=99) + + with pytest.raises(ValidationError): + Problem(status=600) + + +def test_problem_instance_validation(): + with pytest.raises(ValidationError): + Problem(instance="not a valid URL") + + +def test_problem_extra_fields(): + problem = Problem(extra_field="This is allowed") # type: ignore + assert problem.extra_field == "This is allowed" # type: ignore + + +def test_problem_parse_method(): + data = { + "type": "https://example.com/problems/insufficient-funds", + "title": "Insufficient Funds", + "status": 403, + "detail": "Your account does not have enough funds to complete this transaction.", + "instance": "/transactions/12345", + "balance": 30.5, + } + problem = Problem.validate(data) + assert str(problem.type) == "https://example.com/problems/insufficient-funds" + assert problem.title == "Insufficient Funds" + assert problem.status == 403 + assert ( + problem.detail + == "Your account does not have enough funds to complete this transaction." + ) + assert problem.instance == "/transactions/12345" + assert problem.balance == 30.5 # type: ignore + + +def test_problem_type_default(): + problem = Problem(type=None) # type: ignore + assert str(problem.type) == "about:blank" + + +def test_problem_populate_by_name(): + problem = Problem(**{"type": "https://example.com/problem", "status": 404}) + assert str(problem.type) == "https://example.com/problem" + assert problem.status == 404 + + +def test_problem_response_integration(): + class Item(BaseModel): + name: str + quantity: int + + app = FastAPI() + app.add_exception_handler(ValueError, problem_exception_handler) + app.add_exception_handler(HTTPException, problem_exception_handler) + app.add_exception_handler(RequestValidationError, problem_exception_handler) + + items = [ + {"name": "banana", "quantity": 1}, + {"name": "apple", "quantity": 2}, + ] + + @app.post("/items", response_model=None) + def create_item( + item: Item, + accept: Annotated[list[str] | None, Header()] = None, # pylint: disable=unused-argument + ) -> JSONResponse | ProblemResponse: + if item.quantity < 0: + return ProblemResponse( + content=Problem( + type="https://example.com/problems/invalid-quantity", + title="Invalid Quantity", + status=400, + detail="Item quantity cannot be negative", + instance="/items", + received_quantity=item.quantity, # type: ignore + ) + ) + obj = item.model_dump() + items.append(obj) + return JSONResponse(content=obj) + + @app.get("/items/{item_id}", response_model=None) + def get_item(item_id: int) -> JSONResponse | ProblemResponse: + if item_id < 1: # Changed from 0 to 1 + return ProblemResponse( + content=Problem( + type="https://example.com/problems/invalid-id", + title="Invalid Item ID", + status=400, + detail="Item ID cannot be negative or zero", # Updated message + instance=f"/items/{item_id}", + ) + ) + try: + return JSONResponse(content=items[item_id - 1]) # Adjust index by -1 + except IndexError: + raise HTTPException(status_code=404, detail="Item not found") from None + + client = TestClient(app) + + # Test custom problem response + response = client.get("/items/-1") + assert response.status_code == 400 + assert response.headers["content-type"] == "application/problem+json" + assert response.json() == { + "type": "https://example.com/problems/invalid-id", + "title": "Invalid Item ID", + "status": 400, + "detail": "Item ID cannot be negative or zero", + "instance": "/items/-1", + } + + # Test general exception handler + response = client.get("/items/1") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"name": "banana", "quantity": 1} + + # Test HTTP exception handler + response = client.get("/items/100") + assert response.status_code == 404 + assert response.headers["content-type"] == "application/problem+json" + assert response.json() == { + "type": "about:blank", + "status": 404, + "detail": "Item not found", + } + + # Test validation error with custom fields + response = client.post( + "/items", + json={"name": "apple", "quantity": -5}, + ) + assert response.status_code == 400 + assert response.headers["content-type"] == "application/problem+json" + problem = response.json() + assert problem["type"] == "https://example.com/problems/invalid-quantity" + assert problem["title"] == "Invalid Quantity" + assert problem["status"] == 400 + assert problem["detail"] == "Item quantity cannot be negative" + assert problem["instance"] == "/items" + assert problem["received_quantity"] == -5 + + # Test successful request + response = client.post( + "/items", + json={"name": "orange", "quantity": 5}, + ) + assert response.status_code == 200 + assert response.json() == {"name": "orange", "quantity": 5} diff --git a/tests/test_request_preference.py b/tests/test_request_preference.py new file mode 100644 index 0000000..529a9a0 --- /dev/null +++ b/tests/test_request_preference.py @@ -0,0 +1,345 @@ +import asyncio +import os +from typing import Annotated + +import pytest +from fastapi import FastAPI, Header, Response +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from hype.http.prefer import RequestPreferences, parse_prefer_headers + + +def test_parse_empty_header(): + header = "" + prefs = RequestPreferences.parse(header) + assert prefs == RequestPreferences() + + +def test_none_header(): + header = None + prefs = RequestPreferences.parse(header) + assert prefs == RequestPreferences() + + +def test_empty_header(): + header = "" + prefs = RequestPreferences.parse(header) + assert prefs == RequestPreferences() + + +def test_invalid_header(): + header = "invalid" + with pytest.raises(ValueError): + RequestPreferences.parse(header) + + +def test_respond_async(): + header = "respond-async" + prefs = RequestPreferences.parse(header) + + assert prefs.respond_async + + assert prefs.wait is None + assert prefs.handling is None + assert prefs.return_ is None + + +def test_return_representation(): + header = "return=representation" + prefs = RequestPreferences.parse(header) + + assert prefs.return_ == "representation" + + assert prefs.respond_async is None + assert prefs.wait is None + assert prefs.handling is None + + +def test_return_minimal(): + header = "return=minimal" + prefs = RequestPreferences.parse(header) + + assert prefs.return_ == "minimal" + + assert prefs.respond_async is None + assert prefs.wait is None + assert prefs.handling is None + + +def test_wait(): + header = "wait=100" + prefs = RequestPreferences.parse(header) + + assert prefs.wait == 100 + + assert prefs.respond_async is None + assert prefs.handling is None + assert prefs.return_ is None + + +def test_handling_strict(): + header = "handling=strict" + prefs = RequestPreferences.parse(header) + + assert prefs.handling == "strict" + + assert prefs.respond_async is None + assert prefs.wait is None + assert prefs.return_ is None + + +def test_handling_lenient(): + header = "handling=lenient" + prefs = RequestPreferences.parse(header) + + assert prefs.handling == "lenient" + + assert prefs.respond_async is None + assert prefs.wait is None + assert prefs.return_ is None + + +def test_multiple_preferences(): + header = "respond-async, wait=100, handling=lenient, return=representation" + prefs = RequestPreferences.parse(header) + + assert prefs.respond_async + assert prefs.wait == 100 + assert prefs.handling == "lenient" + assert prefs.return_ == "representation" + + +def test_case_insensitive(): + header = "RETURN=minimal, RESPOND-ASYNC" + prefs = RequestPreferences.parse(header) + + assert prefs.respond_async + assert prefs.return_ == "minimal" + + +def test_invalid_return_value(): + header = "return=invalid" + with pytest.raises(ValueError): + RequestPreferences.parse(header) + + +def test_invalid_wait_value(): + header = "wait=notanumber" + with pytest.raises(ValueError): + RequestPreferences.parse(header) + + +def test_invalid_handling_value(): + header = "handling=invalid" + with pytest.raises(ValueError): + RequestPreferences.parse(header) + + +def test_basic_preferences(): + app = FastAPI() + + @app.post("/tasks") + def create_task( + prefer: Annotated[list[str] | None, Header()] = None, + ) -> Response: + prefs = parse_prefer_headers(prefer) + + # Simulate async processing + if prefs.respond_async: + return Response( + status_code=202, + headers={"Location": "/tasks/123", "Retry-After": "5"}, + ) + + # Handle return preference + if prefs.return_ == "minimal": + return Response( + status_code=201, + headers={"Location": "/tasks/123"}, + ) + + # Default to full representation + return JSONResponse( + status_code=201, + content={"id": "123", "status": "pending"}, + headers={"Location": "/tasks/123"}, + ) + + client = TestClient(app) + + # Test async preference + response = client.post("/tasks", headers={"Prefer": "respond-async"}) + assert response.status_code == 202 + assert response.headers["Location"] == "/tasks/123" + assert response.headers["Retry-After"] == "5" + + # Test minimal return + response = client.post("/tasks", headers={"Prefer": "return=minimal"}) + assert response.status_code == 201 + assert response.headers["Location"] == "/tasks/123" + assert not response.content # Should be empty + + # Test full representation (default) + response = client.post("/tasks", headers={"Prefer": "return=representation"}) + assert response.status_code == 201 + assert response.headers["Location"] == "/tasks/123" + assert response.json() == {"id": "123", "status": "pending"} + + +def test_handling_preferences(): + app = FastAPI() + + @app.post("/users") + def create_user( + prefer: Annotated[list[str] | None, Header()] = None, + ) -> Response: + prefs = parse_prefer_headers(prefer) + + # Simulate a partial success scenario + if prefs.handling == "lenient": + return JSONResponse( + status_code=201, + content={ + "id": "456", + "warnings": ["Some optional fields were ignored"], + }, + headers={"Preference-Applied": "handling=lenient"}, + ) + elif prefs.handling == "strict": + return JSONResponse( + status_code=400, + content={"error": "Invalid optional fields"}, + ) + + # Default to strict handling + return JSONResponse( + status_code=400, + content={"error": "Invalid optional fields"}, + ) + + client = TestClient(app) + + # Test lenient handling + response = client.post("/users", headers={"Prefer": "handling=lenient"}) + assert response.status_code == 201 + assert response.headers["Preference-Applied"] == "handling=lenient" + assert response.json()["warnings"] == ["Some optional fields were ignored"] + + # Test strict handling + response = client.post("/users", headers={"Prefer": "handling=strict"}) + assert response.status_code == 400 + assert response.json()["error"] == "Invalid optional fields" + + +def test_wait_preference(): + app = FastAPI() + + @app.post("/process") + def process( + prefer: Annotated[list[str] | None, Header()] = None, + ) -> Response: + prefs = parse_prefer_headers(prefer) + + # Simulate a long-running process + if prefs.wait is not None and prefs.wait < 10: + return JSONResponse( + status_code=202, + content={ + "message": "Processing will take longer than requested wait time" + }, + headers={ + "Retry-After": "10", + "Preference-Applied": f"wait={prefs.wait}", + }, + ) + + return JSONResponse( + status_code=200, + content={"status": "completed"}, + headers={"Preference-Applied": f"wait={prefs.wait}"}, + ) + + client = TestClient(app) + + # Test short wait time + response = client.post("/process", headers={"Prefer": "wait=5"}) + assert response.status_code == 202 + assert response.headers["Preference-Applied"] == "wait=5" + assert response.headers["Retry-After"] == "10" + + # Test acceptable wait time + response = client.post("/process", headers={"Prefer": "wait=15"}) + assert response.status_code == 200 + assert response.headers["Preference-Applied"] == "wait=15" + assert response.json()["status"] == "completed" + + +def test_request_blocking_respond_async(): + app = FastAPI() + + @app.post("/greet") + async def greet( + prefer: Annotated[list[str] | None, Header()] = None, + ) -> JSONResponse: + preferences = parse_prefer_headers(prefer) + if preferences.respond_async: + return JSONResponse( + status_code=202, content=None, headers={"Location": "/tasks/123"} + ) + else: + return JSONResponse(status_code=201, content={"message": "Hello, world!"}) + + client = TestClient(app) + response = client.post("/greet", headers={"Prefer": "respond-async"}) + assert response.status_code == 202 + assert response.json() is None + assert response.headers["Location"] == "/tasks/123" + + response = client.post("/greet") + assert response.status_code == 201 + assert response.json() == {"message": "Hello, world!"} + + +@pytest.mark.skipif(not os.environ.get("CI"), reason="Skip unless running in CI") +def test_request_blocking_wait(): + async def long_running_task(): + await asyncio.sleep(1.1) + return JSONResponse(status_code=201, content={"message": "Goodbye, world!"}) + + app = FastAPI() + + @app.post("/farewell") + async def farewell( + prefer: Annotated[list[str] | None, Header()] = None, + ) -> JSONResponse: + preferences = parse_prefer_headers(prefer) + + task = asyncio.create_task(long_running_task()) + done, _ = await asyncio.wait( + [task], + timeout=preferences.wait, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + return done.pop().result() + else: + # If task was not completed within `wait` seconds, return the 202 response. + return JSONResponse( + status_code=202, content=None, headers={"Location": "/tasks/123"} + ) + + client = TestClient(app) + + response = client.post("/farewell") + assert response.status_code == 201 + assert response.json() == {"message": "Goodbye, world!"} + + response = client.post("/farewell", headers={"Prefer": "wait=1"}) + assert response.status_code == 202 + assert response.json() is None + assert response.headers["Location"] == "/tasks/123" + + response = client.post("/farewell", headers={"Prefer": "wait=10"}) + assert response.status_code == 201 + assert response.json() == {"message": "Goodbye, world!"} diff --git a/uv.lock b/uv.lock index ceef94d..48bf1c5 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "anyio" +version = "4.6.2.post1" +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.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, +] + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -23,6 +71,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "deprecated" +version = "1.2.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/14/1e41f504a246fc224d2ac264c227975427a85caf37c3979979edb9b1b232/Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3", size = 2974416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/8d/778b7d51b981a96554f29136cd59ca7880bf58094338085bcf2a979a0e6a/Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c", size = 9561 }, +] + [[package]] name = "docstring-parser" version = "0.16" @@ -41,6 +101,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] +[[package]] +name = "fastapi" +version = "0.115.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/ce/b64ce344d7b13c0768dc5b131a69d52f57202eb85839408a7637ca0dd7e2/fastapi-0.115.3.tar.gz", hash = "sha256:c091c6a35599c036d676fa24bd4a6e19fa30058d93d950216cdc672881f6f7db", size = 300453 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/95/4c5b79e7ca1f7b372d16a32cad7c9cc6c3c899200bed8f45739f4415cfae/fastapi-0.115.3-py3-none-any.whl", hash = "sha256:8035e8f9a2b0aa89cea03b6c77721178ed5358e1aea4cd8570d9466895c0638c", size = 94647 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + [[package]] name = "hype" version = "0.0.1" @@ -51,15 +163,56 @@ dependencies = [ ] [package.optional-dependencies] -testing = [ +http = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "docstring-parser", specifier = ">=0.16" }, + { name = "fastapi", marker = "extra == 'http'", specifier = ">=0.100.0" }, + { name = "httpx", marker = "extra == 'http'", specifier = ">=0.27.2" }, + { name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'http'" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pytest", marker = "extra == 'testing'" }, + { name = "python-multipart", marker = "extra == 'http'", specifier = ">=0.0.12" }, + { name = "uvicorn", marker = "extra == 'http'" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.7.0" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269 }, ] [[package]] @@ -71,6 +224,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "opentelemetry-api" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/83/93114b6de85a98963aec218a51509a52ed3f8de918fe91eb0f7299805c3f/opentelemetry_api-1.27.0.tar.gz", hash = "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342", size = 62693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1f/737dcdbc9fea2fa96c1b392ae47275165a7c641663fbb08a8d252968eed2/opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7", size = 63970 }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "setuptools" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0e/d9394839af5d55c8feb3b22cd11138b953b49739b20678ca96289e30f904/opentelemetry_instrumentation-0.48b0.tar.gz", hash = "sha256:94929685d906380743a71c3970f76b5f07476eea1834abd5dd9d17abfe23cc35", size = 24724 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7f/405c41d4f359121376c9d5117dcf68149b8122d3f6c718996d037bd4d800/opentelemetry_instrumentation-0.48b0-py3-none-any.whl", hash = "sha256:a69750dc4ba6a5c3eb67986a337185a25b739966d80479befe37b546fc870b44", size = 29449 }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/ac/fd3d40bab3234ec3f5c052a815100676baaae1832fa1067935f11e5c59c6/opentelemetry_instrumentation_asgi-0.48b0.tar.gz", hash = "sha256:04c32174b23c7fa72ddfe192dad874954968a6a924608079af9952964ecdf785", size = 23435 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/74/a0e0d38622856597dd8e630f2bd793760485eb165708e11b8be1696bbb5a/opentelemetry_instrumentation_asgi-0.48b0-py3-none-any.whl", hash = "sha256:ddb1b5fc800ae66e85a4e2eca4d9ecd66367a8c7b556169d9e7b57e10676e44d", size = 15958 }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/20/43477da5850ef2cd3792715d442aecd051e885e0603b6ee5783b2104ba8f/opentelemetry_instrumentation_fastapi-0.48b0.tar.gz", hash = "sha256:21a72563ea412c0b535815aeed75fc580240f1f02ebc72381cfab672648637a2", size = 18497 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/50/745ab075a3041b7a5f29a579d2c28eaad54f64b4589d8f9fd364c62cf0f3/opentelemetry_instrumentation_fastapi-0.48b0-py3-none-any.whl", hash = "sha256:afeb820a59e139d3e5d96619600f11ce0187658b8ae9e3480857dd790bc024f2", size = 11777 }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/89/1724ad69f7411772446067cdfa73b598694c8c91f7f8c922e344d96d81f9/opentelemetry_semantic_conventions-0.48b0.tar.gz", hash = "sha256:12d74983783b6878162208be57c9effcb89dc88691c64992d70bb89dc00daa1a", size = 89445 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/7a/4f0063dbb0b6c971568291a8bc19a4ca70d3c185db2d956230dd67429dfc/opentelemetry_semantic_conventions-0.48b0-py3-none-any.whl", hash = "sha256:a0de9f45c413a8669788a38569c7e0a11ce6ce97861a628cca785deecdc32a1f", size = 149685 }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/d7/185c494754340e0a3928fd39fde2616ee78f2c9d66253affaad62d5b7935/opentelemetry_util_http-0.48b0.tar.gz", hash = "sha256:60312015153580cc20f322e5cdc3d3ecad80a71743235bdb77716e742814623c", size = 7863 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/2e/36097c0a4d0115b8c7e377c90bab7783ac183bc5cb4071308f8959454311/opentelemetry_util_http-0.48b0-py3-none-any.whl", hash = "sha256:76f598af93aab50328d2a69c786beaedc8b6a7770f7a818cc307eb353debfffb", size = 6946 }, +] + [[package]] name = "packaging" version = "24.1" @@ -187,6 +421,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, ] +[[package]] +name = "python-multipart" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/6e/7ecfe1366b9270f7f475c76fcfa28812493a6a1abd489b2433851a444f4f/python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb", size = 35713 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/0b/c316262244abea7481f95f1e91d7575f3dfcf6455d56d1ffe9839c582eb1/python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf", size = 23246 }, +] + +[[package]] +name = "ruff" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/c7/f3367d1da5d568192968c5c9e7f3d51fb317b9ac04828493b23d8fce8ce6/ruff-0.7.0.tar.gz", hash = "sha256:47a86360cf62d9cd53ebfb0b5eb0e882193fc191c6d717e8bef4462bc3b9ea2b", size = 3146645 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/59/a0275a0913f3539498d116046dd679cd657fe3b7caf5afe1733319414932/ruff-0.7.0-py3-none-linux_armv6l.whl", hash = "sha256:0cdf20c2b6ff98e37df47b2b0bd3a34aaa155f59a11182c1303cce79be715628", size = 10434007 }, + { url = "https://files.pythonhosted.org/packages/cd/94/da0ba5f956d04c90dd899209904210600009dcda039ce840d83eb4298c7d/ruff-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:496494d350c7fdeb36ca4ef1c9f21d80d182423718782222c29b3e72b3512737", size = 10048066 }, + { url = "https://files.pythonhosted.org/packages/57/1d/e5cc149ecc46e4f203403a79ccd170fad52d316f98b87d0f63b1945567db/ruff-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:214b88498684e20b6b2b8852c01d50f0651f3cc6118dfa113b4def9f14faaf06", size = 9711389 }, + { url = "https://files.pythonhosted.org/packages/05/67/fb7ea2c869c539725a16c5bc294e9aa34f8b1b6fe702f1d173a5da517c2b/ruff-0.7.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630fce3fefe9844e91ea5bbf7ceadab4f9981f42b704fae011bb8efcaf5d84be", size = 10755174 }, + { url = "https://files.pythonhosted.org/packages/5f/f0/13703bc50536a0613ea3dce991116e5f0917a1f05528c6ab738b33c08d3f/ruff-0.7.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:211d877674e9373d4bb0f1c80f97a0201c61bcd1e9d045b6e9726adc42c156aa", size = 10196040 }, + { url = "https://files.pythonhosted.org/packages/99/c1/77b04ab20324ab03d333522ee55fb0f1c38e3ca0d326b4905f82ce6b6c70/ruff-0.7.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:194d6c46c98c73949a106425ed40a576f52291c12bc21399eb8f13a0f7073495", size = 11033684 }, + { url = "https://files.pythonhosted.org/packages/f2/97/f463334dc4efeea3551cd109163df15561c18a1c3ec13d51643740fd36ba/ruff-0.7.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82c2579b82b9973a110fab281860403b397c08c403de92de19568f32f7178598", size = 11803700 }, + { url = "https://files.pythonhosted.org/packages/b4/f8/a31d40c4bb92933d376a53e7c5d0245d9b27841357e4820e96d38f54b480/ruff-0.7.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9af971fe85dcd5eaed8f585ddbc6bdbe8c217fb8fcf510ea6bca5bdfff56040e", size = 11347848 }, + { url = "https://files.pythonhosted.org/packages/83/62/0c133b35ddaf91c65c30a56718b80bdef36bfffc35684d29e3a4878e0ea3/ruff-0.7.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b641c7f16939b7d24b7bfc0be4102c56562a18281f84f635604e8a6989948914", size = 12480632 }, + { url = "https://files.pythonhosted.org/packages/46/96/464058dd1d980014fb5aa0a1254e78799efb3096fc7a4823cd66a1621276/ruff-0.7.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71672336e46b34e0c90a790afeac8a31954fd42872c1f6adaea1dff76fd44f9", size = 10941919 }, + { url = "https://files.pythonhosted.org/packages/a0/f7/bda37ec77986a435dde44e1f59374aebf4282a5fa9cf17735315b847141f/ruff-0.7.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ab7d98c7eed355166f367597e513a6c82408df4181a937628dbec79abb2a1fe4", size = 10745519 }, + { url = "https://files.pythonhosted.org/packages/c2/33/5f77fc317027c057b61a848020a47442a1cbf12e592df0e41e21f4d0f3bd/ruff-0.7.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1eb54986f770f49edb14f71d33312d79e00e629a57387382200b1ef12d6a4ef9", size = 10284872 }, + { url = "https://files.pythonhosted.org/packages/ff/50/98aec292bc9537f640b8d031c55f3414bf15b6ed13b3e943fed75ac927b9/ruff-0.7.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dc452ba6f2bb9cf8726a84aa877061a2462afe9ae0ea1d411c53d226661c601d", size = 10600334 }, + { url = "https://files.pythonhosted.org/packages/f2/85/12607ae3201423a179b8cfadc7cb1e57d02cd0135e45bd0445acb4cef327/ruff-0.7.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4b406c2dce5be9bad59f2de26139a86017a517e6bcd2688da515481c05a2cb11", size = 11017333 }, + { url = "https://files.pythonhosted.org/packages/d4/7f/3b85a56879e705d5f46ec14daf8a439fca05c3081720fe3dc3209100922d/ruff-0.7.0-py3-none-win32.whl", hash = "sha256:f6c968509f767776f524a8430426539587d5ec5c662f6addb6aa25bc2e8195ec", size = 8570962 }, + { url = "https://files.pythonhosted.org/packages/39/9f/c5ee2b40d377354dabcc23cff47eb299de4b4d06d345068f8f8cc1eadac8/ruff-0.7.0-py3-none-win_amd64.whl", hash = "sha256:ff4aabfbaaba880e85d394603b9e75d32b0693152e16fa659a3064a85df7fce2", size = 9365544 }, + { url = "https://files.pythonhosted.org/packages/89/8b/ee1509f60148cecba644aa718f6633216784302458340311898aaf0b1bed/ruff-0.7.0-py3-none-win_arm64.whl", hash = "sha256:10842f69c245e78d6adec7e1db0a7d9ddc2fff0621d730e61657b64fa36f207e", size = 8695763 }, +] + +[[package]] +name = "setuptools" +version = "75.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/37/b31be7e4b9f13b59cde9dcaeff112d401d49e0dc5b37ed4a9fc8fb12f409/setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec", size = 1350308 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/2d/90165d51ecd38f9a02c6832198c13a4e48652485e2ccf863ebb942c531b6/setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8", size = 1249825 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "starlette" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/53/c3a36690a923706e7ac841f649c64f5108889ab1ec44218dac45771f252a/starlette-0.41.0.tar.gz", hash = "sha256:39cbd8768b107d68bfe1ff1672b38a2c38b49777de46d2a592841d58e3bf7c2a", size = 2573755 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/c6/a4443bfabf5629129512ca0e07866c4c3c094079ba4e9b2551006927253c/starlette-0.41.0-py3-none-any.whl", hash = "sha256:a0193a3c413ebc9c78bff1c3546a45bb8c8bcb4a84cae8747d650a65bd37210a", size = 73216 }, +] + [[package]] name = "tomli" version = "2.0.1" @@ -204,3 +502,65 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3 wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] + +[[package]] +name = "uvicorn" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/fc/1d785078eefd6945f3e5bab5c076e4230698046231eb0f3747bc5c8fa992/uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e", size = 77564 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/78bd0e95dd2444b6caacbca2b730671d4295ccb628ef58b81bee903629df/uvicorn-0.32.0-py3-none-any.whl", hash = "sha256:60b8f3a5ac027dcd31448f411ced12b5ef452c646f76f02f8cc3f25d8d26fd82", size = 63723 }, +] + +[[package]] +name = "wrapt" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/c6/5375258add3777494671d8cec27cdf5402abd91016dee24aa2972c61fedf/wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4", size = 37315 }, + { url = "https://files.pythonhosted.org/packages/32/12/e11adfde33444986135d8881b401e4de6cbb4cced046edc6b464e6ad7547/wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020", size = 38160 }, + { url = "https://files.pythonhosted.org/packages/70/7d/3dcc4a7e96f8d3e398450ec7703db384413f79bd6c0196e0e139055ce00f/wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440", size = 80419 }, + { url = "https://files.pythonhosted.org/packages/d1/c4/8dfdc3c2f0b38be85c8d9fdf0011ebad2f54e40897f9549a356bebb63a97/wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487", size = 72669 }, + { url = "https://files.pythonhosted.org/packages/49/83/b40bc1ad04a868b5b5bcec86349f06c1ee1ea7afe51dc3e46131e4f39308/wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf", size = 80271 }, + { url = "https://files.pythonhosted.org/packages/19/d4/cd33d3a82df73a064c9b6401d14f346e1d2fb372885f0295516ec08ed2ee/wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72", size = 84748 }, + { url = "https://files.pythonhosted.org/packages/ef/58/2fde309415b5fa98fd8f5f4a11886cbf276824c4c64d45a39da342fff6fe/wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0", size = 77522 }, + { url = "https://files.pythonhosted.org/packages/07/44/359e4724a92369b88dbf09878a7cde7393cf3da885567ea898e5904049a3/wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136", size = 84780 }, + { url = "https://files.pythonhosted.org/packages/88/8f/706f2fee019360cc1da652353330350c76aa5746b4e191082e45d6838faf/wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d", size = 35335 }, + { url = "https://files.pythonhosted.org/packages/19/2b/548d23362e3002ebbfaefe649b833fa43f6ca37ac3e95472130c4b69e0b4/wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2", size = 37528 }, + { url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313 }, + { url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164 }, + { url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890 }, + { url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118 }, + { url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746 }, + { url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556 }, + { url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712 }, + { url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327 }, + { url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523 }, + { url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614 }, + { url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316 }, + { url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322 }, + { url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055 }, + { url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291 }, + { url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374 }, + { url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896 }, + { url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738 }, + { url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568 }, + { url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653 }, + { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, +] + +[[package]] +name = "zipp" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200 }, +]