mirror of
https://github.com/aphrody-labs/typesafe-sdk-python.git
synced 2026-10-13 20:10:40 +00:00
1 parent
f078f1e208
commit
c743d81416
17 files changed
+255
-56
No files matched your search
@@ -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
|
||||
|
||||
+5
-2
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.')
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+35
-7
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
+1
-1
@@ -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"]},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user