dycw-utilities 0.109.23__py3-none-any.whl → 0.109.25__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.23
3
+ Version: 0.109.25
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,17 +1,17 @@
1
- utilities/__init__.py,sha256=4lEn-RM4k4cprCQpv11-dCWVShqTEywTV3oeqqUTdtc,61
1
+ utilities/__init__.py,sha256=MyGkR0d3_-KyEaReHVw4HUIouPnP_C4LJeJMaEoyS9g,61
2
2
  utilities/altair.py,sha256=Gpja-flOo-Db0PIPJLJsgzAlXWoKUjPU1qY-DQ829ek,9156
3
3
  utilities/astor.py,sha256=xuDUkjq0-b6fhtwjhbnebzbqQZAjMSHR1IIS5uOodVg,777
4
4
  utilities/asyncio.py,sha256=41oQUurWMvadFK5gFnaG21hMM0Vmfn2WS6OpC0R9mas,14757
5
5
  utilities/atomicwrites.py,sha256=geFjn9Pwn-tTrtoGjDDxWli9NqbYfy3gGL6ZBctiqSo,5393
6
6
  utilities/atools.py,sha256=IYMuFSFGSKyuQmqD6v5IUtDlz8PPw0Sr87Cub_gRU3M,1168
7
7
  utilities/cachetools.py,sha256=C1zqOg7BYz0IfQFK8e3qaDDgEZxDpo47F15RTfJM37Q,2910
8
- utilities/click.py,sha256=JX-A6WnUyE84WYz8OK4MSpUdBg8bA_KiWkqd491WCFQ,15256
8
+ utilities/click.py,sha256=lIkJIp_3vaoqsvADI4azoLgEWqlUHVRWBQePLbPrcPo,14243
9
9
  utilities/concurrent.py,sha256=s2scTEd2AhXVTW4hpASU2qxV_DiVLALfms55cCQzCvM,2886
10
10
  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=rQS-G-JwhQNufV80aThSOD_ENliueqkz4N6NqtTTPN8,23294
14
+ utilities/dataclasses.py,sha256=8-38WHrScAvElBNvFxBnhJwab1XXkSXpDOiNPOAvh2Q,23295
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
@@ -73,11 +73,11 @@ utilities/streamlit.py,sha256=U9PJBaKP1IdSykKhPZhIzSPTZsmLsnwbEPZWzNhJPKk,2955
73
73
  utilities/sys.py,sha256=h0Xr7Vj86wNalvwJVP1wj5Y0kD_VWm1vzuXZ_jw94mE,2743
74
74
  utilities/tempfile.py,sha256=VqmZJAhTJ1OaVywFzk5eqROV8iJbW9XQ_QYAV0bpdRo,1384
75
75
  utilities/tenacity.py,sha256=1PUvODiBVgeqIh7G5TRt5WWMSqjLYkEqP53itT97WQc,4914
76
- utilities/text.py,sha256=X_EjRQeV_PsG3oP7OiGYIyXGKWqciTnSwoKhM2tsy6M,3120
76
+ utilities/text.py,sha256=L62EkdQJtatFpiXwIi0w6Z3js14RmDAkboUnhIZBxMg,6125
77
77
  utilities/threading.py,sha256=GvBOp4CyhHfN90wGXZuA2VKe9fGzMaEa7oCl4f3nnPU,1009
78
78
  utilities/timer.py,sha256=Rkc49KSpHuC8s7vUxGO9DU55U9I6yDKnchsQqrUCVBs,4075
79
79
  utilities/traceback.py,sha256=KwHPLdEbdj0fFhXo8MBfxcvem8A-VXYDwFMNJ6f0cTM,27328
80
- utilities/types.py,sha256=fkaqL67A_1p0-Q88JTu1dt_GV3mVo5mSslYa9DGWNq4,17903
80
+ utilities/types.py,sha256=z1hbBOT5TkzTn2JOvSldw6DScxi3erG9qpJ3xci66GI,17963
81
81
  utilities/typing.py,sha256=gLg4EbE1FX52fJ1d3ji4i08qolwu9qgWt8w_w_Y5DTk,5512
82
82
  utilities/tzdata.py,sha256=2ZsPmhTVM9Ptrxb4QrWKtKOB9RiH8IOO-A1u7ULdVbg,176
