From c743d814166a1bb5c47ea9929c020f467c923f0d Mon Sep 17 00:00:00 2001 From: "typesafe-public-bot[bot]" <321128928+typesafe-public-bot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:43:15 +0000 Subject: [PATCH] Release v0.7.3 Co-authored-by: Daniel Gafni --- docs/changelog.md | 15 ++++++ pyproject.toml | 7 ++- src/typesafe_sdk/_core/client/aio/client.py | 38 ++++++++++++-- src/typesafe_sdk/_core/client/sync/client.py | 9 ++-- src/typesafe_sdk/_core/config.py | 4 ++ src/typesafe_sdk/_core/endpoints.py | 3 ++ src/typesafe_sdk/_core/question_types.py | 36 ++++++++++---- src/typesafe_sdk/_core/questions.py | 26 ++++++++-- tests/test_clients.py | 14 +++--- tests/test_config.py | 7 +++ tests/test_pydantic_response_models.py | 20 ++++---- tests/test_questions.py | 42 +++++++++++++--- tests/test_tls.py | 52 ++++++++++++++++++++ tests/test_types.py | 2 +- tests/typing/negative/noul.py | 9 ++++ tests/typing/pydantic_response_models.py | 12 ++--- uv.lock | 15 ++++-- 17 files changed, 255 insertions(+), 56 deletions(-) create mode 100644 tests/test_tls.py create mode 100644 tests/typing/negative/noul.py diff --git a/docs/changelog.md b/docs/changelog.md index 6599b8d..9c83a8d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,21 @@ icon: lucide/history # Changelog +## v0.7.3 (2026-10-09) + +### Bug fixes + +- prevent async client crashes during concurrent TLS connections with `truststore<=0.10.4` +- add more request validation checks + +### Documentation + +- add a new Performance section with tips for high-concurrency usage + +### Performance + +- update the default settings to keep all pooled connections alive for 30 seconds + ## v0.7.2 (2026-09-26) ### Miscellaneous diff --git a/pyproject.toml b/pyproject.toml index a272423..a7b8a0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "typesafe-sdk" -version = "0.7.2" +version = "0.7.3" description = "Python SDK for TypeSafe AI API." readme = "README.md" license = "MIT" @@ -34,9 +34,12 @@ classifiers = [ ] dependencies = [ "httpx2>=2.0.0", + "packaging>=24.0", "pydantic>=2.12.0", "pydantic-core>=2.41.1", "tenacity>=9.0.0", + # TODO: bump constraint once https://github.com/sethmlarson/truststore/pull/219 is released + "truststore>=0.10.0 ; sys_platform != 'emscripten'", "typing-extensions>=4.13.0", ] @@ -74,7 +77,7 @@ docs = [ "zensical>=0.0.60", ] codegen = [ - "datamodel-code-generator>=0.79.0", + "datamodel-code-generator>=0.81.0", ] [tool.pytest.ini_options] diff --git a/src/typesafe_sdk/_core/client/aio/client.py b/src/typesafe_sdk/_core/client/aio/client.py index d2ef919..d975286 100644 --- a/src/typesafe_sdk/_core/client/aio/client.py +++ b/src/typesafe_sdk/_core/client/aio/client.py @@ -1,15 +1,18 @@ """Asynchronous API client.""" +import ssl +import threading from collections.abc import Mapping from functools import cached_property from types import TracebackType -from typing import overload +from typing import Any, overload import httpx2 +from packaging.version import Version from typing_extensions import Self from typesafe_sdk._core.client.aio.models import AsyncModels -from typesafe_sdk._core.config import Config +from typesafe_sdk._core.config import DEFAULT_LIMITS, Config from typesafe_sdk._core.endpoints import prepare_system_one from typesafe_sdk._core.json_types import JSONContent, JSONValue from typesafe_sdk._core.question_types import Question @@ -18,6 +21,28 @@ from typesafe_sdk._core.retry import RetryPolicy, build_tenacity_async from typesafe_sdk._core.transport import Request, ResponseT, send_async +def _ssl_context() -> ssl.SSLContext: + """httpx2's default SSL context, with `wrap_bio()` serialized on affected truststore versions. + + AnyIO calls `wrap_bio()` from worker threads, and truststore<=0.10.4 reconfigures the shared + context there without a lock, which can abort the interpreter (sethmlarson/truststore#221). + """ + ctx = httpx2.create_ssl_context() + import truststore # noqa: PLC0415 - Unavailable on emscripten; httpx2 imports it lazily too. + + if not isinstance(ctx, truststore.SSLContext) or Version(truststore.__version__) > Version("0.10.4"): + return ctx + wrap_bio = ctx.wrap_bio + lock = threading.Lock() + + def locked_wrap_bio(*args: Any, **kwargs: Any) -> ssl.SSLObject: + with lock: + return wrap_bio(*args, **kwargs) + + ctx.wrap_bio = locked_wrap_bio + return ctx + + class AsyncTypeSafeClient: def __init__( self, @@ -91,7 +116,11 @@ class AsyncTypeSafeClient: timeout = http_client.timeout self._config = Config.resolve(api_key, base_url, model, timeout, headers) self._retry = build_tenacity_async(retry) - self._http_client = httpx2.AsyncClient(timeout=self._config.timeout, transport=transport) if http_client is None else http_client + self._http_client = ( + httpx2.AsyncClient(timeout=self._config.timeout, limits=DEFAULT_LIMITS, transport=transport, verify=_ssl_context()) + if http_client is None + else http_client + ) @cached_property def models(self) -> AsyncModels: @@ -170,7 +199,8 @@ class AsyncTypeSafeClient: name and model and token usage details when no custom model is supplied. Raises: - TypeSafeError: Questions are empty or a score question's criteria list is empty. + TypeSafeError: State is `None`, questions are empty, a score or choice question has no criteria, + or a noul question has neither instructions nor criteria. TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. TypeSafeAPIResponseValidationError: The response body does not match the response model. diff --git a/src/typesafe_sdk/_core/client/sync/client.py b/src/typesafe_sdk/_core/client/sync/client.py index 7cd29e4..5924578 100644 --- a/src/typesafe_sdk/_core/client/sync/client.py +++ b/src/typesafe_sdk/_core/client/sync/client.py @@ -9,7 +9,7 @@ import httpx2 from typing_extensions import Self from typesafe_sdk._core.client.sync.models import Models -from typesafe_sdk._core.config import Config +from typesafe_sdk._core.config import DEFAULT_LIMITS, Config from typesafe_sdk._core.endpoints import prepare_system_one from typesafe_sdk._core.json_types import JSONContent, JSONValue from typesafe_sdk._core.question_types import Question @@ -84,7 +84,9 @@ class TypeSafeClient: timeout = http_client.timeout self._config = Config.resolve(api_key, base_url, model, timeout, headers) self._retry = build_tenacity(retry) - self._http_client = httpx2.Client(timeout=self._config.timeout, transport=transport) if http_client is None else http_client + self._http_client = ( + httpx2.Client(timeout=self._config.timeout, limits=DEFAULT_LIMITS, transport=transport) if http_client is None else http_client + ) @cached_property def models(self) -> Models: @@ -162,7 +164,8 @@ class TypeSafeClient: name and model and token usage details when no custom model is supplied. Raises: - TypeSafeError: Questions are empty or a score question's criteria list is empty. + TypeSafeError: State is `None`, questions are empty, a score or choice question has no criteria, + or a noul question has neither instructions nor criteria. TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. TypeSafeAPIResponseValidationError: The response body does not match the response model. diff --git a/src/typesafe_sdk/_core/config.py b/src/typesafe_sdk/_core/config.py index 2edc8de..4cbb315 100644 --- a/src/typesafe_sdk/_core/config.py +++ b/src/typesafe_sdk/_core/config.py @@ -17,6 +17,10 @@ from typesafe_sdk.constants import ( DEFAULT_TIMEOUT, ) +# httpx2 keeps only 20 idle connections for 5 s by default, so bursts beyond that reopen connections with new TLS +# handshakes. Keep every pooled connection; the API reuses idle connections for far longer than 30 s. +DEFAULT_LIMITS = httpx2.Limits(max_connections=100, max_keepalive_connections=100, keepalive_expiry=30.0) + def _resolve_string(value: str | None, env: str, default: str = "") -> str: """Resolve an explicit string or a stripped environment value, falling back to a default.""" diff --git a/src/typesafe_sdk/_core/endpoints.py b/src/typesafe_sdk/_core/endpoints.py index c80e8a4..646dc3e 100644 --- a/src/typesafe_sdk/_core/endpoints.py +++ b/src/typesafe_sdk/_core/endpoints.py @@ -7,6 +7,7 @@ import httpx2 from typesafe_sdk._core.config import Config from typesafe_sdk._core.constants import MODELS_PATH, SYSTEM_ONE_PATH +from typesafe_sdk._core.errors import TypeSafeError from typesafe_sdk._core.json_types import JSONContent, JSONValue from typesafe_sdk._core.question_types import Question from typesafe_sdk._core.questions import normalize_questions @@ -24,6 +25,8 @@ def prepare_system_one( headers: Mapping[str, str] | None, response_type: type[ResponseT], ) -> Request[ResponseT]: + if state is None: + raise TypeSafeError("State is required.") body: dict[str, Any] = { "state": state, "model": config.default_model if model is None else model, diff --git a/src/typesafe_sdk/_core/question_types.py b/src/typesafe_sdk/_core/question_types.py index 7bac483..5ecd84e 100644 --- a/src/typesafe_sdk/_core/question_types.py +++ b/src/typesafe_sdk/_core/question_types.py @@ -1,9 +1,9 @@ """Question objects and raw input models.""" from collections.abc import Mapping, Sequence -from typing import Any, Literal, TypeAlias +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, overload -from pydantic import BaseModel, ConfigDict, model_serializer +from pydantic import BaseModel, ConfigDict, Field, model_serializer from pydantic.functional_serializers import SerializerFunctionWrapHandler from typing_extensions import NotRequired, TypedDict @@ -23,6 +23,16 @@ class NoulCriteria(TypedDict, total=False, closed=True): """Description of the no outcome as text, a JSON object, or an array; `None` leaves it undescribed.""" +class _DescribedTrue(TypedDict, closed=True): + true: JSONContent + false: NotRequired[JSONContent | None] + + +class _DescribedFalse(TypedDict, closed=True): + true: NotRequired[JSONContent | None] + false: JSONContent + + class NoulModel(TypedDict, closed=True): """A yes/no question dictionary with `type="noul"`. @@ -31,9 +41,9 @@ class NoulModel(TypedDict, closed=True): type: Literal["noul"] instructions: NotRequired[JSONContent | None] - """The question to ask, expressed as text, a JSON object, or an array; optional.""" + """The question to ask, expressed as text, a JSON object, or an array; optional when `criteria` describes an outcome.""" criteria: NotRequired[NoulCriteria | None] - """Optional descriptions of the yes and no outcomes.""" + """Optional descriptions of the yes and no outcomes; required when `instructions` is `None`.""" class ChoiceModel(TypedDict, closed=True): @@ -46,7 +56,7 @@ class ChoiceModel(TypedDict, closed=True): instructions: NotRequired[JSONContent | None] """The question to ask, expressed as text, a JSON object, or an array; optional.""" criteria: Mapping[str, JSONContent | None] - """Labels mapped to text, object, or array descriptions, or `None` for undescribed labels.""" + """A nonempty mapping of labels to text, object, or array descriptions, or `None` for undescribed labels.""" class ScoreModel(TypedDict, closed=True): @@ -82,9 +92,17 @@ class Noul(_Question, wire.NoulQuestion): type: Literal["noul"] = "noul" instructions: JSONContent | None = None # pyrefly: ignore[bad-override-mutable-attribute] - """The question to ask, expressed as text, a JSON object, or an array; optional.""" + """The question to ask, expressed as text, a JSON object, or an array; optional when `criteria` describes an outcome.""" criteria: NoulCriteria | None = None # pyrefly: ignore[bad-override-mutable-attribute] - """Optional descriptions of the yes and no outcomes.""" + """Optional descriptions of the yes and no outcomes; required when `instructions` is `None`.""" + + if TYPE_CHECKING: + # Require instructions or at least one described outcome. + @overload + def __init__(self, *, type: Literal["noul"] = ..., instructions: JSONContent, criteria: NoulCriteria | None = ...) -> None: ... + @overload + def __init__(self, *, type: Literal["noul"] = ..., instructions: None = ..., criteria: _DescribedTrue | _DescribedFalse) -> None: ... + def __init__(self, **data: Any) -> None: ... class Choice(_Question, wire.ChoiceQuestion): @@ -95,7 +113,7 @@ class Choice(_Question, wire.ChoiceQuestion): type: Literal["choice"] = "choice" criteria: Mapping[str, JSONContent | None] # pyrefly: ignore[bad-override-mutable-attribute] - """Labels mapped to text, object, or array descriptions, or `None` for undescribed labels.""" + """A nonempty mapping of labels to text, object, or array descriptions, or `None` for undescribed labels.""" instructions: JSONContent | None = None # pyrefly: ignore[bad-override-mutable-attribute] """The question to ask, expressed as text, a JSON object, or an array; optional.""" @@ -107,7 +125,7 @@ class Score(_Question, wire.ScoreQuestion): """ type: Literal["score"] = "score" - criteria: Sequence[JSONContent] # pyrefly: ignore[bad-override-mutable-attribute] + criteria: Sequence[JSONContent] = Field(min_length=1) # pyrefly: ignore[bad-override-mutable-attribute] """A nonempty, ordered list of text, object, or array descriptions, one per score from zero.""" instructions: JSONContent | None = None # pyrefly: ignore[bad-override-mutable-attribute] """The question to ask, expressed as text, a JSON object, or an array; optional.""" diff --git a/src/typesafe_sdk/_core/questions.py b/src/typesafe_sdk/_core/questions.py index ace2d29..60e5290 100644 --- a/src/typesafe_sdk/_core/questions.py +++ b/src/typesafe_sdk/_core/questions.py @@ -11,15 +11,21 @@ def normalize_questions(questions: Mapping[str, Question]) -> dict[str, Question if not questions: raise TypeSafeError("At least one question is required.") for name, question in questions.items(): - if isinstance(question, Score): - _validate_score_criteria(name, question.criteria) - elif not isinstance(question, (Noul, Choice)): + if isinstance(question, Choice): + _validate_choice_criteria(name, question.criteria) + elif isinstance(question, Noul): + _validate_noul(name, question.instructions, question.criteria) + elif not isinstance(question, Score): if not isinstance(question, dict) or not isinstance(question.get("type"), str) or not question["type"]: raise TypeSafeError(f'Question "{name}" must be a question object or a dictionary with a nonempty string "type".') if question["type"] in ("choice", "score") and "criteria" not in question: raise TypeSafeError(f'Question "{name}" requires "criteria".') if question["type"] == "score": _validate_score_criteria(name, question["criteria"]) + elif question["type"] == "choice": + _validate_choice_criteria(name, question["criteria"]) + elif question["type"] == "noul": + _validate_noul(name, question.get("instructions"), question.get("criteria")) return dict(questions) @@ -27,3 +33,17 @@ def _validate_score_criteria(name: str, criteria: Sequence[JSONContent]) -> None """Reject a score question with no criteria; at least one score is required.""" if not criteria: raise TypeSafeError(f'Score question "{name}" has no criteria; at least one score is required.') + + +def _validate_choice_criteria(name: str, criteria: Mapping[str, JSONContent | None]) -> None: + """Reject a choice question with no criteria; at least one choice is required.""" + if not criteria: + raise TypeSafeError(f'Choice question "{name}" has no criteria; at least one choice is required.') + + +def _validate_noul(name: str, instructions: object, criteria: object) -> None: + """Reject a noul question with non-object criteria or with neither instructions nor a described outcome.""" + if criteria is not None and not isinstance(criteria, Mapping): + raise TypeSafeError(f'Noul question "{name}" criteria must be an object.') + if instructions is None and not (isinstance(criteria, Mapping) and any(value is not None for value in criteria.values())): + raise TypeSafeError(f'Noul question "{name}" has neither instructions nor criteria; at least one is required.') diff --git a/tests/test_clients.py b/tests/test_clients.py index 5ab8268..d96f0f2 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -270,18 +270,20 @@ async def test_invalid_models_response(clients: ClientFactory, body: object) -> @pytest.mark.parametrize( - "questions,match", + "state,questions,match", [ - ({}, "At least one question"), - ({"rating": Score(instructions="?", criteria=[])}, '"rating" has no criteria'), + ("x", {}, "At least one question"), + ("x", {"rating": {"type": "score", "instructions": "?", "criteria": []}}, '"rating" has no criteria'), + ("x", {"spam": Noul.model_validate({})}, '"spam" has neither instructions nor criteria'), + (None, {"spam": Noul(instructions="Spam?")}, "State is required"), ], ) -async def test_validation_before_network(clients: ClientFactory, questions: Any, match: str) -> None: +async def test_validation_before_network(clients: ClientFactory, state: Any, questions: Any, match: str) -> None: def handler(request: httpx2.Request) -> httpx2.Response: - pytest.fail("Invalid questions reached the network") + pytest.fail("Invalid request reached the network") with pytest.raises(TypeSafeError, match=match): - await system_one(clients(handler), state="x", questions=questions) + await system_one(clients(handler), state=state, questions=questions) @pytest.mark.parametrize( diff --git a/tests/test_config.py b/tests/test_config.py index 32a6179..d5596ff 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -168,3 +168,10 @@ async def test_http_client_timeout_precedence( assert requests[0].extensions["timeout"] == expected.as_dict() assert requests[1].extensions["timeout"] == default.as_dict() assert http_client.timeout == http_timeout + + +@pytest.mark.parametrize("client_type", [TypeSafeClient, AsyncTypeSafeClient]) +def test_default_pool_keeps_idle_connections(client_type: type[TypeSafeClient | AsyncTypeSafeClient]) -> None: + pool = client_type(api_key="test")._http_client._transport._pool # pyrefly: ignore[missing-attribute] + assert pool._max_keepalive_connections == pool._max_connections == 100 + assert pool._keepalive_expiry == 30.0 diff --git a/tests/test_pydantic_response_models.py b/tests/test_pydantic_response_models.py index ba793c4..1f6e81a 100644 --- a/tests/test_pydantic_response_models.py +++ b/tests/test_pydantic_response_models.py @@ -48,9 +48,9 @@ async def test_standalone_pydantic_response_model(clients: ClientFactory, extra_ body = {**RESULT, "answers": {**RESULT["answers"], "spam": {**RESULT["answers"]["spam"], **extra_answer_fields}}} client = clients(lambda request: httpx2.Response(200, json=body)) if isinstance(client, AsyncTypeSafeClient): - result = await client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + result = await client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=KnownResponse) else: - result = client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + result = client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=KnownResponse) assert type(result) is KnownResponse assert result.model == "jev-latest" assert result.answers.spam.noul == 0.98 @@ -60,9 +60,9 @@ async def test_standalone_pydantic_response_model(clients: ClientFactory, extra_ async def test_explicit_default_response_model(clients: ClientFactory, response_model: type[SystemOneResponse] | None) -> None: client = clients(lambda request: httpx2.Response(200, json=RESULT, headers={"x-typesafe-request-id": "req-default"})) if isinstance(client, AsyncTypeSafeClient): - result = await client.system_one("x", {"spam": Noul()}, response_model=response_model) + result = await client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=response_model) else: - result = client.system_one("x", {"spam": Noul()}, response_model=response_model) + result = client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=response_model) assert isinstance(result, SystemOneResponse) assert result.nouls["spam"].noul == 0.98 assert result.request_id == "req-default" @@ -73,9 +73,9 @@ async def test_pydantic_system_one_response_subclass(clients: ClientFactory) -> body = {**RESULT, "answers": {**RESULT["answers"], "future": {"type": "future", "value": 1}}} client = clients(lambda request: httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-pydantic"})) if isinstance(client, AsyncTypeSafeClient): - result = await client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + result = await client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=TypedSystemOneResponse) else: - result = client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + result = client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=TypedSystemOneResponse) assert type(result) is TypedSystemOneResponse assert result.spam.noul == 0.98 assert result.tone.choice == "friendly" @@ -108,10 +108,10 @@ async def test_pydantic_response_validation( client = clients(lambda request: httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-invalid"})) if isinstance(client, AsyncTypeSafeClient): with pytest.raises(TypeSafeAPIResponseValidationError) as caught: - await client.system_one("x", {"spam": Noul()}, response_model=response_model) + await client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=response_model) else: with pytest.raises(TypeSafeAPIResponseValidationError) as caught: - client.system_one("x", {"spam": Noul()}, response_model=response_model) + client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=response_model) assert caught.value.field_path == field_path assert caught.value.request_id == "req-invalid" assert caught.value.body == body @@ -122,10 +122,10 @@ async def test_custom_response_preserves_api_errors(clients: ClientFactory) -> N client = clients(lambda request: httpx2.Response(400, json=body, headers={"x-typesafe-request-id": "req-error"})) if isinstance(client, AsyncTypeSafeClient): with pytest.raises(TypeSafeBadRequestError) as caught: - await client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + await client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=KnownResponse) else: with pytest.raises(TypeSafeBadRequestError) as caught: - client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=KnownResponse) assert caught.value.status == 400 assert caught.value.body == body assert caught.value.request_id == "req-error" diff --git a/tests/test_questions.py b/tests/test_questions.py index 78cec45..887135d 100644 --- a/tests/test_questions.py +++ b/tests/test_questions.py @@ -84,7 +84,7 @@ def test_raw_questions_require_structural_keys(invalid: object) -> None: @pytest.mark.parametrize( "question,expected", [ - (Noul(), {"type": "noul"}), + (Noul(criteria={"true": "spam"}), {"type": "noul", "criteria": {"true": "spam"}}), (Choice(criteria={"a": None}), {"type": "choice", "criteria": {"a": None}}), (Score(criteria=["good"]), {"type": "score", "criteria": ["good"]}), (Noul(instructions="", criteria={}), {"type": "noul", "instructions": "", "criteria": {}}), @@ -163,15 +163,43 @@ def test_optional_noul_criteria(raw: bool, criteria: NoulCriteria | None) -> Non def test_typed_noul_criteria_reject_unknown_fields() -> None: criteria = cast(NoulCriteria, {"true": "yes", "metadata": {"source": None}}) with pytest.raises(ValidationError, match="extra_forbidden"): - Noul(criteria=criteria) + Noul(instructions="Spam?", criteria=criteria) + + +def test_empty_score_criteria_is_rejected() -> None: + with pytest.raises(ValidationError, match="too_short"): + Score(instructions="Quality?", criteria=[]) + with pytest.raises(TypeSafeError, match='"rating" has no criteria'): + normalize_questions({"rating": {"type": "score", "instructions": "Quality?", "criteria": []}}) @pytest.mark.parametrize("raw", [False, True]) -def test_empty_score_criteria_is_rejected(raw: bool) -> None: - model = cast(ScoreModel, {"type": "score", "instructions": "Quality?", "criteria": []}) - question = model if raw else Score(instructions=model["instructions"], criteria=model["criteria"]) - with pytest.raises(TypeSafeError, match='"rating" has no criteria'): - normalize_questions({"rating": question}) +def test_empty_choice_criteria_is_rejected(raw: bool) -> None: + model: ChoiceModel = {"type": "choice", "instructions": "Tone?", "criteria": {}} + question = model if raw else Choice(instructions=model["instructions"], criteria=model["criteria"]) + with pytest.raises(TypeSafeError, match='"label" has no criteria'): + normalize_questions({"label": question}) + + +@pytest.mark.parametrize( + "question", + [ + Noul.model_validate({}), + Noul.model_validate({"criteria": {}}), + Noul.model_validate({"criteria": {"true": None, "false": None}}), + {"type": "noul"}, + {"type": "noul", "instructions": None, "criteria": {}}, + {"type": "noul", "criteria": {"true": None, "false": None}}, + ], +) +def test_noul_without_instructions_or_criteria_is_rejected(question: Question) -> None: + with pytest.raises(TypeSafeError, match='"spam" has neither instructions nor criteria'): + normalize_questions({"spam": question}) + + +def test_raw_noul_criteria_must_be_an_object() -> None: + with pytest.raises(TypeSafeError, match='"spam" criteria must be an object'): + normalize_questions(cast(Questions, {"spam": {"type": "noul", "criteria": "spam"}})) async def test_covariant_question_mappings(clients: ClientFactory) -> None: diff --git a/tests/test_tls.py b/tests/test_tls.py new file mode 100644 index 0000000..a798d43 --- /dev/null +++ b/tests/test_tls.py @@ -0,0 +1,52 @@ +import subprocess +import sys +from pathlib import Path + +import pytest +import truststore + +from typesafe_sdk._core.client.aio import client as aio_client + +# Stresses `wrap_bio()` on the default async client's shared SSL context from many threads, as AnyIO does +# during concurrent TLS setup. Unlocked context configuration aborts the interpreter natively, so the +# stress runs in a subprocess. See https://github.com/typesafe-ai/typesafe-sdk-python/issues/20. +_STRESS = """ +import ssl +import threading + +from typesafe_sdk import AsyncTypeSafeClient + +ctx = AsyncTypeSafeClient(api_key="test")._http_client._transport._pool._ssl_context + + +def worker(): + for _ in range(300): + ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname="api.typesafe.ai") + + +threads = [threading.Thread(target=worker) for _ in range(16)] +for thread in threads: + thread.start() +for thread in threads: + thread.join() +""" + + +def test_default_async_ssl_context_survives_concurrent_wrap_bio() -> None: + for _ in range(3): + result = subprocess.run( # noqa: S603 - Fixed interpreter and test-owned script. + [sys.executable, "-X", "faulthandler", "-c", _STRESS], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("truststore_version,locked", [("0.10.4", True), ("0.10.5", False), ("0.11.0", False)]) +def test_wrap_bio_lock_only_on_affected_truststore(monkeypatch: pytest.MonkeyPatch, truststore_version: str, locked: bool) -> None: + monkeypatch.setattr(truststore, "__version__", truststore_version) + assert ("wrap_bio" in vars(aio_client._ssl_context())) is locked + + +def test_wrap_bio_lock_skipped_for_stdlib_context(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("SSL_CERT_DIR", str(tmp_path)) + monkeypatch.setattr(truststore, "__version__", "0.10.4") + assert "wrap_bio" not in vars(aio_client._ssl_context()) diff --git a/tests/test_types.py b/tests/test_types.py index c0c8d5e..dd3444d 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -70,7 +70,7 @@ async def test_array_inputs(clients: ClientFactory, raw: bool) -> None: async def test_raw_optional_fields_preserve_explicit_null(clients: ClientFactory) -> None: questions: Questions = { - "yes": {"type": "noul", "instructions": None, "criteria": None}, + "yes": {"type": "noul", "instructions": None, "criteria": {"true": "spam", "false": None}}, "label": {"type": "choice", "instructions": None, "criteria": {"a": None}}, "rating": {"type": "score", "instructions": None, "criteria": ["good"]}, } diff --git a/tests/typing/negative/noul.py b/tests/typing/negative/noul.py new file mode 100644 index 0000000..7e572df --- /dev/null +++ b/tests/typing/negative/noul.py @@ -0,0 +1,9 @@ +from typesafe_sdk import Noul, NoulCriteria + + +def invalid_nouls(criteria: NoulCriteria) -> None: + Noul() # E: No matching overload + Noul(instructions=None) # E: No matching overload + Noul(criteria={}) # E: No matching overload + Noul(criteria={"true": None, "false": None}) # E: No matching overload + Noul(criteria=criteria) # E: No matching overload diff --git a/tests/typing/pydantic_response_models.py b/tests/typing/pydantic_response_models.py index 1c41cc0..e25c85e 100644 --- a/tests/typing/pydantic_response_models.py +++ b/tests/typing/pydantic_response_models.py @@ -10,7 +10,7 @@ PydanticT = TypeVar("PydanticT", bound=BaseModel) def sync(client: TypeSafeClient, response_model: type[KnownResponse] | None) -> None: - questions = {"spam": Noul()} + questions = {"spam": Noul(instructions="Spam?")} assert_type(client.system_one("x", questions), SystemOneResponse) assert_type(client.system_one("x", questions, response_model=None), SystemOneResponse) assert_type(client.system_one("x", questions, response_model=SystemOneResponse), SystemOneResponse) @@ -18,7 +18,7 @@ def sync(client: TypeSafeClient, response_model: type[KnownResponse] | None) -> standalone = client.system_one("x", questions, response_model=KnownResponse) assert_type(standalone, KnownResponse) assert_type(standalone.answers.spam, NoulAnswer) - typed = client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + typed = client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=TypedSystemOneResponse) assert_type(typed, TypedSystemOneResponse) assert_type(typed.spam, NoulAnswer) assert_type(typed.nouls, dict[str, NoulAnswer]) @@ -27,7 +27,7 @@ def sync(client: TypeSafeClient, response_model: type[KnownResponse] | None) -> async def asynchronous(client: AsyncTypeSafeClient, response_model: type[KnownResponse] | None) -> None: - questions = {"spam": Noul()} + questions = {"spam": Noul(instructions="Spam?")} assert_type(await client.system_one("x", questions), SystemOneResponse) assert_type(await client.system_one("x", questions, response_model=None), SystemOneResponse) assert_type(await client.system_one("x", questions, response_model=SystemOneResponse), SystemOneResponse) @@ -35,7 +35,7 @@ async def asynchronous(client: AsyncTypeSafeClient, response_model: type[KnownRe standalone = await client.system_one("x", questions, response_model=KnownResponse) assert_type(standalone, KnownResponse) assert_type(standalone.answers.spam, NoulAnswer) - typed = await client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + typed = await client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=TypedSystemOneResponse) assert_type(typed, TypedSystemOneResponse) assert_type(typed.spam, NoulAnswer) assert_type(typed.nouls, dict[str, NoulAnswer]) @@ -44,8 +44,8 @@ async def asynchronous(client: AsyncTypeSafeClient, response_model: type[KnownRe def generic_sync(client: TypeSafeClient, response_model: type[PydanticT]) -> PydanticT: - return client.system_one("x", {"spam": Noul()}, response_model=response_model) + return client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=response_model) async def generic_async(client: AsyncTypeSafeClient, response_model: type[PydanticT]) -> PydanticT: - return await client.system_one("x", {"spam": Noul()}, response_model=response_model) + return await client.system_one("x", {"spam": Noul(instructions="Spam?")}, response_model=response_model) diff --git a/uv.lock b/uv.lock index 113df76..a3adad4 100644 --- a/uv.lock +++ b/uv.lock @@ -114,7 +114,7 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.79.0" +version = "0.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -126,10 +126,11 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/98/4b35f12e551d078e4297422e98cbc679f4792475bcf7da2e008308695d97/datamodel_code_generator-0.79.0.tar.gz", hash = "sha256:d1e4297c946cccb9c2f7f662167b5876d5554ce3884ac145042256af08f23924", size = 2661584, upload-time = "2026-09-10T17:56:30.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/ad/bd0c954c4c4671e586b340059139ead38570ba916a237086e245982ab14d/datamodel_code_generator-0.83.0.tar.gz", hash = "sha256:30d841e31b82095c4ef11f3dbfc99525f559a146250b9e685e2a2e4a196ca0a1", size = 3074761, upload-time = "2026-09-24T08:56:40.622Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/62/c4ab81535f7fd6d46040908c535248ef96fa53d90c7379913256ddcd3f6c/datamodel_code_generator-0.79.0-py3-none-any.whl", hash = "sha256:0aa827080096844a56135eb990e69d622e79f74bad2e5f006ce797bd74477d1f", size = 695513, upload-time = "2026-09-10T17:56:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/3a/4e/4a0231a7d3d4043cd27a746a50be87bc7637463023d7db9991610cd5fe56/datamodel_code_generator-0.83.0-py3-none-any.whl", hash = "sha256:371d80be360b2a57bc48462169553af57fa30225c0264ad85ae5f0d59b9d143a", size = 751087, upload-time = "2026-09-24T08:56:38.791Z" }, ] [[package]] @@ -1237,13 +1238,15 @@ wheels = [ [[package]] name = "typesafe-sdk" -version = "0.7.2" +version = "0.7.3" source = { editable = "." } dependencies = [ { name = "httpx2" }, + { name = "packaging" }, { name = "pydantic" }, { name = "pydantic-core" }, { name = "tenacity" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions" }, ] @@ -1278,15 +1281,17 @@ docs = [ requires-dist = [ { name = "httpx2", specifier = ">=2.0.0" }, { name = "httpx2", extras = ["http2"], marker = "extra == 'http2'", specifier = ">=2.0.0" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-core", specifier = ">=2.41.1" }, { name = "tenacity", specifier = ">=9.0.0" }, + { name = "truststore", marker = "sys_platform != 'emscripten'", specifier = ">=0.10.0" }, { name = "typing-extensions", specifier = ">=4.13.0" }, ] provides-extras = ["http2"] [package.metadata.requires-dev] -codegen = [{ name = "datamodel-code-generator", specifier = ">=0.79.0" }] +codegen = [{ name = "datamodel-code-generator", specifier = ">=0.81.0" }] dev = [ { name = "gitpython", specifier = ">=3.1.62" }, { name = "pyrefly", specifier = ">=1.2.0" },