openai-sdk-helpers 0.0.8__py3-none-any.whl → 0.0.9__py3-none-any.whl

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 (64) hide show
  1. openai_sdk_helpers/__init__.py +66 -2
  2. openai_sdk_helpers/agent/__init__.py +8 -4
  3. openai_sdk_helpers/agent/base.py +80 -45
  4. openai_sdk_helpers/agent/config.py +6 -4
  5. openai_sdk_helpers/agent/{project_manager.py → coordination.py} +29 -45
  6. openai_sdk_helpers/agent/prompt_utils.py +7 -1
  7. openai_sdk_helpers/agent/runner.py +67 -141
  8. openai_sdk_helpers/agent/search/__init__.py +33 -0
  9. openai_sdk_helpers/agent/search/base.py +297 -0
  10. openai_sdk_helpers/agent/{vector_search.py → search/vector.py} +89 -157
  11. openai_sdk_helpers/agent/{web_search.py → search/web.py} +77 -156
  12. openai_sdk_helpers/agent/summarizer.py +29 -8
  13. openai_sdk_helpers/agent/translator.py +40 -13
  14. openai_sdk_helpers/agent/validation.py +32 -8
  15. openai_sdk_helpers/async_utils.py +132 -0
  16. openai_sdk_helpers/config.py +74 -36
  17. openai_sdk_helpers/context_manager.py +241 -0
  18. openai_sdk_helpers/enums/__init__.py +9 -1
  19. openai_sdk_helpers/enums/base.py +67 -8
  20. openai_sdk_helpers/environment.py +33 -6
  21. openai_sdk_helpers/errors.py +133 -0
  22. openai_sdk_helpers/logging_config.py +105 -0
  23. openai_sdk_helpers/prompt/__init__.py +10 -71
  24. openai_sdk_helpers/prompt/base.py +172 -0
  25. openai_sdk_helpers/response/__init__.py +35 -3
  26. openai_sdk_helpers/response/base.py +363 -210
  27. openai_sdk_helpers/response/config.py +176 -0
  28. openai_sdk_helpers/response/messages.py +56 -40
  29. openai_sdk_helpers/response/runner.py +77 -33
  30. openai_sdk_helpers/response/tool_call.py +49 -25
  31. openai_sdk_helpers/response/vector_store.py +27 -14
  32. openai_sdk_helpers/retry.py +175 -0
  33. openai_sdk_helpers/streamlit_app/__init__.py +19 -2
  34. openai_sdk_helpers/streamlit_app/app.py +114 -39
  35. openai_sdk_helpers/streamlit_app/config.py +502 -0
  36. openai_sdk_helpers/streamlit_app/streamlit_web_search.py +5 -6
  37. openai_sdk_helpers/structure/__init__.py +69 -3
  38. openai_sdk_helpers/structure/agent_blueprint.py +82 -19
  39. openai_sdk_helpers/structure/base.py +208 -93
  40. openai_sdk_helpers/structure/plan/__init__.py +15 -1
  41. openai_sdk_helpers/structure/plan/enum.py +41 -5
  42. openai_sdk_helpers/structure/plan/plan.py +101 -45
  43. openai_sdk_helpers/structure/plan/task.py +38 -6
  44. openai_sdk_helpers/structure/prompt.py +21 -2
  45. openai_sdk_helpers/structure/responses.py +52 -11
  46. openai_sdk_helpers/structure/summary.py +55 -7
  47. openai_sdk_helpers/structure/validation.py +34 -6
  48. openai_sdk_helpers/structure/vector_search.py +132 -18
  49. openai_sdk_helpers/structure/web_search.py +125 -13
  50. openai_sdk_helpers/types.py +57 -0
  51. openai_sdk_helpers/utils/__init__.py +30 -1
  52. openai_sdk_helpers/utils/core.py +168 -34
  53. openai_sdk_helpers/validation.py +302 -0
  54. openai_sdk_helpers/vector_storage/__init__.py +21 -1
  55. openai_sdk_helpers/vector_storage/cleanup.py +25 -13
  56. openai_sdk_helpers/vector_storage/storage.py +123 -64
  57. openai_sdk_helpers/vector_storage/types.py +20 -19
  58. openai_sdk_helpers-0.0.9.dist-info/METADATA +550 -0
  59. openai_sdk_helpers-0.0.9.dist-info/RECORD +66 -0
  60. openai_sdk_helpers/streamlit_app/configuration.py +0 -324
  61. openai_sdk_helpers-0.0.8.dist-info/METADATA +0 -194
  62. openai_sdk_helpers-0.0.8.dist-info/RECORD +0 -55
  63. {openai_sdk_helpers-0.0.8.dist-info → openai_sdk_helpers-0.0.9.dist-info}/WHEEL +0 -0
  64. {openai_sdk_helpers-0.0.8.dist-info → openai_sdk_helpers-0.0.9.dist-info}/licenses/LICENSE +0 -0