83
83
  utilities/tzlocal.py,sha256=42BCquGF54oIqIKe5RGziP4K8Nbm3Ey7uqcNn6m5ge8,534
@@ -87,7 +87,7 @@ utilities/warnings.py,sha256=yUgjnmkCRf6QhdyAXzl7u0qQFejhQG3PrjoSwxpbHrs,1819
87
87
  utilities/whenever.py,sha256=TjoTAJ1R27-rKXiXzdE4GzPidmYqm0W58XydDXp-QZM,17786
88
88
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
89
89
  utilities/zoneinfo.py,sha256=-DQz5a0Ikw9jfSZtL0BEQkXOMC9yGn_xiJYNCLMiqEc,1989
90
- dycw_utilities-0.109.23.dist-info/METADATA,sha256=fMol_EYcAIARY4nGcAjYEe1AXs-5dAorn3vGy27XdBY,13005
91
- dycw_utilities-0.109.23.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
92
- dycw_utilities-0.109.23.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
93
- dycw_utilities-0.109.23.dist-info/RECORD,,
90
+ dycw_utilities-0.109.25.dist-info/METADATA,sha256=i19O3DlnQ5ld8LT4devIhPYkO0WWg_MmVzpe4nIqICw,13005
91
+ dycw_utilities-0.109.25.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
92
+ dycw_utilities-0.109.25.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
93
+ dycw_utilities-0.109.25.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.109.23"
3
+ __version__ = "0.109.25"
utilities/click.py CHANGED
@@ -21,7 +21,6 @@ from utilities.datetime import EnsureMonthError, MonthLike, ensure_month
21
21
  from utilities.enum import EnsureEnumError, ensure_enum
22
22
  from utilities.functions import EnsureStrError, ensure_str, get_class_name
23
23
  from utilities.iterables import is_iterable_not_str
24
- from utilities.sentinel import SENTINEL_REPR
25
24
  from utilities.text import split_str
26
25
  from utilities.types import (
27
26
  DateLike,
@@ -245,13 +244,10 @@ class ZonedDateTime(ParamType):
245
244
  class FrozenSetParameter(ParamType, Generic[_TParam, _T]):
246
245
  """A frozenset-valued parameter."""
247
246
 
248
- def __init__(
249
- self, param: _TParam, /, *, separator: str = ",", empty: str = SENTINEL_REPR
250
- ) -> None:
247
+ def __init__(self, param: _TParam, /, *, separator: str = ",") -> None:
251
248
  self.name = f"FROZENSET[{param.name}]"
252
249
  self._param = param
253
250
  self._separator = separator
254
- self._empty = empty
255
251
  super().__init__()
256
252
 
257
253
  @override
@@ -273,7 +269,7 @@ class FrozenSetParameter(ParamType, Generic[_TParam, _T]):
273
269
  text = ensure_str(value)
274
270
  except EnsureStrError as error:
275
271
  return self.fail(str(error), param=param, ctx=ctx)
276
- values = split_str(text, separator=self._separator, empty=self._empty)
272
+ values = split_str(text, separator=self._separator)
277
273
  return frozenset(self._param.convert(v, param, ctx) for v in values)
278
274
 
279
275
  @override
@@ -290,15 +286,15 @@ class FrozenSetParameter(ParamType, Generic[_TParam, _T]):
290
286
  class FrozenSetBools(FrozenSetParameter[BoolParamType, str]):
291
287
  """A frozenset-of-bools-valued parameter."""
292
288
 
293
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
294
- super().__init__(BoolParamType(), separator=separator, empty=empty)
289
+ def __init__(self, *, separator: str = ",") -> None:
290
+ super().__init__(BoolParamType(), separator=separator)
295
291
 
296
292
 
297
293
  class FrozenSetDates(FrozenSetParameter[Date, dt.date]):
298
294
  """A frozenset-of-dates-valued parameter."""
299
295
 
300
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
301
- super().__init__(Date(), separator=separator, empty=empty)
296
+ def __init__(self, *, separator: str = ",") -> None:
297
+ super().__init__(Date(), separator=separator)
302
298
 
303
299
 
304
300
  class FrozenSetChoices(FrozenSetParameter[Choice, str]):
@@ -311,12 +307,9 @@ class FrozenSetChoices(FrozenSetParameter[Choice, str]):
311
307
  *,
312
308
  case_sensitive: bool = False,
313
309
  separator: str = ",",
314
- empty: str = SENTINEL_REPR,
315
310
  ) -> None:
