diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1b9bad82a49b1f9c9c9cfcf85c3a89d57cd2a555..7334b8809f68f04c2da6e9cbd01098cea6955a3f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,6 @@ stages: - .pre + - tests - build - deploy @@ -7,15 +8,16 @@ variables: GIT_DEPTH: 0 GIT_STRATEGY: clone GIT_SUBMODULE_STRATEGY: recursive - # uses registry.gitlab.syncad.com/hive/hive/ci-base-image:ubuntu22.04-8 - CI_BASE_IMAGE_TAG: "@sha256:924cb31153b5512ef0263ac18ea57d0d36559a284154ee081e8d6b3e68b4b640" + # uses registry.gitlab.syncad.com/hive/hive/ci-base-image:ubuntu24.04-3 + CI_BASE_IMAGE_TAG: "sha256:df37016fb348b85f9da4e9b0e4d2ef6c231190fd33d9cc42806b6e315629da71" CI_BASE_IMAGE: "registry.gitlab.syncad.com/hive/hive/ci-base-image${CI_BASE_IMAGE_TAG}" include: - project: 'hive/common-ci-configuration' - ref: 5bf5c96f868f4a225705d13c0f8512ec36e9e2e1 + ref: ab4d4c78df8ebc5b5d7744fe2cfa64c51143fe01 file: - '/templates/npm_projects.gitlab-ci.yml' + - 'templates/python_projects.gitlab-ci.yml' image: "${CI_BASE_IMAGE}" @@ -29,6 +31,28 @@ lint: script: - npm run lint-ci +pre_commit_checks: + stage: .pre + extends: .pre_commit_checks_template + variables: + PYPROJECT_DIR: "${CI_PROJECT_DIR}/python" + artifacts: + when: always + expire_in: 1 week + paths: + - "${PYPROJECT_DIR}/poetry.lock" + + +tests: + stage: tests + extends: .project_develop_configuration_template + variables: + PYPROJECT_DIR: "${CI_PROJECT_DIR}/python" + needs: + - job: pre_commit_checks + script: + - pytest "${PYPROJECT_DIR}/tests/" + build: stage: build extends: .npm_build_template @@ -43,6 +67,14 @@ build: when: always expire_in: 1 week +build_wheel: + stage: build + extends: .build_wheel_template + variables: + PYPROJECT_DIR: "${CI_PROJECT_DIR}/python" + needs: + - job: pre_commit_checks + deploy_dev_package: stage: deploy extends: .npm_deploy_package_template @@ -63,3 +95,13 @@ deploy_production_public_npm: needs: - job: lint - job: build + +deploy_wheel_to_gitlab: + stage: deploy + extends: .deploy_wheel_to_gitlab_template + variables: + PYPROJECT_DIR: "${CI_PROJECT_DIR}/python" + needs: + - job: pre_commit_checks + - job: build_wheel + when: on_success diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..df83c158ff9488ca936834d1b67a029e3f56d2cc --- /dev/null +++ b/python/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +files: ^python/.*$ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-added-large-files + - id: check-merge-conflict + - id: check-yaml + args: [ "--unsafe" ] + - id: check-json + - id: pretty-format-json + args: [ "--autofix" ] + - id: trailing-whitespace + - id: end-of-file-fixer + - repo: https://github.com/python-poetry/poetry + rev: 2.1.3 + hooks: + - id: poetry-lock + name: checking if poetry.lock is consistent with pyproject.toml + language_version: python3.12 + args: [ "-C", "./python" ] + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: 'v0.11.5' + hooks: + - id: ruff + name: linting code with Ruff + args: [ "--fix" ] + - id: ruff-format + name: format code using Ruff formatter + - repo: local + hooks: + - id: mypy + name: type check with mypy + entry: mypy + language: system + types: [ python ] diff --git a/python/api_client_generator/__init__.py b/python/api_client_generator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6c8e6b979c5f58121ac7ee2d9e024749da3a8ce1 --- /dev/null +++ b/python/api_client_generator/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/python/api_client_generator/_private/__init__.py b/python/api_client_generator/_private/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/api_client_generator/_private/check_whether_was_ran_as_script.py b/python/api_client_generator/_private/check_whether_was_ran_as_script.py new file mode 100644 index 0000000000000000000000000000000000000000..ebd67202c91da276c438b828e01b8cf269c9172f --- /dev/null +++ b/python/api_client_generator/_private/check_whether_was_ran_as_script.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from api_client_generator.exceptions import RunningScriptWithoutAppropriateFlagError + + +def check_whether_was_ran_as_script() -> None: + """Check if the script is run as a script. If so, raise an error.""" + + if __name__ == "__main__": + if __package__ is None: + raise RunningScriptWithoutAppropriateFlagError diff --git a/python/api_client_generator/_private/client_class_factory/__init__.py b/python/api_client_generator/_private/client_class_factory/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9771065d90ae019ce716cda0612ee2c97ea93b9c --- /dev/null +++ b/python/api_client_generator/_private/client_class_factory/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .json_rpc import create_api_client as create_json_rpc_api_client + +__all__ = ["create_json_rpc_api_client"] diff --git a/python/api_client_generator/_private/client_class_factory/common.py b/python/api_client_generator/_private/client_class_factory/common.py new file mode 100644 index 0000000000000000000000000000000000000000..390ebfb90bc5c7b2b103396e48ca90d7999d202b --- /dev/null +++ b/python/api_client_generator/_private/client_class_factory/common.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import ast + +from api_client_generator._private.common.converters import snake_to_camel +from api_client_generator._private.common.models_aliased import ( + BaseApiClass, + EndpointsDescription, + EndpointsFactory, +) +from api_client_generator._private.resolve_needed_imports import is_struct +from api_client_generator.exceptions import EndpointParamsIsNotMsgspecStructError + + +def create_api_client( # NOQA: PLR0913 + api_name: str, + endpoints: EndpointsDescription, + endpoint_factory: EndpointsFactory, + base_class: type[BaseApiClass] | str, + endpoint_decorator: str, + *, + asynchronous: bool = True, +) -> ast.ClassDef: + """ + Creates a client class for the given API name and endpoints. + + Args: + api_name: The name of the API. Will be used as class name (converted to the CamelCase). + endpoints: The endpoints description for the API. + endpoint_factory: The factory function to create endpoints. + base_class: The base class for the API client. + endpoint_decorator: The name of the endpoint decorator to be used. + asynchronous: If True, the endpoints will be created as asynchronous methods. + + Raises: + EndpointParamsIsNotDataclassError: If the endpoint parameters are not a dataclass. + """ + methods = [] + + for endpoint_name, endpoint_parameters in endpoints.items(): + params = endpoint_parameters.get("params", None) + + if params is not None and not is_struct(params): + raise EndpointParamsIsNotMsgspecStructError(endpoint_name) + + result = endpoint_parameters.get("result", None) + description = endpoint_parameters.get("description", None) + response_array = endpoint_parameters.get("response_array", False) + + methods.append( + endpoint_factory( + endpoint_name, + params, + result, + endpoint_decorator, + str(description) if description else None, + response_array=response_array, + asynchronous=asynchronous, + ) + ) + + base_class_name = base_class if isinstance(base_class, str) else base_class.__name__ + + endpoint_decorator_assign = ast.Assign( # Assign endpoint decorator as class variable + targets=[ast.Name(id=endpoint_decorator)], + value=ast.Attribute( + value=ast.Name(id=base_class_name), + attr=endpoint_decorator, + ), + ) + + body: list[ast.stmt] = [endpoint_decorator_assign, *methods] + + return ast.ClassDef( + name=snake_to_camel(api_name), + bases=[ast.Name(id=base_class_name)], + keywords=[], + body=body, + decorator_list=[], + type_params=[], + ) diff --git a/python/api_client_generator/_private/client_class_factory/json_rpc.py b/python/api_client_generator/_private/client_class_factory/json_rpc.py new file mode 100644 index 0000000000000000000000000000000000000000..3d5c1906a28475a3614bc694a5e3ab7e2e8edd2f --- /dev/null +++ b/python/api_client_generator/_private/client_class_factory/json_rpc.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from api_client_generator._private.client_class_factory.common import ( + create_api_client as common_create_api_client, +) +from api_client_generator._private.common.defaults import DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME +from api_client_generator._private.common.models_aliased import ( + BaseApiClass, + EndpointsDescription, +) +from api_client_generator._private.endpoints_factory import create_json_rpc_endpoint + +if TYPE_CHECKING: + import ast + + +def create_api_client( + api_name: str, + endpoints: EndpointsDescription, + base_class: type[BaseApiClass] | str, + endpoint_decorator: str = DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, + *, + asynchronous: bool = True, +) -> ast.ClassDef: + """ + Creates a client class for the given API name and endpoints. + + + Args: + api_name: The name of the API. Will be used as class name (converted to the CamelCase). + endpoints: The endpoints description for the API. + base_class: The base class for the API client. + endpoint_decorator: The name of the endpoint decorator to be used. + asynchronous: If True, the endpoints will be created as asynchronous methods. + """ + + return common_create_api_client( + api_name, + endpoints, + create_json_rpc_endpoint, # type: ignore[arg-type] + base_class, + endpoint_decorator, + asynchronous=asynchronous, + ) diff --git a/python/api_client_generator/_private/common/__init__.py b/python/api_client_generator/_private/common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/api_client_generator/_private/common/converters.py b/python/api_client_generator/_private/common/converters.py new file mode 100644 index 0000000000000000000000000000000000000000..e6cbed30c5c5ad4e851a31e17817303796df53f5 --- /dev/null +++ b/python/api_client_generator/_private/common/converters.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import re + + +def snake_to_camel(name: str) -> str: + """Converts a snake_case string to CamelCase.""" + return "".join(word.capitalize() for word in name.split("_")) + + +def camel_to_snake(name: str) -> str: + """Converts a CamelCase string to snake_case.""" + s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower() + + +def hyphen_to_snake(name: str) -> str: + """Converts a hyphenated-string to snake_case.""" + return name.replace("-", "_") diff --git a/python/api_client_generator/_private/common/defaults.py b/python/api_client_generator/_private/common/defaults.py new file mode 100644 index 0000000000000000000000000000000000000000..0056c060dba93859ab3e7772bb13c03c4676c129 --- /dev/null +++ b/python/api_client_generator/_private/common/defaults.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Final + +DEFAULT_API_COLLECTION_NAME: Final[str] = "ApiCollection" +DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME: Final[str] = "endpoint_jsonrpc" +DEFAULT_ENDPOINT_REST_DECORATOR_NAME: Final[str] = "endpoint_rest" +DEFAULT_IMPORT_LEVEL: Final[int] = 0 +DEFAULT_LINE_LENGTH_FOR_RUFF: Final[int] = 120 diff --git a/python/api_client_generator/_private/common/generated_class.py b/python/api_client_generator/_private/common/generated_class.py new file mode 100644 index 0000000000000000000000000000000000000000..6e07e634f89c423dccdf4509f906480a40f17154 --- /dev/null +++ b/python/api_client_generator/_private/common/generated_class.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import ast + + +@dataclass +class GeneratedClass: + """Represents a generated class with its definition and imports.""" + + class_def: ast.ClassDef + imports: list[ast.ImportFrom] diff --git a/python/api_client_generator/_private/common/get_type_from_ref_in_camel.py b/python/api_client_generator/_private/common/get_type_from_ref_in_camel.py new file mode 100644 index 0000000000000000000000000000000000000000..363284d4c3fa43eb938a7a58fb726980d17ed04b --- /dev/null +++ b/python/api_client_generator/_private/common/get_type_from_ref_in_camel.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from api_client_generator._private.common.converters import snake_to_camel +from api_client_generator._private.description_tools import get_last_part_of_ref + + +def get_type_from_ref_in_camel(ref: str) -> str: + """Get type from OpenAPI $ref property and convert it to the CamelCase.""" + last_part = get_last_part_of_ref(ref) + type_name = last_part.split(".")[-1] if "." in last_part else last_part + + return snake_to_camel(type_name) diff --git a/python/api_client_generator/_private/common/models_aliased.py b/python/api_client_generator/_private/common/models_aliased.py new file mode 100644 index 0000000000000000000000000000000000000000..678be7c102168941e7c1803474415693320f696a --- /dev/null +++ b/python/api_client_generator/_private/common/models_aliased.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, TypeAlias, cast + +if TYPE_CHECKING: + import ast + from struct import Struct + +AnyJson: TypeAlias = dict[str, "AnyJson"] | list["AnyJson"] | tuple["AnyJson", ...] | str | int | float | bool | None +SwaggerReadyForExtraction: TypeAlias = dict[str, AnyJson] + +EndpointDescriptionBeforeProcessing: TypeAlias = dict[str, str | bool] +""" +A description of an endpoint, params, result, description and response_array boolean parameter. +This is used before processing the endpoint description. At this stage, the params and result are still strings. +""" +ApiDescriptionBeforeProcessing: TypeAlias = dict[str, dict[str, EndpointDescriptionBeforeProcessing]] +EndpointsDescription: TypeAlias = dict[str, dict[str, Any]] +ApiDescription: TypeAlias = dict[str, EndpointsDescription] + + +class Importable(Protocol): + """A protocol that defines the structure of an importable object.""" + + __module__: str + __name__: str + + +class EndpointsFactory(Protocol): + def __call__( # NOQA: PLR0913 + self, + name: str, + params: Struct | None, + result: Importable | None, + endpoint_decorator: str, + description: str | None, + *, + response_array: bool, + asynchronous: bool, + ) -> ast.AsyncFunctionDef | ast.FunctionDef: ... + + +class BaseApiClass(Protocol): + """A protocol that defines the structure of a base class.""" + + __module__: str + __name__: str + + endpoint: EndpointsFactory + + +def ensure_is_importable(potential_importable: Any) -> Importable: + """Ensure that the object is importable.""" + assert hasattr(potential_importable, "__module__") and hasattr(potential_importable, "__name__"), ( + f"Object {potential_importable} is not importable. It must have __module__ and __name__ attributes." + ) + return cast(Importable, potential_importable) + + +class ClientClassFactory(Protocol): + def __call__( + self, + api_name: str, + endpoints: EndpointsDescription, + base_class: type[BaseApiClass] | str, + endpoint_decorator: str, + *, + asynchronous: bool, + ) -> ast.ClassDef: ... diff --git a/python/api_client_generator/_private/common/openapi_to_python_type.py b/python/api_client_generator/_private/common/openapi_to_python_type.py new file mode 100644 index 0000000000000000000000000000000000000000..b4d6d474c4936814829e2b3b55f10fa8d01d4384 --- /dev/null +++ b/python/api_client_generator/_private/common/openapi_to_python_type.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +OPENAPI_BASIC_TYPES_MAPPING: dict[str, type] = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, +} + + +def convert_openapi_type_to_python_type(openapi_type: str) -> type: + """ + Convert OpenAPI type to Python type. + + Args: + openapi_type: The OpenAPI type to convert. + + Returns: + The corresponding Python type. + + Raises: + ValueError: If the OpenAPI type is not supported. + """ + + if openapi_type not in OPENAPI_BASIC_TYPES_MAPPING: + raise ValueError( + f"Unsupported OpenAPI type: {openapi_type}. Supported types are: {', '.join(OPENAPI_BASIC_TYPES_MAPPING.keys())}." + ) + + return OPENAPI_BASIC_TYPES_MAPPING[openapi_type] diff --git a/python/api_client_generator/_private/create_collection_class.py b/python/api_client_generator/_private/create_collection_class.py new file mode 100644 index 0000000000000000000000000000000000000000..d54c2e85aad8828d1c4b51cfa38809ef69aabf82 --- /dev/null +++ b/python/api_client_generator/_private/create_collection_class.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import ast +from typing import Iterable + +from api_client_generator._private.common.converters import camel_to_snake + + +def create_collection_class(collection_name: str, api_client_classes: Iterable[ast.ClassDef]) -> ast.ClassDef: + """ + Creates a collection class for the given API client classes. + + Args: + collection_name: The name of the collection class. + api_client_classes An iterable of API client class definitions. + + Example: + create_collection_class("MyCollection", [FirstApiClientClass, SecondApiClientClass]) + + output: + class MyCollection: + def __init__(self): + super().__init__() + self.first_api_client_class = FirstApiClientClass + self.second_api_client_class = SecondApiClientClass + """ + init_body: list[ast.stmt] = [ + ast.Assign( + targets=[ + ast.Attribute( + value=ast.Name( + id="self", + ), + attr=camel_to_snake(api_client.name), + ) + ], + value=ast.Name(id=api_client.name), + ) + for api_client in api_client_classes + ] + + class_body: list[ast.stmt] = [ + ast.FunctionDef( + name="__init__", + args=ast.arguments( + posonlyargs=[], + args=[ast.arg(arg="self")], + kwonlyargs=[], + kw_defaults=[], + defaults=[], + ), + body=init_body, + decorator_list=[], + returns=None, + type_params=[], + ) + ] + + return ast.ClassDef( + name=collection_name, + bases=[], + keywords=[], + body=class_body, + decorator_list=[], + type_params=[], + ) diff --git a/python/api_client_generator/_private/description_tools.py b/python/api_client_generator/_private/description_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..19b33691e4a65f4bfddcb6db3654277936afd29f --- /dev/null +++ b/python/api_client_generator/_private/description_tools.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import ast +from typing import Any, TypeAlias + +from api_client_generator._private.common.models_aliased import ( + ApiDescription, + SwaggerReadyForExtraction, +) + +AliasToAssign: TypeAlias = tuple[str, type] + + +def get_value_from_swagger_part_recursively( + swagger_part: SwaggerReadyForExtraction, + keys: tuple[str, ...], +) -> Any: + """Get a value from the swagger part by a path of keys.""" + assert isinstance(swagger_part, dict), "This swagger part must be a dictionary." + + key = keys[0] + value = swagger_part[key] + + if len(keys) == 1: # Last key in the path - return searched value + return value + + assert isinstance(value, dict), "This swagger part must be a dictionary." + + return get_value_from_swagger_part_recursively(value, keys[1:]) + + +def get_ref_from_schema(response: SwaggerReadyForExtraction) -> str: + """ + Resolve the reference from the schema. + + Example: + some_responses = { + "responses": { + "200": { + "description": "Some operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/some_response" + } + } + } + } + } + } + + get_ref_from_schema(some_responses["responses"]["200"]) + >>> "#/components/schemas/some_response" + """ + ref = get_value_from_swagger_part_recursively( + response, + ( + "content", + "application/json", + "schema", + "$ref", + ), + ) + assert isinstance(ref, str), "Reference must be a string." + + return ref + + +def get_last_part_of_ref(ref: str) -> str: + """Return the last part of the #ref string.""" + + return ref.split("/")[-1] + + +def get_description_for_endpoint(endpoint_properties: SwaggerReadyForExtraction) -> str: + """ + Resolve the description from the endpoint properties. + + Example: + example = { + "some_api.some_endpoint": { + "post": { + "tags": ["some_api"], + "summary": "Just example.", + "description": "Some description.", + } + } + } + + get_description_for_endpoint(example["some_api.some_endpoint"]) + >>> "Some description." + """ + post_properties = endpoint_properties["post"] + + assert isinstance(post_properties, dict), "Post properties must be a dictionary." + + description = post_properties.get("description", "") + assert isinstance(description, str), "Description must of the endpoint be a string." + + return description + + +def get_result_name_for_endpoint(endpoint_properties: SwaggerReadyForExtraction) -> str: + """ + Resolve the result from the endpoint properties. + + Example: + example = { + "some_api.some_endpoint": { + "post": { + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/some_endpoint_response" + } + } + } + } + } + } + } + } + get_result_name_for_endpoint(example["some_api.some_endpoint"]) + >>> "some_endpoint_response" + """ + response = get_value_from_swagger_part_recursively( + endpoint_properties, + ( + "post", + "responses", + "200", + ), + ) + ref = get_ref_from_schema(response) + return get_last_part_of_ref(ref) + + +def get_params_name_for_endpoint(endpoint_properties: SwaggerReadyForExtraction) -> str | None: + """ + Resolve the params from the endpoint properties. + + Example: + example = { + "some_api.some_endpoint": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/some_ednpoint" + } + } + }, + "required": true + }, + } + } + } + get_params_name_for_endpoint(example["some_api.some_endpoint"]) + >>> "some_ednpoint" + """ + post_properties = endpoint_properties["post"] + + assert isinstance(post_properties, dict), "Post properties must be a dictionary." + + request_body = post_properties.get("requestBody") + if request_body is None: + return None + + assert isinstance(request_body, dict), "Request body must be a dictionary." + ref = get_ref_from_schema(request_body) + + return get_last_part_of_ref(ref) + + +def is_result_array(result: str, components: SwaggerReadyForExtraction) -> bool: + """Check if the response of the endpoint is an array.""" + + response_schema = components.get(result) + assert response_schema is not None, f"Not found response schema for the {result}." + + assert isinstance(response_schema, dict), "Response schema must be a dictionary." + return response_schema.get("type") == "array" + + +def create_api_description_module( + api_description_name: str, + api_description: ApiDescription, + additional_aliases: tuple[AliasToAssign] | None = None, +) -> ast.Module: + assign = ast.Assign( + targets=[ast.Name(id=api_description_name, ctx=ast.Store())], + value=ast.Dict( + keys=[ast.Constant(value=api_name) for api_name in api_description], + values=[ + ast.Dict( + keys=[ast.Constant(value=endpoint) for endpoint in api_description[api_name]], + values=[ + ast.Dict( + keys=[ast.Constant(value=param_name) for param_name in params], + values=[ + ast.Name(id=param_value, ctx=ast.Load()) + if param_name not in ("response_array", "description") + else ast.Constant(value=param_value) + for param_name, param_value in params.items() + ], + ) + for params in api_description[api_name].values() + ], + ) + for api_name in api_description + ], + ), + ) + + body: list[ast.stmt] = [assign] + + if additional_aliases: + for alias in additional_aliases: + body.insert( + 0, + ast.Assign( + targets=[ast.Name(id=alias[0])], + value=ast.Name(id=alias[1].__name__), + ), + ) + + return ast.Module(body=body, type_ignores=[]) + + +def get_api_name_from_server_property(swagger: SwaggerReadyForExtraction) -> str: + """ + Get the API name from the server property of the swagger. + + Example: + swagger = { + "servers": [ + { + "url": "/some-api, + } + ] + } + + get_api_name_from_server_property(swagger) + >>> "some-api" + """ + servers = swagger["servers"] + assert isinstance(servers, list), "Servers must be a list." + + assert len(servers) == 1, "Swagger must have exactly one server." + + server = servers[0] + assert isinstance(server, dict), "Server must be a dictionary." + server_url = server["url"] + + assert isinstance(server_url, str), "Server URL must be a string." + + split_server_url = server_url.split("/") + split_server_url.remove("") # Remove empty strings from the list + + if len(split_server_url) > 1: + return "_".join(split_server_url) # If the URL contains slashes, join them with underscores + + return split_server_url[0] + + +def get_types_name_from_components(swagger: SwaggerReadyForExtraction) -> str: + """ + Get the names of the types from the components of the swagger. + + Example: + swagger = "components": { + "schemas": { + "some_types.some_type": { + "type": "string", + "enum": [ + "post", + "comment", + "all" + ] + }, + } + } + + get_types_name_from_components(components) + >>> "some_types" + """ + schemas_in_swagger = get_value_from_swagger_part_recursively(swagger, ("components", "schemas")) + + first_type = next(iter(schemas_in_swagger)) + assert isinstance(first_type, str), "First type in the schemas must be a string." + return first_type.split(".")[0] diff --git a/python/api_client_generator/_private/endpoints_factory/__init__.py b/python/api_client_generator/_private/endpoints_factory/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..45a844958ef6c6e0ad0c7968b1441fcaeba3be8e --- /dev/null +++ b/python/api_client_generator/_private/endpoints_factory/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .json_rpc import create_endpoint as create_json_rpc_endpoint + +__all__ = [ + "create_json_rpc_endpoint", +] diff --git a/python/api_client_generator/_private/endpoints_factory/common.py b/python/api_client_generator/_private/endpoints_factory/common.py new file mode 100644 index 0000000000000000000000000000000000000000..bd1eeb665bb9969f7d6bb3f18987678e9652faa6 --- /dev/null +++ b/python/api_client_generator/_private/endpoints_factory/common.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import ast + +from api_client_generator._private.common.models_aliased import Importable + + +def create_endpoint( # NOQA: PLR0913 + name: str, + endpoint_arguments: ast.arguments, + endpoint_decorator: str, + result_type: Importable | str | None = None, + description: str | None = None, + *, + response_array: bool = False, + asynchronous: bool = True, +) -> ast.AsyncFunctionDef | ast.FunctionDef: + """ + Create endpoint method. + + Args: + name: The name of the endpoint. + endpoint_arguments: The arguments for the endpoint. + endpoint_decorator: The name of the endpoint decorator to be used. + result_type: The type of the result. + description: The description of the endpoint. + response_array: If True, the result type will be a list of the result type. + asynchronous: If True, the endpoint will be created as an asynchronous method. + + Notes: + - The method body contains an ellipsis (`...`), decorator do all the work. + - If `result_type` is provided, it will be used as the return type hint. + - The first argument is always `self` (function always add it automatically). + + Returns: + ast.AsyncFunctionDef | ast.FunctionDef: The AST representation of the endpoint method. + """ + endpoint_arguments.args.insert(0, ast.arg(arg="self")) + body: list[ast.stmt] = [ + ast.Expr(value=ast.Constant(value=Ellipsis)) + if not description + else ast.Expr(value=ast.Constant(value=description)) + ] + + returns: ast.Name | None + + if isinstance(result_type, str): + returns = ast.Name(id=result_type) + else: + returns = ( + ast.Name(id=result_type.__name__ if not response_array else f"list[{result_type.__name__}]") + if result_type is not None + else None + ) + + function_def: type[ast.FunctionDef] | type[ast.AsyncFunctionDef] = ( + ast.AsyncFunctionDef if asynchronous else ast.FunctionDef + ) + + return function_def( + name=name, + args=endpoint_arguments, + body=body, + decorator_list=[ast.Name(id=endpoint_decorator)], + returns=returns, + type_params=[], + ) diff --git a/python/api_client_generator/_private/endpoints_factory/json_rpc.py b/python/api_client_generator/_private/endpoints_factory/json_rpc.py new file mode 100644 index 0000000000000000000000000000000000000000..f24d8265a1e7bf71404913034b01517651c644c5 --- /dev/null +++ b/python/api_client_generator/_private/endpoints_factory/json_rpc.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import ast + +from msgspec import NODEFAULT, Struct +from msgspec.structs import fields + +from api_client_generator._private.common.defaults import DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME +from api_client_generator._private.common.models_aliased import Importable +from api_client_generator._private.endpoints_factory.common import ( + create_endpoint as create_endpoint_common, +) +from api_client_generator._private.resolve_needed_imports import is_struct +from api_client_generator.exceptions import EndpointParamsIsNotMsgspecStructError + + +def create_endpoint( # NOQA: PLR0913 + name: str, + params: Struct | None = None, + result: Importable | None = None, + endpoint_decorator: str = DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, + description: str | None = None, + *, + response_array: bool = False, + asynchronous: bool = True, +) -> ast.AsyncFunctionDef | ast.FunctionDef: + """ + Create JSON-RPC endpoint method. + + Args: + name: The name of the endpoint. + params: A msgspec struct of parameters. + result: The type of the result. + endpoint_decorator: The decorator for the endpoint. + description: The description of the endpoint. + response_array: If True, the result type will be a list of the result type. + asynchronous: If True, the endpoint will be created as an asynchronous method. + + Notice: + Please note that the `params` argument is expected to be a msgspec struct. + + Returns: + ast.AsyncFunctionDef | ast.FunctionDef: The AST representation of the endpoint method. + + Raises: + EndpointParamsIsNotMsgspecStructError: If the params is not a msgspec struct. + """ + + return create_endpoint_common( + name, + get_endpoint_args(params), + endpoint_decorator, + result, + description, + response_array=response_array, + asynchronous=asynchronous, + ) + + +def get_endpoint_args(params: Struct | None) -> ast.arguments: + """ + Generate arguments for the json-rpc api endpoint method. + + Args: + params: The msgspec struct representing the parameters for the API endpoint. + + Returns: + ast.arguments: The arguments for the API endpoint method. + + Raises: + EndpointParamsIsNotMsgspecStructError: If the params is not a msgspec struct. + """ + + arguments = ast.arguments( + posonlyargs=[], + args=[], + kwonlyargs=[], + kw_defaults=[], + kwarg=None, + defaults=[], + ) + + if params is None: + return arguments + + kwonlyargs: list[ast.arg] = [] + defaults: list[ast.expr | None] = [] + + if not is_struct(params): + raise EndpointParamsIsNotMsgspecStructError + + for param in fields(params): + if param.default is not NODEFAULT: + defaults.append(ast.Constant(value=param.default)) + else: + defaults.append(None) + + kwonlyargs.append( + ast.arg( + arg=param.name, + annotation=ast.Name(id=param.type.__name__), + ) + ) + + arguments.kwonlyargs = kwonlyargs + arguments.kw_defaults = defaults + return arguments diff --git a/python/api_client_generator/_private/endpoints_factory/rest.py b/python/api_client_generator/_private/endpoints_factory/rest.py new file mode 100644 index 0000000000000000000000000000000000000000..99222c948383e2f7331307d6e395c5c75aac8f9c --- /dev/null +++ b/python/api_client_generator/_private/endpoints_factory/rest.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING + +from api_client_generator._private.common.converters import hyphen_to_snake +from api_client_generator._private.common.defaults import DEFAULT_ENDPOINT_REST_DECORATOR_NAME +from api_client_generator._private.common.get_type_from_ref_in_camel import get_type_from_ref_in_camel +from api_client_generator._private.common.models_aliased import Importable +from api_client_generator._private.common.openapi_to_python_type import convert_openapi_type_to_python_type +from api_client_generator._private.description_tools import get_value_from_swagger_part_recursively +from api_client_generator._private.endpoints_factory.common import ( + create_endpoint as create_endpoint_common, +) +from api_client_generator._private.rest_api_tools.models_aliased import ( + PathParam, + QueryParam, + RestApiMethodType, +) + +if TYPE_CHECKING: + from api_client_generator._private.rest_api_tools.rest_method_model import RestApiMethod + + +def create_endpoint( # NOQA: PLR0913 + name: str, + url_path: str, + method: RestApiMethod | None = None, + result: Importable | str | None = None, + method_type: RestApiMethodType = "get", + endpoint_decorator: str = DEFAULT_ENDPOINT_REST_DECORATOR_NAME, + description: str | None = None, + *, + response_array: bool = False, + asynchronous: bool = True, +) -> ast.AsyncFunctionDef | ast.FunctionDef: + """ + Create an endpoint function definition. + + Args: + name: The name of the endpoint function. + url_path: The URL path of the endpoint. + method: The REST API method. + result: The expected result type of the endpoint. If str, will be used as a type hint exactly as it was passed. + method_type: The type of the REST API method (e.g., "get", "post"). + endpoint_decorator: The decorator to use for the endpoint. Note that method_type will be appended to it. + description: A description of the endpoint. + response_array: Whether the response is an array. + asynchronous: Whether the endpoint function should be asynchronous. + + Returns: + An AST representing the endpoint function definition. + """ + + endpoint_decorator += f""".{method_type}("{url_path}")""" + return create_endpoint_common( + name, + get_endpoint_args(method), + endpoint_decorator, + result, + description, + response_array=response_array, + asynchronous=asynchronous, + ) + + +def get_endpoint_args(params: RestApiMethod | None) -> ast.arguments: + arguments = ast.arguments( + posonlyargs=[], + args=[], + kwonlyargs=[], + kw_defaults=[], + kwarg=None, + defaults=[], + ) + + if params is None or params.parameters is None: + return arguments + + args: list[PathParam] = [] + kwonlyargs: list[QueryParam] = [] + defaults: list[ast.expr | None] = [] + + for param in params.parameters: + assert isinstance(param, dict), "Parameter of the REST API endpoint must be a dictionary." + + schema = param["schema"] + assert isinstance(schema, dict), "Parameter schema must be a dictionary." + default = schema.get("default", None) + + if ( + default is not None or param["in"] == "query" + ): # query param must have a default value to properly generate as the kwonly argument + defaults.append(ast.Constant(value=default)) + + annotation = ast.Name( + id=convert_openapi_type_to_python_type( + get_value_from_swagger_part_recursively( + param, + ( + "schema", + "type", + ), + ) + ).__name__ + if param["schema"].get("type") # type: ignore[union-attr] + else get_type_from_ref_in_camel( + get_value_from_swagger_part_recursively( + param, + ( + "schema", + "$ref", + ), + ) + ) + ) + + if not param.get("required"): + annotation.id += " | None" + + argument_name = param.get("name") + assert isinstance(argument_name, str), "Parameter name must be a string." + argument = ast.arg(arg=hyphen_to_snake(argument_name), annotation=annotation) + + if param["in"] == "path": + args.append(argument) + else: + kwonlyargs.append(argument) + + arguments.kwonlyargs = kwonlyargs + arguments.args = args + arguments.kw_defaults = defaults + return arguments diff --git a/python/api_client_generator/_private/export_client_module_to_file.py b/python/api_client_generator/_private/export_client_module_to_file.py new file mode 100644 index 0000000000000000000000000000000000000000..169c742c44f761e6e57d4e8afe3e278825a1a2a4 --- /dev/null +++ b/python/api_client_generator/_private/export_client_module_to_file.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Literal + +from api_client_generator._private.format_using_ruff import format_using_ruff + + +def export_module_to_file(module: ast.Module, mode: Literal["w", "a"] = "w", file_path: Path | None = None) -> None: + """ + Export an AST module to a Python file. Also formats the code using Black. + + Args: + module: The AST module to export. + mode: The file mode to use when writing the file. + file_path: The path to the file where the module will be saved. + If None, defaults to "cwd/generated_api_client.py". + """ + + ast.fix_missing_locations(module) + module_code = ast.unparse(module) + + formatted_module = format_using_ruff(module_code) + + if file_path is None: + file_path = Path.cwd() / "generated_api_client.py" + + with file_path.open(mode, encoding="utf-8") as f: + f.write(formatted_module) diff --git a/python/api_client_generator/_private/format_using_ruff.py b/python/api_client_generator/_private/format_using_ruff.py new file mode 100644 index 0000000000000000000000000000000000000000..dbb5ada5f2af395dbd8149dbbd59f059c54c9933 --- /dev/null +++ b/python/api_client_generator/_private/format_using_ruff.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import subprocess + +from ruff.__main__ import find_ruff_bin # type: ignore[import-untyped] + +from api_client_generator._private.common.defaults import DEFAULT_LINE_LENGTH_FOR_RUFF + + +def format_using_ruff(code: str, line_length: int = DEFAULT_LINE_LENGTH_FOR_RUFF) -> str: + """Format the given code using Ruff.""" + + try: + ruff_bin = find_ruff_bin() + except FileNotFoundError: + ruff_bin = "ruff" + + try: + completed_process = subprocess.run( + [ + ruff_bin, + "format", + "--config", + f"line-length={line_length}", + "--stdin-filename", + "file.py", + "-", + ], + check=True, + capture_output=True, + text=True, + input=code, + ) + except subprocess.CalledProcessError: + return code + else: + return completed_process.stdout diff --git a/python/api_client_generator/_private/json_rpc_tools/__init__.py b/python/api_client_generator/_private/json_rpc_tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/api_client_generator/_private/json_rpc_tools/api_name_tools.py b/python/api_client_generator/_private/json_rpc_tools/api_name_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..593367b5dab505e1eccf5adacf42e249d8e708d7 --- /dev/null +++ b/python/api_client_generator/_private/json_rpc_tools/api_name_tools.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import re + +from api_client_generator._private.common.models_aliased import ApiDescription +from api_client_generator.exceptions import InvalidApiNameError + + +def get_api_name_from_description(api_description: ApiDescription) -> str: + """ + Extract the API name from the provided API description. + + Args: + api_description: The API description. + + Returns: + The name of the API. + """ + + return next(iter(api_description.keys())) + + +def validate_api_name(api_name: str) -> None: + """ + Validate the API name. + + Args: + api_name: The API name to validate. + + Raises: + ApiNameInvalidError: If the API name is not valid. + """ + is_valid = bool(re.match(r"^[a-z]+(_[a-z]+)*$", api_name)) + if not is_valid: + raise InvalidApiNameError(api_name) diff --git a/python/api_client_generator/_private/json_rpc_tools/create_client_and_imports.py b/python/api_client_generator/_private/json_rpc_tools/create_client_and_imports.py new file mode 100644 index 0000000000000000000000000000000000000000..f16d1b0b1cab104c8e1bb3c289dcb81163410d0a --- /dev/null +++ b/python/api_client_generator/_private/json_rpc_tools/create_client_and_imports.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence + +from api_client_generator._private.common.defaults import DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME +from api_client_generator._private.common.generated_class import GeneratedClass +from api_client_generator._private.common.models_aliased import ( + BaseApiClass, + ClientClassFactory, + EndpointsDescription, + Importable, + ensure_is_importable, +) +from api_client_generator._private.resolve_needed_imports import ( + import_class, + import_classes, + import_params_types, + is_struct, +) +from api_client_generator.exceptions import EndpointParamsIsNotMsgspecStructError + +if TYPE_CHECKING: + import ast + + +def create_client_and_imports( # NOQA: PLR0913 + api_name: str, + client_class_factory: ClientClassFactory, + endpoints: EndpointsDescription, + base_class: type[BaseApiClass] | str, + base_class_source: str | None = None, + endpoint_decorator: str = DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, + additional_items_to_import: Sequence[Importable] | None = None, + already_imported: list[str] | None = None, + *, + asynchronous: bool = True, +) -> GeneratedClass: + """ + Create a client class and resolve the needed imports. + + Args: + api_name: The name of the API. + client_class_factory: The factory function to create the client class. + endpoints: The endpoints description for the API. + base_class: The base class for the API client. + base_class_source: The source of the base class. + endpoint_decorator: The name of the endpoint decorator to be used. + additional_items_to_import: Additional items to import. + already_imported: A list of already imported items. + asynchronous: If True, the endpoints will be created as asynchronous methods. + + Raises: + EndpointParamsIsNotDataclassError: If the endpoint parameters are not a dataclass. + """ + + needed_imports: list[ast.ImportFrom] = [] + if already_imported is None: + already_imported = [] + + needed_results = [ensure_is_importable(params["result"]) for params in endpoints.values() if params.get("result")] + needed_results_import = import_classes(needed_results, already_imported) + + needed_params_import = [] + + for params in endpoints.values(): + if params.get("params") is not None: + extracted_params = params.get("params") + + if extracted_params is not None and not is_struct(extracted_params): + raise EndpointParamsIsNotMsgspecStructError + + needed_params_import.extend(import_params_types(extracted_params, already_imported)) + + additional_imports = import_classes(additional_items_to_import or [], already_imported) + + needed_imports.extend(additional_imports + needed_results_import + needed_params_import) + + base_class_import = import_class(base_class, base_class_source) + + base_class_name = base_class if isinstance(base_class, str) else base_class.__name__ + + if base_class_import and base_class_name not in already_imported: + already_imported.append(base_class_name) + needed_imports.append(base_class_import) + + return GeneratedClass( + client_class_factory(api_name, endpoints, base_class, endpoint_decorator, asynchronous=asynchronous), + needed_imports, + ) diff --git a/python/api_client_generator/_private/json_rpc_tools/create_collection_module.py b/python/api_client_generator/_private/json_rpc_tools/create_collection_module.py new file mode 100644 index 0000000000000000000000000000000000000000..f792514890d0318b3bd37683aac3cd1d0103393c --- /dev/null +++ b/python/api_client_generator/_private/json_rpc_tools/create_collection_module.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import ast +from typing import Sequence + +from api_client_generator._private.common.defaults import ( + DEFAULT_API_COLLECTION_NAME, + DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, +) +from api_client_generator._private.common.models_aliased import ( + ApiDescription, + BaseApiClass, + ClientClassFactory, + Importable, +) +from api_client_generator._private.create_collection_class import create_collection_class +from api_client_generator._private.json_rpc_tools.api_name_tools import validate_api_name +from api_client_generator._private.json_rpc_tools.create_client_and_imports import ( + create_client_and_imports, +) + + +def create_collection_module( # NOQA: PLR0913 + api_descriptions: ApiDescription, + client_class_factory: ClientClassFactory, + base_class: type[BaseApiClass] | str, + base_class_source: str | None = None, + collection_name: str = DEFAULT_API_COLLECTION_NAME, + endpoint_decorator: str = DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, + additional_items_to_import: Sequence[Importable] | None = None, + *, + asynchronous: bool = True, +) -> ast.Module: + """ + Generate an API client class based on the provided API descriptions. + + Args: + api_descriptions: The description of the APIs. + client_class_factory: The factory function to create api client class. + base_class: The base class for the API client. + base_class_source: The source of the base class. If None, a `__module__` will be used. + collection_name: The name of the collection class. + endpoint_decorator: The name of the endpoint decorator to be used. + additional_items_to_import: Additional things to import in the created module. + asynchronous: If True, the endpoints will be created as asynchronous methods. + + Raises: + InvalidApiNameError: If the API name is invalid. + """ + + generated_clients = [] + already_imported: list[str] = [] # List of already imported classes to avoid duplicates + imports: list[ast.ImportFrom] = [] + + for api_name, endpoints in api_descriptions.items(): + validate_api_name(api_name) + + created_client = create_client_and_imports( + api_name, + client_class_factory, + endpoints, + base_class, + base_class_source, + endpoint_decorator, + additional_items_to_import, + already_imported, + asynchronous=asynchronous, + ) + generated_clients.append(created_client.class_def) + imports.extend(created_client.imports) + + collection = create_collection_class(collection_name, generated_clients) + + return ast.Module( + body=[*imports, collection, *generated_clients], + type_ignores=[], + ) diff --git a/python/api_client_generator/_private/json_rpc_tools/create_single_client_module.py b/python/api_client_generator/_private/json_rpc_tools/create_single_client_module.py new file mode 100644 index 0000000000000000000000000000000000000000..dce5deb79db43f2c596ebc479c00801b1f10a664 --- /dev/null +++ b/python/api_client_generator/_private/json_rpc_tools/create_single_client_module.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import ast +from typing import Sequence + +from api_client_generator._private.common.defaults import DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME +from api_client_generator._private.common.models_aliased import ( + ApiDescription, + BaseApiClass, + ClientClassFactory, + Importable, +) +from api_client_generator._private.json_rpc_tools.api_name_tools import ( + get_api_name_from_description, + validate_api_name, +) +from api_client_generator._private.json_rpc_tools.create_client_and_imports import ( + create_client_and_imports, +) + + +def create_single_client_module( # NOQA: PLR0913 + api_description: ApiDescription, + client_class_factory: ClientClassFactory, + base_class: type[BaseApiClass] | str, + base_class_source: str | None = None, + endpoint_decorator: str = DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, + additional_items_to_import: Sequence[Importable] | None = None, + *, + asynchronous: bool = True, +) -> ast.Module: + """ + Generate an API client class based on the provided API name, description, and type. + + Args: + api_description: The description of the API. + client_class_factory: The factory function to create api client class. + base_class: The base class for the API client. + base_class_source: The source of the base class. If None, a default source will be used. + endpoint_decorator: The name of the endpoint decorator to be used. + additional_items_to_import(: Additional things to import in the created module. + asynchronous: If True, the endpoints will be created as asynchronous methods. + + Raises: + AssertionError: If the API description does not contain endpoints. + InvalidApiNameError: If the API name is invalid. + """ + + api_name = get_api_name_from_description(api_description) + validate_api_name(api_name) + endpoints = api_description.get(api_name) + assert endpoints is not None, "API description must contain endpoints" + generated_client = create_client_and_imports( + api_name, + client_class_factory, + endpoints, + base_class, + base_class_source, + endpoint_decorator, + additional_items_to_import, + asynchronous=asynchronous, + ) + + return ast.Module( + body=[*generated_client.imports, generated_client.class_def], + type_ignores=[], + ) diff --git a/python/api_client_generator/_private/resolve_needed_imports.py b/python/api_client_generator/_private/resolve_needed_imports.py new file mode 100644 index 0000000000000000000000000000000000000000..01a90a81f87ee476de969fc8532c5220948d02c5 --- /dev/null +++ b/python/api_client_generator/_private/resolve_needed_imports.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING, Any, Sequence, get_type_hints + +from msgspec import Struct + +from api_client_generator._private.common.defaults import DEFAULT_IMPORT_LEVEL +from api_client_generator._private.common.models_aliased import Importable +from api_client_generator.exceptions import ( + ClassPassedByStrWithoutSourceError, + EndpointParamsIsNotMsgspecStructError, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def is_struct(potential_struct: Any) -> bool: + """ + Check if the given parameter is a msgspec struct. + + Args: + potential_struct: The parameter to check. + Returns: + bool: True if the class is a msgspec struct, False otherwise. + """ + return issubclass(potential_struct, Struct) + + +def import_class( + class_: Importable | str, + class_source: str | None = None, +) -> ast.ImportFrom | None: + """ + Import a class from its module. + + Args: + class_: The class to import. + class_source: The source of the class. If None, uses the __module__. + + Raises: + AttributeError: If the module cannot be resolved. + + Returns: + ast.ImportFrom | None: The AST representation of the import statement. + Returns None if the class is from the builtins module. + + Notes: + Please note that the `class_` argument can be a string, but in this case class_source should be passed also. + """ + if not isinstance(class_, str) and class_.__module__ == "builtins": + return None + + if not isinstance(class_, str): + class_source = class_.__module__ + + if class_source is None: + raise ClassPassedByStrWithoutSourceError + + return ast.ImportFrom( + class_source, + [ast.alias(name=class_.__name__ if not isinstance(class_, str) else class_)], + DEFAULT_IMPORT_LEVEL, + ) + + +def import_classes( + classes: Sequence[Importable] | None, already_imported: list[str], sources: Sequence[str] | None = None +) -> list[ast.ImportFrom]: + """ + Import class from the given sequence of classes. + + Args: + classes: A sequence of classes to import. + already_imported: A list of already imported classes. + sources: A sequence of sources for the classes. If None, uses the __module__. + + Raises: + AssertionError: If the length of classes and sources do not match. + + Notes: + - If classes is None or empty, an empty list is returned. + - If sources is provided, it must match the length of classes. + """ + + classes_imports: list[ast.ImportFrom] = [] + + if not classes: + return classes_imports + + def add_import(class_: Importable, source: str | None = None) -> None: + if _should_be_imported(class_, already_imported): + already_imported.append(class_.__name__) + import_stmt = import_class(class_, source) + if import_stmt: + classes_imports.append(import_stmt) + + if sources is None: + for class_ in classes: + add_import(class_) + else: + assert len(classes) == len(sources), "Length of classes and sources must match" + for class_, source in zip(classes, sources): + add_import(class_, source) + + return classes_imports + + +def import_params_types(params: type[Struct] | None, already_imported: list[str]) -> list[ast.ImportFrom]: + """ + Import parameters types from the given dataclass of parameters. + + Args: + params: A msgspec struct with parameters to import. + already_imported: A list of already imported types. + + Notes: + - If params is None or empty, an empty list is returned. + - If a parameter is from the builtins or __main__ module, it is skipped. + + Raises: + EndpointParamsIsNotDataclassError: If params is not a dataclass. + """ + needed_imports: list[ast.ImportFrom] = [] + + def add_import(class_: Importable) -> None: + if _should_be_imported(class_, already_imported): + already_imported.append(class_.__name__) + import_stmt = import_class(class_) + if import_stmt: + needed_imports.append(import_stmt) + + if not params: + return [] + + if params is not None and not is_struct(params): + raise EndpointParamsIsNotMsgspecStructError + + for type_ in get_type_hints(params).values(): + add_import(type_) + + return needed_imports + + +def find_package_root(module_path: Path) -> Path: + """Find the top-level package directory (where `__init__.py` exists).""" + current = module_path.parent + root = None + while True: + init_file = current / "__init__.py" + if init_file.exists(): + root = current + current = current.parent + else: + break + return root if root is not None else module_path.parent + + +def compute_full_module_name(module_path: Path, root: Path) -> str: + """Compute the full dotted module name relative to the package root.""" + relative_path = module_path.relative_to(root) + + parts = relative_path.parts if relative_path.name == "__init__.py" else relative_path.with_suffix("").parts + relative_name = ".".join(parts) + + return f"{root.name}.{relative_name}" if relative_name else root.name + + +def _should_be_imported(class_: Importable, already_imported: list[str]) -> bool: + """ + Check if a class should be imported. + + Args: + class_: The class to check. + already_imported: A list of already imported classes. + + Returns: + bool: True if the class should be imported, False otherwise. + """ + return class_.__module__ != "builtins" and class_.__name__ not in already_imported diff --git a/python/api_client_generator/_private/rest_api_tools/__init__.py b/python/api_client_generator/_private/rest_api_tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/api_client_generator/_private/rest_api_tools/create_client_and_imports.py b/python/api_client_generator/_private/rest_api_tools/create_client_and_imports.py new file mode 100644 index 0000000000000000000000000000000000000000..7ff4c81a03839c5c34e5152e246e52430a365365 --- /dev/null +++ b/python/api_client_generator/_private/rest_api_tools/create_client_and_imports.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Sequence + +from api_client_generator._private.common.converters import snake_to_camel +from api_client_generator._private.common.defaults import ( + DEFAULT_ENDPOINT_REST_DECORATOR_NAME, + DEFAULT_IMPORT_LEVEL, +) +from api_client_generator._private.common.generated_class import GeneratedClass +from api_client_generator._private.common.models_aliased import ( + BaseApiClass, + Importable, +) +from api_client_generator._private.resolve_needed_imports import ( + compute_full_module_name, + find_package_root, + import_class, + import_classes, +) +from api_client_generator._private.rest_api_tools.models_aliased import CreatedEndpoints + + +def create_client_and_imports( # NOQA: PLR0913 + api_name: str, + server_url: str, + endpoints: CreatedEndpoints, + types_module_path: str | Path, + base_class: type[BaseApiClass] | str, + base_class_source: str | None = None, + endpoint_decorator: str = DEFAULT_ENDPOINT_REST_DECORATOR_NAME, + additional_items_to_import: Sequence[Importable] | None = None, + already_imported: list[str] | None = None, +) -> GeneratedClass: + """ + Create a client class and resolve the needed imports. + + Args: + api_name: The name of the API. + server_url: The server URL for the API. + types_module_path: The path to the module containing types. + endpoints: The endpoints description for the API. + base_class: The base class for the API client. + base_class_source: The source of the base class. + endpoint_decorator: The name of the endpoint decorator to be used. + additional_items_to_import: Additional items to import. + already_imported: A list of already imported items. + """ + + already_imported = already_imported or [] + + needed_imports: list[ast.ImportFrom] = [] + + base_class_import = import_class(base_class, base_class_source) + + if base_class_import is not None: + needed_imports.append(base_class_import) + + base_class_name = base_class if isinstance(base_class, str) else base_class.__name__ + already_imported.append(base_class_name) + + types_module_path = Path(types_module_path) + root = find_package_root(types_module_path) + full_module_name = compute_full_module_name(types_module_path, root) + + needed_imports.append( + ast.ImportFrom( + module=full_module_name, + names=[ast.alias(name="*")], + level=DEFAULT_IMPORT_LEVEL, + ) + ) + + additional_imports = import_classes(additional_items_to_import or [], already_imported) + needed_imports.extend(additional_imports) + + class_name = snake_to_camel(api_name) + + endpoint_decorator_assign = ast.Assign( # Assign endpoint decorator as class variable + targets=[ast.Name(id=endpoint_decorator)], + value=ast.Attribute( + value=ast.Name(id=base_class_name), + attr=f"{endpoint_decorator}()", # rest decorator is a class method of the base class + ), + ) + base_url_method = ast.FunctionDef( # type: ignore[call-overload] + name="base_path", + args=ast.arg(arg="self"), + returns=ast.Name(id="str"), + body=[ast.Return(value=ast.Constant(value="/" + server_url))], + decorator_list=[], + ) + + body: list[ast.stmt] = [endpoint_decorator_assign, base_url_method, *endpoints] + + class_def = ast.ClassDef( + name=class_name, + bases=[ast.Name(id=base_class_name)], + keywords=[], + body=body, + decorator_list=[], + type_params=[], + ) + + return GeneratedClass( + class_def, + needed_imports, + ) diff --git a/python/api_client_generator/_private/rest_api_tools/create_endpoints_for_all_url_paths.py b/python/api_client_generator/_private/rest_api_tools/create_endpoints_for_all_url_paths.py new file mode 100644 index 0000000000000000000000000000000000000000..838c066b5cc1cde61d1fe25df854bd49200c1ab4 --- /dev/null +++ b/python/api_client_generator/_private/rest_api_tools/create_endpoints_for_all_url_paths.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from api_client_generator._private.common.converters import snake_to_camel +from api_client_generator._private.common.models_aliased import SwaggerReadyForExtraction +from api_client_generator._private.common.openapi_to_python_type import convert_openapi_type_to_python_type +from api_client_generator._private.description_tools import ( + get_last_part_of_ref, + get_value_from_swagger_part_recursively, +) +from api_client_generator._private.endpoints_factory.rest import create_endpoint +from api_client_generator._private.rest_api_tools.create_method_name import create_method_name +from api_client_generator._private.rest_api_tools.models_aliased import ( + CreatedEndpoints, + is_valid_rest_api_method_type, +) +from api_client_generator._private.rest_api_tools.rest_method_model import RestApiMethod +from api_client_generator.exceptions import UnsupportedHttpMethodError + +if TYPE_CHECKING: + import ast + + +def create_endpoints_for_all_url_paths( + swagger: SwaggerReadyForExtraction, *, asynchronous: bool = True +) -> CreatedEndpoints: + """ + Create endpoints for all URL paths defined in the Swagger/OpenAPI specification. + + Args: + swagger: The Swagger/OpenAPI specification as a dictionary. + asynchronous: If True, the endpoints will be created as asynchronous methods. + + Returns: + A list of endpoints created from the Swagger paths. + """ + + paths_from_swagger = swagger["paths"] + assert isinstance(paths_from_swagger, dict), f"Expected paths to be a dict, got {type(paths_from_swagger)}" + + url_paths = list(paths_from_swagger.keys()) # path is url like /name_of_endpoint/{some_param} + endpoints: list[ast.FunctionDef | ast.AsyncFunctionDef] = [] + + for url_path in url_paths: + method_name = create_method_name(url_path) + path = get_value_from_swagger_part_recursively(swagger, ("paths", url_path)) + assert isinstance(path, dict), f"Expected path to be a dict, got {type(path)} for {url_path}" + + method_types = path.keys() # e.g. ['get', 'post', 'put', 'delete'] + + for method_type in method_types: + if not is_valid_rest_api_method_type(method_type): + raise UnsupportedHttpMethodError(method_type) + + response_schema = get_value_from_swagger_part_recursively( + path, (method_type, "responses", "200", "content", "application/json", "schema") + ) + response: str | type + + if "$ref" in response_schema: + ref = response_schema["$ref"] + response = snake_to_camel(get_last_part_of_ref(ref).split(".")[1]) + else: + response = convert_openapi_type_to_python_type(response_schema["type"]) + + method = RestApiMethod(**path[method_type]) + + endpoints.append( + create_endpoint(method_name, url_path, method, response, method_type, asynchronous=asynchronous) + ) + + return endpoints diff --git a/python/api_client_generator/_private/rest_api_tools/create_method_name.py b/python/api_client_generator/_private/rest_api_tools/create_method_name.py new file mode 100644 index 0000000000000000000000000000000000000000..0d513bec66c9abcb6b52811b586930e5d3053fcb --- /dev/null +++ b/python/api_client_generator/_private/rest_api_tools/create_method_name.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +def create_method_name(url_path: str) -> str: + """ + Create a method name for a REST API client based on the URL path. + + Args: + url_path: The URL path of the endpoint, e.g., "/name_of_endpoint/{some_param}". + + Returns: + url_path: The constructed method name. + + Example: + my_url_path = "/name_of_endpoint/{some_param}/path_part" + create_method_name(my_url_path) + >>> "name_of_endpoint_path_part" + """ + split_path = url_path.split("/") + split_path = [part for part in split_path if part and not part.startswith("{") and not part.endswith("}")] + + return "_".join(split_path).replace("-", "_") diff --git a/python/api_client_generator/_private/rest_api_tools/models_aliased.py b/python/api_client_generator/_private/rest_api_tools/models_aliased.py new file mode 100644 index 0000000000000000000000000000000000000000..3f2ea3117246afe9b9a0e264526756bf168e6354 --- /dev/null +++ b/python/api_client_generator/_private/rest_api_tools/models_aliased.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import ast +from typing import Literal, TypeGuard, get_args + +PathParam = ast.arg +QueryParam = ast.arg + +CreatedEndpoints = list[ast.FunctionDef | ast.AsyncFunctionDef] + +RestApiParameterType = Literal[ + "query", + "path", +] +RestApiMethodType = Literal[ + "get", + "post", + "put", + "patch", + "delete", +] +RestApiMethods: tuple[RestApiMethodType, ...] = get_args(RestApiMethodType) + + +def is_valid_rest_api_method_type(value: str) -> TypeGuard[RestApiMethodType]: + """ + Check if the given value is a valid REST API method type. + + Args: + value: The value to check. + + Returns: + True if the value is a valid REST API method type, False otherwise. + """ + return value in RestApiMethods diff --git a/python/api_client_generator/_private/rest_api_tools/rest_method_model.py b/python/api_client_generator/_private/rest_api_tools/rest_method_model.py new file mode 100644 index 0000000000000000000000000000000000000000..fe59e92e4a28554f9215f9871885c00c88f553f7 --- /dev/null +++ b/python/api_client_generator/_private/rest_api_tools/rest_method_model.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from msgspec import Struct + +from api_client_generator._private.common.models_aliased import AnyJson + + +class RestApiMethod(Struct): + tags: list[str] + summary: str + description: str + operationId: str # NOQA: N815 + responses: AnyJson + parameters: list[AnyJson] | None = None diff --git a/python/api_client_generator/exceptions.py b/python/api_client_generator/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..0c74727ddbb3ef6c12f68c3318b249febbadf212 --- /dev/null +++ b/python/api_client_generator/exceptions.py @@ -0,0 +1,57 @@ +from __future__ import annotations + + +class ApiClientGeneratorError(Exception): + """Base class for all exceptions raised by the ApiClientGenerator.""" + + +class InvalidApiNameError(ApiClientGeneratorError): + """Exception raised when the API name is invalid.""" + + def __init__(self, api_name: str) -> None: + self.message = f"Invalid API name: {api_name} provided." + self.api_name = api_name + super().__init__(self.message) + + +class InvalidApiDescriptionAmountError(ApiClientGeneratorError): + """Exception raised when more than one or none API description is passed to a single generator.""" + + def __init__(self) -> None: + self.message = "More than one or none API dscription passed to the single api client generator." + super().__init__(self.message) + + +class RunningScriptWithoutAppropriateFlagError(ApiClientGeneratorError): + def __init__(self) -> None: + self.message = ( + "Seems like you are trying to run the script without the -m flag. Please run your generation script like this:\n" + "python -m your_script_name.py" + ) + super().__init__(self.message) + + +class EndpointParamsIsNotMsgspecStructError(ApiClientGeneratorError): + """Exception raised when the endpoint parameters are not a msgspec struct.""" + + def __init__(self, endpoint_name: str = "any") -> None: + self.message = f"Params for {endpoint_name} endpoint must be a msgspec struct" + self.endpoint_name = endpoint_name + super().__init__(self.message) + + +class ClassPassedByStrWithoutSourceError(ApiClientGeneratorError): + """Exception raised when a class is passed by string without its source.""" + + def __init__(self) -> None: + self.message = "You've probably passed a class by string and not provided class source." + super().__init__(self.message) + + +class UnsupportedHttpMethodError(ApiClientGeneratorError): + """Exception raised when an unsupported HTTP method is used.""" + + def __init__(self, method: str) -> None: + self.message = f"Unsupported HTTP method: {method}." + self.method = method + super().__init__(self.message) diff --git a/python/api_client_generator/generate_types_from_swagger.py b/python/api_client_generator/generate_types_from_swagger.py new file mode 100644 index 0000000000000000000000000000000000000000..16813e5f1596c35610a48163ec8ca2ba2676564c --- /dev/null +++ b/python/api_client_generator/generate_types_from_swagger.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from datamodel_code_generator import DataModelType, InputFileType, generate + +from api_client_generator._private.resolve_needed_imports import ( + compute_full_module_name, + find_package_root, +) + + +def generate_types_from_swagger( + openapi_api_definition: str | Path, + output: str | Path, +) -> None: + """ + Generate types defined in Swagger. + + Args: + openapi_api_definition: The OpenAPI JSON definition file path. + output: The output file / package path where the generated types will be saved. + + Notes: + The generated types will be saved in the specified output directory, and relative imports will be fixed + to use absolute imports based on the package structure. + + Raises: + FileNotFoundError: If the OpenAPI definition file does not exist. + """ + openapi_file = openapi_api_definition if isinstance(openapi_api_definition, Path) else Path(openapi_api_definition) + output = output if isinstance(output, Path) else Path(output) + + if not openapi_file.exists(): + raise FileNotFoundError(f"File {openapi_file} does not exist.") + + generate( # generation of types available in the API definition + openapi_file, + output=output, + output_model_type=DataModelType.MsgspecStruct, + input_file_type=InputFileType.OpenAPI, + use_field_description=True, + use_standard_collections=True, + use_exact_imports=True, + ) + + package_root = find_package_root(output) + path_to_add_to_imports = compute_full_module_name(output, package_root) + + if path_to_add_to_imports.startswith( + "." + ): # There is just one dot which means that output is in the same directory as package root + path_to_add_to_imports = path_to_add_to_imports.replace(".", "", 1) + + fix_relative_imports(output, path_to_add_to_imports) + + +def fix_relative_imports(output_dir: Path, path_to_add: str) -> None: + """ + Replace relative imports (from .xyz import ...) with absolute imports using the path_to_add. + + Args: + output_dir: Directory with generated Python files. + path_to_add: Path to use in absolute imports. + """ + for py_file in output_dir.rglob("*.py"): + content = py_file.read_text(encoding="utf-8") + + fixed_content = re.sub( + r"^from \.(\w+) import (.+)$", rf"from {path_to_add}.\1 import \2", content, flags=re.MULTILINE + ) + + py_file.write_text(fixed_content, encoding="utf-8") diff --git a/python/api_client_generator/json_rpc/__init__.py b/python/api_client_generator/json_rpc/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2e4ee1f1c74fd61609e0a78d01210d06780fd93f --- /dev/null +++ b/python/api_client_generator/json_rpc/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from .generate_api_client import generate_api_client +from .generate_api_collection import generate_api_collection +from .generate_api_description import generate_api_description + +__all__ = [ + "generate_api_description", + "generate_api_client", + "generate_api_collection", +] diff --git a/python/api_client_generator/json_rpc/generate_api_client.py b/python/api_client_generator/json_rpc/generate_api_client.py new file mode 100644 index 0000000000000000000000000000000000000000..3d6da0107cfd12902b26539f7953860900e71252 --- /dev/null +++ b/python/api_client_generator/json_rpc/generate_api_client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +from api_client_generator._private.check_whether_was_ran_as_script import check_whether_was_ran_as_script +from api_client_generator._private.client_class_factory import create_json_rpc_api_client +from api_client_generator._private.common.defaults import DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME +from api_client_generator._private.common.models_aliased import ApiDescription, BaseApiClass, Importable +from api_client_generator._private.json_rpc_tools.api_name_tools import get_api_name_from_description +from api_client_generator._private.export_client_module_to_file import export_module_to_file +from api_client_generator._private.json_rpc_tools.create_single_client_module import ( + create_single_client_module, +) +from api_client_generator.exceptions import InvalidApiDescriptionAmountError + + +def generate_api_client( # NOQA: PLR0913 + api_description: ApiDescription, + base_class: type[BaseApiClass] | str, + base_class_source: str | None = None, + path: Path | None = None, + endpoint_decorator: str = DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, + additional_items_to_import: Sequence[Importable] | None = None, + *, + asynchronous: bool = True, +) -> None: + """ + Generate an API client class based on the provided API name, description, and type and save it to a file. + + Args: + api_description: The description of the API. + base_class: The base class for the API client. + base_class_source: The source of the base class. If None, a default source will be used. + path: The path where the generated client should be saved. If None, a default path will be used. + endpoint_decorator: Name of the decorator to be used for the endpoint. + additional_items_to_import: Additional items to import in the module. + asynchronous: If True, the endpoints will be created as asynchronous methods. + + Notes: + Your script must be run with the `-m` flag to ensure that the module is executed as a script. + + Raises: + MoreThanOneApiPassedToSingleGeneratorError: If more than one or none API description is provided. If you want to generate a client for multiple APIs, + use `generate_api_collection` instead or pass your api descriptions one by one. + InvalidApiNameError: If the API name is invalid. + RunningScriptWithoutAppropriateFlagError: If the script is run without the appropriate (-m) flag. + + + Example: + from beekeepy import AbstractAsyncApi + + + @dataclass + class MyApiParameters: + account: str + + + @dataclass + class MyApiResult: + data: str + + + my_api_description = { + "my_api": { + "params": MyApiParameters, + "result": MyApiResult, + } + } + + generate_dapi_client(my_api_description, AbstractAsyncApi) + """ + check_whether_was_ran_as_script() + + if len(api_description.keys()) != 1: + raise InvalidApiDescriptionAmountError + + client_module = create_single_client_module( + api_description, + create_json_rpc_api_client, + base_class, + base_class_source, + endpoint_decorator, + additional_items_to_import, + asynchronous=asynchronous, + ) + + api_name = get_api_name_from_description(api_description) # Already validated in the `create_single_client_module` + + file_path = Path(f"{api_name}_client.py") if path is None else path + export_module_to_file(client_module, file_path=file_path) diff --git a/python/api_client_generator/json_rpc/generate_api_collection.py b/python/api_client_generator/json_rpc/generate_api_collection.py new file mode 100644 index 0000000000000000000000000000000000000000..e88c0d56f83016d1bc1d8f59c08ee58d66357313 --- /dev/null +++ b/python/api_client_generator/json_rpc/generate_api_collection.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence + +from api_client_generator._private.check_whether_was_ran_as_script import check_whether_was_ran_as_script +from api_client_generator._private.client_class_factory import create_json_rpc_api_client +from api_client_generator._private.common.defaults import ( + DEFAULT_API_COLLECTION_NAME, + DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, +) +from api_client_generator._private.common.models_aliased import ApiDescription, BaseApiClass, Importable +from api_client_generator._private.export_client_module_to_file import export_module_to_file +from api_client_generator._private.json_rpc_tools.create_collection_module import create_collection_module + +if TYPE_CHECKING: + from pathlib import Path + + +def generate_api_collection( # NOQA: PLR0913 + api_descriptions: ApiDescription, + base_class: type[BaseApiClass] | str, + base_class_source: str | None = None, + path: Path | None = None, + collection_name: str = DEFAULT_API_COLLECTION_NAME, + endpoint_decorator: str = DEFAULT_ENDPOINT_JSON_RPC_DECORATOR_NAME, + additional_items_to_import: Sequence[Importable] | None = None, + *, + asynchronous: bool = True, +) -> None: + """ + Generate an API client collection based on the provided API names, definition, and type and save it to a file. + + Args: + api_descriptions: Description of the APIs. + base_class: The base class for the API client. + base_class_source: The source of the base class. If None, a default source will be used. + path: The path where the generated client should be saved. If None, a default path will be used. + collection_name: Name of the collection. Will be used as the class name for the collection. + endpoint_decorator: Name of the decorator to be used for the endpoint. + additional_items_to_import: Additional items to import in the module. + asynchronous: If True, the endpoints will be created as asynchronous methods. + + Notes: + Your script must be run with the `-m` flag to ensure that the module is executed as a script. + + Raises: + InvalidApiNameError: If the API name is invalid. + RunningScriptWithoutAppropriateFlagError: If the script is run without the appropriate (-m) flag. + """ + check_whether_was_ran_as_script() + + collection_module = create_collection_module( + api_descriptions, + create_json_rpc_api_client, + base_class, + base_class_source, + collection_name, + endpoint_decorator, + additional_items_to_import, + asynchronous=asynchronous, + ) + + export_module_to_file(collection_module, file_path=path) diff --git a/python/api_client_generator/json_rpc/generate_api_description.py b/python/api_client_generator/json_rpc/generate_api_description.py new file mode 100644 index 0000000000000000000000000000000000000000..46167257229a89bf25561cf5160d554ceb749b0f --- /dev/null +++ b/python/api_client_generator/json_rpc/generate_api_description.py @@ -0,0 +1,120 @@ +""" +Simple way to generate a JSON-RPC params-result structure to be used in the client generator. + +This script reads the OpenAPI JSON file, generates the API description, and creates a dictionary like that: + +api_description = { + "name_of_api": + { + "name_of_endpoint": { + + "params": ParamsClass, + "result": NameOfEndpointResponse, + "description": "Description of the endpoint if given", + }, + }, + "name_of_second_endpoint": + { + "name_of_endpoint": { + "params": ParamsClass, + "result": NameOfEndpointResponseItem, + "response_array": True, + "description": "Description of the endpoint if given", + }, + }, + } +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Container + +from api_client_generator._private.common.converters import snake_to_camel +from api_client_generator._private.common.models_aliased import ( + ApiDescriptionBeforeProcessing, + EndpointDescriptionBeforeProcessing, +) +from api_client_generator._private.description_tools import ( + AliasToAssign, + create_api_description_module, + get_description_for_endpoint, + get_params_name_for_endpoint, + get_result_name_for_endpoint, + is_result_array, +) +from api_client_generator._private.export_client_module_to_file import export_module_to_file +from api_client_generator.generate_types_from_swagger import generate_types_from_swagger + + +def generate_api_description( + api_description_name: str, + openapi_api_definition: str | Path, + output_file: str | Path, + additional_aliases: tuple[AliasToAssign] | None = None, + apis_to_skip: Container[str] | None = None, +) -> None: + """ + Generate an API description based on the provided OpenAPI definition. + + Args: + api_description_name: The name of the API description to be generated. + openapi_api_definition: The OpenAPI JSON definition file path. + output_file: The file where the generated API description will be saved. + additional_aliases: Additional aliases to be used in the API description. + apis_to_skip: APIs to skip during the generation process. + + Raises: + FileNotFoundError: If the OpenAPI definition file does not exist. + """ + openapi_api_definition = ( + openapi_api_definition if isinstance(openapi_api_definition, Path) else Path(openapi_api_definition) + ) + output_file = output_file if isinstance(output_file, Path) else Path(output_file) + generate_types_from_swagger(openapi_api_definition, output_file) + + api_description: ApiDescriptionBeforeProcessing = {} + + openapi = json.loads(openapi_api_definition.read_text()) + + paths = list(openapi["paths"].keys()) # path is construct like name_of_api.name_of_endpoint + components = openapi["components"]["schemas"] + + for path in paths: + api_name, endpoint_name = path.split(".") + + if apis_to_skip and api_name in apis_to_skip: + continue + + if api_name not in api_description: + api_description[api_name] = {} + + endpoint_properties = openapi["paths"][path] + + params_name = get_params_name_for_endpoint(endpoint_properties) + result_name = get_result_name_for_endpoint( + endpoint_properties + ) # that's the name of the response class, in the snake case + + endpoint_description: EndpointDescriptionBeforeProcessing = { + "params": snake_to_camel(params_name) if params_name else "None", + "result": snake_to_camel(result_name), + "description": get_description_for_endpoint(endpoint_properties), + } + + if is_result_array(result_name, components): + endpoint_description["response_array"] = True + + assert isinstance(endpoint_description["result"], str), "Result must be a string at this point." + endpoint_description["result"] += "Item" # It will be typed as list[ClasNameResponseItem] + + api_description[api_name][endpoint_name] = endpoint_description + + description_module = create_api_description_module(api_description_name, api_description, additional_aliases) + + export_module_to_file( + description_module, + mode="a", + file_path=output_file, + ) diff --git a/python/api_client_generator/rest/__init__.py b/python/api_client_generator/rest/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..62adb8eb9f47c2e0d9e5d554052c7ab1219701b4 --- /dev/null +++ b/python/api_client_generator/rest/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .generate_api_client_from_swagger import generate_api_client_from_swagger + +__all__ = ["generate_api_client_from_swagger"] diff --git a/python/api_client_generator/rest/generate_api_client_from_swagger.py b/python/api_client_generator/rest/generate_api_client_from_swagger.py new file mode 100644 index 0000000000000000000000000000000000000000..92fa4de7204f9c4208dd5e9bd74e61986021d3b7 --- /dev/null +++ b/python/api_client_generator/rest/generate_api_client_from_swagger.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import ast +import json +from pathlib import Path + +from api_client_generator._private.check_whether_was_ran_as_script import check_whether_was_ran_as_script +from api_client_generator._private.common.converters import hyphen_to_snake +from api_client_generator._private.common.defaults import DEFAULT_ENDPOINT_REST_DECORATOR_NAME +from api_client_generator._private.common.models_aliased import BaseApiClass +from api_client_generator._private.description_tools import ( + get_api_name_from_server_property, + get_types_name_from_components, +) +from api_client_generator._private.export_client_module_to_file import export_module_to_file +from api_client_generator._private.rest_api_tools.create_client_and_imports import ( + create_client_and_imports, +) +from api_client_generator._private.rest_api_tools.create_endpoints_for_all_url_paths import ( + create_endpoints_for_all_url_paths, +) +from api_client_generator.generate_types_from_swagger import generate_types_from_swagger + + +def generate_api_client_from_swagger( # NOQA: PLR0913 + openapi_api_definition: str | Path, + output_package: str | Path, + base_class: type[BaseApiClass] | str, + base_class_source: str | None = None, + endpoint_decorator: str = DEFAULT_ENDPOINT_REST_DECORATOR_NAME, + *, + asynchronous: bool = True, +) -> None: + """ + Generates a REST API client from an OpenAPI definition file. + + Args: + openapi_api_definition: The OpenAPI JSON definition file path. + output_package: The output package where the generated client will be saved. It should be a directory path. + base_class: The base class for the API client. + base_class_source: Optional source file for the base class, if it is not in the same module as the generated client. + endpoint_decorator: The name of the decorator to be used for the endpoints. + asynchronous: Whether to create asynchronous endpoints. + + Raises: + FileNotFoundError: If the OpenAPI definition file does not exist. + RunningScriptWithoutAppropriateFlagError: If the script is run without the appropriate (-m) flag. + """ + check_whether_was_ran_as_script() + + openapi_file = openapi_api_definition if isinstance(openapi_api_definition, Path) else Path(openapi_api_definition) + output_package = output_package if isinstance(output_package, Path) else Path(output_package) + + generate_types_from_swagger(openapi_api_definition, output_package) + + openapi = json.loads(openapi_file.read_text()) + + server_url = get_api_name_from_server_property(openapi) + api_name = hyphen_to_snake(server_url) + + types_module_name = get_types_name_from_components(openapi) + module_path = output_package / f"{types_module_name}.py" + + endpoints = create_endpoints_for_all_url_paths(openapi, asynchronous=asynchronous) + + class_and_imports = create_client_and_imports( + api_name, + server_url, + endpoints, + module_path, + base_class, + base_class_source, + endpoint_decorator, + ) + + client_module = ast.Module(body=[*class_and_imports.imports, class_and_imports.class_def], type_ignores=[]) + + output_client_file = output_package / f"{api_name}_client.py" + + export_module_to_file(client_module, mode="w", file_path=output_client_file) diff --git a/python/poetry.lock b/python/poetry.lock new file mode 100644 index 0000000000000000000000000000000000000000..8963fba2a227463a1b5f8dec06ac81ea40a073f8 --- /dev/null +++ b/python/poetry.lock @@ -0,0 +1,886 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "argcomplete" +version = "3.6.2" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591"}, + {file = "argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + +[[package]] +name = "black" +version = "25.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.2.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "datamodel-code-generator" +version = "0.32.0" +description = "Datamodel Code Generator" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "datamodel_code_generator-0.32.0-py3-none-any.whl", hash = "sha256:48f3cabbb792398112ee756b23a319e17b001ee534896b324893a98ff10e0a55"}, + {file = "datamodel_code_generator-0.32.0.tar.gz", hash = "sha256:c6f84a6a7683ef9841940b0931aa1ee338b19950ba5b10c920f9c7ad6f5e5b72"}, +] + +[package.dependencies] +argcomplete = ">=2.10.1,<4" +black = ">=19.10b0" +genson = ">=1.2.1,<2" +inflect = ">=4.1,<8" +isort = ">=4.3.21,<7" +jinja2 = ">=2.10.1,<4" +packaging = "*" +pydantic = ">=1.5" +pyyaml = ">=6.0.1" + +[package.extras] +all = ["graphql-core (>=3.2.3)", "httpx (>=0.24.1)", "openapi-spec-validator (>=0.2.8,<0.7)", "prance (>=0.18.2)", "pysnooper (>=0.4.1,<2)", "ruff (>=0.9.10)"] +debug = ["pysnooper (>=0.4.1,<2)"] +graphql = ["graphql-core (>=3.2.3)"] +http = ["httpx (>=0.24.1)"] +ruff = ["ruff (>=0.9.10)"] +validation = ["openapi-spec-validator (>=0.2.8,<0.7)", "prance (>=0.18.2)"] + +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + +[[package]] +name = "filelock" +version = "3.18.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, + {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + +[[package]] +name = "genson" +version = "1.3.0" +description = "GenSON is a powerful, user-friendly JSON Schema generator." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7"}, + {file = "genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37"}, +] + +[[package]] +name = "identify" +version = "2.6.12" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, + {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "inflect" +version = "7.5.0" +description = "Correctly generate plurals, singular nouns, ordinals, indefinite articles" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344"}, + {file = "inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f"}, +] + +[package.dependencies] +more_itertools = ">=8.5.0" +typeguard = ">=4.0.1" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["pygments", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e"}, + {file = "more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3"}, +] + +[[package]] +name = "msgspec" +version = "0.18.6" +description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "msgspec-0.18.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:77f30b0234eceeff0f651119b9821ce80949b4d667ad38f3bfed0d0ebf9d6d8f"}, + {file = "msgspec-0.18.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a76b60e501b3932782a9da039bd1cd552b7d8dec54ce38332b87136c64852dd"}, + {file = "msgspec-0.18.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06acbd6edf175bee0e36295d6b0302c6de3aaf61246b46f9549ca0041a9d7177"}, + {file = "msgspec-0.18.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40a4df891676d9c28a67c2cc39947c33de516335680d1316a89e8f7218660410"}, + {file = "msgspec-0.18.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6896f4cd5b4b7d688018805520769a8446df911eb93b421c6c68155cdf9dd5a"}, + {file = "msgspec-0.18.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3ac4dd63fd5309dd42a8c8c36c1563531069152be7819518be0a9d03be9788e4"}, + {file = "msgspec-0.18.6-cp310-cp310-win_amd64.whl", hash = "sha256:fda4c357145cf0b760000c4ad597e19b53adf01382b711f281720a10a0fe72b7"}, + {file = "msgspec-0.18.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e77e56ffe2701e83a96e35770c6adb655ffc074d530018d1b584a8e635b4f36f"}, + {file = "msgspec-0.18.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5351afb216b743df4b6b147691523697ff3a2fc5f3d54f771e91219f5c23aaa"}, + {file = "msgspec-0.18.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3232fabacef86fe8323cecbe99abbc5c02f7698e3f5f2e248e3480b66a3596b"}, + {file = "msgspec-0.18.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3b524df6ea9998bbc99ea6ee4d0276a101bcc1aa8d14887bb823914d9f60d07"}, + {file = "msgspec-0.18.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:37f67c1d81272131895bb20d388dd8d341390acd0e192a55ab02d4d6468b434c"}, + {file = "msgspec-0.18.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d0feb7a03d971c1c0353de1a8fe30bb6579c2dc5ccf29b5f7c7ab01172010492"}, + {file = "msgspec-0.18.6-cp311-cp311-win_amd64.whl", hash = "sha256:41cf758d3f40428c235c0f27bc6f322d43063bc32da7b9643e3f805c21ed57b4"}, + {file = "msgspec-0.18.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d86f5071fe33e19500920333c11e2267a31942d18fed4d9de5bc2fbab267d28c"}, + {file = "msgspec-0.18.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce13981bfa06f5eb126a3a5a38b1976bddb49a36e4f46d8e6edecf33ccf11df1"}, + {file = "msgspec-0.18.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e97dec6932ad5e3ee1e3c14718638ba333befc45e0661caa57033cd4cc489466"}, + {file = "msgspec-0.18.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad237100393f637b297926cae1868b0d500f764ccd2f0623a380e2bcfb2809ca"}, + {file = "msgspec-0.18.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db1d8626748fa5d29bbd15da58b2d73af25b10aa98abf85aab8028119188ed57"}, + {file = "msgspec-0.18.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d70cb3d00d9f4de14d0b31d38dfe60c88ae16f3182988246a9861259c6722af6"}, + {file = "msgspec-0.18.6-cp312-cp312-win_amd64.whl", hash = "sha256:1003c20bfe9c6114cc16ea5db9c5466e49fae3d7f5e2e59cb70693190ad34da0"}, + {file = "msgspec-0.18.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f7d9faed6dfff654a9ca7d9b0068456517f63dbc3aa704a527f493b9200b210a"}, + {file = "msgspec-0.18.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9da21f804c1a1471f26d32b5d9bc0480450ea77fbb8d9db431463ab64aaac2cf"}, + {file = "msgspec-0.18.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46eb2f6b22b0e61c137e65795b97dc515860bf6ec761d8fb65fdb62aa094ba61"}, + {file = "msgspec-0.18.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8355b55c80ac3e04885d72db515817d9fbb0def3bab936bba104e99ad22cf46"}, + {file = "msgspec-0.18.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9080eb12b8f59e177bd1eb5c21e24dd2ba2fa88a1dbc9a98e05ad7779b54c681"}, + {file = "msgspec-0.18.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cc001cf39becf8d2dcd3f413a4797c55009b3a3cdbf78a8bf5a7ca8fdb76032c"}, + {file = "msgspec-0.18.6-cp38-cp38-win_amd64.whl", hash = "sha256:fac5834e14ac4da1fca373753e0c4ec9c8069d1fe5f534fa5208453b6065d5be"}, + {file = "msgspec-0.18.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:974d3520fcc6b824a6dedbdf2b411df31a73e6e7414301abac62e6b8d03791b4"}, + {file = "msgspec-0.18.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fd62e5818731a66aaa8e9b0a1e5543dc979a46278da01e85c3c9a1a4f047ef7e"}, + {file = "msgspec-0.18.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7481355a1adcf1f08dedd9311193c674ffb8bf7b79314b4314752b89a2cf7f1c"}, + {file = "msgspec-0.18.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6aa85198f8f154cf35d6f979998f6dadd3dc46a8a8c714632f53f5d65b315c07"}, + {file = "msgspec-0.18.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e24539b25c85c8f0597274f11061c102ad6b0c56af053373ba4629772b407be"}, + {file = "msgspec-0.18.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c61ee4d3be03ea9cd089f7c8e36158786cd06e51fbb62529276452bbf2d52ece"}, + {file = "msgspec-0.18.6-cp39-cp39-win_amd64.whl", hash = "sha256:b5c390b0b0b7da879520d4ae26044d74aeee5144f83087eb7842ba59c02bc090"}, + {file = "msgspec-0.18.6.tar.gz", hash = "sha256:a59fc3b4fcdb972d09138cb516dbde600c99d07c38fd9372a6ef500d2d031b4e"}, +] + +[package.extras] +dev = ["attrs", "coverage", "furo", "gcovr", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli ; python_version < \"3.11\"", "tomli-w"] +doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"] +test = ["attrs", "msgpack", "mypy", "pyright", "pytest", "pyyaml", "tomli ; python_version < \"3.11\"", "tomli-w"] +toml = ["tomli ; python_version < \"3.11\"", "tomli-w"] +yaml = ["pyyaml"] + +[[package]] +name = "mypy" +version = "1.11.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, + {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, + {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, + {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, + {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, + {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, + {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, + {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, + {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, + {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, + {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, + {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, + {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, + {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, + {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, + {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, + {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, + {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "2.21.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, + {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pydantic" +version = "2.11.7" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, + {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "ruff" +version = "0.11.5" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b"}, + {file = "ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077"}, + {file = "ruff-0.11.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4bfd80a6ec559a5eeb96c33f832418bf0fb96752de0539905cf7b0cc1d31d779"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0947c0a1afa75dcb5db4b34b070ec2bccee869d40e6cc8ab25aca11a7d527794"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad871ff74b5ec9caa66cb725b85d4ef89b53f8170f47c3406e32ef040400b038"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6cf918390cfe46d240732d4d72fa6e18e528ca1f60e318a10835cf2fa3dc19f"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56145ee1478582f61c08f21076dc59153310d606ad663acc00ea3ab5b2125f82"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5f66f8f1e8c9fc594cbd66fbc5f246a8d91f916cb9667e80208663ec3728304"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80b4df4d335a80315ab9afc81ed1cff62be112bd165e162b5eed8ac55bfc8470"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3068befab73620b8a0cc2431bd46b3cd619bc17d6f7695a3e1bb166b652c382a"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5da2e710a9641828e09aa98b92c9ebbc60518fdf3921241326ca3e8f8e55b8b"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ef39f19cb8ec98cbc762344921e216f3857a06c47412030374fffd413fb8fd3a"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2a7cedf47244f431fd11aa5a7e2806dda2e0c365873bda7834e8f7d785ae159"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:81be52e7519f3d1a0beadcf8e974715b2dfc808ae8ec729ecfc79bddf8dbb783"}, + {file = "ruff-0.11.5-py3-none-win32.whl", hash = "sha256:e268da7b40f56e3eca571508a7e567e794f9bfcc0f412c4b607931d3af9c4afe"}, + {file = "ruff-0.11.5-py3-none-win_amd64.whl", hash = "sha256:6c6dc38af3cfe2863213ea25b6dc616d679205732dc0fb673356c2d69608f800"}, + {file = "ruff-0.11.5-py3-none-win_arm64.whl", hash = "sha256:67e241b4314f4eacf14a601d586026a962f4002a475aa702c69980a38087aa4e"}, + {file = "ruff-0.11.5.tar.gz", hash = "sha256:cae2e2439cb88853e421901ec040a758960b576126dab520fa08e9de431d1bef"}, +] + +[[package]] +name = "typeguard" +version = "4.4.4" +description = "Run-time type checker for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e"}, + {file = "typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74"}, +] + +[package.dependencies] +typing_extensions = ">=4.14.0" + +[[package]] +name = "typing-extensions" +version = "4.14.1" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "virtualenv" +version = "20.33.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67"}, + {file = "virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[metadata] +lock-version = "2.1" +python-versions = "^3.12" +content-hash = "d750fa0ff39a19ecb4b3cf7cbd74462bc885a02b9fffc82afc39323313aa92bd" diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..41a4ab398b2c269abaeebfd8c60b14941cb8f297 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = [ + "poetry-core>=1.7.0", + "poetry-dynamic-versioning>=1.0.0,<2.0.0", +] +build-backend = "poetry_dynamic_versioning.backend" + + +[tool.poetry] +name = "api_client_generator" +version = "0.0.0" +description = "" +authors = [ + "Jakub Ziębiński ", +] +packages = [{ include = "api_client_generator" }] + +[tool.poetry.dependencies] +python = "^3.12" +msgspec = "0.18.6" +datamodel-code-generator = "0.32.0" +ruff = "0.11.5" + +[tool.poetry.group.dev.dependencies] +mypy = "1.11.2" +pre-commit = "2.21.0" +pytest = "8.3.5" +pytest-asyncio = "0.25.3" + +[tool.poetry-dynamic-versioning] +enable = true +pattern = '''^v?(?P\d+\.\d+\.\d+)(?:(?P[a-zA-Z]+)(?P\d+))?$''' +format-jinja = """ + {%- set dirty_postfix = '.dirty' if dirty else '' -%} + {%- if distance == 0 and not dirty -%} + {{ serialize_pep440(base, stage, revision) }} + {%- elif revision is not none -%} + {{ serialize_pep440(base, stage, revision + 1, dev=distance, metadata=[commit]) }}{{ dirty_postfix }} + {%- else -%} + {{ serialize_pep440(bump_version(base), stage, revision, dev=distance, metadata=[commit]) }}{{ dirty_postfix }} + {%- endif -%} +""" + +[tool.poetry-dynamic-versioning.substitution] +files = [ + "api_client_generator/__init__.py", +] + +[tool.mypy] +strict = true + +[tool.ruff] +line-length = 120 + +[tool.pytest.ini_options] +log_cli = true +log_level = "INFO" +log_format = "%(asctime)s [%(levelname)s] %(message)s (%(filename)s:%(lineno)s)" +log_date_format = "%Y-%m-%d %H:%M:" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/tests/base_api_classes.py b/python/tests/base_api_classes.py new file mode 100644 index 0000000000000000000000000000000000000000..5fe68e9c65cab57a0f156337afb0b02a08c3c929 --- /dev/null +++ b/python/tests/base_api_classes.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from functools import wraps +from typing import Any, Awaitable, Callable, Final, Self + +VALID_RETURN_VALUE: Final[str] = "valid_return_value" + + +class MockAsyncJsonRpcApiBase: + """Base class for test generating async api.""" + + @classmethod + def endpoint_jsonrpc(cls, wrapped_function: Callable[..., Awaitable[str]]) -> Callable[..., Awaitable[str]]: + @wraps(wrapped_function) + async def impl(*args: Any, **kwargs: Any) -> str: # NOQA: ARG001 + return VALID_RETURN_VALUE + + return impl + + +class MockSyncJsonRpcApiBase: + """Base class for test generating async api.""" + + @classmethod + def endpoint_jsonrpc(cls, wrapped_function: Callable[..., str]) -> Callable[..., str]: + @wraps(wrapped_function) + def impl(*args: Any, **kwargs: Any) -> str: # NOQA: ARG001 + return VALID_RETURN_VALUE + + return impl + + +class MockAsyncRestApiBase: + """Base class for test generating async rest api.""" + + @classmethod + def endpoint_rest(cls) -> Self: + return cls() + + @classmethod + def get(cls, path: str) -> Callable[[Callable[..., Awaitable[str]]], Callable[..., Awaitable[str]]]: # NOQA: ARG003 + def decorator(func: Callable[..., Awaitable[str]]) -> Callable[..., Awaitable[str]]: + @wraps(func) + async def impl(*args: Any, **kwargs: Any) -> str: # NOQA: ARG001 + return VALID_RETURN_VALUE + + return impl + + return decorator + + +class MockSyncRestApiBase: + """Base class for test generating sync rest api.""" + + @classmethod + def endpoint_rest(cls) -> Self: + return cls() + + @classmethod + def get(cls, path: str) -> Callable[[Callable[..., str]], Callable[..., str]]: # NOQA: ARG003 + def decorator(func: Callable[..., str]) -> Callable[..., str]: + @wraps(func) + def impl(*args: Any, **kwargs: Any) -> str: # NOQA: ARG001 + return VALID_RETURN_VALUE + + return impl + + return decorator diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..3682b2323290c5bf1cf724d70d24ae67e5ddb686 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def generate_needed_modules() -> None: + from .generate_clients_and_collections import generate_api_description_from_swagger + + generate_api_description_from_swagger() + + from .generate_clients_and_collections import generate_clients_and_collections + + generate_clients_and_collections() diff --git a/python/tests/generate_clients_and_collections.py b/python/tests/generate_clients_and_collections.py new file mode 100644 index 0000000000000000000000000000000000000000..08fd0921e96a1f85c1f59fb7221b81ed009dc68b --- /dev/null +++ b/python/tests/generate_clients_and_collections.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path + +from api_client_generator.json_rpc import ( + generate_api_client, + generate_api_collection, + generate_api_description, +) +from api_client_generator.rest import generate_api_client_from_swagger +from tests.base_api_classes import ( + MockAsyncJsonRpcApiBase, + MockAsyncRestApiBase, + MockSyncJsonRpcApiBase, + MockSyncRestApiBase, +) +from tests.manual_definition.input import ( + GENERATOR_TEST_API_COLLECTION, + GENERATOR_TEST_SINGLE_API, +) + +SYNC_API_COLLECTION_NAME = "GeneratedSyncApiCollection" +ASYNC_API_COLLECTION_NAME = "GeneratedAsyncApiCollection" + +SINGLE_ASYNC_API_DESTINATION = Path(__file__).parent / "manual_definition" / "generated_async_single_api.py" +SINGLE_SYNC_API_DESTINATION = Path(__file__).parent / "manual_definition" / "generated_sync_single_api.py" +COLLECTION_ASYNC_API_DESTINATION = Path(__file__).parent / "manual_definition" / "generated_async_api_collection.py" +COLLECTION_SYNC_API_DESTINATION = Path(__file__).parent / "manual_definition" / "generated_sync_api_collection.py" + +OPENAPI_JSON_RPC_DEFINITION_DESTINATION = Path(__file__).parent / "swagger" / "json_rpc" / "openapi.json" +SYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION = Path(__file__).parent / "swagger" / "json_rpc" / "generated_sync_api.py" +JSON_RPC_DESCRIPTION_OUTPUT_FILE = Path(__file__).parent / "swagger" / "json_rpc" / "api_description.py" +ASYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION = Path(__file__).parent / "swagger" / "json_rpc" / "generated_async_api.py" + +OPENAPI_REST_DEFINITION_DESTINATION = Path(__file__).parent / "swagger" / "rest" / "openapi.json" +OUTPUT_SYNC_REST_API_CLIENT_DESTINATION = Path(__file__).parent / "swagger" / "rest" / "generated_sync" +OUTPUT_ASYNC_REST_API_CLIENT_DESTINATION = Path(__file__).parent / "swagger" / "rest" / "generated_async" + + +def generate_api_description_from_swagger() -> None: + generate_api_description( + "test_api_description", OPENAPI_JSON_RPC_DEFINITION_DESTINATION, JSON_RPC_DESCRIPTION_OUTPUT_FILE + ) + + +def generate_clients_and_collections() -> None: + from .swagger.json_rpc.api_description import test_api_description # type: ignore[import-untyped] + + generate_api_client_from_swagger( + OPENAPI_REST_DEFINITION_DESTINATION, + OUTPUT_SYNC_REST_API_CLIENT_DESTINATION, + base_class=MockSyncRestApiBase, # type: ignore[arg-type] + asynchronous=False, + ) + + generate_api_client_from_swagger( + OPENAPI_REST_DEFINITION_DESTINATION, + OUTPUT_ASYNC_REST_API_CLIENT_DESTINATION, + base_class=MockAsyncRestApiBase, # type: ignore[arg-type] + ) + + generate_api_client( + test_api_description, + MockSyncJsonRpcApiBase, # type: ignore[arg-type] + path=SYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION, + asynchronous=False, + ) + + generate_api_client( + test_api_description, + MockAsyncJsonRpcApiBase, # type: ignore[arg-type] + path=ASYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION, + ) + + generate_api_client( + GENERATOR_TEST_SINGLE_API, # type: ignore[arg-type] + MockSyncJsonRpcApiBase, # type: ignore[arg-type] + path=SINGLE_SYNC_API_DESTINATION, + asynchronous=False, + ) + + generate_api_client( + GENERATOR_TEST_SINGLE_API, # type: ignore[arg-type] + MockAsyncJsonRpcApiBase, # type: ignore[arg-type] + path=SINGLE_ASYNC_API_DESTINATION, + ) + + generate_api_collection( + GENERATOR_TEST_API_COLLECTION, + MockSyncJsonRpcApiBase, # type: ignore[arg-type] + collection_name=SYNC_API_COLLECTION_NAME, + path=COLLECTION_SYNC_API_DESTINATION, + asynchronous=False, + ) + + generate_api_collection( + GENERATOR_TEST_API_COLLECTION, + MockAsyncJsonRpcApiBase, # type: ignore[arg-type] + collection_name=ASYNC_API_COLLECTION_NAME, + path=COLLECTION_ASYNC_API_DESTINATION, + ) diff --git a/python/tests/manual_definition/__init__.py b/python/tests/manual_definition/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/tests/manual_definition/input.py b/python/tests/manual_definition/input.py new file mode 100644 index 0000000000000000000000000000000000000000..f8dd56bbdb061b1803f69eca206c61870fe324d4 --- /dev/null +++ b/python/tests/manual_definition/input.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from msgspec import Struct + + +class TestCustomType(Struct): ... + + +class TestApiParams(Struct): + some_custom_type: TestCustomType + some_string: str = "" + some_int: int = 0 + + +TestApiResult = str + + +GENERATOR_TEST_SINGLE_API = { + "test_api": { + "first_endpoint": { + "params": TestApiParams, + "result": TestApiResult, + }, + "second_endpoint": { + "params": TestApiParams, + "result": TestApiResult, + }, + "third_endpoint": { + "params": TestApiParams, + "result": TestApiResult, + "description": "This is a test endpoint", + "response_array": True, + }, + } +} + +GENERATOR_TEST_API_COLLECTION = { + "first_test_api": { + "first_endpoint": { + "params": TestApiParams, + "result": TestApiResult, + }, + "second_endpoint": { + "params": TestApiParams, + "result": TestApiResult, + }, + }, + "second_test_api": { + "first_endpoint": { + "params": TestApiParams, + "result": TestApiResult, + }, + }, +} diff --git a/python/tests/manual_definition/output.py b/python/tests/manual_definition/output.py new file mode 100644 index 0000000000000000000000000000000000000000..dcf72862b81b1a151a4dead5a22cc26e3a6e36b6 --- /dev/null +++ b/python/tests/manual_definition/output.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Any + +from .input import TestCustomType + +VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT: dict[str, Any] = { + "return": str, + "some_custom_type": TestCustomType, + "some_string": str, + "some_int": int, +} + +VALID_PARAMS_FOR_THIRD_ENDPOINT: dict[str, Any] = { + "return": list[str], + "some_custom_type": TestCustomType, + "some_string": str, + "some_int": int, +} diff --git a/python/tests/manual_definition/test_collection_generator.py b/python/tests/manual_definition/test_collection_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..54048357aedeff09fe41ae342ab9125662d2a4e6 --- /dev/null +++ b/python/tests/manual_definition/test_collection_generator.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import get_type_hints + +import pytest + +from tests.base_api_classes import VALID_RETURN_VALUE +from tests.generate_clients_and_collections import ( + COLLECTION_ASYNC_API_DESTINATION, + COLLECTION_SYNC_API_DESTINATION, +) +from tests.manual_definition.output import VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT +from tests.messages import ( + API_NOT_GENERATED_MESSAGE, + ENDPOINT_IS_NOT_CALLABLE_MESSAGE, + ENDPOINT_NOT_GENERATED_MESSAGE, +) + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_is_collection_generated(api_type: str) -> None: + # ARRANGE + api_destination = COLLECTION_SYNC_API_DESTINATION if api_type == "sync" else COLLECTION_ASYNC_API_DESTINATION + + # ASSERT + assert api_destination.exists(), "API collection file was not created" + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_endpoint_methods_created_and_are_callable(api_type: str) -> None: + # ARRANGE & ACT + if api_type == "async": + from tests.manual_definition.generated_async_api_collection import ( # type: ignore[import-untyped] + GeneratedAsyncApiCollection as GeneratedCollection, + ) + else: + from tests.manual_definition.generated_sync_api_collection import ( # type: ignore[import-untyped] + GeneratedSyncApiCollection as GeneratedCollection, + ) + + api_collection = GeneratedCollection() + + # ASSERT + assert hasattr(api_collection, "first_test_api"), API_NOT_GENERATED_MESSAGE + assert hasattr(api_collection, "second_test_api"), API_NOT_GENERATED_MESSAGE + + assert hasattr(api_collection.first_test_api, "first_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + assert hasattr(api_collection.first_test_api, "second_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + assert hasattr(api_collection.second_test_api, "first_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + + assert callable(api_collection.first_test_api.first_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + assert callable(api_collection.first_test_api.second_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + + assert callable(api_collection.second_test_api.first_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_api_methods_signature(api_type: str) -> None: + # ARRANGE & ACT + if api_type == "async": + from tests.manual_definition.generated_async_api_collection import ( + GeneratedAsyncApiCollection as GeneratedCollection, + ) + else: + from tests.manual_definition.generated_sync_api_collection import ( + GeneratedSyncApiCollection as GeneratedCollection, + ) + + api_collection = GeneratedCollection() + + # ASSERT + assert get_type_hints(api_collection.first_test_api.first_endpoint) == VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT, ( + "First test endpoint signature is invalid" + ) + assert ( + get_type_hints(api_collection.first_test_api.second_endpoint) == VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT + ), "Second test endpoint signature is invalid" + + assert ( + get_type_hints(api_collection.second_test_api.first_endpoint) == VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT + ), "First test endpoint signature is invalid" + + +def test_sync_api_return_values() -> None: + # ARRANGE & ACT + from tests.manual_definition.generated_sync_api_collection import ( + GeneratedSyncApiCollection, + ) + + api_collection = GeneratedSyncApiCollection() + + # ASSERT + assert api_collection.first_test_api.first_endpoint() == VALID_RETURN_VALUE, ( + "First test endpoint returned invalid value" + ) + assert api_collection.first_test_api.second_endpoint() == VALID_RETURN_VALUE, ( + "Second test endpoint returned invalid value" + ) + + assert api_collection.second_test_api.first_endpoint() == VALID_RETURN_VALUE, ( + "First test endpoint returned invalid value" + ) + + +async def test_async_api_return_values() -> None: + # ARRANGE & ACT + from tests.manual_definition.generated_async_api_collection import GeneratedAsyncApiCollection + + api_collection = GeneratedAsyncApiCollection() + + # ASSERT + assert await api_collection.first_test_api.first_endpoint() == VALID_RETURN_VALUE, ( + "First test endpoint returned invalid value" + ) + assert await api_collection.first_test_api.second_endpoint() == VALID_RETURN_VALUE, ( + "Second test endpoint returned invalid value" + ) + + assert await api_collection.second_test_api.first_endpoint() == VALID_RETURN_VALUE, ( + "First test endpoint returned invalid value" + ) diff --git a/python/tests/manual_definition/test_single_class_generator.py b/python/tests/manual_definition/test_single_class_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..7fe464d6617a7bfdad6b9fd008e1cf5ec3a93628 --- /dev/null +++ b/python/tests/manual_definition/test_single_class_generator.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import get_type_hints + +import pytest + +from tests.base_api_classes import VALID_RETURN_VALUE +from tests.generate_clients_and_collections import ( + SINGLE_ASYNC_API_DESTINATION, + SINGLE_SYNC_API_DESTINATION, +) +from tests.manual_definition.output import ( + VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT, + VALID_PARAMS_FOR_THIRD_ENDPOINT, +) +from tests.messages import ENDPOINT_IS_NOT_CALLABLE_MESSAGE, ENDPOINT_NOT_GENERATED_MESSAGE + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_is_api_file_created(api_type: str) -> None: + # ARRANGE + api_destination = SINGLE_SYNC_API_DESTINATION if api_type == "sync" else SINGLE_ASYNC_API_DESTINATION + + # ASSERT + assert api_destination.exists(), "API file was not created" + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_endpoint_methods_created(api_type: str) -> None: + # ARRANGE + if api_type == "async": + from tests.manual_definition.generated_async_single_api import ( # type: ignore[import-untyped] + TestApi, + ) + + else: + from tests.manual_definition.generated_sync_single_api import ( # type: ignore[import-untyped] + TestApi, + ) + + # ASSERT + assert hasattr(TestApi, "first_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + assert hasattr(TestApi, "second_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + assert hasattr(TestApi, "third_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + + assert callable(TestApi.first_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + assert callable(TestApi.second_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + assert callable(TestApi.third_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_api_methods_signature(api_type: str) -> None: + # ARRANGE & ACT + if api_type == "async": + from tests.manual_definition.generated_async_single_api import TestApi # type: ignore[import-untyped] + else: + from tests.manual_definition.generated_sync_single_api import TestApi # type: ignore[import-untyped] + + test_api_instance = TestApi() + + # ASSERT + assert get_type_hints(test_api_instance.first_endpoint) == VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT, ( + "First test endpoint signature is invalid" + ) + assert get_type_hints(test_api_instance.second_endpoint) == VALID_PARAMS_FOR_FIRST_AND_SECOND_ENDPOINT, ( + "Second test endpoint signature is invalid" + ) + assert get_type_hints(test_api_instance.third_endpoint) == VALID_PARAMS_FOR_THIRD_ENDPOINT, ( + "Third test endpoint signature is invalid" + ) + + +def test_sync_api_return_values() -> None: + # ARRANGE & ACT + from tests.manual_definition.generated_sync_single_api import TestApi + + test_api_instance = TestApi() + + # ASSERT + assert test_api_instance.first_endpoint() == VALID_RETURN_VALUE, "First test endpoint returned invalid value" + assert test_api_instance.second_endpoint() == VALID_RETURN_VALUE, "Second test endpoint returned invalid value" + assert test_api_instance.third_endpoint() == VALID_RETURN_VALUE, "Third test endpoint returned invalid value" + + +async def test_async_api_return_values() -> None: + # ARRANGE & ACT + from tests.manual_definition.generated_async_single_api import TestApi + + test_api_instance = TestApi() + + # ASSERT + assert await test_api_instance.first_endpoint() == VALID_RETURN_VALUE, "First test endpoint returned invalid value" + assert await test_api_instance.second_endpoint() == VALID_RETURN_VALUE, ( + "Second test endpoint returned invalid value" + ) + assert await test_api_instance.third_endpoint() == VALID_RETURN_VALUE, "Third test endpoint returned invalid value" diff --git a/python/tests/messages.py b/python/tests/messages.py new file mode 100644 index 0000000000000000000000000000000000000000..0469f8fb1ebe55159a5802745ba4a1facbbe637b --- /dev/null +++ b/python/tests/messages.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Final + +API_NOT_GENERATED_MESSAGE: Final[str] = "One of the APIs were not created" +ENDPOINT_NOT_GENERATED_MESSAGE: Final[str] = "One of the API endpoints was not created" +ENDPOINT_IS_NOT_CALLABLE_MESSAGE: Final[str] = "The endpoint is not callable" diff --git a/python/tests/swagger/__init__.py b/python/tests/swagger/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/tests/swagger/json_rpc/__init__.py b/python/tests/swagger/json_rpc/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/tests/swagger/json_rpc/openapi.json b/python/tests/swagger/json_rpc/openapi.json new file mode 100644 index 0000000000000000000000000000000000000000..827208fe930713f7a87853060fcf4996defaa7ab --- /dev/null +++ b/python/tests/swagger/json_rpc/openapi.json @@ -0,0 +1,148 @@ +{ + "components": { + "schemas": { + "first_endpoint": { + "properties": { + "some_another_string": { + "description": "Third param", + "example": "Tralala", + "type": "string" + }, + "some_integer": { + "description": "Second param", + "example": 123, + "type": "integer" + }, + "some_string": { + "description": "First param", + "example": "Test test test", + "type": "string" + } + }, + "required": [ + "some_string", + "some_integer", + "some_another_string" + ], + "type": "object" + }, + "first_endpoint_response": { + "type": "string" + }, + "second_endpoint": { + "properties": { + "some_another_string": { + "description": "Third param", + "example": "Tralala", + "type": "string" + }, + "some_integer": { + "description": "Second param", + "example": 123, + "type": "integer" + }, + "some_string": { + "description": "First param", + "example": "Test test test", + "type": "string" + } + }, + "required": [ + "some_string", + "some_integer", + "some_another_string" + ], + "type": "object" + }, + "second_endpoint_response": { + "items": { + "properties": { + "some_bool": { + "description": "Some boolean", + "type": "boolean" + }, + "some_string": { + "description": "Some string", + "example": "Lalala", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + } + }, + "info": { + "description": "Simple api description for testing purposes", + "title": "Test api", + "version": "1.2.1" + }, + "openapi": "3.0.3", + "paths": { + "test_api.first_endpoint": { + "post": { + "description": "First endpoint description", + "operationId": "first_endpoint", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/first_endpoint" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/first_endpoint_response" + } + } + }, + "description": "Successful operation" + } + }, + "summary": "Test api", + "tags": [ + "first_endpoint" + ] + } + }, + "test_api.second_endpoint": { + "post": { + "description": "Sample second_endpoint description", + "operationId": "second_endpoint", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/second_endpoint" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/second_endpoint_response" + } + } + }, + "description": "Successful operation" + } + }, + "summary": "Second endpoint api", + "tags": [ + "second_endpoint" + ] + } + } + } +} diff --git a/python/tests/swagger/json_rpc/test_generation_from_swagger.py b/python/tests/swagger/json_rpc/test_generation_from_swagger.py new file mode 100644 index 0000000000000000000000000000000000000000..f8cf378e09eeee2114216f3e310bcf927292b277 --- /dev/null +++ b/python/tests/swagger/json_rpc/test_generation_from_swagger.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import get_type_hints + +import pytest + +from tests.generate_clients_and_collections import ( + ASYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION, + JSON_RPC_DESCRIPTION_OUTPUT_FILE, + SYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION, +) +from tests.messages import ENDPOINT_IS_NOT_CALLABLE_MESSAGE, ENDPOINT_NOT_GENERATED_MESSAGE +from tests.swagger.json_rpc.valid_output import ( + VALID_PARAMS_FOR_FIRST_ENDPOINT, + get_valid_params_for_second_endpoint, +) + + +def test_is_api_description_created() -> None: + # ASSERT + assert JSON_RPC_DESCRIPTION_OUTPUT_FILE.exists(), "json rpc API description from swagger was not created" + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_is_api_file_created(api_type: str) -> None: + # ARRANGE + api_destination = ( + SYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION + if api_type == "sync" + else ASYNC_JSON_RPC_API_FROM_SWAGGER_DESTINATION + ) + + # ASSERT + assert api_destination.exists(), "API file was not created" + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_endpoint_methods_created(api_type: str) -> None: + # ARRANGE + if api_type == "async": + from tests.swagger.json_rpc.generated_async_api import ( # type: ignore[import-untyped] + TestApi, + ) + else: + from tests.swagger.json_rpc.generated_sync_api import TestApi # type: ignore[import-untyped] + + # ASSERT + assert hasattr(TestApi, "first_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + assert hasattr(TestApi, "second_endpoint"), ENDPOINT_NOT_GENERATED_MESSAGE + + assert callable(TestApi.first_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + assert callable(TestApi.second_endpoint), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_api_methods_signature(api_type: str) -> None: + # ARRANGE & ACT + if api_type == "async": + from tests.swagger.json_rpc.generated_async_api import ( # type: ignore[import-untyped] + TestApi, + ) + else: + from tests.swagger.json_rpc.generated_sync_api import TestApi # type: ignore[import-untyped] + + test_api_instance = TestApi() + + # ASSERT + assert get_type_hints(test_api_instance.first_endpoint) == VALID_PARAMS_FOR_FIRST_ENDPOINT, ( + "First test endpoint generated from swagger signature is invalid" + ) + + second_endpoint_type_hints = get_type_hints(test_api_instance.second_endpoint) + + assert second_endpoint_type_hints == get_valid_params_for_second_endpoint(), ( + "Second test endpoint generated from swagger signature is invalid" + ) diff --git a/python/tests/swagger/json_rpc/valid_output.py b/python/tests/swagger/json_rpc/valid_output.py new file mode 100644 index 0000000000000000000000000000000000000000..070c7139dbffa4bbecf0b16fe92f3ae1c954eff7 --- /dev/null +++ b/python/tests/swagger/json_rpc/valid_output.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any + + +def get_valid_params_for_second_endpoint() -> dict[str, type[str] | type[int] | type[list[Any]]]: + """Get valid params for the second endpoint generated from the swagger.""" + from .api_description import SecondEndpointResponseItem # type: ignore[import-untyped] + + return { + "return": list[SecondEndpointResponseItem], + "some_string": str, + "some_integer": int, + "some_another_string": str, + } + + +VALID_PARAMS_FOR_FIRST_ENDPOINT: dict[str, Any] = { + "return": str, + "some_string": str, + "some_integer": int, + "some_another_string": str, +} diff --git a/python/tests/swagger/rest/__init__.py b/python/tests/swagger/rest/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python/tests/swagger/rest/openapi.json b/python/tests/swagger/rest/openapi.json new file mode 100644 index 0000000000000000000000000000000000000000..b5dd37070d0126e5b14cd2520ec4d52bb29b8eb8 --- /dev/null +++ b/python/tests/swagger/rest/openapi.json @@ -0,0 +1,148 @@ +{ + "components": { + "schemas": { + "test_api.balance": { + "properties": { + "hbd_balance": { + "description": "number of HIVE backed dollars the account has", + "type": "integer", + "x-sql-datatype": "BIGINT" + }, + "hive_balance": { + "description": "account's HIVE balance", + "type": "integer", + "x-sql-datatype": "BIGINT" + }, + "vesting_shares": { + "description": "account's VEST balance", + "type": "string" + } + }, + "type": "object" + }, + "test_api.granularity": { + "enum": [ + "first", + "second", + "third" + ], + "type": "string" + }, + "test_api.sort_direction": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + } + }, + "externalDocs": { + "description": "Balance tracker gitlab repository", + "url": "https://gitlab.syncad.com/hive/balance_tracker" + }, + "info": { + "description": "Simple api description for testing purposes", + "license": { + "name": "MIT License", + "url": "https://opensource.org/license/mit" + }, + "title": "Test API", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/test-2": { + "get": { + "description": "...", + "operationId": "....", + "parameters": [ + { + "description": "...", + "in": "query", + "name": "test-name-3", + "required": false, + "schema": { + "$ref": "#/components/schemas/test_api.balance" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "example": "c2fed8958584511ef1a66dab3dbac8c40f3518f0", + "schema": { + "type": "string" + } + } + }, + "description": "..." + }, + "404": { + "description": "..." + } + }, + "summary": "...", + "tags": [ + "Test-tag" + ] + } + }, + "/test/{test-name}/balances": { + "get": { + "description": "...", + "operationId": "...", + "parameters": [ + { + "description": "...", + "in": "path", + "name": "test-name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "...", + "in": "query", + "name": "test-name-2", + "required": false, + "schema": { + "$ref": "#/components/schemas/test_api.sort_direction" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "..." + }, + "404": { + "description": "..." + } + }, + "summary": "Test balances", + "tags": [ + "Accounts" + ] + } + } + }, + "servers": [ + { + "url": "/test-api" + } + ], + "tags": [ + { + "name": "Test-tag" + } + ] +} diff --git a/python/tests/swagger/rest/test_generation_from_swagger.py b/python/tests/swagger/rest/test_generation_from_swagger.py new file mode 100644 index 0000000000000000000000000000000000000000..9d66f5d1fe61af0c14efdb45bb4fd3b22b90bc37 --- /dev/null +++ b/python/tests/swagger/rest/test_generation_from_swagger.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import get_type_hints + +import pytest + +from tests.generate_clients_and_collections import ( + OUTPUT_ASYNC_REST_API_CLIENT_DESTINATION, + OUTPUT_SYNC_REST_API_CLIENT_DESTINATION, +) +from tests.messages import ENDPOINT_IS_NOT_CALLABLE_MESSAGE, ENDPOINT_NOT_GENERATED_MESSAGE +from tests.swagger.rest.valid_output import ( + get_valid_params_for_async_balances_endpoint, + get_valid_params_for_sync_balances_endpoint, + get_valid_params_for_test_2_async_endpoint, + get_valid_params_for_test_2_sync_endpoint, +) + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_is_api_package_created(api_type: str) -> None: + # ARRANGE + api_destination = ( + OUTPUT_SYNC_REST_API_CLIENT_DESTINATION if api_type == "sync" else OUTPUT_ASYNC_REST_API_CLIENT_DESTINATION + ) + + # ASSERT + assert api_destination.exists(), "API file was not created" + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_endpoint_methods_created(api_type: str) -> None: + # ARRANGE + if api_type == "async": + from tests.swagger.rest.generated_async.test_api_client import ( # type: ignore[import-untyped] + TestApi, + ) + else: + from tests.swagger.rest.generated_sync.test_api_client import ( # type: ignore[import-untyped] + TestApi, + ) + + # ASSERT + assert hasattr(TestApi, "test_balances"), ENDPOINT_NOT_GENERATED_MESSAGE + assert hasattr(TestApi, "test_2"), ENDPOINT_NOT_GENERATED_MESSAGE + + assert callable(TestApi.test_balances), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + assert callable(TestApi.test_2), ENDPOINT_IS_NOT_CALLABLE_MESSAGE + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +def test_api_methods_signature(api_type: str) -> None: + # ARRANGE & ACT + if api_type == "async": + from tests.swagger.rest.generated_async.test_api_client import ( + TestApi, + ) + + valid_params_for_balances_endpoint = get_valid_params_for_async_balances_endpoint() + valid_params_for_test_2_endpoint = get_valid_params_for_test_2_async_endpoint() + + else: + from tests.swagger.rest.generated_sync.test_api_client import TestApi + + valid_params_for_balances_endpoint = get_valid_params_for_sync_balances_endpoint() + valid_params_for_test_2_endpoint = get_valid_params_for_test_2_sync_endpoint() + + test_api_instance = TestApi() + + test_2_endpoint_type_hints = get_type_hints(test_api_instance.test_2) + test_balances_endpoint_type_hints = get_type_hints(test_api_instance.test_balances) + + # ASSERT + assert test_balances_endpoint_type_hints == valid_params_for_balances_endpoint, ( + "Test balances endpoint generated from swagger signature is invalid" + ) + + assert test_2_endpoint_type_hints == valid_params_for_test_2_endpoint, ( + "Test 2 endpoint generated from swagger signature is invalid" + ) diff --git a/python/tests/swagger/rest/valid_output.py b/python/tests/swagger/rest/valid_output.py new file mode 100644 index 0000000000000000000000000000000000000000..dd9d48b4d9f70d0001be7c1bcadefebb672ee0a9 --- /dev/null +++ b/python/tests/swagger/rest/valid_output.py @@ -0,0 +1,39 @@ +from __future__ import annotations + + +def get_valid_params_for_sync_balances_endpoint() -> dict[str, type]: + from .generated_sync.test_api import SortDirection # type: ignore[import-untyped] + + return { + "test_name": str, + "test_name_2": SortDirection | None, + "return": str, + } + + +def get_valid_params_for_async_balances_endpoint() -> dict[str, type]: + from .generated_async.test_api import SortDirection # type: ignore[import-untyped] + + return { + "test_name": str, + "test_name_2": SortDirection | None, + "return": str, + } + + +def get_valid_params_for_test_2_sync_endpoint() -> dict[str, type]: + from .generated_sync.test_api import Balance + + return { + "test_name_3": Balance | None, + "return": str, + } + + +def get_valid_params_for_test_2_async_endpoint() -> dict[str, type]: + from .generated_async.test_api import Balance + + return { + "test_name_3": Balance | None, + "return": str, + }