diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 495c681..60b11b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Get GitHub OIDC Token if: github.repository == 'stainless-sdks/zeroentropy-python' id: github-oidc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c373724..46b9b6b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.8" + ".": "0.1.0-alpha.9" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 9da5d63..1d67610 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-b5badb1383675a2606a4e65b515426cf70010d0e834372de7bcf39fda4939692.yml -openapi_spec_hash: b3b0c03c89fe5ea66cc91fea2fa9726b +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-cd86445a8ef095a12e7bf74baddc7d5a8225531f8edb88ba613e12a52e219a42.yml +openapi_spec_hash: 6da635b19c554a476ea9c967b619ae5b config_hash: f5fb1effd4b0e263e1e93de3f573f46f diff --git a/CHANGELOG.md b/CHANGELOG.md index 8034868..a024c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.0-alpha.9 (2026-03-03) + +Full Changelog: [v0.1.0-alpha.8...v0.1.0-alpha.9](https://github.com/zeroentropy-ai/zeroentropy-python/compare/v0.1.0-alpha.8...v0.1.0-alpha.9) + +### Features + +* **api:** manual updates ([d172a59](https://github.com/zeroentropy-ai/zeroentropy-python/commit/d172a59fa7fe5e224aee24fd345c8ba1e69a1233)) +* **client:** add custom JSON encoder for extended type support ([16fc282](https://github.com/zeroentropy-ai/zeroentropy-python/commit/16fc2820ec1e883128e16be9f296b1070666f199)) + + +### Chores + +* **ci:** upgrade `actions/github-script` ([f2d6a4c](https://github.com/zeroentropy-ai/zeroentropy-python/commit/f2d6a4c509ae5ce57fc64c592b3e5039cf40a9ba)) +* format all `api.md` files ([fbc0e7e](https://github.com/zeroentropy-ai/zeroentropy-python/commit/fbc0e7e1d385c141f59a3b3c674fd78bdeea879a)) +* **internal:** add request options to SSE classes ([1c67b64](https://github.com/zeroentropy-ai/zeroentropy-python/commit/1c67b643e2c6d48d53123e404f0b89f8a9178713)) +* **internal:** bump dependencies ([1fd1cce](https://github.com/zeroentropy-ai/zeroentropy-python/commit/1fd1cce350f354877437976a2f795cf810c6e039)) +* **internal:** fix lint error on Python 3.14 ([31de67c](https://github.com/zeroentropy-ai/zeroentropy-python/commit/31de67c7c1b148810644411e0d62a9ef1eb6fa7e)) +* **internal:** make `test_proxy_environment_variables` more resilient ([ed5da02](https://github.com/zeroentropy-ai/zeroentropy-python/commit/ed5da0254ec6327ac53a498380975e0d0f985681)) +* **internal:** make `test_proxy_environment_variables` more resilient to env ([1f31fae](https://github.com/zeroentropy-ai/zeroentropy-python/commit/1f31fae37fe8f8b789e7d1303311d2a657a932a2)) +* update mock server docs ([78e43f9](https://github.com/zeroentropy-ai/zeroentropy-python/commit/78e43f9c7dc3216aaba24fdf9893103279b91fff)) + ## 0.1.0-alpha.8 (2026-01-21) Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/zeroentropy-ai/zeroentropy-python/compare/v0.1.0-alpha.7...v0.1.0-alpha.8) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e66002e..7918a64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,8 +88,7 @@ $ pip install ./path-to-wheel-file.whl Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. ```sh -# you will need npm installed -$ npx prism mock path/to/your/openapi.yml +$ ./scripts/mock ``` ```sh diff --git a/pyproject.toml b/pyproject.toml index 2512f10..4fe374f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zeroentropy" -version = "0.1.0-alpha.8" +version = "0.1.0-alpha.9" description = "The official Python library for the ZeroEntropy API" dynamic = ["readme"] license = "Apache-2.0" @@ -69,7 +69,7 @@ format = { chain = [ # run formatting again to fix any inconsistencies when imports are stripped "format:ruff", ]} -"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:docs" = "bash -c 'python scripts/utils/ruffen-docs.py README.md $(find . -type f -name api.md)'" "format:ruff" = "ruff format" "lint" = { chain = [ diff --git a/requirements-dev.lock b/requirements-dev.lock index 9904783..eb2e668 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,14 +12,14 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.2 +aiohttp==3.13.3 # via httpx-aiohttp # via zeroentropy aiosignal==1.4.0 # via aiohttp annotated-types==0.7.0 # via pydantic -anyio==4.12.0 +anyio==4.12.1 # via httpx # via zeroentropy argcomplete==3.6.3 @@ -31,7 +31,7 @@ attrs==25.4.0 # via nox backports-asyncio-runner==1.2.0 # via pytest-asyncio -certifi==2025.11.12 +certifi==2026.1.4 # via httpcore # via httpx colorlog==6.10.1 @@ -61,7 +61,7 @@ httpx==0.28.1 # via httpx-aiohttp # via respx # via zeroentropy -httpx-aiohttp==0.1.9 +httpx-aiohttp==0.1.12 # via zeroentropy humanize==4.13.0 # via nox @@ -69,7 +69,7 @@ idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==8.7.0 +importlib-metadata==8.7.1 iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 @@ -82,14 +82,14 @@ multidict==6.7.0 mypy==1.17.0 mypy-extensions==1.1.0 # via mypy -nodeenv==1.9.1 +nodeenv==1.10.0 # via pyright nox==2025.11.12 packaging==25.0 # via dependency-groups # via nox # via pytest -pathspec==0.12.1 +pathspec==1.0.3 # via mypy platformdirs==4.4.0 # via virtualenv @@ -115,13 +115,13 @@ python-dateutil==2.9.0.post0 # via time-machine respx==0.22.0 rich==14.2.0 -ruff==0.14.7 +ruff==0.14.13 six==1.17.0 # via python-dateutil sniffio==1.3.1 # via zeroentropy time-machine==2.19.0 -tomli==2.3.0 +tomli==2.4.0 # via dependency-groups # via mypy # via nox @@ -141,7 +141,7 @@ typing-extensions==4.15.0 # via zeroentropy typing-inspection==0.4.2 # via pydantic -virtualenv==20.35.4 +virtualenv==20.36.1 # via nox yarl==1.22.0 # via aiohttp diff --git a/requirements.lock b/requirements.lock index 4f59ec7..b609058 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,21 +12,21 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.2 +aiohttp==3.13.3 # via httpx-aiohttp # via zeroentropy aiosignal==1.4.0 # via aiohttp annotated-types==0.7.0 # via pydantic -anyio==4.12.0 +anyio==4.12.1 # via httpx # via zeroentropy async-timeout==5.0.1 # via aiohttp attrs==25.4.0 # via aiohttp -certifi==2025.11.12 +certifi==2026.1.4 # via httpcore # via httpx distro==1.9.0 @@ -43,7 +43,7 @@ httpcore==1.0.9 httpx==0.28.1 # via httpx-aiohttp # via zeroentropy -httpx-aiohttp==0.1.9 +httpx-aiohttp==0.1.12 # via zeroentropy idna==3.11 # via anyio diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py index ea48701..ddeaa86 100644 --- a/src/zeroentropy/_base_client.py +++ b/src/zeroentropy/_base_client.py @@ -86,6 +86,7 @@ APIConnectionError, APIResponseValidationError, ) +from ._utils._json import openapi_dumps log: logging.Logger = logging.getLogger(__name__) @@ -554,8 +555,10 @@ def _build_request( kwargs["content"] = options.content elif isinstance(json_data, bytes): kwargs["content"] = json_data - else: - kwargs["json"] = json_data if is_given(json_data) else None + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None kwargs["files"] = files else: headers.pop("Content-Type", None) diff --git a/src/zeroentropy/_compat.py b/src/zeroentropy/_compat.py index bdef67f..786ff42 100644 --- a/src/zeroentropy/_compat.py +++ b/src/zeroentropy/_compat.py @@ -139,6 +139,7 @@ def model_dump( exclude_defaults: bool = False, warnings: bool = True, mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( @@ -148,13 +149,12 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, + by_alias=by_alias, ) return cast( "dict[str, Any]", model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) ), ) diff --git a/src/zeroentropy/_response.py b/src/zeroentropy/_response.py index f63ea68..53510bc 100644 --- a/src/zeroentropy/_response.py +++ b/src/zeroentropy/_response.py @@ -152,6 +152,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -162,6 +163,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=extract_stream_chunk_type(self._stream_cls), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -175,6 +177,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=cast_to, response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) diff --git a/src/zeroentropy/_streaming.py b/src/zeroentropy/_streaming.py index 838d5cd..efde25b 100644 --- a/src/zeroentropy/_streaming.py +++ b/src/zeroentropy/_streaming.py @@ -4,7 +4,7 @@ import json import inspect from types import TracebackType -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable import httpx @@ -13,6 +13,7 @@ if TYPE_CHECKING: from ._client import ZeroEntropy, AsyncZeroEntropy + from ._models import FinalRequestOptions _T = TypeVar("_T") @@ -22,7 +23,7 @@ class Stream(Generic[_T]): """Provides the core interface to iterate over a synchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEBytesDecoder def __init__( @@ -31,10 +32,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: ZeroEntropy, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() @@ -85,7 +88,7 @@ class AsyncStream(Generic[_T]): """Provides the core interface to iterate over an asynchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEDecoder | SSEBytesDecoder def __init__( @@ -94,10 +97,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: AsyncZeroEntropy, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() diff --git a/src/zeroentropy/_utils/_compat.py b/src/zeroentropy/_utils/_compat.py index dd70323..2c70b29 100644 --- a/src/zeroentropy/_utils/_compat.py +++ b/src/zeroentropy/_utils/_compat.py @@ -26,7 +26,7 @@ def is_union(tp: Optional[Type[Any]]) -> bool: else: import types - return tp is Union or tp is types.UnionType + return tp is Union or tp is types.UnionType # type: ignore[comparison-overlap] def is_typeddict(tp: Type[Any]) -> bool: diff --git a/src/zeroentropy/_utils/_json.py b/src/zeroentropy/_utils/_json.py new file mode 100644 index 0000000..6058421 --- /dev/null +++ b/src/zeroentropy/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/src/zeroentropy/_version.py b/src/zeroentropy/_version.py index 69be61f..61ae32c 100644 --- a/src/zeroentropy/_version.py +++ b/src/zeroentropy/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "zeroentropy" -__version__ = "0.1.0-alpha.8" # x-release-please-version +__version__ = "0.1.0-alpha.9" # x-release-please-version diff --git a/src/zeroentropy/resources/queries.py b/src/zeroentropy/resources/queries.py index 6e89238..fb8eb79 100644 --- a/src/zeroentropy/resources/queries.py +++ b/src/zeroentropy/resources/queries.py @@ -130,6 +130,7 @@ def top_pages( query: str, filter: Optional[Dict[str, object]] | Omit = omit, include_content: bool | Omit = omit, + include_metadata: bool | Omit = omit, latency_mode: Literal["low", "high"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -155,6 +156,9 @@ def top_pages( include_content: If set to true, then the content of all pages will be returned. + include_metadata: Whether or not to include the document metadata in the response. If not + provided, then the default will be `False`. + latency_mode: This option selects between our two latency modes. The higher latency mode takes longer, but can allow for more accurate responses. If desired, test both to customize your search experience for your particular use-case, or use the @@ -178,6 +182,7 @@ def top_pages( "query": query, "filter": filter, "include_content": include_content, + "include_metadata": include_metadata, "latency_mode": latency_mode, }, query_top_pages_params.QueryTopPagesParams, @@ -371,6 +376,7 @@ async def top_pages( query: str, filter: Optional[Dict[str, object]] | Omit = omit, include_content: bool | Omit = omit, + include_metadata: bool | Omit = omit, latency_mode: Literal["low", "high"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -396,6 +402,9 @@ async def top_pages( include_content: If set to true, then the content of all pages will be returned. + include_metadata: Whether or not to include the document metadata in the response. If not + provided, then the default will be `False`. + latency_mode: This option selects between our two latency modes. The higher latency mode takes longer, but can allow for more accurate responses. If desired, test both to customize your search experience for your particular use-case, or use the @@ -419,6 +428,7 @@ async def top_pages( "query": query, "filter": filter, "include_content": include_content, + "include_metadata": include_metadata, "latency_mode": latency_mode, }, query_top_pages_params.QueryTopPagesParams, diff --git a/src/zeroentropy/types/query_top_pages_params.py b/src/zeroentropy/types/query_top_pages_params.py index 8a97698..370e4e9 100644 --- a/src/zeroentropy/types/query_top_pages_params.py +++ b/src/zeroentropy/types/query_top_pages_params.py @@ -32,6 +32,12 @@ class QueryTopPagesParams(TypedDict, total=False): include_content: bool """If set to true, then the content of all pages will be returned.""" + include_metadata: bool + """Whether or not to include the document metadata in the response. + + If not provided, then the default will be `False`. + """ + latency_mode: Literal["low", "high"] """This option selects between our two latency modes. diff --git a/src/zeroentropy/types/query_top_pages_response.py b/src/zeroentropy/types/query_top_pages_response.py index ca243f1..2990cae 100644 --- a/src/zeroentropy/types/query_top_pages_response.py +++ b/src/zeroentropy/types/query_top_pages_response.py @@ -1,10 +1,33 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Optional +from typing import Dict, List, Union, Optional from .._models import BaseModel -__all__ = ["QueryTopPagesResponse", "Result"] +__all__ = ["QueryTopPagesResponse", "DocumentResult", "Result"] + + +class DocumentResult(BaseModel): + file_url: str + """ + A URL to the document data, which can be used to download the raw document + content or to display the document in frontend applications. + + NOTE: If a `/documents/update-document` call returned a new document id, then + this url will be invalidated and must be retrieved again. + """ + + metadata: Optional[Dict[str, Union[str, List[str]]]] = None + """The metadata for that document. + + Will be `None` if `include_metadata` is `False`. + """ + + path: str + """The path of the document.""" + + score: float + """The relevancy score assigned to this document.""" class Result(BaseModel): @@ -43,4 +66,12 @@ class Result(BaseModel): class QueryTopPagesResponse(BaseModel): + document_results: List[DocumentResult] + """The array of associated document information. + + Note how each result page has an associated document path. After deduplicating + the document paths, this array will contain document info for each document path + that is referenced by at least one page result. + """ + results: List[Result] diff --git a/tests/api_resources/test_queries.py b/tests/api_resources/test_queries.py index 80e938c..51091c0 100644 --- a/tests/api_resources/test_queries.py +++ b/tests/api_resources/test_queries.py @@ -88,6 +88,7 @@ def test_method_top_pages_with_all_params(self, client: ZeroEntropy) -> None: query="query", filter={"foo": "bar"}, include_content=True, + include_metadata=True, latency_mode="low", ) assert_matches_type(QueryTopPagesResponse, query, path=["response"]) @@ -243,6 +244,7 @@ async def test_method_top_pages_with_all_params(self, async_client: AsyncZeroEnt query="query", filter={"foo": "bar"}, include_content=True, + include_metadata=True, latency_mode="low", ) assert_matches_type(QueryTopPagesResponse, query, path=["response"]) diff --git a/tests/test_client.py b/tests/test_client.py index 68c5407..6582560 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -957,6 +957,14 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultHttpxClient() @@ -1865,6 +1873,14 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultAsyncHttpxClient() diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 0000000..f6fc961 --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from zeroentropy import _compat +from zeroentropy._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}'