316
311
  super().__init__(
317
- Choice(choices, case_sensitive=case_sensitive),
318
- separator=separator,
319
- empty=empty,
312
+ Choice(choices, case_sensitive=case_sensitive), separator=separator
320
313
  )
321
314
 
322
315
 
@@ -330,46 +323,43 @@ class FrozenSetEnums(FrozenSetParameter[Enum[TEnum], TEnum]):
330
323
  *,
331
324
  case_sensitive: bool = False,
332
325
  separator: str = ",",
333
- empty: str = SENTINEL_REPR,
334
326
  ) -> None:
335
- super().__init__(
336
- Enum(enum, case_sensitive=case_sensitive), separator=separator, empty=empty
337
- )
327
+ super().__init__(Enum(enum, case_sensitive=case_sensitive), separator=separator)
338
328
 
339
329
 
340
330
  class FrozenSetFloats(FrozenSetParameter[FloatParamType, float]):
341
331
  """A frozenset-of-floats-valued parameter."""
342
332
 
343
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
344
- super().__init__(FloatParamType(), separator=separator, empty=empty)
333
+ def __init__(self, *, separator: str = ",") -> None:
334
+ super().__init__(FloatParamType(), separator=separator)
345
335
 
346
336
 
347
337
  class FrozenSetInts(FrozenSetParameter[IntParamType, int]):
348
338
  """A frozenset-of-ints-valued parameter."""
349
339
 
350
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
351
- super().__init__(IntParamType(), separator=separator, empty=empty)
340
+ def __init__(self, *, separator: str = ",") -> None:
341
+ super().__init__(IntParamType(), separator=separator)
352
342
 
353
343
 
354
344
  class FrozenSetMonths(FrozenSetParameter[Month, utilities.datetime.Month]):
355
345
  """A frozenset-of-months-valued parameter."""
356
346
 
357
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
358
- super().__init__(Month(), separator=separator, empty=empty)
347
+ def __init__(self, *, separator: str = ",") -> None:
348
+ super().__init__(Month(), separator=separator)
359
349
 
360
350
 
361
351
  class FrozenSetStrs(FrozenSetParameter[StringParamType, str]):
362
352
  """A frozenset-of-strs-valued parameter."""
363
353
 
364
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
365
- super().__init__(StringParamType(), separator=separator, empty=empty)
354
+ def __init__(self, *, separator: str = ",") -> None:
355
+ super().__init__(StringParamType(), separator=separator)
366
356
 
367
357
 
368
358
  class FrozenSetUUIDs(FrozenSetParameter[UUIDParameterType, UUID]):
369
359
  """A frozenset-of-UUIDs-valued parameter."""
370
360
 
371
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
372
- super().__init__(UUIDParameterType(), separator=separator, empty=empty)
361
+ def __init__(self, *, separator: str = ",") -> None:
362
+ super().__init__(UUIDParameterType(), separator=separator)
373
363
 
374
364
 
375
365
  # parameters - list
@@ -378,13 +368,10 @@ class FrozenSetUUIDs(FrozenSetParameter[UUIDParameterType, UUID]):
378
368
  class ListParameter(ParamType, Generic[_TParam, _T]):
379
369
  """A list-valued parameter."""
380
370
 
381
- def __init__(
382
- self, param: _TParam, /, *, separator: str = ",", empty: str = SENTINEL_REPR
383
- ) -> None:
371
+ def __init__(self, param: _TParam, /, *, separator: str = ",") -> None:
384
372
  self.name = f"LIST[{param.name}]"
385
373
  self._param = param
386
374
  self._separator = separator
387
- self._empty = empty
388
375
  super().__init__()
389
376
 
390
377
  @override
@@ -406,7 +393,7 @@ class ListParameter(ParamType, Generic[_TParam, _T]):
406
393
  text = ensure_str(value)
407
394
  except EnsureStrError as error:
408
395
  return self.fail(str(error), param=param, ctx=ctx)
409
- values = split_str(text, separator=self._separator, empty=self._empty)
396
+ values = split_str(text, separator=self._separator)
410
397
  return [self._param.convert(v, param, ctx) for v in values]
