typesafe-sdk 0.7.2__tar.gz → 0.7.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/PKG-INFO +3 -1
  2. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/pyproject.toml +4 -2
  3. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/pyproject.toml.orig +5 -2
  4. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/client/aio/client.py +34 -4
  5. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/client/sync/client.py +6 -3
  6. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/config.py +4 -0
  7. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/endpoints.py +3 -0
  8. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/json.py +2 -1
  9. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/question_types.py +27 -9
  10. typesafe_sdk-0.7.4/src/typesafe_sdk/_core/questions.py +49 -0
  11. typesafe_sdk-0.7.2/src/typesafe_sdk/_core/questions.py +0 -29
  12. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/LICENSE +0 -0
  13. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/README.md +0 -0
  14. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/__init__.py +0 -0
  15. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/__init__.py +0 -0
  16. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/client/__init__.py +0 -0
  17. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/client/aio/__init__.py +0 -0
  18. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/client/aio/models.py +0 -0
  19. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/client/sync/__init__.py +0 -0
  20. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/client/sync/models.py +0 -0
  21. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/constants.py +0 -0
  22. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/errors.py +0 -0
  23. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/json_types.py +0 -0
  24. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/logging.py +0 -0
  25. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/response_types.py +0 -0
  26. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/retry.py +0 -0
  27. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/schemas/__init__.py +0 -0
  28. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/schemas/base.py +0 -0
  29. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_core/transport.py +0 -0
  30. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_schemas/__init__.py +0 -0
  31. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_schemas/models.py +0 -0
  32. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/_version.py +0 -0
  33. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/constants.py +0 -0
  34. {typesafe_sdk-0.7.2 → typesafe_sdk-0.7.4}/src/typesafe_sdk/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typesafe-sdk
3
- Version: 0.7.2
3
+ Version: 0.7.4
4
4
  Summary: Python SDK for TypeSafe AI API.
5
5
  Keywords: typesafe,sdk,api,ai
6
6
  Author: TypeSafe AI
@@ -25,9 +25,11 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
25
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
26
  Classifier: License :: OSI Approved :: MIT License
27
27
  Requires-Dist: httpx2>=2.0.0
28
+ Requires-Dist: packaging>=24.0
28
29
  Requires-Dist: pydantic>=2.12.0
29
30
  Requires-Dist: pydantic-core>=2.41.1
30
31
  Requires-Dist: tenacity>=9.0.0
32
+ Requires-Dist: truststore>=0.10.0 ; sys_platform != 'emscripten'
31
33
  Requires-Dist: typing-extensions>=4.13.0
32
34
  Requires-Dist: httpx2[http2]>=2.0.0 ; extra == 'http2'
33
35
  Maintainer: Daniel Gafni
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "typesafe-sdk"
3
- version = "0.7.2"
3
+ version = "0.7.4"
4
4
  description = "Python SDK for TypeSafe AI API."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -33,9 +33,11 @@ classifiers = [
33
33
  ]
34
34
  dependencies = [
35
35
  "httpx2>=2.0.0",
36
+ "packaging>=24.0",
36
37
  "pydantic>=2.12.0",
37
38
  "pydantic-core>=2.41.1",
38
39
  "tenacity>=9.0.0",
40
+ "truststore>=0.10.0 ; sys_platform != 'emscripten'",
39
41
  "typing-extensions>=4.13.0",
40
42
  ]
41
43
 
@@ -78,7 +80,7 @@ docs = [
78
80
  "ruff>=0.16.6",
79
81
  "zensical>=0.0.60",
80
82
  ]
81
- codegen = ["datamodel-code-generator>=0.79.0"]
83
+ codegen = ["datamodel-code-generator>=0.81.0"]
82
84
 
83
85
  [tool.pytest.ini_options]
84
86
  addopts = "-vv -ra --capture=tee-sys -p no:tach"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "typesafe-sdk"
3
- version = "0.7.2"
3
+ version = "0.7.4"
4
4
  description = "Python SDK for TypeSafe AI API."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -34,9 +34,12 @@ classifiers = [
34
34
  ]
35
35
  dependencies = [
36
36
  "httpx2>=2.0.0",
37
+ "packaging>=24.0",
37
38
  "pydantic>=2.12.0",
38
39
  "pydantic-core>=2.41.1",
39
40
  "tenacity>=9.0.0",
41
+ # TODO: bump constraint once https://github.com/sethmlarson/truststore/pull/219 is released
42
+ "truststore>=0.10.0 ; sys_platform != 'emscripten'",
40
43
  "typing-extensions>=4.13.0",
41
44
  ]
