dycw-utilities 0.109.0__py3-none-any.whl → 0.109.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dycw-utilities
3
- Version: 0.109.0
3
+ Version: 0.109.2
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,4 +1,4 @@
1
- utilities/__init__.py,sha256=us3aLi645m07ZoZciss9gQZW8TJND1cejqEh_im4aL8,60
1
+ utilities/__init__.py,sha256=BFspQsIuZJOmuE31YyhwkR4P62-Ad5dFurosyyABzLA,60
2
2
  utilities/altair.py,sha256=NSyDsm8QlkAGmsGdxVwCkHnPxt_35yJBa9Lg7bz9Ays,9054
3
3
  utilities/astor.py,sha256=xuDUkjq0-b6fhtwjhbnebzbqQZAjMSHR1IIS5uOodVg,777
4
4
  utilities/asyncio.py,sha256=41oQUurWMvadFK5gFnaG21hMM0Vmfn2WS6OpC0R9mas,14757
@@ -11,7 +11,7 @@ utilities/contextlib.py,sha256=OOIIEa5lXKGzFAnauaul40nlQnQko6Na4ryiMJcHkIg,478
11
11
  utilities/contextvars.py,sha256=RsSGGrbQqqZ67rOydnM7WWIsM2lIE31UHJLejnHJPWY,505
12
12
  utilities/cryptography.py,sha256=HyOewI20cl3uRXsKivhIaeLVDInQdzgXZGaly7hS5dE,771
13
13
  utilities/cvxpy.py,sha256=Rv1-fD-XYerosCavRF8Pohop2DBkU3AlFaGTfD8AEAA,13776
14
- utilities/dataclasses.py,sha256=SOWaLVMK6p_HTMPhiXtgCk5F1KGiY8wNxQkYRBz1dVg,18683
14
+ utilities/dataclasses.py,sha256=4siQUalzjYckhJ7R9Cn4Pmerxzx18_mjPIwUmeuwwKs,23049
15
15
  utilities/datetime.py,sha256=GOs-MIEW_A49kzqa1yhIoeNeSqqPVgGO-h2AThtgTDk,37326
16
16
  utilities/enum.py,sha256=HoRwVCWzsnH0vpO9ZEcAAIZLMv0Sn2vJxxA4sYMQgDs,5793
17
17
  utilities/errors.py,sha256=BtSNP0JC3ik536ddPyTerLomCRJV9f6kdMe6POz0QHM,361
@@ -38,22 +38,22 @@ utilities/more_itertools.py,sha256=CPUxrMAcTwRxbzbhiqPKi3Xx9hxqI0t6gkWjutaibGk,5
38
38
  utilities/numpy.py,sha256=rA1b0_GkBUSMjnv77tinRM70KRnkcmZxI9xbrsXFDRg,21819
39
39
  utilities/operator.py,sha256=0M2yZJ0PODH47ogFEnkGMBe_cfxwZR02T_92LZVZvHo,3715
40
40
  utilities/optuna.py,sha256=loyJGWTzljgdJaoLhP09PT8Jz6o_pwBOwehY33lHkhw,1923
41
- utilities/orjson.py,sha256=DW5pOpMyrR5Q8caQYly9AqRPazDBqrWv5GRWfULqka4,36291
41
+ utilities/orjson.py,sha256=Wj5pzG_VdgoAy14a7Luhem-BgYrRtRFvvl_POiszRd0,36930
42
42
  utilities/os.py,sha256=D_FyyT-6TtqiN9KSS7c9g1fnUtgxmyMtzAjmYLkk46A,3587
43
43
  utilities/parse.py,sha256=yLLH51VNwmcWbEvwqh6M-weWt7NIayd7No67Oe80S3k,4585
44
44
  utilities/pathlib.py,sha256=31WPMXdLIyXgYOMMl_HOI2wlo66MGSE-cgeelk-Lias,1410
45
45
  utilities/period.py,sha256=ikHXsWtDLr553cfH6p9mMaiCnIAP69B7q84ckWV3HaA,10884
46
46
  utilities/pickle.py,sha256=Bhvd7cZl-zQKQDFjUerqGuSKlHvnW1K2QXeU5UZibtg,657
47
47
  utilities/platform.py,sha256=NU7ycTvAXAG-fdYmDXaM1m4EOml2cGiaYwaUzfzSqyU,1767
48
- utilities/polars.py,sha256=ZXiHLkn6CbRh0_e0db5KRjHPU0LAedwzGno7k9fsiIo,48917
48
+ utilities/polars.py,sha256=USK_Rck8nmFYg2Rs-akqN9jV4w52lpz4rgkWUMQdLMk,49087
49
49
  utilities/pqdm.py,sha256=foRytQybmOQ05pjt5LF7ANyzrIa--4ScDE3T2wd31a4,3118
50
50
  utilities/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
51
  utilities/pydantic.py,sha256=f6qtR5mO2YMuyvNmbaEj5YeD9eGA4YYfb7Bjzh9jUs0,1845
52
52
  utilities/pyinstrument.py,sha256=ROq2txPwbe2ZUuYJ2IDNbfT97lu2ca0v5_C_yn6sSlM,800
53
53
  utilities/pyrsistent.py,sha256=TLJfiiKO4cKNU_pCoM3zDqmSM421qpuoaeaBNnyC_Ac,2489
54
54
  utilities/pytest.py,sha256=85QUax4g2VBBAqAHtM9wekcSLB7_9O8AKFTaCshztL8,7989
55
- utilities/pytest_regressions.py,sha256=Kp1NS_cyXvBFqyiF_oSzYmSJzIOdAZ0SFcSGmbL_UtI,5001
56
- utilities/python_dotenv.py,sha256=7N4ZbBxXpPNttOTfg-hpaLFFWA5iJwF7tREBzUnbPOM,3415
55
+ utilities/pytest_regressions.py,sha256=-SVT9647Dg6-JcdsiaDKXe3NdOmmrvGevLKWwGjxq3c,5088
56
+ utilities/python_dotenv.py,sha256=10DHEB7AVeZqH7I4wr6nACdJQYQJanlEj6EsyLvCN9w,3059
57
57
  utilities/random.py,sha256=lYdjgxB7GCfU_fwFVl5U-BIM_HV3q6_urL9byjrwDM8,4157
