dycw-utilities 0.135.0__py3-none-any.whl → 0.178.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.

Potentially problematic release.


This version of dycw-utilities might be problematic. Click here for more details.

Files changed (97) hide show
  1. dycw_utilities-0.178.1.dist-info/METADATA +34 -0
  2. dycw_utilities-0.178.1.dist-info/RECORD +105 -0
  3. dycw_utilities-0.178.1.dist-info/WHEEL +4 -0
  4. dycw_utilities-0.178.1.dist-info/entry_points.txt +4 -0
  5. utilities/__init__.py +1 -1
  6. utilities/altair.py +13 -10
  7. utilities/asyncio.py +312 -787
  8. utilities/atomicwrites.py +18 -6
  9. utilities/atools.py +64 -4
  10. utilities/cachetools.py +9 -6
  11. utilities/click.py +195 -77
  12. utilities/concurrent.py +1 -1
  13. utilities/contextlib.py +216 -17
  14. utilities/contextvars.py +20 -1
  15. utilities/cryptography.py +3 -3
  16. utilities/dataclasses.py +15 -28
  17. utilities/docker.py +387 -0
  18. utilities/enum.py +2 -2
  19. utilities/errors.py +17 -3
  20. utilities/fastapi.py +28 -59
  21. utilities/fpdf2.py +2 -2
  22. utilities/functions.py +24 -269
  23. utilities/git.py +9 -30
  24. utilities/grp.py +28 -0
  25. utilities/gzip.py +31 -0
  26. utilities/http.py +3 -2
  27. utilities/hypothesis.py +513 -159
  28. utilities/importlib.py +17 -1
  29. utilities/inflect.py +12 -4
  30. utilities/iterables.py +33 -58
  31. utilities/jinja2.py +148 -0
  32. utilities/json.py +70 -0
  33. utilities/libcst.py +38 -17
  34. utilities/lightweight_charts.py +4 -7
  35. utilities/logging.py +136 -93
  36. utilities/math.py +8 -4
  37. utilities/more_itertools.py +43 -45
  38. utilities/operator.py +27 -27
  39. utilities/orjson.py +189 -36
  40. utilities/os.py +61 -4
  41. utilities/packaging.py +115 -0
  42. utilities/parse.py +8 -5
  43. utilities/pathlib.py +269 -40
  44. utilities/permissions.py +298 -0
  45. utilities/platform.py +7 -6
  46. utilities/polars.py +1205 -413
  47. utilities/polars_ols.py +1 -1
  48. utilities/postgres.py +408 -0
  49. utilities/pottery.py +43 -19
  50. utilities/pqdm.py +3 -3
  51. utilities/psutil.py +5 -57
  52. utilities/pwd.py +28 -0
  53. utilities/pydantic.py +4 -52
  54. utilities/pydantic_settings.py +240 -0
  55. utilities/pydantic_settings_sops.py +76 -0
  56. utilities/pyinstrument.py +7 -7
  57. utilities/pytest.py +104 -143
  58. utilities/pytest_plugins/__init__.py +1 -0
  59. utilities/pytest_plugins/pytest_randomly.py +23 -0
  60. utilities/pytest_plugins/pytest_regressions.py +56 -0
  61. utilities/pytest_regressions.py +26 -46
  62. utilities/random.py +11 -6
  63. utilities/re.py +1 -1
  64. utilities/redis.py +220 -343
  65. utilities/sentinel.py +10 -0
  66. utilities/shelve.py +4 -1
  67. utilities/shutil.py +25 -0
  68. utilities/slack_sdk.py +35 -104
  69. utilities/sqlalchemy.py +496 -471
  70. utilities/sqlalchemy_polars.py +29 -54
  71. utilities/string.py +2 -3
  72. utilities/subprocess.py +1977 -0
  73. utilities/tempfile.py +112 -4
  74. utilities/testbook.py +50 -0
  75. utilities/text.py +174 -42
  76. utilities/throttle.py +158 -0
  77. utilities/timer.py +2 -2
  78. utilities/traceback.py +70 -35
  79. utilities/types.py +102 -30
  80. utilities/typing.py +479 -19
  81. utilities/uuid.py +42 -5
  82. utilities/version.py +27 -26
  83. utilities/whenever.py +1559 -361
  84. utilities/zoneinfo.py +80 -22
  85. dycw_utilities-0.135.0.dist-info/METADATA +0 -39
  86. dycw_utilities-0.135.0.dist-info/RECORD +0 -96
  87. dycw_utilities-0.135.0.dist-info/WHEEL +0 -4
  88. dycw_utilities-0.135.0.dist-info/licenses/LICENSE +0 -21
  89. utilities/aiolimiter.py +0 -25
  90. utilities/arq.py +0 -216
  91. utilities/eventkit.py +0 -388
  92. utilities/luigi.py +0 -183
  93. utilities/period.py +0 -152
  94. utilities/pudb.py +0 -62
  95. utilities/python_dotenv.py +0 -101
  96. utilities/streamlit.py +0 -105
  97. utilities/typed_settings.py +0 -123
