ratapi 0.0.0.dev16__cp315-cp315-win_amd64.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 (63) hide show
  1. ratapi/__init__.py +42 -0
  2. ratapi/classlist.py +620 -0
  3. ratapi/controls.py +258 -0
  4. ratapi/eventManager.dll +0 -0
  5. ratapi/events.py +88 -0
  6. ratapi/examples/__init__.py +25 -0
  7. ratapi/examples/absorption/__init__.py +1 -0
  8. ratapi/examples/absorption/absorption.py +172 -0
  9. ratapi/examples/absorption/volume_thiol_bilayer.py +144 -0
  10. ratapi/examples/convert_rascal_project/Model_IIb.py +90 -0
  11. ratapi/examples/convert_rascal_project/__init__.py +1 -0
  12. ratapi/examples/convert_rascal_project/convert_rascal.py +49 -0
  13. ratapi/examples/data/D2O_spin_down.dat +100 -0
  14. ratapi/examples/data/D2O_spin_up.dat +101 -0
  15. ratapi/examples/data/DSPC_D2O.dat +82 -0
  16. ratapi/examples/data/DSPC_SMW.dat +82 -0
  17. ratapi/examples/data/H2O_spin_down.dat +102 -0
  18. ratapi/examples/data/H2O_spin_up.dat +102 -0
  19. ratapi/examples/data/__init__.py +1 -0
  20. ratapi/examples/data/c_PLP0016596.dat +146 -0
  21. ratapi/examples/data/c_PLP0016601.dat +97 -0
  22. ratapi/examples/data/c_PLP0016607.dat +104 -0
  23. ratapi/examples/data/d2o_background_data.dat +82 -0
  24. ratapi/examples/domains/__init__.py +1 -0
  25. ratapi/examples/domains/alloy_domains.py +34 -0
  26. ratapi/examples/domains/domains_XY_model.py +75 -0
  27. ratapi/examples/domains/domains_custom_XY.py +86 -0
  28. ratapi/examples/domains/domains_custom_layers.py +60 -0
  29. ratapi/examples/domains/domains_standard_layers.py +92 -0
  30. ratapi/examples/languages/__init__.py +1 -0
  31. ratapi/examples/languages/custom_bilayer.py +72 -0
  32. ratapi/examples/languages/run_custom_file_languages.py +41 -0
  33. ratapi/examples/languages/setup_problem.py +130 -0
  34. ratapi/examples/normal_reflectivity/DSPC_custom_XY.py +149 -0
  35. ratapi/examples/normal_reflectivity/DSPC_custom_layers.py +130 -0
  36. ratapi/examples/normal_reflectivity/DSPC_data_background.py +220 -0
  37. ratapi/examples/normal_reflectivity/DSPC_function_background.py +219 -0
  38. ratapi/examples/normal_reflectivity/DSPC_standard_layers.py +210 -0
  39. ratapi/examples/normal_reflectivity/__init__.py +1 -0
  40. ratapi/examples/normal_reflectivity/background_function.py +16 -0
  41. ratapi/examples/normal_reflectivity/custom_XY_DSPC.py +141 -0
  42. ratapi/examples/normal_reflectivity/custom_bilayer_DSPC.py +89 -0
  43. ratapi/inputs.py +603 -0
  44. ratapi/models.py +717 -0
  45. ratapi/outputs.py +821 -0
  46. ratapi/project.py +1091 -0
  47. ratapi/rat_core.cp315-win_amd64.pyd +0 -0
  48. ratapi/run.py +142 -0
  49. ratapi/utils/__init__.py +1 -0
  50. ratapi/utils/convert.py +597 -0
  51. ratapi/utils/custom_errors.py +40 -0
  52. ratapi/utils/enums.py +203 -0
  53. ratapi/utils/matlab.py +254 -0
  54. ratapi/utils/orso.py +247 -0
  55. ratapi/utils/plotting.py +1316 -0
  56. ratapi/wrappers.py +147 -0
  57. ratapi-0.0.0.dev16.dist-info/DELVEWHEEL +2 -0
  58. ratapi-0.0.0.dev16.dist-info/METADATA +61 -0
  59. ratapi-0.0.0.dev16.dist-info/RECORD +63 -0
  60. ratapi-0.0.0.dev16.dist-info/WHEEL +5 -0
  61. ratapi-0.0.0.dev16.dist-info/top_level.txt +1 -0
  62. ratapi.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
  63. ratapi.libs/vcomp140-f96f3a14d88d8846f31f3ab38a490304.dll +0 -0
