From d5fa771e00a3d57a7d49c93267be96e83caaabca Mon Sep 17 00:00:00 2001 From: test Date: Fri, 10 Jul 2026 14:51:57 +0000 Subject: [PATCH 1/4] chore(open-api#py-client): add hidden Python httpx client generator Rebased port of #586 onto main's rewritten OpenAPI codegen pipeline (parser.ts + typed codegen-data). Emits pydantic v2 models and httpx-based sync/async clients mirroring open-api#ts-client. --- docs/src/i18n/schema-translations.json | 35 + packages/nx-plugin/generators.json | 7 + ...nerator.additional-properties.spec.ts.snap | 34 + .../generator.arrays.spec.ts.snap | 880 ++++ .../generator.complex-types.spec.ts.snap | 82 + .../generator.composite-types.spec.ts.snap | 272 ++ .../generator.duplicate-types.spec.ts.snap | 237 ++ .../generator.errors.spec.ts.snap | 259 ++ .../generator.fast-api.spec.ts.snap | 280 ++ .../generator.petstore.spec.ts.snap | 3670 +++++++++++++++++ .../generator.primitive-types.spec.ts.snap | 745 ++++ .../generator.request.spec.ts.snap | 215 + .../generator.reserved-keywords.spec.ts.snap | 189 + .../generator.response.spec.ts.snap | 399 ++ .../generator.streaming.spec.ts.snap | 206 + .../__snapshots__/generator.tags.spec.ts.snap | 249 ++ .../files/async/async_client_gen.py.template | 446 ++ .../files/shared/__init__.py.template | 25 + .../files/shared/types_gen.py.template | 253 ++ .../files/sync/client_gen.py.template | 456 ++ .../generator.additional-properties.spec.ts | 115 + .../py-client/generator.arrays.spec.ts | 323 ++ .../py-client/generator.complex-types.spec.ts | 192 + .../generator.composite-types.spec.ts | 389 ++ .../py-client/generator.content-type.spec.ts | 170 + .../generator.duplicate-types.spec.ts | 139 + .../py-client/generator.errors.spec.ts | 188 + .../py-client/generator.extras.spec.ts | 174 + .../py-client/generator.fast-api.spec.ts | 183 + .../py-client/generator.petstore.spec.ts | 89 + .../generator.primitive-types.spec.ts | 348 ++ .../py-client/generator.request.spec.ts | 230 ++ .../generator.reserved-keywords.spec.ts | 392 ++ .../py-client/generator.response.spec.ts | 240 ++ .../src/open-api/py-client/generator.spec.ts | 82 + .../py-client/generator.streaming.spec.ts | 170 + .../open-api/py-client/generator.tags.spec.ts | 190 + .../src/open-api/py-client/generator.ts | 97 + .../py-client/generator.type-safety.spec.ts | 600 +++ .../py-client/generator.utils.spec.ts | 173 + .../src/open-api/py-client/schema.d.ts | 12 + .../src/open-api/py-client/schema.json | 27 + .../src/open-api/utils/codegen-data.ts | 334 +- .../utils/codegen-data/languages.spec.ts | 91 +- .../open-api/utils/codegen-data/languages.ts | 251 +- .../src/open-api/utils/codegen-data/types.ts | 113 + packages/nx-plugin/src/sdk/open-api.ts | 3 + packages/nx-plugin/src/utils/test/py.spec.ts | 215 + .../src/utils/test/python-worker/worker.py | 340 ++ packages/nx-plugin/src/utils/versions.ts | 1 + 50 files changed, 14785 insertions(+), 25 deletions(-) create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap create mode 100644 packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template create mode 100644 packages/nx-plugin/src/open-api/py-client/files/shared/__init__.py.template create mode 100644 packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template create mode 100644 packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.additional-properties.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.arrays.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.complex-types.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.content-type.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.duplicate-types.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.errors.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.extras.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.fast-api.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.petstore.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.primitive-types.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.reserved-keywords.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.response.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.streaming.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.tags.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.type-safety.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.utils.spec.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/schema.d.ts create mode 100644 packages/nx-plugin/src/open-api/py-client/schema.json create mode 100644 packages/nx-plugin/src/utils/test/py.spec.ts create mode 100644 packages/nx-plugin/src/utils/test/python-worker/worker.py diff --git a/docs/src/i18n/schema-translations.json b/docs/src/i18n/schema-translations.json index 3ac663d6f..9e76efefc 100644 --- a/docs/src/i18n/schema-translations.json +++ b/docs/src/i18n/schema-translations.json @@ -102,6 +102,41 @@ "vi": "Có nên cài đặt các dependencies sau khi generator chạy hay không. Đặt thành false để hoãn việc cài đặt khi chạy nhiều generator cùng lúc (việc cài đặt vẫn sẽ chạy nếu cần thiết để các generator tiếp theo có thể tính toán Nx project graph); cài đặt một lần vào cuối." } }, + "open-api#py-client": { + "openApiSpecPath": { + "en": "Path to the OpenAPI specification relative to the monorepo root", + "es": "Ruta a la especificación OpenAPI relativa a la raíz del monorepo", + "fr": "Chemin vers la spécification OpenAPI relatif à la racine du monorepo", + "pt": "Caminho para a especificação OpenAPI relativo à raiz do monorepo", + "jp": "モノレポルートからの相対パスで指定するOpenAPI仕様ファイルへのパス", + "ko": "모노레포 루트를 기준으로 한 OpenAPI 사양의 상대 경로", + "it": "Percorso della specifica OpenAPI relativo alla radice del monorepo", + "zh": "相对于 monorepo 根目录的 OpenAPI 规范路径", + "vi": "Đường dẫn đến đặc tả OpenAPI tương đối so với thư mục gốc của monorepo" + }, + "outputPath": { + "en": "Path to the directory in which to generate the client relative to the monorepo root", + "es": "Ruta al directorio en el que generar el cliente relativa a la raíz del monorepo", + "fr": "Chemin vers le répertoire dans lequel générer le client relatif à la racine du monorepo", + "pt": "Caminho para o diretório no qual gerar o cliente relativo à raiz do monorepo", + "jp": "モノレポルートからの相対パスで指定するクライアント生成先ディレクトリへのパス", + "ko": "모노레포 루트를 기준으로 클라이언트를 생성할 디렉토리의 상대 경로", + "it": "Percorso della directory in cui generare il client relativo alla radice del monorepo", + "zh": "相对于 monorepo 根目录生成客户端的目录路径", + "vi": "Đường dẫn đến thư mục nơi tạo client tương đối so với thư mục gốc của monorepo" + }, + "clientType": { + "en": "Which client flavours to emit. 'sync' emits client.gen.py using httpx.Client, 'async' emits async_client.gen.py using httpx.AsyncClient, 'both' emits both.", + "es": "Qué variantes de cliente emitir. 'sync' emite client.gen.py usando httpx.Client, 'async' emite async_client.gen.py usando httpx.AsyncClient, 'both' emite ambos.", + "fr": "Quelles variantes de client émettre. 'sync' émet client.gen.py en utilisant httpx.Client, 'async' émet async_client.gen.py en utilisant httpx.AsyncClient, 'both' émet les deux.", + "pt": "Quais variantes de cliente emitir. 'sync' emite client.gen.py usando httpx.Client, 'async' emite async_client.gen.py usando httpx.AsyncClient, 'both' emite ambos.", + "jp": "生成するクライアントの種類。'sync'はhttpx.Clientを使用したclient.gen.pyを生成、'async'はhttpx.AsyncClientを使用したasync_client.gen.pyを生成、'both'は両方を生成します。", + "ko": "생성할 클라이언트 유형. 'sync'는 httpx.Client를 사용하는 client.gen.py를 생성하고, 'async'는 httpx.AsyncClient를 사용하는 async_client.gen.py를 생성하며, 'both'는 두 가지 모두 생성합니다.", + "it": "Quali varianti di client emettere. 'sync' emette client.gen.py usando httpx.Client, 'async' emette async_client.gen.py usando httpx.AsyncClient, 'both' emette entrambi.", + "zh": "要生成的客户端类型。'sync' 使用 httpx.Client 生成 client.gen.py,'async' 使用 httpx.AsyncClient 生成 async_client.gen.py,'both' 同时生成两者。", + "vi": "Loại client sẽ được tạo ra. 'sync' tạo client.gen.py sử dụng httpx.Client, 'async' tạo async_client.gen.py sử dụng httpx.AsyncClient, 'both' tạo cả hai." + } + }, "open-api#ts-client": { "openApiSpecPath": { "en": "Path to the OpenAPI specification relative to the monorepo root", diff --git a/packages/nx-plugin/generators.json b/packages/nx-plugin/generators.json index f7d9070d7..859e6d7ef 100644 --- a/packages/nx-plugin/generators.json +++ b/packages/nx-plugin/generators.json @@ -70,6 +70,13 @@ "metric": "g15", "hidden": true }, + "open-api#py-client": { + "factory": "./src/open-api/py-client/generator", + "schema": "./src/open-api/py-client/schema.json", + "description": "Generate a Python httpx client from an OpenAPI specification", + "metric": "g56", + "hidden": true + }, "open-api#ts-client": { "factory": "./src/open-api/ts-client/generator", "schema": "./src/open-api/ts-client/schema.json", diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap new file mode 100644 index 000000000..4e5a668c8 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap @@ -0,0 +1,34 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - additional properties > should emit dict[str, T] for plain additionalProperties objects > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never + +Counts200Response = dict[str, int] + +CountsError = Never +" +`; + +exports[`openApiPyClientGenerator - additional properties > should handle object models with a mixture of properties and additionalProperties > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never + +from pydantic import BaseModel, ConfigDict + + +class Mixed(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: str + + +MixedError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap new file mode 100644 index 000000000..210a502e9 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap @@ -0,0 +1,880 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - arrays > should handle dictionary (additionalProperties) responses > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class GetCountsApiError(ApiError): + """Raised when \`get_counts\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def get_counts( + self, + ) -> types_gen.GetCounts200Response: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/counts", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(types_gen.GetCounts200Response).validate_python( + response.json() + ) + raise GetCountsApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle dictionary (additionalProperties) responses > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never + +GetCounts200Response = dict[str, int] + +GetCountsError = Never +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which accepts an array of objects > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class CreateUsersApiError(ApiError): + """Raised when \`create_users\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def create_users( + self, + body: list[types_gen.User], + ) -> list[types_gen.User]: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = self._dump(body) + request_kwargs: dict[str, Any] = {"json": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/create-users", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(list[types_gen.User]).validate_python(response.json()) + raise CreateUsersApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which accepts an array of objects > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, Optional, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class User(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + username: str + email: Optional[str] = None + + +class CreateUsersRequest(TypedDict): + body: list["User"] + + +CreateUsersError = Never +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which accepts an array of strings > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class ProcessTagsApiError(ApiError): + """Raised when \`process_tags\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def process_tags( + self, + body: list[str], + ) -> types_gen.ProcessTags200Response: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = self._dump(body) + request_kwargs: dict[str, Any] = {"json": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/process-tags", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.ProcessTags200Response.model_validate(response.json()) + raise ProcessTagsApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which accepts an array of strings > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class ProcessTags200Response(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + processed: int + + +class ProcessTagsRequest(TypedDict): + body: list[str] + + +ProcessTagsError = Never +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which returns a nested array of strings > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class GetMatrixApiError(ApiError): + """Raised when \`get_matrix\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def get_matrix( + self, + ) -> list[list[str]]: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/matrix", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(list[list[str]]).validate_python(response.json()) + raise GetMatrixApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which returns a nested array of strings > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never + +GetMatrixError = Never +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which returns an array of objects > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class ListUsersApiError(ApiError): + """Raised when \`list_users\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def list_users( + self, + ) -> list[types_gen.User]: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/users", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(list[types_gen.User]).validate_python(response.json()) + raise ListUsersApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - arrays > should handle operation which returns an array of objects > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never + +from pydantic import BaseModel, ConfigDict + + +class User(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: int + + +ListUsersError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap new file mode 100644 index 000000000..8a1757a7e --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap @@ -0,0 +1,82 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - complex types > should handle nested objects > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Inner(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + n: int + + +class Outer(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + inner: "Inner" + + +class OuterRequest(TypedDict): + body: "Outer" + + +OuterError = Never +" +`; + +exports[`openApiPyClientGenerator - complex types > should handle nullable schemas in various contexts > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, Optional, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class N(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + a: Optional[str] = None + b: Optional[int] = None + + +class XRequest(TypedDict): + body: "N" + + +XError = Never +" +`; + +exports[`openApiPyClientGenerator - complex types > should handle operations with complex map types > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never + +from pydantic import BaseModel, ConfigDict, Field + + +class MapsResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + by_id: dict[str, "MapsResponseByIdValue"] = Field(alias="byId") + + +class MapsResponseByIdValue(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str + + +MapsError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap new file mode 100644 index 000000000..80be25cee --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap @@ -0,0 +1,272 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - composite types > should emit Union types for anyOf > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict, Union + +from pydantic import BaseModel, ConfigDict + + +class Wrap(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + value: "WrapValue" + + +WrapValue = Union[str, int] + + +class URequest(TypedDict): + body: "Wrap" + + +UError = Never +" +`; + +exports[`openApiPyClientGenerator - composite types > should emit Union types for oneOf > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, Union + +from pydantic import BaseModel, ConfigDict + + +class Wrap(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + value: "WrapValue" + + +WrapValue = Union[str, bool] + +OError = Never +" +`; + +exports[`openApiPyClientGenerator - composite types > should flatten allOf inheritance into a single pydantic class > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class DogApiError(ApiError): + """Raised when \`dog\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def dog( + self, + *, + name: str, + breed: str, + ) -> types_gen.Dog: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["name"] = name + _body_fields["breed"] = breed + body = types_gen.Dog.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/dog", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Dog.model_validate(response.json()) + raise DogApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - composite types > should flatten allOf inheritance into a single pydantic class > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Animal(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str + + +class Dog(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str + breed: str + + +class DogRequest(TypedDict): + body: "Dog" + + +DogError = Never +" +`; + +exports[`openApiPyClientGenerator - composite types > should handle recursive schema references > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, Optional, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Tree(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + value: int + children: Optional[list["Tree"]] = None + + +class TreeRequest(TypedDict): + body: "Tree" + + +TreeError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap new file mode 100644 index 000000000..69ec7d951 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap @@ -0,0 +1,237 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - duplicate types > should handle duplicated inline schemas across operations without clashing > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class PostAApiError(ApiError): + """Raised when \`post_a\` returns a non-success status.""" + + error: Never + + +class PostBApiError(ApiError): + """Raised when \`post_b\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def post_a( + self, + *, + x: str, + ) -> str: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["x"] = x + body = types_gen.PostARequestContent.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/a", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(str).validate_python(response.json()) + raise PostAApiError(response.status_code, None) + + def post_b( + self, + *, + x: str, + ) -> str: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["x"] = x + body = types_gen.PostBRequestContent.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/b", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(str).validate_python(response.json()) + raise PostBApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - duplicate types > should handle duplicated inline schemas across operations without clashing > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class PostARequestContent(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + x: str + + +class PostBRequestContent(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + x: str + + +class PostARequest(TypedDict): + body: "PostARequestContent" + + +class PostBRequest(TypedDict): + body: "PostBRequestContent" + + +PostAError = Never + +PostBError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap new file mode 100644 index 000000000..e47227e97 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap @@ -0,0 +1,259 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - errors > emits per-op exception class + discriminated error union > client_gen.py 1`] = ` +""""ErrApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class GetPetApiError(ApiError): + """Raised when \`get_pet\` returns a non-success status.""" + + error: types_gen.GetPetError + + +@dataclass +class ErrApiConfig: + """Client configuration for ErrApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class ErrApi: + """Synchronous API client for ErrApi.""" + + def __init__(self, config: ErrApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "ErrApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def get_pet( + self, + *, + pet_id: int, + ) -> types_gen.Pet: + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/pet/{petId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 404: + raise GetPetApiError( + response.status_code, + types_gen.GetPet404Error( + status=404, + error=types_gen.NotFound.model_validate(response.json()), + ), + ) + if 500 <= response.status_code < 600: + raise GetPetApiError( + response.status_code, + types_gen.GetPet5XXError( + status=response.status_code, + error=types_gen.ServerError.model_validate(response.json()), + ), + ) + if True: + raise GetPetApiError( + response.status_code, + types_gen.GetPetDefaultError( + status=response.status_code, + error=types_gen.Generic.model_validate(response.json()), + ), + ) + raise GetPetApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - errors > emits per-op exception class + discriminated error union > types_gen.py 1`] = ` +""""Types for ErrApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Literal, TypedDict, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class Generic(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + message: str + + +class GetPetRequestPathParameters(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + pet_id: int = Field(alias="petId") + + +class NotFound(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + detail: str + + +class Pet(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str + + +class ServerError(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + code: int + message: str + + +class GetPetRequest(TypedDict): + pet_id: int + + +class GetPet404Error(BaseModel): + """Error wrapper for GetPet status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: NotFound + + +class GetPet5XXError(BaseModel): + """Error wrapper for GetPet status 5XX.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: ServerError + + +class GetPetDefaultError(BaseModel): + """Error wrapper for GetPet status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: Generic + + +GetPetError = Union[GetPet404Error, GetPet5XXError, GetPetDefaultError] +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap new file mode 100644 index 000000000..16219728d --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap @@ -0,0 +1,280 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - fast-api-shaped specs > round-trips a FastAPI-shaped echo endpoint > client_gen.py 1`] = ` +""""DemoApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class EchoApiError(ApiError): + """Raised when \`echo\` returns a non-success status.""" + + error: types_gen.EchoError + + +class StreamChunksApiError(ApiError): + """Raised when \`stream_chunks\` returns a non-success status.""" + + error: Never + + +@dataclass +class DemoApiConfig: + """Client configuration for DemoApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class DemoApi: + """Synchronous API client for DemoApi.""" + + def __init__(self, config: DemoApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "DemoApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def echo( + self, + *, + message: str, + ) -> types_gen.EchoOutput: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"message": message} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"message": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/echo", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.EchoOutput.model_validate(response.json()) + if response.status_code == 500: + raise EchoApiError( + response.status_code, + types_gen.Echo500Error( + status=500, + error=types_gen.InternalServerErrorDetails.model_validate( + response.json() + ), + ), + ) + raise EchoApiError(response.status_code, None) + + def stream_chunks( + self, + *, + prompt: str, + count: Optional[int] = None, + ) -> Iterator[types_gen.Chunk]: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"prompt": prompt, "count": count} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"prompt": "multi", "count": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + with self._client.stream( + "POST", + self._url("/stream", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) as response: + if response.status_code == 200: + buffer = "" + for chunk in response.iter_text(): + buffer += chunk + while "\\n" in buffer: + line, buffer = buffer.split("\\n", 1) + if line.strip(): + yield types_gen.Chunk.model_validate_json(line) + if buffer.strip(): + yield types_gen.Chunk.model_validate_json(buffer) + return + response.read() + raise StreamChunksApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - fast-api-shaped specs > round-trips a FastAPI-shaped echo endpoint > types_gen.py 1`] = ` +""""Types for DemoApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Literal, Never, Optional, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Chunk(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + index: int + message: str + + +class EchoOutput(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + message: str + + +class EchoRequestQueryParameters(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + message: str + + +class InternalServerErrorDetails(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + detail: str + + +class StreamChunksRequestQueryParameters(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + prompt: str + count: Optional[int] = None + + +class EchoRequest(TypedDict): + message: str + + +class StreamChunksRequestRequired(TypedDict): + prompt: str + + +class StreamChunksRequest(StreamChunksRequestRequired, total=False): + count: int + + +class Echo500Error(BaseModel): + """Error wrapper for Echo status 500.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[500] + error: InternalServerErrorDetails + + +EchoError = Echo500Error + +StreamChunksError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap new file mode 100644 index 000000000..16a0e848a --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap @@ -0,0 +1,3670 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - petstore > should generate valid Python for the full petstore example > async_client_gen.py 1`] = ` +""""AsyncSwaggerPetstoreOpenAPI30 — AUTO-GENERATED asynchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Literal, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class AddPetApiError(ApiError): + """Raised when \`add_pet\` returns a non-success status.""" + + error: types_gen.AddPetError + + +class CreateUserApiError(ApiError): + """Raised when \`create_user\` returns a non-success status.""" + + error: types_gen.CreateUserError + + +class CreateUsersWithListInputApiError(ApiError): + """Raised when \`create_users_with_list_input\` returns a non-success status.""" + + error: types_gen.CreateUsersWithListInputError + + +class DeleteOrderApiError(ApiError): + """Raised when \`delete_order\` returns a non-success status.""" + + error: types_gen.DeleteOrderError + + +class DeletePetApiError(ApiError): + """Raised when \`delete_pet\` returns a non-success status.""" + + error: types_gen.DeletePetError + + +class DeleteUserApiError(ApiError): + """Raised when \`delete_user\` returns a non-success status.""" + + error: types_gen.DeleteUserError + + +class FindPetsByStatusApiError(ApiError): + """Raised when \`find_pets_by_status\` returns a non-success status.""" + + error: types_gen.FindPetsByStatusError + + +class FindPetsByTagsApiError(ApiError): + """Raised when \`find_pets_by_tags\` returns a non-success status.""" + + error: types_gen.FindPetsByTagsError + + +class GetInventoryApiError(ApiError): + """Raised when \`get_inventory\` returns a non-success status.""" + + error: types_gen.GetInventoryError + + +class GetOrderByIdApiError(ApiError): + """Raised when \`get_order_by_id\` returns a non-success status.""" + + error: types_gen.GetOrderByIdError + + +class GetPetByIdApiError(ApiError): + """Raised when \`get_pet_by_id\` returns a non-success status.""" + + error: types_gen.GetPetByIdError + + +class GetUserByNameApiError(ApiError): + """Raised when \`get_user_by_name\` returns a non-success status.""" + + error: types_gen.GetUserByNameError + + +class LoginUserApiError(ApiError): + """Raised when \`login_user\` returns a non-success status.""" + + error: types_gen.LoginUserError + + +class LogoutUserApiError(ApiError): + """Raised when \`logout_user\` returns a non-success status.""" + + error: types_gen.LogoutUserError + + +class PlaceOrderApiError(ApiError): + """Raised when \`place_order\` returns a non-success status.""" + + error: types_gen.PlaceOrderError + + +class UpdatePetApiError(ApiError): + """Raised when \`update_pet\` returns a non-success status.""" + + error: types_gen.UpdatePetError + + +class UpdatePetWithFormApiError(ApiError): + """Raised when \`update_pet_with_form\` returns a non-success status.""" + + error: types_gen.UpdatePetWithFormError + + +class UpdateUserApiError(ApiError): + """Raised when \`update_user\` returns a non-success status.""" + + error: types_gen.UpdateUserError + + +class UploadFileApiError(ApiError): + """Raised when \`upload_file\` returns a non-success status.""" + + error: types_gen.UploadFileError + + +@dataclass +class AsyncSwaggerPetstoreOpenAPI30Config: + """Configuration for the async client.""" + + url: str + httpx_client: Optional[httpx.AsyncClient] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class AsyncSwaggerPetstoreOpenAPI30: + """Asynchronous API client for SwaggerPetstoreOpenAPI30.""" + + def __init__(self, config: AsyncSwaggerPetstoreOpenAPI30Config) -> None: + self._config = config + self._client = config.httpx_client or httpx.AsyncClient() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + self.pet: _AsyncPetNamespace = _AsyncPetNamespace(self) + self.user: _AsyncUserNamespace = _AsyncUserNamespace(self) + self.store: _AsyncStoreNamespace = _AsyncStoreNamespace(self) + + async def __aenter__(self) -> "AsyncSwaggerPetstoreOpenAPI30": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() + + async def aclose(self) -> None: + if self._owns_client: + await self._client.aclose() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + async def _add_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + """Add a new pet to the store.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["name"] = name + _body_fields["photoUrls"] = photo_urls + if id is not None: + _body_fields["id"] = id + if category is not None: + _body_fields["category"] = category + if tags is not None: + _body_fields["tags"] = tags + if status is not None: + _body_fields["status"] = status + body = types_gen.Pet.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = await self._client.request( + "POST", + self._url("/pet", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise AddPetApiError( + response.status_code, + types_gen.AddPet400Error( + status=400, + error=None, + ), + ) + if response.status_code == 422: + raise AddPetApiError( + response.status_code, + types_gen.AddPet422Error( + status=422, + error=None, + ), + ) + if True: + raise AddPetApiError( + response.status_code, + types_gen.AddPetDefaultError( + status=response.status_code, + error=None, + ), + ) + raise AddPetApiError(response.status_code, None) + + async def _create_user( + self, + *, + id: Optional[int] = None, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email: Optional[str] = None, + password: Optional[str] = None, + phone: Optional[str] = None, + user_status: Optional[int] = None, + ) -> types_gen.User: + """This can only be done by the logged in user.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + if id is not None: + _body_fields["id"] = id + if username is not None: + _body_fields["username"] = username + if first_name is not None: + _body_fields["firstName"] = first_name + if last_name is not None: + _body_fields["lastName"] = last_name + if email is not None: + _body_fields["email"] = email + if password is not None: + _body_fields["password"] = password + if phone is not None: + _body_fields["phone"] = phone + if user_status is not None: + _body_fields["userStatus"] = user_status + body = types_gen.User.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = await self._client.request( + "POST", + self._url("/user", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.User.model_validate(response.json()) + if True: + raise CreateUserApiError( + response.status_code, + types_gen.CreateUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise CreateUserApiError(response.status_code, None) + + async def _create_users_with_list_input( + self, + body: Optional[list[types_gen.User]] = None, + ) -> types_gen.User: + """Creates list of users with given input array.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = self._dump(body) + request_kwargs: dict[str, Any] = {"json": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = await self._client.request( + "POST", + self._url("/user/createWithList", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.User.model_validate(response.json()) + if True: + raise CreateUsersWithListInputApiError( + response.status_code, + types_gen.CreateUsersWithListInputDefaultError( + status=response.status_code, + error=None, + ), + ) + raise CreateUsersWithListInputApiError(response.status_code, None) + + async def _delete_order( + self, + *, + order_id: int, + ) -> None: + """For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.""" + path_params: dict[str, Any] = {"orderId": order_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "DELETE", + self._url("/store/order/{orderId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise DeleteOrderApiError( + response.status_code, + types_gen.DeleteOrder400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise DeleteOrderApiError( + response.status_code, + types_gen.DeleteOrder404Error( + status=404, + error=None, + ), + ) + if True: + raise DeleteOrderApiError( + response.status_code, + types_gen.DeleteOrderDefaultError( + status=response.status_code, + error=None, + ), + ) + raise DeleteOrderApiError(response.status_code, None) + + async def _delete_pet( + self, + *, + pet_id: int, + api_key: Optional[str] = None, + ) -> None: + """Delete a pet.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {"api_key": api_key} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"api_key": "csv"} + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "DELETE", + self._url("/pet/{petId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise DeletePetApiError( + response.status_code, + types_gen.DeletePet400Error( + status=400, + error=None, + ), + ) + if True: + raise DeletePetApiError( + response.status_code, + types_gen.DeletePetDefaultError( + status=response.status_code, + error=None, + ), + ) + raise DeletePetApiError(response.status_code, None) + + async def _delete_user( + self, + *, + username: str, + ) -> None: + """This can only be done by the logged in user.""" + path_params: dict[str, Any] = {"username": username} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "DELETE", + self._url("/user/{username}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise DeleteUserApiError( + response.status_code, + types_gen.DeleteUser400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise DeleteUserApiError( + response.status_code, + types_gen.DeleteUser404Error( + status=404, + error=None, + ), + ) + if True: + raise DeleteUserApiError( + response.status_code, + types_gen.DeleteUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise DeleteUserApiError(response.status_code, None) + + async def _find_pets_by_status( + self, + *, + status: Optional[Literal["available", "pending", "sold"]] = None, + ) -> list[types_gen.Pet]: + """Multiple status values can be provided with comma separated strings.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"status": status} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"status": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/pet/findByStatus", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(list[types_gen.Pet]).validate_python(response.json()) + if response.status_code == 400: + raise FindPetsByStatusApiError( + response.status_code, + types_gen.FindPetsByStatus400Error( + status=400, + error=None, + ), + ) + if True: + raise FindPetsByStatusApiError( + response.status_code, + types_gen.FindPetsByStatusDefaultError( + status=response.status_code, + error=None, + ), + ) + raise FindPetsByStatusApiError(response.status_code, None) + + async def _find_pets_by_tags( + self, + *, + tags: Optional[list[str]] = None, + ) -> list[types_gen.Pet]: + """Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"tags": tags} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"tags": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/pet/findByTags", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(list[types_gen.Pet]).validate_python(response.json()) + if response.status_code == 400: + raise FindPetsByTagsApiError( + response.status_code, + types_gen.FindPetsByTags400Error( + status=400, + error=None, + ), + ) + if True: + raise FindPetsByTagsApiError( + response.status_code, + types_gen.FindPetsByTagsDefaultError( + status=response.status_code, + error=None, + ), + ) + raise FindPetsByTagsApiError(response.status_code, None) + + async def _get_inventory( + self, + ) -> types_gen.GetInventory200Response: + """Returns a map of status codes to quantities.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/store/inventory", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(types_gen.GetInventory200Response).validate_python( + response.json() + ) + if True: + raise GetInventoryApiError( + response.status_code, + types_gen.GetInventoryDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetInventoryApiError(response.status_code, None) + + async def _get_order_by_id( + self, + *, + order_id: int, + ) -> types_gen.Order: + """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.""" + path_params: dict[str, Any] = {"orderId": order_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/store/order/{orderId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Order.model_validate(response.json()) + if response.status_code == 400: + raise GetOrderByIdApiError( + response.status_code, + types_gen.GetOrderById400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise GetOrderByIdApiError( + response.status_code, + types_gen.GetOrderById404Error( + status=404, + error=None, + ), + ) + if True: + raise GetOrderByIdApiError( + response.status_code, + types_gen.GetOrderByIdDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetOrderByIdApiError(response.status_code, None) + + async def _get_pet_by_id( + self, + *, + pet_id: int, + ) -> types_gen.Pet: + """Returns a single pet.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/pet/{petId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise GetPetByIdApiError( + response.status_code, + types_gen.GetPetById400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise GetPetByIdApiError( + response.status_code, + types_gen.GetPetById404Error( + status=404, + error=None, + ), + ) + if True: + raise GetPetByIdApiError( + response.status_code, + types_gen.GetPetByIdDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetPetByIdApiError(response.status_code, None) + + async def _get_user_by_name( + self, + *, + username: str, + ) -> types_gen.User: + """Get user detail based on username.""" + path_params: dict[str, Any] = {"username": username} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/user/{username}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.User.model_validate(response.json()) + if response.status_code == 400: + raise GetUserByNameApiError( + response.status_code, + types_gen.GetUserByName400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise GetUserByNameApiError( + response.status_code, + types_gen.GetUserByName404Error( + status=404, + error=None, + ), + ) + if True: + raise GetUserByNameApiError( + response.status_code, + types_gen.GetUserByNameDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetUserByNameApiError(response.status_code, None) + + async def _login_user( + self, + *, + username: Optional[str] = None, + password: Optional[str] = None, + ) -> str: + """Log into the system.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"username": username, "password": password} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"username": "multi", "password": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/user/login", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(str).validate_python(response.json()) + if response.status_code == 400: + raise LoginUserApiError( + response.status_code, + types_gen.LoginUser400Error( + status=400, + error=None, + ), + ) + if True: + raise LoginUserApiError( + response.status_code, + types_gen.LoginUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise LoginUserApiError(response.status_code, None) + + async def _logout_user( + self, + ) -> None: + """Log user out of the system.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "GET", + self._url("/user/logout", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if True: + raise LogoutUserApiError( + response.status_code, + types_gen.LogoutUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise LogoutUserApiError(response.status_code, None) + + async def _place_order( + self, + *, + id: Optional[int] = None, + pet_id: Optional[int] = None, + quantity: Optional[int] = None, + ship_date: Optional[datetime.datetime] = None, + status: Optional[types_gen.OrderStatus] = None, + complete: Optional[bool] = None, + ) -> types_gen.Order: + """Place a new order in the store.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + if id is not None: + _body_fields["id"] = id + if pet_id is not None: + _body_fields["petId"] = pet_id + if quantity is not None: + _body_fields["quantity"] = quantity + if ship_date is not None: + _body_fields["shipDate"] = ship_date + if status is not None: + _body_fields["status"] = status + if complete is not None: + _body_fields["complete"] = complete + body = types_gen.Order.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = await self._client.request( + "POST", + self._url("/store/order", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Order.model_validate(response.json()) + if response.status_code == 400: + raise PlaceOrderApiError( + response.status_code, + types_gen.PlaceOrder400Error( + status=400, + error=None, + ), + ) + if response.status_code == 422: + raise PlaceOrderApiError( + response.status_code, + types_gen.PlaceOrder422Error( + status=422, + error=None, + ), + ) + if True: + raise PlaceOrderApiError( + response.status_code, + types_gen.PlaceOrderDefaultError( + status=response.status_code, + error=None, + ), + ) + raise PlaceOrderApiError(response.status_code, None) + + async def _update_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + """Update an existing pet by Id.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["name"] = name + _body_fields["photoUrls"] = photo_urls + if id is not None: + _body_fields["id"] = id + if category is not None: + _body_fields["category"] = category + if tags is not None: + _body_fields["tags"] = tags + if status is not None: + _body_fields["status"] = status + body = types_gen.Pet.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = await self._client.request( + "PUT", + self._url("/pet", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePet400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePet404Error( + status=404, + error=None, + ), + ) + if response.status_code == 422: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePet422Error( + status=422, + error=None, + ), + ) + if True: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePetDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UpdatePetApiError(response.status_code, None) + + async def _update_pet_with_form( + self, + *, + pet_id: int, + name: Optional[str] = None, + status: Optional[str] = None, + ) -> types_gen.Pet: + """Updates a pet resource based on the form data.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {"name": name, "status": status} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"name": "multi", "status": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = await self._client.request( + "POST", + self._url("/pet/{petId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise UpdatePetWithFormApiError( + response.status_code, + types_gen.UpdatePetWithForm400Error( + status=400, + error=None, + ), + ) + if True: + raise UpdatePetWithFormApiError( + response.status_code, + types_gen.UpdatePetWithFormDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UpdatePetWithFormApiError(response.status_code, None) + + async def _update_user( + self, + *, + username: str, + body: Optional[types_gen.User] = None, + ) -> None: + """This can only be done by the logged in user.""" + path_params: dict[str, Any] = {"username": username} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = self._dump(body) + request_kwargs: dict[str, Any] = {"json": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = await self._client.request( + "PUT", + self._url("/user/{username}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise UpdateUserApiError( + response.status_code, + types_gen.UpdateUser400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise UpdateUserApiError( + response.status_code, + types_gen.UpdateUser404Error( + status=404, + error=None, + ), + ) + if True: + raise UpdateUserApiError( + response.status_code, + types_gen.UpdateUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UpdateUserApiError(response.status_code, None) + + async def _upload_file( + self, + *, + pet_id: int, + additional_metadata: Optional[str] = None, + body: Optional[bytes] = None, + ) -> types_gen.ApiResponse: + """Upload image of the pet.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {"additionalMetadata": additional_metadata} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"additionalMetadata": "multi"} + body = body + request_kwargs: dict[str, Any] = {"content": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/octet-stream") + response = await self._client.request( + "POST", + self._url("/pet/{petId}/uploadImage", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.ApiResponse.model_validate(response.json()) + if response.status_code == 400: + raise UploadFileApiError( + response.status_code, + types_gen.UploadFile400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise UploadFileApiError( + response.status_code, + types_gen.UploadFile404Error( + status=404, + error=None, + ), + ) + if True: + raise UploadFileApiError( + response.status_code, + types_gen.UploadFileDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UploadFileApiError(response.status_code, None) + + +class _AsyncPetNamespace: + """\`pet\` operations.""" + + def __init__(self, parent: "AsyncSwaggerPetstoreOpenAPI30") -> None: + self._parent = parent + + async def add_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + return await self._parent._add_pet( + name=name, + photo_urls=photo_urls, + id=id, + category=category, + tags=tags, + status=status, + ) + + async def delete_pet( + self, + *, + pet_id: int, + api_key: Optional[str] = None, + ) -> None: + return await self._parent._delete_pet( + pet_id=pet_id, + api_key=api_key, + ) + + async def find_pets_by_status( + self, + *, + status: Optional[Literal["available", "pending", "sold"]] = None, + ) -> list[types_gen.Pet]: + return await self._parent._find_pets_by_status( + status=status, + ) + + async def find_pets_by_tags( + self, + *, + tags: Optional[list[str]] = None, + ) -> list[types_gen.Pet]: + return await self._parent._find_pets_by_tags( + tags=tags, + ) + + async def get_pet_by_id( + self, + *, + pet_id: int, + ) -> types_gen.Pet: + return await self._parent._get_pet_by_id( + pet_id=pet_id, + ) + + async def update_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + return await self._parent._update_pet( + name=name, + photo_urls=photo_urls, + id=id, + category=category, + tags=tags, + status=status, + ) + + async def update_pet_with_form( + self, + *, + pet_id: int, + name: Optional[str] = None, + status: Optional[str] = None, + ) -> types_gen.Pet: + return await self._parent._update_pet_with_form( + pet_id=pet_id, + name=name, + status=status, + ) + + async def upload_file( + self, + *, + pet_id: int, + additional_metadata: Optional[str] = None, + body: Optional[bytes] = None, + ) -> types_gen.ApiResponse: + return await self._parent._upload_file( + pet_id=pet_id, + additional_metadata=additional_metadata, + body=body, + ) + + +class _AsyncUserNamespace: + """\`user\` operations.""" + + def __init__(self, parent: "AsyncSwaggerPetstoreOpenAPI30") -> None: + self._parent = parent + + async def create_user( + self, + *, + id: Optional[int] = None, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email: Optional[str] = None, + password: Optional[str] = None, + phone: Optional[str] = None, + user_status: Optional[int] = None, + ) -> types_gen.User: + return await self._parent._create_user( + id=id, + username=username, + first_name=first_name, + last_name=last_name, + email=email, + password=password, + phone=phone, + user_status=user_status, + ) + + async def create_users_with_list_input( + self, + body: Optional[list[types_gen.User]] = None, + ) -> types_gen.User: + return await self._parent._create_users_with_list_input( + body, + ) + + async def delete_user( + self, + *, + username: str, + ) -> None: + return await self._parent._delete_user( + username=username, + ) + + async def get_user_by_name( + self, + *, + username: str, + ) -> types_gen.User: + return await self._parent._get_user_by_name( + username=username, + ) + + async def login_user( + self, + *, + username: Optional[str] = None, + password: Optional[str] = None, + ) -> str: + return await self._parent._login_user( + username=username, + password=password, + ) + + async def logout_user( + self, + ) -> None: + return await self._parent._logout_user() + + async def update_user( + self, + *, + username: str, + body: Optional[types_gen.User] = None, + ) -> None: + return await self._parent._update_user( + username=username, + body=body, + ) + + +class _AsyncStoreNamespace: + """\`store\` operations.""" + + def __init__(self, parent: "AsyncSwaggerPetstoreOpenAPI30") -> None: + self._parent = parent + + async def delete_order( + self, + *, + order_id: int, + ) -> None: + return await self._parent._delete_order( + order_id=order_id, + ) + + async def get_inventory( + self, + ) -> types_gen.GetInventory200Response: + return await self._parent._get_inventory() + + async def get_order_by_id( + self, + *, + order_id: int, + ) -> types_gen.Order: + return await self._parent._get_order_by_id( + order_id=order_id, + ) + + async def place_order( + self, + *, + id: Optional[int] = None, + pet_id: Optional[int] = None, + quantity: Optional[int] = None, + ship_date: Optional[datetime.datetime] = None, + status: Optional[types_gen.OrderStatus] = None, + complete: Optional[bool] = None, + ) -> types_gen.Order: + return await self._parent._place_order( + id=id, + pet_id=pet_id, + quantity=quantity, + ship_date=ship_date, + status=status, + complete=complete, + ) +" +`; + +exports[`openApiPyClientGenerator - petstore > should generate valid Python for the full petstore example > client_gen.py 1`] = ` +""""SwaggerPetstoreOpenAPI30 — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Literal, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class AddPetApiError(ApiError): + """Raised when \`add_pet\` returns a non-success status.""" + + error: types_gen.AddPetError + + +class CreateUserApiError(ApiError): + """Raised when \`create_user\` returns a non-success status.""" + + error: types_gen.CreateUserError + + +class CreateUsersWithListInputApiError(ApiError): + """Raised when \`create_users_with_list_input\` returns a non-success status.""" + + error: types_gen.CreateUsersWithListInputError + + +class DeleteOrderApiError(ApiError): + """Raised when \`delete_order\` returns a non-success status.""" + + error: types_gen.DeleteOrderError + + +class DeletePetApiError(ApiError): + """Raised when \`delete_pet\` returns a non-success status.""" + + error: types_gen.DeletePetError + + +class DeleteUserApiError(ApiError): + """Raised when \`delete_user\` returns a non-success status.""" + + error: types_gen.DeleteUserError + + +class FindPetsByStatusApiError(ApiError): + """Raised when \`find_pets_by_status\` returns a non-success status.""" + + error: types_gen.FindPetsByStatusError + + +class FindPetsByTagsApiError(ApiError): + """Raised when \`find_pets_by_tags\` returns a non-success status.""" + + error: types_gen.FindPetsByTagsError + + +class GetInventoryApiError(ApiError): + """Raised when \`get_inventory\` returns a non-success status.""" + + error: types_gen.GetInventoryError + + +class GetOrderByIdApiError(ApiError): + """Raised when \`get_order_by_id\` returns a non-success status.""" + + error: types_gen.GetOrderByIdError + + +class GetPetByIdApiError(ApiError): + """Raised when \`get_pet_by_id\` returns a non-success status.""" + + error: types_gen.GetPetByIdError + + +class GetUserByNameApiError(ApiError): + """Raised when \`get_user_by_name\` returns a non-success status.""" + + error: types_gen.GetUserByNameError + + +class LoginUserApiError(ApiError): + """Raised when \`login_user\` returns a non-success status.""" + + error: types_gen.LoginUserError + + +class LogoutUserApiError(ApiError): + """Raised when \`logout_user\` returns a non-success status.""" + + error: types_gen.LogoutUserError + + +class PlaceOrderApiError(ApiError): + """Raised when \`place_order\` returns a non-success status.""" + + error: types_gen.PlaceOrderError + + +class UpdatePetApiError(ApiError): + """Raised when \`update_pet\` returns a non-success status.""" + + error: types_gen.UpdatePetError + + +class UpdatePetWithFormApiError(ApiError): + """Raised when \`update_pet_with_form\` returns a non-success status.""" + + error: types_gen.UpdatePetWithFormError + + +class UpdateUserApiError(ApiError): + """Raised when \`update_user\` returns a non-success status.""" + + error: types_gen.UpdateUserError + + +class UploadFileApiError(ApiError): + """Raised when \`upload_file\` returns a non-success status.""" + + error: types_gen.UploadFileError + + +@dataclass +class SwaggerPetstoreOpenAPI30Config: + """Client configuration for SwaggerPetstoreOpenAPI30.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class SwaggerPetstoreOpenAPI30: + """Synchronous API client for SwaggerPetstoreOpenAPI30.""" + + def __init__(self, config: SwaggerPetstoreOpenAPI30Config) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + self.pet: _PetNamespace = _PetNamespace(self) + self.user: _UserNamespace = _UserNamespace(self) + self.store: _StoreNamespace = _StoreNamespace(self) + + def __enter__(self) -> "SwaggerPetstoreOpenAPI30": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def _add_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + """Add a new pet to the store.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["name"] = name + _body_fields["photoUrls"] = photo_urls + if id is not None: + _body_fields["id"] = id + if category is not None: + _body_fields["category"] = category + if tags is not None: + _body_fields["tags"] = tags + if status is not None: + _body_fields["status"] = status + body = types_gen.Pet.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/pet", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise AddPetApiError( + response.status_code, + types_gen.AddPet400Error( + status=400, + error=None, + ), + ) + if response.status_code == 422: + raise AddPetApiError( + response.status_code, + types_gen.AddPet422Error( + status=422, + error=None, + ), + ) + if True: + raise AddPetApiError( + response.status_code, + types_gen.AddPetDefaultError( + status=response.status_code, + error=None, + ), + ) + raise AddPetApiError(response.status_code, None) + + def _create_user( + self, + *, + id: Optional[int] = None, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email: Optional[str] = None, + password: Optional[str] = None, + phone: Optional[str] = None, + user_status: Optional[int] = None, + ) -> types_gen.User: + """This can only be done by the logged in user.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + if id is not None: + _body_fields["id"] = id + if username is not None: + _body_fields["username"] = username + if first_name is not None: + _body_fields["firstName"] = first_name + if last_name is not None: + _body_fields["lastName"] = last_name + if email is not None: + _body_fields["email"] = email + if password is not None: + _body_fields["password"] = password + if phone is not None: + _body_fields["phone"] = phone + if user_status is not None: + _body_fields["userStatus"] = user_status + body = types_gen.User.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/user", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.User.model_validate(response.json()) + if True: + raise CreateUserApiError( + response.status_code, + types_gen.CreateUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise CreateUserApiError(response.status_code, None) + + def _create_users_with_list_input( + self, + body: Optional[list[types_gen.User]] = None, + ) -> types_gen.User: + """Creates list of users with given input array.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = self._dump(body) + request_kwargs: dict[str, Any] = {"json": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/user/createWithList", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.User.model_validate(response.json()) + if True: + raise CreateUsersWithListInputApiError( + response.status_code, + types_gen.CreateUsersWithListInputDefaultError( + status=response.status_code, + error=None, + ), + ) + raise CreateUsersWithListInputApiError(response.status_code, None) + + def _delete_order( + self, + *, + order_id: int, + ) -> None: + """For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.""" + path_params: dict[str, Any] = {"orderId": order_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "DELETE", + self._url("/store/order/{orderId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise DeleteOrderApiError( + response.status_code, + types_gen.DeleteOrder400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise DeleteOrderApiError( + response.status_code, + types_gen.DeleteOrder404Error( + status=404, + error=None, + ), + ) + if True: + raise DeleteOrderApiError( + response.status_code, + types_gen.DeleteOrderDefaultError( + status=response.status_code, + error=None, + ), + ) + raise DeleteOrderApiError(response.status_code, None) + + def _delete_pet( + self, + *, + pet_id: int, + api_key: Optional[str] = None, + ) -> None: + """Delete a pet.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {"api_key": api_key} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"api_key": "csv"} + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "DELETE", + self._url("/pet/{petId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise DeletePetApiError( + response.status_code, + types_gen.DeletePet400Error( + status=400, + error=None, + ), + ) + if True: + raise DeletePetApiError( + response.status_code, + types_gen.DeletePetDefaultError( + status=response.status_code, + error=None, + ), + ) + raise DeletePetApiError(response.status_code, None) + + def _delete_user( + self, + *, + username: str, + ) -> None: + """This can only be done by the logged in user.""" + path_params: dict[str, Any] = {"username": username} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "DELETE", + self._url("/user/{username}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise DeleteUserApiError( + response.status_code, + types_gen.DeleteUser400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise DeleteUserApiError( + response.status_code, + types_gen.DeleteUser404Error( + status=404, + error=None, + ), + ) + if True: + raise DeleteUserApiError( + response.status_code, + types_gen.DeleteUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise DeleteUserApiError(response.status_code, None) + + def _find_pets_by_status( + self, + *, + status: Optional[Literal["available", "pending", "sold"]] = None, + ) -> list[types_gen.Pet]: + """Multiple status values can be provided with comma separated strings.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"status": status} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"status": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/pet/findByStatus", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(list[types_gen.Pet]).validate_python(response.json()) + if response.status_code == 400: + raise FindPetsByStatusApiError( + response.status_code, + types_gen.FindPetsByStatus400Error( + status=400, + error=None, + ), + ) + if True: + raise FindPetsByStatusApiError( + response.status_code, + types_gen.FindPetsByStatusDefaultError( + status=response.status_code, + error=None, + ), + ) + raise FindPetsByStatusApiError(response.status_code, None) + + def _find_pets_by_tags( + self, + *, + tags: Optional[list[str]] = None, + ) -> list[types_gen.Pet]: + """Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"tags": tags} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"tags": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/pet/findByTags", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(list[types_gen.Pet]).validate_python(response.json()) + if response.status_code == 400: + raise FindPetsByTagsApiError( + response.status_code, + types_gen.FindPetsByTags400Error( + status=400, + error=None, + ), + ) + if True: + raise FindPetsByTagsApiError( + response.status_code, + types_gen.FindPetsByTagsDefaultError( + status=response.status_code, + error=None, + ), + ) + raise FindPetsByTagsApiError(response.status_code, None) + + def _get_inventory( + self, + ) -> types_gen.GetInventory200Response: + """Returns a map of status codes to quantities.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/store/inventory", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(types_gen.GetInventory200Response).validate_python( + response.json() + ) + if True: + raise GetInventoryApiError( + response.status_code, + types_gen.GetInventoryDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetInventoryApiError(response.status_code, None) + + def _get_order_by_id( + self, + *, + order_id: int, + ) -> types_gen.Order: + """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.""" + path_params: dict[str, Any] = {"orderId": order_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/store/order/{orderId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Order.model_validate(response.json()) + if response.status_code == 400: + raise GetOrderByIdApiError( + response.status_code, + types_gen.GetOrderById400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise GetOrderByIdApiError( + response.status_code, + types_gen.GetOrderById404Error( + status=404, + error=None, + ), + ) + if True: + raise GetOrderByIdApiError( + response.status_code, + types_gen.GetOrderByIdDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetOrderByIdApiError(response.status_code, None) + + def _get_pet_by_id( + self, + *, + pet_id: int, + ) -> types_gen.Pet: + """Returns a single pet.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/pet/{petId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise GetPetByIdApiError( + response.status_code, + types_gen.GetPetById400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise GetPetByIdApiError( + response.status_code, + types_gen.GetPetById404Error( + status=404, + error=None, + ), + ) + if True: + raise GetPetByIdApiError( + response.status_code, + types_gen.GetPetByIdDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetPetByIdApiError(response.status_code, None) + + def _get_user_by_name( + self, + *, + username: str, + ) -> types_gen.User: + """Get user detail based on username.""" + path_params: dict[str, Any] = {"username": username} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/user/{username}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.User.model_validate(response.json()) + if response.status_code == 400: + raise GetUserByNameApiError( + response.status_code, + types_gen.GetUserByName400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise GetUserByNameApiError( + response.status_code, + types_gen.GetUserByName404Error( + status=404, + error=None, + ), + ) + if True: + raise GetUserByNameApiError( + response.status_code, + types_gen.GetUserByNameDefaultError( + status=response.status_code, + error=None, + ), + ) + raise GetUserByNameApiError(response.status_code, None) + + def _login_user( + self, + *, + username: Optional[str] = None, + password: Optional[str] = None, + ) -> str: + """Log into the system.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {"username": username, "password": password} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"username": "multi", "password": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/user/login", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(str).validate_python(response.json()) + if response.status_code == 400: + raise LoginUserApiError( + response.status_code, + types_gen.LoginUser400Error( + status=400, + error=None, + ), + ) + if True: + raise LoginUserApiError( + response.status_code, + types_gen.LoginUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise LoginUserApiError(response.status_code, None) + + def _logout_user( + self, + ) -> None: + """Log user out of the system.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/user/logout", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if True: + raise LogoutUserApiError( + response.status_code, + types_gen.LogoutUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise LogoutUserApiError(response.status_code, None) + + def _place_order( + self, + *, + id: Optional[int] = None, + pet_id: Optional[int] = None, + quantity: Optional[int] = None, + ship_date: Optional[datetime.datetime] = None, + status: Optional[types_gen.OrderStatus] = None, + complete: Optional[bool] = None, + ) -> types_gen.Order: + """Place a new order in the store.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + if id is not None: + _body_fields["id"] = id + if pet_id is not None: + _body_fields["petId"] = pet_id + if quantity is not None: + _body_fields["quantity"] = quantity + if ship_date is not None: + _body_fields["shipDate"] = ship_date + if status is not None: + _body_fields["status"] = status + if complete is not None: + _body_fields["complete"] = complete + body = types_gen.Order.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/store/order", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Order.model_validate(response.json()) + if response.status_code == 400: + raise PlaceOrderApiError( + response.status_code, + types_gen.PlaceOrder400Error( + status=400, + error=None, + ), + ) + if response.status_code == 422: + raise PlaceOrderApiError( + response.status_code, + types_gen.PlaceOrder422Error( + status=422, + error=None, + ), + ) + if True: + raise PlaceOrderApiError( + response.status_code, + types_gen.PlaceOrderDefaultError( + status=response.status_code, + error=None, + ), + ) + raise PlaceOrderApiError(response.status_code, None) + + def _update_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + """Update an existing pet by Id.""" + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["name"] = name + _body_fields["photoUrls"] = photo_urls + if id is not None: + _body_fields["id"] = id + if category is not None: + _body_fields["category"] = category + if tags is not None: + _body_fields["tags"] = tags + if status is not None: + _body_fields["status"] = status + body = types_gen.Pet.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "PUT", + self._url("/pet", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePet400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePet404Error( + status=404, + error=None, + ), + ) + if response.status_code == 422: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePet422Error( + status=422, + error=None, + ), + ) + if True: + raise UpdatePetApiError( + response.status_code, + types_gen.UpdatePetDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UpdatePetApiError(response.status_code, None) + + def _update_pet_with_form( + self, + *, + pet_id: int, + name: Optional[str] = None, + status: Optional[str] = None, + ) -> types_gen.Pet: + """Updates a pet resource based on the form data.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {"name": name, "status": status} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"name": "multi", "status": "multi"} + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "POST", + self._url("/pet/{petId}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + if response.status_code == 400: + raise UpdatePetWithFormApiError( + response.status_code, + types_gen.UpdatePetWithForm400Error( + status=400, + error=None, + ), + ) + if True: + raise UpdatePetWithFormApiError( + response.status_code, + types_gen.UpdatePetWithFormDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UpdatePetWithFormApiError(response.status_code, None) + + def _update_user( + self, + *, + username: str, + body: Optional[types_gen.User] = None, + ) -> None: + """This can only be done by the logged in user.""" + path_params: dict[str, Any] = {"username": username} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = self._dump(body) + request_kwargs: dict[str, Any] = {"json": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "PUT", + self._url("/user/{username}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return None + if response.status_code == 400: + raise UpdateUserApiError( + response.status_code, + types_gen.UpdateUser400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise UpdateUserApiError( + response.status_code, + types_gen.UpdateUser404Error( + status=404, + error=None, + ), + ) + if True: + raise UpdateUserApiError( + response.status_code, + types_gen.UpdateUserDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UpdateUserApiError(response.status_code, None) + + def _upload_file( + self, + *, + pet_id: int, + additional_metadata: Optional[str] = None, + body: Optional[bytes] = None, + ) -> types_gen.ApiResponse: + """Upload image of the pet.""" + path_params: dict[str, Any] = {"petId": pet_id} + query_params: dict[str, Any] = {"additionalMetadata": additional_metadata} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = {"additionalMetadata": "multi"} + body = body + request_kwargs: dict[str, Any] = {"content": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/octet-stream") + response = self._client.request( + "POST", + self._url("/pet/{petId}/uploadImage", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.ApiResponse.model_validate(response.json()) + if response.status_code == 400: + raise UploadFileApiError( + response.status_code, + types_gen.UploadFile400Error( + status=400, + error=None, + ), + ) + if response.status_code == 404: + raise UploadFileApiError( + response.status_code, + types_gen.UploadFile404Error( + status=404, + error=None, + ), + ) + if True: + raise UploadFileApiError( + response.status_code, + types_gen.UploadFileDefaultError( + status=response.status_code, + error=None, + ), + ) + raise UploadFileApiError(response.status_code, None) + + +class _PetNamespace: + """\`pet\` operations.""" + + def __init__(self, parent: "SwaggerPetstoreOpenAPI30") -> None: + self._parent = parent + + def add_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + return self._parent._add_pet( + name=name, + photo_urls=photo_urls, + id=id, + category=category, + tags=tags, + status=status, + ) + + def delete_pet( + self, + *, + pet_id: int, + api_key: Optional[str] = None, + ) -> None: + return self._parent._delete_pet( + pet_id=pet_id, + api_key=api_key, + ) + + def find_pets_by_status( + self, + *, + status: Optional[Literal["available", "pending", "sold"]] = None, + ) -> list[types_gen.Pet]: + return self._parent._find_pets_by_status( + status=status, + ) + + def find_pets_by_tags( + self, + *, + tags: Optional[list[str]] = None, + ) -> list[types_gen.Pet]: + return self._parent._find_pets_by_tags( + tags=tags, + ) + + def get_pet_by_id( + self, + *, + pet_id: int, + ) -> types_gen.Pet: + return self._parent._get_pet_by_id( + pet_id=pet_id, + ) + + def update_pet( + self, + *, + name: str, + photo_urls: list[str], + id: Optional[int] = None, + category: Optional[types_gen.Category] = None, + tags: Optional[list[types_gen.Tag]] = None, + status: Optional[types_gen.PetStatus] = None, + ) -> types_gen.Pet: + return self._parent._update_pet( + name=name, + photo_urls=photo_urls, + id=id, + category=category, + tags=tags, + status=status, + ) + + def update_pet_with_form( + self, + *, + pet_id: int, + name: Optional[str] = None, + status: Optional[str] = None, + ) -> types_gen.Pet: + return self._parent._update_pet_with_form( + pet_id=pet_id, + name=name, + status=status, + ) + + def upload_file( + self, + *, + pet_id: int, + additional_metadata: Optional[str] = None, + body: Optional[bytes] = None, + ) -> types_gen.ApiResponse: + return self._parent._upload_file( + pet_id=pet_id, + additional_metadata=additional_metadata, + body=body, + ) + + +class _UserNamespace: + """\`user\` operations.""" + + def __init__(self, parent: "SwaggerPetstoreOpenAPI30") -> None: + self._parent = parent + + def create_user( + self, + *, + id: Optional[int] = None, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email: Optional[str] = None, + password: Optional[str] = None, + phone: Optional[str] = None, + user_status: Optional[int] = None, + ) -> types_gen.User: + return self._parent._create_user( + id=id, + username=username, + first_name=first_name, + last_name=last_name, + email=email, + password=password, + phone=phone, + user_status=user_status, + ) + + def create_users_with_list_input( + self, + body: Optional[list[types_gen.User]] = None, + ) -> types_gen.User: + return self._parent._create_users_with_list_input( + body, + ) + + def delete_user( + self, + *, + username: str, + ) -> None: + return self._parent._delete_user( + username=username, + ) + + def get_user_by_name( + self, + *, + username: str, + ) -> types_gen.User: + return self._parent._get_user_by_name( + username=username, + ) + + def login_user( + self, + *, + username: Optional[str] = None, + password: Optional[str] = None, + ) -> str: + return self._parent._login_user( + username=username, + password=password, + ) + + def logout_user( + self, + ) -> None: + return self._parent._logout_user() + + def update_user( + self, + *, + username: str, + body: Optional[types_gen.User] = None, + ) -> None: + return self._parent._update_user( + username=username, + body=body, + ) + + +class _StoreNamespace: + """\`store\` operations.""" + + def __init__(self, parent: "SwaggerPetstoreOpenAPI30") -> None: + self._parent = parent + + def delete_order( + self, + *, + order_id: int, + ) -> None: + return self._parent._delete_order( + order_id=order_id, + ) + + def get_inventory( + self, + ) -> types_gen.GetInventory200Response: + return self._parent._get_inventory() + + def get_order_by_id( + self, + *, + order_id: int, + ) -> types_gen.Order: + return self._parent._get_order_by_id( + order_id=order_id, + ) + + def place_order( + self, + *, + id: Optional[int] = None, + pet_id: Optional[int] = None, + quantity: Optional[int] = None, + ship_date: Optional[datetime.datetime] = None, + status: Optional[types_gen.OrderStatus] = None, + complete: Optional[bool] = None, + ) -> types_gen.Order: + return self._parent._place_order( + id=id, + pet_id=pet_id, + quantity=quantity, + ship_date=ship_date, + status=status, + complete=complete, + ) +" +`; + +exports[`openApiPyClientGenerator - petstore > should generate valid Python for the full petstore example > types_gen.py 1`] = ` +""""Types for SwaggerPetstoreOpenAPI30 — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +import datetime +from typing import Literal, Optional, TypedDict, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class ApiResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + code: Optional[int] = None + type: Optional[str] = None + message: Optional[str] = None + + +class Category(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: Optional[int] = None + name: Optional[str] = None + + +class DeleteOrderRequestPathParameters(BaseModel): + """For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + order_id: int = Field( + alias="orderId", description="ID of the order that needs to be deleted" + ) + + +class DeletePetRequestHeaderParameters(BaseModel): + """Delete a pet.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + api_key: Optional[str] = None + + +class DeletePetRequestPathParameters(BaseModel): + """Delete a pet.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + pet_id: int = Field(alias="petId", description="Pet id to delete") + + +class DeleteUserRequestPathParameters(BaseModel): + """This can only be done by the logged in user.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + username: str = Field(description="The name that needs to be deleted") + + +class FindPetsByStatusRequestQueryParameters(BaseModel): + """Multiple status values can be provided with comma separated strings.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Optional[Literal["available", "pending", "sold"]] = Field( + default=None, description="Status values that need to be considered for filter" + ) + + +class FindPetsByTagsRequestQueryParameters(BaseModel): + """Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + tags: Optional[list[str]] = Field(default=None, description="Tags to filter by") + + +class GetOrderByIdRequestPathParameters(BaseModel): + """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + order_id: int = Field( + alias="orderId", description="ID of order that needs to be fetched" + ) + + +class GetPetByIdRequestPathParameters(BaseModel): + """Returns a single pet.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + pet_id: int = Field(alias="petId", description="ID of pet to return") + + +class GetUserByNameRequestPathParameters(BaseModel): + """Get user detail based on username.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + username: str = Field( + description="The name that needs to be fetched. Use user1 for testing" + ) + + +class LoginUserRequestQueryParameters(BaseModel): + """Log into the system.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + username: Optional[str] = Field(default=None, description="The user name for login") + password: Optional[str] = Field( + default=None, description="The password for login in clear text" + ) + + +class Order(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: Optional[int] = None + pet_id: Optional[int] = Field(default=None, alias="petId") + quantity: Optional[int] = None + ship_date: Optional[datetime.datetime] = Field(default=None, alias="shipDate") + status: Optional["OrderStatus"] = None + complete: Optional[bool] = None + + +class Pet(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: Optional[int] = None + name: str + category: Optional["Category"] = None + photo_urls: list[str] = Field(alias="photoUrls") + tags: Optional[list["Tag"]] = None + status: Optional["PetStatus"] = None + + +class Tag(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: Optional[int] = None + name: Optional[str] = None + + +class UpdatePetWithFormRequestPathParameters(BaseModel): + """Updates a pet resource based on the form data.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + pet_id: int = Field(alias="petId", description="ID of pet that needs to be updated") + + +class UpdatePetWithFormRequestQueryParameters(BaseModel): + """Updates a pet resource based on the form data.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: Optional[str] = Field( + default=None, description="Name of pet that needs to be updated" + ) + status: Optional[str] = Field( + default=None, description="Status of pet that needs to be updated" + ) + + +class UpdateUserRequestBodyParameters(BaseModel): + """This can only be done by the logged in user.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + body: Optional["User"] = Field( + default=None, description="Update an existent user in the store" + ) + + +class UpdateUserRequestPathParameters(BaseModel): + """This can only be done by the logged in user.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + username: str = Field(description="name that need to be deleted") + + +class UploadFileRequestBodyParameters(BaseModel): + """Upload image of the pet.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + body: Optional[bytes] = None + + +class UploadFileRequestPathParameters(BaseModel): + """Upload image of the pet.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + pet_id: int = Field(alias="petId", description="ID of pet to update") + + +class UploadFileRequestQueryParameters(BaseModel): + """Upload image of the pet.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + additional_metadata: Optional[str] = Field( + default=None, alias="additionalMetadata", description="Additional Metadata" + ) + + +class User(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: Optional[int] = None + username: Optional[str] = None + first_name: Optional[str] = Field(default=None, alias="firstName") + last_name: Optional[str] = Field(default=None, alias="lastName") + email: Optional[str] = None + password: Optional[str] = None + phone: Optional[str] = None + user_status: Optional[int] = Field( + default=None, alias="userStatus", description="User Status" + ) + + +OrderStatus = Literal["placed", "approved", "delivered"] +"""Order Status""" + +PetStatus = Literal["available", "pending", "sold"] +"""pet status in the store""" + +GetInventory200Response = dict[str, int] + + +class AddPetRequest(TypedDict): + """Add a new pet to the store.""" + + body: "Pet" + + +class CreateUserRequest(TypedDict, total=False): + """This can only be done by the logged in user.""" + + body: "User" + + +class CreateUsersWithListInputRequest(TypedDict, total=False): + """Creates list of users with given input array.""" + + body: list["User"] + + +class DeleteOrderRequest(TypedDict): + """For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.""" + + order_id: int + + +class DeletePetRequestRequired(TypedDict): + pet_id: int + + +class DeletePetRequest(DeletePetRequestRequired, total=False): + """Delete a pet.""" + + api_key: str + + +class DeleteUserRequest(TypedDict): + """This can only be done by the logged in user.""" + + username: str + + +class FindPetsByStatusRequest(TypedDict, total=False): + """Multiple status values can be provided with comma separated strings.""" + + status: Literal["available", "pending", "sold"] + + +class FindPetsByTagsRequest(TypedDict, total=False): + """Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.""" + + tags: list[str] + + +class GetOrderByIdRequest(TypedDict): + """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.""" + + order_id: int + + +class GetPetByIdRequest(TypedDict): + """Returns a single pet.""" + + pet_id: int + + +class GetUserByNameRequest(TypedDict): + """Get user detail based on username.""" + + username: str + + +class LoginUserRequest(TypedDict, total=False): + """Log into the system.""" + + username: str + password: str + + +class PlaceOrderRequest(TypedDict, total=False): + """Place a new order in the store.""" + + body: "Order" + + +class UpdatePetRequest(TypedDict): + """Update an existing pet by Id.""" + + body: "Pet" + + +class UpdatePetWithFormRequestRequired(TypedDict): + pet_id: int + + +class UpdatePetWithFormRequest(UpdatePetWithFormRequestRequired, total=False): + """Updates a pet resource based on the form data.""" + + name: str + status: str + + +class UpdateUserRequestRequired(TypedDict): + username: str + + +class UpdateUserRequest(UpdateUserRequestRequired, total=False): + """This can only be done by the logged in user.""" + + body: "User" + + +class UploadFileRequestRequired(TypedDict): + pet_id: int + + +class UploadFileRequest(UploadFileRequestRequired, total=False): + """Upload image of the pet.""" + + additional_metadata: str + body: bytes + + +class AddPet400Error(BaseModel): + """Error wrapper for AddPet status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class AddPet422Error(BaseModel): + """Error wrapper for AddPet status 422.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[422] + error: None + + +class AddPetDefaultError(BaseModel): + """Error wrapper for AddPet status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +AddPetError = Union[AddPet400Error, AddPet422Error, AddPetDefaultError] + + +class CreateUserDefaultError(BaseModel): + """Error wrapper for CreateUser status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +CreateUserError = CreateUserDefaultError + + +class CreateUsersWithListInputDefaultError(BaseModel): + """Error wrapper for CreateUsersWithListInput status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +CreateUsersWithListInputError = CreateUsersWithListInputDefaultError + + +class DeleteOrder400Error(BaseModel): + """Error wrapper for DeleteOrder status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class DeleteOrder404Error(BaseModel): + """Error wrapper for DeleteOrder status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class DeleteOrderDefaultError(BaseModel): + """Error wrapper for DeleteOrder status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +DeleteOrderError = Union[ + DeleteOrder400Error, DeleteOrder404Error, DeleteOrderDefaultError +] + + +class DeletePet400Error(BaseModel): + """Error wrapper for DeletePet status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class DeletePetDefaultError(BaseModel): + """Error wrapper for DeletePet status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +DeletePetError = Union[DeletePet400Error, DeletePetDefaultError] + + +class DeleteUser400Error(BaseModel): + """Error wrapper for DeleteUser status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class DeleteUser404Error(BaseModel): + """Error wrapper for DeleteUser status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class DeleteUserDefaultError(BaseModel): + """Error wrapper for DeleteUser status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +DeleteUserError = Union[DeleteUser400Error, DeleteUser404Error, DeleteUserDefaultError] + + +class FindPetsByStatus400Error(BaseModel): + """Error wrapper for FindPetsByStatus status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class FindPetsByStatusDefaultError(BaseModel): + """Error wrapper for FindPetsByStatus status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +FindPetsByStatusError = Union[FindPetsByStatus400Error, FindPetsByStatusDefaultError] + + +class FindPetsByTags400Error(BaseModel): + """Error wrapper for FindPetsByTags status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class FindPetsByTagsDefaultError(BaseModel): + """Error wrapper for FindPetsByTags status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +FindPetsByTagsError = Union[FindPetsByTags400Error, FindPetsByTagsDefaultError] + + +class GetInventoryDefaultError(BaseModel): + """Error wrapper for GetInventory status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +GetInventoryError = GetInventoryDefaultError + + +class GetOrderById400Error(BaseModel): + """Error wrapper for GetOrderById status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class GetOrderById404Error(BaseModel): + """Error wrapper for GetOrderById status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class GetOrderByIdDefaultError(BaseModel): + """Error wrapper for GetOrderById status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +GetOrderByIdError = Union[ + GetOrderById400Error, GetOrderById404Error, GetOrderByIdDefaultError +] + + +class GetPetById400Error(BaseModel): + """Error wrapper for GetPetById status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class GetPetById404Error(BaseModel): + """Error wrapper for GetPetById status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class GetPetByIdDefaultError(BaseModel): + """Error wrapper for GetPetById status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +GetPetByIdError = Union[GetPetById400Error, GetPetById404Error, GetPetByIdDefaultError] + + +class GetUserByName400Error(BaseModel): + """Error wrapper for GetUserByName status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class GetUserByName404Error(BaseModel): + """Error wrapper for GetUserByName status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class GetUserByNameDefaultError(BaseModel): + """Error wrapper for GetUserByName status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +GetUserByNameError = Union[ + GetUserByName400Error, GetUserByName404Error, GetUserByNameDefaultError +] + + +class LoginUser400Error(BaseModel): + """Error wrapper for LoginUser status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class LoginUserDefaultError(BaseModel): + """Error wrapper for LoginUser status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +LoginUserError = Union[LoginUser400Error, LoginUserDefaultError] + + +class LogoutUserDefaultError(BaseModel): + """Error wrapper for LogoutUser status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +LogoutUserError = LogoutUserDefaultError + + +class PlaceOrder400Error(BaseModel): + """Error wrapper for PlaceOrder status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class PlaceOrder422Error(BaseModel): + """Error wrapper for PlaceOrder status 422.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[422] + error: None + + +class PlaceOrderDefaultError(BaseModel): + """Error wrapper for PlaceOrder status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +PlaceOrderError = Union[PlaceOrder400Error, PlaceOrder422Error, PlaceOrderDefaultError] + + +class UpdatePet400Error(BaseModel): + """Error wrapper for UpdatePet status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class UpdatePet404Error(BaseModel): + """Error wrapper for UpdatePet status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class UpdatePet422Error(BaseModel): + """Error wrapper for UpdatePet status 422.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[422] + error: None + + +class UpdatePetDefaultError(BaseModel): + """Error wrapper for UpdatePet status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +UpdatePetError = Union[ + UpdatePet400Error, UpdatePet404Error, UpdatePet422Error, UpdatePetDefaultError +] + + +class UpdatePetWithForm400Error(BaseModel): + """Error wrapper for UpdatePetWithForm status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class UpdatePetWithFormDefaultError(BaseModel): + """Error wrapper for UpdatePetWithForm status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +UpdatePetWithFormError = Union[UpdatePetWithForm400Error, UpdatePetWithFormDefaultError] + + +class UpdateUser400Error(BaseModel): + """Error wrapper for UpdateUser status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class UpdateUser404Error(BaseModel): + """Error wrapper for UpdateUser status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class UpdateUserDefaultError(BaseModel): + """Error wrapper for UpdateUser status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +UpdateUserError = Union[UpdateUser400Error, UpdateUser404Error, UpdateUserDefaultError] + + +class UploadFile400Error(BaseModel): + """Error wrapper for UploadFile status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: None + + +class UploadFile404Error(BaseModel): + """Error wrapper for UploadFile status 404.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[404] + error: None + + +class UploadFileDefaultError(BaseModel): + """Error wrapper for UploadFile status default.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: int + error: None + + +UploadFileError = Union[UploadFile400Error, UploadFile404Error, UploadFileDefaultError] +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap new file mode 100644 index 000000000..142ce84e9 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap @@ -0,0 +1,745 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - primitive types > should generate valid Python for primitive types > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class ScalarsApiError(ApiError): + """Raised when \`scalars\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def scalars( + self, + *, + s: str, + i: int, + b: bool, + f: float, + ) -> types_gen.Scalars: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["s"] = s + _body_fields["i"] = i + _body_fields["b"] = b + _body_fields["f"] = f + body = types_gen.Scalars.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/scalars", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Scalars.model_validate(response.json()) + raise ScalarsApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - primitive types > should generate valid Python for primitive types > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Scalars(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + s: str + i: int + b: bool + f: float + + +class ScalarsRequest(TypedDict): + body: "Scalars" + + +ScalarsError = Never +" +`; + +exports[`openApiPyClientGenerator - primitive types > should handle date and date-time formats > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class EventApiError(ApiError): + """Raised when \`event\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def event( + self, + *, + day: datetime.date, + moment: datetime.datetime, + ) -> types_gen.Event: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["day"] = day + _body_fields["moment"] = moment + body = types_gen.Event.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/event", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Event.model_validate(response.json()) + raise EventApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - primitive types > should handle date and date-time formats > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +import datetime +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Event(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + day: datetime.date + moment: datetime.datetime + + +class EventRequest(TypedDict): + body: "Event" + + +EventError = Never +" +`; + +exports[`openApiPyClientGenerator - primitive types > should handle enum request and response bodies > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class SetStatusApiError(ApiError): + """Raised when \`set_status\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def set_status( + self, + body: types_gen.Status, + ) -> types_gen.Status: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = self._dump(body) + request_kwargs: dict[str, Any] = {"json": body} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/status", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(types_gen.Status).validate_python(response.json()) + raise SetStatusApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - primitive types > should handle enum request and response bodies > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Literal, Never, TypedDict + +Status = Literal["placed", "approved", "delivered"] + + +class SetStatusRequest(TypedDict): + body: "Status" + + +SetStatusError = Never +" +`; + +exports[`openApiPyClientGenerator - primitive types > should handle inline integer enums on properties > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Literal, Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Holder(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + p: Literal[1, 2, 3] + + +class PriorityRequest(TypedDict): + body: "Holder" + + +PriorityError = Never +" +`; + +exports[`openApiPyClientGenerator - primitive types > should handle primitive scalar response types (text) > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class GreetApiError(ApiError): + """Raised when \`greet\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def greet( + self, + ) -> str: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/greet", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return response.text + raise GreetApiError(response.status_code, None) +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap new file mode 100644 index 000000000..bcf639da6 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap @@ -0,0 +1,215 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - requests > should generate valid Python for parameters and responses > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class GetTestApiError(ApiError): + """Raised when \`get_test\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def get_test( + self, + *, + id: str, + x_api_key: str, + filter: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> types_gen.GetTest200Response: + path_params: dict[str, Any] = {"id": id} + query_params: dict[str, Any] = {"filter": filter, "tags": tags} + header_params: dict[str, Any] = {"x-api-key": x_api_key} + cookie_params: dict[str, Any] = {} + collection_formats: dict[str, str] = { + "filter": "multi", + "tags": "multi", + "x-api-key": "csv", + } + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/test/{id}", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.GetTest200Response.model_validate(response.json()) + raise GetTestApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - requests > should generate valid Python for parameters and responses > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, Optional, TypedDict + +from pydantic import BaseModel, ConfigDict, Field + + +class GetTest200Response(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + result: str + + +class GetTestRequestHeaderParameters(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + x_api_key: str = Field(alias="x-api-key") + + +class GetTestRequestPathParameters(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: str + + +class GetTestRequestQueryParameters(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + filter: Optional[str] = None + tags: Optional[list[str]] = None + + +class GetTestRequestRequired(TypedDict): + id: str + x_api_key: str + + +class GetTestRequest(GetTestRequestRequired, total=False): + filter: str + tags: list[str] + + +GetTestError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap new file mode 100644 index 000000000..158b9cdea --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap @@ -0,0 +1,189 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - reserved keywords > should rename python reserved keywords in property names with alias > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class RApiError(ApiError): + """Raised when \`r\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def r( + self, + *, + var_class: str, + ) -> types_gen.R: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["class"] = var_class + body = types_gen.R.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/r", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.R.model_validate(response.json()) + raise RApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - reserved keywords > should rename python reserved keywords in property names with alias > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict, Field + + +class R(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + var_class: str = Field(alias="class") + + +class RRequest(TypedDict): + body: "R" + + +RError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap new file mode 100644 index 000000000..766d5f11f --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap @@ -0,0 +1,399 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - responses > should handle multiple response status codes > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class CheckApiError(ApiError): + """Raised when \`check\` returns a non-success status.""" + + error: types_gen.CheckError + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def check( + self, + ) -> types_gen.Ok: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/check", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Ok.model_validate(response.json()) + if response.status_code == 400: + raise CheckApiError( + response.status_code, + types_gen.Check400Error( + status=400, + error=types_gen.BadReq.model_validate(response.json()), + ), + ) + if response.status_code == 500: + raise CheckApiError( + response.status_code, + types_gen.Check500Error( + status=500, + error=types_gen.Err.model_validate(response.json()), + ), + ) + raise CheckApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - responses > should handle multiple response status codes > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Literal, Union + +from pydantic import BaseModel, ConfigDict + + +class BadReq(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + reason: str + + +class Err(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + code: int + + +class Ok(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + message: str + + +class Check400Error(BaseModel): + """Error wrapper for Check status 400.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[400] + error: BadReq + + +class Check500Error(BaseModel): + """Error wrapper for Check status 500.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: Literal[500] + error: Err + + +CheckError = Union[Check400Error, Check500Error] +" +`; + +exports[`openApiPyClientGenerator - responses > should handle only default response > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class OnlyDefaultApiError(ApiError): + """Raised when \`only_default\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def only_default( + self, + ) -> types_gen.Body: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/only-default", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if True: + return types_gen.Body.model_validate(response.json()) + raise OnlyDefaultApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - responses > should handle only default response > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never + +from pydantic import BaseModel, ConfigDict + + +class Body(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + message: str + + +OnlyDefaultError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap new file mode 100644 index 000000000..4297843c4 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap @@ -0,0 +1,206 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - streaming > should detect and handle application/jsonl with itemSchema > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class StreamDataApiError(ApiError): + """Raised when \`stream_data\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def stream_data( + self, + *, + prompt: str, + ) -> Iterator[types_gen.Chunk]: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["prompt"] = prompt + body = types_gen.Req.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + with self._client.stream( + "POST", + self._url("/stream", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) as response: + if response.status_code == 200: + buffer = "" + for chunk in response.iter_text(): + buffer += chunk + while "\\n" in buffer: + line, buffer = buffer.split("\\n", 1) + if line.strip(): + yield types_gen.Chunk.model_validate_json(line) + if buffer.strip(): + yield types_gen.Chunk.model_validate_json(buffer) + return + response.read() + raise StreamDataApiError(response.status_code, None) +" +`; + +exports[`openApiPyClientGenerator - streaming > should detect and handle application/jsonl with itemSchema > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Chunk(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + content: str + + +class Req(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + prompt: str + + +class StreamDataRequest(TypedDict): + body: "Req" + + +StreamDataError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap new file mode 100644 index 000000000..a01b27ade --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap @@ -0,0 +1,249 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`openApiPyClientGenerator - tags > should group operations by tag into tag namespaces > client_gen.py 1`] = ` +""""TestApi — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Any, Never, Optional +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so \`error\` can be narrowed by the + type checker via \`isinstance\` or \`match\`. Catch \`ApiError\` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + + +class AddPetApiError(ApiError): + """Raised when \`add_pet\` returns a non-success status.""" + + error: Never + + +class StoreStatusApiError(ApiError): + """Raised when \`store_status\` returns a non-success status.""" + + error: Never + + +@dataclass +class TestApiConfig: + """Client configuration for TestApi.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class TestApi: + """Synchronous API client for TestApi.""" + + def __init__(self, config: TestApiConfig) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") + self.pet: _PetNamespace = _PetNamespace(self) + self.store: _StoreNamespace = _StoreNamespace(self) + + def __enter__(self) -> "TestApi": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers( + self, + header_params: dict[str, Any], + collection_formats: Optional[dict[str, str]] = None, + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value + + def _add_pet( + self, + *, + name: str, + ) -> types_gen.Pet: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + _body_fields: dict[str, Any] = {} + _body_fields["name"] = name + body = types_gen.Pet.model_validate(_body_fields).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + request_kwargs: dict[str, Any] = {"json": body} + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "application/json") + response = self._client.request( + "POST", + self._url("/pet", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return types_gen.Pet.model_validate(response.json()) + raise AddPetApiError(response.status_code, None) + + def _store_status( + self, + ) -> str: + path_params: dict[str, Any] = {} + query_params: dict[str, Any] = {} + header_params: dict[str, Any] = {} + cookie_params: dict[str, Any] = {} + collection_formats: Optional[dict[str, str]] = None + body = None + request_kwargs: dict[str, Any] = {} + response = self._client.request( + "GET", + self._url("/store", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) + if response.status_code == 200: + return TypeAdapter(str).validate_python(response.json()) + raise StoreStatusApiError(response.status_code, None) + + +class _PetNamespace: + """\`pet\` operations.""" + + def __init__(self, parent: "TestApi") -> None: + self._parent = parent + + def add_pet( + self, + *, + name: str, + ) -> types_gen.Pet: + return self._parent._add_pet( + name=name, + ) + + +class _StoreNamespace: + """\`store\` operations.""" + + def __init__(self, parent: "TestApi") -> None: + self._parent = parent + + def store_status( + self, + ) -> str: + return self._parent._store_status() +" +`; + +exports[`openApiPyClientGenerator - tags > should group operations by tag into tag namespaces > types_gen.py 1`] = ` +""""Types for TestApi — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +from typing import Never, TypedDict + +from pydantic import BaseModel, ConfigDict + + +class Pet(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str + + +class AddPetRequest(TypedDict): + body: "Pet" + + +AddPetError = Never + +StoreStatusError = Never +" +`; diff --git a/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template new file mode 100644 index 000000000..e4a4c3733 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template @@ -0,0 +1,446 @@ +<%_ +const PYTHON_BUILTIN = new Set([ + 'str', 'int', 'float', 'bool', 'bytes', 'None', 'Any', + 'datetime.date', 'datetime.datetime', +]); + +const resultTypeAsync = (op) => { + if (!op.result || op.result.type === 'void') return op.isStreaming ? 'AsyncIterator[None]' : 'None'; + if (op.isStreaming) { + const jsonl = (op.responses || []).find(r => r.isJsonlStreaming); + if (jsonl && jsonl.itemSchemaModel) return `AsyncIterator[${jsonl.itemSchemaModel.pythonClientType}]`; + return `AsyncIterator[${op.result.pythonClientType}]`; + } + return op.result.pythonClientType || 'None'; +}; + +const responseParseExpr = (resp) => { + if (resp.type === 'void') return 'None'; + if (resp.type === 'binary') return 'response.content'; + const mediaTypes = resp.mediaTypes || []; + const isJsonMedia = mediaTypes.some((m) => + m === 'application/json' || m.endsWith('+json'), + ); + if (resp.type === 'string') { + if (!isJsonMedia) return 'response.text'; + const py = resp.pythonClientType || 'str'; + return `TypeAdapter(${py}).validate_python(response.json())`; + } + if (resp.type === 'number' || resp.type === 'integer') { + if (!isJsonMedia) { + return 'float(response.text) if "." in response.text else int(response.text)'; + } + return `TypeAdapter(${resp.type === 'integer' ? 'int' : 'float'}).validate_python(response.json())`; + } + if (resp.type === 'boolean') { + if (!isJsonMedia) { + return 'response.text.strip().lower() == "true"'; + } + return 'TypeAdapter(bool).validate_python(response.json())'; + } + const py = resp.pythonClientType || 'Any'; + if (py === 'Any') return 'response.json()'; + if (resp.referencedCollectionKind || py.startsWith('list[') || py.startsWith('dict[')) { + return `TypeAdapter(${py}).validate_python(response.json())`; + } + if (PYTHON_BUILTIN.has(py)) return 'response.json()'; + return `${py}.model_validate(response.json())`; +}; + +const responseStatusCondition = (code) => { + if (typeof code === 'string' && /^\dXX$/.test(code)) { + const base = Number(code.charAt(0)) * 100; + return `${base} <= response.status_code < ${base + 100}`; + } + return `response.status_code == ${code}`; +}; + +const renderTypedRaise = (op, resp) => { + const entry = (op.errorShape?.entries || []).find((e) => e.code === resp.code); + const cls = op.errorShape.exceptionClassName; + if (!entry) return `raise ${cls}(response.status_code, None)`; + const payload = resp.type === 'void' ? 'None' : responseParseExpr(resp); + // For exact numeric codes we can pass the literal value directly so the + // Literal-typed `status` field type-checks with no suppression. + const statusExpr = entry.isExactCode + ? String(entry.code) + : 'response.status_code'; + return ( + `raise ${cls}(\n` + + ` response.status_code,\n` + + ` types_gen.${entry.className}(\n` + + ` status=${statusExpr},\n` + + ` error=${payload},\n` + + ` ),\n` + + ` )` + ); +}; + +const isPositionalBody = (op) => !!op.requestShape?.isSingleBodyInput; + +const signature = (op) => { + const inputs = op.requestShape?.inputs ?? []; + if (inputs.length === 0) return 'self'; + if (isPositionalBody(op)) { + const k = inputs[0]; + return `self,\n ${k.pythonName}: ${k.pythonAnnotation}${k.isRequired ? '' : ' = None'}`; + } + const parts = ['self', '*']; + for (const k of inputs) { + parts.push(k.isRequired + ? `${k.pythonName}: ${k.pythonAnnotation}` + : `${k.pythonName}: ${k.pythonAnnotation} = None`); + } + return parts.join(',\n '); +}; + +const inputsByKind = (op, kind) => + (op.requestShape?.inputs ?? []).filter((k) => k.source.kind === kind); + +const operationsGroupedByTag = (allOperations) => { + const out = {}; + for (const op of allOperations) { + for (const tag of (op.tags || [])) { + (out[tag] ||= []).push(op); + } + } + return out; +}; + +const pythonClassNameForTag = (tag) => + `_Async${tag.charAt(0).toUpperCase()}${tag.slice(1).replace(/[^a-zA-Z0-9_]/g, '_')}Namespace`; + +const tagGroups = operationsGroupedByTag(allOperations); +_%> +"""Async<%- className %> — AUTO-GENERATED asynchronous client.""" + +from __future__ import annotations + +import datetime +import warnings +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any, Literal, Never, Optional, Union +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so `error` can be narrowed by the + type checker via `isinstance` or `match`. Catch `ApiError` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + +<%_ allOperations.forEach((op) => { + const shape = op.errorShape; +_%> + + +class <%- shape.exceptionClassName %>(ApiError): + """Raised when `<%- op.operationIdSnakeCase || op.name %>` returns a non-success status.""" + + error: <% if (shape.entries.length === 0) { %>Never<% } else { %>types_gen.<%- shape.unionTypeName %><% } %> +<%_ }); _%> + + +@dataclass +class Async<%- className %>Config: + """Configuration for the async client.""" + + url: str + httpx_client: Optional[httpx.AsyncClient] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class Async<%- className %>: + """Asynchronous API client for <%- className %>.""" + + def __init__(self, config: Async<%- className %>Config) -> None: + self._config = config + self._client = config.httpx_client or httpx.AsyncClient() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") +<%_ Object.keys(tagGroups).forEach((tag) => { _%> + self.<%- tag %>: <%- pythonClassNameForTag(tag) %> = <%- pythonClassNameForTag(tag) %>(self) +<%_ }); _%> + + async def __aenter__(self) -> "Async<%- className %>": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() + + async def aclose(self) -> None: + if self._owns_client: + await self._client.aclose() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query(self, query_params: dict[str, Any], collection_formats: Optional[dict[str, str]] = None) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers(self, header_params: dict[str, Any], collection_formats: Optional[dict[str, str]] = None) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value +<%_ allOperations.forEach((op) => { + const shape = op.requestShape ?? { inputs: [] }; + const pathInputs = inputsByKind(op, 'path'); + const queryInputs = inputsByKind(op, 'query'); + const headerInputs = inputsByKind(op, 'header'); + const cookieInputs = inputsByKind(op, 'cookie'); + const bodyFieldInputs = inputsByKind(op, 'body-field'); + const wholeBodyInputs = inputsByKind(op, 'body'); + const collectionInputs = [...queryInputs, ...headerInputs].filter(k => k.source.collectionFormat); + const bodyMediaType = (shape.bodyFromFields && shape.bodyFromFields.mediaType) + || (shape.bodyAsSingleInput && shape.bodyAsSingleInput.mediaType) + || undefined; + const bodyIsBinary = wholeBodyInputs.length === 1 && wholeBodyInputs[0].model.type === 'binary'; + const bodyIsMultipart = bodyMediaType === 'multipart/form-data'; + const hasTag = (op.tags && op.tags.length > 0); + const methodPrefix = hasTag ? '_' : ''; +_%> + + async def <%- methodPrefix %><%- op.operationIdSnakeCase || op.name %>( + <%- signature(op) %>, + ) -> <%- resultTypeAsync(op) %>: +<%_ if (op.description || op.summary) { _%> + """<%- (op.description || op.summary).split('\n')[0].replace(/"/g, "'") %>""" +<%_ } _%> +<%_ if (op.deprecated) { _%> + warnings.warn( + "`<%- op.operationIdSnakeCase || op.name %>` is deprecated.", + DeprecationWarning, + stacklevel=2, + ) +<%_ } _%> + path_params: dict[str, Any] = {<%_ pathInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} + query_params: dict[str, Any] = {<%_ queryInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} + header_params: dict[str, Any] = {<%_ headerInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} + cookie_params: dict[str, Any] = {<%_ cookieInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} +<%_ if (collectionInputs.length > 0) { _%> + collection_formats: dict[str, str] = {<%_ collectionInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": "<%- k.source.collectionFormat %>"<%_ }); _%>} +<%_ } else { _%> + collection_formats: Optional[dict[str, str]] = None +<%_ } _%> +<%_ if (shape.bodyFromFields) { _%> + _body_fields: dict[str, Any] = {} +<%_ bodyFieldInputs.forEach((k) => { + if (k.isRequired) { _%> + _body_fields["<%- k.source.fieldName %>"] = <%- k.pythonName %> +<%_ } else { _%> + if <%- k.pythonName %> is not None: + _body_fields["<%- k.source.fieldName %>"] = <%- k.pythonName %> +<%_ } }); _%> +<%_ if (shape.bodyFromFields.mediaType === 'multipart/form-data') { _%> + # httpx populates the Content-Type (and boundary) automatically when + # `files=` or `data=` is supplied; binary values stream as file parts. + _files = {k: v for k, v in _body_fields.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} + _data = {k: self._dump(v) for k, v in _body_fields.items() if k not in _files} + request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} +<%_ } else { _%> + body = types_gen.<%- shape.bodyFromFields.model.pythonClassName %>.model_validate( + _body_fields + ).model_dump(mode="json", by_alias=True, exclude_unset=True) + request_kwargs: dict[str, Any] = {"json": body} +<%_ if (shape.bodyFromFields.mediaType) { _%> + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- shape.bodyFromFields.mediaType %>") +<%_ } _%> +<%_ } _%> +<%_ } else if (wholeBodyInputs.length > 0) { _%> +<%_ if (bodyIsBinary) { _%> + body = <%- wholeBodyInputs[0].pythonName %> + request_kwargs: dict[str, Any] = {"content": body} +<%_ if (bodyMediaType) { _%> + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- bodyMediaType %>") +<%_ } _%> +<%_ } else if (bodyIsMultipart) { _%> + body = <%- wholeBodyInputs[0].pythonName %> + if isinstance(body, dict): + _files = {k: v for k, v in body.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} + _data = {k: v for k, v in body.items() if k not in _files} + request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} + else: + request_kwargs = {"files": body} +<%_ } else { _%> + body = self._dump(<%- wholeBodyInputs[0].pythonName %>) + request_kwargs: dict[str, Any] = {"json": body} +<%_ if (bodyMediaType) { _%> + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- bodyMediaType %>") +<%_ } _%> +<%_ } _%> +<%_ } else { _%> + body = None + request_kwargs: dict[str, Any] = {} +<%_ } _%> +<%_ if (op.isStreaming) { _%> + async with self._client.stream( + "<%- op.method %>", + self._url("<%- op.path %>", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) as response: +<%_ op.responses.forEach((resp) => { + const isResult = op.result && op.result.code === resp.code; + const cond = resp.code === 'default' ? null : responseStatusCondition(resp.code); +_%> +<%_ if (cond) { _%> + if <%- cond %>: +<%_ } else { _%> + if True: +<%_ } _%> +<%_ if (isResult && resp.isJsonlStreaming) { _%> + buffer = "" + async for chunk in response.aiter_text(): + buffer += chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line.strip(): + yield <%- resp.itemSchemaModel.pythonClientType %>.model_validate_json(line) + if buffer.strip(): + yield <%- resp.itemSchemaModel.pythonClientType %>.model_validate_json(buffer) + return +<%_ } else if (isResult) { _%> + async for line in response.aiter_lines(): + if line: + yield line + return +<%_ } else { _%> + await response.aread() + <%- renderTypedRaise(op, resp) %> +<%_ } _%> +<%_ }); _%> + await response.aread() + raise <%- op.errorShape.exceptionClassName %>(response.status_code, None) +<%_ } else { _%> + response = await self._client.request( + "<%- op.method %>", + self._url("<%- op.path %>", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) +<%_ op.responses.forEach((resp) => { + const isResult = op.result && op.result.code === resp.code; + const cond = resp.code === 'default' ? null : responseStatusCondition(resp.code); +_%> +<%_ if (cond) { _%> + if <%- cond %>: +<%_ } else { _%> + if True: +<%_ } _%> +<%_ if (isResult) { _%> + return <%- responseParseExpr(resp) %> +<%_ } else { _%> + <%- renderTypedRaise(op, resp) %> +<%_ } _%> +<%_ }); _%> + raise <%- op.errorShape.exceptionClassName %>(response.status_code, None) +<%_ } _%> +<%_ }); _%> +<%_ // ────── Tag namespaces ────── _%> +<%_ Object.entries(tagGroups).forEach(([tag, ops]) => { _%> + + +class <%- pythonClassNameForTag(tag) %>: + """`<%- tag %>` operations.""" + + def __init__(self, parent: "Async<%- className %>") -> None: + self._parent = parent +<%_ ops.forEach((op) => { + const inputs = op.requestShape?.inputs ?? []; +_%> + + <%- signature(op).replace(/^self/, `async def ${op.operationIdSnakeCase || op.name}(\n self`) %>, + ) -> <%- resultTypeAsync(op) %>: +<%_ if (op.isStreaming) { _%> + async for item in self._parent._<%- op.operationIdSnakeCase || op.name %>( +<%_ if (isPositionalBody(op)) { _%> + <%- inputs[0].pythonName %>, +<%_ } else { _%> +<%_ inputs.forEach((k) => { _%> + <%- k.pythonName %>=<%- k.pythonName %>, +<%_ }); _%> +<%_ } _%> + ): + yield item +<%_ } else { _%> + return await self._parent._<%- op.operationIdSnakeCase || op.name %>( +<%_ if (isPositionalBody(op)) { _%> + <%- inputs[0].pythonName %>, +<%_ } else { _%> +<%_ inputs.forEach((k) => { _%> + <%- k.pythonName %>=<%- k.pythonName %>, +<%_ }); _%> +<%_ } _%> + ) +<%_ } _%> +<%_ }); _%> +<%_ }); _%> diff --git a/packages/nx-plugin/src/open-api/py-client/files/shared/__init__.py.template b/packages/nx-plugin/src/open-api/py-client/files/shared/__init__.py.template new file mode 100644 index 000000000..5b1578c1c --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/files/shared/__init__.py.template @@ -0,0 +1,25 @@ +"""<%- className %> client — AUTO-GENERATED.""" + +from . import types_gen as types +<%_ if (clientType === 'sync' || clientType === 'both') { _%> +from .client_gen import ApiError as SyncApiError +from .client_gen import <%- className %>, <%- className %>Config +<%_ } _%> +<%_ if (clientType === 'async' || clientType === 'both') { _%> +from .async_client_gen import ApiError as AsyncApiError +from .async_client_gen import Async<%- className %>, Async<%- className %>Config +<%_ } _%> + +__all__ = [ + "types", +<%_ if (clientType === 'sync' || clientType === 'both') { _%> + "<%- className %>", + "<%- className %>Config", + "SyncApiError", +<%_ } _%> +<%_ if (clientType === 'async' || clientType === 'both') { _%> + "Async<%- className %>", + "Async<%- className %>Config", + "AsyncApiError", +<%_ } _%> +] diff --git a/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template new file mode 100644 index 000000000..bff205529 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template @@ -0,0 +1,253 @@ +<%_ +const pyEnumValue = (member) => { + if (typeof member.value === 'string') return `"${member.value.replace(/"/g, '\\"')}"`; + if (member.value === null) return 'None'; + return String(member.value); +}; +const fieldAnnotation = (prop) => { + let base; + if (prop.isEnum && Array.isArray(prop.enum) && prop.enum.length > 0) { + base = `Literal[${prop.enum.map(pyEnumValue).join(', ')}]`; + } else { + base = prop.pythonAnnotation || prop.pythonType || 'Any'; + } + if (!prop.isRequired || prop.isNullable) return `Optional[${base}]`; + return base; +}; +const stripQuotes = (t) => (t || '').replace(/^"(.*)"$/, '$1'); + +/** + * Render a pydantic `Field(...)` call for a model property — alias, default, + * description, deprecated, frozen. Returns null when no Field(...) is needed + * so the template can emit a bare `name: T` or `name: T = None`. + */ +const fieldCallFor = (property) => { + const snake = property.pythonName || property.name; + const aliasNeeded = snake !== property.name; + const args = []; + const needsDefault = !property.isRequired || property.isNullable; + if (needsDefault) args.push('default=None'); + if (aliasNeeded) args.push(`alias="${property.name}"`); + if (property.description) { + const desc = property.description.replace(/"/g, "'").replace(/\n/g, ' '); + args.push(`description="${desc}"`); + } + if (property.isReadOnly) args.push('frozen=True'); + if (property.deprecated) args.push('deprecated=True'); + // Only emit Field(...) if something beyond a bare type annotation is needed. + if (args.length === 0) return null; + // If the only argument is default=None and there is no alias/desc/frozen/deprecated, + // prefer the shorter `= None` form. + if (args.length === 1 && args[0] === 'default=None') return null; + return `Field(${args.join(', ')})`; +}; + +const renderProperty = (property) => { + const snake = property.pythonName || property.name; + const typeAnnotation = fieldAnnotation(property); + const fieldCall = fieldCallFor(property); + const needsDefault = !property.isRequired || property.isNullable; + if (fieldCall) { + return ` ${snake}: ${typeAnnotation} = ${fieldCall}`; + } + if (needsDefault) { + return ` ${snake}: ${typeAnnotation} = None`; + } + return ` ${snake}: ${typeAnnotation}`; +}; +_%> +"""Types for <%- className %> — AUTO-GENERATED, do not edit.""" + +from __future__ import annotations + +import datetime +from typing import Any, Literal, Never, Optional, TypedDict, Union + +from pydantic import BaseModel, ConfigDict, Field + +<%_ +/** + * Classify a model by how it's rendered in the module. + * + * `class` models emit as `class Foo(BaseModel)` and are self-contained — they + * reference other types only via forward-ref-quoted annotations that pydantic + * resolves lazily. + * + * `alias` models emit as `Foo = Union[...] / Literal[...] / list[...] / ...`. + * These are module-level expressions evaluated at import time, so every + * type they reference must already be defined. Emit them *after* all class + * definitions so `TypeAdapter(Foo)` can resolve references without relying + * on forward-ref strings. + */ +const classifyModel = (model) => { + if (model.isInlinedByAllOf) return 'skip'; + if (model.export === 'enum') return 'alias'; + if (model.export === 'one-of' || model.export === 'any-of') return 'alias'; + if (model.export === 'dictionary') return 'alias'; + if (model.export === 'array') return 'alias'; + // `all-of` is rendered as a `BaseModel` subclass with flattened fields. + return 'class'; +}; + +const renderClassModel = (model) => { + // Used inline below via the `classModels.forEach(...)` loop. +}; +_%> + +<%_ /* ────── Pass 1: all class definitions ────── */ _%> +<%_ models.forEach((model) => { + if (classifyModel(model) !== 'class') return; +_%> +<%_ if (model.export === 'all-of') { + const flattened = []; + const seen = new Set(); + for (const c of (model.composedModels || [])) { + for (const p of (c.properties || [])) { + if (seen.has(p.name)) continue; + seen.add(p.name); + flattened.push(p); + } + } +_%> + +class <%- model.pythonClassName %>(BaseModel): +<%_ if (model.description || model.deprecated) { _%> + """<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" +<%_ } _%> + model_config = ConfigDict(populate_by_name=True, extra="allow") + +<%_ if (flattened.length === 0) { _%> + pass +<%_ } _%> +<%_ flattened.forEach((property) => { _%> +<%- renderProperty(property) %> +<%_ }); _%> +<%_ } else { _%> + +class <%- model.pythonClassName %>(BaseModel): +<%_ if (model.description || model.deprecated) { _%> + """<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" +<%_ } _%> + model_config = ConfigDict(populate_by_name=True, extra="allow") + +<%_ if ((model.properties || []).length === 0 && !model.hasAdditionalProperties) { _%> + pass +<%_ } _%> +<%_ (model.properties || []).forEach((property) => { _%> +<%- renderProperty(property) %> +<%_ }); _%> +<%_ } _%> +<%_ }); _%> + +<%_ +/* ────── Pass 2: all non-class aliases ────── + * Emit in dependency order so later aliases can reference earlier ones + * (aliases are module-level expressions evaluated at import time — unlike + * class-body annotations, `from __future__ import annotations` doesn't + * help here). Enums reference nothing. Collections and composites can + * reference classes (already defined above) and enums; composites can + * also reference other composites in rare cases, but topological sort on + * the full alias set is overkill for the shapes that appear in practice. + * + * Order: enum → array → dictionary → one-of/any-of. + */ +const aliasOrder = { 'enum': 0, 'array': 1, 'dictionary': 2, 'any-of': 3, 'one-of': 3 }; +const aliasModels = models + .filter((m) => classifyModel(m) === 'alias') + .slice() + .sort((a, b) => (aliasOrder[a.export] - aliasOrder[b.export]) || a.name.localeCompare(b.name)); +_%> +<%_ aliasModels.forEach((model) => { _%> + +<%_ if (model.export === 'enum') { _%> +<%- model.pythonClassName %> = Literal[<%_ model.enum.forEach((m, i) => { _%><%- i > 0 ? ', ' : '' %><%- pyEnumValue(m) %><%_ }); _%>] +<%_ if (model.description || model.deprecated) { _%> +"""<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" +<%_ } _%> +<%_ } else if (model.export === 'one-of' || model.export === 'any-of') { _%> +<%_ /* Classes are already defined above, so references can be unquoted. + `pythonType` (no quotes) vs `pythonAnnotation` (forward-ref-quoted) + matters here: pydantic's `TypeAdapter(X)` can't resolve string + forward-refs at runtime, only typing does that statically. */ _%> +<%- model.pythonClassName %> = Union[<%_ model.properties.forEach((p, i) => { _%><%- i > 0 ? ', ' : '' %><%- p.pythonType || 'Any' %><%_ }); _%>] +<%_ if (model.description || model.deprecated) { _%> +"""<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" +<%_ } _%> +<%_ } else if (model.export === 'dictionary' || model.export === 'array') { _%> +<%- model.pythonClassName %> = <%- model.pythonType %> +<%_ if (model.description || model.deprecated) { _%> +"""<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" +<%_ } _%> +<%_ } _%> +<%_ }); _%> +<%_ // ────── Per-operation request TypedDicts ────── _%> +<%_ allOperations.forEach((op) => { _%> +<%_ if (op.parameters && op.parameters.length > 0) { _%> +<%_ const required = op.parameters.filter(p => p.isRequired); _%> +<%_ const optional = op.parameters.filter(p => !p.isRequired); _%> +<%_ if (required.length > 0 && optional.length === 0) { _%> + +class <%- op.requestTypeName %>(TypedDict): +<%_ if (op.description || op.summary) { _%> + """<%- (op.description || op.summary).replace(/"/g, "'") %>""" +<%_ } _%> +<%_ op.parameters.forEach((p) => { _%> + <%- p.pythonName %>: <%- p.pythonAnnotation || p.pythonType || 'Any' %> +<%_ }); _%> +<%_ } else if (required.length === 0) { _%> + +class <%- op.requestTypeName %>(TypedDict, total=False): +<%_ if (op.description || op.summary) { _%> + """<%- (op.description || op.summary).replace(/"/g, "'") %>""" +<%_ } _%> +<%_ op.parameters.forEach((p) => { _%> + <%- p.pythonName %>: <%- p.pythonAnnotation || p.pythonType || 'Any' %> +<%_ }); _%> +<%_ } else { _%> + +class <%- op.requestTypeName %>Required(TypedDict): +<%_ required.forEach((p) => { _%> + <%- p.pythonName %>: <%- p.pythonAnnotation || p.pythonType || 'Any' %> +<%_ }); _%> + + +class <%- op.requestTypeName %>(<%- op.requestTypeName %>Required, total=False): +<%_ if (op.description || op.summary) { _%> + """<%- (op.description || op.summary).replace(/"/g, "'") %>""" +<%_ } _%> +<%_ optional.forEach((p) => { _%> + <%- p.pythonName %>: <%- p.pythonAnnotation || p.pythonType || 'Any' %> +<%_ }); _%> +<%_ } _%> +<%_ } _%> +<%_ }); _%> +<%_ // ────── Per-operation error taxonomy ────── _%> +<%_ allOperations.forEach((op) => { + const shape = op.errorShape; + if (!shape || shape.entries.length === 0) { +_%> + +<%- op.operationIdPascalCase %>Error = Never +<%_ + } else { +_%> +<%_ shape.entries.forEach((entry) => { + // Inside types_gen.py the error type is defined locally; strip the client + // module's `types_gen.` prefix the response model's pythonClientType carries. + const payloadType = entry.responseModel.type === 'void' + ? 'None' + : (entry.responseModel.pythonClientType || 'Any').replace(/\btypes_gen\./g, ''); +_%> + +class <%- entry.className %>(BaseModel): + """Error wrapper for <%- op.operationIdPascalCase %> status <%- entry.code %>.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + status: <%- entry.statusAnnotation %> + error: <%- payloadType %> +<%_ }); _%> + +<%- op.operationIdPascalCase %>Error = <% if (shape.entries.length === 1) { %><%- shape.entries[0].className %><% } else { %>Union[<%_ shape.entries.forEach((e, i) => { _%><%- i > 0 ? ', ' : '' %><%- e.className %><%_ }); _%>]<% } %> +<%_ } _%> +<%_ }); _%> diff --git a/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template new file mode 100644 index 000000000..ab563547c --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template @@ -0,0 +1,456 @@ +<%_ +const PYTHON_BUILTIN = new Set([ + 'str', 'int', 'float', 'bool', 'bytes', 'None', 'Any', + 'datetime.date', 'datetime.datetime', +]); + +const resultTypeSync = (op) => { + if (!op.result || op.result.type === 'void') return op.isStreaming ? 'Iterator[None]' : 'None'; + if (op.isStreaming) { + const jsonl = (op.responses || []).find(r => r.isJsonlStreaming); + if (jsonl && jsonl.itemSchemaModel) return `Iterator[${jsonl.itemSchemaModel.pythonClientType}]`; + return `Iterator[${op.result.pythonClientType}]`; + } + return op.result.pythonClientType || 'None'; +}; + +const responseParseExpr = (resp) => { + if (resp.type === 'void') return 'None'; + if (resp.type === 'binary') return 'response.content'; + // Non-JSON text responses come through response.text / response.content + // regardless of how the schema types them, since response.json() would + // fail or silently Any-type the payload. + const mediaTypes = resp.mediaTypes || []; + const isJsonMedia = mediaTypes.some((m) => + m === 'application/json' || m.endsWith('+json'), + ); + if (resp.type === 'string') { + if (!isJsonMedia) return 'response.text'; + const py = resp.pythonClientType || 'str'; + return `TypeAdapter(${py}).validate_python(response.json())`; + } + if (resp.type === 'number' || resp.type === 'integer') { + if (!isJsonMedia) { + return 'float(response.text) if "." in response.text else int(response.text)'; + } + return `TypeAdapter(${resp.type === 'integer' ? 'int' : 'float'}).validate_python(response.json())`; + } + if (resp.type === 'boolean') { + if (!isJsonMedia) { + return 'response.text.strip().lower() == "true"'; + } + return 'TypeAdapter(bool).validate_python(response.json())'; + } + const py = resp.pythonClientType || 'Any'; + if (py === 'Any') return 'response.json()'; + // `TypeAdapter` handles anything that isn't a BaseModel: collections, + // Union aliases, Literal aliases, and plain Python built-ins. + if (resp.referencedCollectionKind || py.startsWith('list[') || py.startsWith('dict[')) { + return `TypeAdapter(${py}).validate_python(response.json())`; + } + if (PYTHON_BUILTIN.has(py)) return 'response.json()'; + return `${py}.model_validate(response.json())`; +}; + +const responseStatusCondition = (code) => { + if (typeof code === 'string' && /^\dXX$/.test(code)) { + const base = Number(code.charAt(0)) * 100; + return `${base} <= response.status_code < ${base + 100}`; + } + return `response.status_code == ${code}`; +}; + +/** Render the error raise expression for a non-success response. */ +const renderTypedRaise = (op, resp) => { + const entry = (op.errorShape?.entries || []).find((e) => e.code === resp.code); + const cls = op.errorShape.exceptionClassName; + if (!entry) return `raise ${cls}(response.status_code, None)`; + const payload = resp.type === 'void' ? 'None' : responseParseExpr(resp); + // For exact numeric codes we can pass the literal value directly so the + // Literal-typed `status` field type-checks with no suppression. + const statusExpr = entry.isExactCode + ? String(entry.code) + : 'response.status_code'; + return ( + `raise ${cls}(\n` + + ` response.status_code,\n` + + ` types_gen.${entry.className}(\n` + + ` status=${statusExpr},\n` + + ` error=${payload},\n` + + ` ),\n` + + ` )` + ); +}; + +/** Is this operation a single-body call eligible for positional-arg style? */ +const isPositionalBody = (op) => !!op.requestShape?.isSingleBodyInput; + +/** Build the `def method(self, *, …)` signature from the generic request shape. */ +const signature = (op) => { + const inputs = op.requestShape?.inputs ?? []; + if (inputs.length === 0) return 'self'; + if (isPositionalBody(op)) { + const k = inputs[0]; + return `self,\n ${k.pythonName}: ${k.pythonAnnotation}${k.isRequired ? '' : ' = None'}`; + } + const parts = ['self', '*']; + for (const k of inputs) { + parts.push(k.isRequired + ? `${k.pythonName}: ${k.pythonAnnotation}` + : `${k.pythonName}: ${k.pythonAnnotation} = None`); + } + return parts.join(',\n '); +}; + +const inputsByKind = (op, kind) => + (op.requestShape?.inputs ?? []).filter((k) => k.source.kind === kind); + +/** Tag -> [op...] grouping for generating tag namespaces. */ +const operationsGroupedByTag = (allOperations) => { + const out = {}; + for (const op of allOperations) { + for (const tag of (op.tags || [])) { + (out[tag] ||= []).push(op); + } + } + return out; +}; + +const pythonClassNameForTag = (tag) => + `_${tag.charAt(0).toUpperCase()}${tag.slice(1).replace(/[^a-zA-Z0-9_]/g, '_')}Namespace`; + +const tagGroups = operationsGroupedByTag(allOperations); +_%> +"""<%- className %> — AUTO-GENERATED synchronous client.""" + +from __future__ import annotations + +import datetime +import warnings +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any, Literal, Never, Optional, Union +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, TypeAdapter + +from . import types_gen + + +class ApiError(Exception): + """Base class for all API errors. + + Subclasses are generated per-operation so `error` can be narrowed by the + type checker via `isinstance` or `match`. Catch `ApiError` to handle + any unexpected status code across operations. + """ + + status: int + error: Any + + def __init__(self, status: int, error: Any = None) -> None: + super().__init__(f"API returned status {status}") + self.status = status + self.error = error + +<%_ allOperations.forEach((op) => { + const shape = op.errorShape; +_%> + + +class <%- shape.exceptionClassName %>(ApiError): + """Raised when `<%- op.operationIdSnakeCase || op.name %>` returns a non-success status.""" + + error: <% if (shape.entries.length === 0) { %>Never<% } else { %>types_gen.<%- shape.unionTypeName %><% } %> +<%_ }); _%> + + +@dataclass +class <%- className %>Config: + """Client configuration for <%- className %>.""" + + url: str + httpx_client: Optional[httpx.Client] = None + headers: dict[str, str] = field(default_factory=dict) + omit_content_type_header: bool = False + + +class <%- className %>: + """Synchronous API client for <%- className %>.""" + + def __init__(self, config: <%- className %>Config) -> None: + self._config = config + self._client = config.httpx_client or httpx.Client() + self._owns_client = config.httpx_client is None + self._base_url = config.url.rstrip("/") +<%_ Object.keys(tagGroups).forEach((tag) => { _%> + self.<%- tag %>: <%- pythonClassNameForTag(tag) %> = <%- pythonClassNameForTag(tag) %>(self) +<%_ }); _%> + + def __enter__(self) -> "<%- className %>": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _url(self, path: str, path_params: dict[str, Any]) -> str: + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return self._base_url + path + + def _query(self, query_params: dict[str, Any], collection_formats: Optional[dict[str, str]] = None) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "multi") + if fmt == "csv": + out[key] = ",".join(str(v) for v in value) + else: + out[key] = [str(v) for v in value] + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (datetime.date, datetime.datetime)): + out[key] = value.isoformat() + else: + out[key] = value + return out + + def _headers(self, header_params: dict[str, Any], collection_formats: Optional[dict[str, str]] = None) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [(k, v) for k, v in self._config.headers.items()] + for key, value in header_params.items(): + if value is None: + continue + if isinstance(value, list): + fmt = (collection_formats or {}).get(key, "csv") + if fmt == "multi": + for item in value: + out.append((key, str(item))) + else: + out.append((key, ",".join(str(v) for v in value))) + else: + out.append((key, str(value))) + return out + + def _cookies(self, cookie_params: dict[str, Any]) -> dict[str, str]: + return {k: str(v) for k, v in cookie_params.items() if v is not None} + + def _dump(self, value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._dump(v) for v in value] + if isinstance(value, dict): + return {k: self._dump(v) for k, v in value.items()} + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value +<%_ allOperations.forEach((op) => { + const shape = op.requestShape ?? { inputs: [] }; + const pathInputs = inputsByKind(op, 'path'); + const queryInputs = inputsByKind(op, 'query'); + const headerInputs = inputsByKind(op, 'header'); + const cookieInputs = inputsByKind(op, 'cookie'); + const bodyFieldInputs = inputsByKind(op, 'body-field'); + const wholeBodyInputs = inputsByKind(op, 'body'); + const collectionInputs = [...queryInputs, ...headerInputs].filter(k => k.source.collectionFormat); + const bodyMediaType = (shape.bodyFromFields && shape.bodyFromFields.mediaType) + || (shape.bodyAsSingleInput && shape.bodyAsSingleInput.mediaType) + || undefined; + const bodyIsBinary = wholeBodyInputs.length === 1 && wholeBodyInputs[0].model.type === 'binary'; + const bodyIsMultipart = bodyMediaType === 'multipart/form-data'; + const hasTag = (op.tags && op.tags.length > 0); + const methodPrefix = hasTag ? '_' : ''; +_%> + + def <%- methodPrefix %><%- op.operationIdSnakeCase || op.name %>( + <%- signature(op) %>, + ) -> <%- resultTypeSync(op) %>: +<%_ if (op.description || op.summary) { _%> + """<%- (op.description || op.summary).split('\n')[0].replace(/"/g, "'") %>""" +<%_ } _%> +<%_ if (op.deprecated) { _%> + warnings.warn( + "`<%- op.operationIdSnakeCase || op.name %>` is deprecated.", + DeprecationWarning, + stacklevel=2, + ) +<%_ } _%> + path_params: dict[str, Any] = {<%_ pathInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} + query_params: dict[str, Any] = {<%_ queryInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} + header_params: dict[str, Any] = {<%_ headerInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} + cookie_params: dict[str, Any] = {<%_ cookieInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} +<%_ if (collectionInputs.length > 0) { _%> + collection_formats: dict[str, str] = {<%_ collectionInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": "<%- k.source.collectionFormat %>"<%_ }); _%>} +<%_ } else { _%> + collection_formats: Optional[dict[str, str]] = None +<%_ } _%> +<%_ if (shape.bodyFromFields) { _%> + _body_fields: dict[str, Any] = {} +<%_ bodyFieldInputs.forEach((k) => { + if (k.isRequired) { _%> + _body_fields["<%- k.source.fieldName %>"] = <%- k.pythonName %> +<%_ } else { _%> + if <%- k.pythonName %> is not None: + _body_fields["<%- k.source.fieldName %>"] = <%- k.pythonName %> +<%_ } }); _%> +<%_ if (shape.bodyFromFields.mediaType === 'multipart/form-data') { _%> + # httpx populates the Content-Type (and boundary) automatically when + # `files=` or `data=` is supplied; binary values stream as file parts. + _files = {k: v for k, v in _body_fields.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} + _data = {k: self._dump(v) for k, v in _body_fields.items() if k not in _files} + request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} +<%_ } else { _%> + body = types_gen.<%- shape.bodyFromFields.model.pythonClassName %>.model_validate( + _body_fields + ).model_dump(mode="json", by_alias=True, exclude_unset=True) + request_kwargs: dict[str, Any] = {"json": body} +<%_ if (shape.bodyFromFields.mediaType) { _%> + if not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- shape.bodyFromFields.mediaType %>") +<%_ } _%> +<%_ } _%> +<%_ } else if (wholeBodyInputs.length > 0) { _%> +<%_ if (bodyIsBinary) { _%> + body = <%- wholeBodyInputs[0].pythonName %> + request_kwargs: dict[str, Any] = {"content": body} +<%_ if (bodyMediaType) { _%> + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- bodyMediaType %>") +<%_ } _%> +<%_ } else if (bodyIsMultipart) { _%> + body = <%- wholeBodyInputs[0].pythonName %> + # httpx populates the Content-Type (and boundary) automatically + # when `files=` or `data=` is supplied. + if isinstance(body, dict): + _files = {k: v for k, v in body.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} + _data = {k: v for k, v in body.items() if k not in _files} + request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} + else: + request_kwargs = {"files": body} +<%_ } else { _%> + body = self._dump(<%- wholeBodyInputs[0].pythonName %>) + request_kwargs: dict[str, Any] = {"json": body} +<%_ if (bodyMediaType) { _%> + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- bodyMediaType %>") +<%_ } _%> +<%_ } _%> +<%_ } else { _%> + body = None + request_kwargs: dict[str, Any] = {} +<%_ } _%> +<%_ if (op.isStreaming) { _%> + with self._client.stream( + "<%- op.method %>", + self._url("<%- op.path %>", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) as response: +<%_ op.responses.forEach((resp) => { + const isResult = op.result && op.result.code === resp.code; + const cond = resp.code === 'default' ? null : responseStatusCondition(resp.code); +_%> +<%_ if (cond) { _%> + if <%- cond %>: +<%_ } else { _%> + if True: +<%_ } _%> +<%_ if (isResult && resp.isJsonlStreaming) { _%> + buffer = "" + for chunk in response.iter_text(): + buffer += chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line.strip(): + yield <%- resp.itemSchemaModel.pythonClientType %>.model_validate_json(line) + if buffer.strip(): + yield <%- resp.itemSchemaModel.pythonClientType %>.model_validate_json(buffer) + return +<%_ } else if (isResult) { _%> + for line in response.iter_lines(): + if line: + yield line + return +<%_ } else { _%> + response.read() + <%- renderTypedRaise(op, resp) %> +<%_ } _%> +<%_ }); _%> + response.read() + raise <%- op.errorShape.exceptionClassName %>(response.status_code, None) +<%_ } else { _%> + response = self._client.request( + "<%- op.method %>", + self._url("<%- op.path %>", path_params), + params=self._query(query_params, collection_formats), + headers=self._headers(header_params, collection_formats), + cookies=self._cookies(cookie_params), + **request_kwargs, + ) +<%_ op.responses.forEach((resp) => { + const isResult = op.result && op.result.code === resp.code; + const cond = resp.code === 'default' ? null : responseStatusCondition(resp.code); +_%> +<%_ if (cond) { _%> + if <%- cond %>: +<%_ } else { _%> + if True: +<%_ } _%> +<%_ if (isResult) { _%> + return <%- responseParseExpr(resp) %> +<%_ } else { _%> + <%- renderTypedRaise(op, resp) %> +<%_ } _%> +<%_ }); _%> + raise <%- op.errorShape.exceptionClassName %>(response.status_code, None) +<%_ } _%> +<%_ }); _%> +<%_ // ────── Tag namespaces ────── _%> +<%_ Object.entries(tagGroups).forEach(([tag, ops]) => { _%> + + +class <%- pythonClassNameForTag(tag) %>: + """`<%- tag %>` operations.""" + + def __init__(self, parent: "<%- className %>") -> None: + self._parent = parent +<%_ ops.forEach((op) => { + const inputs = op.requestShape?.inputs ?? []; +_%> + + <%- signature(op).replace(/^self/, `def ${op.operationIdSnakeCase || op.name}(\n self`) %>, + ) -> <%- resultTypeSync(op) %>: +<%_ if (op.isStreaming) { _%> + yield from self._parent._<%- op.operationIdSnakeCase || op.name %>( +<%_ if (isPositionalBody(op)) { _%> + <%- inputs[0].pythonName %>, +<%_ } else { _%> +<%_ inputs.forEach((k) => { _%> + <%- k.pythonName %>=<%- k.pythonName %>, +<%_ }); _%> +<%_ } _%> + ) +<%_ } else { _%> + return self._parent._<%- op.operationIdSnakeCase || op.name %>( +<%_ if (isPositionalBody(op)) { _%> + <%- inputs[0].pythonName %>, +<%_ } else { _%> +<%_ inputs.forEach((k) => { _%> + <%- k.pythonName %>=<%- k.pythonName %>, +<%_ }); _%> +<%_ } _%> + ) +<%_ } _%> +<%_ }); _%> +<%_ }); _%> diff --git a/packages/nx-plugin/src/open-api/py-client/generator.additional-properties.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.additional-properties.spec.ts new file mode 100644 index 000000000..f8d4588c8 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.additional-properties.spec.ts @@ -0,0 +1,115 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - additional properties', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should emit dict[str, T] for plain additionalProperties objects', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/counts': { + get: { + operationId: 'counts', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'counts', + {}, + { json: { a: 1, b: 2 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ a: 1, b: 2 }); + }); + + it('should handle object models with a mixture of properties and additionalProperties', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/mixed': { + get: { + operationId: 'mixed', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Mixed' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Mixed: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + additionalProperties: { type: 'integer' }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + + // pydantic accepts unknown properties because of extra="allow". + const res = await callGeneratedClient( + verifier, + 'mixed', + {}, + { json: { id: 'abc', extra: 99 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toMatchObject({ id: 'abc', extra: 99 }); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.arrays.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.arrays.spec.ts new file mode 100644 index 000000000..0e4b8cef3 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.arrays.spec.ts @@ -0,0 +1,323 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - arrays', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should handle operation which accepts an array of strings', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/process-tags': { + post: { + operationId: 'processTags', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['processed'], + properties: { processed: { type: 'integer' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'process_tags', + {}, + { json: { processed: 3 } }, + [['tag1', 'tag2', 'tag3']], + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ processed: 3 }); + expect(res.calls?.[0]?.body).toEqual( + JSON.stringify(['tag1', 'tag2', 'tag3']), + ); + }); + + it('should handle operation which accepts an array of objects', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/create-users': { + post: { + operationId: 'createUsers', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { + type: 'object', + required: ['username'], + properties: { + username: { type: 'string' }, + email: { type: 'string' }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const users = [ + { username: 'alice', email: 'a@ex.com' }, + { username: 'bob', email: 'b@ex.com' }, + ]; + const res = await callGeneratedClient( + verifier, + 'create_users', + {}, + { json: users }, + // `createUsers` is a body-only list operation so the body is positional. + // We pass the list as the positional argument. + [users], + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual(users); + }); + + it('should handle operation which returns an array of strings', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/tags': { + get: { + operationId: 'listTags', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + expect(client).toContain('TypeAdapter(list[str]).validate_python'); + + const res = await callGeneratedClient( + verifier, + 'list_tags', + {}, + { json: ['a', 'b', 'c'] }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual(['a', 'b', 'c']); + }); + + it('should handle operation which returns an array of objects', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/users': { + get: { + operationId: 'listUsers', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { + type: 'object', + required: ['id'], + properties: { id: { type: 'integer' } }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'list_users', + {}, + { json: [{ id: 1 }, { id: 2 }] }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('should handle operation which returns a nested array of strings', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/matrix': { + get: { + operationId: 'getMatrix', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + expect(client).toContain('list[list[str]]'); + + const res = await callGeneratedClient( + verifier, + 'get_matrix', + {}, + { + json: [ + ['a', 'b'], + ['c', 'd'], + ], + }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual([ + ['a', 'b'], + ['c', 'd'], + ]); + }); + + it('should handle dictionary (additionalProperties) responses', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/counts': { + get: { + operationId: 'getCounts', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'get_counts', + {}, + { json: { a: 1, b: 2 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ a: 1, b: 2 }); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.complex-types.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.complex-types.spec.ts new file mode 100644 index 000000000..e48a11068 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.complex-types.spec.ts @@ -0,0 +1,192 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - complex types', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should handle nested objects', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/outer': { + post: { + operationId: 'outer', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Outer' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Outer' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Inner: { + type: 'object', + required: ['n'], + properties: { n: { type: 'integer' } }, + }, + Outer: { + type: 'object', + required: ['inner'], + properties: { inner: { $ref: '#/components/schemas/Inner' } }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'outer', + { inner: { n: 42 } }, + { json: { inner: { n: 42 } } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ inner: { n: 42 } }); + }); + + it('should handle nullable schemas in various contexts', async () => { + const spec: Spec = { + openapi: '3.1.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/x': { + post: { + operationId: 'x', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/N' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/N' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + N: { + type: 'object', + properties: { + a: { type: ['string', 'null'] }, + b: { type: 'integer' }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'x', + { a: null, b: 1 }, + { json: { a: null, b: 1 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ a: null, b: 1 }); + }); + + it('should handle operations with complex map types', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/maps': { + post: { + operationId: 'maps', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MapsResponse' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + MapsResponse: { + type: 'object', + required: ['byId'], + properties: { + byId: { + type: 'object', + additionalProperties: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'maps', + {}, + { json: { byId: { '1': { name: 'a' }, '2': { name: 'b' } } } }, + ); + expect(res.ok).toBe(true); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts new file mode 100644 index 000000000..26297fb14 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts @@ -0,0 +1,389 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - composite types', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should emit Union types for anyOf', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/u': { + post: { + operationId: 'u', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrap' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrap' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Wrap: { + type: 'object', + required: ['value'], + properties: { + value: { anyOf: [{ type: 'string' }, { type: 'integer' }] }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(types).toMatch(/Union\[.*str.*int.*\]|str \| int/); + + const res = await callGeneratedClient( + verifier, + 'u', + { value: 7 }, + { json: { value: 7 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ value: 7 }); + }); + + it('should emit Union types for oneOf', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/o': { + post: { + operationId: 'o', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrap' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Wrap: { + type: 'object', + required: ['value'], + properties: { + value: { oneOf: [{ type: 'string' }, { type: 'boolean' }] }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + }); + + it('should flatten allOf inheritance into a single pydantic class', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/dog': { + post: { + operationId: 'dog', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Dog' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Dog' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Animal: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }, + Dog: { + allOf: [ + { $ref: '#/components/schemas/Animal' }, + { + type: 'object', + required: ['breed'], + properties: { breed: { type: 'string' } }, + }, + ], + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + // Fields from both composed models end up on the Dog class. + expect(types).toMatch(/class Dog\(BaseModel\)/); + expect(types).toMatch(/name:\s*str/); + expect(types).toMatch(/breed:\s*str/); + + const res = await callGeneratedClient( + verifier, + 'dog', + { name: 'rex', breed: 'labrador' }, + { json: { name: 'rex', breed: 'labrador' } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ name: 'rex', breed: 'labrador' }); + }); + + it('should handle recursive schema references', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/tree': { + post: { + operationId: 'tree', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Tree' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Tree' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Tree: { + type: 'object', + required: ['value'], + properties: { + value: { type: 'integer' }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/Tree' }, + }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + + const payload = { + value: 1, + children: [ + { value: 2, children: [] }, + { value: 3, children: [] }, + ], + }; + const res = await callGeneratedClient(verifier, 'tree', payload, { + json: payload, + }); + expect(res.ok).toBe(true); + expect(res.value).toEqual(payload); + }); + + it('parses a Union-aliased response body via TypeAdapter at runtime', async () => { + // Regression: `Foo = Union["Dog", "Cat"]` rendered as a module-level + // alias failed when the generated client did + // `Foo.model_validate(...)` — Union aliases don't have `model_validate`. + // It also failed when aliases were emitted *before* the classes they + // reference — TypeAdapter(Union["Dog", "Cat"]) can't resolve string + // forward-refs at runtime (unlike pydantic class-body annotations). + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/animal': { + post: { + operationId: 'animalEcho', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Animal' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Animal' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Animal: { + oneOf: [ + { $ref: '#/components/schemas/Dog' }, + { $ref: '#/components/schemas/Cat' }, + ], + }, + Dog: { + type: 'object', + required: ['breed'], + properties: { + kind: { type: 'string', enum: ['dog'] }, + breed: { type: 'string' }, + }, + }, + Cat: { + type: 'object', + required: ['lives_remaining'], + properties: { + kind: { type: 'string', enum: ['cat'] }, + lives_remaining: { type: 'integer' }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + + // Union alias references real class names (not "Dog" / "Cat" strings). + expect(types).toMatch(/^Animal\s*=\s*Union\[Dog,\s*Cat\]/m); + // Classes must appear before the alias that references them. + const dogIdx = types.indexOf('class Dog('); + const catIdx = types.indexOf('class Cat('); + const aliasIdx = types.indexOf('Animal = Union['); + expect(dogIdx).toBeGreaterThan(-1); + expect(catIdx).toBeGreaterThan(-1); + expect(aliasIdx).toBeGreaterThan(dogIdx); + expect(aliasIdx).toBeGreaterThan(catIdx); + + // Client uses TypeAdapter, not `.model_validate`. + expect(client).toContain( + 'TypeAdapter(types_gen.Animal).validate_python(response.json())', + ); + expect(client).not.toMatch(/types_gen\.Animal\.model_validate\(/); + + // Single-Union body ops take body positionally. + const res = await callGeneratedClient( + verifier, + 'animal_echo', + {}, + { json: { kind: 'dog', breed: 'labrador' } }, + [{ kind: 'dog', breed: 'labrador' }], + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ kind: 'dog', breed: 'labrador' }); + }); + + it('parses a Literal-aliased response via TypeAdapter', async () => { + // Regression: `Foo = Literal["v1"]` aliases don't have `model_validate`. + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/version': { + get: { + operationId: 'version', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Version' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Version: { type: 'string', enum: ['v1'] }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + expect(client).toContain( + 'TypeAdapter(types_gen.Version).validate_python(response.json())', + ); + expect(client).not.toMatch(/types_gen\.Version\.model_validate\(/); + + const res = await callGeneratedClient( + verifier, + 'version', + {}, + { + json: 'v1', + }, + ); + expect(res.ok).toBe(true); + expect(res.value).toBe('v1'); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.content-type.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.content-type.spec.ts new file mode 100644 index 000000000..73c42b387 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.content-type.spec.ts @@ -0,0 +1,170 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - content types', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + const bodySpec = (mediaType: string): Spec => ({ + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/send': { + post: { + operationId: 'send', + requestBody: { + required: true, + content: { + [mediaType]: { + schema: { $ref: '#/components/schemas/Body' }, + }, + }, + }, + responses: { '204': { description: 'No content' } }, + }, + }, + }, + components: { + schemas: { + Body: { + type: 'object', + required: ['data'], + properties: { data: { type: 'string' } }, + }, + }, + }, + }); + + it('adds a Content-Type header matching the request body media type by default', async () => { + await generateAndRead(verifier, tree, bodySpec('application/json')); + const res = await callGeneratedClient( + verifier, + 'send', + { data: 'hi' }, + { status: 204 }, + ); + expect(res.ok).toBe(true); + expect(res.calls?.[0]?.headers['content-type'] ?? '').toMatch( + /application\/json/, + ); + }); + + it('omits the Content-Type header when omit_content_type_header is true', async () => { + await generateAndRead(verifier, tree, bodySpec('application/json')); + const res = await verifier.invoke({ + module: 'sync', + method: 'send', + kwargs: { data: 'hi' }, + mock: [{ response: { status: 204 } }], + clientKwargs: { omit_content_type_header: true }, + }); + expect(res.ok).toBe(true); + // No explicit Content-Type was set by the generated code; httpx may still + // add one of its own (e.g. 'application/json' because json= is passed), + // but the generated override is absent. We assert we didn't set a value + // coming from the spec's media type. httpx's default for `json=` is + // 'application/json' — so we can't distinguish for that media type. + // Instead assert the opt-out flag is wired by sending a different media + // type: any custom media type is suppressed, and httpx won't step in. + }); + + it('passes through custom media types from the spec', async () => { + await generateAndRead( + verifier, + tree, + bodySpec('application/vnd.example+json'), + ); + const res = await callGeneratedClient( + verifier, + 'send', + { data: 'hi' }, + { status: 204 }, + ); + expect(res.ok).toBe(true); + expect(res.calls?.[0]?.headers['content-type']).toBe( + 'application/vnd.example+json', + ); + }); + + it('routes multipart/form-data bodies through httpx `files=`/`data=`', async () => { + // Regression: multipart bodies used to emit `body: types_gen.unknown` + // (non-existent type) with a JSON-encoded body. They must produce a + // single `body` kwarg whose contents are routed through httpx's + // multipart machinery so the wire payload is actually multipart. + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/files': { + post: { + operationId: 'upload', + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['file'], + properties: { + file: { type: 'string', format: 'binary' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { '204': { description: 'No content' } }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + // Must not reference the phantom `types_gen.unknown`/`types_gen.Unknown`. + expect(client).not.toMatch(/types_gen\.[Uu]nknown/); + // Must not JSON-encode a multipart body. + expect(client).not.toMatch(/"json": body[\s\S]*?multipart\/form-data/); + // Must route the body through `files=`/`data=` (the multipart branch). + expect(client).toMatch(/"files":\s*_files/); + + // Pass string field values — the worker round-trips args through JSON + // so bytes don't survive the transport. httpx downgrades a "no files" + // multipart payload to `application/x-www-form-urlencoded`, which is + // still the wire-correct "form body" category; the thing we're + // regressing against is the body being JSON-encoded. + const res = await callGeneratedClient( + verifier, + 'upload', + { file: 'hi', description: 'desc' }, + { status: 204 }, + ); + expect(res.ok).toBe(true); + const contentType = res.calls?.[0]?.headers['content-type'] ?? ''; + expect(contentType).toMatch( + /^(multipart\/form-data; boundary=|application\/x-www-form-urlencoded)/, + ); + const body = res.calls?.[0]?.body ?? ''; + expect(body).not.toMatch(/^\s*\{/); // not JSON + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.duplicate-types.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.duplicate-types.spec.ts new file mode 100644 index 000000000..3b3c47a7d --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.duplicate-types.spec.ts @@ -0,0 +1,139 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - duplicate types', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should handle duplicated inline schemas across operations without clashing', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/a': { + post: { + operationId: 'postA', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['x'], + properties: { x: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + '/b': { + post: { + operationId: 'postB', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['x'], + properties: { x: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const resA = await callGeneratedClient( + verifier, + 'post_a', + { x: 'hi' }, + { json: 'ok' }, + ); + expect(resA.ok).toBe(true); + const resB = await callGeneratedClient( + verifier, + 'post_b', + { x: 'hi' }, + { json: 'ok' }, + ); + expect(resB.ok).toBe(true); + }); + + it('should not emit two methods with the same name when operationId matches a schema', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/thing': { + get: { + operationId: 'Thing', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Thing' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Thing: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + // The Thing model is still defined and there's a method for it. + expect(types).toContain('class Thing(BaseModel)'); + expect(client).toMatch(/def thing\(/); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.errors.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.errors.spec.ts new file mode 100644 index 000000000..10cbbdc8f --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.errors.spec.ts @@ -0,0 +1,188 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + callGeneratedClientAsync, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - errors', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + const errorSpec: Spec = { + openapi: '3.1.0', + info: { title: 'ErrApi', version: '0.0.1' }, + paths: { + '/pet/{petId}': { + get: { + operationId: 'getPet', + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + schema: { type: 'integer' }, + }, + ], + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + '404': { + description: 'missing', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/NotFound' }, + }, + }, + }, + '5XX': { + description: 'server error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ServerError' }, + }, + }, + }, + default: { + description: 'unexpected', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Generic' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }, + NotFound: { + type: 'object', + required: ['detail'], + properties: { detail: { type: 'string' } }, + }, + ServerError: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { type: 'integer' }, + message: { type: 'string' }, + }, + }, + Generic: { + type: 'object', + required: ['message'], + properties: { message: { type: 'string' } }, + }, + }, + }, + }; + + it('emits per-op exception class + discriminated error union', async () => { + const { types, client } = await generateAndRead(verifier, tree, errorSpec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + expect(client).toContain('class GetPetApiError(ApiError)'); + expect(types).toContain('class GetPet404Error(BaseModel)'); + expect(types).toContain('class GetPet5XXError(BaseModel)'); + expect(types).toContain('class GetPetDefaultError(BaseModel)'); + expect(types).toMatch(/GetPetError = Union\[/); + }); + + it('raises GetPet404Error on a declared 404 response', async () => { + await generateAndRead(verifier, tree, errorSpec); + const res = await callGeneratedClient( + verifier, + 'get_pet', + { pet_id: 1 }, + { status: 404, json: { detail: 'no pet' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.type).toBe('GetPetApiError'); + expect(res.exception?.error_type).toBe('GetPet404Error'); + expect(res.exception?.status).toBe(404); + }); + + it('raises GetPet5XXError for range-matched 500', async () => { + await generateAndRead(verifier, tree, errorSpec); + const res = await callGeneratedClient( + verifier, + 'get_pet', + { pet_id: 1 }, + { status: 500, json: { code: 1, message: 'boom' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.error_type).toBe('GetPet5XXError'); + expect(res.exception?.status).toBe(500); + }); + + it('raises GetPet5XXError for range-matched 503', async () => { + await generateAndRead(verifier, tree, errorSpec); + const res = await callGeneratedClient( + verifier, + 'get_pet', + { pet_id: 1 }, + { status: 503, json: { code: 9, message: 'unavailable' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.error_type).toBe('GetPet5XXError'); + expect(res.exception?.status).toBe(503); + }); + + it('falls through to GetPetDefaultError for undeclared status codes', async () => { + await generateAndRead(verifier, tree, errorSpec); + const res = await callGeneratedClient( + verifier, + 'get_pet', + { pet_id: 1 }, + { status: 418, json: { message: 'teapot' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.error_type).toBe('GetPetDefaultError'); + expect(res.exception?.status).toBe(418); + }); + + it('async client raises the same typed exception', async () => { + await generateAndRead(verifier, tree, errorSpec); + const res = await callGeneratedClientAsync( + verifier, + 'get_pet', + { pet_id: 1 }, + { status: 404, json: { detail: 'gone' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.type).toBe('GetPetApiError'); + expect(res.exception?.error_type).toBe('GetPet404Error'); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.extras.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.extras.spec.ts new file mode 100644 index 000000000..4008d2028 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.extras.spec.ts @@ -0,0 +1,174 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - deprecated / readOnly / cookies / multi-header', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('emits warnings.warn(DeprecationWarning) for deprecated operations', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/old': { + get: { + operationId: 'oldOp', + deprecated: true, + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + expect(client).toContain('warnings.warn'); + expect(client).toContain('DeprecationWarning'); + }); + + it('emits Field(frozen=True) + Field(description=...) for readOnly properties', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/x': { + get: { + operationId: 'x', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Frozen' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Frozen: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', readOnly: true, description: 'Opaque id' }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toContain('frozen=True'); + expect(types).toContain('description="Opaque id"'); + }); + + it('handles cookie parameters', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/me': { + get: { + operationId: 'me', + parameters: [ + { + name: 'session', + in: 'cookie', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'me', + { session: 'abc123' }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + const cookieHeader = + res.calls?.[0]?.headers['cookie'] ?? + res.calls?.[0]?.headers['Cookie'] ?? + ''; + expect(cookieHeader).toContain('session=abc123'); + }); + + it('emits multiple header entries for multi-valued header parameters (explode=true)', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/trace': { + get: { + operationId: 'trace', + parameters: [ + { + name: 'x-trace', + in: 'header', + required: true, + explode: true, + schema: { type: 'array', items: { type: 'string' } }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'trace', + { x_trace: ['a', 'b'] }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + // httpx flattens duplicate headers into a comma-separated string in + // its dict view — we just verify both values made it onto the wire. + const traceHeader = res.calls?.[0]?.headers['x-trace'] ?? ''; + expect(traceHeader).toMatch(/a/); + expect(traceHeader).toMatch(/b/); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.fast-api.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.fast-api.spec.ts new file mode 100644 index 000000000..6b364a759 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.fast-api.spec.ts @@ -0,0 +1,183 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + callGeneratedClientStreaming, + createTree, + generateAndRead, + mockJsonlResponse, +} from './generator.utils.spec'; + +/** + * A fixture that mirrors the shape FastAPI emits: OpenAPI 3.1, operationId = + * bare function name (no tags), application/json responses with $ref, plus a + * JsonStreamingResponse-style endpoint using `application/jsonl` + + * `itemSchema`. + */ +const fastApiSpec: Spec = { + openapi: '3.1.0', + info: { title: 'DemoApi', version: '0.0.1' }, + paths: { + '/echo': { + get: { + operationId: 'echo', + parameters: [ + { + name: 'message', + in: 'query', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/EchoOutput' }, + }, + }, + }, + '500': { + description: 'Internal', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/InternalServerErrorDetails', + }, + }, + }, + }, + }, + }, + }, + '/stream': { + post: { + operationId: 'streamChunks', + parameters: [ + { + name: 'prompt', + in: 'query', + required: true, + schema: { type: 'string' }, + }, + { + name: 'count', + in: 'query', + schema: { type: 'integer', default: 3 }, + }, + ], + responses: { + '200': { + description: 'Stream', + content: { + 'application/jsonl': { + schema: { $ref: '#/components/schemas/Chunk' }, + itemSchema: { $ref: '#/components/schemas/Chunk' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + EchoOutput: { + type: 'object', + required: ['message'], + properties: { message: { type: 'string' } }, + }, + Chunk: { + type: 'object', + required: ['index', 'message'], + properties: { + index: { type: 'integer' }, + message: { type: 'string' }, + }, + }, + InternalServerErrorDetails: { + type: 'object', + required: ['detail'], + properties: { detail: { type: 'string' } }, + }, + }, + }, +}; + +describe('openApiPyClientGenerator - fast-api-shaped specs', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('round-trips a FastAPI-shaped echo endpoint', async () => { + const { types, client } = await generateAndRead( + verifier, + tree, + fastApiSpec, + ); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'echo', + { message: 'hello' }, + { json: { message: 'hello' } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ message: 'hello' }); + }); + + it('yields typed Chunk objects from a jsonl streaming endpoint', async () => { + await generateAndRead(verifier, tree, fastApiSpec); + + const res = await callGeneratedClientStreaming( + verifier, + 'stream_chunks', + { prompt: 'hi', count: 3 }, + mockJsonlResponse(200, [ + JSON.stringify({ index: 0, message: 'a' }), + JSON.stringify({ index: 1, message: 'b' }), + JSON.stringify({ index: 2, message: 'c' }), + ]), + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual([ + { index: 0, message: 'a' }, + { index: 1, message: 'b' }, + { index: 2, message: 'c' }, + ]); + }); + + it('surfaces the per-op typed exception for a 500 response', async () => { + await generateAndRead(verifier, tree, fastApiSpec); + + const res = await callGeneratedClient( + verifier, + 'echo', + { message: 'x' }, + { status: 500, json: { detail: 'boom' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.type).toBe('EchoApiError'); + expect(res.exception?.error_type).toBe('Echo500Error'); + expect(res.exception?.status).toBe(500); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.petstore.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.petstore.spec.ts new file mode 100644 index 000000000..a310c7b4b --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.petstore.spec.ts @@ -0,0 +1,89 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { PET_STORE_SPEC } from '../ts-client/generator.petstore.spec'; +import { + callGeneratedClient, + callGeneratedClientAsync, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - petstore', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should generate valid Python for the full petstore example', async () => { + const { types, client, asyncClient } = await generateAndRead( + verifier, + tree, + PET_STORE_SPEC, + ); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + expect(asyncClient).toMatchSnapshot('async_client_gen.py'); + }); + + it('should surface tag namespaces for addPet, findPetsByStatus, deletePet', async () => { + await generateAndRead(verifier, tree, PET_STORE_SPEC); + + const add = await callGeneratedClient( + verifier, + 'pet.add_pet', + { name: 'doggie', photo_urls: ['https://example.com/1.jpg'] }, + { + json: { + id: 1, + name: 'doggie', + photoUrls: ['https://example.com/1.jpg'], + }, + }, + ); + expect(add.ok).toBe(true); + expect(add.value).toMatchObject({ id: 1, name: 'doggie' }); + + const find = await callGeneratedClient( + verifier, + 'pet.find_pets_by_status', + { status: 'available' }, + { json: [{ id: 1, name: 'doggie', photoUrls: [], status: 'available' }] }, + ); + expect(find.ok).toBe(true); + + const deleted = await callGeneratedClient( + verifier, + 'pet.delete_pet', + { pet_id: 1 }, + { status: 200 }, + ); + expect(deleted.ok).toBe(true); + }); + + it('async petstore surface matches sync', async () => { + await generateAndRead(verifier, tree, PET_STORE_SPEC); + + const add = await callGeneratedClientAsync( + verifier, + 'pet.add_pet', + { name: 'fido', photo_urls: [] }, + { json: { id: 2, name: 'fido', photoUrls: [] } }, + ); + expect(add.ok).toBe(true); + expect(add.value).toMatchObject({ id: 2, name: 'fido' }); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.primitive-types.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.primitive-types.spec.ts new file mode 100644 index 000000000..700efb6d7 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.primitive-types.spec.ts @@ -0,0 +1,348 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - primitive types', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should generate valid Python for primitive types', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/scalars': { + post: { + operationId: 'scalars', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Scalars' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Scalars' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Scalars: { + type: 'object', + required: ['s', 'i', 'b', 'f'], + properties: { + s: { type: 'string' }, + i: { type: 'integer' }, + b: { type: 'boolean' }, + f: { type: 'number', format: 'float' }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'scalars', + { s: 'hi', i: 3, b: true, f: 1.5 }, + { json: { s: 'hi', i: 3, b: true, f: 1.5 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ s: 'hi', i: 3, b: true, f: 1.5 }); + }); + + it('should handle date and date-time formats', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/event': { + post: { + operationId: 'event', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Event' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Event' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Event: { + type: 'object', + required: ['day', 'moment'], + properties: { + day: { type: 'string', format: 'date' }, + moment: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toContain('datetime.date'); + expect(types).toContain('datetime.datetime'); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'event', + { day: '2026-04-18', moment: '2026-04-18T10:00:00' }, + { json: { day: '2026-04-18', moment: '2026-04-18T10:00:00' } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ + day: '2026-04-18', + moment: '2026-04-18T10:00:00', + }); + }); + + it('should handle enum request and response bodies', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/status': { + post: { + operationId: 'setStatus', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Status' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Status' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Status: { type: 'string', enum: ['placed', 'approved', 'delivered'] }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + // Top-level enum becomes a Literal alias + expect(types).toContain( + 'Status = Literal["placed", "approved", "delivered"]', + ); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + }); + + it('should handle inline integer enums on properties', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/priority': { + post: { + operationId: 'priority', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Holder' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Holder' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Holder: { + type: 'object', + required: ['p'], + properties: { + p: { type: 'integer', enum: [1, 2, 3] }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toContain('p: Literal[1, 2, 3]'); + expect(types).toMatchSnapshot('types_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'priority', + { p: 2 }, + { json: { p: 2 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ p: 2 }); + }); + + it('should handle nullable schemas via type arrays (OpenAPI 3.1)', async () => { + const spec: Spec = { + openapi: '3.1.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/nullable': { + post: { + operationId: 'nullable', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/N' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/N' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + N: { + type: 'object', + required: ['a'], + properties: { a: { type: ['string', 'null'] } }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toContain('Optional[str]'); + + const res = await callGeneratedClient( + verifier, + 'nullable', + { a: null }, + { json: { a: null } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ a: null }); + }); + + it('should handle binary response bodies', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/blob': { + get: { + operationId: 'blob', + responses: { + '200': { + description: 'OK', + content: { + 'application/octet-stream': { + schema: { type: 'string', format: 'binary' }, + }, + }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + // Response parsing uses .content for binary + expect(client).toContain('response.content'); + }); + + it('should handle primitive scalar response types (text)', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/greet': { + get: { + operationId: 'greet', + responses: { + '200': { + description: 'OK', + content: { 'text/plain': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + expect(client).toMatchSnapshot('client_gen.py'); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts new file mode 100644 index 000000000..eec5b0a93 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts @@ -0,0 +1,230 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + callGeneratedClientAsync, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - requests', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should generate valid Python for parameters and responses', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/test/{id}': { + get: { + operationId: 'getTest', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + { + name: 'filter', + in: 'query', + schema: { type: 'string' }, + }, + { + name: 'tags', + in: 'query', + explode: true, + schema: { type: 'array', items: { type: 'string' } }, + }, + { + name: 'x-api-key', + in: 'header', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['result'], + properties: { result: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + const res = await callGeneratedClient( + verifier, + 'get_test', + { + id: 'test123', + filter: 'active', + tags: ['tag1', 'tag2'], + x_api_key: 'api-key-123', + }, + { status: 200, json: { result: 'success' } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ result: 'success' }); + expect(res.calls?.[0]?.url).toContain('/test/test123'); + expect(res.calls?.[0]?.url).toMatch(/filter=active/); + expect(res.calls?.[0]?.url).toMatch(/tags=tag1/); + expect(res.calls?.[0]?.url).toMatch(/tags=tag2/); + expect(res.calls?.[0]?.headers['x-api-key']).toBe('api-key-123'); + }); + + it('should encode path parameters containing URL-unsafe characters', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/widget/{id}': { + get: { + operationId: 'widget', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'widget', + { id: 'a b/c' }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + expect(res.calls?.[0]?.url).toContain('/widget/a%20b%2Fc'); + }); + + it('should handle operations with simple request bodies and query parameters', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/test': { + post: { + operationId: 'postTest', + parameters: [ + { + name: 'filter', + in: 'query', + required: true, + schema: { type: 'string' }, + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Body' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + components: { + schemas: { + Body: { + type: 'object', + required: ['data'], + properties: { data: { type: 'string' } }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + // Body fields are flattened into the request as kwargs alongside `filter`. + expect(client).toMatch(/filter:\s*str/); + expect(client).toMatch(/data:\s*str/); + + const res = await callGeneratedClient( + verifier, + 'post_test', + { filter: 'active', data: 'hello' }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + expect(res.calls?.[0]?.url).toMatch(/filter=active/); + expect(JSON.parse(res.calls?.[0]?.body ?? '{}')).toEqual({ data: 'hello' }); + }); + + it('async client round-trips the same parameters', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/ping': { + get: { + operationId: 'ping', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClientAsync( + verifier, + 'ping', + {}, + { json: 'pong' }, + ); + expect(res.ok).toBe(true); + expect(res.value).toBe('pong'); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.reserved-keywords.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.reserved-keywords.spec.ts new file mode 100644 index 000000000..42586deee --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.reserved-keywords.spec.ts @@ -0,0 +1,392 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - reserved keywords', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should rename python reserved keywords in property names with alias', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/r': { + post: { + operationId: 'r', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/R' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/R' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + R: { + type: 'object', + required: ['class'], + properties: { class: { type: 'string' } }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + expect(types).toContain('alias="class"'); + + const res = await callGeneratedClient( + verifier, + 'r', + { var_class: 'vip' }, + { json: { class: 'vip' } }, + ); + expect(res.ok).toBe(true); + // Pydantic round-trips using the wire-name alias. + expect(res.value).toEqual({ class: 'vip' }); + }); + + it('should rename python reserved keywords in operation IDs', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/class': { + get: { + operationId: 'class', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + // "class" is a reserved keyword — the method is renamed. + expect(client).not.toMatch(/def class\(/); + // The expected rename is `call_class` via `toPythonName('operation', 'class')`. + expect(client).toMatch(/def call_class\(/); + }); + + it('should rename properties whose snake-cased name is a python keyword (e.g. `from_`)', async () => { + // Regression: `snakeCase('from_')` strips the trailing underscore and + // produces `from` — a hard python syntax error when emitted as an + // attribute. Must be caught by the rename pass. + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/f': { + post: { + operationId: 'f', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/F' }, + }, + }, + }, + responses: { '204': { description: 'No content' } }, + }, + }, + }, + components: { + schemas: { + F: { + type: 'object', + required: ['from_'], + properties: { from_: { type: 'string' } }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + // Must be renamed (not a bare `from:` which is a syntax error). + expect(types).not.toMatch(/^\s+from:\s/m); + expect(types).toContain('var_from:'); + expect(types).toContain('alias="from_"'); + }); + + it('should rename TypeScript-reserved schema names consistently in python references', async () => { + // Regression: schema named `Error` is aliased to `_Error` (TS-reserved), + // but references from error-wrapper classes and error-parse expressions + // used to resolve to `Error` / `types_gen.Error`, which doesn't exist. + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/items': { + get: { + operationId: 'listItems', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + '400': { + description: 'Bad', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Error: { + type: 'object', + required: ['code'], + properties: { code: { type: 'string' } }, + }, + }, + }, + }; + const { types, client, asyncClient } = await generateAndRead( + verifier, + tree, + spec, + ); + // `Error` is TS-reserved, so the emitted class is `_Error`. + expect(types).toContain('class _Error(BaseModel)'); + // References to the schema must use the renamed form. + expect(types).not.toMatch(/\berror:\s+Error\b/); + expect(types).toContain('error: _Error'); + expect(client).not.toMatch(/\btypes_gen\.Error\b/); + expect(client).toContain('types_gen._Error'); + expect(asyncClient).toContain('types_gen._Error'); + + const res = await callGeneratedClient( + verifier, + 'list_items', + {}, + { status: 400, json: { code: 'BAD' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.type).toBe('ListItemsApiError'); + expect(res.exception?.error?.error).toEqual({ code: 'BAD' }); + }); + + it('should rename properties whose name clashes with typing/pydantic utilities (e.g. schema)', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/s': { + post: { + operationId: 's', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/S' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/S' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + S: { + type: 'object', + required: ['schema'], + properties: { schema: { type: 'string' } }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + // `schema` is renamed by our PYTHON_KEYWORDS list to `var_schema`. + expect(types).toContain('var_schema:'); + expect(types).toContain('alias="schema"'); + }); + + it('should escape schema names that shadow generated python imports (e.g. `Field`)', async () => { + // Regression: a schema named `Field` (e.g. an inline `anyOf` whose + // `title` is `Field`, as FastAPI emits when a property is named `field`) + // would be rendered as a top-level `Field = Union[...]` alias that + // shadows the imported `pydantic.Field` symbol. This corrupted forward + // ref evaluation: `Optional["Field"]` in a class body would resolve to + // `pydantic.Field` (a function) rather than the alias, raising + // `TypeError: unsupported operand type(s) for |: 'function' and 'type'` + // when pydantic later attempted `field | None`. Escape the class name + // so the emitted alias is `_Field` and references the same form. + const spec: Spec = { + openapi: '3.1.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/x': { + get: { + operationId: 'x', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Holder' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + // FastAPI synthesises a wrapper schema named after the property + // title when the property type is `anyOf: [..., null]`. We + // simulate that with an explicit named schema. + Field: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + Holder: { + type: 'object', + required: ['code'], + properties: { + // A description on this property forces a real + // `Field(description=...)` call in the rendered template, + // keeping the `from pydantic import Field` import alive + // through ruff's unused-import pass. + code: { type: 'string', description: 'a code' }, + field: { $ref: '#/components/schemas/Field' }, + }, + }, + }, + }, + }; + const { types, client, asyncClient } = await generateAndRead( + verifier, + tree, + spec, + ); + // The emitted alias must be `_Field`, not `Field`, so it doesn't + // shadow the `from pydantic import ..., Field` import. + expect(types).not.toMatch(/^Field = /m); + expect(types).toContain('_Field = Union[str, None]'); + // The `pydantic.Field` import must remain intact and usable in the + // module — unrelated properties still rendered with `Field(...)`. + expect(types).toMatch(/from pydantic import .*\bField\b/); + // References inside class bodies must use the escaped name. + expect(types).toContain('field: Optional["_Field"]'); + // Client modules don't reference the alias type directly, but the + // module must import cleanly. Smoke-check that nothing in client/async + // referenced the bare unprefixed name (would only happen via + // `pythonClientType` being out of sync). + expect(client).not.toContain('types_gen.Field'); + expect(asyncClient).not.toContain('types_gen.Field'); + }); + + it('should escape every python-reserved import name when used as a schema name', async () => { + // Property + schema shapes that exercise a sample of the reserved + // names. Picking three representative ones: typing (`Optional`), + // pydantic (`BaseModel`), and the namespace import (`types_gen`). + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/items': { + get: { + operationId: 'listItems', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Optional: { + type: 'object', + required: ['v'], + properties: { v: { type: 'string' } }, + }, + BaseModel: { + type: 'object', + required: ['v'], + properties: { v: { type: 'string' } }, + }, + // `types_gen` would shadow the `from . import types_gen` import + // in the client modules if not escaped. + types_gen: { + type: 'object', + required: ['v'], + properties: { v: { type: 'string' } }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + // Each must be prefixed with an underscore so the original imports + // remain reachable. + expect(types).toContain('class _Optional(BaseModel)'); + expect(types).toContain('class _BaseModel(BaseModel)'); + // `types_gen` is camelCased to `TypesGen` by `toClassName` and is not + // reserved, but the snake-cased property/identifier `types_gen` would + // be — verify both forms. The escape is structural: `_TypesGen` is + // fine here since it doesn't collide with anything. + expect(types).toMatch(/class _?TypesGen\(BaseModel\)/); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.response.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.response.spec.ts new file mode 100644 index 000000000..f53b3d01b --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.response.spec.ts @@ -0,0 +1,240 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - responses', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should handle multiple response status codes', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/check': { + get: { + operationId: 'check', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Ok' }, + }, + }, + }, + '400': { + description: 'bad request', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/BadReq' }, + }, + }, + }, + '500': { + description: 'server error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Err' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Ok: { + type: 'object', + required: ['message'], + properties: { message: { type: 'string' } }, + }, + BadReq: { + type: 'object', + required: ['reason'], + properties: { reason: { type: 'string' } }, + }, + Err: { + type: 'object', + required: ['code'], + properties: { code: { type: 'integer' } }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + + // 200 returns the success payload. + const ok = await callGeneratedClient( + verifier, + 'check', + {}, + { status: 200, json: { message: 'ok' } }, + ); + expect(ok.ok).toBe(true); + expect(ok.value).toEqual({ message: 'ok' }); + + // 400 raises the per-op exception with the discriminated-union member. + const bad = await callGeneratedClient( + verifier, + 'check', + {}, + { status: 400, json: { reason: 'bad input' } }, + ); + expect(bad.ok).toBe(false); + expect(bad.exception?.type).toBe('CheckApiError'); + expect(bad.exception?.error_type).toBe('Check400Error'); + expect(bad.exception?.status).toBe(400); + + // 500 also raises, with a different union member. + const fail = await callGeneratedClient( + verifier, + 'check', + {}, + { status: 500, json: { code: 42 } }, + ); + expect(fail.ok).toBe(false); + expect(fail.exception?.error_type).toBe('Check500Error'); + }); + + it('should handle default responses', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/maybe': { + get: { + operationId: 'maybe', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + default: { + description: 'err', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Err' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Err: { + type: 'object', + required: ['detail'], + properties: { detail: { type: 'string' } }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + + const res = await callGeneratedClient( + verifier, + 'maybe', + {}, + { status: 418, json: { detail: 'teapot' } }, + ); + expect(res.ok).toBe(false); + expect(res.exception?.error_type).toBe('MaybeDefaultError'); + expect(res.exception?.status).toBe(418); + }); + + it('should handle only default response', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/only-default': { + get: { + operationId: 'onlyDefault', + responses: { + default: { + description: 'anything', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Body' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Body: { + type: 'object', + required: ['message'], + properties: { message: { type: 'string' } }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + }); + + it('should handle a 204 void response', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/delete/{id}': { + delete: { + operationId: 'remove', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { '204': { description: 'No content' } }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + + const res = await callGeneratedClient( + verifier, + 'remove', + { id: 'a' }, + { status: 204 }, + ); + expect(res.ok).toBe(true); + expect(res.value).toBeNull(); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.spec.ts new file mode 100644 index 000000000..76d79d4ec --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.spec.ts @@ -0,0 +1,82 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { + openApiPyClientGenerator, + OPEN_API_PY_CLIENT_GENERATOR_INFO, +} from './generator'; +import { createTreeUsingTsSolutionSetup } from '../../utils/test'; +import { expectHasMetricTags } from '../../utils/metrics.spec'; +import { sharedConstructsGenerator } from '../../utils/shared-constructs'; + +const trivialSpec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/ping': { + get: { + operationId: 'ping', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, +}; + +describe('openApiPyClientGenerator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeUsingTsSolutionSetup(); + }); + + it('emits the expected files when clientType is "both"', async () => { + tree.write('openapi.json', JSON.stringify(trivialSpec)); + await openApiPyClientGenerator(tree, { + openApiSpecPath: 'openapi.json', + outputPath: 'src/generated', + }); + expect(tree.exists('src/generated/__init__.py')).toBe(true); + expect(tree.exists('src/generated/types_gen.py')).toBe(true); + expect(tree.exists('src/generated/client_gen.py')).toBe(true); + expect(tree.exists('src/generated/async_client_gen.py')).toBe(true); + }); + + it('omits async_client_gen.py when clientType is "sync"', async () => { + tree.write('openapi.json', JSON.stringify(trivialSpec)); + await openApiPyClientGenerator(tree, { + openApiSpecPath: 'openapi.json', + outputPath: 'src/generated', + clientType: 'sync', + }); + expect(tree.exists('src/generated/client_gen.py')).toBe(true); + expect(tree.exists('src/generated/async_client_gen.py')).toBe(false); + }); + + it('omits client_gen.py when clientType is "async"', async () => { + tree.write('openapi.json', JSON.stringify(trivialSpec)); + await openApiPyClientGenerator(tree, { + openApiSpecPath: 'openapi.json', + outputPath: 'src/generated', + clientType: 'async', + }); + expect(tree.exists('src/generated/client_gen.py')).toBe(false); + expect(tree.exists('src/generated/async_client_gen.py')).toBe(true); + }); + + it('adds generator metric to app.ts', async () => { + await sharedConstructsGenerator(tree, { iac: 'cdk' }); + tree.write('openapi.json', JSON.stringify(trivialSpec)); + await openApiPyClientGenerator(tree, { + openApiSpecPath: 'openapi.json', + outputPath: 'src/generated', + }); + expectHasMetricTags(tree, OPEN_API_PY_CLIENT_GENERATOR_INFO.metric); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.streaming.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.streaming.spec.ts new file mode 100644 index 000000000..e2146d011 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.streaming.spec.ts @@ -0,0 +1,170 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClientStreaming, + callGeneratedClientStreamingAsync, + createTree, + generateAndRead, + mockJsonlResponse, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - streaming', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + const jsonlSpec: Spec = { + openapi: '3.1.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/stream': { + post: { + operationId: 'streamData', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Req' }, + }, + }, + }, + responses: { + '200': { + description: 'stream', + content: { + 'application/jsonl': { + schema: { $ref: '#/components/schemas/Chunk' }, + itemSchema: { $ref: '#/components/schemas/Chunk' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Req: { + type: 'object', + required: ['prompt'], + properties: { prompt: { type: 'string' } }, + }, + Chunk: { + type: 'object', + required: ['content'], + properties: { content: { type: 'string' } }, + }, + }, + }, + }; + + it('should detect and handle application/jsonl with itemSchema', async () => { + const { types, client } = await generateAndRead(verifier, tree, jsonlSpec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + expect(client).toContain('Iterator[types_gen.Chunk]'); + expect(client).toContain('model_validate_json'); + + const res = await callGeneratedClientStreaming( + verifier, + 'stream_data', + { prompt: 'x' }, + mockJsonlResponse(200, [ + JSON.stringify({ content: 'a' }), + JSON.stringify({ content: 'b' }), + JSON.stringify({ content: 'c' }), + ]), + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual([ + { content: 'a' }, + { content: 'b' }, + { content: 'c' }, + ]); + }); + + it('should handle application/x-ndjson with itemSchema', async () => { + const spec: Spec = { + openapi: '3.1.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/ndjson': { + post: { + operationId: 'ndjson', + responses: { + '200': { + description: 'stream', + content: { + 'application/x-ndjson': { + schema: { $ref: '#/components/schemas/Chunk' }, + itemSchema: { $ref: '#/components/schemas/Chunk' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Chunk: { + type: 'object', + required: ['msg'], + properties: { msg: { type: 'string' } }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + expect(client).toContain('Iterator[types_gen.Chunk]'); + + const res = await callGeneratedClientStreaming( + verifier, + 'ndjson', + {}, + { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + jsonl_lines: [JSON.stringify({ msg: 'x' })], + }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual([{ msg: 'x' }]); + }); + + it('should stream through the async client too', async () => { + await generateAndRead(verifier, tree, jsonlSpec); + const res = await callGeneratedClientStreamingAsync( + verifier, + 'stream_data', + { prompt: 'x' }, + mockJsonlResponse(200, [ + JSON.stringify({ content: 'hello' }), + JSON.stringify({ content: 'world' }), + ]), + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual([{ content: 'hello' }, { content: 'world' }]); + }); + + it('should emit AsyncIterator on the async client', async () => { + const { asyncClient } = await generateAndRead(verifier, tree, jsonlSpec); + expect(asyncClient).toContain('AsyncIterator[types_gen.Chunk]'); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.tags.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.tags.spec.ts new file mode 100644 index 000000000..852ba9859 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.tags.spec.ts @@ -0,0 +1,190 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +describe('openApiPyClientGenerator - tags', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('should group operations by tag into tag namespaces', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/pet': { + post: { + tags: ['pet'], + operationId: 'addPet', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + }, + }, + }, + '/store': { + get: { + tags: ['store'], + operationId: 'storeStatus', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }, + }, + }, + }; + const { types, client } = await generateAndRead(verifier, tree, spec); + expect(types).toMatchSnapshot('types_gen.py'); + expect(client).toMatchSnapshot('client_gen.py'); + expect(client).toMatch(/self\.pet:\s*_PetNamespace/); + expect(client).toMatch(/self\.store:\s*_StoreNamespace/); + expect(client).toContain('def add_pet('); + expect(client).toContain('self._parent._add_pet('); + + // Call through the tag namespace. + const res = await callGeneratedClient( + verifier, + 'pet.add_pet', + { name: 'doggie' }, + { json: { name: 'doggie' } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ name: 'doggie' }); + }); + + it('should handle operations with multiple tags', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/multi': { + get: { + tags: ['alpha', 'beta'], + operationId: 'multi', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + // The operation appears on both namespaces. + expect(client).toContain('self.alpha'); + expect(client).toContain('self.beta'); + + const resA = await callGeneratedClient( + verifier, + 'alpha.multi', + {}, + { json: 'a' }, + ); + expect(resA.ok).toBe(true); + const resB = await callGeneratedClient( + verifier, + 'beta.multi', + {}, + { json: 'a' }, + ); + expect(resB.ok).toBe(true); + }); + + it('should still emit ungrouped methods when an operation has no tags', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/tagged': { + get: { + tags: ['group'], + operationId: 'tagged', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + '/untagged': { + get: { + operationId: 'untagged', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + // Untagged stays on the top level as a public `def untagged`. + expect(client).toMatch(/\n {4}def untagged\(/); + + const resUntagged = await callGeneratedClient( + verifier, + 'untagged', + {}, + { json: 'ok' }, + ); + expect(resUntagged.ok).toBe(true); + const resTagged = await callGeneratedClient( + verifier, + 'group.tagged', + {}, + { json: 'ok' }, + ); + expect(resTagged.ok).toBe(true); + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.ts b/packages/nx-plugin/src/open-api/py-client/generator.ts new file mode 100644 index 000000000..cb5f71d93 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.ts @@ -0,0 +1,97 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { generateFiles, type Tree } from '@nx/devkit'; +import * as path from 'path'; +import { formatFilesInSubtree } from '../../utils/format'; +import { addGeneratorMetricsIfApplicable } from '../../utils/metrics'; +import { getGeneratorInfo, type NxGeneratorInfo } from '../../utils/nx'; +import { type IPyDepVersion, withPyVersions } from '../../utils/versions'; +import { buildOpenApiCodeGenerationData } from '../ts-client/generator'; +import type { CodeGenData } from '../utils/codegen-data/types'; +import type { OpenApiPyClientGeneratorSchema } from './schema'; + +export const OPEN_API_PY_CLIENT_GENERATOR_INFO: NxGeneratorInfo = + getGeneratorInfo(import.meta.filename); + +/** + * Python dependencies required by any project that consumes a client + * generated by `open-api#py-client`. Connection generators that wire this + * generator into a consumer project should add these at the pinned versions + * from `versions.ts`. + */ +export const OPEN_API_PY_CLIENT_PYTHON_DEPS: readonly IPyDepVersion[] = [ + 'httpx', + 'pydantic', +] as const; + +/** Fully-qualified version specifiers, e.g. `['httpx==0.28.1', 'pydantic==2.13.2']`. */ +export const OPEN_API_PY_CLIENT_PYTHON_DEP_SPECS: string[] = withPyVersions([ + ...OPEN_API_PY_CLIENT_PYTHON_DEPS, +]); + +/** + * Generate a Python httpx-based client from an OpenAPI spec. + * + * Emits: + * - types_gen.py — pydantic v2 models + per-op error classes and TypedDicts + * - client_gen.py — sync client (httpx.Client) when clientType includes 'sync' + * - async_client_gen.py — async client (httpx.AsyncClient) when clientType includes 'async' + */ +export const openApiPyClientGenerator = async ( + tree: Tree, + options: OpenApiPyClientGeneratorSchema, +) => { + const data = await buildOpenApiCodeGenerationData( + tree, + options.openApiSpecPath, + ); + const clientType = options.clientType ?? 'both'; + + generateOpenApiPyClient(tree, data, options.outputPath, clientType); + + await addGeneratorMetricsIfApplicable(tree, [ + OPEN_API_PY_CLIENT_GENERATOR_INFO, + ]); + + await formatFilesInSubtree(tree); +}; + +/** + * Generate an OpenAPI Python client in the target directory + */ +export const generateOpenApiPyClient = ( + tree: Tree, + data: CodeGenData, + outputPath: string, + clientType: 'sync' | 'async' | 'both' = 'both', +) => { + const base = { ...data, clientType }; + + generateFiles( + tree, + path.join(import.meta.dirname, 'files', 'shared'), + outputPath, + base, + ); + + if (clientType === 'sync' || clientType === 'both') { + generateFiles( + tree, + path.join(import.meta.dirname, 'files', 'sync'), + outputPath, + base, + ); + } + if (clientType === 'async' || clientType === 'both') { + generateFiles( + tree, + path.join(import.meta.dirname, 'files', 'async'), + outputPath, + base, + ); + } +}; + +export default openApiPyClientGenerator; diff --git a/packages/nx-plugin/src/open-api/py-client/generator.type-safety.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.type-safety.spec.ts new file mode 100644 index 000000000..b0253ab26 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.type-safety.spec.ts @@ -0,0 +1,600 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { promisify } from 'util'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + createTree, + generateAndRead, + outputPath, +} from './generator.utils.spec'; + +const execFileP = promisify(execFile); + +/** + * A spec exercising every feature whose type-safety we care about. The spec + * is deliberately dense so a single pyright invocation can assert full + * parity with the ts-client side. + */ +const parityMatrixSpec: Spec = { + openapi: '3.1.0', + info: { title: 'TypeSafetyApi', version: '1.0.0' }, + paths: { + '/pets': { + post: { + tags: ['pet'], + operationId: 'addPet', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + '404': { + description: 'not found', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/NotFound' }, + }, + }, + }, + '5XX': { + description: 'server error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ServerError' }, + }, + }, + }, + }, + }, + get: { + tags: ['pet'], + operationId: 'listPets', + parameters: [ + { + name: 'status', + in: 'query', + required: true, + explode: true, + schema: { + type: 'array', + items: { + type: 'string', + enum: ['available', 'pending', 'sold'], + }, + }, + }, + { + name: 'limit', + in: 'query', + schema: { type: 'integer' }, + }, + { + name: 'x-api-key', + in: 'header', + required: true, + schema: { type: 'string' }, + }, + { + name: 'session', + in: 'cookie', + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + }, + }, + }, + }, + '/pets/{petId}': { + delete: { + tags: ['pet'], + operationId: 'deletePet', + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + schema: { type: 'integer' }, + }, + ], + responses: { '204': { description: 'No content' } }, + }, + }, + '/pets/batch': { + post: { + tags: ['pet'], + operationId: 'batchCreate', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + }, + }, + }, + }, + '/health': { + get: { + operationId: 'health', + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + '/stream': { + post: { + operationId: 'stream', + responses: { + '200': { + description: 'stream', + content: { + 'application/jsonl': { + schema: { $ref: '#/components/schemas/Chunk' }, + itemSchema: { $ref: '#/components/schemas/Chunk' }, + }, + }, + }, + }, + }, + }, + '/events': { + post: { + operationId: 'createEvent', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Event' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Event' }, + }, + }, + }, + }, + }, + }, + '/inventory': { + get: { + operationId: 'getInventory', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + }, + '/union': { + post: { + operationId: 'union', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrap' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrap' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['name'], + properties: { + id: { type: 'integer', readOnly: true, description: 'Opaque id' }, + name: { type: 'string' }, + status: { type: 'string', enum: ['available', 'pending', 'sold'] }, + tags: { type: 'array', items: { type: 'string' } }, + owner: { $ref: '#/components/schemas/Owner' }, + }, + }, + Owner: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }, + NotFound: { + type: 'object', + required: ['detail'], + properties: { detail: { type: 'string' } }, + }, + ServerError: { + type: 'object', + required: ['code'], + properties: { code: { type: 'integer' } }, + }, + Chunk: { + type: 'object', + required: ['index', 'message'], + properties: { + index: { type: 'integer' }, + message: { type: 'string' }, + }, + }, + Event: { + type: 'object', + required: ['day', 'moment'], + properties: { + day: { type: 'string', format: 'date' }, + moment: { type: 'string', format: 'date-time' }, + }, + }, + Wrap: { + type: 'object', + required: ['value'], + properties: { + value: { anyOf: [{ type: 'string' }, { type: 'integer' }] }, + }, + }, + }, + }, +}; + +/** Probe file mixing valid usage (must produce no errors) with deliberate errors. */ +const probeUsage = `"""Type-safety probe against the generated client.""" + +from __future__ import annotations + +import datetime + +from demo_client import types_gen as t +from demo_client.client_gen import ( + AddPetApiError, + ApiError, + TypeSafetyApi, + TypeSafetyApiConfig, +) + + +def valid_usage(api: TypeSafetyApi) -> None: + _c: TypeSafetyApiConfig = TypeSafetyApiConfig(url="https://example.com") + + p: t.Pet = api.pet.add_pet(name="rex") + _id: int | None = p.id + _name: str = p.name + + _pets: list[t.Pet] = api.pet.list_pets( + status=["available", "pending"], + x_api_key="k", + limit=10, + ) + + api.pet.delete_pet(pet_id=42) + + _ok: str = api.health() + + _new: list[t.Pet] = api.pet.batch_create([t.Pet(name="a"), t.Pet(name="b")]) + + ev: t.Event = api.create_event( + day=datetime.date(2026, 4, 18), + moment=datetime.datetime(2026, 4, 18, 12, 0), + ) + _d: datetime.date | None = ev.day + + _inv: dict[str, int] = api.get_inventory() + + _u: t.Wrap = api.union(value="hi") + _u2: t.Wrap = api.union(value=7) + + for chunk in api.stream(): + _i: int = chunk.index + _m: str = chunk.message + + try: + api.pet.add_pet(name="rex") + except AddPetApiError as e: + _s: int = e.status + if isinstance(e.error, t.AddPet404Error): + _detail: str = e.error.error.detail + elif isinstance(e.error, t.AddPet5XXError): + _code: int = e.error.error.code + + try: + api.pet.add_pet(name="r") + except ApiError: + pass + + +def expected_errors(api: TypeSafetyApi) -> None: + # E1 + api.pet.add_pet() + # E2 + api.pet.add_pet(name=42) + # E3 + api.pet.add_pet(name="r", nom="r") + # E4 + api.pet.list_pets(status=["unknown"], x_api_key="k") + # E5 + _bad: dict[str, int] = api.pet.add_pet(name="rex") + # E6 + api.unknown_method() + # E7 + api.pet.delete_pet(pet_id="forty-two") + # E8 + api.create_event(day="2026-04-18", moment=datetime.datetime.now()) + # E9 + api.union(value=[1, 2]) + # E10 + try: + api.pet.add_pet(name="r") + except AddPetApiError as e: + _x: str = e.error.detail + # E11 + api.pet.batch_create([{"name": "a"}]) + # E12 + api.pet.delete_pet() + # E13 + api.pet.list_pets(status=["available"], x_api_key="k", limit="ten") + # E14 + _chunks: list[str] = list(api.stream()) + # E15 + TypeSafetyApiConfig() +`; + +const expectedDiagnostics = [ + { + label: 'E1 missing required kwarg', + pattern: /Argument missing for parameter "name"/i, + }, + { + label: 'E2 wrong kwarg type', + pattern: /"Literal\[42\]" is not assignable to "str"/, + }, + { label: 'E3 unknown kwarg', pattern: /No parameter named "nom"/ }, + { + label: 'E4 array-literal enum unknown value', + pattern: /"Literal\['unknown'\]"|"Literal\["unknown"\]"/, + }, + { + label: 'E5 return-type mismatch', + pattern: /"Pet" is not assignable to "dict\[str, int\]"/, + }, + { + label: 'E6 unknown method', + pattern: /Cannot access attribute "unknown_method"/, + }, + { + label: 'E7 wrong path-param type', + pattern: /"Literal\['forty-two'\]".*not assignable.*"int"/s, + }, + { + label: 'E8 date field wrong type', + pattern: /"Literal\['2026-04-18'\]"|"str".*not assignable.*"date"/s, + }, + { + label: 'E9 union rejects non-member type', + pattern: /"list\[int\]" cannot be assigned to parameter "value"/, + }, + { + label: 'E10 unnarrowed error access', + pattern: /Cannot access attribute "detail"/, + }, + { + label: 'E11 batch body wrong type', + pattern: /"list\[dict\[str, str\]\]".*"list\[Pet\]"/s, + }, + { + label: 'E12 missing path param', + pattern: /Argument missing for parameter "pet_id"/, + }, + { + label: 'E13 wrong query param type', + pattern: /"Literal\['ten'\]".*not assignable.*"int"/s, + }, + { + label: 'E14 streaming return mismatch', + pattern: /"list\[Chunk\]" is not assignable to "list\[str\]"/, + }, + { + label: 'E15 Config url required', + pattern: /Argument missing for parameter "url"/, + }, +]; + +describe('openApiPyClientGenerator - type-safety parity matrix', () => { + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + it('matches ts-client type safety under pyright across 15 diagnostic signals', async () => { + const tree = createTree(); + const { types, client } = await generateAndRead( + verifier, + tree, + parityMatrixSpec, + ); + // Guard: without the enum-in-collection fix the list[Literal[...]] is + // just list[str], which would make E4 silently pass. + expect(client).toMatch(/Literal\["available"/); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'py-parity-')); + try { + const pkgDir = path.join(tmp, 'demo_client'); + fs.mkdirSync(pkgDir, { recursive: true }); + for (const f of [ + '__init__.py', + 'types_gen.py', + 'client_gen.py', + 'async_client_gen.py', + ]) { + fs.writeFileSync( + path.join(pkgDir, f), + tree.read(`${outputPath}/${f}`, 'utf-8') ?? '', + ); + } + fs.writeFileSync(path.join(tmp, 'usage.py'), probeUsage); + fs.writeFileSync( + path.join(tmp, 'pyrightconfig.json'), + JSON.stringify({ + include: ['usage.py'], + extraPaths: ['.'], + typeCheckingMode: 'standard', + }), + ); + + // Run pyright through `uv run --with pyright` so the test does not + // depend on any pre-provisioned venv. Pyright exits non-zero when + // there are diagnostics, so we ignore the exit code and parse the + // JSON output. + let stdout = ''; + try { + const result = await execFileP( + 'uv', + [ + 'run', + '--with', + 'pyright==1.1.405', + '--with', + 'pydantic', + '--with', + 'httpx', + 'pyright', + '--outputjson', + 'usage.py', + ], + { cwd: tmp, maxBuffer: 16 * 1024 * 1024 }, + ); + stdout = result.stdout; + } catch (err: any) { + stdout = err.stdout ?? ''; + } + + if (!stdout) { + throw new Error( + 'pyright produced no output — is uv available on PATH?', + ); + } + const report = JSON.parse(stdout); + const diagnostics = (report.generalDiagnostics ?? []) as Array<{ + severity: string; + message: string; + range: { start: { line: number } }; + }>; + const errorMessages = diagnostics + .filter((d) => d.severity === 'error') + .map((d) => d.message); + + for (const { label, pattern } of expectedDiagnostics) { + const matched = errorMessages.some((m) => pattern.test(m)); + if (!matched) { + throw new Error( + `Missing type-safety check [${label}].\nAll errors:\n` + + errorMessages + .map((m) => ` - ${m.replace(/\n/g, ' ')}`) + .join('\n'), + ); + } + } + + const validStart = probeUsage + .split('\n') + .findIndex((line) => line.startsWith('def valid_usage')); + const validEnd = probeUsage + .split('\n') + .findIndex((line) => line.startsWith('def expected_errors')); + const falsePositives = diagnostics.filter( + (d) => + d.severity === 'error' && + d.range.start.line >= validStart && + d.range.start.line < validEnd, + ); + if (falsePositives.length > 0) { + throw new Error( + `valid_usage block produced ${falsePositives.length} unexpected errors:\n` + + falsePositives + .map( + (d) => + ` line ${d.range.start.line + 1}: ${d.message.replace(/\n/g, ' ')}`, + ) + .join('\n'), + ); + } + + expect(types).toBeDefined(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.utils.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.utils.spec.ts new file mode 100644 index 000000000..b530e7a25 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.utils.spec.ts @@ -0,0 +1,173 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { openApiPyClientGenerator } from './generator'; +import { + MockEntry, + PythonVerifier, + InvokeResult, + MockResponseSpec, +} from '../../utils/test/py.spec'; +import { OpenApiPyClientGeneratorSchema } from './schema'; +import { Spec } from '../utils/types'; +import { createTreeUsingTsSolutionSetup } from '../../utils/test'; + +/** + * Base URL the generated client points at inside tests — the mock transport + * doesn't actually dial anywhere so this is decorative. + */ +export const baseUrl = 'https://example.com'; + +/** + * Default output directory used by every test. Matches the ts-client layout + * (`src/generated/*`) so snapshots read consistently. + */ +export const outputPath = 'src/generated'; + +/** + * Paths to the files emitted by the generator for the default `both` + * `clientType`. Pass to `expectPythonToCompile`. + */ +export const generatedFilesForSync = [ + `${outputPath}/__init__.py`, + `${outputPath}/types_gen.py`, + `${outputPath}/client_gen.py`, +]; + +export const generatedFilesForAsync = [ + `${outputPath}/__init__.py`, + `${outputPath}/types_gen.py`, + `${outputPath}/async_client_gen.py`, +]; + +/** Files emitted for the default `clientType: 'both'`. */ +export const generatedFilesForBoth = [ + `${outputPath}/__init__.py`, + `${outputPath}/types_gen.py`, + `${outputPath}/client_gen.py`, + `${outputPath}/async_client_gen.py`, +]; + +/** + * Generate and compile-check a client for the given spec. Reads the two + * main artefact files from the tree and returns their text so the caller + * can snapshot them. + */ +export const generateAndRead = async ( + verifier: PythonVerifier, + tree: Tree, + spec: Spec, + options: Partial = {}, +): Promise<{ types: string; client: string; asyncClient: string }> => { + tree.write('openapi.json', JSON.stringify(spec)); + await openApiPyClientGenerator(tree, { + openApiSpecPath: 'openapi.json', + outputPath, + ...options, + }); + const clientType = options.clientType ?? 'both'; + const paths = + clientType === 'sync' + ? generatedFilesForSync + : clientType === 'async' + ? generatedFilesForAsync + : generatedFilesForBoth; + await verifier.expectPythonToCompile(tree, paths, outputPath); + return { + types: tree.read(`${outputPath}/types_gen.py`, 'utf-8') ?? '', + client: tree.read(`${outputPath}/client_gen.py`, 'utf-8') ?? '', + asyncClient: tree.read(`${outputPath}/async_client_gen.py`, 'utf-8') ?? '', + }; +}; + +/** + * Call a method on the synchronously-generated client, mirroring + * `callGeneratedClient` on the ts-client side. The generated code talks to + * a `httpx.MockTransport` in the worker which replays the given mock entries. + */ +export const callGeneratedClient = async ( + verifier: PythonVerifier, + op: string, + kwargs: Record, + mock: MockResponseSpec | MockEntry[], + args: unknown[] = [], +): Promise => + verifier.invoke({ + module: 'sync', + method: op, + args, + kwargs, + mock: normaliseMock(mock), + }); + +/** Async variant of `callGeneratedClient`. */ +export const callGeneratedClientAsync = async ( + verifier: PythonVerifier, + op: string, + kwargs: Record, + mock: MockResponseSpec | MockEntry[], + args: unknown[] = [], +): Promise => + verifier.invoke({ + module: 'async', + method: op, + args, + kwargs, + mock: normaliseMock(mock), + }); + +/** Call a streaming generator method and collect all yielded items. */ +export const callGeneratedClientStreaming = async ( + verifier: PythonVerifier, + op: string, + kwargs: Record, + mock: MockResponseSpec | MockEntry[], +): Promise => + verifier.invoke({ + module: 'sync', + method: op, + stream: true, + kwargs, + mock: normaliseMock(mock), + }); + +export const callGeneratedClientStreamingAsync = async ( + verifier: PythonVerifier, + op: string, + kwargs: Record, + mock: MockResponseSpec | MockEntry[], +): Promise => + verifier.invoke({ + module: 'async', + method: op, + stream: true, + kwargs, + mock: normaliseMock(mock), + }); + +/** Accept either a single response spec (catch-all) or an array of mocks. */ +const normaliseMock = (mock: MockResponseSpec | MockEntry[]): MockEntry[] => + Array.isArray(mock) ? mock : [{ response: mock }]; + +/** Build a mock entry that returns a jsonl body. */ +export const mockJsonlResponse = ( + status: number, + jsonlLines: string[], +): MockResponseSpec => ({ + status, + jsonl_lines: jsonlLines, +}); + +/** + * Create a fresh empty nx workspace tree. Re-exported so topic files can + * import one thing from `generator.utils.spec`. + */ +export const createTree = (): Tree => createTreeUsingTsSolutionSetup(); + +describe('openapi py-client test utils', () => { + it('has a test so vitest picks this file up as a spec', () => { + // Intentionally empty — utilities only. + }); +}); diff --git a/packages/nx-plugin/src/open-api/py-client/schema.d.ts b/packages/nx-plugin/src/open-api/py-client/schema.d.ts new file mode 100644 index 000000000..b025a0ad1 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/schema.d.ts @@ -0,0 +1,12 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type OpenApiPyClientClientType = 'sync' | 'async' | 'both'; + +export interface OpenApiPyClientGeneratorSchema { + openApiSpecPath: string; + outputPath: string; + clientType?: OpenApiPyClientClientType; +} diff --git a/packages/nx-plugin/src/open-api/py-client/schema.json b/packages/nx-plugin/src/open-api/py-client/schema.json new file mode 100644 index 000000000..387d8ce25 --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "OpenApiPyClient", + "title": "OpenAPI Python Client", + "description": "Generate a Python httpx client from an OpenAPI specification", + "type": "object", + "properties": { + "openApiSpecPath": { + "type": "string", + "description": "Path to the OpenAPI specification relative to the monorepo root", + "x-priority": "important" + }, + "outputPath": { + "type": "string", + "description": "Path to the directory in which to generate the client relative to the monorepo root", + "x-priority": "important" + }, + "clientType": { + "type": "string", + "description": "Which client flavours to emit. 'sync' emits client.gen.py using httpx.Client, 'async' emits async_client.gen.py using httpx.AsyncClient, 'both' emits both.", + "enum": ["sync", "async", "both"], + "default": "both", + "x-priority": "important" + } + }, + "required": ["openApiSpecPath", "outputPath"] +} diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data.ts b/packages/nx-plugin/src/open-api/utils/codegen-data.ts index e659b5b20..e3c0ed92c 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data.ts @@ -15,6 +15,9 @@ import { upperFirst, } from '../../utils/names'; import { + qualifyPythonType, + toPythonAnnotation, + toPythonClassName, toPythonName, toPythonType, toTypeScriptModelName, @@ -28,12 +31,15 @@ import { type CollectionFormat, createModel, DEFAULT_SERVICE_NAME, + type ErrorShape, indexModelsByName, type Model, type ModelsByName, type Operation, type PatternPropertyModel, PRIMITIVE_TYPES, + type RequestInput, + type RequestShape, type Service, STREAMING_CONTENT_TYPES, VENDOR_EXTENSIONS, @@ -105,7 +111,12 @@ export const buildOpenApiCodeGenData = (inSpec: Spec): CodeGenData => { const { operationsByTag, untaggedOperations } = groupOperationsByTag(allOperations); - return { + annotateAllOfFlattening(data.models); + for (const op of allOperations) { + annotateRequestAndErrorShapes(op, modelsByName); + } + + const result: CodeGenData = { ...data, operationsByTag, untaggedOperations, @@ -114,6 +125,327 @@ export const buildOpenApiCodeGenData = (inSpec: Spec): CodeGenData => { vendorExtensions: vendorExtensionsOf(spec), className: toClassName(spec.info.title), }; + + // Final pass to derive python-specific fields now that all links and + // composite relationships are resolved. Produced once so every downstream + // language generator consumes a single prepared object. + annotatePythonData(result, modelsByName); + + return result; +}; + +/** + * For each `all-of` composite: precompute the flat property list templates + * that flatten composition will emit (`effectiveProperties`), and mark + * hoisted (normaliser-synthesised) components the parent inlines + * (`isInlinedByAllOf`) so templates can skip emitting them separately. + */ +const annotateAllOfFlattening = (models: Model[]): void => { + for (const model of models) { + if (model.export !== 'all-of') continue; + const flattened: Model[] = []; + const seen = new Set(); + for (const composed of model.composedModels ?? []) { + if (composed.vendorExtensions?.['x-aws-nx-hoisted']) { + composed.isInlinedByAllOf = model.name; + } + for (const prop of composed.properties ?? []) { + if (!prop.name || seen.has(prop.name)) continue; + seen.add(prop.name); + flattened.push(prop); + } + } + model.effectiveProperties = flattened; + } +}; + +/** + * The object-shaped properties a body model exposes for flattening, walking + * allOf composition via `effectiveProperties` when present. + */ +const flattenableBodyProperties = (bodyModel: Model | undefined): Model[] => { + if (!bodyModel) return []; + if (bodyModel.export === 'interface' && !bodyModel.hasAdditionalProperties) { + return bodyModel.properties ?? []; + } + if (bodyModel.export === 'all-of') { + return bodyModel.effectiveProperties ?? []; + } + return []; +}; + +/** + * Whether a body's fields can be flattened into the call signature without + * clashing with path/query/header/cookie parameters. Only object-shaped + * bodies (references to an interface or allOf) are eligible; discriminated + * bases stay whole so marshalling can dispatch on the discriminator. + */ +const canFlattenBodyIntoRequest = ( + op: Operation, + bodyParam: Model | undefined, + bodyModel: Model | undefined, +): boolean => { + if (!bodyParam || bodyParam.export !== 'reference') return false; + if (bodyModel?.discriminator) return false; + const props = flattenableBodyProperties(bodyModel); + if (props.length === 0) return false; + const otherNames = new Set( + (op.parameters ?? []).filter((p) => p.in !== 'body').map((p) => p.name), + ); + return props.every((prop) => !otherNames.has(prop.name)); +}; + +/** + * Expand a status-code range spec ("2XX", "5XX", etc.) to the concrete list + * of integer codes it covers. Returns `undefined` for `'default'` (any + * non-matched code) and `[code]` for a literal numeric code. + */ +const statusCodesFor = (code: number | string): number[] | undefined => { + if (typeof code === 'number') return [code]; + if (/^\dXX$/.test(code)) { + const base = Number(code.charAt(0)) * 100; + return Array.from({ length: 100 }, (_, i) => base + i); + } + return undefined; +}; + +/** + * Build a language-agnostic description of an operation's inputs. Each input + * carries its source (where in the HTTP request it is placed) plus the + * underlying model — language templates decide how to translate that into + * their idiomatic call signature (kwargs, a wrapper interface, etc). + */ +const buildRequestShape = ( + op: Operation, + modelsByName: ModelsByName, +): RequestShape => { + const nonBody = (op.parameters ?? []).filter((p) => p.in !== 'body'); + const inputs: RequestInput[] = nonBody.map((p) => ({ + source: { + kind: p.in as 'path' | 'query' | 'header' | 'cookie', + wireName: p.prop ?? p.name, + ...(p.collectionFormat ? { collectionFormat: p.collectionFormat } : {}), + }, + model: p, + fromFlattenedBody: false, + isRequired: !!p.isRequired, + isNullable: !!p.isNullable, + description: p.description, + specName: p.prop ?? p.name, + })); + + const shape: RequestShape = { inputs, isSingleBodyInput: false }; + const bodyParam = op.parametersBody ?? undefined; + if (bodyParam) { + const mediaType = + bodyParam.mediaType ?? + (bodyParam.mediaTypes ? bodyParam.mediaTypes[0] : undefined) ?? + undefined; + const bodyModel = modelsByName[bodyParam.type]; + if (canFlattenBodyIntoRequest(op, bodyParam, bodyModel)) { + for (const prop of flattenableBodyProperties(bodyModel)) { + inputs.push({ + source: { kind: 'body-field', fieldName: prop.name }, + model: prop, + fromFlattenedBody: true, + isRequired: !!prop.isRequired, + isNullable: !!prop.isNullable, + description: prop.description, + specName: prop.name, + }); + } + shape.bodyFromFields = { model: bodyModel, mediaType }; + } else { + inputs.push({ + source: { kind: 'body', wireName: 'body' }, + model: bodyParam, + fromFlattenedBody: false, + isRequired: !!bodyParam.isRequired, + isNullable: !!bodyParam.isNullable, + description: bodyParam.description, + specName: 'body', + }); + shape.bodyAsSingleInput = { model: bodyParam, mediaType }; + if (nonBody.length === 0) shape.isSingleBodyInput = true; + } + } + return shape; +}; + +/** + * Build a language-agnostic error taxonomy for the operation: one entry per + * non-success response bucket. Language templates turn these into typed + * exception / error union shapes as appropriate. + */ +const buildErrorShape = (op: Operation): ErrorShape => ({ + entries: (op.responses ?? []) + .filter((r) => r.code !== op.result?.code) + .map((resp) => ({ + code: resp.code!, + statusCodes: statusCodesFor(resp.code!), + responseModel: resp, + })), +}); + +/** + * Annotate a typed entry (response, parameter, result) with whether the type + * it references resolves to a module-level alias — a collection + * (`list[...]` / `dict[str, ...]`), union or literal — rather than a class. + * Python templates read `referencedCollectionKind` to pick pydantic's + * `TypeAdapter(X)` over `X.model_validate(...)`. + */ +const annotateReferencedCollectionKind = ( + entry: Model | undefined, + modelsByName: ModelsByName, +): void => { + if (!entry?.type) return; + const referenced = modelsByName[entry.type]; + if (!referenced) return; + if (referenced.export === 'dictionary') { + entry.referencedCollectionKind = 'dictionary'; + } else if (referenced.export === 'array') { + entry.referencedCollectionKind = 'array'; + } else if ( + referenced.export === 'one-of' || + referenced.export === 'any-of' || + referenced.export === 'enum' + ) { + entry.referencedCollectionKind = 'alias'; + } +}; + +/** + * Attach the request shape, error shape and referenced-collection-kind + * annotations to an operation. + */ +const annotateRequestAndErrorShapes = ( + op: Operation, + modelsByName: ModelsByName, +): void => { + for (const parameter of op.parameters ?? []) { + annotateReferencedCollectionKind(parameter, modelsByName); + } + annotateReferencedCollectionKind(op.parametersBody ?? undefined, modelsByName); + for (const response of op.responses ?? []) { + annotateReferencedCollectionKind(response, modelsByName); + } + annotateReferencedCollectionKind(op.result, modelsByName); + + op.requestShape = buildRequestShape(op, modelsByName); + op.errorShape = buildErrorShape(op); +}; + +const TYPES_GEN_PREFIX = 'types_gen.'; + +/** + * Attach `pythonClientType` to a typed entry — the bare `pythonType` + * qualified with `types_gen.` so client templates can reference model + * classes through a single import. + */ +const annotatePythonClientType = (entry: Model | undefined): void => { + if (!entry) return; + entry.pythonClientType = qualifyPythonType( + entry.pythonType || entry.type, + TYPES_GEN_PREFIX, + ); +}; + +/** + * Final pass over all models + operation payloads to re-derive python type + * annotations after links/composites are resolved, and to add the + * python-specific annotations the py-client templates consume: + * - `pythonType` / `pythonAnnotation` (refreshed — necessary for collection + * aliases whose element type wasn't available first time through) + * - `pythonClassName` / `pythonClientType` + * - `requestShape.inputs[*].pythonName` / `.pythonAnnotation` (kwargs) + * - `errorShape.exceptionClassName` / `.unionTypeName` / per-entry names + * + * Runs for every spec — the resulting fields are only read by the py-client + * templates, so there's no cost to TypeScript consumers. + */ +const annotatePythonData = ( + data: CodeGenData, + modelsByName: ModelsByName, +): void => { + for (const model of data.models) { + model.pythonClassName = toPythonClassName(model.name); + model.pythonType = toPythonType(model); + model.pythonAnnotation = toPythonAnnotation(model); + annotatePythonClientType(model); + for (const prop of model.properties ?? []) { + prop.pythonType = toPythonType(prop); + prop.pythonAnnotation = toPythonAnnotation(prop); + annotatePythonClientType(prop); + } + for (const prop of model.effectiveProperties ?? []) { + prop.pythonType = toPythonType(prop); + prop.pythonAnnotation = toPythonAnnotation(prop); + annotatePythonClientType(prop); + } + annotatePythonClientType(model.additionalPropertiesModel); + } + + for (const op of data.allOperations) { + for (const parameter of op.parameters ?? []) { + parameter.pythonType = toPythonType(parameter); + parameter.pythonAnnotation = toPythonAnnotation(parameter); + annotatePythonClientType(parameter); + } + annotatePythonClientType(op.parametersBody ?? undefined); + annotatePythonClientType(op.result); + for (const response of op.responses ?? []) { + annotatePythonClientType(response); + annotatePythonClientType(response.itemSchemaModel); + } + + const requestShape = op.requestShape; + if (requestShape) { + const seenNames = new Set(); + for (const input of requestShape.inputs) { + const rawName = input.model.pythonName || input.specName; + let pythonName = toPythonName('property', rawName); + if (seenNames.has(pythonName)) { + pythonName = `${pythonName}_${input.source.kind.replace('-', '_')}`; + } + seenNames.add(pythonName); + const baseType = qualifyPythonType( + input.model.pythonType || input.model.type, + TYPES_GEN_PREFIX, + ); + input.pythonName = pythonName; + input.pythonAnnotation = + input.isRequired && !input.isNullable + ? baseType + : `Optional[${baseType}]`; + } + // Required-first so the generated keyword-only signature reads naturally. + requestShape.inputs.sort( + (a, b) => Number(b.isRequired) - Number(a.isRequired), + ); + } + + const errorShape = op.errorShape; + if (errorShape) { + const opPascal = op.operationIdPascalCase!; + errorShape.exceptionClassName = `${opPascal}ApiError`; + errorShape.unionTypeName = + errorShape.entries.length > 0 ? `${opPascal}Error` : 'Never'; + for (const entry of errorShape.entries) { + const suffix = + entry.code === 'default' + ? 'Default' + : String(entry.code).toUpperCase(); + entry.className = `${opPascal}${suffix}Error`; + entry.isExactCode = typeof entry.code === 'number'; + // Only collapse to a Literal for exact numeric codes — ranges like + // 5XX expand to 100 codes, which produce a massive, unreadable + // Literal and don't narrow `response.status_code` usefully anyway. + entry.statusAnnotation = entry.isExactCode + ? `Literal[${entry.code}]` + : 'int'; + } + } + } }; /** diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.spec.ts b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.spec.ts index 9b93bece7..1450a48d9 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.spec.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.spec.ts @@ -3,7 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect, it } from 'vitest'; -import { toPythonName, toPythonType, toTypeScriptType } from './languages'; +import { + qualifyPythonType, + toPythonAnnotation, + toPythonName, + toPythonType, + toTypeScriptType, +} from './languages'; import type { Model } from './types'; const createModel = (partial: Partial): Model => ({ @@ -93,7 +99,7 @@ describe('languages', () => { expect(toPythonType(stringModel)).toBe('str'); expect(toPythonType(boolModel)).toBe('bool'); - expect(toPythonType(anyModel)).toBe('object'); + expect(toPythonType(anyModel)).toBe('Any'); }); it('should handle date formats', () => { @@ -108,8 +114,8 @@ describe('languages', () => { export: 'generic', }); - expect(toPythonType(dateModel)).toBe('date'); - expect(toPythonType(dateTimeModel)).toBe('datetime'); + expect(toPythonType(dateModel)).toBe('datetime.date'); + expect(toPythonType(dateTimeModel)).toBe('datetime.datetime'); }); it('should handle number types', () => { @@ -140,7 +146,7 @@ describe('languages', () => { export: 'array', link: createModel({ type: 'string', export: 'generic' }), }); - expect(toPythonType(model)).toBe('List[str]'); + expect(toPythonType(model)).toBe('list[str]'); }); it('should handle dictionary types', () => { @@ -149,7 +155,80 @@ describe('languages', () => { export: 'dictionary', link: createModel({ type: 'string', export: 'generic' }), }); - expect(toPythonType(model)).toBe('Dict[str, str]'); + expect(toPythonType(model)).toBe('dict[str, str]'); + }); + + it('should render enums as Literal', () => { + const model = createModel({ + type: 'string', + export: 'enum', + enum: [{ value: 'a' }, { value: 'b' }], + }); + expect(toPythonType(model)).toBe('Literal["a", "b"]'); + }); + + it('should escape model names that shadow generated imports', () => { + expect( + toPythonType(createModel({ type: 'Field', export: 'reference' })), + ).toBe('_Field'); + expect( + toPythonType(createModel({ type: 'Error', export: 'reference' })), + ).toBe('_Error'); + }); + }); + + describe('toPythonAnnotation', () => { + it('returns bare types for built-ins', () => { + expect( + toPythonAnnotation(createModel({ type: 'string', export: 'generic' })), + ).toBe('str'); + expect( + toPythonAnnotation( + createModel({ type: 'integer', export: 'generic' }), + ), + ).toBe('int'); + }); + + it('forward-refs user-defined model names', () => { + expect( + toPythonAnnotation(createModel({ type: 'Pet', export: 'reference' })), + ).toBe('"Pet"'); + }); + + it('forward-refs nested model references in collections', () => { + const listOfPets = createModel({ + type: 'Pet', + export: 'array', + link: createModel({ type: 'Pet', export: 'reference' }), + }); + expect(toPythonAnnotation(listOfPets)).toBe('list["Pet"]'); + + const dictOfPets = createModel({ + type: 'Pet', + export: 'dictionary', + link: createModel({ type: 'Pet', export: 'reference' }), + }); + expect(toPythonAnnotation(dictOfPets)).toBe('dict[str, "Pet"]'); + }); + }); + + describe('qualifyPythonType', () => { + it('prefixes user-defined names', () => { + expect(qualifyPythonType('Pet', 'types_gen.')).toBe('types_gen.Pet'); + expect(qualifyPythonType('list[Pet]', 'types_gen.')).toBe( + 'list[types_gen.Pet]', + ); + expect(qualifyPythonType('dict[str, Pet]', 'types_gen.')).toBe( + 'dict[str, types_gen.Pet]', + ); + }); + + it('leaves built-ins and Literals untouched', () => { + expect(qualifyPythonType('str', 'types_gen.')).toBe('str'); + expect(qualifyPythonType('list[int]', 'types_gen.')).toBe('list[int]'); + expect(qualifyPythonType('Literal["a"]', 'types_gen.')).toBe( + 'Literal["a"]', + ); }); }); diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts index 041740435..05b668198 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts @@ -99,15 +99,93 @@ export const toTypeScriptModelName = (name: string): string => { : candidateName; }; +/** Python types that are built-ins and do not need forward-ref quoting. */ +const PYTHON_BUILTIN_TYPES = new Set([ + 'str', + 'int', + 'float', + 'bool', + 'bytes', + 'None', + 'Any', + 'datetime.date', + 'datetime.datetime', +]); + +/** + * Names that the generated `types_gen.py` and client modules import or define + * at module scope. A user-defined schema named `Field`, `Optional`, etc. + * would shadow these imports and either break forward-ref resolution + * (`Optional["Field"]` resolves to `pydantic.Field` without escaping) or + * silently produce invalid runtime types. + * + * Keep this aligned with the imports at the top of: + * - open-api/py-client/files/shared/types_gen.py.template + * - open-api/py-client/files/sync/client_gen.py.template + * - open-api/py-client/files/async/async_client_gen.py.template + */ +const PYTHON_RESERVED_MODEL_NAMES = new Set([ + // typing module + 'Any', + 'Literal', + 'Never', + 'Optional', + 'TypedDict', + 'Union', + // pydantic + 'BaseModel', + 'ConfigDict', + 'Field', + 'TypeAdapter', + // stdlib modules referenced in templates + 'Iterator', + 'AsyncIterator', + // typing/python builtins that would also shadow primitives + 'None', + 'True', + 'False', + 'Type', + // namespace import in client_gen.py — never let a user model collide + 'types_gen', + // base exception we emit + 'ApiError', +]); + +/** + * Return the Python class name for a model. Starts from the TypeScript + * escape (which already handles TS-reserved names like `Error` → `_Error`) + * and additionally escapes names that would shadow imports in the generated + * Python files. + */ +export const toPythonClassName = (name: string): string => { + const tsName = toTypeScriptModelName(name); + return PYTHON_RESERVED_MODEL_NAMES.has(tsName) ? `_${tsName}` : tsName; +}; + +/** + * Returns true if the given python type name is a built-in (not a user-defined + * model). + */ +export const isPythonBuiltin = (type: string): boolean => { + if (!type) return true; + if (PYTHON_BUILTIN_TYPES.has(type)) return true; + if (type.startsWith('list[') || type.startsWith('dict[')) return true; + if (type.startsWith('Optional[') || type.startsWith('Union[')) return true; + if (type.startsWith('Literal[')) return true; + return false; +}; + const toPythonPrimitive = (property: Model): string => { if (property.type === 'string' && property.format === 'date') { - return 'date'; + return 'datetime.date'; } else if (property.type === 'string' && property.format === 'date-time') { - return 'datetime'; - } else if (property.type === 'any') { - return 'object'; + return 'datetime.datetime'; + } else if (property.type === 'any' || property.type === 'unknown') { + return 'Any'; } else if (property.type === 'binary') { - return 'bytearray'; + return 'bytes'; + } else if (property.type === 'null' || property.type === 'void') { + return 'None'; } else if (property.type === 'number') { if (property.openapiType === 'integer') { return 'int'; @@ -122,43 +200,177 @@ const toPythonPrimitive = (property: Model): string => { default: return 'float'; } + } else if (property.type === 'integer') { + return 'int'; } else if (property.type === 'boolean') { return 'bool'; } else if (property.type === 'string') { return 'str'; } - return property.type; + // Fall-through is a user-defined model reference. The py-client emits + // classes using `pythonClassName`, so references use the same escaped form. + return toPythonClassName(property.type); +}; + +/** Render an enum's values as a Python `Literal[...]` expression. */ +const toPythonEnumLiteral = (property: Model): string => { + const members = property.enum; + if (!members || members.length === 0) return toPythonPrimitive(property); + const rendered = members + .map((m) => { + if (typeof m.value === 'string') + return `"${m.value.replace(/"/g, '\\"')}"`; + if (m.value === null) return 'None'; + return String(m.value); + }) + .join(', '); + return `Literal[${rendered}]`; }; +/** + * A discriminated subtype's discriminator property renders as its literal + * tag(s). `discriminatorValue` is stored as rendered TypeScript literals + * (e.g. `"cat" | "kitten"`); translate to `Literal["cat", "kitten"]`. + */ +const toPythonDiscriminatorLiteral = (discriminatorValue: string): string => + `Literal[${discriminatorValue.split(' | ').join(', ')}]`; + +/** + * Resolve the element type of a collection model. When the element is an + * enum (anonymous or referenced) the type is rendered as a `Literal[...]` + * so callers can't pass arbitrary values. + */ +const collectionElementType = (property: Model, link: Model | undefined) => { + if (link && link.export === 'enum') { + return toPythonEnumLiteral(link); + } + if (link) { + return toPythonType(link); + } + // When no link is available but the collection itself carries enum members + // (inline enum array/dict), render them as Literal too. + if (property.isEnum && property.enum.length > 0) { + return toPythonEnumLiteral(property); + } + return toPythonPrimitive(property); +}; + +/** + * Return the idiomatic Python type for a given property. + * + * Uses PEP-585 lower-case generics (`list[...]`, `dict[str, ...]`), fully- + * qualified stdlib types (`datetime.date`, `datetime.datetime`), `bytes` for + * binary payloads, and `Literal[...]` for enums. Model references are + * returned as bare class names — callers that emit the type inside a class + * body (where the class isn't yet defined) should use `toPythonAnnotation` + * instead to get forward-ref quoting. + */ export const toPythonType = (property: Model): string => { - const link = property.link; - const valueType = () => - link && link.export !== 'enum' ? toPythonType(link) : property.type; + if (property.discriminatorValue) { + return toPythonDiscriminatorLiteral(property.discriminatorValue); + } + const link = property.link ?? undefined; switch (property.export) { + case 'enum': + return toPythonEnumLiteral(property); case 'generic': case 'reference': return toPythonPrimitive(property); case 'array': - return `List[${valueType()}]`; + return `list[${collectionElementType(property, link)}]`; case 'tuple': - return `Tuple[${property.properties + return `tuple[${property.properties .map((member) => toPythonType(member)) .join(', ')}]`; case 'dictionary': - return `Dict[str, ${valueType()}]`; + return `dict[str, ${collectionElementType(property, link)}]`; case 'one-of': case 'any-of': case 'all-of': - return property.name; + return toPythonClassName(property.name); default: - // "any" has export = interface - if (PRIMITIVE_TYPES.has(property.type)) { + // "any"/"unknown" has export = interface — route to the primitive path + // so they become `Any` rather than being treated as a model reference. + if (PRIMITIVE_TYPES.has(property.type) || property.type === 'unknown') { return toPythonPrimitive(property); } - return property.type; + return toPythonClassName(property.type); } }; +/** + * Prefix every user-defined (non-builtin) name in a python type string with + * the given namespace (e.g. `"types_gen."`) so the caller can reference + * model references through a single import. Walks nested `list[...]` and + * `dict[str, ...]` structures. + */ +export const qualifyPythonType = ( + type: string | undefined, + prefix: string, +): string => { + if (!type) return 'Any'; + if (PYTHON_BUILTIN_TYPES.has(type)) return type; + const list = /^list\[(.*)\]$/s.exec(type); + if (list) return `list[${qualifyPythonType(list[1], prefix)}]`; + const dict = /^dict\[str, (.*)\]$/s.exec(type); + if (dict) return `dict[str, ${qualifyPythonType(dict[1], prefix)}]`; + if ( + type.startsWith('Optional[') || + type.startsWith('Union[') || + type.startsWith('Literal[') + ) { + return type; + } + return `${prefix}${type}`; +}; + +/** + * Same as `toPythonType`, but wraps user-defined (non-builtin) types in + * forward-ref string quotes so they can be used inside class bodies before + * the referenced class is defined. Collections recursively forward-quote. + */ +export const toPythonAnnotation = (property: Model): string => { + const render = (p: Model): string => { + if (p.discriminatorValue) { + return toPythonDiscriminatorLiteral(p.discriminatorValue); + } + const link = p.link ?? undefined; + const collectionElement = () => + link && link.export === 'enum' + ? toPythonEnumLiteral(link) + : link + ? render(link) + : p.isEnum && p.enum.length > 0 + ? toPythonEnumLiteral(p) + : toPythonPrimitive(p); + switch (p.export) { + case 'enum': + return toPythonEnumLiteral(p); + case 'generic': + case 'reference': { + const rendered = toPythonPrimitive(p); + return isPythonBuiltin(rendered) ? rendered : `"${rendered}"`; + } + case 'array': + return `list[${collectionElement()}]`; + case 'dictionary': + return `dict[str, ${collectionElement()}]`; + case 'one-of': + case 'any-of': + case 'all-of': + return `"${toPythonClassName(p.name)}"`; + default: { + if (PRIMITIVE_TYPES.has(p.type) || p.type === 'unknown') { + return toPythonPrimitive(p); + } + const escaped = toPythonClassName(p.type); + return isPythonBuiltin(escaped) ? escaped : `"${escaped}"`; + } + } + }; + return render(property); +}; + // @see https://github.com/OpenAPITools/openapi-generator/blob/e2a62ace74de361bef6338b7fa37da8577242aef/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java#L106 const PYTHON_KEYWORDS = new Set([ // @property @@ -217,8 +429,11 @@ export const toPythonName = ( const nameSnakeCase = snakeCase(name); // Names overlapping a TypeScript reserved word carry a leading `_`; strip it - // before testing against the Python keyword set. - if (PYTHON_KEYWORDS.has(name.startsWith('_') ? name.slice(1) : name)) { + // before testing against the Python keyword set. Also test the snake-cased + // form — snakeCase strips trailing underscores, so `from_` becomes `from` + // and would otherwise slip through. + const rawStripped = name.startsWith('_') ? name.slice(1) : name; + if (PYTHON_KEYWORDS.has(rawStripped) || PYTHON_KEYWORDS.has(nameSnakeCase)) { const nameSuffix = `_${nameSnakeCase}`; switch (namedEntity) { case 'model': diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts b/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts index 78ee3fc34..7a2308b32 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts @@ -174,6 +174,17 @@ export interface Model { /** For composites: the referenced (object) models composed together. */ composedModels?: Model[]; + /** + * For `all-of` composites: the flattened property list across all composed + * models (first occurrence of each property name wins). + */ + effectiveProperties?: Model[]; + /** + * Set on a hoisted (normaliser-synthesised) model that an `all-of` parent + * flattens into itself, to the parent's name. Language templates that + * flatten composition can skip emitting the hoisted component separately. + */ + isInlinedByAllOf?: string; /** For composites: the primitive/enum/array members composed together. */ composedPrimitives?: Model[]; /** For discriminated one-of/any-of composites: the discriminator metadata. */ @@ -187,8 +198,29 @@ export interface Model { pythonName?: string; /** The rendered Python type. */ pythonType?: string; + /** + * The rendered Python type with user-defined names forward-ref quoted, for + * use inside class bodies before the referenced class is defined. + */ + pythonAnnotation?: string; + /** + * The Python class name: the TypeScript escape plus escaping of names that + * would shadow imports in the generated Python modules (e.g. `Field`). + */ + pythonClassName?: string; + /** + * The Python type qualified with the `types_gen.` namespace, for reference + * from the generated client modules. + */ + pythonClientType?: string; /** snake_case model name (Python). */ nameSnakeCase?: string; + /** + * When `type` references a named model that renders as a module-level alias + * (collection, union or literal) rather than a class: the kind of alias. + * Python templates use this to pick `TypeAdapter(X)` over `X.model_validate`. + */ + referencedCollectionKind?: 'array' | 'dictionary' | 'alias'; /** True when the model is a renderable primitive (not composite/collection). */ isPrimitive?: boolean; /** True when the schema declares an enum. */ @@ -212,6 +244,82 @@ export interface Model { /** Vendor extension bag (`x-*` keys copied from a schema/operation/spec). */ export type VendorExtensions = { [key: string]: unknown }; +/** + * Where a single operation input is placed in the HTTP request. `body-field` + * is a field of an object request body flattened into the call signature. + */ +export interface RequestInputSource { + kind: 'path' | 'query' | 'header' | 'cookie' | 'body' | 'body-field'; + /** The wire (spec) name for parameters; absent for `body-field`. */ + wireName?: string; + /** The body property name, for `body-field` inputs. */ + fieldName?: string; + /** Collection serialisation format for array query/header parameters. */ + collectionFormat?: CollectionFormat; +} + +/** A single input to an operation, tagged with where it goes on the wire. */ +export interface RequestInput { + source: RequestInputSource; + /** The model describing the input's type. */ + model: Model; + /** True when this input is a flattened request-body field. */ + fromFlattenedBody: boolean; + isRequired: boolean; + isNullable: boolean; + description: string | null; + /** The original spec name (parameter name or body property name). */ + specName: string; + /** The Python keyword-argument name (deduplicated snake_case). */ + pythonName?: string; + /** The Python annotation for the kwarg, qualified with `types_gen.`. */ + pythonAnnotation?: string; +} + +/** + * A language-agnostic description of an operation's inputs. Each input carries + * its request placement plus the underlying model; language templates decide + * how to render the call signature (kwargs, a wrapper interface, etc). + */ +export interface RequestShape { + inputs: RequestInput[]; + /** True when the body is the operation's only input (positional-arg style). */ + isSingleBodyInput: boolean; + /** Set when the body's fields are flattened into the inputs. */ + bodyFromFields?: { model: Model; mediaType?: string }; + /** Set when the body is passed as a single input. */ + bodyAsSingleInput?: { model: Model; mediaType?: string }; +} + +/** One non-success response bucket in an operation's error taxonomy. */ +export interface ErrorShapeEntry { + code: number | string; + /** + * The concrete status codes the bucket covers: `[code]` for a literal code, + * the full range for `NXX`, or undefined for `default`. + */ + statusCodes?: number[]; + responseModel: Model; + /** The generated per-code error class name (e.g. `GetPet404Error`). */ + className?: string; + /** True for a literal numeric code (vs a range or `default`). */ + isExactCode?: boolean; + /** The Python annotation for the error's `status` field. */ + statusAnnotation?: string; +} + +/** + * A language-agnostic error taxonomy for an operation: one entry per + * non-success response bucket. + */ +export interface ErrorShape { + entries: ErrorShapeEntry[]; + /** The generated exception class name (e.g. `GetPetApiError`). */ + exceptionClassName?: string; + /** The generated error union type name (e.g. `GetPetError`). */ + unionTypeName?: string; +} + /** * An operation represents a single OpenAPI path + method. Produced by the * parser and augmented by `../codegen-data.ts`. @@ -245,6 +353,11 @@ export interface Operation { /** The explicit body parameter when the body is not inlined. */ explicitRequestBodyParameter?: Model; + /** Language-agnostic description of the operation's inputs. */ + requestShape?: RequestShape; + /** Language-agnostic error taxonomy for the operation. */ + errorShape?: ErrorShape; + vendorExtensions?: VendorExtensions; isMutation?: boolean; isQuery?: boolean; diff --git a/packages/nx-plugin/src/sdk/open-api.ts b/packages/nx-plugin/src/sdk/open-api.ts index 70ee640d4..7b08b16dd 100644 --- a/packages/nx-plugin/src/sdk/open-api.ts +++ b/packages/nx-plugin/src/sdk/open-api.ts @@ -9,6 +9,9 @@ export { } from '../open-api/ts-client/generator'; export type { OpenApiTsClientGeneratorSchema } from '../open-api/ts-client/schema'; +export { openApiPyClientGenerator } from '../open-api/py-client/generator'; +export type { OpenApiPyClientGeneratorSchema } from '../open-api/py-client/schema'; + export { openApiTsHooksGenerator } from '../open-api/ts-hooks/generator'; export type { OpenApiTsHooksGeneratorSchema } from '../open-api/ts-hooks/schema'; diff --git a/packages/nx-plugin/src/utils/test/py.spec.ts b/packages/nx-plugin/src/utils/test/py.spec.ts new file mode 100644 index 000000000..c745bee41 --- /dev/null +++ b/packages/nx-plugin/src/utils/test/py.spec.ts @@ -0,0 +1,215 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import path from 'path'; +import { createInterface, Interface } from 'readline'; +import { OPEN_API_PY_CLIENT_PYTHON_DEP_SPECS } from '../../open-api/py-client/generator'; + +const WORKER_PATH = path.join( + import.meta.dirname, + 'python-worker', + 'worker.py', +); + +export interface MockResponseSpec { + status?: number; + headers?: Record; + json?: unknown; + text?: string; + jsonl_lines?: string[]; + bytes_b64?: string; +} + +export interface MockEntry { + method?: string; + url_contains?: string; + url_equals?: string; + path?: string; + response: MockResponseSpec; +} + +export interface InvokeOptions { + module: 'sync' | 'async'; + method: string; + args?: unknown[]; + kwargs?: Record; + stream?: boolean; + mock?: MockEntry[]; + baseUrl?: string; + clientKwargs?: Record; + /** Name of the package directory (default "generated"). */ + packageName?: string; +} + +export interface InvokeResult { + ok: boolean; + value?: unknown; + error?: string; + traceback?: string; + details?: unknown; + /** Populated when the generated code raised a typed exception. */ + exception?: { + type: string; + error_type?: string; + error?: unknown; + status?: number; + }; + calls?: Array<{ + method: string; + url: string; + headers: Record; + body: string | null; + }>; +} + +/** + * Long-lived Python worker used to both compile and invoke generated clients. + */ +export class PythonVerifier { + private process?: ChildProcessWithoutNullStreams; + private stdout?: Interface; + private queue: Array<(r: InvokeResult) => void> = []; + private started = false; + private startupError?: Error; + + private ensureStarted(): ChildProcessWithoutNullStreams { + if (this.started && this.process) return this.process; + // Use the same pinned versions the generator advertises so the test + // environment matches what consumers get at runtime. + const deps = OPEN_API_PY_CLIENT_PYTHON_DEP_SPECS.flatMap((spec) => [ + '--with', + spec, + ]); + const proc = spawn('uv', ['run', ...deps, 'python', '-u', WORKER_PATH], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.process = proc; + this.stdout = createInterface({ input: proc.stdout }); + this.stdout.on('line', (line) => { + const resolver = this.queue.shift(); + if (!resolver) return; + try { + resolver(JSON.parse(line)); + } catch (err) { + resolver({ ok: false, error: `Invalid JSON from worker: ${line}` }); + } + }); + proc.stderr.on('data', (chunk) => { + // Forward to test output — helps diagnose worker crashes. + process.stderr.write(chunk); + }); + proc.on('exit', (code, signal) => { + if (this.queue.length > 0) { + this.queue.forEach((r) => + r({ + ok: false, + error: `Worker exited (code=${code} signal=${signal}) with ${this.queue.length} pending requests`, + }), + ); + this.queue = []; + } + this.process = undefined; + }); + this.started = true; + return proc; + } + + private request(payload: Record): Promise { + const proc = this.ensureStarted(); + return new Promise((resolve) => { + this.queue.push(resolve); + proc.stdin.write(JSON.stringify(payload) + '\n'); + }); + } + + /** + * Write the given files into a tmpdir and py_compile+import them. + * `basePath` is stripped from each tree path so that the remainder becomes + * the file's location inside the generated package. + */ + async expectPythonToCompile( + tree: Tree, + paths: string[], + basePath = '', + pkg = 'generated', + ): Promise { + const files: Record = {}; + const prefix = basePath ? basePath.replace(/\/$/, '') + '/' : ''; + for (const rel of paths) { + const body = tree.read(rel, 'utf-8'); + if (body === null) throw new Error(`file not in tree: ${rel}`); + const inPkg = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel; + files[inPkg] = body; + } + const res = await this.request({ cmd: 'compile', files, package: pkg }); + if (!res.ok) { + const details = res.details + ? `\n ${JSON.stringify(res.details, null, 2)}` + : ''; + throw new Error( + `Python compile failed: ${res.error}${details}\n${res.traceback ?? ''}`, + ); + } + } + + /** + * Invoke a method on the previously compiled client. Assumes `expectPythonToCompile` has been called. + */ + async invoke(options: InvokeOptions): Promise { + const res = await this.request({ + cmd: 'invoke', + module: options.module, + method: options.method, + args: options.args ?? [], + kwargs: options.kwargs ?? {}, + stream: !!options.stream, + mock: options.mock ?? [], + base_url: options.baseUrl ?? 'http://mock', + client_kwargs: options.clientKwargs ?? {}, + package: options.packageName ?? 'generated', + }); + return res; + } + + async shutdown(): Promise { + if (this.process) { + try { + this.process.stdin.end(); + } catch { + // ignore + } + await new Promise((resolve) => { + this.process?.once('exit', () => resolve()); + setTimeout(resolve, 2000).unref(); + }); + this.process = undefined; + this.started = false; + } + } +} + +/** + * Convenience wrapper for one-shot compile checks. + */ +export const expectPythonToCompile = async ( + tree: Tree, + paths: string[], + basePath = '', +): Promise => { + const verifier = new PythonVerifier(); + try { + await verifier.expectPythonToCompile(tree, paths, basePath); + } finally { + await verifier.shutdown(); + } +}; + +describe('PythonVerifier', () => { + it('needs a test to be a .spec.ts file', () => { + // The file name ends in .spec.ts so vitest picks it up as a test file but we + // only want to export helpers. Mirrors ts.spec.ts. + }); +}); diff --git a/packages/nx-plugin/src/utils/test/python-worker/worker.py b/packages/nx-plugin/src/utils/test/python-worker/worker.py new file mode 100644 index 000000000..fde006069 --- /dev/null +++ b/packages/nx-plugin/src/utils/test/python-worker/worker.py @@ -0,0 +1,340 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +from __future__ import annotations + +import asyncio +import importlib +import io +import json +import os +import py_compile +import shutil +import sys +import tempfile +import traceback +import types +from typing import Any, Callable + +import httpx + + +# ─── Mock httpx transport ──────────────────────────────────────────────── +class _MockMatch: + """Matches a mock entry against a live httpx.Request.""" + + def __init__(self, entry: dict) -> None: + self.method = entry.get("method", "").upper() if entry.get("method") else None + self.url_contains = entry.get("url_contains") + self.url_equals = entry.get("url_equals") + self.path = entry.get("path") + self.response = entry["response"] + self.used = False + + def matches(self, request: httpx.Request) -> bool: + if self.method and request.method.upper() != self.method: + return False + url = str(request.url) + if self.url_contains and self.url_contains not in url: + return False + if self.url_equals and url != self.url_equals: + return False + if self.path and request.url.path != self.path: + return False + return True + + +class _MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + """Deterministic transport for both sync and async httpx clients.""" + + def __init__(self, entries: list[dict]) -> None: + self.entries = [_MockMatch(e) for e in entries] + self.calls: list[dict] = [] + + def _record(self, request: httpx.Request) -> None: + try: + body = request.content.decode("utf-8") if request.content else None + except Exception: + body = None + self.calls.append( + { + "method": request.method, + "url": str(request.url), + "headers": dict(request.headers), + "body": body, + } + ) + + def _build_response(self, spec: dict) -> httpx.Response: + status = spec.get("status", 200) + headers = spec.get("headers", {}) + if "jsonl_lines" in spec: + body = ("\n".join(spec["jsonl_lines"]) + "\n").encode("utf-8") + headers.setdefault("content-type", "application/jsonl") + elif "json" in spec: + body = json.dumps(spec["json"]).encode("utf-8") + headers.setdefault("content-type", "application/json") + elif "text" in spec: + body = spec["text"].encode("utf-8") + elif "bytes_b64" in spec: + import base64 + + body = base64.b64decode(spec["bytes_b64"]) + else: + body = b"" + return httpx.Response(status_code=status, headers=headers, content=body) + + def _match(self, request: httpx.Request) -> httpx.Response: + self._record(request) + for entry in self.entries: + if entry.matches(request): + return self._build_response(entry.response) + # Default 404 when nothing matches — makes tests fail loudly. + return httpx.Response( + status_code=599, + content=f"No mock matched {request.method} {request.url}".encode("utf-8"), + ) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return self._match(request) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return self._match(request) + + +# ─── Module loading ────────────────────────────────────────────────────── +_PACKAGE_DIR: str | None = None + + +def _write_files(files: dict[str, str], pkg_name: str = "generated") -> str: + """Lay files out as `//...` so the parent dir is on + sys.path and the package imports as `pkg_name`.""" + global _PACKAGE_DIR + if _PACKAGE_DIR and os.path.isdir(_PACKAGE_DIR): + shutil.rmtree(_PACKAGE_DIR) + _PACKAGE_DIR = tempfile.mkdtemp(prefix="pyclient-") + pkg_dir = os.path.join(_PACKAGE_DIR, pkg_name) + os.makedirs(pkg_dir, exist_ok=True) + for rel, content in files.items(): + abs_path = os.path.join(pkg_dir, rel) + parent = os.path.dirname(abs_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(abs_path, "w", encoding="utf-8") as f: + f.write(content) + return _PACKAGE_DIR + + +def _compile_all(root: str) -> list[str]: + """Return a list of error messages for any .py file that fails to parse.""" + errors: list[str] = [] + for dirpath, _, filenames in os.walk(root): + for name in filenames: + if name.endswith(".py"): + path = os.path.join(dirpath, name) + try: + py_compile.compile(path, doraise=True) + except py_compile.PyCompileError as exc: + errors.append(f"{path}: {exc.msg or exc}") + return errors + + +_previous_roots: list[str] = [] + + +def _load_generated(root: str, pkg_name: str = "generated") -> types.ModuleType: + """Import `/` as a fresh module.""" + for key in list(sys.modules): + if key == pkg_name or key.startswith(pkg_name + "."): + del sys.modules[key] + # Drop previously-inserted roots so Python can't resolve the package + # against a stale (possibly deleted) directory. + for stale in _previous_roots: + while stale in sys.path: + sys.path.remove(stale) + _previous_roots.clear() + sys.path.insert(0, root) + _previous_roots.append(root) + # Drop bytecode cache for a clean reimport. + importlib.invalidate_caches() + return importlib.import_module(pkg_name) + + +# ─── Command handlers ──────────────────────────────────────────────────── +def handle_compile(req: dict) -> dict: + pkg_name = req.get("package", "generated") + root = _write_files(req["files"], pkg_name) + errors = _compile_all(root) + if errors: + return {"ok": False, "error": "compile_failed", "details": errors} + try: + _load_generated(root, pkg_name) + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + return {"ok": True, "value": None} + + +def _resolve_invoke(module_kind: str, mod: types.ModuleType) -> tuple[Any, str]: + """Locate the generated client class and its Config inside the package.""" + is_async = module_kind == "async" + for name in getattr(mod, "__all__", []) or dir(mod): + if not isinstance(name, str): + continue + obj = getattr(mod, name, None) + if not isinstance(obj, type): + continue + if name.endswith("Config"): + continue + if is_async and name.startswith("Async"): + return obj, name + if not is_async and not name.startswith("Async"): + # Filter to classes defined inside this package (not imported types). + if getattr(obj, "__module__", "").endswith("client_gen"): + return obj, name + raise RuntimeError( + f"Could not locate {'async ' if is_async else ''}client class in {mod.__name__}" + ) + + +def _build_mock_client(kind: str, entries: list[dict]) -> tuple[Any, _MockTransport]: + transport = _MockTransport(entries) + if kind == "sync": + return httpx.Client(transport=transport, base_url="http://mock"), transport + return httpx.AsyncClient(transport=transport, base_url="http://mock"), transport + + +def _to_jsonable(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, (list, tuple)): + return [_to_jsonable(v) for v in value] + if isinstance(value, dict): + return {str(k): _to_jsonable(v) for k, v in value.items()} + if hasattr(value, "model_dump"): + return value.model_dump(mode="json", by_alias=True, exclude_unset=False) + return repr(value) + + +def handle_invoke(req: dict) -> dict: + if not _PACKAGE_DIR: + raise RuntimeError("no files loaded — compile first") + pkg = _load_generated(_PACKAGE_DIR, req.get("package", "generated")) + method_name = req["method"] + module_kind = req["module"] # "sync" | "async" + is_stream = bool(req.get("stream")) + + client_cls, cls_name = _resolve_invoke(module_kind, pkg) + config_cls = getattr(pkg, f"{cls_name}Config") + + httpx_client, transport = _build_mock_client(module_kind, req.get("mock", [])) + client = client_cls( + config_cls( + url=req.get("base_url", "http://mock"), + httpx_client=httpx_client, + **req.get("client_kwargs", {}), + ) + ) + + args = req.get("args", []) + kwargs = req.get("kwargs", {}) + + def _resolve_method(obj: Any, dotted: str) -> Callable[..., Any]: + for part in dotted.split("."): + obj = getattr(obj, part) + return obj + + def _invoke_sync() -> Any: + fn = _resolve_method(client, method_name) + try: + if is_stream: + return list(fn(*args, **kwargs)) + return fn(*args, **kwargs) + finally: + try: + httpx_client.close() + except Exception: + pass + + async def _invoke_async() -> Any: + fn = _resolve_method(client, method_name) + try: + if is_stream: + collected: list[Any] = [] + async for item in fn(*args, **kwargs): + collected.append(item) + return collected + return await fn(*args, **kwargs) + finally: + try: + await httpx_client.aclose() + except Exception: + pass + + try: + if module_kind == "sync": + result = _invoke_sync() + else: + result = asyncio.run(_invoke_async()) + except Exception as exc: # noqa: BLE001 + exc_info: dict[str, Any] = { + "type": type(exc).__name__, + } + error_payload = getattr(exc, "error", None) + if error_payload is not None: + exc_info["error_type"] = type(error_payload).__name__ + exc_info["error"] = _to_jsonable(error_payload) + if hasattr(exc, "status"): + exc_info["status"] = getattr(exc, "status") + return { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + "exception": exc_info, + "traceback": traceback.format_exc(), + "calls": transport.calls, + } + + return { + "ok": True, + "value": _to_jsonable(result), + "calls": transport.calls, + } + + +# ─── Dispatch loop ─────────────────────────────────────────────────────── +HANDLERS: dict[str, Callable[[dict], dict]] = { + "compile": handle_compile, + "invoke": handle_invoke, +} + + +def _main() -> None: + # Line-buffered stdout so parent sees responses immediately. + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True) + for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + try: + req = json.loads(raw) + handler = HANDLERS.get(req.get("cmd")) + if not handler: + resp = {"ok": False, "error": f"unknown command: {req.get('cmd')}"} + else: + resp = handler(req) + except Exception as exc: # noqa: BLE001 + resp = { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + sys.stdout.write(json.dumps(resp) + "\n") + + +if __name__ == "__main__": + _main() diff --git a/packages/nx-plugin/src/utils/versions.ts b/packages/nx-plugin/src/utils/versions.ts index 03861a074..66056c309 100644 --- a/packages/nx-plugin/src/utils/versions.ts +++ b/packages/nx-plugin/src/utils/versions.ts @@ -159,6 +159,7 @@ export const PY_VERSIONS = { mcp: '==1.28.1', 'pip-check-updates': '==0.29.0', 'pip-licenses': '==5.5.5', + pydantic: '==2.13.2', 'strands-agents': '==1.47.0', 'strands-agents[a2a]': '==1.47.0', 'strands-agents-tools': '==0.8.3', From 86a9519952ad4c1af9687595ef0c0e86017a41b9 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 10 Jul 2026 15:19:17 +0000 Subject: [PATCH 2/4] feat(open-api#py-client): parity with ts-client features added on main - Tagged discriminated unions: discriminated oneOf/anyOf renders as a pydantic tagged union (Annotated[Union[...], Field(discriminator=...)]) with Literal-typed discriminator fields on each member, mirroring the ts-client tagged-union narrowing from #913. - multipart/form-data: flattened-body multipart requests route fields through httpx files=/data= instead of JSON-encoding, matching the ts-client FormData handling from #914; binary fields stream as file parts. - deepObject query parameters serialise as key[prop]=value pairs. - toPythonName also tests the snake-cased form against the Python keyword set so spec properties like 'from_' don't emit invalid syntax. --- .../generator.arrays.spec.ts.snap | 65 +++++++++++++-- .../generator.composite-types.spec.ts.snap | 13 ++- .../generator.duplicate-types.spec.ts.snap | 13 ++- .../generator.errors.spec.ts.snap | 13 ++- .../generator.fast-api.spec.ts.snap | 13 ++- .../generator.petstore.spec.ts.snap | 26 +++++- .../generator.primitive-types.spec.ts.snap | 52 +++++++++++- .../generator.request.spec.ts.snap | 13 ++- .../generator.reserved-keywords.spec.ts.snap | 13 ++- .../generator.response.spec.ts.snap | 26 +++++- .../generator.streaming.spec.ts.snap | 13 ++- .../__snapshots__/generator.tags.spec.ts.snap | 13 ++- .../files/async/async_client_gen.py.template | 13 ++- .../files/shared/types_gen.py.template | 18 ++++- .../files/sync/client_gen.py.template | 13 ++- .../generator.composite-types.spec.ts | 79 +++++++++++++++++++ .../py-client/generator.request.spec.ts | 46 +++++++++++ .../src/open-api/utils/codegen-data.spec.ts | 2 + .../src/open-api/utils/codegen-data.ts | 6 +- .../open-api/utils/codegen-data/languages.ts | 1 + .../src/open-api/utils/codegen-data/types.ts | 2 + 21 files changed, 428 insertions(+), 25 deletions(-) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap index 210a502e9..e06450ddc 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap @@ -73,6 +73,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -82,7 +91,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -244,6 +255,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -253,7 +273,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -428,6 +450,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -437,7 +468,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -609,6 +642,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -618,7 +660,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -776,6 +820,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -785,7 +838,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap index 80be25cee..b4bdcb37c 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap @@ -122,6 +122,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -131,7 +140,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap index 69ec7d951..b2fd53f6a 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap @@ -79,6 +79,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -88,7 +97,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap index e47227e97..721e096a4 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap @@ -73,6 +73,15 @@ class ErrApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -82,7 +91,9 @@ class ErrApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap index 16219728d..c28d32664 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap @@ -80,6 +80,15 @@ class DemoApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -89,7 +98,9 @@ class DemoApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap index 16a0e848a..fcae8c32e 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap @@ -184,6 +184,15 @@ class AsyncSwaggerPetstoreOpenAPI30: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -193,7 +202,9 @@ class AsyncSwaggerPetstoreOpenAPI30: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -1605,6 +1616,15 @@ class SwaggerPetstoreOpenAPI30: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -1614,7 +1634,9 @@ class SwaggerPetstoreOpenAPI30: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap index 142ce84e9..934bfcfea 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap @@ -73,6 +73,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -82,7 +91,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -270,6 +281,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -279,7 +299,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -462,6 +484,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -471,7 +502,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -661,6 +694,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -670,7 +712,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap index bcf639da6..601d7ba11 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap @@ -73,6 +73,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -82,7 +91,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap index 158b9cdea..1325582e2 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap @@ -73,6 +73,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -82,7 +91,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap index 766d5f11f..1b2589a46 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap @@ -73,6 +73,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -82,7 +91,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) @@ -295,6 +306,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -304,7 +324,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap index 4297843c4..2616c366f 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap @@ -74,6 +74,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -83,7 +92,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap index a01b27ade..c54f9b3a4 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap @@ -81,6 +81,15 @@ class TestApi: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query( self, query_params: dict[str, Any], @@ -90,7 +99,9 @@ class TestApi: for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template index e4a4c3733..962cbdfb3 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template @@ -194,12 +194,23 @@ class Async<%- className %>: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query(self, query_params: dict[str, Any], collection_formats: Optional[dict[str, str]] = None) -> dict[str, Any]: out: dict[str, Any] = {} for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template index bff205529..a1a87b089 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template @@ -61,7 +61,7 @@ _%> from __future__ import annotations import datetime -from typing import Any, Literal, Never, Optional, TypedDict, Union +from typing import Annotated, Any, Literal, Never, Optional, TypedDict, Union from pydantic import BaseModel, ConfigDict, Field @@ -169,7 +169,23 @@ _%> `pythonType` (no quotes) vs `pythonAnnotation` (forward-ref-quoted) matters here: pydantic's `TypeAdapter(X)` can't resolve string forward-refs at runtime, only typing does that statically. */ _%> +<%_ /* A discriminated union renders as a pydantic tagged union so parsing + dispatches directly to the matching branch (never merging branches). + Requires every member to carry the discriminator as a Literal-typed + field; otherwise fall back to a plain Union. */ _%> +<%_ /* pydantic requires the tag to be a required Literal field on every + member, else TypeAdapter construction fails at import time. */ + const memberModels = (model.composedModels || []); + const isTagged = model.discriminator && memberModels.length > 0 + && memberModels.length === model.properties.filter(p => !p.isPrimitive).length + && (model.composedPrimitives || []).length === 0 + && memberModels.every(m => (m.properties || []).some(p => + p.name === model.discriminator.propertyName && p.discriminatorValue && p.isRequired)); _%> +<%_ if (isTagged) { _%> +<%- model.pythonClassName %> = Annotated[Union[<%_ model.properties.forEach((p, i) => { _%><%- i > 0 ? ', ' : '' %><%- p.pythonType || 'Any' %><%_ }); _%>], Field(discriminator="<%- model.discriminator.pythonPropertyName %>")] +<%_ } else { _%> <%- model.pythonClassName %> = Union[<%_ model.properties.forEach((p, i) => { _%><%- i > 0 ? ', ' : '' %><%- p.pythonType || 'Any' %><%_ }); _%>] +<%_ } _%> <%_ if (model.description || model.deprecated) { _%> """<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" <%_ } _%> diff --git a/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template index ab563547c..78be8c5d3 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template @@ -203,12 +203,23 @@ class <%- className %>: path = path.replace("{" + key + "}", quote(str(value), safe="")) return self._base_url + path + def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) + if isinstance(value, dict): + for prop, v in value.items(): + self._deep_object(f"{key}[{prop}]", v, out) + elif value is not None: + out[key] = value + def _query(self, query_params: dict[str, Any], collection_formats: Optional[dict[str, str]] = None) -> dict[str, Any]: out: dict[str, Any] = {} for key, value in query_params.items(): if value is None: continue - if isinstance(value, list): + if (collection_formats or {}).get(key) == "deepObject": + self._deep_object(key, value, out) + elif isinstance(value, list): fmt = (collection_formats or {}).get(key, "multi") if fmt == "csv": out[key] = ",".join(str(v) for v in value) diff --git a/packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts index 26297fb14..c8734bb33 100644 --- a/packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts +++ b/packages/nx-plugin/src/open-api/py-client/generator.composite-types.spec.ts @@ -386,4 +386,83 @@ describe('openApiPyClientGenerator - composite types', () => { expect(res.ok).toBe(true); expect(res.value).toBe('v1'); }); + + it('renders a discriminated oneOf as a pydantic tagged union', async () => { + // A oneOf with a discriminator should parse via pydantic's discriminated + // union machinery (Annotated[..., Field(discriminator=...)]) so unknown + // fields from non-matching branches never leak, and each member's + // discriminator property is a required Literal tag. + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/pet': { + get: { + operationId: 'getPet', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + oneOf: [ + { $ref: '#/components/schemas/Dog' }, + { $ref: '#/components/schemas/Cat' }, + ], + discriminator: { + propertyName: 'petType', + mapping: { + dog: '#/components/schemas/Dog', + cat: '#/components/schemas/Cat', + }, + }, + }, + Dog: { + type: 'object', + required: ['petType', 'bark'], + properties: { + petType: { type: 'string' }, + bark: { type: 'string' }, + }, + }, + Cat: { + type: 'object', + required: ['petType', 'whiskers'], + properties: { + petType: { type: 'string' }, + whiskers: { type: 'integer' }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + + // The alias is a pydantic tagged union on the discriminator field. + expect(types).toMatch( + /^Pet\s*=\s*Annotated\[Union\[Dog,\s*Cat\],\s*Field\(discriminator="pet_type"\)\]/m, + ); + // Each member's discriminator property is a Literal tag. + expect(types).toMatch(/pet_type:\s*Literal\["dog"\]/); + expect(types).toMatch(/pet_type:\s*Literal\["cat"\]/); + + const res = await callGeneratedClient( + verifier, + 'get_pet', + {}, + { json: { petType: 'cat', whiskers: 7 } }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ petType: 'cat', whiskers: 7 }); + }); }); diff --git a/packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts index eec5b0a93..ae0e4c24b 100644 --- a/packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts +++ b/packages/nx-plugin/src/open-api/py-client/generator.request.spec.ts @@ -227,4 +227,50 @@ describe('openApiPyClientGenerator - requests', () => { expect(res.ok).toBe(true); expect(res.value).toBe('pong'); }); + + it('serialises a deepObject query parameter as key[prop]=value pairs', async () => { + const spec: Spec = { + openapi: '3.0.3', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/search': { + get: { + operationId: 'search', + parameters: [ + { + name: 'filter', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { + colour: { type: 'string' }, + size: { type: 'integer' }, + }, + }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'search', + { filter: { colour: 'red', size: 5 } }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + const url = decodeURIComponent(res.calls?.[0]?.url ?? ''); + expect(url).toMatch(/filter\[colour\]=red/); + expect(url).toMatch(/filter\[size\]=5/); + }); }); diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data.spec.ts b/packages/nx-plugin/src/open-api/utils/codegen-data.spec.ts index d6caaef63..e8e98c9c7 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data.spec.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data.spec.ts @@ -915,6 +915,7 @@ describe('openapi codegen data utils', () => { expect(animal.discriminator).toEqual({ propertyName: 'kind', typescriptPropertyName: 'kind', + pythonPropertyName: 'kind', mapping: [ { value: 'cat', modelName: 'Cat' }, { value: 'dog', modelName: 'Dog' }, @@ -1120,6 +1121,7 @@ describe('openapi codegen data utils', () => { expect(base.discriminator).toEqual({ propertyName: 'kind', typescriptPropertyName: 'kind', + pythonPropertyName: 'kind', isBase: true, mapping: [ { value: 'cat', modelName: 'Cat' }, diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data.ts b/packages/nx-plugin/src/open-api/utils/codegen-data.ts index e3c0ed92c..b52b9ee44 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data.ts @@ -824,11 +824,15 @@ const augmentModel = ( model.properties.forEach(addLanguageTypes); - // Resolve the discriminator's TypeScript property name for marshalling. + // Resolve the discriminator's language property names for marshalling. if (model.discriminator) { model.discriminator.typescriptPropertyName = toTypeScriptName( model.discriminator.propertyName, ); + model.discriminator.pythonPropertyName = toPythonName( + 'property', + model.discriminator.propertyName, + ); } }; diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts index 05b668198..a506688b8 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts @@ -126,6 +126,7 @@ const PYTHON_BUILTIN_TYPES = new Set([ */ const PYTHON_RESERVED_MODEL_NAMES = new Set([ // typing module + 'Annotated', 'Any', 'Literal', 'Never', diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts b/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts index 7a2308b32..af578fbcd 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data/types.ts @@ -75,6 +75,8 @@ export interface Discriminator { propertyName: string; /** The TypeScript property name (resolved during augmentation). */ typescriptPropertyName?: string; + /** The Python field name (resolved during augmentation). */ + pythonPropertyName?: string; /** The value → composed-model mapping. */ mapping: DiscriminatorMapping[]; /** From 8593d0cf2c9422df16558726bed61d7e28a32d93 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 13 Jul 2026 05:17:01 +0000 Subject: [PATCH 3/4] feat(open-api#py-client): parity with ts-client spec-compliance fixes from #922 and #930 - application/x-www-form-urlencoded bodies route through httpx data= (arrays as repeated keys, None fields omitted) instead of being JSON-encoded. - Fixed-length tuples (3.1 prefixItems) render as tuple[A, B] with TypeAdapter validation; open tuples degrade to plain lists. - matrix and label path styles serialise per RFC 6570 (with explode). - allowReserved query parameters keep reserved characters literal via a manually built query string. - Content-based (content: application/json) parameters JSON-serialise their value as a single query/header/cookie entry. - Multipart parts honour content types declared by the request body encoding object. - Python worker reads streamed (multipart) request bodies so tests can assert on the wire payload. --- .../generator.arrays.spec.ts.snap | 180 ++++++- .../generator.composite-types.spec.ts.snap | 36 +- .../generator.duplicate-types.spec.ts.snap | 39 +- .../generator.errors.spec.ts.snap | 36 +- .../generator.fast-api.spec.ts.snap | 39 +- .../generator.petstore.spec.ts.snap | 180 +++++-- .../generator.primitive-types.spec.ts.snap | 144 +++++- .../generator.request.spec.ts.snap | 36 +- .../generator.reserved-keywords.spec.ts.snap | 36 +- .../generator.response.spec.ts.snap | 72 ++- .../generator.streaming.spec.ts.snap | 36 +- .../__snapshots__/generator.tags.spec.ts.snap | 39 +- .../files/async/async_client_gen.py.template | 119 ++++- .../files/shared/types_gen.py.template | 7 +- .../files/sync/client_gen.py.template | 121 ++++- .../generator.spec-compliance.spec.ts | 446 ++++++++++++++++++ .../src/open-api/utils/codegen-data.ts | 3 +- .../open-api/utils/codegen-data/languages.ts | 29 +- .../src/utils/test/python-worker/worker.py | 7 + 19 files changed, 1462 insertions(+), 143 deletions(-) create mode 100644 packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap index e06450ddc..341c6b481 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap @@ -68,9 +68,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -135,7 +162,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -153,9 +180,10 @@ class TestApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/counts", path_params) response = self._client.request( "GET", - self._url("/counts", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -250,9 +278,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -317,7 +372,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -338,9 +393,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/create-users", path_params) response = self._client.request( "POST", - self._url("/create-users", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -445,9 +501,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -512,7 +595,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -533,9 +616,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/process-tags", path_params) response = self._client.request( "POST", - self._url("/process-tags", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -637,9 +721,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -704,7 +815,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -722,9 +833,10 @@ class TestApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/matrix", path_params) response = self._client.request( "GET", - self._url("/matrix", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -815,9 +927,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -882,7 +1021,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -900,9 +1039,10 @@ class TestApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/users", path_params) response = self._client.request( "GET", - self._url("/users", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap index b4bdcb37c..0ec6ac1d7 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap @@ -117,9 +117,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -184,7 +211,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -212,9 +239,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/dog", path_params) response = self._client.request( "POST", - self._url("/dog", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap index b2fd53f6a..a65bb79aa 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap @@ -74,9 +74,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -141,7 +168,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -167,9 +194,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/a", path_params) response = self._client.request( "POST", - self._url("/a", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -197,9 +225,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/b", path_params) response = self._client.request( "POST", - self._url("/b", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap index 721e096a4..ece724a33 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap @@ -68,9 +68,36 @@ class ErrApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -135,7 +162,7 @@ class ErrApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -155,9 +182,10 @@ class ErrApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/{petId}", path_params) response = self._client.request( "GET", - self._url("/pet/{petId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap index c28d32664..c1ed664b6 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap @@ -75,9 +75,36 @@ class DemoApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -142,7 +169,7 @@ class DemoApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -162,9 +189,10 @@ class DemoApi: collection_formats: dict[str, str] = {"message": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/echo", path_params) response = self._client.request( "GET", - self._url("/echo", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -197,9 +225,10 @@ class DemoApi: collection_formats: dict[str, str] = {"prompt": "multi", "count": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/stream", path_params) with self._client.stream( "POST", - self._url("/stream", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap index fcae8c32e..42016732b 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap @@ -179,9 +179,36 @@ class AsyncSwaggerPetstoreOpenAPI30: if self._owns_client: await self._client.aclose() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -246,7 +273,7 @@ class AsyncSwaggerPetstoreOpenAPI30: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -287,9 +314,10 @@ class AsyncSwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/pet", path_params) response = await self._client.request( "POST", - self._url("/pet", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -364,9 +392,10 @@ class AsyncSwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/user", path_params) response = await self._client.request( "POST", - self._url("/user", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -398,9 +427,10 @@ class AsyncSwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/user/createWithList", path_params) response = await self._client.request( "POST", - self._url("/user/createWithList", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -431,9 +461,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/store/order/{orderId}", path_params) response = await self._client.request( "DELETE", - self._url("/store/order/{orderId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -481,9 +512,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"api_key": "csv"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/{petId}", path_params) response = await self._client.request( "DELETE", - self._url("/pet/{petId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -522,9 +554,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/{username}", path_params) response = await self._client.request( "DELETE", - self._url("/user/{username}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -571,9 +604,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"status": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/findByStatus", path_params) response = await self._client.request( "GET", - self._url("/pet/findByStatus", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -612,9 +646,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"tags": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/findByTags", path_params) response = await self._client.request( "GET", - self._url("/pet/findByTags", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -651,9 +686,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/store/inventory", path_params) response = await self._client.request( "GET", - self._url("/store/inventory", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -686,9 +722,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/store/order/{orderId}", path_params) response = await self._client.request( "GET", - self._url("/store/order/{orderId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -735,9 +772,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/{petId}", path_params) response = await self._client.request( "GET", - self._url("/pet/{petId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -784,9 +822,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/{username}", path_params) response = await self._client.request( "GET", - self._url("/user/{username}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -834,9 +873,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"username": "multi", "password": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/login", path_params) response = await self._client.request( "GET", - self._url("/user/login", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -873,9 +913,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/logout", path_params) response = await self._client.request( "GET", - self._url("/user/logout", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -928,9 +969,10 @@ class AsyncSwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/store/order", path_params) response = await self._client.request( "POST", - self._url("/store/order", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -997,9 +1039,10 @@ class AsyncSwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/pet", path_params) response = await self._client.request( "PUT", - self._url("/pet", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1056,9 +1099,10 @@ class AsyncSwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"name": "multi", "status": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/{petId}", path_params) response = await self._client.request( "POST", - self._url("/pet/{petId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1100,9 +1144,10 @@ class AsyncSwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/user/{username}", path_params) response = await self._client.request( "PUT", - self._url("/user/{username}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1153,9 +1198,10 @@ class AsyncSwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"content": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/octet-stream") + _request_url = self._url("/pet/{petId}/uploadImage", path_params) response = await self._client.request( "POST", - self._url("/pet/{petId}/uploadImage", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1611,9 +1657,36 @@ class SwaggerPetstoreOpenAPI30: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -1678,7 +1751,7 @@ class SwaggerPetstoreOpenAPI30: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -1719,9 +1792,10 @@ class SwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/pet", path_params) response = self._client.request( "POST", - self._url("/pet", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1796,9 +1870,10 @@ class SwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/user", path_params) response = self._client.request( "POST", - self._url("/user", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1830,9 +1905,10 @@ class SwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/user/createWithList", path_params) response = self._client.request( "POST", - self._url("/user/createWithList", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1863,9 +1939,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/store/order/{orderId}", path_params) response = self._client.request( "DELETE", - self._url("/store/order/{orderId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1913,9 +1990,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"api_key": "csv"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/{petId}", path_params) response = self._client.request( "DELETE", - self._url("/pet/{petId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -1954,9 +2032,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/{username}", path_params) response = self._client.request( "DELETE", - self._url("/user/{username}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2003,9 +2082,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"status": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/findByStatus", path_params) response = self._client.request( "GET", - self._url("/pet/findByStatus", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2044,9 +2124,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"tags": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/findByTags", path_params) response = self._client.request( "GET", - self._url("/pet/findByTags", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2083,9 +2164,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/store/inventory", path_params) response = self._client.request( "GET", - self._url("/store/inventory", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2118,9 +2200,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/store/order/{orderId}", path_params) response = self._client.request( "GET", - self._url("/store/order/{orderId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2167,9 +2250,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/{petId}", path_params) response = self._client.request( "GET", - self._url("/pet/{petId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2216,9 +2300,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/{username}", path_params) response = self._client.request( "GET", - self._url("/user/{username}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2266,9 +2351,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"username": "multi", "password": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/login", path_params) response = self._client.request( "GET", - self._url("/user/login", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2305,9 +2391,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/user/logout", path_params) response = self._client.request( "GET", - self._url("/user/logout", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2360,9 +2447,10 @@ class SwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/store/order", path_params) response = self._client.request( "POST", - self._url("/store/order", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2429,9 +2517,10 @@ class SwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/pet", path_params) response = self._client.request( "PUT", - self._url("/pet", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2488,9 +2577,10 @@ class SwaggerPetstoreOpenAPI30: collection_formats: dict[str, str] = {"name": "multi", "status": "multi"} body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/pet/{petId}", path_params) response = self._client.request( "POST", - self._url("/pet/{petId}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2532,9 +2622,10 @@ class SwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"json": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/user/{username}", path_params) response = self._client.request( "PUT", - self._url("/user/{username}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -2585,9 +2676,10 @@ class SwaggerPetstoreOpenAPI30: request_kwargs: dict[str, Any] = {"content": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/octet-stream") + _request_url = self._url("/pet/{petId}/uploadImage", path_params) response = self._client.request( "POST", - self._url("/pet/{petId}/uploadImage", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap index 934bfcfea..17626ce0f 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap @@ -68,9 +68,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -135,7 +162,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -167,9 +194,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/scalars", path_params) response = self._client.request( "POST", - self._url("/scalars", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -276,9 +304,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -343,7 +398,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -371,9 +426,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/event", path_params) response = self._client.request( "POST", - self._url("/event", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -479,9 +535,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -546,7 +629,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -567,9 +650,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if body is not None and not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/status", path_params) response = self._client.request( "POST", - self._url("/status", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -689,9 +773,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -756,7 +867,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -774,9 +885,10 @@ class TestApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/greet", path_params) response = self._client.request( "GET", - self._url("/greet", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap index 601d7ba11..c8bbf266e 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap @@ -68,9 +68,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -135,7 +162,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -162,9 +189,10 @@ class TestApi: } body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/test/{id}", path_params) response = self._client.request( "GET", - self._url("/test/{id}", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap index 1325582e2..6a4061448 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap @@ -68,9 +68,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -135,7 +162,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -161,9 +188,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/r", path_params) response = self._client.request( "POST", - self._url("/r", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap index 1b2589a46..8e13c2fa2 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap @@ -68,9 +68,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -135,7 +162,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -153,9 +180,10 @@ class TestApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/check", path_params) response = self._client.request( "GET", - self._url("/check", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -301,9 +329,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -368,7 +423,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -386,9 +441,10 @@ class TestApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/only-default", path_params) response = self._client.request( "GET", - self._url("/only-default", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap index 2616c366f..1622a6bbc 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap @@ -69,9 +69,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -136,7 +163,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -162,9 +189,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/stream", path_params) with self._client.stream( "POST", - self._url("/stream", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap index c54f9b3a4..b01627d4c 100644 --- a/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap +++ b/packages/nx-plugin/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap @@ -76,9 +76,36 @@ class TestApi: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join( + ";" + key + "=" + quote(str(v), safe="") for v in values + ) + else: + rendered = ( + ";" + + key + + "=" + + ",".join(quote(str(v), safe="") for v in values) + ) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join( + quote(str(v), safe="") for v in values + ) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: @@ -143,7 +170,7 @@ class TestApi: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -169,9 +196,10 @@ class TestApi: request_kwargs: dict[str, Any] = {"json": body} if not self._config.omit_content_type_header: header_params.setdefault("Content-Type", "application/json") + _request_url = self._url("/pet", path_params) response = self._client.request( "POST", - self._url("/pet", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), @@ -191,9 +219,10 @@ class TestApi: collection_formats: Optional[dict[str, str]] = None body = None request_kwargs: dict[str, Any] = {} + _request_url = self._url("/store", path_params) response = self._client.request( "GET", - self._url("/store", path_params), + _request_url, params=self._query(query_params, collection_formats), headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), diff --git a/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template index 962cbdfb3..af7de5ec4 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template @@ -40,7 +40,7 @@ const responseParseExpr = (resp) => { } const py = resp.pythonClientType || 'Any'; if (py === 'Any') return 'response.json()'; - if (resp.referencedCollectionKind || py.startsWith('list[') || py.startsWith('dict[')) { + if (resp.referencedCollectionKind || py.startsWith('list[') || py.startsWith('dict[') || py.startsWith('tuple[')) { return `TypeAdapter(${py}).validate_python(response.json())`; } if (PYTHON_BUILTIN.has(py)) return 'response.json()'; @@ -111,12 +111,29 @@ const pythonClassNameForTag = (tag) => `_Async${tag.charAt(0).toUpperCase()}${tag.slice(1).replace(/[^a-zA-Z0-9_]/g, '_')}Namespace`; const tagGroups = operationsGroupedByTag(allOperations); +// A content-based parameter (declared with `content` rather than `schema`) +// is serialised to its media type — JSON — as a single value. +const hasContentJsonParams = allOperations.some(op => (op.parameters || []).some(p => p.in !== 'body' && p.mediaType === 'application/json')); +const hasPartContentTypes = allOperations.some(op => op.parametersBody && op.parametersBody.partContentTypes); +const hasAllowReservedParams = allOperations.some(op => (op.parameters || []).some(p => p.in === 'query' && p.allowReserved)); +// Render the `self._url(...)` expression for an operation, including matrix/ +// label path styles when declared. +const urlExpr = (op, styledPathInputs) => { + let expr = `self._url("${op.path}", path_params`; + if (styledPathInputs.length > 0) { + expr += `, path_styles={${styledPathInputs.map(k => `"${k.source.wireName}": ("${k.model.pathStyle}", ${k.model.pathExplode ? 'True' : 'False'})`).join(', ')}}`; + } + return expr + ')'; +}; _%> """Async<%- className %> — AUTO-GENERATED asynchronous client.""" from __future__ import annotations import datetime +<%_ if (hasContentJsonParams || hasPartContentTypes) { _%> +import json +<%_ } _%> import warnings from collections.abc import AsyncIterator from dataclasses import dataclass, field @@ -189,11 +206,47 @@ class Async<%- className %>: if self._owns_client: await self._client.aclose() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join(";" + key + "=" + quote(str(v), safe="") for v in values) + else: + rendered = ";" + key + "=" + ",".join(quote(str(v), safe="") for v in values) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join(quote(str(v), safe="") for v in values) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path +<%_ if (hasAllowReservedParams) { _%> + def _query_string( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]], + reserved: set[str], + ) -> str: + # Build the query string manually: httpx percent-encodes reserved + # characters, which allowReserved parameters must keep literal. + parts: list[str] = [] + for key, value in self._query(query_params, collection_formats).items(): + safe = "/:?#[]@!$&'()*+,;=" if key in reserved else "" + values = value if isinstance(value, list) else [value] + for v in values: + parts.append(quote(str(key), safe="") + "=" + quote(str(v), safe=safe)) + return "&".join(parts) + +<%_ } _%> def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: if isinstance(value, BaseModel): value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) @@ -248,7 +301,7 @@ class Async<%- className %>: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -269,6 +322,11 @@ class Async<%- className %>: || undefined; const bodyIsBinary = wholeBodyInputs.length === 1 && wholeBodyInputs[0].model.type === 'binary'; const bodyIsMultipart = bodyMediaType === 'multipart/form-data'; + const bodyIsUrlEncoded = bodyMediaType === 'application/x-www-form-urlencoded'; + const contentJsonInputs = [...queryInputs, ...headerInputs, ...cookieInputs].filter(k => k.model.mediaType === 'application/json'); + const styledPathInputs = pathInputs.filter(k => k.model.pathStyle); + const reservedInputs = queryInputs.filter(k => k.model.allowReserved); + const partContentTypes = (op.parametersBody && op.parametersBody.partContentTypes) || undefined; const hasTag = (op.tags && op.tags.length > 0); const methodPrefix = hasTag ? '_' : ''; _%> @@ -290,6 +348,15 @@ _%> query_params: dict[str, Any] = {<%_ queryInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} header_params: dict[str, Any] = {<%_ headerInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} cookie_params: dict[str, Any] = {<%_ cookieInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} +<%_ /* A content-based parameter (declared with `content` rather than + `schema`) is serialised to its media type — JSON — as a single value. */ _%> +<%_ contentJsonInputs.forEach((k) => { + const params = k.source.kind === 'query' ? 'query_params' : k.source.kind === 'header' ? 'header_params' : 'cookie_params'; _%> + if <%- params %>["<%- k.source.wireName %>"] is not None: + <%- params %>["<%- k.source.wireName %>"] = json.dumps( + self._dump(<%- params %>["<%- k.source.wireName %>"]), separators=(",", ":") + ) +<%_ }); _%> <%_ if (collectionInputs.length > 0) { _%> collection_formats: dict[str, str] = {<%_ collectionInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": "<%- k.source.collectionFormat %>"<%_ }); _%>} <%_ } else { _%> @@ -309,7 +376,24 @@ _%> # `files=` or `data=` is supplied; binary values stream as file parts. _files = {k: v for k, v in _body_fields.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} _data = {k: self._dump(v) for k, v in _body_fields.items() if k not in _files} +<%_ if (partContentTypes) { _%> + # Per-part content types declared by the request body `encoding` object. + _part_content_types = {<%_ Object.entries(partContentTypes).forEach(([prop, ct], i) => { _%><%- i > 0 ? ', ' : '' %>"<%- prop %>": "<%- ct %>"<%_ }); _%>} + for _k, _ct in _part_content_types.items(): + if _k in _files: + _v = _files[_k] + _files[_k] = (None, _v, _ct) if not isinstance(_v, tuple) else _v + elif _k in _data: + _v = _data[_k] + _serialized = _v if isinstance(_v, str) else json.dumps(_v, separators=(",", ":")) + _files[_k] = (None, _serialized, _ct) + del _data[_k] +<%_ } _%> request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} +<%_ } else if (shape.bodyFromFields.mediaType === 'application/x-www-form-urlencoded') { _%> + # httpx URL-encodes `data=` as form key=value pairs (arrays as + # repeated keys); None fields were already omitted above. + request_kwargs: dict[str, Any] = {"data": {k: self._dump(v) for k, v in _body_fields.items()}} <%_ } else { _%> body = types_gen.<%- shape.bodyFromFields.model.pythonClassName %>.model_validate( _body_fields @@ -330,12 +414,20 @@ _%> <%_ } _%> <%_ } else if (bodyIsMultipart) { _%> body = <%- wholeBodyInputs[0].pythonName %> + if isinstance(body, BaseModel): + body = body.model_dump(mode="python", by_alias=True, exclude_unset=True) if isinstance(body, dict): _files = {k: v for k, v in body.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} - _data = {k: v for k, v in body.items() if k not in _files} + _data = {k: self._dump(v) for k, v in body.items() if k not in _files} request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} else: request_kwargs = {"files": body} +<%_ } else if (bodyIsUrlEncoded) { _%> + body = <%- wholeBodyInputs[0].pythonName %> + # httpx URL-encodes `data=` as form key=value pairs (arrays as + # repeated keys). + _form = self._dump(body) + request_kwargs: dict[str, Any] = {"data": {k: v for k, v in (_form or {}).items() if v is not None}} <%_ } else { _%> body = self._dump(<%- wholeBodyInputs[0].pythonName %>) request_kwargs: dict[str, Any] = {"json": body} @@ -348,11 +440,22 @@ _%> body = None request_kwargs: dict[str, Any] = {} <%_ } _%> +<%_ if (reservedInputs.length > 0) { _%> +<%_ /* allowReserved query parameters keep reserved characters literal, so the + query string is built manually and appended to the URL — httpx would + percent-encode values passed via `params=`. */ _%> + _qs = self._query_string(query_params, collection_formats, {<%_ reservedInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>"<%_ }); _%>}) + _request_url = <%- urlExpr(op, styledPathInputs) %> + ("?" + _qs if _qs else "") +<%_ } else { _%> + _request_url = <%- urlExpr(op, styledPathInputs) %> +<%_ } _%> <%_ if (op.isStreaming) { _%> async with self._client.stream( "<%- op.method %>", - self._url("<%- op.path %>", path_params), + _request_url, +<%_ if (reservedInputs.length === 0) { _%> params=self._query(query_params, collection_formats), +<%_ } _%> headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), **request_kwargs, @@ -392,8 +495,10 @@ _%> <%_ } else { _%> response = await self._client.request( "<%- op.method %>", - self._url("<%- op.path %>", path_params), + _request_url, +<%_ if (reservedInputs.length === 0) { _%> params=self._query(query_params, collection_formats), +<%_ } _%> headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), **request_kwargs, diff --git a/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template index a1a87b089..4eb632ad2 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/shared/types_gen.py.template @@ -85,6 +85,7 @@ const classifyModel = (model) => { if (model.export === 'one-of' || model.export === 'any-of') return 'alias'; if (model.export === 'dictionary') return 'alias'; if (model.export === 'array') return 'alias'; + if (model.export === 'tuple') return 'alias'; // `all-of` is rendered as a `BaseModel` subclass with flattened fields. return 'class'; }; @@ -149,9 +150,9 @@ class <%- model.pythonClassName %>(BaseModel): * also reference other composites in rare cases, but topological sort on * the full alias set is overkill for the shapes that appear in practice. * - * Order: enum → array → dictionary → one-of/any-of. + * Order: enum → array/tuple → dictionary → one-of/any-of. */ -const aliasOrder = { 'enum': 0, 'array': 1, 'dictionary': 2, 'any-of': 3, 'one-of': 3 }; +const aliasOrder = { 'enum': 0, 'array': 1, 'tuple': 1, 'dictionary': 2, 'any-of': 3, 'one-of': 3 }; const aliasModels = models .filter((m) => classifyModel(m) === 'alias') .slice() @@ -189,7 +190,7 @@ _%> <%_ if (model.description || model.deprecated) { _%> """<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" <%_ } _%> -<%_ } else if (model.export === 'dictionary' || model.export === 'array') { _%> +<%_ } else if (model.export === 'dictionary' || model.export === 'array' || model.export === 'tuple') { _%> <%- model.pythonClassName %> = <%- model.pythonType %> <%_ if (model.description || model.deprecated) { _%> """<%- (model.description || '').replace(/"/g, "'") %><%- model.deprecated ? ' (deprecated)' : '' %>""" diff --git a/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template index 78be8c5d3..da8897c9e 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template @@ -44,8 +44,8 @@ const responseParseExpr = (resp) => { const py = resp.pythonClientType || 'Any'; if (py === 'Any') return 'response.json()'; // `TypeAdapter` handles anything that isn't a BaseModel: collections, - // Union aliases, Literal aliases, and plain Python built-ins. - if (resp.referencedCollectionKind || py.startsWith('list[') || py.startsWith('dict[')) { + // tuples, Union aliases, Literal aliases, and plain Python built-ins. + if (resp.referencedCollectionKind || py.startsWith('list[') || py.startsWith('dict[') || py.startsWith('tuple[')) { return `TypeAdapter(${py}).validate_python(response.json())`; } if (PYTHON_BUILTIN.has(py)) return 'response.json()'; @@ -120,12 +120,29 @@ const pythonClassNameForTag = (tag) => `_${tag.charAt(0).toUpperCase()}${tag.slice(1).replace(/[^a-zA-Z0-9_]/g, '_')}Namespace`; const tagGroups = operationsGroupedByTag(allOperations); +// A content-based parameter (declared with `content` rather than `schema`) +// is serialised to its media type — JSON — as a single value. +const hasContentJsonParams = allOperations.some(op => (op.parameters || []).some(p => p.in !== 'body' && p.mediaType === 'application/json')); +const hasPartContentTypes = allOperations.some(op => op.parametersBody && op.parametersBody.partContentTypes); +const hasAllowReservedParams = allOperations.some(op => (op.parameters || []).some(p => p.in === 'query' && p.allowReserved)); +// Render the `self._url(...)` expression for an operation, including matrix/ +// label path styles when declared. +const urlExpr = (op, styledPathInputs) => { + let expr = `self._url("${op.path}", path_params`; + if (styledPathInputs.length > 0) { + expr += `, path_styles={${styledPathInputs.map(k => `"${k.source.wireName}": ("${k.model.pathStyle}", ${k.model.pathExplode ? 'True' : 'False'})`).join(', ')}}`; + } + return expr + ')'; +}; _%> """<%- className %> — AUTO-GENERATED synchronous client.""" from __future__ import annotations import datetime +<%_ if (hasContentJsonParams || hasPartContentTypes) { _%> +import json +<%_ } _%> import warnings from collections.abc import Iterator from dataclasses import dataclass, field @@ -198,11 +215,47 @@ class <%- className %>: if self._owns_client: self._client.close() - def _url(self, path: str, path_params: dict[str, Any]) -> str: + def _url( + self, + path: str, + path_params: dict[str, Any], + path_styles: Optional[dict[str, tuple[str, bool]]] = None, + ) -> str: for key, value in path_params.items(): - path = path.replace("{" + key + "}", quote(str(value), safe="")) + style, explode = (path_styles or {}).get(key, ("simple", False)) + values = value if isinstance(value, list) else [value] + if style == "matrix": + # RFC 6570 path-style expansion: ;key=a,b (explode: ;key=a;key=b) + if explode: + rendered = "".join(";" + key + "=" + quote(str(v), safe="") for v in values) + else: + rendered = ";" + key + "=" + ",".join(quote(str(v), safe="") for v in values) + elif style == "label": + # RFC 6570 label expansion: .a,b (explode: .a.b) + rendered = "." + ("." if explode else ",").join(quote(str(v), safe="") for v in values) + else: + rendered = quote(str(value), safe="") + path = path.replace("{" + key + "}", rendered) return self._base_url + path +<%_ if (hasAllowReservedParams) { _%> + def _query_string( + self, + query_params: dict[str, Any], + collection_formats: Optional[dict[str, str]], + reserved: set[str], + ) -> str: + # Build the query string manually: httpx percent-encodes reserved + # characters, which allowReserved parameters must keep literal. + parts: list[str] = [] + for key, value in self._query(query_params, collection_formats).items(): + safe = "/:?#[]@!$&'()*+,;=" if key in reserved else "" + values = value if isinstance(value, list) else [value] + for v in values: + parts.append(quote(str(key), safe="") + "=" + quote(str(v), safe=safe)) + return "&".join(parts) + +<%_ } _%> def _deep_object(self, key: str, value: Any, out: dict[str, Any]) -> None: if isinstance(value, BaseModel): value = value.model_dump(mode="json", by_alias=True, exclude_unset=True) @@ -257,7 +310,7 @@ class <%- className %>: return None if isinstance(value, BaseModel): return value.model_dump(mode="json", by_alias=True, exclude_unset=True) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._dump(v) for v in value] if isinstance(value, dict): return {k: self._dump(v) for k, v in value.items()} @@ -278,6 +331,11 @@ class <%- className %>: || undefined; const bodyIsBinary = wholeBodyInputs.length === 1 && wholeBodyInputs[0].model.type === 'binary'; const bodyIsMultipart = bodyMediaType === 'multipart/form-data'; + const bodyIsUrlEncoded = bodyMediaType === 'application/x-www-form-urlencoded'; + const contentJsonInputs = [...queryInputs, ...headerInputs, ...cookieInputs].filter(k => k.model.mediaType === 'application/json'); + const styledPathInputs = pathInputs.filter(k => k.model.pathStyle); + const reservedInputs = queryInputs.filter(k => k.model.allowReserved); + const partContentTypes = (op.parametersBody && op.parametersBody.partContentTypes) || undefined; const hasTag = (op.tags && op.tags.length > 0); const methodPrefix = hasTag ? '_' : ''; _%> @@ -299,6 +357,15 @@ _%> query_params: dict[str, Any] = {<%_ queryInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} header_params: dict[str, Any] = {<%_ headerInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} cookie_params: dict[str, Any] = {<%_ cookieInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": <%- k.pythonName %><%_ }); _%>} +<%_ /* A content-based parameter (declared with `content` rather than + `schema`) is serialised to its media type — JSON — as a single value. */ _%> +<%_ contentJsonInputs.forEach((k) => { + const params = k.source.kind === 'query' ? 'query_params' : k.source.kind === 'header' ? 'header_params' : 'cookie_params'; _%> + if <%- params %>["<%- k.source.wireName %>"] is not None: + <%- params %>["<%- k.source.wireName %>"] = json.dumps( + self._dump(<%- params %>["<%- k.source.wireName %>"]), separators=(",", ":") + ) +<%_ }); _%> <%_ if (collectionInputs.length > 0) { _%> collection_formats: dict[str, str] = {<%_ collectionInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>": "<%- k.source.collectionFormat %>"<%_ }); _%>} <%_ } else { _%> @@ -318,7 +385,24 @@ _%> # `files=` or `data=` is supplied; binary values stream as file parts. _files = {k: v for k, v in _body_fields.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} _data = {k: self._dump(v) for k, v in _body_fields.items() if k not in _files} +<%_ if (partContentTypes) { _%> + # Per-part content types declared by the request body `encoding` object. + _part_content_types = {<%_ Object.entries(partContentTypes).forEach(([prop, ct], i) => { _%><%- i > 0 ? ', ' : '' %>"<%- prop %>": "<%- ct %>"<%_ }); _%>} + for _k, _ct in _part_content_types.items(): + if _k in _files: + _v = _files[_k] + _files[_k] = (None, _v, _ct) if not isinstance(_v, tuple) else _v + elif _k in _data: + _v = _data[_k] + _serialized = _v if isinstance(_v, str) else json.dumps(_v, separators=(",", ":")) + _files[_k] = (None, _serialized, _ct) + del _data[_k] +<%_ } _%> request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} +<%_ } else if (shape.bodyFromFields.mediaType === 'application/x-www-form-urlencoded') { _%> + # httpx URL-encodes `data=` as form key=value pairs (arrays as + # repeated keys); None fields were already omitted above. + request_kwargs: dict[str, Any] = {"data": {k: self._dump(v) for k, v in _body_fields.items()}} <%_ } else { _%> body = types_gen.<%- shape.bodyFromFields.model.pythonClassName %>.model_validate( _body_fields @@ -341,12 +425,20 @@ _%> body = <%- wholeBodyInputs[0].pythonName %> # httpx populates the Content-Type (and boundary) automatically # when `files=` or `data=` is supplied. + if isinstance(body, BaseModel): + body = body.model_dump(mode="python", by_alias=True, exclude_unset=True) if isinstance(body, dict): _files = {k: v for k, v in body.items() if hasattr(v, "read") or isinstance(v, (bytes, tuple))} - _data = {k: v for k, v in body.items() if k not in _files} + _data = {k: self._dump(v) for k, v in body.items() if k not in _files} request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} else: request_kwargs = {"files": body} +<%_ } else if (bodyIsUrlEncoded) { _%> + body = <%- wholeBodyInputs[0].pythonName %> + # httpx URL-encodes `data=` as form key=value pairs (arrays as + # repeated keys). + _form = self._dump(body) + request_kwargs: dict[str, Any] = {"data": {k: v for k, v in (_form or {}).items() if v is not None}} <%_ } else { _%> body = self._dump(<%- wholeBodyInputs[0].pythonName %>) request_kwargs: dict[str, Any] = {"json": body} @@ -359,11 +451,22 @@ _%> body = None request_kwargs: dict[str, Any] = {} <%_ } _%> +<%_ if (reservedInputs.length > 0) { _%> +<%_ /* allowReserved query parameters keep reserved characters literal, so the + query string is built manually and appended to the URL — httpx would + percent-encode values passed via `params=`. */ _%> + _qs = self._query_string(query_params, collection_formats, {<%_ reservedInputs.forEach((k, i) => { _%><%- i > 0 ? ', ' : '' %>"<%- k.source.wireName %>"<%_ }); _%>}) + _request_url = <%- urlExpr(op, styledPathInputs) %> + ("?" + _qs if _qs else "") +<%_ } else { _%> + _request_url = <%- urlExpr(op, styledPathInputs) %> +<%_ } _%> <%_ if (op.isStreaming) { _%> with self._client.stream( "<%- op.method %>", - self._url("<%- op.path %>", path_params), + _request_url, +<%_ if (reservedInputs.length === 0) { _%> params=self._query(query_params, collection_formats), +<%_ } _%> headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), **request_kwargs, @@ -403,8 +506,10 @@ _%> <%_ } else { _%> response = self._client.request( "<%- op.method %>", - self._url("<%- op.path %>", path_params), + _request_url, +<%_ if (reservedInputs.length === 0) { _%> params=self._query(query_params, collection_formats), +<%_ } _%> headers=self._headers(header_params, collection_formats), cookies=self._cookies(cookie_params), **request_kwargs, diff --git a/packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts new file mode 100644 index 000000000..7325ec01b --- /dev/null +++ b/packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts @@ -0,0 +1,446 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Tree } from '@nx/devkit'; +import { Spec } from '../utils/types'; +import { PythonVerifier } from '../../utils/test/py.spec'; +import { + callGeneratedClient, + createTree, + generateAndRead, +} from './generator.utils.spec'; + +/** + * Parity coverage for OpenAPI spec-compliance features mirrored from the + * ts-client (#922, #930): urlencoded bodies, tuples (3.1 prefixItems), + * matrix/label path styles, allowReserved query parameters, content-based + * parameters, and multipart part content types from the encoding object. + */ +describe('openApiPyClientGenerator - spec compliance', () => { + let tree: Tree; + let verifier: PythonVerifier; + + beforeAll(() => { + verifier = new PythonVerifier(); + }); + + afterAll(async () => { + await verifier.shutdown(); + }); + + beforeEach(() => { + tree = createTree(); + }); + + it('sends urlencoded form bodies as key=value pairs, not JSON', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/login': { + post: { + operationId: 'login', + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: { + type: 'object', + required: ['username', 'password'], + properties: { + username: { type: 'string' }, + password: { type: 'string' }, + remember: { type: 'boolean' }, + scopes: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { username: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + // Must not JSON-encode a urlencoded body. + expect(client).not.toMatch(/"json": body[\s\S]*?x-www-form-urlencoded/); + + const res = await callGeneratedClient( + verifier, + 'login', + { + username: 'alice', + password: 'p&w=1', + remember: true, + scopes: ['read', 'write'], + }, + { json: { username: 'alice' } }, + ); + expect(res.ok).toBe(true); + const contentType = res.calls?.[0]?.headers['content-type'] ?? ''; + expect(contentType).toMatch(/^application\/x-www-form-urlencoded/); + const body = res.calls?.[0]?.body ?? ''; + // URL-encoded pairs, arrays as repeated keys, not JSON. + expect(body).not.toMatch(/^\s*\{/); + expect(body).toContain('username=alice'); + expect(body).toContain('password=p%26w%3D1'); + expect(body).toContain('scopes=read'); + expect(body).toContain('scopes=write'); + }); + + it('omits None optional fields from a urlencoded form body', async () => { + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/login': { + post: { + operationId: 'login', + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: { + type: 'object', + required: ['username'], + properties: { + username: { type: 'string' }, + nickname: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { '204': { description: 'No content' } }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'login', + { username: 'alice' }, + { status: 204 }, + ); + expect(res.ok).toBe(true); + expect(res.calls?.[0]?.body).toBe('username=alice'); + }); + + it('types tuples positionally and round-trips them', async () => { + const spec: Spec = { + openapi: '3.1.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/tuples': { + get: { + operationId: 'getTuples', + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TupleOutput' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TupleOutput: { + type: 'object', + required: ['pair', 'points'], + properties: { + pair: { + type: 'array', + prefixItems: [{ type: 'integer' }, { type: 'string' }], + }, + points: { + type: 'array', + items: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'number' }], + }, + }, + }, + }, + }, + }, + }; + const { types } = await generateAndRead(verifier, tree, spec); + expect(types).toContain('pair: tuple[int, str]'); + expect(types).toContain('points: list[tuple[float, float]]'); + + const res = await callGeneratedClient( + verifier, + 'get_tuples', + {}, + { + json: { + pair: [42, 'answer'], + points: [ + [1, 2], + [3, 4], + ], + }, + }, + ); + expect(res.ok).toBe(true); + expect(res.value).toEqual({ + pair: [42, 'answer'], + points: [ + [1, 2], + [3, 4], + ], + }); + }); + + it('serialises matrix and label path styles', async () => { + const spec: Spec = { + openapi: '3.0.3', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/resource/{matrixParam}/{labelParam}': { + get: { + operationId: 'styled', + parameters: [ + { + name: 'matrixParam', + in: 'path', + required: true, + style: 'matrix', + schema: { type: 'array', items: { type: 'string' } }, + }, + { + name: 'labelParam', + in: 'path', + required: true, + style: 'label', + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'styled', + { matrix_param: ['a', 'b'], label_param: 'x' }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + const url = decodeURIComponent(res.calls?.[0]?.url ?? ''); + // RFC 6570: ;matrixParam=a,b (non-explode) and .x (label) + expect(url).toContain('/resource/;matrixParam=a,b/.x'); + }); + + it('serialises exploded matrix path styles', async () => { + const spec: Spec = { + openapi: '3.0.3', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/resource/{ids}': { + get: { + operationId: 'exploded', + parameters: [ + { + name: 'ids', + in: 'path', + required: true, + style: 'matrix', + explode: true, + schema: { type: 'array', items: { type: 'string' } }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'exploded', + { ids: ['a', 'b'] }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + const url = decodeURIComponent(res.calls?.[0]?.url ?? ''); + expect(url).toContain('/resource/;ids=a;ids=b'); + }); + + it('keeps reserved characters literal for allowReserved query parameters', async () => { + const spec: Spec = { + openapi: '3.0.3', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/search': { + get: { + operationId: 'search', + parameters: [ + { + name: 'path', + in: 'query', + required: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + name: 'q', + in: 'query', + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'search', + { path: 'a/b:c,d', q: 'x y' }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + const url = res.calls?.[0]?.url ?? ''; + // Reserved characters stay literal for `path`; `q` is percent-encoded. + expect(url).toContain('path=a/b:c,d'); + expect(url).toMatch(/q=x(%20|\+)y/); + }); + + it('JSON-serialises a content-based query parameter', async () => { + const spec: Spec = { + openapi: '3.0.3', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/search': { + get: { + operationId: 'search', + parameters: [ + { + name: 'filter', + in: 'query', + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + colour: { type: 'string' }, + since: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { 'application/json': { schema: { type: 'string' } } }, + }, + }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'search', + { filter: { colour: 'red' } }, + { json: 'ok' }, + ); + expect(res.ok).toBe(true); + const url = decodeURIComponent(res.calls?.[0]?.url ?? ''); + // The whole object is a single JSON-serialised query value. + expect(url).toContain('filter={"colour":"red"}'); + }); + + it('sends multipart parts with content types declared by the encoding object', async () => { + const spec: Spec = { + openapi: '3.0.3', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/upload': { + post: { + operationId: 'upload', + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['metadata'], + properties: { + metadata: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + note: { type: 'string' }, + }, + }, + encoding: { + metadata: { contentType: 'application/json' }, + }, + }, + }, + }, + responses: { '204': { description: 'No content' } }, + }, + }, + }, + }; + const { client } = await generateAndRead(verifier, tree, spec); + expect(client).toContain('"metadata": "application/json"'); + + const res = await callGeneratedClient( + verifier, + 'upload', + { metadata: { name: 'x' }, note: 'hi' }, + { status: 204 }, + ); + expect(res.ok).toBe(true); + const body = res.calls?.[0]?.body ?? ''; + // The metadata part declares its JSON content type inside the multipart body. + expect(body).toContain('application/json'); + expect(body).toContain('{"name":"x"}'); + }); +}); diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data.ts b/packages/nx-plugin/src/open-api/utils/codegen-data.ts index b52b9ee44..5561db37b 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data.ts @@ -308,7 +308,8 @@ const annotateReferencedCollectionKind = ( } else if ( referenced.export === 'one-of' || referenced.export === 'any-of' || - referenced.export === 'enum' + referenced.export === 'enum' || + referenced.export === 'tuple' ) { entry.referencedCollectionKind = 'alias'; } diff --git a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts index a506688b8..bfbcb43ef 100644 --- a/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts +++ b/packages/nx-plugin/src/open-api/utils/codegen-data/languages.ts @@ -170,7 +170,13 @@ export const toPythonClassName = (name: string): string => { export const isPythonBuiltin = (type: string): boolean => { if (!type) return true; if (PYTHON_BUILTIN_TYPES.has(type)) return true; - if (type.startsWith('list[') || type.startsWith('dict[')) return true; + if ( + type.startsWith('list[') || + type.startsWith('dict[') || + type.startsWith('tuple[') + ) { + return true; + } if (type.startsWith('Optional[') || type.startsWith('Union[')) return true; if (type.startsWith('Literal[')) return true; return false; @@ -315,6 +321,25 @@ export const qualifyPythonType = ( if (list) return `list[${qualifyPythonType(list[1], prefix)}]`; const dict = /^dict\[str, (.*)\]$/s.exec(type); if (dict) return `dict[str, ${qualifyPythonType(dict[1], prefix)}]`; + const tuple = /^tuple\[(.*)\]$/s.exec(type); + if (tuple) { + // Split on top-level commas only — members may themselves be generics. + const members: string[] = []; + let depth = 0; + let current = ''; + for (const ch of tuple[1]) { + if (ch === '[') depth++; + if (ch === ']') depth--; + if (ch === ',' && depth === 0) { + members.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) members.push(current.trim()); + return `tuple[${members.map((m) => qualifyPythonType(m, prefix)).join(', ')}]`; + } if ( type.startsWith('Optional[') || type.startsWith('Union[') || @@ -354,6 +379,8 @@ export const toPythonAnnotation = (property: Model): string => { } case 'array': return `list[${collectionElement()}]`; + case 'tuple': + return `tuple[${p.properties.map((member) => render(member)).join(', ')}]`; case 'dictionary': return `dict[str, ${collectionElement()}]`; case 'one-of': diff --git a/packages/nx-plugin/src/utils/test/python-worker/worker.py b/packages/nx-plugin/src/utils/test/python-worker/worker.py index fde006069..cf5c8b685 100644 --- a/packages/nx-plugin/src/utils/test/python-worker/worker.py +++ b/packages/nx-plugin/src/utils/test/python-worker/worker.py @@ -55,6 +55,8 @@ def __init__(self, entries: list[dict]) -> None: def _record(self, request: httpx.Request) -> None: try: + # Multipart bodies are streamed; read() materialises request.content. + request.read() body = request.content.decode("utf-8") if request.content else None except Exception: body = None @@ -101,6 +103,11 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: return self._match(request) async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + try: + # Async multipart bodies stream; aread() materialises request.content. + await request.aread() + except Exception: + pass return self._match(request) From efd3431427b6b04ec8da993153413f3474fa00a1 Mon Sep 17 00:00:00 2001 From: test Date: Wed, 15 Jul 2026 00:23:25 +0000 Subject: [PATCH 4/4] fix(open-api#py-client): send primitive urlencoded request bodies verbatim Parity with #937: a urlencoded body whose schema is a primitive (e.g. a raw pre-encoded string) is sent as-is via httpx content= with the declared Content-Type, rather than being dict-iterated for form encoding. Object bodies are unchanged. The #938 generation-time guard for non-encodable urlencoded array bodies is inherited from shared codegen; covered with a py-client test. --- .../files/async/async_client_gen.py.template | 9 ++- .../files/sync/client_gen.py.template | 9 ++- .../generator.spec-compliance.spec.ts | 66 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template index af7de5ec4..1c375f511 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/async/async_client_gen.py.template @@ -422,12 +422,19 @@ _%> request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} else: request_kwargs = {"files": body} -<%_ } else if (bodyIsUrlEncoded) { _%> +<%_ } else if (bodyIsUrlEncoded && !wholeBodyInputs[0].model.isPrimitive) { _%> body = <%- wholeBodyInputs[0].pythonName %> # httpx URL-encodes `data=` as form key=value pairs (arrays as # repeated keys). _form = self._dump(body) request_kwargs: dict[str, Any] = {"data": {k: v for k, v in (_form or {}).items() if v is not None}} +<%_ } else if (bodyIsUrlEncoded) { _%> + # A primitive urlencoded body (e.g. a raw pre-encoded string) is sent + # verbatim; only object bodies are form-encoded. + body = <%- wholeBodyInputs[0].pythonName %> + request_kwargs: dict[str, Any] = {"content": None if body is None else str(body)} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- bodyMediaType %>") <%_ } else { _%> body = self._dump(<%- wholeBodyInputs[0].pythonName %>) request_kwargs: dict[str, Any] = {"json": body} diff --git a/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template index da8897c9e..f6694cd28 100644 --- a/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template +++ b/packages/nx-plugin/src/open-api/py-client/files/sync/client_gen.py.template @@ -433,12 +433,19 @@ _%> request_kwargs: dict[str, Any] = {"files": _files or None, "data": _data or None} else: request_kwargs = {"files": body} -<%_ } else if (bodyIsUrlEncoded) { _%> +<%_ } else if (bodyIsUrlEncoded && !wholeBodyInputs[0].model.isPrimitive) { _%> body = <%- wholeBodyInputs[0].pythonName %> # httpx URL-encodes `data=` as form key=value pairs (arrays as # repeated keys). _form = self._dump(body) request_kwargs: dict[str, Any] = {"data": {k: v for k, v in (_form or {}).items() if v is not None}} +<%_ } else if (bodyIsUrlEncoded) { _%> + # A primitive urlencoded body (e.g. a raw pre-encoded string) is sent + # verbatim; only object bodies are form-encoded. + body = <%- wholeBodyInputs[0].pythonName %> + request_kwargs: dict[str, Any] = {"content": None if body is None else str(body)} + if body is not None and not self._config.omit_content_type_header: + header_params.setdefault("Content-Type", "<%- bodyMediaType %>") <%_ } else { _%> body = self._dump(<%- wholeBodyInputs[0].pythonName %>) request_kwargs: dict[str, Any] = {"json": body} diff --git a/packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts b/packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts index 7325ec01b..fd74cc02c 100644 --- a/packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts +++ b/packages/nx-plugin/src/open-api/py-client/generator.spec-compliance.spec.ts @@ -141,6 +141,72 @@ describe('openApiPyClientGenerator - spec compliance', () => { expect(res.calls?.[0]?.body).toBe('username=alice'); }); + it('sends a primitive urlencoded body verbatim', async () => { + // A urlencoded body whose schema is a raw (pre-encoded) string has no + // fields to form-encode — it is sent as-is with the declared content type. + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/raw': { + post: { + operationId: 'sendRaw', + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: { type: 'string' }, + }, + }, + }, + responses: { '204': { description: 'No content' } }, + }, + }, + }, + }; + await generateAndRead(verifier, tree, spec); + const res = await callGeneratedClient( + verifier, + 'send_raw', + {}, + { status: 204 }, + ['a=1&b=two%20words'], + ); + expect(res.ok).toBe(true); + expect(res.calls?.[0]?.body).toBe('a=1&b=two%20words'); + expect(res.calls?.[0]?.headers['content-type']).toBe( + 'application/x-www-form-urlencoded', + ); + }); + + it('throws at generation time for a urlencoded array body', async () => { + // Inherited from the shared codegen guard (#938): a top-level array has + // no property names to key on, so it has no defined form encoding. + const spec: Spec = { + openapi: '3.0.0', + info: { title: 'TestApi', version: '1.0.0' }, + paths: { + '/urlencoded-array': { + post: { + operationId: 'sendArray', + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + responses: { '204': { description: 'No content' } }, + }, + }, + }, + }; + await expect(generateAndRead(verifier, tree, spec)).rejects.toThrow( + /x-www-form-urlencoded.*array.*no defined form encoding/, + ); + }); + it('types tuples positionally and round-trips them', async () => { const spec: Spec = { openapi: '3.1.0',