411
398
 
412
399
  @override
@@ -423,15 +410,15 @@ class ListParameter(ParamType, Generic[_TParam, _T]):
423
410
  class ListBools(ListParameter[BoolParamType, str]):
424
411
  """A list-of-bools-valued parameter."""
425
412
 
426
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
427
- super().__init__(BoolParamType(), separator=separator, empty=empty)
413
+ def __init__(self, *, separator: str = ",") -> None:
414
+ super().__init__(BoolParamType(), separator=separator)
428
415
 
429
416
 
430
417
  class ListDates(ListParameter[Date, dt.date]):
431
418
  """A list-of-dates-valued parameter."""
432
419
 
433
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
434
- super().__init__(Date(), separator=separator, empty=empty)
420
+ def __init__(self, *, separator: str = ",") -> None:
421
+ super().__init__(Date(), separator=separator)
435
422
 
436
423
 
437
424
  class ListEnums(ListParameter[Enum[TEnum], TEnum]):
@@ -444,46 +431,43 @@ class ListEnums(ListParameter[Enum[TEnum], TEnum]):
444
431
  *,
445
432
  case_sensitive: bool = False,
446
433
  separator: str = ",",
447
- empty: str = SENTINEL_REPR,
448
434
  ) -> None:
449
- super().__init__(
450
- Enum(enum, case_sensitive=case_sensitive), separator=separator, empty=empty
451
- )
435
+ super().__init__(Enum(enum, case_sensitive=case_sensitive), separator=separator)
452
436
 
453
437
 
454
438
  class ListFloats(ListParameter[FloatParamType, float]):
455
439
  """A list-of-floats-valued parameter."""
456
440
 
457
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
458
- super().__init__(FloatParamType(), separator=separator, empty=empty)
441
+ def __init__(self, *, separator: str = ",") -> None:
442
+ super().__init__(FloatParamType(), separator=separator)
459
443
 
460
444
 
461
445
  class ListInts(ListParameter[IntParamType, int]):
462
446
  """A list-of-ints-valued parameter."""
463
447
 
464
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
465
- super().__init__(IntParamType(), separator=separator, empty=empty)
448
+ def __init__(self, *, separator: str = ",") -> None:
449
+ super().__init__(IntParamType(), separator=separator)
466
450
 
467
451
 
468
452
  class ListMonths(ListParameter[Month, utilities.datetime.Month]):
469
453
  """A list-of-months-valued parameter."""
470
454
 
471
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
472
- super().__init__(Month(), separator=separator, empty=empty)
455
+ def __init__(self, *, separator: str = ",") -> None:
456
+ super().__init__(Month(), separator=separator)
473
457
 
474
458
 
475
459
  class ListStrs(ListParameter[StringParamType, str]):
476
460
  """A list-of-strs-valued parameter."""
477
461
 
478
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
479
- super().__init__(StringParamType(), separator=separator, empty=empty)
462
+ def __init__(self, *, separator: str = ",") -> None:
463
+ super().__init__(StringParamType(), separator=separator)
480
464
 
481
465
 
482
466
  class ListUUIDs(ListParameter[UUIDParameterType, UUID]):
483
467
  """A list-of-UUIDs-valued parameter."""
484
468
 
485
- def __init__(self, *, separator: str = ",", empty: str = SENTINEL_REPR) -> None:
486
- super().__init__(UUIDParameterType(), separator=separator, empty=empty)
469
+ def __init__(self, *, separator: str = ",") -> None:
470
+ super().__init__(UUIDParameterType(), separator=separator)
487
471
 
488
472
 
489
473
  # private
utilities/dataclasses.py CHANGED
@@ -24,7 +24,7 @@ from utilities.iterables import OneStrEmptyError, OneStrNonUniqueError, one_str
24
24
  from utilities.operator import is_equal
25
25
  from utilities.parse import ParseTextError, parse_text
26
26
  from utilities.sentinel import Sentinel, sentinel
27
- from utilities.types import ParseTextExtra, TDataclass
27
+ from utilities.types import ParseTextExtra, StrStrMapping, TDataclass
28
28
  from utilities.typing import get_type_hints
29
29
 
30
30
  if TYPE_CHECKING:
@@ -444,7 +444,7 @@ class StrMappingToFieldMappingError(Exception):
444
444
 
