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.
- openai_sdk_helpers/__init__.py +66 -2
- openai_sdk_helpers/agent/__init__.py +8 -4
- openai_sdk_helpers/agent/base.py +80 -45
- openai_sdk_helpers/agent/config.py +6 -4
- openai_sdk_helpers/agent/{project_manager.py → coordination.py} +29 -45
- openai_sdk_helpers/agent/prompt_utils.py +7 -1
- openai_sdk_helpers/agent/runner.py +67 -141
- openai_sdk_helpers/agent/search/__init__.py +33 -0
- openai_sdk_helpers/agent/search/base.py +297 -0
- openai_sdk_helpers/agent/{vector_search.py → search/vector.py} +89 -157
- openai_sdk_helpers/agent/{web_search.py → search/web.py} +77 -156
- openai_sdk_helpers/agent/summarizer.py +29 -8
- openai_sdk_helpers/agent/translator.py +40 -13
- openai_sdk_helpers/agent/validation.py +32 -8
- openai_sdk_helpers/async_utils.py +132 -0
- openai_sdk_helpers/config.py +74 -36
- openai_sdk_helpers/context_manager.py +241 -0
- openai_sdk_helpers/enums/__init__.py +9 -1
- openai_sdk_helpers/enums/base.py +67 -8
- openai_sdk_helpers/environment.py +33 -6
- openai_sdk_helpers/errors.py +133 -0
- openai_sdk_helpers/logging_config.py +105 -0
- openai_sdk_helpers/prompt/__init__.py +10 -71
- openai_sdk_helpers/prompt/base.py +172 -0
- openai_sdk_helpers/response/__init__.py +35 -3
- openai_sdk_helpers/response/base.py +363 -210
- openai_sdk_helpers/response/config.py +176 -0
- openai_sdk_helpers/response/messages.py +56 -40
- openai_sdk_helpers/response/runner.py +77 -33
- openai_sdk_helpers/response/tool_call.py +49 -25
- openai_sdk_helpers/response/vector_store.py +27 -14
- openai_sdk_helpers/retry.py +175 -0
- openai_sdk_helpers/streamlit_app/__init__.py +19 -2
- openai_sdk_helpers/streamlit_app/app.py +114 -39
- openai_sdk_helpers/streamlit_app/config.py +502 -0
- openai_sdk_helpers/streamlit_app/streamlit_web_search.py +5 -6
- openai_sdk_helpers/structure/__init__.py +69 -3
- openai_sdk_helpers/structure/agent_blueprint.py +82 -19
- openai_sdk_helpers/structure/base.py +208 -93
- openai_sdk_helpers/structure/plan/__init__.py +15 -1
- openai_sdk_helpers/structure/plan/enum.py +41 -5
- openai_sdk_helpers/structure/plan/plan.py +101 -45
- openai_sdk_helpers/structure/plan/task.py +38 -6
- openai_sdk_helpers/structure/prompt.py +21 -2
- openai_sdk_helpers/structure/responses.py +52 -11
- openai_sdk_helpers/structure/summary.py +55 -7
- openai_sdk_helpers/structure/validation.py +34 -6
- openai_sdk_helpers/structure/vector_search.py +132 -18
- openai_sdk_helpers/structure/web_search.py +125 -13
- openai_sdk_helpers/types.py +57 -0
- openai_sdk_helpers/utils/__init__.py +30 -1
- openai_sdk_helpers/utils/core.py +168 -34
- openai_sdk_helpers/validation.py +302 -0
- openai_sdk_helpers/vector_storage/__init__.py +21 -1
- openai_sdk_helpers/vector_storage/cleanup.py +25 -13
- openai_sdk_helpers/vector_storage/storage.py +123 -64
- openai_sdk_helpers/vector_storage/types.py +20 -19
- openai_sdk_helpers-0.0.9.dist-info/METADATA +550 -0
- openai_sdk_helpers-0.0.9.dist-info/RECORD +66 -0
- openai_sdk_helpers/streamlit_app/configuration.py +0 -324
- openai_sdk_helpers-0.0.8.dist-info/METADATA +0 -194
- openai_sdk_helpers-0.0.8.dist-info/RECORD +0 -55
- {openai_sdk_helpers-0.0.8.dist-info → openai_sdk_helpers-0.0.9.dist-info}/WHEEL +0 -0
- {openai_sdk_helpers-0.0.8.dist-info → openai_sdk_helpers-0.0.9.dist-info}/licenses/LICENSE +0 -0
openai_sdk_helpers/utils/core.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 :
|
|
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
|
|
26
|
-
Converted float value or
|
|
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:
|
|
48
|
-
"""Return an int when the provided value can be coerced, otherwise
|
|
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 :
|
|
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
|
|
58
|
-
Converted integer value or
|
|
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:
|
|
82
|
-
"""Return a string-keyed dictionary built from
|
|
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 :
|
|
87
|
-
Mapping-like value to convert.
|
|
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
|
|
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) ->
|
|
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.
|
|
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
|
|
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
|
|
140
|
-
Path object to validate. Mutually exclusive with
|
|
141
|
-
fullfilepath : str
|
|
142
|
-
String path to validate. Mutually exclusive with
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
|
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) ->
|
|
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
|
|
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
|