dycw-utilities 0.129.10__py3-none-any.whl → 0.175.17__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.
Files changed (103) hide show
  1. dycw_utilities-0.175.17.dist-info/METADATA +34 -0
  2. dycw_utilities-0.175.17.dist-info/RECORD +103 -0
  3. dycw_utilities-0.175.17.dist-info/WHEEL +4 -0
  4. dycw_utilities-0.175.17.dist-info/entry_points.txt +4 -0
  5. utilities/__init__.py +1 -1
  6. utilities/altair.py +14 -14
  7. utilities/asyncio.py +350 -819
  8. utilities/atomicwrites.py +18 -6
  9. utilities/atools.py +77 -22
  10. utilities/cachetools.py +24 -29
  11. utilities/click.py +393 -237
  12. utilities/concurrent.py +8 -11
  13. utilities/contextlib.py +216 -17
  14. utilities/contextvars.py +20 -1
  15. utilities/cryptography.py +3 -3
  16. utilities/dataclasses.py +83 -118
  17. utilities/docker.py +293 -0
  18. utilities/enum.py +26 -23
  19. utilities/errors.py +17 -3
  20. utilities/fastapi.py +29 -65
  21. utilities/fpdf2.py +3 -3
  22. utilities/functions.py +169 -416
  23. utilities/functools.py +18 -19
  24. utilities/git.py +9 -30
  25. utilities/grp.py +28 -0
  26. utilities/gzip.py +31 -0
  27. utilities/http.py +3 -2
  28. utilities/hypothesis.py +738 -589
  29. utilities/importlib.py +17 -1
  30. utilities/inflect.py +25 -0
  31. utilities/iterables.py +194 -262
  32. utilities/jinja2.py +148 -0
  33. utilities/json.py +70 -0
  34. utilities/libcst.py +38 -17
  35. utilities/lightweight_charts.py +5 -9
  36. utilities/logging.py +345 -543
  37. utilities/math.py +18 -13
  38. utilities/memory_profiler.py +11 -15
  39. utilities/more_itertools.py +200 -131
  40. utilities/operator.py +33 -29
  41. utilities/optuna.py +6 -6
  42. utilities/orjson.py +272 -137
  43. utilities/os.py +61 -4
  44. utilities/parse.py +59 -61
  45. utilities/pathlib.py +281 -40
  46. utilities/permissions.py +298 -0
  47. utilities/pickle.py +2 -2
  48. utilities/platform.py +24 -5
  49. utilities/polars.py +1214 -430
  50. utilities/polars_ols.py +1 -1
  51. utilities/postgres.py +408 -0
  52. utilities/pottery.py +113 -26
  53. utilities/pqdm.py +10 -11
  54. utilities/psutil.py +6 -57
  55. utilities/pwd.py +28 -0
  56. utilities/pydantic.py +4 -54
  57. utilities/pydantic_settings.py +240 -0
  58. utilities/pydantic_settings_sops.py +76 -0
  59. utilities/pyinstrument.py +8 -10
  60. utilities/pytest.py +227 -121
  61. utilities/pytest_plugins/__init__.py +1 -0
  62. utilities/pytest_plugins/pytest_randomly.py +23 -0
  63. utilities/pytest_plugins/pytest_regressions.py +56 -0
  64. utilities/pytest_regressions.py +26 -46
  65. utilities/random.py +13 -9
  66. utilities/re.py +58 -28
  67. utilities/redis.py +401 -550
  68. utilities/scipy.py +1 -1
  69. utilities/sentinel.py +10 -0
  70. utilities/shelve.py +4 -1
  71. utilities/shutil.py +25 -0
  72. utilities/slack_sdk.py +36 -106
  73. utilities/sqlalchemy.py +502 -473
  74. utilities/sqlalchemy_polars.py +38 -94
  75. utilities/string.py +2 -3
  76. utilities/subprocess.py +1572 -0
  77. utilities/tempfile.py +86 -4
  78. utilities/testbook.py +50 -0
  79. utilities/text.py +165 -42
  80. utilities/timer.py +37 -65
  81. utilities/traceback.py +158 -929
  82. utilities/types.py +146 -116
  83. utilities/typing.py +531 -71
  84. utilities/tzdata.py +1 -53
  85. utilities/tzlocal.py +6 -23
  86. utilities/uuid.py +43 -5
  87. utilities/version.py +27 -26
  88. utilities/whenever.py +1776 -386
  89. utilities/zoneinfo.py +84 -22
  90. dycw_utilities-0.129.10.dist-info/METADATA +0 -241
  91. dycw_utilities-0.129.10.dist-info/RECORD +0 -96
  92. dycw_utilities-0.129.10.dist-info/WHEEL +0 -4
  93. dycw_utilities-0.129.10.dist-info/licenses/LICENSE +0 -21
  94. utilities/datetime.py +0 -1409
  95. utilities/eventkit.py +0 -402
  96. utilities/loguru.py +0 -144
  97. utilities/luigi.py +0 -228
  98. utilities/period.py +0 -324
  99. utilities/pyrsistent.py +0 -89
  100. utilities/python_dotenv.py +0 -105
  101. utilities/streamlit.py +0 -105
  102. utilities/sys.py +0 -87
  103. utilities/tenacity.py +0 -145