445
445
 
446
446
  def text_to_dataclass(
447
- text_or_mapping: str | Mapping[str, str],
447
+ text_or_mapping: str | StrStrMapping,
448
448
  cls: type[TDataclass],
449
449
  /,
450
450
  *,
@@ -499,9 +499,7 @@ def text_to_dataclass(
499
499
  )
500
500
 
501
501
 
502
- def _text_to_dataclass_split_text(
503
- text: str, cls: type[TDataclass], /
504
- ) -> Mapping[str, str]:
502
+ def _text_to_dataclass_split_text(text: str, cls: type[TDataclass], /) -> StrStrMapping:
505
503
  pairs = (t for t in text.split(",") if t != "")
506
504
  return dict(_text_to_dataclass_split_key_value_pair(pair, cls) for pair in pairs)
507
505
 
utilities/text.py CHANGED
@@ -4,30 +4,15 @@ import re
4
4
  from dataclasses import dataclass
5
5
  from re import IGNORECASE, Match, search
6
6
  from textwrap import dedent
7
- from typing import TYPE_CHECKING, Any, override
7
+ from typing import TYPE_CHECKING, Any, Literal, overload, override
8
8
 
9
- from utilities.sentinel import SENTINEL_REPR
9
+ from utilities.iterables import CheckDuplicatesError, check_duplicates
10
+ from utilities.reprlib import get_repr
10
11
 
11
12
  if TYPE_CHECKING:
12
- from collections.abc import Iterable
13
+ from collections.abc import Iterable, Mapping, Sequence
13
14
 
14
-
15
- def join_strs(
16
- texts: Iterable[str],
17
- /,
18
- *,
19
- sort: bool = False,
20
- separator: str = ",",
21
- empty: str = SENTINEL_REPR,
22
- ) -> str:
23
- """Join a collection of strings, with a special provision for the empty list."""
24
- texts = sorted(texts) if sort else list(texts)
25
- if len(texts) >= 1:
26
- return separator.join(texts)
27
- return empty
28
-
29
-
30
- ##
15
+ from utilities.types import StrStrMapping
31
16
 
32
17
 
33
18
  def parse_bool(text: str, /) -> bool:
@@ -101,11 +86,134 @@ def _snake_case_title(match: Match[str], /) -> str:
101
86
  ##
102
87
 
103
88
 
89
+ @overload
90
+ def split_key_value_pairs(
91
+ text: str,
92
+ /,
93
+ *,
94
+ list_separator: str = ",",
95
+ pair_separator: str = "=",
96
+ mapping: Literal[True],
97
+ ) -> StrStrMapping: ...
98
+ @overload
99
+ def split_key_value_pairs(
100
+ text: str,
101
+ /,
102
+ *,
103
+ list_separator: str = ",",
104
+ pair_separator: str = "=",
105
+ mapping: Literal[False] = False,
106
+ ) -> Sequence[tuple[str, str]]: ...
107
+ @overload
108
+ def split_key_value_pairs(
109
+ text: str,
110
+ /,
111
+ *,
112
+ list_separator: str = ",",
113
+ pair_separator: str = "=",
114
+ mapping: bool = False,
115
+ ) -> Sequence[tuple[str, str]] | StrStrMapping: ...
116
+ def split_key_value_pairs(
117
+ text: str,
118
+ /,
119
+ *,
120
+ list_separator: str = ",",
121
+ pair_separator: str = "=",
122
+ mapping: bool = False,
123
+ ) -> Sequence[tuple[str, str]] | StrStrMapping:
124
+ """Split a string into key-value pairs."""
125
+ pairs = [
126
+ split_str(text_i, separator=pair_separator, n=2)
127
+ for text_i in split_str(text, separator=list_separator)
128
+ ]
129
+ if not mapping:
130
+ return pairs
131
+ try:
132
+ check_duplicates(k for k, _ in pairs)
133
+ except CheckDuplicatesError as error:
134
+ raise SplitKeyValuePairsError(text=text, counts=error.counts) from None
135
+ return dict(pairs)
136
+
137
+
138
+ @dataclass(kw_only=True, slots=True)
139
+ class SplitKeyValuePairsError(Exception):
140
+ text: str
141
+ counts: Mapping[str, int]
142
+
143
+ @override
144
+ def __str__(self) -> str:
145
+ return f"Unable to split {self.text!r} into a mapping since there are duplicate keys; got {get_repr(self.counts)}"
146
+
147
+
148
+ ##
149
+
150
+
151
+ @overload
152
+ def split_str(text: str, /, *, separator: str = ",", n: Literal[1]) -> tuple[str]: ...
153
+ @overload
154
+ def split_str(
155
+ text: str, /, *, separator: str = ",", n: Literal[2]
156
+ ) -> tuple[str, str]: ...
157
+ @overload
104
158
  def split_str(
105
- text: str, /, *, separator: str = ",", empty: str = SENTINEL_REPR
106
- ) -> list[str]:
159
+ text: str, /, *, separator: str = ",", n: Literal[3]
160
+ ) -> tuple[str, str, str]: ...
161
+ @overload
162
+ def split_str(
163
+ text: str, /, *, separator: str = ",", n: Literal[4]
164
+ ) -> tuple[str, str, str, str]: ...
165
+ @overload
166
+ def split_str(
167
+ text: str, /, *, separator: str = ",", n: Literal[5]
168
+ ) -> tuple[str, str, str, str, str]: ...
169
+ @overload
170
+ def split_str(
171
+ text: str, /, *, separator: str = ",", n: int | None = None
172
+ ) -> Sequence[str]: ...
173
+ def split_str(
174
+ text: str, /, *, separator: str = ",", n: int | None = None
175
+ ) -> Sequence[str]:
107
176
  """Split a string, with a special provision for the empty string."""