ratapi/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """ratapi is a Python package for modelling, fitting and optimising reflectivity problems."""
2
+
3
+
4
+ # start delvewheel patch
5
+ def _delvewheel_patch_1_13_0():
6
+ import os
7
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'ratapi.libs'))):
8
+ os.add_dll_directory(libs_dir)
9
+
10
+
11
+ _delvewheel_patch_1_13_0()
12
+ del _delvewheel_patch_1_13_0
13
+ # end delvewheel patch
14
+
15
+ from contextlib import suppress
16
+
17
+ import ratapi.examples as examples
18
+ from ratapi import events, models
19
+ from ratapi.classlist import ClassList
20
+ from ratapi.controls import Controls
21
+ from ratapi.outputs import BayesResults, Results
22
+ from ratapi.project import Project
23
+ from ratapi.run import run
24
+ from ratapi.utils import convert, matlab, plotting
25
+
26
+ with suppress(ImportError): # orsopy is an optional dependency
27
+ from ratapi.utils import orso as orso
28
+
29
+ __all__ = [
30
+ "examples",
31
+ "models",
32
+ "events",
33
+ "ClassList",
34
+ "Controls",
35
+ "BayesResults",
36
+ "Results",
37
+ "Project",
38
+ "run",
39
+ "plotting",
40
+ "convert",
41
+ "matlab",
42
+ ]
ratapi/classlist.py ADDED
@@ -0,0 +1,620 @@
1
+ """The ClassList class, which defines a list containing instances of a particular class."""
2
+
3
+ import collections
4
+ import contextlib
5
+ import importlib
6
+ import warnings
7
+ from collections.abc import Sequence
8
+ from typing import Any, Generic, TypeVar
9
+
10
+ import numpy as np
11
+ import prettytable
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ class ClassList(collections.UserList, Generic[T]):
17
+ """List of instances of a particular class.
18
+
19
+ This class subclasses collections.UserList to construct a list intended to store ONLY instances of a particular
20
+ class, given on initialisation. Any attempt to introduce an object of a different type will raise a ValueError.
21
+ The class must be able to accept attribute values using keyword arguments. In addition, if the class has the
22
+ attribute given in the ClassList's "name_field" attribute (the default is "name"), the ClassList will ensure that
23
+ all objects within the ClassList have unique values for that attribute. It is then possible to use this attribute
24
+ of an object in the .remove(), .count(), and .index() routines in place of the full object. Due to the requirement
25
+ of unique values of the ``name_field`` attribute, the multiplication operators __mul__, __rmul__, and __imul__ have
26
+ been disabled, since they cannot allow for unique attribute values by definition.
27
+
28
+ We extend the UserList class to enable objects to be added and modified using just the keyword arguments, enable
29
+ the object ``name_field`` attribute to be used in place of the full object, and ensure all elements are of the
30
+ specified type, with unique ``name_field`` attributes defined.
31
+
32
+ Parameters
33
+ ----------
34
+ init_list : Sequence [T] or T, optional
35
+ An instance, or list of instance(s), of the class to be used in this ClassList.
36
+ name_field : str, optional
37
+ The field used to define unique objects in the ClassList (default is "name").
38
+
39
+ """
40
+
41
+ def __init__(self, init_list: Sequence[T] | T = None, name_field: str = "name") -> None:
42
+ self.name_field = name_field
43
+
44
+ # Set input as list if necessary
45
+ if init_list and not (isinstance(init_list, Sequence) and not isinstance(init_list, str)):
46
+ init_list = [init_list]
47
+
48
+ # Set class to be used for this instance of the ClassList, checking that all elements of the input list are of
49
+ # the same type and have unique values of the specified name_field
50
+ if init_list:
51
+ self._class_handle = self._determine_class_handle(init_list)
52
+ self._check_classes(init_list)
53
+ self._check_unique_name_fields(init_list)
54
+
55
+ super().__init__(init_list)
56
+
57
+ def __str__(self):
58
+ # `display_fields` gives more control over the items displayed from the list if available
59
+ if not self.data:
60
+ return str([])
61
+ try:
62
+ model_display_fields = [model.display_fields for model in self.data]
63
+ # get all items included in at least one list
64
+ # the list comprehension ensures they are in the order that they're in in the model
65
+ required_fields = list(set().union(*model_display_fields))
66
+ table_fields = ["index"] + [i for i in list(self.data[0].__dict__) if i in required_fields]
67
+ except AttributeError:
68
+ try:
69
+ model_display_fields = [model.__dict__ for model in self.data]
70
+ table_fields = ["index"] + list(self.data[0].__dict__)
71
+ except AttributeError:
72
+ return str(self.data)
73
+
74
+ if any(model_display_fields):
75
+ table = prettytable.PrettyTable()
76
+ table.field_names = [field.replace("_", " ") for field in table_fields]
77
+ rows = []
78
+ for index, model in enumerate(self.data):
79
+ row = [index]
80
+ for field in table_fields[1:]:
81
+ value = getattr(model, field, "")
82
+ if isinstance(value, np.ndarray):
83
+ value = (
84
+ f"{'Data array: [' + ' x '.join(str(i) for i in value.shape) if value.size > 0 else '['}]"
85
+ )
86
+ elif field == "model":
87
+ value = "\n".join(str(element) for element in value)
88
+ else:
89
+ value = str(value)
90
+ row.append(value)
91
+ rows.append(row)
92
+ table.add_rows(rows)
93
+ output = table.get_string()
94
+ else:
95
+ if any(model.__dict__ for model in self.data):
96
+ table = prettytable.PrettyTable()
97
+ table.field_names = ["index"] + [key.replace("_", " ") for key in self.data[0].__dict__]
98
+ table.add_rows(
99
+ [
100
+ [index]
101
+ + list(
102
+ f"{'Data array: [' + ' x '.join(str(i) for i in v.shape) if v.size > 0 else '['}]"
103
+ if isinstance(v, np.ndarray)
104
+ else "\n".join(element for element in v)
105
+ if k == "model"
106
+ else str(v)
107
+ for k, v in model.__dict__.items()
108
+ )
109
+ for index, model in enumerate(self.data)
110
+ ]
111
+ )
112
+ output = table.get_string()
113
+ else:
114
+ output = str(self.data)
115
+ return output
116
+
117
+ def __getitem__(self, index: int | slice | str | T) -> T:
118
+ """Get an item by its index, name, a slice, or the object itself."""
119
+ if isinstance(index, (int, slice)):
120
+ return self.data[index]
121
+ elif isinstance(index, (str, self._class_handle)):
122
+ return self.data[self.index(index)]
123
+ else:
124
+ raise IndexError("ClassLists can only be indexed by integers, slices, name strings, or objects.")
125
+
126
+ def __setitem__(self, index: int, item: T) -> None:
127
+ """Replace the object at an existing index of the ClassList."""
128
+ self._setitem(index, item)
129
+
130
+ def _setitem(self, index: int, item: T) -> None:
131
+ """Auxiliary routine of "__setitem__" used to enable wrapping."""
132
+ self._check_classes([item])
133
+ self._check_unique_name_fields([item])
134
+ self.data[index] = item
135
+
136
+ def __delitem__(self, index: int) -> None:
137
+ """Delete an object from the list by index."""
138
+ self._delitem(index)
139
+
140
+ def _delitem(self, index: int) -> None:
141
+ """Auxiliary routine of "__delitem__" used to enable wrapping."""
142
+ del self.data[index]
143
+
144
+ def __iadd__(self, other: Sequence[T]) -> "ClassList":
145
+ """Define in-place addition using the "+=" operator."""
146
+ return self._iadd(other)
147
+
148
+ def _iadd(self, other: Sequence[T]) -> "ClassList":
149
+ """Auxiliary routine of "__iadd__" used to enable wrapping."""
150
+ if other and not (isinstance(other, Sequence) and not isinstance(other, str)):
151
+ other = [other]
152
+ if not hasattr(self, "_class_handle"):
153
+ self._class_handle = self._determine_class_handle(self + other)
154
+ self._check_classes(other)
155
+ self._check_unique_name_fields(other)
156
+ super().__iadd__(other)
157
+ return self
158
+
159
+ def __mul__(self, n: int) -> None:
160
+ """Define multiplication using the "*" operator."""
161
+ raise TypeError(f"unsupported operand type(s) for *: '{self.__class__.__name__}' and '{n.__class__.__name__}'")
162
+
163
+ def __rmul__(self, n: int) -> None:
164
+ """Define multiplication using the "*" operator."""
165
+ raise TypeError(f"unsupported operand type(s) for *: '{n.__class__.__name__}' and '{self.__class__.__name__}'")
166
+
167
+ def __imul__(self, n: int) -> None:
168
+ """Define in-place multiplication using the "*=" operator."""
169
+ raise TypeError(f"unsupported operand type(s) for *=: '{self.__class__.__name__}' and '{n.__class__.__name__}'")
170
+
171
+ def append(self, obj: T = None, **kwargs) -> None:
172
+ """Append a new object to the ClassList.
173
+
174
+ This method can use the object itself, or can provide attribute values as keyword arguments for a new object.
175
+
176
+ Parameters
177
+ ----------
178
+ obj : T, optional
179
+ An instance of the class specified by self._class_handle.
180
+ **kwargs : dict[str, Any], optional
181
+ The input keyword arguments for a new object in the ClassList.
182
+
183
+ Raises
184
+ ------
185
+ ValueError
186
+ Raised if the input arguments contain a ``name_field`` value already defined in the ClassList.
187
+
188
+ Warnings
189
+ --------
190
+ SyntaxWarning
191
+ Raised if the input arguments contain BOTH an object and keyword arguments. In this situation the object is
192
+ appended to the ClassList and the keyword arguments are discarded.
193
+
194
+ """
195
+ if obj and kwargs:
196
+ warnings.warn(
197
+ "ClassList.append() called with both an object and keyword arguments. "
198
+ "The keyword arguments will be ignored.",
199
+ SyntaxWarning,
200
+ stacklevel=2,
201
+ )
202
+ if obj:
203
+ if not hasattr(self, "_class_handle"):
204
+ self._class_handle = type(obj)
205
+ self._check_classes([obj])
206
+ self._check_unique_name_fields([obj])
207
+ self.data.append(obj)
208
+ else:
209
+ if not hasattr(self, "_class_handle"):
210
+ raise TypeError(
211
+ "ClassList.append() called with keyword arguments for a ClassList without a class "
212
+ "defined. Call ClassList.append() with an object to define the class.",
213
+ )
214
+ self._validate_name_field(kwargs)
215
+ self.data.append(self._class_handle(**kwargs))
216
+
217
+ def insert(self, index: int, obj: T = None, **kwargs) -> None:
218
+ """Insert a new object at a given index.
219
+
220
+ This method can use the object itself, or can provide attribute values as keyword arguments for a new object.
221
+
222
+ Parameters
223
+ ----------
224
+ index: int
225
+ The index at which to insert a new object in the ClassList.
226
+ obj : T, optional
227
+ An instance of the class specified by self._class_handle.
228
+ **kwargs : dict[str, Any], optional
229
+ The input keyword arguments for a new object in the ClassList.
230
+
231
+ Raises
232
+ ------
233
+ ValueError
234
+ Raised if the input arguments contain a ``name_field`` value already defined in the ClassList.
235
+
236
+ Warnings
237
+ --------
238
+ SyntaxWarning
239
+ Raised if the input arguments contain both an object and keyword arguments. In this situation the object is
240
+ inserted into the ClassList and the keyword arguments are discarded.
241
+
242
+ """
243
+ if obj and kwargs:
244
+ warnings.warn(
245
+ "ClassList.insert() called with both an object and keyword arguments. "
246
+ "The keyword arguments will be ignored.",
247
+ SyntaxWarning,
248
+ stacklevel=2,
249
+ )
250
+ if obj:
251
+ if not hasattr(self, "_class_handle"):
252
+ self._class_handle = type(obj)
253
+ self._check_classes([obj])
254
+ self._check_unique_name_fields([obj])
255
+ self.data.insert(index, obj)
256
+ else:
257
+ if not hasattr(self, "_class_handle"):
258
+ raise TypeError(
259
+ "ClassList.insert() called with keyword arguments for a ClassList without a class "
260
+ "defined. Call ClassList.insert() with an object to define the class.",
261
+ )
262
+ self._validate_name_field(kwargs)
263
+ self.data.insert(index, self._class_handle(**kwargs))
264
+
265
+ def remove(self, item: T | str) -> None:
266
+ """Remove an object from the ClassList using either the object itself or its ``name_field`` value."""
267
+ item = self._get_item_from_name_field(item)
268
+ self.data.remove(item)
269
+
270
+ def count(self, item: T | str) -> int:
271
+ """Return the number of times an object appears in the ClassList.
272
+
273
+ This method can use either the object itself or its ``name_field`` value.
274
+
275
+ """
276
+ item = self._get_item_from_name_field(item)
277
+ return self.data.count(item)
278
+
279
+ def index(self, item: T | str, offset: bool = False, *args) -> int:
280
+ """Return the index of a particular object in the ClassList.
281
+
282
+ This method can use either the object itself or its ``name_field`` value.
283
+ If offset is specified, add one to the index. This is used to account for one-based indexing.
284
+
285
+ """
286
+ item = self._get_item_from_name_field(item)
287
+ return self.data.index(item, *args) + int(offset)
288
+
289
+ def extend(self, other: Sequence[T]) -> None:
290
+ """Extend the ClassList by adding another sequence."""
291
+ if other and not (isinstance(other, Sequence) and not isinstance(other, str)):
292
+ other = [other]
293
+ if not hasattr(self, "_class_handle"):
294
+ self._class_handle = self._determine_class_handle(self + other)
295
+ self._check_classes(other)
296
+ self._check_unique_name_fields(other)
297
+ self.data.extend(other)
298
+
299
+ def union(self, other: Sequence[T]) -> None:
300
+ """Extend the ClassList by a sequence, ignoring input items with names that already exist."""
301
+ if other and not (isinstance(other, Sequence) and not isinstance(other, str)):
302
+ other = [other]
303
+
304
+ self.extend(
305
+ [
306
+ item
307
+ for item in other
308
+ if hasattr(item, self.name_field) and getattr(item, self.name_field) not in self.get_names()
309
+ ]
310
+ )
311
+
312
+ def set_fields(self, index: int | slice | str | T, **kwargs) -> None:
313
+ """Assign the values of an existing object's attributes using keyword arguments."""
314
+ self._validate_name_field(kwargs)
315
+ pydantic_object = False
316
+
317
+ # Find index if name or object is supplied
318
+ if isinstance(index, (str, self._class_handle)):
319
+ index = self.index(index)
320
+
321
+ # Prioritise changing language to avoid CustomFile validator bug
322
+ value = kwargs.pop("language", None)
323
+ if value is not None:
324
+ kwargs = {"language": value, **kwargs}
325
+
326
+ if importlib.util.find_spec("pydantic"):
327
+ # Pydantic is installed, so set up a context manager that will
328
+ # suppress custom validation errors until all fields have been set.
329
+ from pydantic import BaseModel, ValidationError
330
+
331
+ if isinstance(self.data[index], BaseModel):
332
+ pydantic_object = True
333
+
334
+ # Define a custom context manager
335
+ class SuppressCustomValidation(contextlib.AbstractContextManager):
336
+ """Context manager to suppress "value_error" based validation errors in pydantic.
337
+
338
+ This validation context is necessary because errors can occur whilst individual
339
+ model values are set, which are resolved when all of the input values are set.
340
+
341
+ After the exception is suppressed, execution proceeds with the next
342
+ statement following the with statement.
343
+
344
+ with SuppressCustomValidation():
345
+ setattr(self.data[index], key, value)
346
+ # Execution still resumes here if the attribute cannot be set
347
+ """
348
+
349
+ def __init__(self):
350
+ pass
351
+
352
+ def __enter__(self):
353
+ pass
354
+
355
+ def __exit__(self, exctype, excinst, exctb):
356
+ # If the return of __exit__ is True or truthy, the exception is suppressed.
357
+ # Otherwise, the default behaviour of raising the exception applies.
358
+ #
359
+ # To suppress errors arising from field and model validators in pydantic,
360
+ # we will examine the validation errors raised. If all of the errors
361
+ # listed in the exception have the type "value_error", this indicates
362
+ # they have arisen from field or model validators and will be suppressed.
363
+ # Otherwise, they will be raised.
364
+ if exctype is None:
365
+ return
366
+ if issubclass(exctype, ValidationError) and all(
367
+ [error["type"] == "value_error" for error in excinst.errors()]
368
+ ):
369
+ return True
370
+ return False
371
+
372
+ validation_context = SuppressCustomValidation()
373
+ else:
374
+ validation_context = contextlib.nullcontext()
375
+
376
+ for key, value in kwargs.items():
377
+ with validation_context:
378
+ setattr(self.data[index], key, value)
379
+
380
+ # We have suppressed custom validation errors for pydantic objects.
381
+ # We now must revalidate the pydantic model outside the validation context
382
+ # to catch any errors that remain after setting all of the fields.
383
+ if pydantic_object:
384
+ self._class_handle.model_validate(self.data[index])
385
+
386
+ def get_names(self) -> list[str]:
387
+ """Return a list of the values of the ``name_field`` attribute of each class object in the list.
388
+
389
+ Returns
390
+ -------
391
+ names : list [str]
392
+ The value of the ``name_field`` attribute of each object in the ClassList.
393
+
394
+ """
395
+ return [getattr(model, self.name_field) for model in self.data if hasattr(model, self.name_field)]
396
+
397
+ def get_all_matches(self, value: Any) -> list[tuple]:
398
+ """Return a list of all (index, field) tuples where the value of the field is equal to the given value.
399
+
400
+ Parameters
401
+ ----------
402
+ value : str
403
+ The value we are searching for in the ClassList.
404
+
405
+ Returns
406
+ -------
407
+ : list [tuple]
408
+ A list of (index, field) tuples matching the given value.
409
+
410
+ """
411
+ return [
412
+ (index, field)
413
+ for index, element in enumerate(self.data)
414
+ for field in vars(element)
415
+ if getattr(element, field) == value
416
+ ]
417
+
418
+ def _validate_name_field(self, input_args: dict[str, Any]) -> None:
419
+ """Raise a ValueError if the user tries to add an object with a ``name_field`` already in the ClassList.
420
+
421
+ Parameters
422
+ ----------
423
+ input_args : dict [str, Any]
424
+ The input keyword arguments for a new object in the ClassList.
425
+
426
+ Raises
427
+ ------
428
+ ValueError
429
+ Raised if the input arguments contain a ``name_field`` value already defined in the ClassList.
430
+
431
+ """
432
+ names = [name.lower() for name in self.get_names()]
433
+ with contextlib.suppress(KeyError):
434
+ name = input_args[self.name_field].lower()
435
+ if name in names:
436
+ raise ValueError(
437
+ f"Input arguments contain the {self.name_field} '{input_args[self.name_field]}', "
438
+ f"which is already specified at index {names.index(name)} of the ClassList",
439
+ )
440
+
441
+ def _check_unique_name_fields(self, input_list: Sequence[T]) -> None:
442
+ """Raise a ValueError if any value of the ``name_field`` attribute is repeated in a list of class objects.
443
+
444
+ Parameters
445
+ ----------
446
+ input_list : iterable
447
+ An iterable of instances of the class given in self._class_handle.
448
+
449
+ Raises
450
+ ------
451
+ ValueError
452
+ Raised if the input list defines more than one object with the same value of name_field.
453
+
454
+ """
455
+ error_list = []
456
+ try:
457
+ existing_names = [name.lower() for name in self.get_names()]
458
+ except AttributeError:
459
+ existing_names = []
460
+
461
+ new_names = [getattr(model, self.name_field).lower() for model in input_list if hasattr(model, self.name_field)]
462
+ full_names = existing_names + new_names
463
+
464
+ # There are duplicate names if this test fails
465
+ if len(set(full_names)) != len(full_names):
466
+ unique_names = [*dict.fromkeys(new_names)]
467
+
468
+ for name in unique_names:
469
+ existing_indices = [i for i, other_name in enumerate(existing_names) if other_name == name]
470
+ new_indices = [i for i, other_name in enumerate(new_names) if other_name == name]
471
+ if (len(existing_indices) + len(new_indices)) > 1:
472
+ existing_string = ""
473
+ new_string = ""
474
+ if existing_indices:
475
+ existing_list = ", ".join(str(i) for i in existing_indices[:-1])
476
+ existing_string = (
477
+ f" item{f's {existing_list} and ' if existing_list else ' '}"
478
+ f"{existing_indices[-1]} of the existing ClassList"
479
+ )
480
+ if new_indices:
481
+ new_list = ", ".join(str(i) for i in new_indices[:-1])
482
+ new_string = (
483
+ f" item{f's {new_list} and ' if new_list else ' '}{new_indices[-1]} of the input list"
484
+ )
485
+ error_list.append(
486
+ f" '{name}' is shared between{existing_string}"
487
+ f"{', and' if existing_string and new_string else ''}{new_string}"
488
+ )
489
+
490
+ if error_list:
491
+ newline = "\n"
492
+ raise ValueError(
493
+ f"The value of the '{self.name_field}' attribute must be unique for each item in the ClassList:\n"
494
+ f"{newline.join(error for error in error_list)}"
495
+ )
496
+
497
+ def _check_classes(self, input_list: Sequence[T]) -> None:
498
+ """Raise a ValueError if any object in a list of objects is not of the type specified by ``self._class_handle``.
499
+
500
+ Parameters
501
+ ----------
502
+ input_list : iterable
503
+ A list of instances of the class given in ``self._class_handle``.
504
+
505
+ Raises
506
+ ------
507
+ ValueError
508
+ If the input list contains objects of any type other than that given in ``self._class_handle``.
509
+
510
+ """
511
+ error_list = []
512
+ for i, element in enumerate(input_list):
513
+ if not isinstance(element, self._class_handle):
514
+ error_list.append(f" index {i} is of type {type(element).__name__}")
515
+ if error_list:
516
+ newline = "\n"
517
+ raise ValueError(
518
+ f"This ClassList only supports elements of type {self._class_handle.__name__}. "
519
+ f"In the input list:\n{newline.join(error for error in error_list)}\n"
520
+ )
521
+
522
+ def _get_item_from_name_field(self, value: T | str) -> T | str:
523
+ """Return the object with the given value of the ``name_field`` attribute in the ClassList.
524
+
525
+ Parameters
526
+ ----------
527
+ value : T or str
528
+ Either an object in the ClassList, or the value of the ``name_field`` for an object in the ClassList.
529
+
530
+ Returns
531
+ -------
532
+ instance : T or str
533
+ Either the object with the value of the ``name_field`` attribute given by value, or the input value if an
534
+ object with that value of the ``name_field`` attribute cannot be found.
535
+
536
+ """
537
+ try:
538
+ lower_value = value.lower()
539
+ except AttributeError:
540
+ lower_value = value
541
+
542
+ return next((model for model in self.data if getattr(model, self.name_field).lower() == lower_value), value)
543
+
544
+ @staticmethod
545
+ def _determine_class_handle(input_list: Sequence[T]):
546
+ """Determine the class handle from a sequence of objects.
547
+
548
+ The ``_class_handle`` of the sequence is the type of the first element in the sequence
549
+ which is a subclass of all elements in the sequence. If no such element exists, the handle
550
+ is set to be the type of the first element in the list.
551
+
552
+ Parameters
553
+ ----------
554
+ input_list : Sequence[T]
555
+ A list of instances to populate the ClassList.
556
+
557
+ Returns
558
+ -------
559
+ class_handle : type
560
+ The type object of the first element which is a subclass of all of the other
561
+ elements, or the first element if no such element exists.
562
+
563
+ """
564
+ for element in input_list:
565
+ if all(issubclass(type(instance), type(element)) for instance in input_list):
566
+ class_handle = type(element)
567
+ break
568
+ else:
569
+ class_handle = type(input_list[0])
570
+
571
+ return class_handle
572
+
573
+ # Pydantic core schema which allows ClassLists to be validated
574
+ # in short: it validates that each ClassList is indeed a ClassList,
575
+ # and then validates ClassList.data as though it were a typed list
576
+ # e.g. ClassList[str] data is validated like list[str]
577
+ @classmethod
578
+ def __get_pydantic_core_schema__(cls, source: Any, handler):
579
+ # import here so that the ClassList can be instantiated and used without Pydantic installed
580
+ from typing import get_args, get_origin
581
+
582
+ from pydantic import ValidatorFunctionWrapHandler
583
+ from pydantic.types import (
584
+ core_schema, # import core_schema through here rather than making pydantic_core a dependency
585
+ )
586
+
587
+ # if annotated with a class, get the item type of that class
588
+ origin = get_origin(source)
589
+ item_tp = Any if origin is None else get_args(source)[0]
590
+
591
+ list_schema = handler.generate_schema(list[item_tp])
592
+
593
+ def coerce(v: Any, handler: ValidatorFunctionWrapHandler) -> ClassList[T]:
594
+ """If a sequence is given, try to coerce it to a ClassList."""
595
+ if isinstance(v, Sequence):
596
+ classlist = ClassList()
597
+ if len(v) > 0 and isinstance(v[0], dict):
598
+ # we want to be OK if the type is a model and is passed as a dict;
599
+ # pydantic will coerce it or fall over later
600
+ classlist._class_handle = dict
601
+ elif item_tp is not Any:
602
+ classlist._class_handle = item_tp
603
+ classlist.extend(v)
604
+ v = classlist
605
+ v = handler(v)
606
+ return v
607
+
608
+ def validate_items(v: ClassList[T], handler: ValidatorFunctionWrapHandler) -> ClassList[T]:
609
+ v.data = handler(v.data)
610
+ return v
611
+
612
+ schema = core_schema.chain_schema(
613
+ [
614
+ core_schema.no_info_wrap_validator_function(coerce, core_schema.is_instance_schema(cls)),
615
+ core_schema.no_info_wrap_validator_function(validate_items, list_schema),
616
+ ],
617
+ serialization=core_schema.plain_serializer_function_ser_schema(lambda x: x),
618
+ )
619
+
620
+ return schema