utilities/tempfile.py CHANGED
@@ -1,11 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import tempfile
4
+ from contextlib import contextmanager
3
5
  from pathlib import Path
4
- from tempfile import TemporaryDirectory as _TemporaryDirectory
6
+ from shutil import move
7
+ from tempfile import NamedTemporaryFile as _NamedTemporaryFile
5
8
  from tempfile import gettempdir as _gettempdir
6
- from typing import TYPE_CHECKING
9
+ from typing import TYPE_CHECKING, override
10
+
11
+ from utilities.warnings import suppress_warnings
7
12
 
8
13
  if TYPE_CHECKING:
14
+ from collections.abc import Iterator
9
15
  from types import TracebackType
10
16
 
11
17
  from utilities.types import PathLike
@@ -21,13 +27,15 @@ class TemporaryDirectory:
21
27
  prefix: str | None = None,
22
28
  dir: PathLike | None = None, # noqa: A002
23
29
  ignore_cleanup_errors: bool = False,
30
+ delete: bool = True,
24
31
  ) -> None:
25
32
  super().__init__()
26
- self._temp_dir = _TemporaryDirectory(
33
+ self._temp_dir = _TemporaryDirectoryNoResourceWarning(
27
34
  suffix=suffix,
28
35
  prefix=prefix,
29
36
  dir=dir,
30
37
  ignore_cleanup_errors=ignore_cleanup_errors,
38
+ delete=delete,
31
39
  )
32
40
  self.path = Path(self._temp_dir.name)
33
41
 
@@ -43,6 +51,106 @@ class TemporaryDirectory:
43
51
  self._temp_dir.__exit__(exc, val, tb)
44
52
 
45
53
 
54
+ class _TemporaryDirectoryNoResourceWarning(tempfile.TemporaryDirectory):
55
+ @classmethod
56
+ @override
57
+ def _cleanup( # pyright: ignore[reportGeneralTypeIssues]
58
+ cls,
59
+ name: str,
60
+ warn_message: str,
61
+ ignore_errors: bool = False,
62
+ delete: bool = True,
63
+ ) -> None:
64
+ with suppress_warnings(category=ResourceWarning):
65
+ return super()._cleanup( # pyright: ignore[reportAttributeAccessIssue]
66
+ name, warn_message, ignore_errors=ignore_errors, delete=delete
67
+ )
68
+
69
+
70
+ ##
71
+
72
+
73
+ @contextmanager
74
+ def TemporaryFile( # noqa: N802
75
+ *,
76
+ dir: PathLike | None = None, # noqa: A002
77
+ suffix: str | None = None,
78
+ prefix: str | None = None,
79
+ ignore_cleanup_errors: bool = False,
80
+ delete: bool = True,
81
+ name: str | None = None,
82
+ text: str | None = None,
83
+ ) -> Iterator[Path]:
84
+ """Yield a temporary file."""
85
+ if dir is None:
86
+ with (
87
+ TemporaryDirectory(
88
+ suffix=suffix,
89
+ prefix=prefix,
90
+ dir=dir,
91
+ ignore_cleanup_errors=ignore_cleanup_errors,
92
+ delete=delete,
93
+ ) as temp_dir,
94
+ _temporary_file_outer(
95
+ temp_dir,
96
+ suffix=suffix,
97
+ prefix=prefix,
98
+ delete=delete,
99
+ name=name,
100
+ text=text,
101
+ ) as temp,
102
+ ):
103
+ yield temp
104
+ else:
105
+ with _temporary_file_outer(
106
+ dir, suffix=suffix, prefix=prefix, delete=delete, name=name, text=text
107
+ ) as temp:
108
+ yield temp
109
+
110
+
111
+ @contextmanager
112
+ def _temporary_file_outer(
113
+ path: PathLike,
114
+ /,
115
+ *,
116
+ suffix: str | None = None,
117
+ prefix: str | None = None,
118
+ delete: bool = True,
119
+ name: str | None = None,
120
+ text: str | None = None,
121
+ ) -> Iterator[Path]:
122
+ with _temporary_file_inner(
123
+ path, suffix=suffix, prefix=prefix, delete=delete, name=name
124
+ ) as temp:
125
+ if text is not None:
126
+ _ = temp.write_text(text)
127
+ yield temp
128
+
129
+
130
+ @contextmanager
131
+ def _temporary_file_inner(
132
+ path: PathLike,
133
+ /,
134
+ *,
135
+ suffix: str | None = None,
136
+ prefix: str | None = None,
137
+ delete: bool = True,
138
+ name: str | None = None,
139
+ ) -> Iterator[Path]:
140
+ path = Path(path)
141
+ temp = _NamedTemporaryFile( # noqa: SIM115
142
+ suffix=suffix, prefix=prefix, dir=path, delete=delete, delete_on_close=False
143
+ )
144
+ if name is None:
145
+ yield path / temp.name
146
+ else:
147
+ _ = move(path / temp.name, path / name)
148
+ yield path / name
149
+
150
+
151
+ ##
152
+
153
+
46
154
  def gettempdir() -> Path:
47
155
  """Get the name of the directory used for temporary files."""
48
156
  return Path(_gettempdir())
@@ -51,4 +159,4 @@ def gettempdir() -> Path:
51
159
  TEMP_DIR = gettempdir()
52
160
 
53
161
 
54
- __all__ = ["TEMP_DIR", "TemporaryDirectory", "gettempdir"]
162
+ __all__ = ["TEMP_DIR", "TemporaryDirectory", "TemporaryFile", "gettempdir"]
utilities/testbook.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from testbook import testbook
7
+
8
+ from utilities.pytest import throttle_test
9
+ from utilities.text import pascal_case
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from utilities.types import Delta, PathLike
15
+
16
+
17
+ def build_notebook_tester(
18
+ path: PathLike, /, *, throttle: Delta | None = None, on_try: bool = False
19
+ ) -> type[Any]:
20
+ """Build the notebook tester class."""
21
+ path = Path(path)
22
+ name = f"Test{pascal_case(path.stem)}"
23
+ notebooks = [
24
+ path_i
25
+ for path_i in path.rglob("**/*.ipynb")
26
+ if all(p != ".ipynb_checkpoints" for p in path_i.parts)
27
+ ]
28
+ namespace = {
29
+ f"test_{p.stem.replace('-', '_')}": _build_test_method(
30
+ p, delta=throttle, on_try=on_try
31
+ )
32
+ for p in notebooks
33
+ }
34
+ return type(name, (), namespace)
35
+
36
+
37
+ def _build_test_method(
38
+ path: Path, /, *, delta: Delta | None = None, on_try: bool = False
39
+ ) -> Callable[..., Any]:
40
+ @testbook(path, execute=True)
41
+ def method(self: Any, tb: Any) -> None:
42
+ _ = (self, tb) # pragma: no cover
43
+
44
+ if delta is not None:
45
+ method = throttle_test(delta=delta, on_try=on_try)(method)
46
+
47
+ return method
48
+
49
+
50
+ __all__ = ["build_notebook_tester"]
utilities/text.py CHANGED
@@ -2,26 +2,44 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  from collections import deque
5
+ from collections.abc import Callable
5
6
  from dataclasses import dataclass
6
7
  from itertools import chain
7
8
  from os import getpid
8
- from re import IGNORECASE, Match, escape, search
9
+ from re import IGNORECASE, VERBOSE, escape, search
9
10
  from textwrap import dedent
10
11
  from threading import get_ident
11
12
  from time import time_ns
12
- from typing import TYPE_CHECKING, Any, Literal, overload, override
13
+ from typing import (
14
+ TYPE_CHECKING,
15
+ Any,
16
+ ClassVar,
17
+ Literal,
18
+ assert_never,
19
+ overload,
20
+ override,
21
+ )
13
22
  from uuid import uuid4
14
23
 
15
24
  from utilities.iterables import CheckDuplicatesError, check_duplicates, transpose
16
25
  from utilities.reprlib import get_repr
26
+ from utilities.sentinel import Sentinel
17
27
 
18
28
  if TYPE_CHECKING:
19
29
  from collections.abc import Iterable, Mapping, Sequence
20
30
 
21
- from utilities.types import StrStrMapping
31
+ from utilities.types import MaybeCallableBoolLike, MaybeCallableStr, StrStrMapping
22
32
 
23
33
 
24
- DEFAULT_SEPARATOR = ","
34
+ _DEFAULT_SEPARATOR = ","
35
+
36
+
37
+ ##
38
+
39
+
40
+ def kebab_case(text: str, /) -> str:
41
+ """Convert text into kebab case."""
42
+ return _kebab_snake_case(text, "-")
25
43
 
26
44
 
27
45
  ##
@@ -67,6 +85,29 @@ class ParseNoneError(Exception):
67
85
  ##
68
86
 
69
87
 
88
+ def pascal_case(text: str, /) -> str:
89
+ """Convert text to pascal case."""
90
+ parts = _SPLIT_TEXT.findall(text)
91
+ parts = [p for p in parts if len(p) >= 1]
92
+ parts = list(map(_pascal_case_one, parts))
93
+ return "".join(parts)
94
+
95
+
96
+ def _pascal_case_one(text: str, /) -> str:
97
+ return text if text.isupper() else text.title()
98
+
99
+
100
+ ##
101
+
102
+
103
+ def prompt_bool(prompt: object = "", /, *, confirm: bool = False) -> bool:
104
+ """Prompt for a boolean."""
105
+ return True if confirm else parse_bool(input(prompt))
106
+
107
+
108
+ ##
109
+
110
+
70
111
  def repr_encode(obj: Any, /) -> bytes:
71
112
  """Return the representation of the object encoded as bytes."""
72
113
  return repr(obj).encode()
@@ -75,30 +116,15 @@ def repr_encode(obj: Any, /) -> bytes:
75
116
  ##
76
117
 
77
118
 
78
- _ACRONYM_PATTERN = re.compile(r"([A-Z\d]+)(?=[A-Z\d]|$)")
79
- _SPACES_PATTERN = re.compile(r"\s+")
80
- _SPLIT_PATTERN = re.compile(r"([\-_]*[A-Z][^A-Z]*[\-_]*)")
81
-
82
-
83
119
  def snake_case(text: str, /) -> str:
84
120
  """Convert text into snake case."""
85
- text = _SPACES_PATTERN.sub("", text)
86
- if not text.isupper():
87
- text = _ACRONYM_PATTERN.sub(_snake_case_title, text)
88
- text = "_".join(s for s in _SPLIT_PATTERN.split(text) if s)
89
- while search("__", text):
90
- text = text.replace("__", "_")
91
- return text.lower()
92
-
93
-
94
- def _snake_case_title(match: Match[str], /) -> str:
95
- return match.group(0).title()
121
+ return _kebab_snake_case(text, "_")
96
122
 
97
123
 
98
124
  ##
99
125
 
100
126
 
101
- LIST_SEPARATOR = DEFAULT_SEPARATOR
127
+ LIST_SEPARATOR = _DEFAULT_SEPARATOR
102
128
  PAIR_SEPARATOR = "="
103
129
  BRACKETS = [("(", ")"), ("[", "]"), ("{", "}")]
104
130
 
@@ -108,7 +134,7 @@ def split_key_value_pairs(
108
134
  text: str,
109
135
  /,
110
136
  *,
111
- list_separator: str = DEFAULT_SEPARATOR,
137
+ list_separator: str = _DEFAULT_SEPARATOR,
112
138
  pair_separator: str = PAIR_SEPARATOR,
113
139
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
114
140
  mapping: Literal[True],
@@ -118,7 +144,7 @@ def split_key_value_pairs(
118
144
  text: str,
119
145
  /,
120
146
  *,
121
- list_separator: str = DEFAULT_SEPARATOR,
147
+ list_separator: str = _DEFAULT_SEPARATOR,
122
148
  pair_separator: str = PAIR_SEPARATOR,
123
149
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
124
150
  mapping: Literal[False] = False,
@@ -128,7 +154,7 @@ def split_key_value_pairs(
128
154
  text: str,
129
155
  /,
130
156
  *,
131
- list_separator: str = DEFAULT_SEPARATOR,
157
+ list_separator: str = _DEFAULT_SEPARATOR,
132
158
  pair_separator: str = PAIR_SEPARATOR,
133
159
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
134
160
  mapping: bool = False,
@@ -137,7 +163,7 @@ def split_key_value_pairs(
137
163
  text: str,
138
164
  /,
139
165
  *,
140
- list_separator: str = DEFAULT_SEPARATOR,
166
+ list_separator: str = _DEFAULT_SEPARATOR,
141
167
  pair_separator: str = PAIR_SEPARATOR,
142
168
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
143
169
  mapping: bool = False,
@@ -196,7 +222,7 @@ def split_str(
196
222
  text: str,
197
223
  /,
198
224
  *,
199
- separator: str = DEFAULT_SEPARATOR,
225
+ separator: str = _DEFAULT_SEPARATOR,
200
226
  brackets: Iterable[tuple[str, str]] | None = None,
201
227
  n: Literal[1],
202
228
  ) -> tuple[str]: ...
@@ -205,7 +231,7 @@ def split_str(
205
231
  text: str,
206
232
  /,
207
233
  *,
208
- separator: str = DEFAULT_SEPARATOR,
234
+ separator: str = _DEFAULT_SEPARATOR,
209
235
  brackets: Iterable[tuple[str, str]] | None = None,
210
236
  n: Literal[2],
211
237
  ) -> tuple[str, str]: ...
@@ -214,7 +240,7 @@ def split_str(
214
240
  text: str,
215
241
  /,
216
242
  *,
217
- separator: str = DEFAULT_SEPARATOR,
243
+ separator: str = _DEFAULT_SEPARATOR,
218
244
  brackets: Iterable[tuple[str, str]] | None = None,
219
245
  n: Literal[3],
220
246
  ) -> tuple[str, str, str]: ...
@@ -223,7 +249,7 @@ def split_str(
223
249
  text: str,
224
250
  /,
225
251
  *,
226
- separator: str = DEFAULT_SEPARATOR,
252
+ separator: str = _DEFAULT_SEPARATOR,
227
253
  brackets: Iterable[tuple[str, str]] | None = None,
228
254
  n: Literal[4],
229
255
  ) -> tuple[str, str, str, str]: ...
@@ -232,7 +258,7 @@ def split_str(
232
258
  text: str,
233
259
  /,
234
260
  *,
235
- separator: str = DEFAULT_SEPARATOR,
261
+ separator: str = _DEFAULT_SEPARATOR,
236
262
  brackets: Iterable[tuple[str, str]] | None = None,
237
263
  n: Literal[5],
238
264
  ) -> tuple[str, str, str, str, str]: ...
@@ -241,18 +267,18 @@ def split_str(
241
267
  text: str,
242
268
  /,
243
269
  *,
244
- separator: str = DEFAULT_SEPARATOR,
270
+ separator: str = _DEFAULT_SEPARATOR,
245
271
  brackets: Iterable[tuple[str, str]] | None = None,
246
272
  n: int | None = None,
247
- ) -> Sequence[str]: ...
273
+ ) -> tuple[str, ...]: ...
248
274
  def split_str(
249
275
  text: str,
250
276
  /,
251
277
  *,
252
- separator: str = DEFAULT_SEPARATOR,
278
+ separator: str = _DEFAULT_SEPARATOR,
253
279
  brackets: Iterable[tuple[str, str]] | None = None,
254
280
  n: int | None = None,
255
- ) -> Sequence[str]:
281
+ ) -> tuple[str, ...]:
256
282
  """Split a string, with a special provision for the empty string."""
257
283
  if text == "":
258
284
  texts = []
@@ -263,7 +289,7 @@ def split_str(
263
289
  else:
264
290
  texts = _split_str_brackets(text, brackets, separator=separator)
265
291
  if n is None:
266
- return texts
292
+ return tuple(texts)
267
293
  if len(texts) != n:
268
294
  raise _SplitStrCountError(text=text, n=n, texts=texts)
269
295
  return tuple(texts)
@@ -274,8 +300,8 @@ def _split_str_brackets(
274
300
  brackets: Iterable[tuple[str, str]],
275
301
  /,
276
302
  *,
277
- separator: str = DEFAULT_SEPARATOR,
278
- ) -> Sequence[str]:
303
+ separator: str = _DEFAULT_SEPARATOR,
304
+ ) -> list[str]:
279
305
  brackets = list(brackets)
280
306
  opens, closes = transpose(brackets)
281
307
  close_to_open = {close: open_ for open_, close in brackets}
@@ -283,7 +309,7 @@ def _split_str_brackets(
283
309
  escapes = map(escape, chain(chain.from_iterable(brackets), [separator]))
284
310
  pattern = re.compile("|".join(escapes))
285
311
 
286
- results: Sequence[str] = []
312
+ results: list[str] = []
287
313
  stack: deque[tuple[str, int]] = deque()
288
314
  last = 0
289
315
 
@@ -325,7 +351,7 @@ class SplitStrError(Exception):
325
351
  @dataclass(kw_only=True, slots=True)
326
352
  class _SplitStrCountError(SplitStrError):
327
353
  n: int
328
- texts: Sequence[str]
354
+ texts: list[str]
329
355
 
330
356
  @override
331
357
  def __str__(self) -> str:
@@ -365,7 +391,7 @@ class _SplitStrOpeningBracketUnmatchedError(SplitStrError):
365
391
 
366
392
 
367
393
  def join_strs(
368
- texts: Iterable[str], /, *, sort: bool = False, separator: str = DEFAULT_SEPARATOR
394
+ texts: Iterable[str], /, *, sort: bool = False, separator: str = _DEFAULT_SEPARATOR
369
395
  ) -> str:
370
396
  """Join a collection of strings, with a special provision for the empty list."""
371
397
  texts = list(texts)
@@ -378,13 +404,47 @@ def join_strs(
378
404
  return separator.join(texts)
379
405
 
380
406
 
381
- def _escape_separator(*, separator: str = DEFAULT_SEPARATOR) -> str:
407
+ def _escape_separator(*, separator: str = _DEFAULT_SEPARATOR) -> str:
382
408
  return f"\\{separator}"
383
409
 
384
410
 
385
411
  ##
386
412
 
387
413
 
414
+ def repr_str(obj: Any, /) -> str:
415
+ """Get the representation of the string of an object."""
416
+ return repr(str(obj))
417
+
418
+
419
+ ##
420
+
421
+
422
+ class secret_str(str): # noqa: N801
423
+ """A string with an obfuscated representation."""
424
+
425
+ __slots__ = ("_text",)
426
+ _REPR: ClassVar[str] = "***"
427
+
428
+ def __init__(self, text: str, /) -> None:
429
+ super().__init__()
430
+ self._text = text
431
+
432
+ @override
433
+ def __repr__(self) -> str:
434
+ return self._REPR
435
+
436
+ @override
437
+ def __str__(self) -> str:
438
+ return self._REPR
439
+
440
+ @property
441
+ def str(self) -> str:
442
+ return self._text
443
+
444
+
445
+ ##
446
+
447
+
388
448
  def str_encode(obj: Any, /) -> bytes:
389
449
  """Return the string representation of the object encoded as bytes."""
390
450
  return str(obj).encode()
@@ -402,6 +462,50 @@ def strip_and_dedent(text: str, /, *, trailing: bool = False) -> str:
402
462
  ##
403
463
 
404
464
 
465
+ @overload
466
+ def to_bool(bool_: MaybeCallableBoolLike, /) -> bool: ...
467
+ @overload
468
+ def to_bool(bool_: None, /) -> None: ...
469
+ @overload
470
+ def to_bool(bool_: Sentinel, /) -> Sentinel: ...
471
+ def to_bool(
472
+ bool_: MaybeCallableBoolLike | None | Sentinel, /
473
+ ) -> bool | None | Sentinel:
474
+ """Convert to a bool."""
475
+ match bool_:
476
+ case bool() | None | Sentinel():
477
+ return bool_
478
+ case str():
479
+ return parse_bool(bool_)
480
+ case Callable() as func:
481
+ return to_bool(func())
482
+ case never:
483
+ assert_never(never)
484
+
485
+
486
+ ##
487
+
488
+
489
+ @overload
490
+ def to_str(text: MaybeCallableStr, /) -> str: ...
491
+ @overload
492
+ def to_str(text: None, /) -> None: ...
493
+ @overload
494
+ def to_str(text: Sentinel, /) -> Sentinel: ...
495
+ def to_str(text: MaybeCallableStr | None | Sentinel, /) -> str | None | Sentinel:
496
+ """Convert to a string."""
497
+ match text:
498
+ case str() | None | Sentinel():
499
+ return text
500
+ case Callable() as func:
501
+ return to_str(func())
502
+ case never:
503
+ assert_never(never)
504
+
505
+
506
+ ##
507
+
508
+
405
509
  def unique_str() -> str:
406
510
  """Generate at unique string."""
407
511
  now = time_ns()
@@ -411,9 +515,30 @@ def unique_str() -> str:
411
515
  return f"{now}_{pid}_{ident}_{key}"
412
516
 
413
517
 
518
+ ##
519
+
520
+
521
+ def _kebab_snake_case(text: str, separator: str, /) -> str:
522
+ """Convert text into kebab/snake case."""
523
+ leading = bool(search(r"^_", text))
524
+ trailing = bool(search(r"_$", text))
525
+ parts = _SPLIT_TEXT.findall(text)
526
+ parts = (p for p in parts if len(p) >= 1)
527
+ parts = chain([""] if leading else [], parts, [""] if trailing else [])
528
+ return separator.join(parts).lower()
529
+
530
+
531
+ _SPLIT_TEXT = re.compile(
532
+ r"""
533
+ [A-Z]+(?=[A-Z][a-z0-9]) | # all caps followed by Upper+lower or digit (API in APIResponse2)
534
+ [A-Z]?[a-z]+[0-9]* | # normal words with optional trailing digits (Text123)
535
+ [A-Z]+[0-9]* | # consecutive caps with optional trailing digits (ID2)
536
+ """,
537
+ flags=VERBOSE,
538
+ )
539
+
414
540
  __all__ = [
415
541
  "BRACKETS",
416
- "DEFAULT_SEPARATOR",
417
542
  "LIST_SEPARATOR",
418
543
  "PAIR_SEPARATOR",
419
544
  "ParseBoolError",
@@ -421,13 +546,20 @@ __all__ = [
421
546
  "SplitKeyValuePairsError",
422
547
  "SplitStrError",
423
548
  "join_strs",
549
+ "kebab_case",
424
550
  "parse_bool",
425
551
  "parse_none",
552
+ "pascal_case",
553
+ "prompt_bool",
426
554
  "repr_encode",
555
+ "repr_str",
556
+ "secret_str",
427
557
  "snake_case",
428
558
  "split_key_value_pairs",
429
559
  "split_str",
430
560
  "str_encode",
431
561
  "strip_and_dedent",
562
+ "to_bool",
563
+ "to_str",
432
564
  "unique_str",
433
565
  ]