108
- return [] if text == empty else text.split(separator)
177
+ if text == "":
178
+ texts = []
179
+ elif text == _escape_separator(separator=separator):
180
+ texts = [""]
181
+ else:
182
+ texts = text.split(separator)
183
+ if n is None:
184
+ return texts
185
+ if len(texts) != n:
186
+ raise SplitStrError(text=text, n=n, texts=texts)
187
+ return tuple(texts)
188
+
189
+
190
+ @dataclass(kw_only=True, slots=True)
191
+ class SplitStrError(Exception):
192
+ text: str
193
+ n: int
194
+ texts: Sequence[str]
195
+
196
+ @override
197
+ def __str__(self) -> str:
198
+ return f"Unable to split {self.text!r} into {self.n} part(s); got {len(self.texts)}"
199
+
200
+
201
+ def join_strs(
202
+ texts: Iterable[str], /, *, sort: bool = False, separator: str = ","
203
+ ) -> str:
204
+ """Join a collection of strings, with a special provision for the empty list."""
205
+ texts = list(texts)
206
+ if sort:
207
+ texts = sorted(texts)
208
+ if texts == []:
209
+ return ""
210
+ if texts == [""]:
211
+ return _escape_separator(separator=separator)
212
+ return separator.join(texts)
213
+
214
+
215
+ def _escape_separator(*, separator: str = ",") -> str:
216
+ return f"\\{separator}"
109
217
 
110
218
 
111
219
  ##
@@ -128,11 +236,13 @@ def strip_and_dedent(text: str, /, *, trailing: bool = False) -> str:
128
236
  __all__ = [
129
237
  "ParseBoolError",
130
238
  "ParseNoneError",
239
+ "SplitStrError",
131
240
  "join_strs",
132
241
  "parse_bool",
133
242
  "parse_none",
134
243
  "repr_encode",
135
244
  "snake_case",
245
+ "split_key_value_pairs",
136
246
  "split_str",
137
247
  "str_encode",
138
248
  "strip_and_dedent",
utilities/types.py CHANGED
@@ -46,6 +46,7 @@ type OpenMode = Literal[
46
46
  "a+b",
47
47
  ]
48
48
  type StrMapping = Mapping[str, Any]
49
+ type StrStrMapping = Mapping[str, str]
49
50
  type TupleOrStrMapping = tuple[Any, ...] | StrMapping
50
51
  type MaybeCallable[_T] = _T | Callable[[], _T]
51
52
  type MaybeStr[_T] = _T | str
@@ -288,6 +289,7 @@ __all__ = [
288
289
  "RoundMode",
289
290
  "Seed",
290
291
  "StrMapping",
292
+ "StrStrMapping",
291
293
  "SupportsAbs",
292
294
  "SupportsAdd",
293
295
  "SupportsBytes",