@@ -1,29 +1,37 @@
1
- """Core utility helpers for openai-sdk-helpers."""
1
+ """Core utility helpers for openai-sdk-helpers.
2
+
3
+ This module provides foundational utility functions for type coercion,
4
+ file path validation, JSON serialization, and logging. These utilities
5
+ support consistent data handling across the package.
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
5
10
  import json
6
11
  import logging
7
12
  import ast
13
+ from collections.abc import Iterable, Mapping
8
14
  from dataclasses import asdict, is_dataclass
9
15
  from datetime import datetime
10
16
  from enum import Enum
11
17
  from pathlib import Path
12
- from typing import Any, Dict, Iterable, List, Mapping, Optional, TypeVar
18
+ from typing import Any, TypeVar
19
+
13
20
 
21
+ def coerce_optional_float(value: object) -> float | None:
22
+ """Return a float when the provided value can be coerced, otherwise None.
14
23
 
15
- def coerce_optional_float(value: Any) -> Optional[float]:
16
- """Return a float when the provided value can be coerced, otherwise ``None``.
24
+ Handles float, int, and string inputs. Empty strings or None return None.
17
25
 
18
26
  Parameters
19
27
  ----------
20
- value : Any
28
+ value : object
21
29
  Value to convert into a float. Strings must be parseable as floats.
22
30
 
23
31
  Returns
24
32
  -------
25
- float | None
26
- Converted float value or ``None`` if the input is ``None``.
33
+ float or None
34
+ Converted float value or None if the input is None.
27
35
 
28
36
  Raises
29
37
  ------
@@ -31,6 +39,15 @@ def coerce_optional_float(value: Any) -> Optional[float]:
31
39
  If a non-empty string cannot be converted to a float.
32
40
  TypeError
33
41
  If the value is not a float-compatible type.
42
+
43
+ Examples
44
+ --------
45
+ >>> coerce_optional_float(3.14)
46
+ 3.14
47
+ >>> coerce_optional_float("2.5")
48
+ 2.5
49
+ >>> coerce_optional_float(None) is None
50
+ True
34
51
  """
35
52
  if value is None:
36
53
  return None
@@ -44,18 +61,21 @@ def coerce_optional_float(value: Any) -> Optional[float]:
44
61
  raise TypeError("timeout must be a float, int, str, or None")
45
62
 
46
63
 
47
- def coerce_optional_int(value: Any) -> Optional[int]:
48
- """Return an int when the provided value can be coerced, otherwise ``None``.
64
+ def coerce_optional_int(value: object) -> int | None:
65
+ """Return an int when the provided value can be coerced, otherwise None.
66
+
67
+ Handles int, float (if whole number), and string inputs. Empty strings
68
+ or None return None. Booleans are not considered valid integers.
49
69
 
50
70
  Parameters
51
71
  ----------
52
- value : Any
72
+ value : object
53
73
  Value to convert into an int. Strings must be parseable as integers.
54
74
 
55
75
  Returns
56
76
  -------
57
- int | None
58
- Converted integer value or ``None`` if the input is ``None``.
77
+ int or None
78
+ Converted integer value or None if the input is None.
59
79
 
60
80
  Raises
61
81
  ------
@@ -63,6 +83,17 @@ def coerce_optional_int(value: Any) -> Optional[int]:
63
83
  If a non-empty string cannot be converted to an integer.
64
84
  TypeError
