dycw-utilities 0.109.0__py3-none-any.whl → 0.109.1__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.1
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=hXFp40lgEbzHJocIbjY5_v_hKGMzhE6Os9g06SFuZME,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=12Cxq0ZXFuc4lPD6Y92Ny-b25C0EnkAZ3Hz59tetcbQ,21709
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
@@ -53,7 +53,7 @@ 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
55
  utilities/pytest_regressions.py,sha256=Kp1NS_cyXvBFqyiF_oSzYmSJzIOdAZ0SFcSGmbL_UtI,5001
56
- utilities/python_dotenv.py,sha256=7N4ZbBxXpPNttOTfg-hpaLFFWA5iJwF7tREBzUnbPOM,3415
56
+ utilities/python_dotenv.py,sha256=-d2bQ3Ayyv9JUK59k6F3-mTzQmb2SV0HzqG9fpsD8C8,2976
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
@@ -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.1.dist-info/METADATA,sha256=dCcv9ZcxodWQ1Ze4u30ZUglTEC7NipHnFMOMN9zv6Oc,13004
88
+ dycw_utilities-0.109.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
89
+ dycw_utilities-0.109.1.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
90
+ dycw_utilities-0.109.1.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.1"
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
@@ -186,76 +186,140 @@ def mapping_to_dataclass(
186
186
  mapping: StrMapping,
187
187
  /,
188
188
  *,
189
+ fields: Iterable[_YieldFieldsClass[Any]] | None = None,
189
190
  globalns: StrMapping | None = None,
190
191
  localns: StrMapping | None = None,
192
+ head: bool = False,
191
193
  case_sensitive: bool = False,
192
- post: Callable[[_YieldFieldsClass[Any], Any], Any] | None = None,
194
+ allow_extra: bool = False,
193
195
  ) -> TDataclass:
194
196
  """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
199
- )
200
- for f in fields
197
+ if fields is None:
198
+ fields_use = list(yield_fields(cls, globalns=globalns, localns=localns))
199
+ else:
200
+ fields_use = fields
201
+ fields_to_values = str_mapping_to_field_mapping(
202
+ cls,
203
+ mapping,
204
+ fields=fields_use,
205
+ globalns=globalns,
206
+ localns=localns,
207
+ head=head,
208
+ case_sensitive=case_sensitive,
209
+ allow_extra=allow_extra,
210
+ )
211
+ field_names_to_values = {f.name: v for f, v in fields_to_values.items()}
212
+ default = {
213
+ f.name
214
+ for f in fields_use
215
+ if (not isinstance(f.default, Sentinel))
216
+ or (not isinstance(f.default_factory, Sentinel))
201
217
  }
202
- return cls(**mapping_use)
218
+ have = set(field_names_to_values) | default
219
+ missing = {f.name for f in fields_use} - have
220
+ if len(missing) >= 1:
221
+ raise MappingToDataclassError(cls=cls, fields=missing)
222
+ return cls(**field_names_to_values)
203
223
 
204
224
 
205
- def _mapping_to_dataclass_one(
206
- field: _YieldFieldsClass[Any],
207
- mapping: StrMapping,
225
+ @dataclass(kw_only=True, slots=True)
226
+ class MappingToDataclassError(Exception, Generic[TDataclass]):
227
+ cls: type[TDataclass]
228
+ fields: AbstractSet[str]
229
+
230
+ @override
231
+ def __str__(self) -> str:
232
+ desc = ", ".join(map(repr, sorted(self.fields)))
233
+ return f"Unable to construct {get_class_name(self.cls)!r}; missing values for {desc}"
234
+
235
+
236
+ ##
237
+
238
+
239
+ def one_field(
240
+ cls: type[Dataclass],
241
+ key: str,
208
242
  /,
209
243
  *,
244
+ fields: Iterable[_YieldFieldsClass[Any]] | None = None,
245
+ globalns: StrMapping | None = None,
246
+ localns: StrMapping | None = None,
247
+ head: bool = False,
210
248
  case_sensitive: bool = False,
211
- post: Callable[[_YieldFieldsClass[Any], Any], Any] | None = None,
212
- ) -> Any:
249
+ ) -> _YieldFieldsClass[Any]:
250
+ """Get the unique field a key matches to."""
251
+ if fields is None:
252
+ fields_use = list(yield_fields(cls, globalns=globalns, localns=localns))
253
+ else:
254
+ fields_use = fields
255
+ mapping = {f.name: f for f in fields_use}
213
256
  try:
214
- key = one_str(mapping, field.name, case_sensitive=case_sensitive)
257
+ name = one_str(mapping, key, head=head, case_sensitive=case_sensitive)
215
258
  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
259
+ raise OneFieldEmptyError(
260
+ cls=cls, key=key, head=head, case_sensitive=case_sensitive
261
+ ) from None
224
262
  except OneStrNonUniqueError as error:
225
- raise _MappingToDataclassCaseInsensitiveNonUniqueError(
226
- mapping=mapping, field=field.name, first=error.first, second=error.second
263
+ raise OneFieldNonUniqueError(
264
+ cls=cls,
265
+ key=key,
266
+ head=head,
267
+ case_sensitive=case_sensitive,
268
+ first=error.first,
269
+ second=error.second,
227
270
  ) from None
228
- else:
229
- value = mapping[key]
230
- if post is not None:
231
- value = post(field, value)
232
- return value
271
+ return mapping[name]
233
272
 
234
273
 
235
274
  @dataclass(kw_only=True, slots=True)
236
- class MappingToDataclassError(Exception):
237
- mapping: StrMapping
238
- field: str
275
+ class OneFieldError(Exception, Generic[TDataclass]):
276
+ cls: type[TDataclass]
277
+ key: str
278
+ head: bool = False
279
+ case_sensitive: bool = False
239
280
 
240
281
 
241
282
  @dataclass(kw_only=True, slots=True)
242
- class _MappingToDataclassEmptyError(MappingToDataclassError):
243
- case_sensitive: bool = False
244
-
283
+ class OneFieldEmptyError(OneFieldError[TDataclass]):
245
284
  @override
246
285
  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)"
286
+ head = f"Dataclass {get_class_name(self.cls)!r} does not contain"
287
+ match self.head, self.case_sensitive:
288
+ case False, True:
289
+ tail = f"a field {self.key!r}"
290
+ case False, False:
291
+ tail = f"a field {self.key!r} (modulo case)"
292
+ case True, True:
293
+ tail = f"any field starting with {self.key!r}"
294
+ case True, False:
295
+ tail = f"any field starting with {self.key!r} (modulo case)"
296
+ case _ as never:
297
+ assert_never(never)
298
+ return f"{head} {tail}"
249
299
 
250
300
 
251
301
  @dataclass(kw_only=True, slots=True)
252
- class _MappingToDataclassCaseInsensitiveNonUniqueError(MappingToDataclassError):
302
+ class OneFieldNonUniqueError(OneFieldError[TDataclass]):
253
303
  first: str
254
304
  second: str
255
305
 
256
306
  @override
257
307
  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"
308
+ head = f"Dataclass {get_class_name(self.cls)!r} must contain"
309
+ match self.head, self.case_sensitive:
310
+ case False, True:
311
+ raise ImpossibleCaseError( # pragma: no cover
312
+ case=[f"{self.head=}", f"{self.case_sensitive=}"]
313
+ )
314
+ case False, False:
315
+ mid = f"field {self.key!r} exactly once (modulo case)"
316
+ case True, True:
317
+ mid = f"exactly one field starting with {self.key!r}"
318
+ case True, False:
319
+ mid = f"exactly one field starting with {self.key!r} (modulo case)"
320
+ case _ as never:
321
+ assert_never(never)
322
+ return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more"
259
323
 
260
324
 
261
325
  ##
@@ -290,6 +354,66 @@ def replace_non_sentinel(
290
354
  ##
291
355
 
292
356
 
357
+ def str_mapping_to_field_mapping(
358
+ cls: type[TDataclass],
359
+ mapping: Mapping[str, _T],
360
+ /,
361
+ *,
362
+ fields: Iterable[_YieldFieldsClass[Any]] | None = None,
363
+ globalns: StrMapping | None = None,
364
+ localns: StrMapping | None = None,
365
+ head: bool = False,
366
+ case_sensitive: bool = False,
367
+ allow_extra: bool = False,
368
+ ) -> Mapping[_YieldFieldsClass[Any], _T]:
369
+ """Convert a string-mapping into a field-mapping."""
370
+ keys_to_fields: Mapping[str, _YieldFieldsClass[Any]] = {}
371
+ for key in mapping:
372
+ try:
373
+ keys_to_fields[key] = one_field(
374
+ cls,
375
+ key,
376
+ fields=fields,
377
+ globalns=globalns,
378
+ localns=localns,
379
+ head=head,
380
+ case_sensitive=case_sensitive,
381
+ )
382
+ except OneFieldEmptyError:
383
+ if not allow_extra:
384
+ raise StrMappingToFieldMappingError(
385
+ cls=cls, key=key, head=head, case_sensitive=case_sensitive
386
+ ) from None
387
+ return {field: mapping[key] for key, field in keys_to_fields.items()}
388
+
389
+
390
+ @dataclass(kw_only=True, slots=True)
391
+ class StrMappingToFieldMappingError(Exception):
392
+ cls: type[Dataclass]
393
+ key: str
394
+ head: bool = False
395
+ case_sensitive: bool = False
396
+
397
+ @override
398
+ def __str__(self) -> str:
399
+ head = f"Dataclass {get_class_name(self.cls)!r} does not contain"
400
+ match self.head, self.case_sensitive:
401
+ case False, True:
402
+ tail = f"a field {self.key!r}"
403
+ case False, False:
404
+ tail = f"a field {self.key!r} (modulo case)"
405
+ case True, True:
406
+ tail = f"any field starting with {self.key!r}"
407
+ case True, False:
408
+ tail = f"any field starting with {self.key!r} (modulo case)"
409
+ case _ as never:
410
+ assert_never(never)
411
+ return f"{head} {tail}"
412
+
413
+
414
+ ##
415
+
416
+
293
417
  def text_to_dataclass(
294
418
  text_or_mapping: str | Mapping[str, str],
295
419
  cls: type[TDataclass],
@@ -297,29 +421,42 @@ def text_to_dataclass(
297
421
  *,
298
422
  globalns: StrMapping | None = None,
299
423
  localns: StrMapping | None = None,
424
+ head: bool = False,
300
425
  case_sensitive: bool = False,
426
+ allow_extra: bool = False,
301
427
  ) -> TDataclass:
302
428
  """Construct a dataclass from a string or a mapping or strings."""
303
- fields = list(yield_fields(cls, globalns=globalns, localns=localns))
304
429
  match text_or_mapping:
305
430
  case str() as text:
306
- text_mapping = _text_to_dataclass_split_text(text, cls)
307
- case Mapping() as text_mapping:
431
+ keys_to_serializes = _text_to_dataclass_split_text(text, cls)
432
+ case Mapping() as keys_to_serializes:
308
433
  ...
309
434
  case _ as never:
310
435
  assert_never(never)
311
- value_mapping = dict(
312
- _text_to_dataclass_get_and_parse(
313
- fields, key, value, cls, case_sensitive=case_sensitive
314
- )
315
- for key, value in text_mapping.items()
436
+ fields = list(yield_fields(cls, globalns=globalns, localns=localns))
437
+ fields_to_serializes = str_mapping_to_field_mapping(
438
+ cls,
439
+ keys_to_serializes,
440
+ fields=fields,
441
+ globalns=globalns,
442
+ localns=localns,
443
+ head=head,
444
+ case_sensitive=case_sensitive,
445
+ allow_extra=allow_extra,
316
446
  )
447
+ field_names_to_values = {
448
+ f.name: _text_to_dataclass_parse(f, t, cls, case_sensitive=case_sensitive)
449
+ for f, t in fields_to_serializes.items()
450
+ }
317
451
  return mapping_to_dataclass(
318
452
  cls,
319
- value_mapping,
453
+ field_names_to_values,
454
+ fields=fields,
320
455
  globalns=globalns,
321
456
  localns=localns,
457
+ head=head,
322
458
  case_sensitive=case_sensitive,
459
+ allow_extra=allow_extra,
323
460
  )
324
461
 
325
462
 
@@ -340,38 +477,18 @@ def _text_to_dataclass_split_key_value_pair(
340
477
  return key, value
341
478
 
342
479
 
343
- def _text_to_dataclass_get_and_parse(
344
- fields: Iterable[_YieldFieldsClass[Any]],
345
- key: str,
346
- value: str,
480
+ def _text_to_dataclass_parse(
481
+ field: _YieldFieldsClass[Any],
482
+ text: str,
347
483
  cls: type[Dataclass],
348
484
  /,
349
485
  *,
350
486
  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]
487
+ ) -> Any:
368
488
  try:
369
- parsed = parse_text(field.type_, value, case_sensitive=case_sensitive)
489
+ return parse_text(field.type_, text, case_sensitive=case_sensitive)
370
490
  except ParseTextError:
371
- raise _TextToDataClassParseValueError(
372
- cls=cls, field=field, text=value
373
- ) from None
374
- return key, parsed
491
+ raise _TextToDataClassParseValueError(cls=cls, field=field, text=text) from None
375
492
 
376
493
 
377
494
  @dataclass(kw_only=True, slots=True)
@@ -388,31 +505,6 @@ class _TextToDataClassSplitKeyValuePairError(TextToDataClassError):
388
505
  return f"Unable to construct {get_class_name(self.cls)!r}; failed to split key-value pair {self.text!r}"
389
506
 
390
507
 
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
508
  @dataclass(kw_only=True, slots=True)
417
509
  class _TextToDataClassParseValueError(TextToDataClassError[TDataclass]):
418
510
  field: _YieldFieldsClass[Any]
@@ -431,16 +523,16 @@ def yield_fields(
431
523
  obj: Dataclass,
432
524
  /,
433
525
  *,
434
- globalns: StrMapping | None = ...,
435
- localns: StrMapping | None = ...,
526
+ globalns: StrMapping | None = None,
527
+ localns: StrMapping | None = None,
436
528
  ) -> Iterator[_YieldFieldsInstance[Any]]: ...
437
529
  @overload
438
530
  def yield_fields(
439
531
  obj: type[Dataclass],
440
532
  /,
441
533
  *,
442
- globalns: StrMapping | None = ...,
443
- localns: StrMapping | None = ...,
534
+ globalns: StrMapping | None = None,
535
+ localns: StrMapping | None = None,
444
536
  ) -> Iterator[_YieldFieldsClass[Any]]: ...
445
537
  def yield_fields(
446
538
  obj: Dataclass | type[Dataclass],
@@ -580,12 +672,18 @@ class YieldFieldsError(Exception):
580
672
 
581
673
  __all__ = [
582
674
  "MappingToDataclassError",
675
+ "OneFieldEmptyError",
676
+ "OneFieldError",
677
+ "OneFieldNonUniqueError",
678
+ "StrMappingToFieldMappingError",
583
679
  "TextToDataClassError",
584
680
  "YieldFieldsError",
585
681
  "dataclass_repr",
586
682
  "dataclass_to_dict",
587
683
  "mapping_to_dataclass",
684
+ "one_field",
588
685
  "replace_non_sentinel",
686
+ "str_mapping_to_field_mapping",
589
687
  "text_to_dataclass",
590
688
  "yield_fields",
591
689
  ]
@@ -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,8 @@ def load_settings(
32
27
  cwd: PathLike = PWD,
33
28
  globalns: StrMapping | None = None,
34
29
  localns: StrMapping | None = None,
30
+ head: bool = False,
31
+ case_sensitive: bool = False,
35
32
  ) -> TDataclass:
36
33
  """Load a set of settings from the `.env` file."""
37
34
  path = get_repo_root(cwd=cwd).joinpath(".env")
@@ -39,35 +36,29 @@ def load_settings(
39
36
  raise _LoadSettingsFileNotFoundError(path=path) from None
40
37
  maybe_values_dotenv = dotenv_values(path)
41
38
  try:
42
- maybe_values = merge_str_mappings(maybe_values_dotenv, environ)
39
+ maybe_values: Mapping[str, str | None] = merge_str_mappings(
40
+ maybe_values_dotenv, environ, case_sensitive=case_sensitive
41
+ )
43
42
  except MergeStrMappingsError as error:
44
43
  raise _LoadSettingsDuplicateKeysError(
45
- path=path, values=error.mapping, counts=error.counts
44
+ path=path,
45
+ values=error.mapping,
46
+ counts=error.counts,
47
+ case_sensitive=case_sensitive,
46
48
  ) from None
47
49
  values = {k: v for k, v in maybe_values.items() if v is not None}
48
50
  try:
49
- return mapping_to_dataclass(
50
- cls,
51
+ return text_to_dataclass(
51
52
  values,
53
+ cls,
52
54
  globalns=globalns,
53
55
  localns=localns,
54
- post=partial(_load_settings_post, path=path, values=values),
56
+ head=head,
57
+ case_sensitive=case_sensitive,
58
+ allow_extra=True,
55
59
  )
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
60
+ except MappingToDataclassError as error:
61
+ raise _LoadSettingsMissingKeysError(path=path, fields=error.fields) from None
71
62
 
72
63
 
73
64
  @dataclass(kw_only=True, slots=True)
@@ -79,22 +70,13 @@ class LoadSettingsError(Exception):
79
70
  class _LoadSettingsDuplicateKeysError(LoadSettingsError):
80
71
  values: StrMapping
81
72
  counts: Mapping[str, int]
73
+ case_sensitive: bool = False
82
74
 
83
75
  @override
84
76
  def __str__(self) -> str:
85
77
  return f"Mapping {get_repr(dict(self.values))} keys must not contain duplicates (modulo case); got {get_repr(self.counts)}"
86
78
 
87
79
 
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
80
  @dataclass(kw_only=True, slots=True)
99
81
  class _LoadSettingsFileNotFoundError(LoadSettingsError):
100
82
  @override
@@ -103,14 +85,13 @@ class _LoadSettingsFileNotFoundError(LoadSettingsError):
103
85
 
104
86
 
105
87
  @dataclass(kw_only=True, slots=True)
106
- class _LoadSettingsParseTextError(LoadSettingsError):
107
- values: StrMapping
108
- field: _YieldFieldsClass[Any]
109
- text: str
88
+ class _LoadSettingsMissingKeysError(LoadSettingsError):
89
+ fields: AbstractSet[str]
110
90
 
111
91
  @override
112
92
  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}"
93
+ desc = ", ".join(map(repr, sorted(self.fields)))
94
+ return f"Unable to load {str(self.path)!r}; missing value(s) for {desc}"
114
95
 
115
96
 
116
97
  __all__ = ["LoadSettingsError", "load_settings"]