linkml 1.8.2__py3-none-any.whl → 1.8.4__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 (50) hide show
  1. linkml/cli/main.py +4 -0
  2. linkml/generators/__init__.py +2 -0
  3. linkml/generators/common/ifabsent_processor.py +286 -0
  4. linkml/generators/docgen/index.md.jinja2 +6 -6
  5. linkml/generators/docgen.py +64 -14
  6. linkml/generators/golanggen.py +3 -1
  7. linkml/generators/jsonldcontextgen.py +0 -1
  8. linkml/generators/jsonschemagen.py +4 -2
  9. linkml/generators/owlgen.py +36 -17
  10. linkml/generators/projectgen.py +13 -11
  11. linkml/generators/pydanticgen/array.py +340 -56
  12. linkml/generators/pydanticgen/build.py +4 -2
  13. linkml/generators/pydanticgen/pydanticgen.py +46 -24
  14. linkml/generators/pydanticgen/template.py +108 -3
  15. linkml/generators/pydanticgen/templates/imports.py.jinja +11 -3
  16. linkml/generators/pydanticgen/templates/module.py.jinja +1 -3
  17. linkml/generators/pydanticgen/templates/validator.py.jinja +2 -2
  18. linkml/generators/python/__init__.py +1 -0
  19. linkml/generators/python/python_ifabsent_processor.py +92 -0
  20. linkml/generators/pythongen.py +19 -31
  21. linkml/generators/shacl/__init__.py +1 -3
  22. linkml/generators/shacl/shacl_data_type.py +1 -1
  23. linkml/generators/shacl/shacl_ifabsent_processor.py +89 -0
  24. linkml/generators/shaclgen.py +39 -13
  25. linkml/generators/sparqlgen.py +3 -1
  26. linkml/generators/sqlalchemygen.py +5 -3
  27. linkml/generators/sqltablegen.py +4 -2
  28. linkml/generators/typescriptgen.py +13 -6
  29. linkml/linter/linter.py +2 -1
  30. linkml/transformers/logical_model_transformer.py +3 -3
  31. linkml/transformers/relmodel_transformer.py +18 -4
  32. linkml/utils/converter.py +3 -1
  33. linkml/utils/exceptions.py +11 -0
  34. linkml/utils/execute_tutorial.py +22 -20
  35. linkml/utils/generator.py +6 -4
  36. linkml/utils/mergeutils.py +4 -2
  37. linkml/utils/schema_fixer.py +5 -5
  38. linkml/utils/schemaloader.py +5 -3
  39. linkml/utils/sqlutils.py +3 -1
  40. linkml/validator/plugins/pydantic_validation_plugin.py +1 -1
  41. linkml/validators/jsonschemavalidator.py +3 -1
  42. linkml/validators/sparqlvalidator.py +5 -3
  43. linkml/workspaces/example_runner.py +3 -1
  44. {linkml-1.8.2.dist-info → linkml-1.8.4.dist-info}/METADATA +3 -1
  45. {linkml-1.8.2.dist-info → linkml-1.8.4.dist-info}/RECORD +48 -45
  46. linkml/generators/shacl/ifabsent_processor.py +0 -59
  47. linkml/utils/ifabsent_functions.py +0 -138
  48. {linkml-1.8.2.dist-info → linkml-1.8.4.dist-info}/LICENSE +0 -0
  49. {linkml-1.8.2.dist-info → linkml-1.8.4.dist-info}/WHEEL +0 -0
  50. {linkml-1.8.2.dist-info → linkml-1.8.4.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  import sys
2
2
  from abc import ABC, abstractmethod
3
3
  from enum import Enum
4
- from typing import TYPE_CHECKING, Any, ClassVar, Generic, Iterable, Optional, Type, TypeVar, Union, get_args
4
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Iterable, List, Optional, Type, TypeVar, Union, get_args
5
5
 
6
6
  from linkml_runtime.linkml_model import Element
7
7
  from linkml_runtime.linkml_model.meta import ArrayExpression, DimensionExpression
@@ -26,11 +26,12 @@ else:
26
26
 
27
27
  from linkml.generators.pydanticgen.build import RangeResult
28
28
  from linkml.generators.pydanticgen.template import ConditionalImport, Import, Imports, ObjectImport
29
+ from linkml.utils.exceptions import ValidationError
29
30
 
30
31
 
31
32
  class ArrayRepresentation(Enum):
32
33
  LIST = "list"
33
- NPARRAY = "nparray" # numpy and nptyping must be installed to use this
34
+ NUMPYDANTIC = "numpydantic" # numpydantic must be installed to use this
34
35
 
35
36
 
36
37
  _BOUNDED_ARRAY_FIELDS = ("exact_number_dimensions", "minimum_number_dimensions", "maximum_number_dimensions")
@@ -44,17 +45,18 @@ class AnyShapeArrayType(Generic[_T]):
44
45
  def __get_pydantic_core_schema__(cls, source_type: Any, handler: "GetCoreSchemaHandler") -> "CoreSchema":
45
46
  # double-nested parameterized types here
46
47
  # source_type: List[Union[T,List[...]]]
47
- item_type = Any if get_args(get_args(source_type)[0])[0] is _T else get_args(get_args(source_type)[0])[0]
48
+ item_type = (Any,) if get_args(get_args(source_type)[0])[0] is _T else get_args(get_args(source_type)[0])[:-1]
48
49
 
49
- item_schema = handler.generate_schema(item_type)
50
- if item_schema.get("type", "any") != "any":
50
+ if len(item_type) == 1:
51
+ item_schema = handler.generate_schema(item_type[0])
52
+ else:
53
+ item_schema = core_schema.union_schema([handler.generate_schema(i) for i in item_type])
54
+
55
+ if all([getattr(i, "__module__", "") == "builtins" and i is not Any for i in item_type]):
51
56
  item_schema["strict"] = True
52
57
 
53
- if item_type is Any:
54
- # Before python 3.11, `Any` type was a special object without a __name__
55
- item_name = "Any"
56
- else:
57
- item_name = item_type.__name__
58
+ # Before python 3.11, `Any` type was a special object without a __name__
59
+ item_name = "_".join(["Any" if i is Any else i.__name__ for i in item_type])
58
60
 
59
61
  array_ref = f"any-shape-array-{item_name}"
60
62
 
@@ -109,6 +111,124 @@ _AnyShapeArrayInjects = [
109
111
  _ConListImports = Imports() + Import(module="pydantic", objects=[ObjectImport(name="conlist")])
110
112
 
111
113
 
114
+ class ArrayValidator:
115
+ """
116
+ Validate the specification of a LinkML Array
117
+
118
+ .. todo::
119
+
120
+ It looks like :mod:`linkml.validator` is for validating instances against schema, rather
121
+ than validating the schema itself, so am not subclassing/writing as a plugin.
122
+ Unsure if there is a more general means of validating schema, but for now this is
123
+ an independent class
124
+ """
125
+
126
+ @classmethod
127
+ def validate(cls, array: ArrayExpression):
128
+ """
129
+ Validate an array expression.
130
+
131
+ Raises:
132
+ :class:`.ValidationError` if invalid
133
+ """
134
+ cls.array_exact_dimensions(array)
135
+ cls.array_consistent_n_dimensions(array)
136
+ cls.array_explicitly_unbounded(array)
137
+ cls.array_dimensions_ordinal(array)
138
+
139
+ if array.dimensions:
140
+ for dimension in array.dimensions:
141
+ cls.validate_dimension(dimension)
142
+
143
+ @classmethod
144
+ def validate_dimension(cls, dimension: DimensionExpression):
145
+ """
146
+ Validate a single array dimension
147
+
148
+ Raises:
149
+ :class:`.ValidationError` if invalid
150
+ """
151
+ cls.dimension_exact_cardinality(dimension)
152
+ cls.dimension_ordinal(dimension)
153
+
154
+ @staticmethod
155
+ def array_exact_dimensions(array: ArrayExpression):
156
+ """Arrays can have exact_number_dimensions OR min/max_number_dimensions, but not both"""
157
+ if array.exact_number_dimensions is not None and (
158
+ array.minimum_number_dimensions is not None or array.maximum_number_dimensions is not None
159
+ ):
160
+ raise ValidationError(
161
+ f"Can only specify EITHER exact_number_dimensions OR minimum/maximum dimensions, got: {array}"
162
+ )
163
+
164
+ @staticmethod
165
+ def array_consistent_n_dimensions(array: ArrayExpression):
166
+ """
167
+ Complex arrays with both exact/min/max_number_dimensions and parameterized dimensions
168
+ need to have the exact/min/max_number_dimensions greater than the number of parameterized dimensions!
169
+ """
170
+ if not array.dimensions:
171
+ return
172
+
173
+ for field_name in _BOUNDED_ARRAY_FIELDS:
174
+ field = getattr(array, field_name, None)
175
+ if field and field < len(array.dimensions):
176
+ raise ValidationError(
177
+ "if exact/minimum/maximum_number_dimensions is provided, "
178
+ "it must be greater than the parameterized dimensions. "
179
+ f"got\n- {field_name}: {field}\n- dimensions: {array.dimensions}"
180
+ )
181
+
182
+ @staticmethod
183
+ def array_dimensions_ordinal(array: ArrayExpression):
184
+ """
185
+ minimum_number_dimensions needs to be less than maximum_number_dimensions when both are set
186
+ """
187
+ if array.minimum_number_dimensions is not None and array.maximum_number_dimensions:
188
+ if array.minimum_number_dimensions > array.maximum_number_dimensions:
189
+ raise ValidationError(
190
+ "minimum_number_dimensions must be lesser than maximum_number_dimensions when both are set. "
191
+ f"got minimum: {array.minimum_number_dimensions}, maximum: {array.maximum_number_dimensions}"
192
+ )
193
+
194
+ @staticmethod
195
+ def array_explicitly_unbounded(array: ArrayExpression):
196
+ """
197
+ Complex arrays with a minimum_number_dimensions and parameterized dimensions
198
+ need to either use exact_number_dimensions to specify extra anonymous dimensions
199
+ or set maximum_number_dimensions to ``False`` to specify unbounded extra anonymous
200
+ dimensions to avoid ambiguity.
201
+ """
202
+ if array.minimum_number_dimensions is not None and array.maximum_number_dimensions is None and array.dimensions:
203
+ raise ValidationError(
204
+ (
205
+ "Cannot specify a minimum_number_dimensions while maximum is None while using labeled dimensions - "
206
+ "either use exact_number_dimensions > len(dimensions) for extra parameterized dimensions or set "
207
+ "maximum_number_dimensions explicitly to False for unbounded dimensions"
208
+ )
209
+ )
210
+
211
+ @staticmethod
212
+ def dimension_exact_cardinality(dimension: DimensionExpression):
213
+ """Dimensions can only have exact_cardinality OR min/max_cardinality, but not both"""
214
+ if dimension.exact_cardinality is not None and (
215
+ dimension.minimum_cardinality is not None or dimension.maximum_cardinality is not None
216
+ ):
217
+ raise ValidationError(
218
+ f"Can only specify EITHER exact_cardinality OR minimum/maximum cardinality, got: {dimension}"
219
+ )
220
+
221
+ @staticmethod
222
+ def dimension_ordinal(dimension: DimensionExpression):
223
+ """minimum_cardinality must be less than maximum_cardinality when both are set"""
224
+ if dimension.minimum_cardinality is not None and dimension.maximum_cardinality is not None:
225
+ if dimension.minimum_cardinality > dimension.maximum_cardinality:
226
+ raise ValidationError(
227
+ "minimum_cardinality must be lesser than maximum_cardinality when both are set. "
228
+ f"got minimum: {dimension.minimum_cardinality}, maximum: {dimension.maximum_cardinality}"
229
+ )
230
+
231
+
112
232
  class ArrayRangeGenerator(ABC):
113
233
  """
114
234
  Metaclass for generating a given format of array range.
@@ -142,16 +262,33 @@ class ArrayRangeGenerator(ABC):
142
262
  self.dtype = dtype
143
263
 
144
264
  def make(self) -> RangeResult:
145
- """Create the string form of the array representation"""
265
+ """
266
+ Create the string form of the array representation, validating first
267
+ """
268
+ self.validate()
269
+
146
270
  if not self.array.dimensions and not self.has_bounded_dimensions:
147
- # any-shaped array
148
- return self.any_shape(self.array)
271
+ return self._any_shape(self.array)
149
272
  elif not self.array.dimensions and self.has_bounded_dimensions:
150
- return self.bounded_dimensions(self.array)
273
+ return self._bounded_dimensions(self.array)
151
274
  elif self.array.dimensions and not self.has_bounded_dimensions:
152
- return self.parameterized_dimensions(self.array)
275
+ return self._parameterized_dimensions(self.array)
153
276
  else:
154
- return self.complex_dimensions(self.array)
277
+ return self._complex_dimensions(self.array)
278
+
279
+ def validate(self):
280
+ """
281
+ Ensure that the given ArrayExpression is valid using :class:`.ArrayValidator`
282
+
283
+ .. todo::
284
+
285
+ Integrate with more general schema validation that happens when a schema is loaded,
286
+ rather than when an array is generated
287
+
288
+ Raises:
289
+ :class:`.ValidationError` if the schema is invalid
290
+ """
291
+ ArrayValidator.validate(self.array)
155
292
 
156
293
  @property
157
294
  def has_bounded_dimensions(self) -> bool:
@@ -167,22 +304,22 @@ class ArrayRangeGenerator(ABC):
167
304
  raise ValueError(f"Generator for array representation {repr} not found!")
168
305
 
169
306
  @abstractmethod
170
- def any_shape(self, array: Optional[ArrayRepresentation] = None) -> RangeResult:
307
+ def _any_shape(self, array: Optional[ArrayRepresentation] = None) -> RangeResult:
171
308
  """Any shaped array!"""
172
309
  pass
173
310
 
174
311
  @abstractmethod
175
- def bounded_dimensions(self, array: ArrayExpression) -> RangeResult:
312
+ def _bounded_dimensions(self, array: ArrayExpression) -> RangeResult:
176
313
  """Array shape specified numerically, without axis parameterization"""
177
314
  pass
178
315
 
179
316
  @abstractmethod
180
- def parameterized_dimensions(self, array: ArrayExpression) -> RangeResult:
317
+ def _parameterized_dimensions(self, array: ArrayExpression) -> RangeResult:
181
318
  """Array shape specified with ``dimensions`` without additional parameterized dimensions"""
182
319
  pass
183
320
 
184
321
  @abstractmethod
185
- def complex_dimensions(self, array: ArrayExpression) -> RangeResult:
322
+ def _complex_dimensions(self, array: ArrayExpression) -> RangeResult:
186
323
  """Array shape with both ``parameterized`` and ``bounded`` dimensions"""
187
324
  pass
188
325
 
@@ -190,9 +327,6 @@ class ArrayRangeGenerator(ABC):
190
327
  class ListOfListsArray(ArrayRangeGenerator):
191
328
  """
192
329
  Represent arrays as lists of lists!
193
-
194
- TODO: Move all validation of values (eg. anywhere we raise a ValueError) to the ArrayExpression
195
- dataclass and out of the generator class
196
330
  """
197
331
 
198
332
  REPR = ArrayRepresentation.LIST
@@ -204,9 +338,7 @@ class ListOfListsArray(ArrayRangeGenerator):
204
338
  @staticmethod
205
339
  def _parameterized_dimension(dimension: DimensionExpression, dtype: str) -> RangeResult:
206
340
  # TODO: Preserve label representation in some readable way! doing the MVP now of using conlist
207
- if dimension.exact_cardinality and (dimension.minimum_cardinality or dimension.maximum_cardinality):
208
- raise ValueError("Can only specify EITHER exact_cardinality OR minimum/maximum cardinality")
209
- elif dimension.exact_cardinality:
341
+ if dimension.exact_cardinality:
210
342
  dmin = dimension.exact_cardinality
211
343
  dmax = dimension.exact_cardinality
212
344
  elif dimension.minimum_cardinality or dimension.maximum_cardinality:
@@ -228,7 +360,7 @@ class ListOfListsArray(ArrayRangeGenerator):
228
360
 
229
361
  return RangeResult(range=range, imports=_ConListImports)
230
362
 
231
- def any_shape(self, array: Optional[ArrayExpression] = None, with_inner_union: bool = False) -> RangeResult:
363
+ def _any_shape(self, array: Optional[ArrayExpression] = None, with_inner_union: bool = False) -> RangeResult:
232
364
  """
233
365
  An AnyShaped array (using :class:`.AnyShapeArray` )
234
366
 
@@ -247,7 +379,7 @@ class ListOfListsArray(ArrayRangeGenerator):
247
379
  range = f"Union[{range}, {self.dtype}]"
248
380
  return RangeResult(range=range, injected_classes=_AnyShapeArrayInjects, imports=_AnyShapeArrayImports)
249
381
 
250
- def bounded_dimensions(self, array: ArrayExpression) -> RangeResult:
382
+ def _bounded_dimensions(self, array: ArrayExpression) -> RangeResult:
251
383
  """
252
384
  A nested series of ``List[]`` ranges with :attr:`.dtype` at the center.
253
385
 
@@ -263,23 +395,22 @@ class ListOfListsArray(ArrayRangeGenerator):
263
395
  elif not array.maximum_number_dimensions and (
264
396
  array.minimum_number_dimensions is None or array.minimum_number_dimensions == 1
265
397
  ):
266
- return self.any_shape()
398
+ return self._any_shape()
267
399
  elif array.maximum_number_dimensions:
268
400
  # e.g., if min = 2, max = 3, range = Union[List[List[dtype]], List[List[List[dtype]]]]
269
401
  min_dims = array.minimum_number_dimensions if array.minimum_number_dimensions is not None else 1
270
402
  ranges = [self._list_of_lists(i, self.dtype) for i in range(min_dims, array.maximum_number_dimensions + 1)]
271
- # TODO: Format this nicely!
272
403
  return RangeResult(range="Union[" + ", ".join(ranges) + "]")
273
404
  else:
274
405
  # min specified with no max
275
406
  # e.g., if min = 3, range = List[List[AnyShapeArray[dtype]]]
276
407
  return RangeResult(
277
- range=self._list_of_lists(array.minimum_number_dimensions - 1, self.any_shape().range),
408
+ range=self._list_of_lists(array.minimum_number_dimensions - 1, self._any_shape().range),
278
409
  injected_classes=_AnyShapeArrayInjects,
279
410
  imports=_AnyShapeArrayImports,
280
411
  )
281
412
 
282
- def parameterized_dimensions(self, array: ArrayExpression) -> RangeResult:
413
+ def _parameterized_dimensions(self, array: ArrayExpression) -> RangeResult:
283
414
  """
284
415
  Constrained shapes using :func:`pydantic.conlist`
285
416
 
@@ -296,12 +427,13 @@ class ListOfListsArray(ArrayRangeGenerator):
296
427
 
297
428
  return RangeResult(range=range, imports=_ConListImports)
298
429
 
299
- def complex_dimensions(self, array: ArrayExpression) -> RangeResult:
430
+ def _complex_dimensions(self, array: ArrayExpression) -> RangeResult:
300
431
  """
301
432
  Mixture of parameterized dimensions with a max or min (or both) shape for anonymous dimensions.
302
433
 
303
434
  A mixture of ``List`` , :class:`.conlist` , and :class:`.AnyShapeArray` .
304
435
  """
436
+ res = None
305
437
  # first process any unlabeled dimensions which must be the innermost level of the range,
306
438
  # then wrap that with labeled dimensions
307
439
  if array.exact_number_dimensions or (
@@ -314,39 +446,29 @@ class ListOfListsArray(ArrayRangeGenerator):
314
446
  res = RangeResult(range=self._list_of_lists(exact_dims - len(array.dimensions), self.dtype))
315
447
  elif exact_dims == len(array.dimensions):
316
448
  # equivalent to labeled shape
317
- return self.parameterized_dimensions(array)
318
- else:
319
- raise ValueError(
320
- "if exact_number_dimensions is provided, it must be greater than the parameterized dimensions"
321
- )
449
+ return self._parameterized_dimensions(array)
450
+ # else is invalid, see: ArrayValidator.array_consistent_n_dimensions
322
451
 
323
452
  elif array.maximum_number_dimensions is not None and not array.maximum_number_dimensions:
324
453
  # unlimited n dimensions, so innermost is AnyShape with dtype
325
- res = self.any_shape(with_inner_union=True)
454
+ res = self._any_shape(with_inner_union=True)
326
455
 
327
- if array.minimum_number_dimensions and array.minimum_number_dimensions > len(array.dimensions):
456
+ if array.minimum_number_dimensions:
328
457
  # some minimum anonymous dimensions but unlimited max dimensions
329
458
  # e.g., if min = 3, len(dim) = 2, then res.range = List[Union[AnyShapeArray[dtype], dtype]]
330
459
  # res.range will be wrapped with the 2 labeled dimensions later
331
460
  res.range = self._list_of_lists(array.minimum_number_dimensions - len(array.dimensions), res.range)
332
461
 
333
- elif array.minimum_number_dimensions and array.maximum_number_dimensions is None:
334
- raise ValueError(
335
- (
336
- "Cannot specify a minimum_number_dimensions while maximum is None while using labeled dimensions - "
337
- "either use exact_number_dimensions > len(dimensions) for extra parameterized dimensions or set "
338
- "maximum_number_dimensions explicitly to False for unbounded dimensions"
339
- )
340
- )
341
462
  elif array.maximum_number_dimensions:
342
463
  initial_min = array.minimum_number_dimensions if array.minimum_number_dimensions is not None else 0
343
464
  dmin = max(len(array.dimensions), initial_min) - len(array.dimensions)
344
465
  dmax = array.maximum_number_dimensions - len(array.dimensions)
345
466
 
346
- res = self.bounded_dimensions(
467
+ res = self._bounded_dimensions(
347
468
  ArrayExpression(minimum_number_dimensions=dmin, maximum_number_dimensions=dmax)
348
469
  )
349
- else:
470
+
471
+ if res is None:
350
472
  raise ValueError("Unsupported array specification! this is almost certainly a bug!") # pragma: no cover
351
473
 
352
474
  # Wrap inner dimension with labeled dimension
@@ -366,13 +488,175 @@ class ListOfListsArray(ArrayRangeGenerator):
366
488
  return res
367
489
 
368
490
 
369
- class NPTypingArray(ArrayRangeGenerator):
491
+ class NumpydanticArray(ArrayRangeGenerator):
492
+ """
493
+ Represent array range with :class:`numpydantic.NDArray` annotations,
494
+ allowing an abstract array specification to be used with many different array
495
+ libraries.
496
+ """
497
+
498
+ REPR = ArrayRepresentation.NUMPYDANTIC
499
+ MIN_NUMPYDANTIC_VERSION = "1.6.1"
370
500
  """
371
- Represent array range with nptyping, and serialization/loading with an ArrayProxy
501
+ Minimum numpydantic version needed to be installed in the environment using
502
+ the generated models
372
503
  """
504
+ IMPORTS = Imports() + Import(
505
+ module="numpydantic", objects=[ObjectImport(name="NDArray"), ObjectImport(name="Shape")]
506
+ )
507
+ INJECTS = [f'MIN_NUMPYDANTIC_VERSION = "{MIN_NUMPYDANTIC_VERSION}"']
508
+
509
+ def make(self) -> RangeResult:
510
+ result = super().make()
511
+ result.imports = self.IMPORTS.model_copy()
512
+ result.injected_classes = self.INJECTS.copy()
513
+ return result
514
+
515
+ @staticmethod
516
+ def ndarray_annotation(shape: Optional[List[Union[int, str]]] = None, dtype: Optional[str] = None) -> str:
517
+ """
518
+ Make a stringified :class:`numpydantic.NDArray` annotation for a given shape
519
+ and dtype.
520
+
521
+ If either ``shape`` or ``dtype`` is ``None`` , use ``Any``
522
+ """
523
+ if shape is None:
524
+ shape = "Any"
525
+ else:
526
+ shape_expression = ", ".join([str(i) for i in shape])
527
+ shape = f'Shape["{shape_expression}"]'
528
+
529
+ if dtype is None or dtype in ("Any", "AnyType"):
530
+ dtype = "Any"
531
+
532
+ if shape == "Any" and dtype == "Any":
533
+ return "NDArray"
534
+ else:
535
+ return f"NDArray[{shape}, {dtype}]"
536
+
537
+ @staticmethod
538
+ def _dimension_shape(dimension: DimensionExpression) -> str:
539
+ if dimension.exact_cardinality:
540
+ shape = str(dimension.exact_cardinality)
541
+ elif dimension.minimum_cardinality and not dimension.maximum_cardinality:
542
+ shape = f"{dimension.minimum_cardinality}-*"
543
+ elif dimension.maximum_cardinality and not dimension.minimum_cardinality:
544
+ shape = f"*-{dimension.maximum_cardinality}"
545
+ elif dimension.minimum_cardinality and dimension.maximum_cardinality:
546
+ shape = f"{dimension.minimum_cardinality}-{dimension.maximum_cardinality}"
547
+ else:
548
+ shape = "*"
549
+
550
+ return shape
551
+
552
+ @classmethod
553
+ def _parameterized_dimension(cls, dimension: DimensionExpression) -> str:
554
+ shape = cls._dimension_shape(dimension)
555
+ if dimension.alias is not None:
556
+ return f"{shape} {dimension.alias}"
557
+ else:
558
+ return shape
559
+
560
+ def _any_shape(self, array: Optional[ArrayRepresentation] = None) -> RangeResult:
561
+ """
562
+ Any shaped array, either an unparameterized :class:`numpydantic.NDArray`
563
+ if dtype is :class:`typing.Any` , or like ``NDArray[Any, {self.dtype}]``
564
+ otherwise.
565
+ """
566
+ if self.dtype in ("Any", "AnyType"):
567
+ range = "NDArray"
568
+ else:
569
+ range = f"NDArray[Any, {self.dtype}]"
570
+
571
+ return RangeResult(range=range)
572
+
573
+ def _bounded_dimensions(self, array: ArrayExpression) -> RangeResult:
574
+ """
575
+ Number of dimensions specified without shape
576
+ """
577
+ if array.exact_number_dimensions or (
578
+ array.minimum_number_dimensions
579
+ and array.maximum_number_dimensions
580
+ and array.minimum_number_dimensions == array.maximum_number_dimensions
581
+ ):
582
+ exact_dims = array.exact_number_dimensions or array.minimum_number_dimensions
583
+
584
+ return RangeResult(range=self.ndarray_annotation(["*"] * exact_dims, self.dtype))
585
+ elif not array.maximum_number_dimensions and (
586
+ array.minimum_number_dimensions is None or array.minimum_number_dimensions == 1
587
+ ):
588
+ return self._any_shape()
589
+ elif array.maximum_number_dimensions:
590
+ # e.g., if min = 2, max = 3, range = Union[NDArray[Shape["*, *"], dtype], NDArray[Shape["*, *, *"], dtype]]
591
+ min_dims = array.minimum_number_dimensions if array.minimum_number_dimensions is not None else 1
592
+ ranges = [
593
+ self.ndarray_annotation(["*"] * i, self.dtype)
594
+ for i in range(min_dims, array.maximum_number_dimensions + 1)
595
+ ]
596
+ return RangeResult(range="Union[" + ", ".join(ranges) + "]")
597
+ else:
598
+ # min specified with no max
599
+ # e.g., if min = 3, range = NDArray[Shape[*, *, *, ...], dtype]
600
+ shape_inner = ["*"] * array.minimum_number_dimensions
601
+ shape_inner.append("...")
602
+ return RangeResult(range=self.ndarray_annotation(shape_inner, self.dtype))
603
+
604
+ def _parameterized_dimensions(self, array: ArrayExpression) -> RangeResult:
605
+ """
606
+ Arrays with constrained shapes or labels
607
+ """
608
+ dims = [self._parameterized_dimension(d) for d in array.dimensions]
609
+ range = self.ndarray_annotation(dims, self.dtype)
610
+ return RangeResult(range=range)
611
+
612
+ def _complex_dimensions(self, array: ArrayExpression) -> RangeResult:
613
+ """
614
+ Mixture of parameterized dimensions with a max or min (or both) shape for anonymous dimensions.
615
+ """
616
+ dims = [self._parameterized_dimension(d) for d in array.dimensions]
617
+ res = None
618
+
619
+ if array.exact_number_dimensions or (
620
+ array.minimum_number_dimensions
621
+ and array.maximum_number_dimensions
622
+ and array.minimum_number_dimensions == array.maximum_number_dimensions
623
+ ):
624
+ exact_dims = array.exact_number_dimensions or array.minimum_number_dimensions
625
+ if exact_dims > len(array.dimensions):
626
+ dims.extend(["*"] * (exact_dims - len(dims)))
627
+ res = self.ndarray_annotation(dims, self.dtype)
628
+ elif exact_dims == len(array.dimensions):
629
+ # equivalent to labeled shape
630
+ return self._parameterized_dimensions(array)
631
+ # else is invalid, see: ArrayValidator.array_consistent_n_dimensions(array)
632
+
633
+ elif array.maximum_number_dimensions is not None and not array.maximum_number_dimensions:
634
+ # unlimited n dimensions
635
+
636
+ if array.minimum_number_dimensions:
637
+ # some minimum anonymous dimensions but unlimited max dimensions
638
+ dims.extend(["*"] * (array.minimum_number_dimensions - len(dims)))
639
+
640
+ dims.append("...")
641
+ res = self.ndarray_annotation(dims, self.dtype)
642
+
643
+ elif array.maximum_number_dimensions:
644
+ # some res of anonymous dimensions
645
+
646
+ if array.minimum_number_dimensions:
647
+ min_dim = array.minimum_number_dimensions
648
+ else:
649
+ min_dim = len(dims)
650
+
651
+ dim_union = []
652
+ for i in range(min_dim, array.maximum_number_dimensions + 1):
653
+ this_dims = dims.copy()
654
+ this_dims.extend(["*"] * (i - len(dims)))
655
+ dim_union.append(self.ndarray_annotation(this_dims, self.dtype))
656
+ dim_union = ", ".join(dim_union)
657
+ res = f"Union[{dim_union}]"
373
658
 
374
- REPR = ArrayRepresentation.NPARRAY
659
+ if res is None:
660
+ raise ValueError(f"Unhandled range case! {array}")
375
661
 
376
- def __init__(self, **kwargs):
377
- super(self).__init__(**kwargs)
378
- raise NotImplementedError("NPTyping array ranges are not implemented yet :(")
662
+ return RangeResult(range=res)
@@ -59,8 +59,10 @@ class PydanticBuildResult(BuildResult):
59
59
  self_copy.imports = other.imports
60
60
  if other.injected_classes:
61
61
  if self_copy.injected_classes is not None:
62
- self_copy.injected_classes.extend(other.injected_classes)
63
- self_copy.injected_classes = list(dict.fromkeys(self_copy.injected_classes))
62
+ # only combine and dedupe when injected_classes don't match
63
+ if self_copy.injected_classes != other.injected_classes:
64
+ self_copy.injected_classes.extend(other.injected_classes)
65
+ self_copy.injected_classes = list(dict.fromkeys(self_copy.injected_classes))
64
66
  else:
65
67
  self_copy.injected_classes = other.injected_classes
66
68
  return self_copy