65
85
  If the value is not an int-compatible type.
86
+
87
+ Examples
88
+ --------
89
+ >>> coerce_optional_int(42)
90
+ 42
91
+ >>> coerce_optional_int("100")
92
+ 100
93
+ >>> coerce_optional_int(3.0)
94
+ 3
95
+ >>> coerce_optional_int(None) is None
96
+ True
66
97
  """
67
98
  if value is None:
68
99
  return None
@@ -78,23 +109,32 @@ def coerce_optional_int(value: Any) -> Optional[int]:
78
109
  raise TypeError("max_retries must be an int, str, or None")
79
110
 
80
111
 
81
- def coerce_dict(value: Any) -> Dict[str, Any]:
82
- """Return a string-keyed dictionary built from ``value`` if possible.
112
+ def coerce_dict(value: object) -> dict[str, Any]:
113
+ """Return a string-keyed dictionary built from value if possible.
114
+
115
+ Converts Mapping objects to dictionaries. None returns an empty dict.
83
116
 
84
117
  Parameters
85
118
  ----------
86
- value : Any
87
- Mapping-like value to convert. ``None`` yields an empty dictionary.
119
+ value : object
120
+ Mapping-like value to convert. None yields an empty dictionary.
88
121
 
89
122
  Returns
90
123
  -------
91
124
  dict[str, Any]
92
- Dictionary representation of ``value``.
125
+ Dictionary representation of value.
93
126
 
94
127
  Raises
95
128
  ------
96
129
  TypeError
97
130
  If the value cannot be treated as a mapping.
131
+
132
+ Examples
133
+ --------
134
+ >>> coerce_dict({"a": 1})
135
+ {'a': 1}
136
+ >>> coerce_dict(None)
137
+ {}
98
138
  """
99
139
  if value is None:
100
140
  return {}
@@ -107,18 +147,32 @@ T = TypeVar("T")
107
147
  _configured_logging = False
108
148
 
109
149
 
110
- def ensure_list(value: Iterable[T] | T | None) -> List[T]:
150
+ def ensure_list(value: Iterable[T] | T | None) -> list[T]:
111
151
  """Normalize a single item or iterable into a list.
112
152
 
153
+ Converts None to empty list, tuples to lists, and wraps single
154
+ items in a list.
155
+
113
156
  Parameters
114
157
  ----------
115
158
  value : Iterable[T] | T | None
116
- Item or iterable to wrap. ``None`` yields an empty list.
159
+ Item or iterable to wrap. None yields an empty list.
117
160
 
118
161
  Returns
119
162
  -------
120
163
  list[T]
121
- Normalized list representation of ``value``.
164
+ Normalized list representation of value.
165
+
166
+ Examples
167
+ --------
168
+ >>> ensure_list(None)
169
+ []
170
+ >>> ensure_list(5)
171
+ [5]
172
+ >>> ensure_list([1, 2, 3])
173
+ [1, 2, 3]
174
+ >>> ensure_list(("a", "b"))
175
+ ['a', 'b']
122
176
  """
123
177
  if value is None:
124
178
  return []
@@ -134,12 +188,15 @@ def check_filepath(
134
188
  ) -> Path:
135
189
  """Ensure the parent directory for a file path exists.
136
190
 
191
+ Creates parent directories as needed. Exactly one of filepath or
192
+ fullfilepath must be provided.
193
+
137
194
  Parameters
138
195
  ----------
139
- filepath : Path | None, optional
140
- Path object to validate. Mutually exclusive with ``fullfilepath``.
141
- fullfilepath : str | None, optional
142
- String path to validate. Mutually exclusive with ``filepath``.
196
+ filepath : Path or None, optional
197
+ Path object to validate. Mutually exclusive with fullfilepath.
198
+ fullfilepath : str or None, optional
199
+ String path to validate. Mutually exclusive with filepath.
143
200
 
144
201
  Returns
145
202
  -------
@@ -149,7 +206,14 @@ def check_filepath(
149
206
  Raises
150
207
  ------
151
208
  ValueError
152
- If neither ``filepath`` nor ``fullfilepath`` is provided.
209
+ If neither filepath nor fullfilepath is provided.
210
+
211
+ Examples
212
+ --------
213
+ >>> from pathlib import Path
214
+ >>> path = check_filepath(filepath=Path("/tmp/test.txt"))
215
+ >>> isinstance(path, Path)
216
+ True
153
217
  """