58
58
  utilities/re.py,sha256=5J4d8VwIPFVrX2Eb8zfoxImDv7IwiN_U7mJ07wR2Wvs,3958
59
59
  utilities/redis.py,sha256=CsDQqc9V6ASLzLQwtbQXZQEndyG9pJiCOhPlPeszt7Y,21203
@@ -75,7 +75,7 @@ utilities/threading.py,sha256=GvBOp4CyhHfN90wGXZuA2VKe9fGzMaEa7oCl4f3nnPU,1009
75
75
  utilities/timer.py,sha256=Rkc49KSpHuC8s7vUxGO9DU55U9I6yDKnchsQqrUCVBs,4075
76
76
  utilities/traceback.py,sha256=KwHPLdEbdj0fFhXo8MBfxcvem8A-VXYDwFMNJ6f0cTM,27328
77
77
  utilities/types.py,sha256=QK8kgH80TJdh_vktaZHrCEk7f1f8kHiDr8dJlK8aSac,17814
78
- utilities/typing.py,sha256=qX0o3NfImTXt40iGpERIVjuOQGb0XINfWUOxMmlh1s4,5157
78
+ utilities/typing.py,sha256=h1vt82GUs-3ww7yFbZ3BWjdUM4NBPHaptcJHSKuRa5E,5341
79
79
  utilities/tzdata.py,sha256=2ZsPmhTVM9Ptrxb4QrWKtKOB9RiH8IOO-A1u7ULdVbg,176
80
80
  utilities/tzlocal.py,sha256=42BCquGF54oIqIKe5RGziP4K8Nbm3Ey7uqcNn6m5ge8,534
81
81
  utilities/uuid.py,sha256=jJTFxz-CWgltqNuzmythB7iEQ-Q1mCwPevUfKthZT3c,611
@@ -84,7 +84,7 @@ utilities/warnings.py,sha256=yUgjnmkCRf6QhdyAXzl7u0qQFejhQG3PrjoSwxpbHrs,1819
84
84
  utilities/whenever.py,sha256=5x2t47VJmJRWcd_NLFy54NkB3uom-XQYxEbLtEfL1bs,17775
85
85
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
86
86
  utilities/zoneinfo.py,sha256=-DQz5a0Ikw9jfSZtL0BEQkXOMC9yGn_xiJYNCLMiqEc,1989
87
- dycw_utilities-0.109.0.dist-info/METADATA,sha256=7Urd3hro439NaLJ2Kkzu7mJNlYt5qxb3HmbzrcZwbkQ,13004
88
- dycw_utilities-0.109.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
89
- dycw_utilities-0.109.0.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
90
- dycw_utilities-0.109.0.dist-info/RECORD,,
87
+ dycw_utilities-0.109.2.dist-info/METADATA,sha256=9cmTxKEFxwqlG4VNbAnM88UGivDzayFQlB0ntOvtBno,13004
88
+ dycw_utilities-0.109.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
89
+ dycw_utilities-0.109.2.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
90
+ dycw_utilities-0.109.2.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.109.0"
3
+ __version__ = "0.109.2"
utilities/dataclasses.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Mapping
4
+ from collections.abc import Set as AbstractSet
4
5
  from dataclasses import MISSING, dataclass, field, fields, replace
