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.
- ratapi/__init__.py +42 -0
- ratapi/classlist.py +620 -0
- ratapi/controls.py +258 -0
- ratapi/eventManager.dll +0 -0
- ratapi/events.py +88 -0
- ratapi/examples/__init__.py +25 -0
- ratapi/examples/absorption/__init__.py +1 -0
- ratapi/examples/absorption/absorption.py +172 -0
- ratapi/examples/absorption/volume_thiol_bilayer.py +144 -0
- ratapi/examples/convert_rascal_project/Model_IIb.py +90 -0
- ratapi/examples/convert_rascal_project/__init__.py +1 -0
- ratapi/examples/convert_rascal_project/convert_rascal.py +49 -0
- ratapi/examples/data/D2O_spin_down.dat +100 -0
- ratapi/examples/data/D2O_spin_up.dat +101 -0
- ratapi/examples/data/DSPC_D2O.dat +82 -0
- ratapi/examples/data/DSPC_SMW.dat +82 -0
- ratapi/examples/data/H2O_spin_down.dat +102 -0
- ratapi/examples/data/H2O_spin_up.dat +102 -0
- ratapi/examples/data/__init__.py +1 -0
- ratapi/examples/data/c_PLP0016596.dat +146 -0
- ratapi/examples/data/c_PLP0016601.dat +97 -0
- ratapi/examples/data/c_PLP0016607.dat +104 -0
- ratapi/examples/data/d2o_background_data.dat +82 -0
- ratapi/examples/domains/__init__.py +1 -0
- ratapi/examples/domains/alloy_domains.py +34 -0
- ratapi/examples/domains/domains_XY_model.py +75 -0
- ratapi/examples/domains/domains_custom_XY.py +86 -0
- ratapi/examples/domains/domains_custom_layers.py +60 -0
- ratapi/examples/domains/domains_standard_layers.py +92 -0
- ratapi/examples/languages/__init__.py +1 -0
- ratapi/examples/languages/custom_bilayer.py +72 -0
- ratapi/examples/languages/run_custom_file_languages.py +41 -0
- ratapi/examples/languages/setup_problem.py +130 -0
- ratapi/examples/normal_reflectivity/DSPC_custom_XY.py +149 -0
- ratapi/examples/normal_reflectivity/DSPC_custom_layers.py +130 -0
- ratapi/examples/normal_reflectivity/DSPC_data_background.py +220 -0
- ratapi/examples/normal_reflectivity/DSPC_function_background.py +219 -0
- ratapi/examples/normal_reflectivity/DSPC_standard_layers.py +210 -0
- ratapi/examples/normal_reflectivity/__init__.py +1 -0
- ratapi/examples/normal_reflectivity/background_function.py +16 -0
- ratapi/examples/normal_reflectivity/custom_XY_DSPC.py +141 -0
- ratapi/examples/normal_reflectivity/custom_bilayer_DSPC.py +89 -0
- ratapi/inputs.py +603 -0
- ratapi/models.py +717 -0
- ratapi/outputs.py +821 -0
- ratapi/project.py +1091 -0
- ratapi/rat_core.cp315-win_amd64.pyd +0 -0
- ratapi/run.py +142 -0
- ratapi/utils/__init__.py +1 -0
- ratapi/utils/convert.py +597 -0
- ratapi/utils/custom_errors.py +40 -0
- ratapi/utils/enums.py +203 -0
- ratapi/utils/matlab.py +254 -0
- ratapi/utils/orso.py +247 -0
- ratapi/utils/plotting.py +1316 -0
- ratapi/wrappers.py +147 -0
- ratapi-0.0.0.dev16.dist-info/DELVEWHEEL +2 -0
- ratapi-0.0.0.dev16.dist-info/METADATA +61 -0
- ratapi-0.0.0.dev16.dist-info/RECORD +63 -0
- ratapi-0.0.0.dev16.dist-info/WHEEL +5 -0
- ratapi-0.0.0.dev16.dist-info/top_level.txt +1 -0
- ratapi.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
- 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
|