154
218
  if filepath is None and fullfilepath is None:
155
219
  raise ValueError("filepath or fullfilepath is required.")
@@ -166,6 +230,9 @@ def check_filepath(
166
230
  def _to_jsonable(value: Any) -> Any:
167
231
  """Convert common helper types to JSON-serializable forms.
168
232
 
233
+ Handles Enum, Path, datetime, dataclasses, Pydantic models, dicts,
234
+ lists, tuples, and sets.
235
+
169
236
  Parameters
170
237
  ----------
171
238
  value : Any
@@ -174,7 +241,11 @@ def _to_jsonable(value: Any) -> Any:
174
241
  Returns
175
242
  -------
176
243
  Any
177
- A JSON-safe representation of ``value``.
244
+ A JSON-safe representation of value.
245
+
246
+ Notes
247
+ -----
248
+ This is an internal helper function. Use coerce_jsonable for public API.
178
249
  """
179
250
  if value is None:
180
251
  return None
@@ -197,7 +268,10 @@ def _to_jsonable(value: Any) -> Any:
197
268
 
198
269
 
199
270
  def coerce_jsonable(value: Any) -> Any:
200
- """Convert ``value`` into a JSON-serializable representation.
271
+ """Convert value into a JSON-serializable representation.
272
+
273
+ Handles BaseStructure, BaseResponse, dataclasses, and other complex
274
+ types by recursively converting them to JSON-compatible forms.
201
275
 
202
276
  Parameters
203
277
  ----------
@@ -207,7 +281,14 @@ def coerce_jsonable(value: Any) -> Any:
207
281
  Returns
208
282
  -------
209
283
  Any
210
- JSON-serializable representation of ``value``.
284
+ JSON-serializable representation of value.
285
+
286
+ Examples
287
+ --------
288
+ >>> from datetime import datetime
289
+ >>> result = coerce_jsonable({"date": datetime(2024, 1, 1)})
290
+ >>> isinstance(result, dict)
291
+ True
211
292
  """
212
293
  from openai_sdk_helpers.response.base import BaseResponse
213
294
  from openai_sdk_helpers.structure.base import BaseStructure
@@ -229,16 +310,29 @@ def coerce_jsonable(value: Any) -> Any:
229
310
 
230
311
 
231
312
  class customJSONEncoder(json.JSONEncoder):
232
- """Encode common helper types like enums and paths.
313
+ """JSON encoder for common helper types like enums and paths.
314
+
315
+ Extends json.JSONEncoder to handle Enum, Path, datetime, dataclasses,
316
+ and Pydantic models automatically.
233
317
 
234
318
  Methods
235
319
  -------
236
320
  default(o)
237
- Return a JSON-serializable representation of ``o``.
321
+ Return a JSON-serializable representation of o.
322
+
323
+ Examples
324
+ --------
325
+ >>> import json
326
+ >>> from pathlib import Path
327
+ >>> json.dumps({"path": Path("/tmp")}, cls=customJSONEncoder)
328
+ '{"path": "/tmp"}'
238
329
  """
239
330
 
240
331
  def default(self, o: Any) -> Any:
241
- """Return a JSON-serializable representation of ``o``.
332
+ """Return a JSON-serializable representation of o.
333
+
334
+ Called by the json module when the default serialization fails.
335
+ Delegates to _to_jsonable for type-specific conversions.
242
336
 
243
337
  Parameters
244
338
  ----------
@@ -248,7 +342,7 @@ class customJSONEncoder(json.JSONEncoder):
248
342
  Returns
249
343
  -------
250
344
  Any
251
- JSON-safe representation of ``o``.
345
+ JSON-safe representation of o.
252
346
  """
253
347
  return _to_jsonable(o)
254
348
 
@@ -256,21 +350,44 @@ class customJSONEncoder(json.JSONEncoder):
256
350
  class JSONSerializable:
257
351
  """Mixin for classes that can be serialized to JSON.
258
352
 
353
+ Provides to_json() and to_json_file() methods for any class. Works
354
+ with dataclasses, Pydantic models, and regular classes with __dict__.
355
+
259
356
  Methods
260
357
  -------
261
358
  to_json()
262
359
  Return a JSON-compatible dict representation of the instance.
263
360
  to_json_file(filepath)
264
361
  Write serialized JSON data to a file path.
362
+
363
+ Examples
364
+ --------
365
+ >>> from dataclasses import dataclass
366
+ >>> @dataclass
367
+ ... class MyClass(JSONSerializable):
368
+ ... value: int
369
+ >>> obj = MyClass(value=42)
370
+ >>> obj.to_json()
371
+ {'value': 42}
265
372
  """
266
373
 
267
- def to_json(self) -> Dict[str, Any]:
374
+ def to_json(self) -> dict[str, Any]:
268
375
  """Return a JSON-compatible dict representation.
269
376
 
377
+ Automatically handles dataclasses, Pydantic models, and objects
378
+ with __dict__ attributes.
379
+
270
380
  Returns
271
381
  -------
272
382
  dict[str, Any]
273
383
  Mapping with only JSON-serializable values.
384
+
385
+ Examples
386
+ --------
387
+ >>> obj = JSONSerializable()
388
+ >>> result = obj.to_json()
389
+ >>> isinstance(result, dict)
390
+ True
274
391
  """
275
392
  if is_dataclass(self) and not isinstance(self, type):
276
393
  return {k: _to_jsonable(v) for k, v in asdict(self).items()}
@@ -282,6 +399,9 @@ class JSONSerializable:
282
399
  def to_json_file(self, filepath: str | Path) -> str:
283
400
  """Write serialized JSON data to a file path.
284
401
 
402
+ Creates parent directories as needed. Uses customJSONEncoder for
403
+ handling special types.
404
+
285
405
  Parameters
286
406
  ----------
287
407
  filepath : str | Path
@@ -291,6 +411,11 @@ class JSONSerializable:
291
411
  -------
292
412
  str
293
413
  String representation of the file path written.
414
+
415
+ Examples
416
+ --------
417
+ >>> obj = JSONSerializable()
418
+ >>> path = obj.to_json_file("/tmp/output.json") # doctest: +SKIP
294
419
  """
295
420
  target = Path(filepath)
296
421
  check_filepath(fullfilepath=str(target))
@@ -308,12 +433,21 @@ class JSONSerializable:
308
433
  def log(message: str, level: int = logging.INFO) -> None:
309
434
  """Log a message with a basic configuration.
310
435
 
436
+ Configures logging on first use with a simple timestamp format.
437
+ Subsequent calls use the existing configuration.
438
+
311
439
  Parameters
312
440
  ----------
313
441
  message : str
314
442
  Message to emit.
315
443
  level : int, optional
316
- Logging level, by default ``logging.INFO``.
444
+ Logging level (e.g., logging.INFO, logging.WARNING), by default
445
+ logging.INFO.
446
+
447
+ Examples
448
+ --------
449
+ >>> import logging
450
+ >>> log("Test message", level=logging.INFO) # doctest: +SKIP
317
451
  """
318
452
  global _configured_logging
319
453
  if not _configured_logging:
@@ -0,0 +1,302 @@
1
+ """Input validation utilities for openai-sdk-helpers.
2
+
3
+ Provides validators and validation helpers for ensuring data integrity
4
+ at API boundaries and configuration initialization.
5
+ """
6
+
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Callable, TypeVar
10
+
11
+ from openai_sdk_helpers.errors import InputValidationError
12
+
13
+ T = TypeVar("T")
14
+ K = TypeVar("K", bound=str)
15
+ V = TypeVar("V")
16
+ U = TypeVar("U")
17
+
18
+
19
+ def validate_non_empty_string(value: str, field_name: str) -> str:
20
+ """Validate that a string is non-empty.
21
+
22
+ Parameters
23
+ ----------
24
+ value : str
25
+ String value to validate.
26
+ field_name : str
27
+ Name of the field for error messages.
28
+
29
+ Returns
30
+ -------
31
+ str
32
+ The validated (stripped) string.
33
+
34
+ Raises
35
+ ------
36
+ InputValidationError
37
+ If string is empty or only whitespace.
38
+ """
39
+ if not isinstance(value, str):
40
+ raise InputValidationError(
41
+ f"{field_name} must be a string, got {type(value).__name__}"
42
+ )
43
+ stripped = value.strip()
44
+ if not stripped:
45
+ raise InputValidationError(f"{field_name} must be non-empty")
46
+ return stripped
47
+
48
+
49
+ def validate_max_length(value: str, max_len: int, field_name: str) -> str:
50
+ """Validate that a string doesn't exceed maximum length.
51
+
52
+ Parameters
53
+ ----------
54
+ value : str
55
+ String value to validate.
56
+ max_len : int
57
+ Maximum allowed length.
58
+ field_name : str
59
+ Name of the field for error messages.
60
+
61
+ Returns
62
+ -------
63
+ str
64
+ The validated string.
65
+
66
+ Raises
67
+ ------
68
+ InputValidationError
69
+ If string exceeds maximum length.
70
+ """
71
+ if len(value) > max_len:
72
+ raise InputValidationError(
73
+ f"{field_name} must be <= {max_len} characters, "
74
+ f"got {len(value)} characters"
75
+ )
76
+ return value
77
+
78
+
79
+ def validate_url_format(url: str, field_name: str = "URL") -> str:
80
+ """Validate that a string is a valid URL.
81
+
82
+ Parameters
83
+ ----------
84
+ url : str
85
+ URL string to validate.
86
+ field_name : str
87
+ Name of the field for error messages. Default is "URL".
88
+
89
+ Returns
90
+ -------
91
+ str
92
+ The validated URL.
93
+
94
+ Raises
95
+ ------
96
+ InputValidationError
97
+ If URL format is invalid.
98
+ """
99
+ if not url.startswith(("http://", "https://")):
100
+ raise InputValidationError(
101
+ f"{field_name} must start with http:// or https://, got: {url}"
102
+ )
103
+ return url
104
+
105
+
106
+ def validate_dict_mapping(
107
+ mapping: Mapping[K, V],
108
+ expected_keys: set[K],
109
+ field_name: str,
110
+ allow_extra: bool = False,
111
+ ) -> dict[K, V]:
112
+ """Validate that a dict contains expected keys.
113
+
114
+ Parameters
115
+ ----------
116
+ mapping : Mapping
117
+ Dictionary-like object to validate.
118
+ expected_keys : set
119
+ Set of required key names.
120
+ field_name : str
121
+ Name of the field for error messages.
122
+ allow_extra : bool
123
+ Whether extra keys are allowed. Default is False.
124
+
125
+ Returns
126
+ -------
127
+ dict
128
+ The validated dictionary.
129
+
130
+ Raises
131
+ ------
132
+ InputValidationError
133
+ If required keys are missing or unexpected keys present (if not allowed).
134
+ """
135
+ if not isinstance(mapping, Mapping):
136
+ raise InputValidationError(
137
+ f"{field_name} must be a dict or mapping, got {type(mapping).__name__}"
138
+ )
139
+
140
+ missing_keys = expected_keys - set(mapping.keys())
141
+ if missing_keys:
142
+ raise InputValidationError(
143
+ f"{field_name} missing required keys: {', '.join(sorted(missing_keys))}"
144
+ )
145
+
146
+ if not allow_extra:
147
+ extra_keys = set(mapping.keys()) - expected_keys
148
+ if extra_keys:
149
+ raise InputValidationError(
150
+ f"{field_name} has unexpected keys: {', '.join(sorted(extra_keys))}"
151
+ )
152
+
153
+ return dict(mapping)
154
+
155
+
156
+ def validate_list_items(
157
+ items: list[U],
158
+ item_validator: Callable[[U], T],
159
+ field_name: str,
160
+ allow_empty: bool = False,
161
+ ) -> list[T]:
162
+ """Validate all items in a list using a validator function.
163
+
164
+ Parameters
165
+ ----------
166
+ items : list
167
+ List to validate.
168
+ item_validator : Callable
169
+ Function that validates individual items.
170
+ field_name : str
171
+ Name of the field for error messages.
172
+ allow_empty : bool
173
+ Whether an empty list is allowed. Default is False.
174
+
175
+ Returns
176
+ -------
177
+ list
178
+ List of validated items.
179
+
180
+ Raises
181
+ ------
182
+ InputValidationError
183
+ If list is empty when not allowed, or if any item fails validation.
184
+ """
185
+ if not isinstance(items, list):
186
+ raise InputValidationError(
187
+ f"{field_name} must be a list, got {type(items).__name__}"
188
+ )
189
+
190
+ if not items and not allow_empty:
191
+ raise InputValidationError(f"{field_name} must not be empty")
192
+
193
+ validated = []
194
+ for i, item in enumerate(items):
195
+ try:
196
+ validated.append(item_validator(item))
197
+ except (InputValidationError, ValueError) as exc:
198
+ raise InputValidationError(f"{field_name}[{i}] is invalid: {exc}") from exc
199
+
200
+ return validated
201
+
202
+
203
+ def validate_choice(
204
+ value: U,
205
+ allowed_values: set[U],
206
+ field_name: str,
207
+ ) -> U:
208
+ """Validate that a value is one of allowed choices.
209
+
210
+ Parameters
211
+ ----------
212
+ value : Any
213
+ Value to validate.
214
+ allowed_values : set
215
+ Set of allowed values.
216
+ field_name : str
217
+ Name of the field for error messages.
218
+
219
+ Returns
220
+ -------
221
+ Any
222
+ The validated value.
223
+
224
+ Raises
225
+ ------
226
+ InputValidationError
227
+ If value is not in allowed values.
228
+ """
229
+ if value not in allowed_values:
230
+ raise InputValidationError(
231
+ f"{field_name} must be one of {', '.join(map(str, sorted(allowed_values, key=str)))}; got: {value}"
232
+ )
233
+ return value
234
+
235
+
236
+ def validate_safe_path(
237
+ path: Path | str,
238
+ base_dir: Path | None = None,
239
+ field_name: str = "path",
240
+ ) -> Path:
241
+ """Validate that a path is safe and does not escape the base directory.
242
+
243
+ Protects against path traversal attacks by ensuring the resolved path
244
+ is within the base directory when provided.
245
+
246
+ Parameters
247
+ ----------
248
+ path : Path or str
249
+ Path to validate. Can be absolute or relative.
250
+ base_dir : Path or None
251
+ Base directory to validate against. If None, only checks for
252
+ suspicious patterns but allows any valid path.
253
+ field_name : str
254
+ Name of the field for error messages. Default is "path".
255
+
256
+ Returns
257
+ -------
258
+ Path
259
+ Validated resolved path.
260
+
261
+ Raises
262
+ ------
263
+ InputValidationError
264
+ If path is invalid, contains suspicious patterns, or escapes
265
+ the base directory.
266
+
267
+ Examples
268
+ --------
269
+ >>> from pathlib import Path
270
+ >>> validate_safe_path(Path("./templates/file.txt"), Path("/base"))
271
+ PosixPath('/base/templates/file.txt')
272
+ """
273
+ if isinstance(path, str):
274
+ path = Path(path)
275
+
276
+ # Check for suspicious patterns
277
+ path_str = str(path)
278
+ if ".." in path.parts:
279
+ raise InputValidationError(
280
+ f"{field_name} contains suspicious '..' pattern: {path_str}"
281
+ )
282
+
283
+ # Resolve to absolute path
284
+ try:
285
+ resolved = path.resolve()
286
+ except (OSError, RuntimeError) as exc:
287
+ raise InputValidationError(
288
+ f"{field_name} cannot be resolved: {path_str}"
289
+ ) from exc
290
+
291
+ # If base_dir provided, ensure path is within it
292
+ if base_dir is not None:
293
+ try:
294
+ base_resolved = base_dir.resolve()
295
+ resolved.relative_to(base_resolved)
296
+ except ValueError:
297
+ raise InputValidationError(
298
+ f"{field_name} escapes base directory: {path_str} "
299
+ f"is not within {base_dir}"
300
+ ) from None
301
+
302
+ return resolved