5
6
  from typing import (
6
7
  TYPE_CHECKING,
@@ -22,7 +23,6 @@ from utilities.functions import (
22
23
  from utilities.iterables import OneStrEmptyError, OneStrNonUniqueError, one_str
23
24
  from utilities.operator import is_equal
24
25
  from utilities.parse import ParseTextError, parse_text
25
- from utilities.reprlib import get_repr
26
26
  from utilities.sentinel import Sentinel, sentinel
27
27
  from utilities.types import TDataclass
28
28
  from utilities.typing import get_type_hints
@@ -48,6 +48,7 @@ def dataclass_repr(
48
48
  exclude: Iterable[str] | None = None,
49
49
  globalns: StrMapping | None = None,
50
50
  localns: StrMapping | None = None,
51
+ warn_name_errors: bool = False,
51
52
  rel_tol: float | None = None,
52
53
  abs_tol: float | None = None,
53
54
  extra: Mapping[type[_T], Callable[[_T, _T], bool]] | None = None,
@@ -56,7 +57,9 @@ def dataclass_repr(
56
57
  ) -> str:
57
58
  """Repr a dataclass, without its defaults."""
58
59
  out: dict[str, str] = {}
59
- for fld in yield_fields(obj, globalns=globalns, localns=localns):
60
+ for fld in yield_fields(
61
+ obj, globalns=globalns, localns=localns, warn_name_errors=warn_name_errors
62
+ ):
60
63
  if (
61
64
  fld.keep(
62
65
  include=include,
@@ -76,6 +79,7 @@ def dataclass_repr(
76
79
  exclude=exclude,
77
80
  globalns=globalns,
78
81
  localns=localns,
82
+ warn_name_errors=warn_name_errors,
79
83
  rel_tol=rel_tol,
80
84
  abs_tol=abs_tol,
81
85
  extra=extra,
@@ -90,6 +94,7 @@ def dataclass_repr(
90
94
  exclude=exclude,
91
95
  globalns=globalns,
92
96
  localns=localns,
97
+ warn_name_errors=warn_name_errors,
93
98
  rel_tol=rel_tol,
94
99
  abs_tol=abs_tol,
95
100
  extra=extra,
@@ -122,6 +127,7 @@ def dataclass_to_dict(
122
127
  exclude: Iterable[str] | None = None,
123
128
  globalns: StrMapping | None = None,
124
129
  localns: StrMapping | None = None,
130
+ warn_name_errors: bool = False,
125
131
  rel_tol: float | None = None,
126
132
  abs_tol: float | None = None,
127
133
  extra: Mapping[type[_T], Callable[[_T, _T], bool]] | None = None,
@@ -131,7 +137,9 @@ def dataclass_to_dict(
131
137
  ) -> StrMapping:
132
138
  """Convert a dataclass to a dictionary."""
133
139
  out: StrMapping = {}
134
- for fld in yield_fields(obj, globalns=globalns, localns=localns):
140
+ for fld in yield_fields(
141
+ obj, globalns=globalns, localns=localns, warn_name_errors=warn_name_errors
142
+ ):
135
143
  if fld.keep(
136
144
  include=include,
137
145
  exclude=exclude,
@@ -146,6 +154,7 @@ def dataclass_to_dict(
146
154
  fld.value,
147
155
  globalns=globalns,
148
156
  localns=localns,
157
+ warn_name_errors=warn_name_errors,
149
158
  rel_tol=rel_tol,
150
159
  abs_tol=abs_tol,
151
160
  extra=extra,
@@ -159,6 +168,7 @@ def dataclass_to_dict(
159
168
  v,
160
169
  globalns=globalns,
161
170
  localns=localns,
171
+ warn_name_errors=warn_name_errors,
162
172
  rel_tol=rel_tol,
163
173
  abs_tol=abs_tol,
164
174
  extra=extra,
@@ -186,76 +196,157 @@ def mapping_to_dataclass(
186
196
  mapping: StrMapping,
187
197
  /,
188
198
  *,
199
+ fields: Iterable[_YieldFieldsClass[Any]] | None = None,
189
200
  globalns: StrMapping | None = None,
190
201
  localns: StrMapping | None = None,
202
+ warn_name_errors: bool = False,
203
+ head: bool = False,
191
204
  case_sensitive: bool = False,
192
- post: Callable[[_YieldFieldsClass[Any], Any], Any] | None = None,
205
+ allow_extra: bool = False,
193
206
  ) -> TDataclass:
194
207
  """Construct a dataclass from a mapping."""
195
- fields = yield_fields(cls, globalns=globalns, localns=localns)
196
- mapping_use = {
197
- f.name: _mapping_to_dataclass_one(
198
- f, mapping, case_sensitive=case_sensitive, post=post
208
+ if fields is None:
209
+ fields_use = list(
210
+ yield_fields(
211
+ cls,
212
+ globalns=globalns,
213
+ localns=localns,
214
+ warn_name_errors=warn_name_errors,
215
+ )
199
216
  )
200
- for f in fields
217
+ else:
218
+ fields_use = fields
219
+ fields_to_values = str_mapping_to_field_mapping(
220
+ cls,
221
+ mapping,
222
+ fields=fields_use,
223
+ globalns=globalns,
224
+ localns=localns,
225
+ warn_name_errors=warn_name_errors,
226
+ head=head,
227
+ case_sensitive=case_sensitive,
228
+ allow_extra=allow_extra,
229
+ )
230
+ field_names_to_values = {f.name: v for f, v in fields_to_values.items()}
231
+ default = {
232
+ f.name
233
+ for f in fields_use
234
+ if (not isinstance(f.default, Sentinel))
235
+ or (not isinstance(f.default_factory, Sentinel))
201
236
  }
202
- return cls(**mapping_use)
237
+ have = set(field_names_to_values) | default
238
+ missing = {f.name for f in fields_use} - have
239
+ if len(missing) >= 1:
240
+ raise MappingToDataclassError(cls=cls, fields=missing)
241
+ return cls(**field_names_to_values)
203
242
 
204
243
 
205
- def _mapping_to_dataclass_one(
206
- field: _YieldFieldsClass[Any],
207
- mapping: StrMapping,
244
+ @dataclass(kw_only=True, slots=True)
245
+ class MappingToDataclassError(Exception, Generic[TDataclass]):
246
+ cls: type[TDataclass]
247
+ fields: AbstractSet[str]
248
+
249
+ @override
250
+ def __str__(self) -> str:
251
+ desc = ", ".join(map(repr, sorted(self.fields)))
252
+ return f"Unable to construct {get_class_name(self.cls)!r}; missing values for {desc}"
253
+
254
+
255
+ ##
256
+
257
+
258
+ def one_field(
259
+ cls: type[Dataclass],
260
+ key: str,
208
261
  /,
209
262
  *,
263
+ fields: Iterable[_YieldFieldsClass[Any]] | None = None,
264
+ globalns: StrMapping | None = None,
265
+ localns: StrMapping | None = None,
266
+ warn_name_errors: bool = False,
267
+ head: bool = False,
210
268
  case_sensitive: bool = False,
211
- post: Callable[[_YieldFieldsClass[Any], Any], Any] | None = None,
212
- ) -> Any:
269
+ ) -> _YieldFieldsClass[Any]:
270
+ """Get the unique field a key matches to."""
271
+ if fields is None:
272
+ fields_use = list(
273
+ yield_fields(
274
+ cls,
275
+ globalns=globalns,
276
+ localns=localns,
277
+ warn_name_errors=warn_name_errors,
278
+ )
279
+ )
280
+ else:
281
+ fields_use = fields
282
+ mapping = {f.name: f for f in fields_use}
213
283
  try:
214
- key = one_str(mapping, field.name, case_sensitive=case_sensitive)
284
+ name = one_str(mapping, key, head=head, case_sensitive=case_sensitive)
215
285
  except OneStrEmptyError:
216
- if not isinstance(field.default, Sentinel):
217
- value = field.default
218
- elif not isinstance(field.default_factory, Sentinel):
219
- value = field.default_factory()
220
- else:
221
- raise _MappingToDataclassEmptyError(
222
- mapping=mapping, field=field.name, case_sensitive=case_sensitive
223
- ) from None
286
+ raise OneFieldEmptyError(
287
+ cls=cls, key=key, head=head, case_sensitive=case_sensitive
288
+ ) from None
224
289
  except OneStrNonUniqueError as error:
225
- raise _MappingToDataclassCaseInsensitiveNonUniqueError(
226
- mapping=mapping, field=field.name, first=error.first, second=error.second
290
+ raise OneFieldNonUniqueError(
291
+ cls=cls,
292
+ key=key,
293
+ head=head,
294
+ case_sensitive=case_sensitive,
295
+ first=error.first,
296
+ second=error.second,
227
297
  ) from None
228
- else:
229
- value = mapping[key]
230
- if post is not None:
231
- value = post(field, value)
232
- return value
298
+ return mapping[name]
233
299
 
234
300
 
235
301
  @dataclass(kw_only=True, slots=True)
236
- class MappingToDataclassError(Exception):
237
- mapping: StrMapping
238
- field: str
302
+ class OneFieldError(Exception, Generic[TDataclass]):
303
+ cls: type[TDataclass]
304
+ key: str
305
+ head: bool = False
306
+ case_sensitive: bool = False
239
307
 
240
308
 
241
309
  @dataclass(kw_only=True, slots=True)
242
- class _MappingToDataclassEmptyError(MappingToDataclassError):
243
- case_sensitive: bool = False
244
-
310
+ class OneFieldEmptyError(OneFieldError[TDataclass]):
245
311
  @override
246
312
  def __str__(self) -> str:
247
- desc = f"Mapping {get_repr(self.mapping)} does not contain {self.field!r}"
248
- return desc if self.case_sensitive else f"{desc} (modulo case)"
313
+ head = f"Dataclass {get_class_name(self.cls)!r} does not contain"
314
+ match self.head, self.case_sensitive:
315
+ case False, True:
316
+ tail = f"a field {self.key!r}"
317
+ case False, False:
318
+ tail = f"a field {self.key!r} (modulo case)"
319
+ case True, True:
320
+ tail = f"any field starting with {self.key!r}"
321
+ case True, False:
322
+ tail = f"any field starting with {self.key!r} (modulo case)"
323
+ case _ as never:
324
+ assert_never(never)
325
+ return f"{head} {tail}"
249
326
 
250
327
 
251
328
  @dataclass(kw_only=True, slots=True)
252
- class _MappingToDataclassCaseInsensitiveNonUniqueError(MappingToDataclassError):
329
+ class OneFieldNonUniqueError(OneFieldError[TDataclass]):
253
330
  first: str
254
331
  second: str
255
332
 
256
333
  @override
257
334
  def __str__(self) -> str:
258
- return f"Mapping {get_repr(self.mapping)} must contain {self.field!r} exactly once (modulo case); got {self.first!r}, {self.second!r} and perhaps more"
335
+ head = f"Dataclass {get_class_name(self.cls)!r} must contain"
336
+ match self.head, self.case_sensitive:
337
+ case False, True:
338
+ raise ImpossibleCaseError( # pragma: no cover
339
+ case=[f"{self.head=}", f"{self.case_sensitive=}"]
340
+ )
341
+ case False, False:
342
+ mid = f"field {self.key!r} exactly once (modulo case)"
343
+ case True, True:
344
+ mid = f"exactly one field starting with {self.key!r}"
345
+ case True, False:
346
+ mid = f"exactly one field starting with {self.key!r} (modulo case)"
347
+ case _ as never:
348
+ assert_never(never)
349
+ return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more"
259
350
 
260
351
 
261
352
  ##
@@ -290,6 +381,68 @@ def replace_non_sentinel(
290
381
  ##
291
382
 
292
383
 
384
+ def str_mapping_to_field_mapping(
385
+ cls: type[TDataclass],
386
+ mapping: Mapping[str, _T],
387
+ /,
388
+ *,
389
+ fields: Iterable[_YieldFieldsClass[Any]] | None = None,
390
+ globalns: StrMapping | None = None,
391
+ localns: StrMapping | None = None,
392
+ warn_name_errors: bool = False,
393
+ head: bool = False,
394
+ case_sensitive: bool = False,
395
+ allow_extra: bool = False,
396
+ ) -> Mapping[_YieldFieldsClass[Any], _T]:
397
+ """Convert a string-mapping into a field-mapping."""
398
+ keys_to_fields: Mapping[str, _YieldFieldsClass[Any]] = {}
399
+ for key in mapping:
400
+ try:
401
+ keys_to_fields[key] = one_field(
402
+ cls,
403
+ key,
404
+ fields=fields,
405
+ globalns=globalns,
406
+ localns=localns,
407
+ warn_name_errors=warn_name_errors,
408
+ head=head,
409
+ case_sensitive=case_sensitive,
410
+ )
411
+ except OneFieldEmptyError:
412
+ if not allow_extra:
413
+ raise StrMappingToFieldMappingError(
414
+ cls=cls, key=key, head=head, case_sensitive=case_sensitive
415
+ ) from None
416
+ return {field: mapping[key] for key, field in keys_to_fields.items()}
417
+
418
+
419
+ @dataclass(kw_only=True, slots=True)
420
+ class StrMappingToFieldMappingError(Exception):
421
+ cls: type[Dataclass]
422
+ key: str
423
+ head: bool = False
424
+ case_sensitive: bool = False
425
+
426
+ @override
427
+ def __str__(self) -> str:
428
+ head = f"Dataclass {get_class_name(self.cls)!r} does not contain"
429
+ match self.head, self.case_sensitive:
430
+ case False, True:
431
+ tail = f"a field {self.key!r}"
432
+ case False, False:
433
+ tail = f"a field {self.key!r} (modulo case)"
434
+ case True, True:
435
+ tail = f"any field starting with {self.key!r}"
436
+ case True, False:
437
+ tail = f"any field starting with {self.key!r} (modulo case)"
438
+ case _ as never:
439
+ assert_never(never)
440
+ return f"{head} {tail}"
441
+
442
+
443
+ ##
444
+
445
+
293
446
  def text_to_dataclass(
294
447
  text_or_mapping: str | Mapping[str, str],
295
448
  cls: type[TDataclass],
@@ -297,29 +450,49 @@ def text_to_dataclass(
297
450
  *,
298
451
  globalns: StrMapping | None = None,
299
452
  localns: StrMapping | None = None,
453
+ warn_name_errors: bool = False,
454
+ head: bool = False,
300
455
  case_sensitive: bool = False,
456
+ allow_extra: bool = False,
301
457
  ) -> TDataclass:
302
458
  """Construct a dataclass from a string or a mapping or strings."""
303
- fields = list(yield_fields(cls, globalns=globalns, localns=localns))
304
459
  match text_or_mapping:
305
460
  case str() as text:
306
- text_mapping = _text_to_dataclass_split_text(text, cls)
307
- case Mapping() as text_mapping:
461
+ keys_to_serializes = _text_to_dataclass_split_text(text, cls)
462
+ case Mapping() as keys_to_serializes:
308
463
  ...
309
464
  case _ as never:
310
465
  assert_never(never)
311
- value_mapping = dict(
312
- _text_to_dataclass_get_and_parse(
313
- fields, key, value, cls, case_sensitive=case_sensitive
466
+ fields = list(
467
+ yield_fields(
468
+ cls, globalns=globalns, localns=localns, warn_name_errors=warn_name_errors
314
469
  )
315
- for key, value in text_mapping.items()
316
470
  )
471
+ fields_to_serializes = str_mapping_to_field_mapping(
472
+ cls,
473
+ keys_to_serializes,
474
+ fields=fields,
475
+ globalns=globalns,
476
+ localns=localns,
477
+ warn_name_errors=warn_name_errors,
478
+ head=head,
479
+ case_sensitive=case_sensitive,
480
+ allow_extra=allow_extra,
481
+ )
482
+ field_names_to_values = {
483
+ f.name: _text_to_dataclass_parse(f, t, cls, case_sensitive=case_sensitive)
484
+ for f, t in fields_to_serializes.items()
485
+ }
317
486
  return mapping_to_dataclass(
318
487
  cls,
319
- value_mapping,
488
+ field_names_to_values,
489
+ fields=fields,
320
490
  globalns=globalns,
321
491
  localns=localns,
492
+ warn_name_errors=warn_name_errors,
493
+ head=head,
322
494
  case_sensitive=case_sensitive,
495
+ allow_extra=allow_extra,
323
496
  )
324
497
 
325
498
 
@@ -340,38 +513,18 @@ def _text_to_dataclass_split_key_value_pair(
340
513
  return key, value
341
514
 
342
515
 
343
- def _text_to_dataclass_get_and_parse(
344
- fields: Iterable[_YieldFieldsClass[Any]],
345
- key: str,
346
- value: str,
516
+ def _text_to_dataclass_parse(
517
+ field: _YieldFieldsClass[Any],
518
+ text: str,
347
519
  cls: type[Dataclass],
348
520
  /,
349
521
  *,
350
522
  case_sensitive: bool = False,
351
- ) -> tuple[str, Any]:
352
- mapping = {f.name: f for f in fields}
353
- try:
354
- name = one_str(mapping, key, head=True, case_sensitive=case_sensitive)
355
- except OneStrEmptyError:
356
- raise _TextToDataClassGetFieldEmptyError(
357
- cls=cls, key=key, case_sensitive=case_sensitive
358
- ) from None
359
- except OneStrNonUniqueError as error:
360
- raise _TextToDataClassGetFieldNonUniqueError(
361
- cls=cls,
362
- key=key,
363
- case_sensitive=case_sensitive,
364
- first=error.first,
365
- second=error.second,
366
- ) from None
367
- field = mapping[name]
523
+ ) -> Any:
368
524
  try:
369
- parsed = parse_text(field.type_, value, case_sensitive=case_sensitive)
525
+ return parse_text(field.type_, text, case_sensitive=case_sensitive)
370
526
  except ParseTextError:
371
- raise _TextToDataClassParseValueError(
372
- cls=cls, field=field, text=value
373
- ) from None
374
- return key, parsed
527
+ raise _TextToDataClassParseValueError(cls=cls, field=field, text=text) from None
375
528
 
376
529
 
377
530
  @dataclass(kw_only=True, slots=True)
@@ -388,31 +541,6 @@ class _TextToDataClassSplitKeyValuePairError(TextToDataClassError):
388
541
  return f"Unable to construct {get_class_name(self.cls)!r}; failed to split key-value pair {self.text!r}"
389
542
 
390
543
 
391
- @dataclass(kw_only=True, slots=True)
392
- class _TextToDataClassGetFieldEmptyError(TextToDataClassError[TDataclass]):
393
- key: str
394
- case_sensitive: bool = False
395
-
396
- @override
397
- def __str__(self) -> str:
398
- desc = f"Dataclass {get_class_name(self.cls)!r} does not contain any field starting with {self.key!r}"
399
- return desc if self.case_sensitive else f"{desc} (modulo case)"
400
-
401
-
402
- @dataclass(kw_only=True, slots=True)
403
- class _TextToDataClassGetFieldNonUniqueError(TextToDataClassError[TDataclass]):
404
- key: str
405
- case_sensitive: bool = False
406
- first: str
407
- second: str
408
-
409
- @override
410
- def __str__(self) -> str:
411
- head = f"Dataclass {get_class_name(self.cls)!r} must contain exactly one field starting with {self.key!r}"
412
- mid = "" if self.case_sensitive else " (modulo case)"
413
- return f"{head}{mid}; got {self.first!r}, {self.second!r} and perhaps more"
414
-
415
-
416
544
  @dataclass(kw_only=True, slots=True)
417
545
  class _TextToDataClassParseValueError(TextToDataClassError[TDataclass]):
418
546
  field: _YieldFieldsClass[Any]
@@ -431,16 +559,18 @@ def yield_fields(
431
559
  obj: Dataclass,
432
560
  /,
433
561
  *,
434
- globalns: StrMapping | None = ...,
435
- localns: StrMapping | None = ...,
562
+ globalns: StrMapping | None = None,
563
+ localns: StrMapping | None = None,
564
+ warn_name_errors: bool = False,
436
565
  ) -> Iterator[_YieldFieldsInstance[Any]]: ...
437
566
  @overload
438
567
  def yield_fields(
439
568
  obj: type[Dataclass],
440
569
  /,
441
570
  *,
442
- globalns: StrMapping | None = ...,
443
- localns: StrMapping | None = ...,
571
+ globalns: StrMapping | None = None,
572
+ localns: StrMapping | None = None,
573
+ warn_name_errors: bool = False,
444
574
  ) -> Iterator[_YieldFieldsClass[Any]]: ...
445
575
  def yield_fields(
446
576
  obj: Dataclass | type[Dataclass],
@@ -448,10 +578,16 @@ def yield_fields(
448
578
  *,
449
579
  globalns: StrMapping | None = None,
450
580
  localns: StrMapping | None = None,
581
+ warn_name_errors: bool = False,
451
582
  ) -> Iterator[_YieldFieldsInstance[Any]] | Iterator[_YieldFieldsClass[Any]]:
452
583
  """Yield the fields of a dataclass."""
453
584
  if is_dataclass_instance(obj):
454
- for field in yield_fields(type(obj), globalns=globalns, localns=localns):
585
+ for field in yield_fields(
586
+ type(obj),
587
+ globalns=globalns,
588
+ localns=localns,
589
+ warn_name_errors=warn_name_errors,
590
+ ):
455
591
  yield _YieldFieldsInstance(
456
592
  name=field.name,
457
593
  value=getattr(obj, field.name),
@@ -466,7 +602,9 @@ def yield_fields(
466
602
  kw_only=field.kw_only,
467
603
  )
468
604
  elif is_dataclass_class(obj):
469
- hints = get_type_hints(obj, globalns=globalns, localns=localns)
605
+ hints = get_type_hints(
606
+ obj, globalns=globalns, localns=localns, warn_name_errors=warn_name_errors
607
+ )
470
608
  for field in fields(obj):
471
609
  if isinstance(field.type, type):
472
610
  type_ = field.type
@@ -580,12 +718,18 @@ class YieldFieldsError(Exception):
580
718
 
581
719
  __all__ = [
582
720
  "MappingToDataclassError",
721
+ "OneFieldEmptyError",
722
+ "OneFieldError",
723
+ "OneFieldNonUniqueError",
724
+ "StrMappingToFieldMappingError",
583
725
  "TextToDataClassError",
584
726
  "YieldFieldsError",
585
727
  "dataclass_repr",
586
728
  "dataclass_to_dict",
587
729
  "mapping_to_dataclass",
730
+ "one_field",
588
731
  "replace_non_sentinel",
732
+ "str_mapping_to_field_mapping",
589
733
  "text_to_dataclass",
590
734
  "yield_fields",
591
735
  ]
utilities/orjson.py CHANGED
@@ -114,6 +114,7 @@ def serialize(
114
114
  before: Callable[[Any], Any] | None = None,
115
115
  globalns: StrMapping | None = None,
116
116
  localns: StrMapping | None = None,
117
+ warn_name_errors: bool = False,
117
118
  dataclass_hook: _DataclassHook | None = None,
118
119
  dataclass_defaults: bool = False,
119
120
  ) -> bytes:
@@ -123,6 +124,7 @@ def serialize(
123
124
  before=before,
124
125
  globalns=globalns,
125
126
  localns=localns,
127
+ warn_name_errors=warn_name_errors,
126
128
  dataclass_hook=dataclass_hook,
127
129
  dataclass_defaults=dataclass_defaults,
128
130
  )
@@ -139,6 +141,7 @@ def _pre_process(
139
141
  before: Callable[[Any], Any] | None = None,
140
142
  globalns: StrMapping | None = None,
141
143
  localns: StrMapping | None = None,
144
+ warn_name_errors: bool = False,
142
145
  dataclass_hook: _DataclassHook | None = None,
143
146
  dataclass_defaults: bool = False,
144
147
  error: _ErrorMode = "raise",
@@ -150,6 +153,7 @@ def _pre_process(
150
153
  before=before,
151
154
  globalns=globalns,
152
155
  localns=localns,
156
+ warn_name_errors=warn_name_errors,
153
157
  dataclass_hook=dataclass_hook,
154
158
  dataclass_defaults=dataclass_defaults,
155
159
  error=error,
@@ -196,6 +200,7 @@ def _pre_process(
196
200
  dataclass,
197
201
  globalns=globalns,
198
202
  localns=localns,
203
+ warn_name_errors=warn_name_errors,
199
204
  final=partial(_dataclass_final, hook=dataclass_hook),
200
205
  defaults=dataclass_defaults,
201
206
  )
@@ -212,6 +217,7 @@ def _pre_process(
212
217
  before=before,
213
218
  globalns=globalns,
214
219
  localns=localns,
220
+ warn_name_errors=warn_name_errors,
215
221
  dataclass_hook=dataclass_hook,
216
222
  )
217
223
  case list() as list_:
@@ -222,6 +228,7 @@ def _pre_process(
222
228
  before=before,
223
229
  globalns=globalns,
224
230
  localns=localns,
231
+ warn_name_errors=warn_name_errors,
225
232
  dataclass_hook=dataclass_hook,
226
233
  )
227
234
  case Mapping() as mapping:
@@ -234,6 +241,7 @@ def _pre_process(
234
241
  before=before,
235
242
  globalns=globalns,
236
243
  localns=localns,
244
+ warn_name_errors=warn_name_errors,
237
245
  dataclass_hook=dataclass_hook,
238
246
  )
239
247
  case tuple() as tuple_:
@@ -244,6 +252,7 @@ def _pre_process(
244
252
  before=before,
245
253
  globalns=globalns,
246
254
  localns=localns,
255
+ warn_name_errors=warn_name_errors,
247
256
  dataclass_hook=dataclass_hook,
248
257
  )
249
258
  # other
@@ -263,6 +272,7 @@ def _pre_process_container(
263
272
  before: Callable[[Any], Any] | None = None,
264
273
  globalns: StrMapping | None = None,
265
274
  localns: StrMapping | None = None,
275
+ warn_name_errors: bool = False,
266
276
  dataclass_hook: _DataclassHook | None = None,
267
277
  dataclass_include_defaults: bool = False,
268
278
  ) -> Any:
@@ -272,6 +282,7 @@ def _pre_process_container(
272
282
  before=before,
273
283
  globalns=globalns,
274
284
  localns=localns,
285
+ warn_name_errors=warn_name_errors,
275
286
  dataclass_hook=dataclass_hook,
276
287
  dataclass_defaults=dataclass_include_defaults,
277
288
  )
@@ -716,6 +727,7 @@ class OrjsonFormatter(Formatter):
716
727
  before: Callable[[Any], Any] | None = None,
717
728
  globalns: StrMapping | None = None,
718
729
  localns: StrMapping | None = None,
730
+ warn_name_errors: bool = False,
719
731
  dataclass_hook: _DataclassHook | None = None,
720
732
  dataclass_defaults: bool = False,
721
733
  ) -> None:
@@ -723,6 +735,7 @@ class OrjsonFormatter(Formatter):
723
735
  self._before = before
724
736
  self._globalns = globalns
725
737
  self._localns = localns
738
+ self._warn_name_errors = warn_name_errors
726
739
  self._dataclass_hook = dataclass_hook
727
740
  self._dataclass_defaults = dataclass_defaults
728
741
 
@@ -752,6 +765,7 @@ class OrjsonFormatter(Formatter):
752
765
  before=self._before,
753
766
  globalns=self._globalns,
754
767
  localns=self._localns,
768
+ warn_name_errors=self._warn_name_errors,
755
769
  dataclass_hook=self._dataclass_hook,
756
770
  dataclass_defaults=self._dataclass_defaults,
757
771
  ).decode()
utilities/polars.py CHANGED
@@ -726,10 +726,13 @@ def dataclass_to_schema(
726
726
  *,
727
727
  globalns: StrMapping | None = None,
728
728
  localns: StrMapping | None = None,
729
+ warn_name_errors: bool = False,
729
730
  ) -> SchemaDict:
730
731
  """Cast a dataclass as a schema dict."""
731
732
  out: dict[str, Any] = {}
732
- for field in yield_fields(obj, globalns=globalns, localns=localns):
733
+ for field in yield_fields(
734
+ obj, globalns=globalns, localns=localns, warn_name_errors=warn_name_errors
735
+ ):
733
736
  if is_dataclass_instance(field.value):
734
737
  dtypes = dataclass_to_schema(
735
738
  field.value, globalns=globalns, localns=localns
@@ -1388,12 +1391,15 @@ def struct_from_dataclass(
1388
1391
  *,
1389
1392
  globalns: StrMapping | None = None,
1390
1393
  localns: StrMapping | None = None,
1394
+ warn_name_errors: bool = False,
1391
1395
  time_zone: TimeZoneLike | None = None,
1392
1396
  ) -> Struct:
1393
1397
  """Construct the Struct data type for a dataclass."""
1394
1398
  if not is_dataclass_class(cls):
1395
1399
  raise _StructFromDataClassNotADataclassError(cls=cls)
1396
- anns = get_type_hints(cls, globalns=globalns, localns=localns)
1400
+ anns = get_type_hints(
1401
+ cls, globalns=globalns, localns=localns, warn_name_errors=warn_name_errors
1402
+ )
1397
1403
  data_types = {
1398
1404
  k: _struct_from_dataclass_one(v, time_zone=time_zone) for k, v in anns.items()
1399
1405
  }
@@ -51,6 +51,7 @@ class OrjsonRegressionFixture:
51
51
  *,
52
52
  globalns: StrMapping | None = None,
53
53
  localns: StrMapping | None = None,
54
+ warn_name_errors: bool = False,
54
55
  dataclass_defaults: bool = False,
55
56
  suffix: str | None = None,
56
57
  ) -> None:
@@ -61,6 +62,7 @@ class OrjsonRegressionFixture:
61
62
  obj,
62
63
  globalns=globalns,
63
64
  localns=localns,
65
+ warn_name_errors=warn_name_errors,
64
66
  dataclass_defaults=dataclass_defaults,
65
67
  )
66
68
  basename = self._basename
@@ -1,25 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from functools import partial
5
4
  from os import environ
6
- from typing import TYPE_CHECKING, Any, override
5
+ from typing import TYPE_CHECKING, override
7
6
 
8
7
  from dotenv import dotenv_values
9
8
 
10
- from utilities.dataclasses import (
11
- _MappingToDataclassEmptyError,
12
- _YieldFieldsClass,
13
- mapping_to_dataclass,
14
- )
9
+ from utilities.dataclasses import MappingToDataclassError, text_to_dataclass
15
10
  from utilities.git import get_repo_root
16
11
  from utilities.iterables import MergeStrMappingsError, merge_str_mappings
17
- from utilities.parse import ParseTextError, parse_text
18
12
  from utilities.pathlib import PWD
19
13
  from utilities.reprlib import get_repr
20
14
 
21
15
  if TYPE_CHECKING:
22
16
  from collections.abc import Mapping
17
+ from collections.abc import Set as AbstractSet
23
18
  from pathlib import Path
24
19
 
25
20
  from utilities.types import PathLike, StrMapping, TDataclass
@@ -32,6 +27,9 @@ def load_settings(
32
27
  cwd: PathLike = PWD,
33
28
  globalns: StrMapping | None = None,
34
29
  localns: StrMapping | None = None,
30
+ warn_name_errors: bool = False,
31
+ head: bool = False,
32
+ case_sensitive: bool = False,
35
33
  ) -> TDataclass:
36
34
  """Load a set of settings from the `.env` file."""
37
35
  path = get_repo_root(cwd=cwd).joinpath(".env")
@@ -39,35 +37,30 @@ def load_settings(
39
37
  raise _LoadSettingsFileNotFoundError(path=path) from None
40
38
  maybe_values_dotenv = dotenv_values(path)
41
39
  try:
42
- maybe_values = merge_str_mappings(maybe_values_dotenv, environ)
40
+ maybe_values: Mapping[str, str | None] = merge_str_mappings(
41
+ maybe_values_dotenv, environ, case_sensitive=case_sensitive
42
+ )
43
43
  except MergeStrMappingsError as error:
44
44
  raise _LoadSettingsDuplicateKeysError(
45
- path=path, values=error.mapping, counts=error.counts
45
+ path=path,
46
+ values=error.mapping,
47
+ counts=error.counts,
48
+ case_sensitive=case_sensitive,
46
49
  ) from None
47
50
  values = {k: v for k, v in maybe_values.items() if v is not None}
48
51
  try:
49
- return mapping_to_dataclass(
50
- cls,
52
+ return text_to_dataclass(
51
53
  values,
54
+ cls,
52
55
  globalns=globalns,
53
56
  localns=localns,
54
- post=partial(_load_settings_post, path=path, values=values),
57
+ warn_name_errors=warn_name_errors,
58
+ head=head,
59
+ case_sensitive=case_sensitive,
60
+ allow_extra=True,
55
61
  )
56
- except _MappingToDataclassEmptyError as error:
57
- raise _LoadSettingsEmptyError(
58
- path=path, values=error.mapping, field=error.field
59
- ) from None
60
-
61
-
62
- def _load_settings_post(
63
- field: _YieldFieldsClass[Any], text: str, /, *, path: Path, values: StrMapping
64
- ) -> Any:
65
- try:
66
- return parse_text(field.type_, text)
67
- except ParseTextError:
68
- raise _LoadSettingsParseTextError(
69
- path=path, values=values, field=field, text=text
70
- ) from None
62
+ except MappingToDataclassError as error:
63
+ raise _LoadSettingsMissingKeysError(path=path, fields=error.fields) from None
71
64
 
72
65
 
73
66
  @dataclass(kw_only=True, slots=True)
@@ -79,22 +72,13 @@ class LoadSettingsError(Exception):
79
72
  class _LoadSettingsDuplicateKeysError(LoadSettingsError):
80
73
  values: StrMapping
81
74
  counts: Mapping[str, int]
75
+ case_sensitive: bool = False
82
76
 
83
77
  @override
84
78
  def __str__(self) -> str:
85
79
  return f"Mapping {get_repr(dict(self.values))} keys must not contain duplicates (modulo case); got {get_repr(self.counts)}"
86
80
 
87
81
 
88
- @dataclass(kw_only=True, slots=True)
89
- class _LoadSettingsEmptyError(LoadSettingsError):
90
- values: StrMapping
91
- field: str
92
-
93
- @override
94
- def __str__(self) -> str:
95
- return f"Field {self.field!r} must exist (modulo case)"
96
-
97
-
98
82
  @dataclass(kw_only=True, slots=True)
99
83
  class _LoadSettingsFileNotFoundError(LoadSettingsError):
100
84
  @override
@@ -103,14 +87,13 @@ class _LoadSettingsFileNotFoundError(LoadSettingsError):
103
87
 
104
88
 
105
89
  @dataclass(kw_only=True, slots=True)
106
- class _LoadSettingsParseTextError(LoadSettingsError):
107
- values: StrMapping
108
- field: _YieldFieldsClass[Any]
109
- text: str
90
+ class _LoadSettingsMissingKeysError(LoadSettingsError):
91
+ fields: AbstractSet[str]
110
92
 
111
93
  @override
112
94
  def __str__(self) -> str:
113
- return f"Unable to parse field {self.field.name!r} of type {self.field.type_!r}; got {self.text!r}"
95
+ desc = ", ".join(map(repr, sorted(self.fields)))
96
+ return f"Unable to load {str(self.path)!r}; missing value(s) for {desc}"
114
97
 
115
98
 
116
99
  __all__ = ["LoadSettingsError", "load_settings"]
utilities/typing.py CHANGED
@@ -19,6 +19,7 @@ from typing import (
19
19
  from typing import get_args as _get_args
20
20
  from typing import get_type_hints as _get_type_hints
21
21
  from uuid import UUID
22
+ from warnings import warn
22
23
 
23
24
  from utilities.iterables import unique_everseen
24
25
  from utilities.sentinel import Sentinel
@@ -68,16 +69,18 @@ def get_type_hints(
68
69
  *,
69
70
  globalns: StrMapping | None = None,
70
71
  localns: StrMapping | None = None,
72
+ warn_name_errors: bool = False,
71
73
  ) -> dict[str, Any]:
72
74
  """Get the type hints of an object."""
73
75
  result: dict[str, Any] = cls.__annotations__
74
76
  _ = {Literal, Path, Sentinel, StrMapping, UUID, dt}
75
- globalns = globals() | ({} if globalns is None else dict(globalns))
76
- localns = {} if localns is None else dict(localns)
77
+ globalns_use = globals() | ({} if globalns is None else dict(globalns))
78
+ localns_use = {} if localns is None else dict(localns)
77
79
  try:
78
- hints = _get_type_hints(cls, globalns=globalns, localns=localns)
79
- except NameError:
80
- pass
80
+ hints = _get_type_hints(cls, globalns=globalns_use, localns=localns_use)
81
+ except NameError as error:
82
+ if warn_name_errors:
83
+ warn(f"Error getting type hints for {cls!r}; {error}", stacklevel=2)
81
84
  else:
82
85
  result.update({
83
86
  key: value