utilities/os.py CHANGED
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- from contextlib import contextmanager, suppress
3
+ from contextlib import suppress
4
4
  from dataclasses import dataclass
5
5
  from os import cpu_count, environ, getenv
6
6
  from typing import TYPE_CHECKING, Literal, assert_never, overload, override
7
7
 
8
+ from utilities.contextlib import enhanced_context_manager
8
9
  from utilities.iterables import OneStrEmptyError, one_str
10
+ from utilities.platform import SYSTEM
9
11
 
10
12
  if TYPE_CHECKING:
11
13
  from collections.abc import Iterator, Mapping
@@ -47,7 +49,7 @@ def get_cpu_use(*, n: IntOrAll = "all") -> int:
47
49
  raise GetCPUUseError(n=n)
48
50
  case "all":
49
51
  return CPU_COUNT
50
- case _ as never:
52
+ case never:
51
53
  assert_never(never)
52
54
 
53
55
 
@@ -104,7 +106,7 @@ def get_env_var(
104
106
  return None
105
107
  case str(), _:
106
108
  return default
107
- case _ as never:
109
+ case never:
108
110
  assert_never(never)
109
111
  return environ[key_use]
110
112
 
@@ -123,7 +125,56 @@ class GetEnvVarError(Exception):
123
125
  ##
124
126
 
125
127
 
126
- @contextmanager
128
+ def get_effective_group_id() -> int | None:
129
+ """Get the effective group ID."""
130
+ match SYSTEM:
131
+ case "windows": # skipif-not-windows
132
+ return None
133
+ case "mac" | "linux": # skipif-windows
134
+ from os import getegid
135
+
136
+ return getegid()
137
+ case never:
138
+ assert_never(never)
139
+
140
+
141
+ def get_effective_user_id() -> int | None:
142
+ """Get the effective user ID."""
143
+ match SYSTEM:
144
+ case "windows": # skipif-not-windows
145
+ return None
146
+ case "mac" | "linux": # skipif-windows
147
+ from os import geteuid
148
+
149
+ return geteuid()
150
+ case never:
151
+ assert_never(never)
152
+
153
+
154
+ EFFECTIVE_USER_ID = get_effective_user_id()
155
+ EFFECTIVE_GROUP_ID = get_effective_group_id()
156
+
157
+
158
+ ##
159
+
160
+
161
+ def is_debug() -> bool:
162
+ """Check if we are in `DEBUG` mode."""
163
+ return get_env_var("DEBUG", nullable=True) is not None
164
+
165
+
166
+ ##
167
+
168
+
169
+ def is_pytest() -> bool:
170
+ """Check if `pytest` is running."""
171
+ return get_env_var("PYTEST_VERSION", nullable=True) is not None
172
+
173
+
174
+ ##
175
+
176
+
177
+ @enhanced_context_manager
127
178
  def temp_environ(
128
179
  env: Mapping[str, str | None] | None = None, **env_kwargs: str | None
129
180
  ) -> Iterator[None]:
@@ -148,11 +199,17 @@ def temp_environ(
148
199
 
149
200
  __all__ = [
150
201
  "CPU_COUNT",
202
+ "EFFECTIVE_GROUP_ID",
203
+ "EFFECTIVE_USER_ID",
151
204
  "GetCPUCountError",
152
205
  "GetCPUUseError",
153
206
  "IntOrAll",
154
207
  "get_cpu_count",
155
208
  "get_cpu_use",
209
+ "get_effective_group_id",
210
+ "get_effective_user_id",
156
211
  "get_env_var",
212
+ "is_debug",
213
+ "is_pytest",
157
214
  "temp_environ",
158
215
  ]
utilities/parse.py CHANGED
@@ -1,14 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
- import datetime as dt
4
3
  from contextlib import suppress
5
4
  from dataclasses import dataclass
6
5
  from enum import Enum
6
+ from ipaddress import IPv4Address, IPv6Address
7
7
  from pathlib import Path
8
8
  from re import DOTALL
9
9
  from types import NoneType
10
10
  from typing import TYPE_CHECKING, Any, override
11
11
 
12
+ from whenever import (
13
+ Date,
14
+ DateDelta,
15
+ DateTimeDelta,
16
+ MonthDay,
17
+ PlainDateTime,
18
+ Time,
19
+ TimeDelta,
20
+ YearMonth,
21
+ ZonedDateTime,
22
+ )
23
+
12
24
  from utilities.enum import ParseEnumError, parse_enum
13
25
  from utilities.iterables import OneEmptyError, OneNonUniqueError, one, one_str
14
26
  from utilities.math import ParseNumberError, parse_number
@@ -26,7 +38,7 @@ from utilities.text import (
26
38
  split_key_value_pairs,
27
39
  split_str,
28
40
  )
29
- from utilities.types import Duration, Number, ParseObjectExtra, SerializeObjectExtra
41
+ from utilities.types import Number, ParseObjectExtra, SerializeObjectExtra
30
42
  from utilities.typing import (
31
43
  get_args,
32
44
  is_dict_type,
@@ -167,14 +179,9 @@ def _parse_object_type(
167
179
  return parse_bool(text)
168
180
  except ParseBoolError:
169
181
  raise _ParseObjectParseError(type_=cls, text=text) from None
170
- if is_subclass_gen(cls, int):
171
- try:
172
- return int(text)
173
- except ValueError:
174
- raise _ParseObjectParseError(type_=cls, text=text) from None
175
- if issubclass(cls, float):
182
+ if is_subclass_gen(cls, int | float | IPv4Address | IPv6Address):
176
183
  try:
177
- return float(text)
184
+ return cls(text)
178
185
  except ValueError:
179
186
  raise _ParseObjectParseError(type_=cls, text=text) from None
180
187
  if issubclass(cls, Enum):
@@ -182,6 +189,24 @@ def _parse_object_type(
182
189
  return parse_enum(text, cls, case_sensitive=case_sensitive)
183
190
  except ParseEnumError:
184
191
  raise _ParseObjectParseError(type_=cls, text=text) from None
192
+ if issubclass(
193
+ cls,
194
+ (
195
+ Date,
196
+ DateDelta,
197
+ DateTimeDelta,
198
+ MonthDay,
199
+ PlainDateTime,
200
+ Time,
201
+ TimeDelta,
202
+ YearMonth,
203
+ ZonedDateTime,
204
+ ),
205
+ ):
206
+ try:
207
+ return cls.parse_iso(text)
208
+ except ValueError:
209
+ raise _ParseObjectParseError(type_=cls, text=text) from None
185
210
  if issubclass(cls, Path):
186
211
  return Path(text).expanduser()
187
212
  if issubclass(cls, Sentinel):
@@ -194,34 +219,6 @@ def _parse_object_type(
194
219
  return parse_version(text)
195
220
  except ParseVersionError:
196
221
  raise _ParseObjectParseError(type_=cls, text=text) from None
197
- if is_subclass_gen(cls, dt.date):
198
- from utilities.whenever import ParseDateError, parse_date
199
-
200
- try:
201
- return parse_date(text)
202
- except ParseDateError:
203
- raise _ParseObjectParseError(type_=cls, text=text) from None
204
- if is_subclass_gen(cls, dt.datetime):
205
- from utilities.whenever import ParseDateTimeError, parse_datetime
206
-
207
- try:
208
- return parse_datetime(text)
209
- except ParseDateTimeError:
210
- raise _ParseObjectParseError(type_=cls, text=text) from None
211
- if issubclass(cls, dt.time):
212
- from utilities.whenever import ParseTimeError, parse_time
213
-
214
- try:
215
- return parse_time(text)
216
- except ParseTimeError:
217
- raise _ParseObjectParseError(type_=cls, text=text) from None
218
- if issubclass(cls, dt.timedelta):
219
- from utilities.whenever import ParseTimedeltaError, parse_timedelta
220
-
221
- try:
222
- return parse_timedelta(text)
223
- except ParseTimedeltaError:
224
- raise _ParseObjectParseError(type_=cls, text=text) from None
225
222
  raise _ParseObjectParseError(type_=cls, text=text)
226
223
 
227
224
 
@@ -374,13 +371,6 @@ def _parse_object_union_type(type_: Any, text: str, /) -> Any:
374
371
  return parse_number(text)
375
372
  except ParseNumberError:
376
373
  raise _ParseObjectParseError(type_=type_, text=text) from None
377
- if type_ is Duration:
378
- from utilities.whenever import ParseDurationError, parse_duration
379
-
380
- try:
381
- return parse_duration(text)
382
- except ParseDurationError:
383
- raise _ParseObjectParseError(type_=type_, text=text) from None
384
374
  raise _ParseObjectParseError(type_=type_, text=text) from None
385
375
 
386
376
 
@@ -461,25 +451,33 @@ def serialize_object(
461
451
  with suppress(_SerializeObjectSerializeError):
462
452
  return _serialize_object_extra(obj, extra)
463
453
  if (obj is None) or isinstance(
464
- obj, bool | int | float | str | Path | Sentinel | Version
454
+ obj,
455
+ bool
456
+ | int
457
+ | float
458
+ | str
459
+ | IPv4Address
460
+ | IPv6Address
461
+ | Path
462
+ | Sentinel
463
+ | Version,
465
464
  ):
466
465
  return str(obj)
467
- if is_instance_gen(obj, dt.date):
468
- from utilities.whenever import serialize_date
469
-
470
- return serialize_date(obj)
471
- if is_instance_gen(obj, dt.datetime):
472
- from utilities.whenever import serialize_datetime
473
-
474
- return serialize_datetime(obj)
475
- if isinstance(obj, dt.time):
476
- from utilities.whenever import serialize_time
477
-
478
- return serialize_time(obj)
479
- if isinstance(obj, dt.timedelta):
480
- from utilities.whenever import serialize_timedelta
481
-
482
- return serialize_timedelta(obj)
466
+ if isinstance(
467
+ obj,
468
+ (
469
+ Date,
470
+ DateDelta,
471
+ DateTimeDelta,
472
+ MonthDay,
473
+ PlainDateTime,
474
+ Time,
475
+ TimeDelta,
476
+ YearMonth,
477
+ ZonedDateTime,
478
+ ),
479
+ ):
480
+ return obj.format_iso()
483
481
  if isinstance(obj, Enum):
484
482
  return obj.name
485
483
  if isinstance(obj, dict):
utilities/pathlib.py CHANGED
@@ -1,22 +1,27 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Callable
4
- from contextlib import contextmanager, suppress
5
4
  from dataclasses import dataclass
6
5
  from itertools import chain
7
6
  from os import chdir
7
+ from os.path import expandvars
8
8
  from pathlib import Path
9
9
  from re import IGNORECASE, search
10
10
  from subprocess import PIPE, CalledProcessError, check_output
11
- from typing import TYPE_CHECKING, assert_never, overload, override
11
+ from typing import TYPE_CHECKING, Literal, assert_never, overload, override
12
12
 
13
- from utilities.sentinel import Sentinel, sentinel
13
+ from utilities.contextlib import enhanced_context_manager
14
+ from utilities.errors import ImpossibleCaseError
15
+ from utilities.grp import get_gid_name
16
+ from utilities.pwd import get_uid_name
17
+ from utilities.sentinel import Sentinel
14
18
 
15
19
  if TYPE_CHECKING:
16
20
  from collections.abc import Iterator, Sequence
17
21
 
18
22
  from utilities.types import MaybeCallablePathLike, PathLike
19
23
 
24
+
20
25
  PWD = Path.cwd()
21
26
 
22
27
 
@@ -24,60 +29,149 @@ def ensure_suffix(path: PathLike, suffix: str, /) -> Path:
24
29
  """Ensure a path has a given suffix."""
25
30
  path = Path(path)
26
31
  parts = path.name.split(".")
27
- parts = list(chain([parts[0]], (f".{p}" for p in parts[1:])))
28
- if (len(parts) == 0) or (parts[-1] != suffix):
29
- parts.append(suffix)
30
- name = "".join(parts)
32
+ suffixes = suffix.strip(".").split(".")
33
+ max_len = max(len(parts), len(suffixes))
34
+ try:
35
+ i = next(i for i in range(max_len, 0, -1) if parts[-i:] == suffixes[:i])
36
+ except StopIteration:
37
+ add = suffixes
38
+ else:
39
+ add = suffixes[i:]
40
+ name = ".".join(chain(parts, add))
31
41
  return path.with_name(name)
32
42
 
33
43
 
34
44
  ##
35
45
 
36
46
 
37
- @overload
38
- def get_path(*, path: MaybeCallablePathLike | None) -> Path: ...
39
- @overload
40
- def get_path(*, path: Sentinel) -> Sentinel: ...
41
- def get_path(
42
- *, path: MaybeCallablePathLike | None | Sentinel = sentinel
43
- ) -> Path | None | Sentinel:
44
- """Get the path."""
45
- match path:
46
- case Path() | Sentinel():
47
- return path
48
- case str():
49
- return Path(path)
50
- case None:
51
- return Path.cwd()
52
- case Callable() as func:
53
- return get_path(path=func())
54
- case _ as never:
55
- assert_never(never)
47
+ def expand_path(path: PathLike, /) -> Path:
48
+ """Expand a path."""
49
+ path = str(path)
50
+ path = expandvars(path)
51
+ return Path(path).expanduser()
56
52
 
57
53
 
58
54
  ##
59
55
 
60
56
 
61
- def get_root(*, path: MaybeCallablePathLike | None = None) -> Path:
62
- """Get the root of a path."""
63
- path = get_path(path=path)
57
+ def get_file_group(path: PathLike, /) -> str | None:
58
+ """Get the group of a file."""
59
+ return get_gid_name(to_path(path).stat().st_gid)
60
+
61
+
62
+ def get_file_owner(path: PathLike, /) -> str | None:
63
+ """Get the owner of a file."""
64
+ return get_uid_name(to_path(path).stat().st_uid)
65
+
66
+
67
+ ##
68
+
69
+
70
+ def get_package_root(path: MaybeCallablePathLike = Path.cwd, /) -> Path:
71
+ """Get the package root."""
72
+ path = to_path(path)
73
+ path_dir = path.parent if path.is_file() else path
74
+ all_paths = list(chain([path_dir], path_dir.parents))
75
+ try:
76
+ return next(
77
+ p.resolve()
78
+ for p in all_paths
79
+ if any(p_i.name == "pyproject.toml" for p_i in p.iterdir())
80
+ )
81
+ except StopIteration:
82
+ raise GetPackageRootError(path=path) from None
83
+
84
+
85
+ @dataclass(kw_only=True, slots=True)
86
+ class GetPackageRootError(Exception):
87
+ path: PathLike
88
+
89
+ @override
90
+ def __str__(self) -> str:
91
+ return f"Path is not part of a package: {self.path}"
92
+
93
+
94
+ ##
95
+
96
+
97
+ def get_repo_root(path: MaybeCallablePathLike = Path.cwd, /) -> Path:
98
+ """Get the repo root."""
99
+ path = to_path(path)
100
+ path_dir = path.parent if path.is_file() else path
64
101
  try:
65
102
  output = check_output(
66
- ["git", "rev-parse", "--show-toplevel"], stderr=PIPE, cwd=path, text=True
103
+ ["git", "rev-parse", "--show-toplevel"],
104
+ stderr=PIPE,
105
+ cwd=path_dir,
106
+ text=True,
67
107
  )
68
108
  except CalledProcessError as error:
69
109
  # newer versions of git report "Not a git repository", whilst older
70
110
  # versions report "not a git repository"
71
- if not search("fatal: not a git repository", error.stderr, flags=IGNORECASE):
72
- raise # pragma: no cover
111
+ if search("fatal: not a git repository", error.stderr, flags=IGNORECASE):
112
+ raise _GetRepoRootNotARepoError(path=path) from None
113
+ raise # pragma: no cover
114
+ except FileNotFoundError as error: # pragma: no cover
115
+ if search("No such file or directory: 'git'", str(error), flags=IGNORECASE):
116
+ raise _GetRepoRootGitNotFoundError from None
117
+ raise
73
118
  else:
74
119
  return Path(output.strip("\n"))
75
- all_paths = list(chain([path], path.parents))
76
- with suppress(StopIteration):
77
- return next(
78
- p for p in all_paths if any(p_i.name == ".envrc" for p_i in p.iterdir())
79
- )
80
- raise GetRootError(path=path)
120
+
121
+
122
+ @dataclass(kw_only=True, slots=True)
123
+ class GetRepoRootError(Exception): ...
124
+
125
+
126
+ @dataclass(kw_only=True, slots=True)
127
+ class _GetRepoRootGitNotFoundError(GetRepoRootError):
128
+ @override
129
+ def __str__(self) -> str:
130
+ return "'git' not found" # pragma: no cover
131
+
132
+
133
+ @dataclass(kw_only=True, slots=True)
134
+ class _GetRepoRootNotARepoError(GetRepoRootError):
135
+ path: Path
136
+
137
+ @override
138
+ def __str__(self) -> str:
139
+ return f"Path is not part of a `git` repository: {self.path}"
140
+
141
+
142
+ ##
143
+
144
+
145
+ def get_root(path: MaybeCallablePathLike = Path.cwd, /) -> Path:
146
+ """Get the root of a path."""
147
+ path = to_path(path)
148
+ try:
149
+ repo = get_repo_root(path)
150
+ except GetRepoRootError:
151
+ repo = None
152
+ try:
153
+ package = get_package_root(path)
154
+ except GetPackageRootError:
155
+ package = None
156
+ match repo, package:
157
+ case None, None:
158
+ raise GetRootError(path=path)
159
+ case Path(), None:
160
+ return repo
161
+ case None, Path():
162
+ return package
163
+ case Path(), Path():
164
+ if repo == package:
165
+ return repo
166
+ if is_sub_path(repo, package, strict=True):
167
+ return repo
168
+ if is_sub_path(package, repo, strict=True):
169
+ return package
170
+ raise ImpossibleCaseError( # pragma: no cover
171
+ case=[f"{repo=}", f"{package=}"]
172
+ )
173
+ case never:
174
+ assert_never(never)
81
175
 
82
176
 
83
177
  @dataclass(kw_only=True, slots=True)
@@ -92,6 +186,112 @@ class GetRootError(Exception):
92
186
  ##
93
187
 
94
188
 
189
+ type _GetTailDisambiguate = Literal["raise", "earlier", "later"]
190
+
191
+
192
+ def get_tail(
193
+ path: PathLike, root: PathLike, /, *, disambiguate: _GetTailDisambiguate = "raise"
194
+ ) -> Path:
195
+ """Get the tail of a path following a root match."""
196
+ path_parts, root_parts = [Path(p).parts for p in [path, root]]
197
+ len_path, len_root = map(len, [path_parts, root_parts])
198
+ if len_root > len_path:
199
+ raise _GetTailLengthError(path=path, root=root, len_root=len_root)
200
+ candidates = {
201
+ i + len_root: path_parts[i : i + len_root]
202
+ for i in range(len_path + 1 - len_root)
203
+ }
204
+ matches = {k: v for k, v in candidates.items() if v == root_parts}
205
+ match len(matches), disambiguate:
206
+ case 0, _:
207
+ raise _GetTailEmptyError(path=path, root=root)
208
+ case 1, _:
209
+ return _get_tail_core(path, next(iter(matches)))
210
+ case _, "raise":
211
+ first, second, *_ = matches
212
+ raise _GetTailNonUniqueError(
213
+ path=path,
214
+ root=root,
215
+ first=_get_tail_core(path, first),
216
+ second=_get_tail_core(path, second),
217
+ )
218
+ case _, "earlier":
219
+ return _get_tail_core(path, next(iter(matches)))
220
+ case _, "later":
221
+ return _get_tail_core(path, next(iter(reversed(matches))))
222
+ case never:
223
+ assert_never(never)
224
+
225
+
226
+ def _get_tail_core(path: PathLike, i: int, /) -> Path:
227
+ parts = Path(path).parts
228
+ return Path(*parts[i:])
229
+
230
+
231
+ @dataclass(kw_only=True, slots=True)
232
+ class GetTailError(Exception):
233
+ path: PathLike
234
+ root: PathLike
235
+
236
+
237
+ @dataclass(kw_only=True, slots=True)
238
+ class _GetTailLengthError(GetTailError):
239
+ len_root: int
240
+
241
+ @override
242
+ def __str__(self) -> str:
243
+ return f"Unable to get the tail of {str(self.path)!r} with root of length {self.len_root}"
244
+
245
+
246
+ @dataclass(kw_only=True, slots=True)
247
+ class _GetTailEmptyError(GetTailError):
248
+ @override
249
+ def __str__(self) -> str:
250
+ return (
251
+ f"Unable to get the tail of {str(self.path)!r} with root {str(self.root)!r}"
252
+ )
253
+
254
+
255
+ @dataclass(kw_only=True, slots=True)
256
+ class _GetTailNonUniqueError(GetTailError):
257
+ first: Path
258
+ second: Path
259
+
260
+ @override
261
+ def __str__(self) -> str:
262
+ return f"Path {str(self.path)!r} must contain exactly one tail with root {str(self.root)!r}; got {str(self.first)!r}, {str(self.second)!r} and perhaps more"
263
+
264
+
265
+ ##
266
+
267
+
268
+ def module_path(
269
+ path: PathLike,
270
+ /,
271
+ *,
272
+ root: PathLike | None = None,
273
+ disambiguate: _GetTailDisambiguate = "raise",
274
+ ) -> str:
275
+ """Return a module path."""
276
+ path = Path(path)
277
+ if root is not None:
278
+ path = get_tail(path, root, disambiguate=disambiguate)
279
+ parts = path.with_suffix("").parts
280
+ return ".".join(parts)
281
+
282
+
283
+ ##
284
+
285
+
286
+ def is_sub_path(x: PathLike, y: PathLike, /, *, strict: bool = False) -> bool:
287
+ """Check if a path is a sub path of another."""
288
+ x, y = [Path(i).resolve() for i in [x, y]]
289
+ return x.is_relative_to(y) and not (strict and y.is_relative_to(x))
290
+
291
+
292
+ ##
293
+
294
+
95
295
  def list_dir(path: PathLike, /) -> Sequence[Path]:
96
296
  """List the contents of a directory."""
97
297
  return sorted(Path(path).iterdir())
@@ -100,7 +300,7 @@ def list_dir(path: PathLike, /) -> Sequence[Path]:
100
300
  ##
101
301
 
102
302
 
103
- @contextmanager
303
+ @enhanced_context_manager
104
304
  def temp_cwd(path: PathLike, /) -> Iterator[None]:
105
305
  """Context manager with temporary current working directory set."""
106
306
  prev = Path.cwd()
@@ -111,4 +311,45 @@ def temp_cwd(path: PathLike, /) -> Iterator[None]:
111
311
  chdir(prev)
112
312
 
113
313
 
114
- __all__ = ["PWD", "ensure_suffix", "get_path", "list_dir", "temp_cwd"]
314
+ ##
315
+
316
+
317
+ @overload
318
+ def to_path(path: Sentinel, /) -> Sentinel: ...
319
+ @overload
320
+ def to_path(path: MaybeCallablePathLike | None = Path.cwd, /) -> Path: ...
321
+ def to_path(
322
+ path: MaybeCallablePathLike | None | Sentinel = Path.cwd, /
323
+ ) -> Path | Sentinel:
324
+ """Get the path."""
325
+ match path:
326
+ case Path() | Sentinel():
327
+ return path
328
+ case None:
329
+ return Path.cwd()
330
+ case str():
331
+ return Path(path)
332
+ case Callable() as func:
333
+ return to_path(func())
334
+ case never:
335
+ assert_never(never)
336
+
337
+
338
+ __all__ = [
339
+ "PWD",
340
+ "GetPackageRootError",
341
+ "GetRepoRootError",
342
+ "GetTailError",
343
+ "ensure_suffix",
344
+ "expand_path",
345
+ "get_file_group",
346
+ "get_file_owner",
347
+ "get_package_root",
348
+ "get_repo_root",
349
+ "get_tail",
350
+ "is_sub_path",
351
+ "list_dir",
352
+ "module_path",
353
+ "temp_cwd",
354
+ "to_path",
355
+ ]