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.
- dycw_utilities-0.175.17.dist-info/METADATA +34 -0
- dycw_utilities-0.175.17.dist-info/RECORD +103 -0
- dycw_utilities-0.175.17.dist-info/WHEEL +4 -0
- dycw_utilities-0.175.17.dist-info/entry_points.txt +4 -0
- utilities/__init__.py +1 -1
- utilities/altair.py +14 -14
- utilities/asyncio.py +350 -819
- utilities/atomicwrites.py +18 -6
- utilities/atools.py +77 -22
- utilities/cachetools.py +24 -29
- utilities/click.py +393 -237
- utilities/concurrent.py +8 -11
- utilities/contextlib.py +216 -17
- utilities/contextvars.py +20 -1
- utilities/cryptography.py +3 -3
- utilities/dataclasses.py +83 -118
- utilities/docker.py +293 -0
- utilities/enum.py +26 -23
- utilities/errors.py +17 -3
- utilities/fastapi.py +29 -65
- utilities/fpdf2.py +3 -3
- utilities/functions.py +169 -416
- utilities/functools.py +18 -19
- utilities/git.py +9 -30
- utilities/grp.py +28 -0
- utilities/gzip.py +31 -0
- utilities/http.py +3 -2
- utilities/hypothesis.py +738 -589
- utilities/importlib.py +17 -1
- utilities/inflect.py +25 -0
- utilities/iterables.py +194 -262
- utilities/jinja2.py +148 -0
- utilities/json.py +70 -0
- utilities/libcst.py +38 -17
- utilities/lightweight_charts.py +5 -9
- utilities/logging.py +345 -543
- utilities/math.py +18 -13
- utilities/memory_profiler.py +11 -15
- utilities/more_itertools.py +200 -131
- utilities/operator.py +33 -29
- utilities/optuna.py +6 -6
- utilities/orjson.py +272 -137
- utilities/os.py +61 -4
- utilities/parse.py +59 -61
- utilities/pathlib.py +281 -40
- utilities/permissions.py +298 -0
- utilities/pickle.py +2 -2
- utilities/platform.py +24 -5
- utilities/polars.py +1214 -430
- utilities/polars_ols.py +1 -1
- utilities/postgres.py +408 -0
- utilities/pottery.py +113 -26
- utilities/pqdm.py +10 -11
- utilities/psutil.py +6 -57
- utilities/pwd.py +28 -0
- utilities/pydantic.py +4 -54
- utilities/pydantic_settings.py +240 -0
- utilities/pydantic_settings_sops.py +76 -0
- utilities/pyinstrument.py +8 -10
- utilities/pytest.py +227 -121
- utilities/pytest_plugins/__init__.py +1 -0
- utilities/pytest_plugins/pytest_randomly.py +23 -0
- utilities/pytest_plugins/pytest_regressions.py +56 -0
- utilities/pytest_regressions.py +26 -46
- utilities/random.py +13 -9
- utilities/re.py +58 -28
- utilities/redis.py +401 -550
- utilities/scipy.py +1 -1
- utilities/sentinel.py +10 -0
- utilities/shelve.py +4 -1
- utilities/shutil.py +25 -0
- utilities/slack_sdk.py +36 -106
- utilities/sqlalchemy.py +502 -473
- utilities/sqlalchemy_polars.py +38 -94
- utilities/string.py +2 -3
- utilities/subprocess.py +1572 -0
- utilities/tempfile.py +86 -4
- utilities/testbook.py +50 -0
- utilities/text.py +165 -42
- utilities/timer.py +37 -65
- utilities/traceback.py +158 -929
- utilities/types.py +146 -116
- utilities/typing.py +531 -71
- utilities/tzdata.py +1 -53
- utilities/tzlocal.py +6 -23
- utilities/uuid.py +43 -5
- utilities/version.py +27 -26
- utilities/whenever.py +1776 -386
- utilities/zoneinfo.py +84 -22
- dycw_utilities-0.129.10.dist-info/METADATA +0 -241
- dycw_utilities-0.129.10.dist-info/RECORD +0 -96
- dycw_utilities-0.129.10.dist-info/WHEEL +0 -4
- dycw_utilities-0.129.10.dist-info/licenses/LICENSE +0 -21
- utilities/datetime.py +0 -1409
- utilities/eventkit.py +0 -402
- utilities/loguru.py +0 -144
- utilities/luigi.py +0 -228
- utilities/period.py +0 -324
- utilities/pyrsistent.py +0 -89
- utilities/python_dotenv.py +0 -105
- utilities/streamlit.py +0 -105
- utilities/sys.py +0 -87
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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.
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
62
|
-
"""Get the
|
|
63
|
-
|
|
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"],
|
|
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
|
|
72
|
-
raise
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
+
]
|