edri 2025.11.1rc2__py3-none-any.whl → 2025.11.1rc3__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.
@@ -3,7 +3,7 @@ from enum import Enum
3
3
  from http import HTTPMethod
4
4
  from inspect import isclass
5
5
  from logging import getLogger
6
- from types import NoneType, UnionType
6
+ from types import NoneType, UnionType, GenericAlias
7
7
  from typing import Type, get_origin, get_args
8
8
  from uuid import UUID
9
9
 
@@ -14,7 +14,9 @@ from edri.api.dataclass.file import File
14
14
  from edri.api.extensions.url_prefix import PrefixBase
15
15
  from edri.config.constant import ApiType
16
16
  from edri.dataclass.event import EventHandlingType, _event, Event
17
+ from edri.dataclass.injection import Injection
17
18
  from edri.utility.function import camel2snake
19
+ from edri.utility.validation import ListValidation
18
20
 
19
21
 
20
22
  @dataclass
@@ -109,6 +111,13 @@ def api(cls=None, /, *, init=True, repr=True, eq=True, order=False,
109
111
  raise TypeError(f"{item_args[0]} cannot be used as a type for API event")
110
112
  elif item_type not in allowed_types and not hasattr(field.type, "fromisoformat"):
111
113
  raise TypeError(f"{field.type} cannot be used as a type for API event")
114
+ elif isinstance(field.type, Injection):
115
+ for validator in field.type.classes:
116
+ if validator == ListValidation:
117
+ raise TypeError(
118
+ "ListValidation must be used as ListValidation[T], "
119
+ "e.g. ListValidation[Any] or ListValidation[inject(...)]."
120
+ )
112
121
 
113
122
  http_method = method or dataclass.method
114
123
  if http_method is None:
@@ -1,8 +1,9 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from dataclasses import fields, MISSING
3
+ from inspect import signature
3
4
  from logging import getLogger
4
- from types import UnionType, NoneType
5
- from typing import Callable, Type, get_origin, Union, get_args, Any, TypedDict, Literal, TypeAliasType
5
+ from types import UnionType, NoneType, GenericAlias
6
+ from typing import Callable, Type, get_origin, Union, get_args, Any, TypedDict, Literal, TypeAliasType, Iterable
6
7
  from urllib.parse import parse_qs, unquote
7
8
 
8
9
  from edri.dataclass.directive import ResponseDirective
@@ -79,73 +80,228 @@ class BaseHandler[T: ResponseDirective](ABC):
79
80
  def convert_type(self, value: Any, annotation: type) -> Any:
80
81
  """
81
82
  Validates and converts input values to the specified annotation type,
82
- supporting basic types, Optional, and lists with type annotations.
83
+ supporting:
84
+ - basic types
85
+ - Optional / Union / |
86
+ - list / tuple / dict with type args
87
+ - Literal
88
+ - list-like subclasses (e.g. ListValidation[int])
89
+ - Injection of validation classes (e.g. Injection((ListValidation[int],), {...}))
90
+ """
91
+ annotation = self._normalize_annotation(annotation)
83
92
 
84
- Parameters:
85
- value: The input value to be validated and converted.
86
- annotation: The target type annotation for the conversion.
93
+ # Any
94
+ if annotation is Any:
95
+ return value
87
96
 
88
- Returns:
89
- The converted value if conversion is successful.
97
+ # Injection (chain of validation classes)
98
+ if isinstance(annotation, Injection):
99
+ return self._convert_injection(value, annotation)
90
100
 
91
- Raises:
92
- TypeError: If the value cannot be converted to the specified type.
93
- """
101
+ # Unions / Optional
102
+ if self._is_union(annotation):
103
+ return self._convert_union(value, annotation)
104
+
105
+ origin = get_origin(annotation)
106
+
107
+ # Generics: list, tuple, dict, Literal, ListValidation[int], ...
108
+ if origin is not None:
109
+ return self._convert_generic(value, annotation, origin)
110
+
111
+ # Non-generic simple types (including bare ListValidation, bool, etc.)
112
+ return self._convert_simple(value, annotation)
113
+
114
+ def _normalize_annotation(self, annotation: type) -> type:
115
+ """Unwrap TypeAliasType and other simple normalizations."""
94
116
  if isinstance(annotation, TypeAliasType):
95
- annotation = annotation.__value__
96
- if get_origin(annotation) or isinstance(annotation, UnionType):
97
- if isinstance(annotation, UnionType) or get_origin(annotation) == Union:
98
- annotations = get_args(annotation)
99
- if value is None:
100
- if NoneType in annotations:
101
- return None
102
- else:
103
- raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
104
- for annotation in annotations:
105
- try:
106
- return self.convert_type(value, annotation)
107
- except TypeError:
108
- continue
117
+ return annotation.__value__
118
+ return annotation
119
+
120
+ def _is_union(self, annotation: type) -> bool:
121
+ """Check if annotation is a Union / Optional / | type."""
122
+ return isinstance(annotation, UnionType) or get_origin(annotation) is Union
123
+
124
+ def _convert_injection(self, value: Any, injection: Injection) -> Any:
125
+ """
126
+ Run all validation classes in an Injection.
127
+
128
+ Each `cls` in `injection.classes` is:
129
+ - either a plain validation class (e.g. ListValidation),
130
+ - or a GenericAlias like ListValidation[int].
131
+
132
+ For list-like generics (e.g. ListValidation[int]) we:
133
+ - convert each element of `value` using the inner type (int here),
134
+ - then instantiate the validation class with filtered params.
135
+ """
136
+ try:
137
+ for cls in injection.classes:
138
+ # Determine underlying class and optional inner type
139
+ item_type = None
140
+
141
+ if isinstance(cls, GenericAlias):
142
+ origin = get_origin(cls) # e.g. ListValidation
143
+ args = get_args(cls) # e.g. (int,)
144
+ target_cls = origin
145
+
146
+ # list-like validation class: ListValidation[int], MyListValidator[str], ...
147
+ if issubclass(origin, list) and args:
148
+ item_type = args[0]
109
149
  else:
110
- raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
111
- elif get_origin(annotation) == list and isinstance(value, list):
112
- if len(get_args(annotation)) != 1:
113
- raise TypeError("Type of list item must be specified")
114
- return [self.convert_type(value, get_args(annotation)[0]) for value in value]
115
- elif get_origin(annotation) == tuple and isinstance(value, tuple):
116
- return tuple(self.convert_type(v, a) for v, a in zip(value, get_args(annotation)))
117
- elif get_origin(annotation) == Literal and isinstance(value, str) and value in get_args(annotation):
118
- return value
119
- elif get_origin(annotation) == dict and isinstance(value, dict):
120
- a_args = get_args(annotation)
121
- return {self.convert_type(k, a_args[0]): self.convert_type(v, a_args[1]) for k, v in value.items()}
122
- else:
123
- raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
124
- else:
125
- if annotation is Any:
126
- return value
127
- elif isinstance(annotation, Injection):
128
- try:
129
- for validator in annotation:
130
- value = validator(value)
131
- return value
132
- except ValueError:
133
- raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
134
- elif isinstance(value, annotation):
150
+ target_cls = cls
151
+
152
+ # Filter parameters to those that target_cls.__init__ actually accepts
153
+ sig = signature(target_cls)
154
+ param_names = [
155
+ p.name for p in sig.parameters.values()
156
+ if p.name != "self"
157
+ ]
158
+ filtered_params = {
159
+ k: v for k, v in injection.parameters.items()
160
+ if k in param_names
161
+ }
162
+
163
+ # If this is a list-like validator with an inner type -> convert elements first
164
+ if item_type is not None:
165
+ if (not isinstance(value, Iterable)) or isinstance(value, (str, bytes)):
166
+ raise TypeError(
167
+ f"Value '{value}' is not a valid iterable for validator {cls}"
168
+ )
169
+
170
+ converted_items = [
171
+ self.convert_type(item, item_type) for item in value
172
+ ]
173
+ value = target_cls(converted_items, **filtered_params)
174
+ else:
175
+ # Any other validation class: just feed the (possibly already converted) value
176
+ value = target_cls(value, **filtered_params)
177
+
178
+ return value
179
+
180
+ except ValueError:
181
+ # Your original contract: map validator ValueError -> TypeError
182
+ raise TypeError(f"Value '{value}' cannot be converted to type {injection}")
183
+
184
+ def _convert_union(self, value: Any, annotation: type) -> Any:
185
+ """Handle Union/Optional annotations."""
186
+ annotations = get_args(annotation)
187
+
188
+ # Handle Optional[...] where None is allowed
189
+ if value is None:
190
+ if NoneType in annotations:
191
+ return None
192
+ raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
193
+
194
+ # Try each type in the Union
195
+ last_error: Exception | None = None
196
+ for ann in annotations:
197
+ try:
198
+ return self.convert_type(value, ann)
199
+ except TypeError as e:
200
+ last_error = e
201
+ continue
202
+
203
+ raise TypeError(f"Value '{value}' cannot be converted to type {annotation}") from last_error
204
+
205
+ def _convert_generic(self, value: Any, annotation: type, origin: type) -> Any:
206
+ """
207
+ Handle generics like:
208
+ - list[T] and list-like subclasses (ListValidation[T])
209
+ - tuple[X, Y, ...]
210
+ - dict[K, V]
211
+ - Literal[...]
212
+ """
213
+ args = get_args(annotation)
214
+
215
+ # list[T] and list-like subclasses (e.g. ListValidation[int])
216
+ if isinstance(origin, type) and issubclass(origin, list):
217
+ if not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
218
+ raise TypeError(f"Value '{value}' is not a valid iterable for type {annotation}")
219
+
220
+ item_type = args[0] if args else Any
221
+ converted_items = [self.convert_type(item, item_type) for item in value]
222
+
223
+ if origin is list:
224
+ return converted_items
225
+
226
+ # Subclass of list, e.g. ListValidation[int]
227
+ try:
228
+ return origin(converted_items)
229
+ except Exception as e:
230
+ raise TypeError(
231
+ f"Value '{value}' cannot be converted to list-like type {annotation}"
232
+ ) from e
233
+
234
+ # ---- tuple[X, Y, ...] ----
235
+ if origin is tuple:
236
+ if not isinstance(value, tuple):
237
+ raise TypeError(f"Value '{value}' is not a tuple for type {annotation}")
238
+ return tuple(self.convert_type(v, a) for v, a in zip(value, args))
239
+
240
+ # ---- dict[K, V] ----
241
+ if origin is dict:
242
+ if not isinstance(value, dict):
243
+ raise TypeError(f"Value '{value}' is not a dict for type {annotation}")
244
+ if len(args) != 2:
245
+ raise TypeError("Key and value types for dict must be specified")
246
+ key_type, value_type = args
247
+ return {
248
+ self.convert_type(k, key_type): self.convert_type(v, value_type)
249
+ for k, v in value.items()
250
+ }
251
+
252
+ # ---- Literal["a", "b", ...] ----
253
+ if origin is Literal:
254
+ literal_values = args
255
+ if value in literal_values:
135
256
  return value
136
- elif isinstance(value, str) and value.lower() == "false" and annotation == bool:
137
- return False
257
+ raise TypeError(
258
+ f"Value '{value}' is not one of the allowed Literal values {literal_values}"
259
+ )
260
+
261
+ # Unknown generic
262
+ raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
263
+
264
+ def _convert_simple(self, value: Any, annotation: type) -> Any:
265
+ """
266
+ Handle non-generic types: bool, dataclasses, custom classes,
267
+ and bare list subclasses like ListValidation.
268
+ """
269
+ # Already correct type
270
+ if isinstance(value, annotation):
271
+ return value
272
+
273
+ # Support bare list-like subclasses (e.g. annotation is ListValidation without [T])
274
+ try:
275
+ is_list_subclass = isinstance(annotation, type) and issubclass(annotation, list)
276
+ except TypeError:
277
+ is_list_subclass = False
278
+
279
+ if is_list_subclass and not isinstance(value, (str, bytes)) and isinstance(value, Iterable):
138
280
  try:
139
281
  return annotation(value)
140
- except Exception:
141
- if hasattr(annotation, "fromisoformat"):
142
- try:
143
- return annotation.fromisoformat(value)
144
- except Exception:
145
- raise TypeError(
146
- "Value '%s' cannot be converted from isoformat to type %s" % (value, annotation))
147
- else:
148
- raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
282
+ except Exception as e:
283
+ raise TypeError(
284
+ f"Value '{value}' cannot be converted to list-like type {annotation}"
285
+ ) from e
286
+
287
+ # Special case: string "false" -> False for bool
288
+ if isinstance(value, str) and value.lower() == "false" and annotation is bool:
289
+ return False
290
+
291
+ # Normal constructor-based conversion
292
+ try:
293
+ return annotation(value)
294
+ except Exception:
295
+ # Try fromisoformat if available (e.g., datetime, date)
296
+ if hasattr(annotation, "fromisoformat"):
297
+ try:
298
+ return annotation.fromisoformat(value)
299
+ except Exception:
300
+ raise TypeError(
301
+ "Value '%s' cannot be converted from isoformat to type %s"
302
+ % (value, annotation)
303
+ )
304
+ raise TypeError(f"Value '{value}' cannot be converted to type {annotation}")
149
305
 
150
306
  @abstractmethod
151
307
  def handle_directives(self, directives: list[ResponseDirective]) -> ...:
@@ -1,5 +1,6 @@
1
1
  from inspect import signature
2
- from typing import Any, Type
2
+ from types import GenericAlias
3
+ from typing import Any, Type, get_origin
3
4
 
4
5
 
5
6
  class Injection:
@@ -32,7 +33,10 @@ class Injection:
32
33
  # Create the callable on demand when iterating
33
34
  for cls in self.classes:
34
35
  # Get the signature of the __init__ method of the class
35
- sig = signature(cls)
36
+ if isinstance(cls, GenericAlias):
37
+ sig = signature(get_origin(cls))
38
+ else:
39
+ sig = signature(cls)
36
40
  # Extract parameter names from the signature
37
41
  param_names: list[str] = [param.name for param in sig.parameters.values() if param.name != 'self']
38
42
 
@@ -1,6 +1,6 @@
1
1
  from datetime import date, datetime, time
2
2
  from re import Pattern
3
- from typing import Self
3
+ from typing import Self, Iterable, Any
4
4
 
5
5
 
6
6
  class StringValidation(str):
@@ -222,3 +222,44 @@ class DateTimeValidation(datetime):
222
222
  raise ValueError(f"Datetime '{instance}' is later than maximum allowed '{maximum_datetime}'")
223
223
 
224
224
  return instance
225
+
226
+
227
+ class ListValidation(list):
228
+ """
229
+ A list type that performs validation on initialization.
230
+
231
+ This class validates the list against optional constraints:
232
+ - Minimum allowed length.
233
+ - Maximum allowed length.
234
+
235
+ Args:
236
+ iterable (Iterable, optional): Values to initialize the list with.
237
+ minimum_length (int, optional): The smallest allowed list length.
238
+ maximum_length (int, optional): The largest allowed list length.
239
+
240
+ Raises:
241
+ ValueError: If the list length is outside the allowed bounds.
242
+
243
+ Example:
244
+ >>> ListValidation([1, 2, 3], minimum_length=2)
245
+ [1, 2, 3]
246
+ >>> ListValidation([1, 2, 3], maximum_length=2)
247
+ ValueError: List length '3' is greater than maximum allowed '2'
248
+ """
249
+
250
+ def __init__(self, iterable: Iterable[Any] = (), /, *, minimum_length: int | None = None,
251
+ maximum_length: int | None = None):
252
+
253
+ super().__init__(iterable)
254
+
255
+ length = len(self)
256
+
257
+ if minimum_length is not None and length < minimum_length:
258
+ raise ValueError(
259
+ f"List length '{length}' is smaller than minimum allowed '{minimum_length}'"
260
+ )
261
+
262
+ if maximum_length is not None and length > maximum_length:
263
+ raise ValueError(
264
+ f"List length '{length}' is greater than maximum allowed '{maximum_length}'"
265
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edri
3
- Version: 2025.11.1rc2
3
+ Version: 2025.11.1rc3
4
4
  Summary: Event Driven Routing Infrastructure
5
5
  Author: Marek Olšan
6
6
  Author-email: marek.olsan@gmail.com
@@ -13,14 +13,14 @@ edri/api/broker.py,sha256=6O-B2Io7WF9KPXJCXsCzcyxpwjieIx5vqR06cSEi1oI,37276
13
13
  edri/api/listener.py,sha256=QaBlrlWH8j5OsD0mFMeTd95eP2nq7J-B6FCPL9OtFac,20289
14
14
  edri/api/middleware.py,sha256=6_x55swthVDczT-fu_1ufY1cDsHTZ04jMx6J6xfjbsM,5483
15
15
  edri/api/dataclass/__init__.py,sha256=8Y-zcaJtzMdALnNG7M9jsCaB1qAJKM8Ld3h9MDajYjA,292
16
- edri/api/dataclass/api_event.py,sha256=NzS-BCw9DYy3QGlLJVDmL7RyJ0yH8stKcV_3io6sa3w,6085
16
+ edri/api/dataclass/api_event.py,sha256=vAP5JOqlrg3XBZtKd_a721dPSKa2N1NlMtebD_kpP2Q,6589
17
17
  edri/api/dataclass/client.py,sha256=ctc2G4mXJR2wUSujANudT3LqxW7qxk_YkpM_TEXD0tM,216
18
18
  edri/api/dataclass/file.py,sha256=OJfJlrCTjSnzCF8yFVnxr8rGeL0l08WVMsXJx00S4qc,225
19
19
  edri/api/extensions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  edri/api/extensions/url_extension.py,sha256=rZKumjR7J6pDTiSLIZf8IzxGgDZP7p2g0Kgs0USug_U,1971
21
21
  edri/api/extensions/url_prefix.py,sha256=kNI6g5ZlW0w-J_IMacYLco1EQvmTtMJyEkN6-SK1wC0,491
22
22
  edri/api/handlers/__init__.py,sha256=MI6OGDf1rM8jf_uCKK_JYeOGMts62CNy10BwwNlG0Tk,200
23
- edri/api/handlers/base_handler.py,sha256=iuVXOmWeCB1ZgLIpq_1QCtAvB7QL1NhPdx064NC2b50,7529
23
+ edri/api/handlers/base_handler.py,sha256=wENi5nfXaz2heyypzN8ikbN0ucPGxyRMcnxCpD9_o9Q,13151
24
24
  edri/api/handlers/html_handler.py,sha256=OprcTg1IQDI7eBK-_oHqA60P1H30LA9xIQpD7iV-Neg,7464
25
25
  edri/api/handlers/http_handler.py,sha256=Il16LpIMjSreUXohn4c2R79Bx9mKqz7a08LfTVvPwws,35567
26
26
  edri/api/handlers/rest_handler.py,sha256=GAG5lVTsRMCf9IUmYb_pokxyPcOfbnKZ2p3jxfy_-Dw,3300
@@ -36,7 +36,7 @@ edri/config/setting.py,sha256=-6_BDFM-RdxIenYhE1Ykm8uGL73YhYUYbdz5YmI08xo,1833
36
36
  edri/dataclass/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
37
  edri/dataclass/event.py,sha256=3XwbS_8Nst0V5D6vQ0FYhrX5rx6KfLGd3-9ba71xUMQ,9866
38
38
  edri/dataclass/health_checker.py,sha256=62H5wGUtOhql3acPwFtMhpGKPUTmFwWQ4hlqIn6tjfo,1784
39
- edri/dataclass/injection.py,sha256=3bZIGQLKzsO7zv2pD1Wq0Mc4NfwsZ--wHBcHZxfcIPU,3394
39
+ edri/dataclass/injection.py,sha256=C-VzC64FNhMvnLxkGBhgc-Kma2vPIvnRUyPEpyIE-h8,3554
40
40
  edri/dataclass/response.py,sha256=VBMmVdna1IOKC5YGBXor6AayYOoiEYb9xx_RZ3bpKnw,3867
41
41
  edri/dataclass/directive/__init__.py,sha256=nfvsh1BmxhACW7Q8gnwy7y3l3_cI1P0k2WP0jV5RJhI,608
42
42
  edri/dataclass/directive/base.py,sha256=2ghQpv1bGcNHYEMA0nyWGumIplXBzj9cPQ34aJ7uVr0,296
@@ -112,7 +112,7 @@ edri/utility/queue.py,sha256=xBbeu1DT3Krdxni0YABk7gDZ5fLQL9eX-H3U-1jSqag,3628
112
112
  edri/utility/shared_memory_pipe.py,sha256=kmtd-1999s-cUVThxXVtw4N-rp_WgrHtl-h4hhEliXA,6396
113
113
  edri/utility/storage.py,sha256=AbZwtj8py0OBy3dM5C0fJ97uV88TERZO79heEmyE9Yk,3781
114
114
  edri/utility/transformation.py,sha256=4FeRNav-ifxuqgwq9ys3G5WtMzUAC3_2B3tnFhMENho,1450
115
- edri/utility/validation.py,sha256=bLFgfBLc_nu8tK3-imS2pLdd_IOh-uR7CHjV_r6QodY,8225
115
+ edri/utility/validation.py,sha256=V4EYvuDRsuVgfQTUSF-109O7tDtnNA78tsT231oeST4,9624
116
116
  edri/utility/watcher.py,sha256=9nwU-h6B_QCd02-z-2-Hvf6huro8B9yVcZAepoFtXQ4,4623
117
117
  edri/utility/manager/__init__.py,sha256=bNyqET60wyq-QFmNwk52UKRweK5lYTDH_TF2UgS6enk,73
118
118
  edri/utility/manager/scheduler.py,sha256=3wRPph-FGNrVMN3TG7SvZ_PDW8mNK7UdM3PnjI_QTH8,11624
@@ -156,7 +156,7 @@ tests/utility/test_validation.py,sha256=OEM9hGzlFoSvZdwf5_pyH-Xg9ISxw0QJFbgMaDP8
156
156
  tests/utility/manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
157
157
  tests/utility/manager/test_scheduler.py,sha256=sROffYvSOaWsYQxQGTy6l9Mn_qeNPRmJoXLVPKU3XNY,9153
158
158
  tests/utility/manager/test_store.py,sha256=xlo1JUsPLIhPJyQn7AXldAgWDo_O8ba2ns25TEaaGdQ,2821
159
- edri-2025.11.1rc2.dist-info/METADATA,sha256=Znv_9pH-YVrYGlhn9lN31KKpT-fSrrOkMj2sg4La034,8346
160
- edri-2025.11.1rc2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
161
- edri-2025.11.1rc2.dist-info/top_level.txt,sha256=himES6JgPlx4Zt8aDrQEj2fxAd7IDD6MBOsiNZkzKHQ,11
162
- edri-2025.11.1rc2.dist-info/RECORD,,
159
+ edri-2025.11.1rc3.dist-info/METADATA,sha256=d0LFmSlm3cYRVlw79-cj59_Pa33mweHzTS7Ox063e4Y,8346
160
+ edri-2025.11.1rc3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
161
+ edri-2025.11.1rc3.dist-info/top_level.txt,sha256=himES6JgPlx4Zt8aDrQEj2fxAd7IDD6MBOsiNZkzKHQ,11
162
+ edri-2025.11.1rc3.dist-info/RECORD,,