42
45
 
@@ -74,7 +77,7 @@ docs = [
74
77
  "zensical>=0.0.60",
75
78
  ]
76
79
  codegen = [
77
- "datamodel-code-generator>=0.79.0",
80
+ "datamodel-code-generator>=0.81.0",
78
81
  ]
79
82
 
80
83
  [tool.pytest.ini_options]
@@ -1,15 +1,18 @@
1
1
  """Asynchronous API client."""
2
2
 
3
+ import ssl
4
+ import threading
3
5
  from collections.abc import Mapping
4
6
  from functools import cached_property
5
7
  from types import TracebackType
6
- from typing import overload
8
+ from typing import Any, overload
7
9
 
8
10
  import httpx2
11
+ from packaging.version import Version
9
12
  from typing_extensions import Self
10
13
 
11
14
  from typesafe_sdk._core.client.aio.models import AsyncModels
12
- from typesafe_sdk._core.config import Config
15
+ from typesafe_sdk._core.config import DEFAULT_LIMITS, Config
13
16
  from typesafe_sdk._core.endpoints import prepare_system_one
14
17
  from typesafe_sdk._core.json_types import JSONContent, JSONValue
15
18
  from typesafe_sdk._core.question_types import Question
@@ -18,6 +21,28 @@ from typesafe_sdk._core.retry import RetryPolicy, build_tenacity_async
18
21
  from typesafe_sdk._core.transport import Request, ResponseT, send_async
19
22
 
20
23
 
24
+ def _ssl_context() -> ssl.SSLContext:
25
+ """httpx2's default SSL context, with `wrap_bio()` serialized on affected truststore versions.
26
+
27
+ AnyIO calls `wrap_bio()` from worker threads, and truststore<=0.10.4 reconfigures the shared
28
+ context there without a lock, which can abort the interpreter (sethmlarson/truststore#221).
29
+ """
30
+ ctx = httpx2.create_ssl_context()
31
+ import truststore # noqa: PLC0415 - Unavailable on emscripten; httpx2 imports it lazily too.
32
+
33
+ if not isinstance(ctx, truststore.SSLContext) or Version(truststore.__version__) > Version("0.10.4"):
34
+ return ctx
35
+ wrap_bio = ctx.wrap_bio
36
+ lock = threading.Lock()
37
+
38
+ def locked_wrap_bio(*args: Any, **kwargs: Any) -> ssl.SSLObject:
39
+ with lock:
40
+ return wrap_bio(*args, **kwargs)
41
+
42
+ ctx.wrap_bio = locked_wrap_bio
43
+ return ctx
44
+
45
+
21
46
  class AsyncTypeSafeClient:
22
47
  def __init__(
23
48
  self,
@@ -91,7 +116,11 @@ class AsyncTypeSafeClient:
91
116
  timeout = http_client.timeout
92
117
  self._config = Config.resolve(api_key, base_url, model, timeout, headers)
93
118
  self._retry = build_tenacity_async(retry)
94
- self._http_client = httpx2.AsyncClient(timeout=self._config.timeout, transport=transport) if http_client is None else http_client
119
+ self._http_client = (
120
+ httpx2.AsyncClient(timeout=self._config.timeout, limits=DEFAULT_LIMITS, transport=transport, verify=_ssl_context())
121
+ if http_client is None
122
+ else http_client
123
+ )
95
124
 
96
125
  @cached_property
97
126
  def models(self) -> AsyncModels:
@@ -170,7 +199,8 @@ class AsyncTypeSafeClient:
170
199
  name and model and token usage details when no custom model is supplied.
171
200
 
172
201
  Raises:
173
- TypeSafeError: Questions are empty or a score question's criteria list is empty.
202
+ TypeSafeError: State is `None`, questions are empty, a score or choice question has no criteria,
203
+ or a noul question has neither instructions nor criteria.
174
204
  TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries.
175
205
  TypeSafeAPIConnectionError: The request cannot connect or times out after any retries.
176
206
  TypeSafeAPIResponseValidationError: The response body does not match the response model.
@@ -9,7 +9,7 @@ import httpx2
9
9
  from typing_extensions import Self
10
10
 
11
11
  from typesafe_sdk._core.client.sync.models import Models
12
- from typesafe_sdk._core.config import Config
12
+ from typesafe_sdk._core.config import DEFAULT_LIMITS, Config
13
13
  from typesafe_sdk._core.endpoints import prepare_system_one
14
14
  from typesafe_sdk._core.json_types import JSONContent, JSONValue
15
15
  from typesafe_sdk._core.question_types import Question
@@ -84,7 +84,9 @@ class TypeSafeClient:
84
84
  timeout = http_client.timeout
85
85
  self._config = Config.resolve(api_key, base_url, model, timeout, headers)
86
86
  self._retry = build_tenacity(retry)
87
- self._http_client = httpx2.Client(timeout=self._config.timeout, transport=transport) if http_client is None else http_client
87
+ self._http_client = (
88
+ httpx2.Client(timeout=self._config.timeout, limits=DEFAULT_LIMITS, transport=transport) if http_client is None else http_client
89
+ )
88
90
 
89
91
  @cached_property
90
92
  def models(self) -> Models:
@@ -162,7 +164,8 @@ class TypeSafeClient:
162
164
  name and model and token usage details when no custom model is supplied.
163
165
 
164
166
  Raises:
165
- TypeSafeError: Questions are empty or a score question's criteria list is empty.
167
+ TypeSafeError: State is `None`, questions are empty, a score or choice question has no criteria,
168
+ or a noul question has neither instructions nor criteria.
166
169
  TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries.
167
170
  TypeSafeAPIConnectionError: The request cannot connect or times out after any retries.
168
171
  TypeSafeAPIResponseValidationError: The response body does not match the response model.
@@ -17,6 +17,10 @@ from typesafe_sdk.constants import (
17
17
  DEFAULT_TIMEOUT,
18
18
  )
19
19
 
20
+ # httpx2 keeps only 20 idle connections for 5 s by default, so bursts beyond that reopen connections with new TLS
21
+ # handshakes. Keep every pooled connection; the API reuses idle connections for far longer than 30 s.
22
+ DEFAULT_LIMITS = httpx2.Limits(max_connections=100, max_keepalive_connections=100, keepalive_expiry=30.0)
23
+
20
24
 
21
25
  def _resolve_string(value: str | None, env: str, default: str = "") -> str:
22
26
  """Resolve an explicit string or a stripped environment value, falling back to a default."""
@@ -7,6 +7,7 @@ import httpx2
7
7
 
8
8
  from typesafe_sdk._core.config import Config
9
9
  from typesafe_sdk._core.constants import MODELS_PATH, SYSTEM_ONE_PATH
10
+ from typesafe_sdk._core.errors import TypeSafeError
10
11
  from typesafe_sdk._core.json_types import JSONContent, JSONValue
11
12
  from typesafe_sdk._core.question_types import Question
12
13
  from typesafe_sdk._core.questions import normalize_questions
@@ -24,6 +25,8 @@ def prepare_system_one(
24
25
  headers: Mapping[str, str] | None,
25
26
  response_type: type[ResponseT],
26
27
  ) -> Request[ResponseT]:
28
+ if state is None:
29
+ raise TypeSafeError("State is required.")
27
30
  body: dict[str, Any] = {
28
31
  "state": state,
29
32
  "model": config.default_model if model is None else model,
@@ -23,7 +23,8 @@ def _fallback(value: object) -> object:
23
23
 
24
24
 
25
25
  def serialize(value: object) -> bytes:
26
- return to_json(value, fallback=_fallback)
26
+ # JSON has no NaN/Infinity tokens; emit them as the strings "NaN"/"Infinity"/"-Infinity" instead.
27
+ return to_json(value, fallback=_fallback, inf_nan_mode="strings")
27
28
 
28
29
 
29
30
  def deserialize(content: bytes) -> Any:
@@ -1,9 +1,9 @@
1
1
  """Question objects and raw input models."""
2
2
 
3
3
  from collections.abc import Mapping, Sequence
4
- from typing import Any, Literal, TypeAlias
4
+ from typing import TYPE_CHECKING, Any, Literal, TypeAlias, overload
5
5
 
6
- from pydantic import BaseModel, ConfigDict, model_serializer
6
+ from pydantic import BaseModel, ConfigDict, Field, model_serializer
7
7
  from pydantic.functional_serializers import SerializerFunctionWrapHandler
8
8
  from typing_extensions import NotRequired, TypedDict
9
9
 
@@ -23,6 +23,16 @@ class NoulCriteria(TypedDict, total=False, closed=True):
23
23
  """Description of the no outcome as text, a JSON object, or an array; `None` leaves it undescribed."""
24
24
 
25
25
 
26
+ class _DescribedTrue(TypedDict, closed=True):
27
+ true: JSONContent
28
+ false: NotRequired[JSONContent | None]
29
+
30
+
31
+ class _DescribedFalse(TypedDict, closed=True):
32
+ true: NotRequired[JSONContent | None]
33
+ false: JSONContent
34
+
35
+
26
36
  class NoulModel(TypedDict, closed=True):
27
37
  """A yes/no question dictionary with `type="noul"`.
28
38
 
@@ -31,9 +41,9 @@ class NoulModel(TypedDict, closed=True):
31
41
 
32
42
  type: Literal["noul"]
33
43
  instructions: NotRequired[JSONContent | None]
34
- """The question to ask, expressed as text, a JSON object, or an array; optional."""
44
+ """The question to ask, expressed as text, a JSON object, or an array; optional when `criteria` describes an outcome."""
35
45
  criteria: NotRequired[NoulCriteria | None]
36
- """Optional descriptions of the yes and no outcomes."""
46
+ """Optional descriptions of the yes and no outcomes; required when `instructions` is `None`."""
37
47
 
38
48
 
39
49
  class ChoiceModel(TypedDict, closed=True):
@@ -46,7 +56,7 @@ class ChoiceModel(TypedDict, closed=True):
46
56
  instructions: NotRequired[JSONContent | None]
47
57
  """The question to ask, expressed as text, a JSON object, or an array; optional."""
48
58
  criteria: Mapping[str, JSONContent | None]
49
- """Labels mapped to text, object, or array descriptions, or `None` for undescribed labels."""
59
+ """A nonempty mapping of labels to text, object, or array descriptions, or `None` for undescribed labels."""
50
60
 
51
61
 
52
62
  class ScoreModel(TypedDict, closed=True):
@@ -82,9 +92,17 @@ class Noul(_Question, wire.NoulQuestion):
82
92
 
83
93
  type: Literal["noul"] = "noul"
84
94
  instructions: JSONContent | None = None # pyrefly: ignore[bad-override-mutable-attribute]
85
- """The question to ask, expressed as text, a JSON object, or an array; optional."""
95
+ """The question to ask, expressed as text, a JSON object, or an array; optional when `criteria` describes an outcome."""
86
96
  criteria: NoulCriteria | None = None # pyrefly: ignore[bad-override-mutable-attribute]
87
- """Optional descriptions of the yes and no outcomes."""
97
+ """Optional descriptions of the yes and no outcomes; required when `instructions` is `None`."""
98
+
99
+ if TYPE_CHECKING:
100
+ # Require instructions or at least one described outcome.
101
+ @overload
102
+ def __init__(self, *, type: Literal["noul"] = ..., instructions: JSONContent, criteria: NoulCriteria | None = ...) -> None: ...
103
+ @overload
104
+ def __init__(self, *, type: Literal["noul"] = ..., instructions: None = ..., criteria: _DescribedTrue | _DescribedFalse) -> None: ...
105
+ def __init__(self, **data: Any) -> None: ...
88
106
 
89
107
 
90
108
  class Choice(_Question, wire.ChoiceQuestion):
@@ -95,7 +113,7 @@ class Choice(_Question, wire.ChoiceQuestion):
95
113
 
96
114
  type: Literal["choice"] = "choice"
97
115
  criteria: Mapping[str, JSONContent | None] # pyrefly: ignore[bad-override-mutable-attribute]
98
- """Labels mapped to text, object, or array descriptions, or `None` for undescribed labels."""
116
+ """A nonempty mapping of labels to text, object, or array descriptions, or `None` for undescribed labels."""
99
117
  instructions: JSONContent | None = None # pyrefly: ignore[bad-override-mutable-attribute]
100
118
  """The question to ask, expressed as text, a JSON object, or an array; optional."""
101
119
 
@@ -107,7 +125,7 @@ class Score(_Question, wire.ScoreQuestion):
107
125
  """
108
126
 
109
127
  type: Literal["score"] = "score"
110
- criteria: Sequence[JSONContent] # pyrefly: ignore[bad-override-mutable-attribute]
128
+ criteria: Sequence[JSONContent] = Field(min_length=1) # pyrefly: ignore[bad-override-mutable-attribute]
111
129
  """A nonempty, ordered list of text, object, or array descriptions, one per score from zero."""
112
130
  instructions: JSONContent | None = None # pyrefly: ignore[bad-override-mutable-attribute]
113
131
  """The question to ask, expressed as text, a JSON object, or an array; optional."""
@@ -0,0 +1,49 @@
1
+ """Question validation before JSON encoding."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+
5
+ from typesafe_sdk._core.errors import TypeSafeError
6
+ from typesafe_sdk._core.json_types import JSONContent
7
+ from typesafe_sdk._core.question_types import Choice, Noul, Question, Score
8
+
9
+
10
+ def normalize_questions(questions: Mapping[str, Question]) -> dict[str, Question]:
11
+ if not questions:
12
+ raise TypeSafeError("At least one question is required.")
13
+ for name, question in questions.items():
14
+ if isinstance(question, Choice):
15
+ _validate_choice_criteria(name, question.criteria)
16
+ elif isinstance(question, Noul):
17
+ _validate_noul(name, question.instructions, question.criteria)
18
+ elif not isinstance(question, Score):
19
+ if not isinstance(question, dict) or not isinstance(question.get("type"), str) or not question["type"]:
20
+ raise TypeSafeError(f'Question "{name}" must be a question object or a dictionary with a nonempty string "type".')
21
+ if question["type"] in ("choice", "score") and "criteria" not in question:
22
+ raise TypeSafeError(f'Question "{name}" requires "criteria".')
23
+ if question["type"] == "score":
24
+ _validate_score_criteria(name, question["criteria"])
25
+ elif question["type"] == "choice":
26
+ _validate_choice_criteria(name, question["criteria"])
27
+ elif question["type"] == "noul":
28
+ _validate_noul(name, question.get("instructions"), question.get("criteria"))
29
+ return dict(questions)
30
+
31
+
32
+ def _validate_score_criteria(name: str, criteria: Sequence[JSONContent]) -> None:
33
+ """Reject a score question with no criteria; at least one score is required."""
34
+ if not criteria:
35
+ raise TypeSafeError(f'Score question "{name}" has no criteria; at least one score is required.')
36
+
37
+
38
+ def _validate_choice_criteria(name: str, criteria: Mapping[str, JSONContent | None]) -> None:
39
+ """Reject a choice question with no criteria; at least one choice is required."""
40
+ if not criteria:
41
+ raise TypeSafeError(f'Choice question "{name}" has no criteria; at least one choice is required.')
42
+
43
+
44
+ def _validate_noul(name: str, instructions: object, criteria: object) -> None:
45
+ """Reject a noul question with non-object criteria or with neither instructions nor a described outcome."""
46
+ if criteria is not None and not isinstance(criteria, Mapping):
47
+ raise TypeSafeError(f'Noul question "{name}" criteria must be an object.')
48
+ if instructions is None and not (isinstance(criteria, Mapping) and any(value is not None for value in criteria.values())):
49
+ raise TypeSafeError(f'Noul question "{name}" has neither instructions nor criteria; at least one is required.')
@@ -1,29 +0,0 @@
1
- """Question validation before JSON encoding."""
2
-
3
- from collections.abc import Mapping, Sequence
4
-
5
- from typesafe_sdk._core.errors import TypeSafeError
6
- from typesafe_sdk._core.json_types import JSONContent
7
- from typesafe_sdk._core.question_types import Choice, Noul, Question, Score
8
-
9
-
10
- def normalize_questions(questions: Mapping[str, Question]) -> dict[str, Question]:
11
- if not questions:
12
- raise TypeSafeError("At least one question is required.")
13
- for name, question in questions.items():
14
- if isinstance(question, Score):
15
- _validate_score_criteria(name, question.criteria)
16
- elif not isinstance(question, (Noul, Choice)):
17
- if not isinstance(question, dict) or not isinstance(question.get("type"), str) or not question["type"]:
18
- raise TypeSafeError(f'Question "{name}" must be a question object or a dictionary with a nonempty string "type".')
19
- if question["type"] in ("choice", "score") and "criteria" not in question:
20
- raise TypeSafeError(f'Question "{name}" requires "criteria".')
21
- if question["type"] == "score":
22
- _validate_score_criteria(name, question["criteria"])
23
- return dict(questions)
24
-
25
-
26
- def _validate_score_criteria(name: str, criteria: Sequence[JSONContent]) -> None:
27
- """Reject a score question with no criteria; at least one score is required."""
28
- if not criteria:
29
- raise TypeSafeError(f'Score question "{name}" has no criteria; at least one score is required.')
File without changes
File without changes