dycw-utilities 0.126.12__py3-none-any.whl → 0.129.13__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.126.12.dist-info → dycw_utilities-0.129.13.dist-info}/METADATA +16 -10
- {dycw_utilities-0.126.12.dist-info → dycw_utilities-0.129.13.dist-info}/RECORD +24 -23
- utilities/__init__.py +1 -1
- utilities/aiolimiter.py +25 -0
- utilities/asyncio.py +62 -426
- utilities/datetime.py +0 -8
- utilities/fastapi.py +26 -12
- utilities/git.py +5 -58
- utilities/hypothesis.py +1 -11
- utilities/logging.py +69 -56
- utilities/pathlib.py +83 -13
- utilities/pyinstrument.py +6 -4
- utilities/pytest_regressions.py +2 -2
- utilities/python_dotenv.py +10 -6
- utilities/redis.py +5 -58
- utilities/scipy.py +1 -1
- utilities/slack_sdk.py +2 -54
- utilities/sqlalchemy.py +2 -65
- utilities/traceback.py +278 -12
- utilities/types.py +2 -2
- utilities/version.py +0 -8
- utilities/whenever.py +64 -1
- {dycw_utilities-0.126.12.dist-info → dycw_utilities-0.129.13.dist-info}/WHEEL +0 -0
- {dycw_utilities-0.126.12.dist-info → dycw_utilities-0.129.13.dist-info}/licenses/LICENSE +0 -0
utilities/datetime.py
CHANGED
@@ -509,14 +509,6 @@ def get_datetime(*, datetime: MaybeCallableDateTime) -> dt.datetime: ...
|
|
509
509
|
def get_datetime(*, datetime: None) -> None: ...
|
510
510
|
@overload
|
511
511
|
def get_datetime(*, datetime: Sentinel) -> Sentinel: ...
|
512
|
-
@overload
|
513
|
-
def get_datetime(
|
514
|
-
*, datetime: MaybeCallableDateTime | Sentinel
|
515
|
-
) -> dt.datetime | Sentinel: ...
|
516
|
-
@overload
|
517
|
-
def get_datetime(
|
518
|
-
*, datetime: MaybeCallableDateTime | None | Sentinel = sentinel
|
519
|
-
) -> dt.datetime | None | Sentinel: ...
|
520
512
|
def get_datetime(
|
521
513
|
*, datetime: MaybeCallableDateTime | None | Sentinel = sentinel
|
522
514
|
) -> dt.datetime | None | Sentinel:
|
utilities/fastapi.py
CHANGED
@@ -1,15 +1,18 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
from asyncio import Task, create_task
|
3
4
|
from dataclasses import InitVar, dataclass, field
|
4
|
-
from typing import TYPE_CHECKING, Any, Literal, override
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal, Self, override
|
5
6
|
|
6
7
|
from fastapi import FastAPI
|
7
8
|
from uvicorn import Config, Server
|
8
9
|
|
9
|
-
from utilities.asyncio import
|
10
|
+
from utilities.asyncio import Looper
|
10
11
|
from utilities.datetime import SECOND, datetime_duration_to_float
|
11
12
|
|
12
13
|
if TYPE_CHECKING:
|
14
|
+
from types import TracebackType
|
15
|
+
|
13
16
|
from utilities.types import Duration
|
14
17
|
|
15
18
|
|
@@ -36,7 +39,7 @@ class _PingerReceiverApp(FastAPI):
|
|
36
39
|
|
37
40
|
|
38
41
|
@dataclass(kw_only=True)
|
39
|
-
class PingReceiver(
|
42
|
+
class PingReceiver(Looper[None]):
|
40
43
|
"""A ping receiver."""
|
41
44
|
|
42
45
|
host: InitVar[str] = _LOCALHOST
|
@@ -44,12 +47,31 @@ class PingReceiver(InfiniteLooper):
|
|
44
47
|
_app: _PingerReceiverApp = field(
|
45
48
|
default_factory=_PingerReceiverApp, init=False, repr=False
|
46
49
|
)
|
47
|
-
_await_upon_aenter: bool = field(default=False, init=False, repr=False)
|
48
50
|
_server: Server = field(init=False, repr=False)
|
51
|
+
_server_task: Task[None] | None = field(default=None, init=False, repr=False)
|
49
52
|
|
53
|
+
@override
|
50
54
|
def __post_init__(self, host: str, port: int, /) -> None:
|
55
|
+
super().__post_init__() # skipif-ci
|
51
56
|
self._server = Server(Config(self._app, host=host, port=port)) # skipif-ci
|
52
57
|
|
58
|
+
@override
|
59
|
+
async def __aenter__(self) -> Self:
|
60
|
+
_ = await super().__aenter__() # skipif-ci
|
61
|
+
async with self._lock: # skipif-ci
|
62
|
+
self._server_task = create_task(self._server.serve())
|
63
|
+
return self # skipif-ci
|
64
|
+
|
65
|
+
@override
|
66
|
+
async def __aexit__(
|
67
|
+
self,
|
68
|
+
exc_type: type[BaseException] | None = None,
|
69
|
+
exc_value: BaseException | None = None,
|
70
|
+
traceback: TracebackType | None = None,
|
71
|
+
) -> None:
|
72
|
+
await super().__aexit__(exc_type, exc_value, traceback) # skipif-ci
|
73
|
+
await self._server.shutdown() # skipif-ci
|
74
|
+
|
53
75
|
@classmethod
|
54
76
|
async def ping(
|
55
77
|
cls, port: int, /, *, host: str = _LOCALHOST, timeout: Duration = _TIMEOUT
|
@@ -66,13 +88,5 @@ class PingReceiver(InfiniteLooper):
|
|
66
88
|
return False
|
67
89
|
return response.text if response.status_code == 200 else False # skipif-ci
|
68
90
|
|
69
|
-
@override
|
70
|
-
async def _initialize(self) -> None:
|
71
|
-
await self._server.serve() # skipif-ci
|
72
|
-
|
73
|
-
@override
|
74
|
-
async def _teardown(self) -> None:
|
75
|
-
await self._server.shutdown() # skipif-ci
|
76
|
-
|
77
91
|
|
78
92
|
__all__ = ["PingReceiver"]
|
utilities/git.py
CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from pathlib import Path
|
5
5
|
from re import IGNORECASE, search
|
6
|
-
from subprocess import PIPE, CalledProcessError,
|
6
|
+
from subprocess import PIPE, CalledProcessError, check_output
|
7
7
|
from typing import TYPE_CHECKING, override
|
8
8
|
|
9
9
|
from utilities.pathlib import PWD
|
@@ -12,63 +12,17 @@ if TYPE_CHECKING:
|
|
12
12
|
from utilities.types import PathLike
|
13
13
|
|
14
14
|
|
15
|
-
def
|
16
|
-
"""Fetch the tags."""
|
17
|
-
_ = check_call(["git", "fetch", "--all", "--tags"], cwd=cwd)
|
18
|
-
|
19
|
-
|
20
|
-
##
|
21
|
-
|
22
|
-
|
23
|
-
def get_branch_name(*, cwd: PathLike = PWD) -> str:
|
24
|
-
"""Get the current branch name."""
|
25
|
-
output = check_output(
|
26
|
-
_GIT_REV_PARSE_ABBREV_REV_HEAD, stderr=PIPE, cwd=cwd, text=True
|
27
|
-
)
|
28
|
-
return output.strip("\n")
|
29
|
-
|
30
|
-
|
31
|
-
_GIT_REV_PARSE_ABBREV_REV_HEAD = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
|
32
|
-
|
33
|
-
|
34
|
-
##
|
35
|
-
|
36
|
-
|
37
|
-
def get_ref_tags(ref: str, /, *, cwd: PathLike = PWD) -> list[str]:
|
38
|
-
"""Get the tags of a reference."""
|
39
|
-
output = check_output([*_GIT_TAG_POINTS_AT, ref], stderr=PIPE, cwd=cwd, text=True)
|
40
|
-
return output.strip("\n").splitlines()
|
41
|
-
|
42
|
-
|
43
|
-
_GIT_TAG_POINTS_AT = ["git", "tag", "--points-at"]
|
44
|
-
|
45
|
-
|
46
|
-
##
|
47
|
-
|
48
|
-
|
49
|
-
def get_repo_name(*, cwd: PathLike = PWD) -> str:
|
50
|
-
"""Get the repo name."""
|
51
|
-
output = check_output(_GIT_REMOTE_GET_URL_ORIGIN, stderr=PIPE, cwd=cwd, text=True)
|
52
|
-
return Path(output.strip("\n")).stem # not valid_path
|
53
|
-
|
54
|
-
|
55
|
-
_GIT_REMOTE_GET_URL_ORIGIN = ["git", "remote", "get-url", "origin"]
|
56
|
-
|
57
|
-
|
58
|
-
##
|
59
|
-
|
60
|
-
|
61
|
-
def get_repo_root(*, cwd: PathLike = PWD) -> Path:
|
15
|
+
def get_repo_root(*, path: PathLike = PWD) -> Path:
|
62
16
|
"""Get the repo root."""
|
63
17
|
try:
|
64
18
|
output = check_output(
|
65
|
-
["git", "rev-parse", "--show-toplevel"], stderr=PIPE, cwd=
|
19
|
+
["git", "rev-parse", "--show-toplevel"], stderr=PIPE, cwd=path, text=True
|
66
20
|
)
|
67
21
|
except CalledProcessError as error:
|
68
22
|
# newer versions of git report "Not a git repository", whilst older
|
69
23
|
# versions report "not a git repository"
|
70
24
|
if search("fatal: not a git repository", error.stderr, flags=IGNORECASE):
|
71
|
-
raise GetRepoRootError(cwd=
|
25
|
+
raise GetRepoRootError(cwd=path) from error
|
72
26
|
raise # pragma: no cover
|
73
27
|
else:
|
74
28
|
return Path(output.strip("\n"))
|
@@ -83,11 +37,4 @@ class GetRepoRootError(Exception):
|
|
83
37
|
return f"Path is not part of a `git` repository: {self.cwd}"
|
84
38
|
|
85
39
|
|
86
|
-
__all__ = [
|
87
|
-
"GetRepoRootError",
|
88
|
-
"fetch_all_tags",
|
89
|
-
"get_branch_name",
|
90
|
-
"get_ref_tags",
|
91
|
-
"get_repo_name",
|
92
|
-
"get_repo_root",
|
93
|
-
]
|
40
|
+
__all__ = ["GetRepoRootError", "get_repo_root"]
|
utilities/hypothesis.py
CHANGED
@@ -506,13 +506,7 @@ def floats_extra(
|
|
506
506
|
|
507
507
|
|
508
508
|
@composite
|
509
|
-
def git_repos(
|
510
|
-
draw: DrawFn,
|
511
|
-
/,
|
512
|
-
*,
|
513
|
-
branch: MaybeSearchStrategy[str | None] = None,
|
514
|
-
remote: MaybeSearchStrategy[str | None] = None,
|
515
|
-
) -> Path:
|
509
|
+
def git_repos(draw: DrawFn, /) -> Path:
|
516
510
|
path = draw(temp_paths())
|
517
511
|
with temp_cwd(path):
|
518
512
|
_ = check_call(["git", "init", "-b", "master"])
|
@@ -525,10 +519,6 @@ def git_repos(
|
|
525
519
|
_ = check_call(["git", "commit", "-m", "add"])
|
526
520
|
_ = check_call(["git", "rm", file_str])
|
527
521
|
_ = check_call(["git", "commit", "-m", "rm"])
|
528
|
-
if (branch_ := draw2(draw, branch)) is not None:
|
529
|
-
_ = check_call(["git", "checkout", "-b", branch_])
|
530
|
-
if (remote_ := draw2(draw, remote)) is not None:
|
531
|
-
_ = check_call(["git", "remote", "add", "origin", remote_])
|
532
522
|
return path
|
533
523
|
|
534
524
|
|
utilities/logging.py
CHANGED
@@ -23,7 +23,7 @@ from logging import (
|
|
23
23
|
)
|
24
24
|
from logging.handlers import BaseRotatingHandler, TimedRotatingFileHandler
|
25
25
|
from pathlib import Path
|
26
|
-
from re import Pattern
|
26
|
+
from re import Pattern
|
27
27
|
from sys import stdout
|
28
28
|
from time import time
|
29
29
|
from typing import (
|
@@ -46,9 +46,8 @@ from utilities.datetime import (
|
|
46
46
|
serialize_compact,
|
47
47
|
)
|
48
48
|
from utilities.errors import ImpossibleCaseError
|
49
|
-
from utilities.git import get_repo_root
|
50
49
|
from utilities.iterables import OneEmptyError, always_iterable, one
|
51
|
-
from utilities.pathlib import ensure_suffix,
|
50
|
+
from utilities.pathlib import ensure_suffix, get_path, get_root
|
52
51
|
from utilities.reprlib import (
|
53
52
|
RICH_EXPAND_ALL,
|
54
53
|
RICH_INDENT_SIZE,
|
@@ -68,9 +67,9 @@ if TYPE_CHECKING:
|
|
68
67
|
from utilities.types import (
|
69
68
|
LoggerOrName,
|
70
69
|
LogLevel,
|
70
|
+
MaybeCallablePathLike,
|
71
71
|
MaybeIterable,
|
72
72
|
PathLike,
|
73
|
-
PathLikeOrCallable,
|
74
73
|
)
|
75
74
|
from utilities.version import MaybeCallableVersionLike
|
76
75
|
|
@@ -86,6 +85,9 @@ except ModuleNotFoundError: # pragma: no cover
|
|
86
85
|
type _When = Literal[
|
87
86
|
"S", "M", "H", "D", "midnight", "W0", "W1", "W2", "W3", "W4", "W5", "W6"
|
88
87
|
]
|
88
|
+
_BACKUP_COUNT: int = 100
|
89
|
+
_MAX_BYTES: int = 10 * 1024**2
|
90
|
+
_WHEN: _When = "D"
|
89
91
|
|
90
92
|
|
91
93
|
class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
|
@@ -101,15 +103,16 @@ class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
|
|
101
103
|
encoding: str | None = None,
|
102
104
|
delay: bool = False,
|
103
105
|
errors: Literal["strict", "ignore", "replace"] | None = None,
|
104
|
-
maxBytes: int =
|
105
|
-
when: _When =
|
106
|
+
maxBytes: int = _MAX_BYTES,
|
107
|
+
when: _When = _WHEN,
|
106
108
|
interval: int = 1,
|
107
|
-
backupCount: int =
|
109
|
+
backupCount: int = _BACKUP_COUNT,
|
108
110
|
utc: bool = False,
|
109
111
|
atTime: dt.time | None = None,
|
110
112
|
) -> None:
|
111
|
-
|
112
|
-
|
113
|
+
path = Path(filename)
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
115
|
+
super().__init__(path, mode, encoding=encoding, delay=delay, errors=errors)
|
113
116
|
self._max_bytes = maxBytes if maxBytes >= 1 else None
|
114
117
|
self._backup_count = backupCount if backupCount >= 1 else None
|
115
118
|
self._filename = Path(self.baseFilename)
|
@@ -118,7 +121,7 @@ class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
|
|
118
121
|
self._suffix = self._filename.suffix
|
119
122
|
self._patterns = _compute_rollover_patterns(self._stem, self._suffix)
|
120
123
|
self._time_handler = TimedRotatingFileHandler(
|
121
|
-
|
124
|
+
path,
|
122
125
|
when=when,
|
123
126
|
interval=interval,
|
124
127
|
backupCount=backupCount,
|
@@ -161,13 +164,11 @@ class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
|
|
161
164
|
def _should_rollover(self, record: LogRecord, /) -> bool:
|
162
165
|
if self._max_bytes is not None: # skipif-ci-and-windows
|
163
166
|
try:
|
164
|
-
|
167
|
+
size = self._filename.stat().st_size
|
165
168
|
except FileNotFoundError:
|
166
169
|
pass
|
167
170
|
else:
|
168
|
-
|
169
|
-
new = current + delta
|
170
|
-
if new >= self._max_bytes:
|
171
|
+
if size >= self._max_bytes:
|
171
172
|
return True
|
172
173
|
return bool(self._time_handler.shouldRollover(record)) # skipif-ci-and-windows
|
173
174
|
|
@@ -383,10 +384,10 @@ class StandaloneFileHandler(Handler):
|
|
383
384
|
|
384
385
|
@override
|
385
386
|
def __init__(
|
386
|
-
self, *, level: int = NOTSET, path:
|
387
|
+
self, *, level: int = NOTSET, path: MaybeCallablePathLike | None = None
|
387
388
|
) -> None:
|
388
389
|
super().__init__(level=level)
|
389
|
-
self._path = path
|
390
|
+
self._path = get_path(path=path)
|
390
391
|
|
391
392
|
@override
|
392
393
|
def emit(self, record: LogRecord) -> None:
|
@@ -394,10 +395,8 @@ class StandaloneFileHandler(Handler):
|
|
394
395
|
from utilities.tzlocal import get_now_local
|
395
396
|
|
396
397
|
try:
|
397
|
-
path = (
|
398
|
-
|
399
|
-
.joinpath(serialize_compact(get_now_local()))
|
400
|
-
.with_suffix(".txt")
|
398
|
+
path = self._path.joinpath(serialize_compact(get_now_local())).with_suffix(
|
399
|
+
".txt"
|
401
400
|
)
|
402
401
|
formatted = self.format(record)
|
403
402
|
with writer(path, overwrite=True) as temp, temp.open(mode="w") as fh:
|
@@ -420,16 +419,53 @@ def add_filters(handler: Handler, /, *filters: _FilterType) -> None:
|
|
420
419
|
|
421
420
|
def basic_config(
|
422
421
|
*,
|
423
|
-
|
422
|
+
obj: LoggerOrName | Handler | None = None,
|
423
|
+
format_: str = "{asctime} | {name} | {levelname:8} | {message}",
|
424
|
+
whenever: bool = False,
|
424
425
|
level: LogLevel = "INFO",
|
426
|
+
plain: bool = False,
|
425
427
|
) -> None:
|
426
428
|
"""Do the basic config."""
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
429
|
+
if whenever:
|
430
|
+
format_ = format_.replace("{asctime}", "{zoned_datetime}")
|
431
|
+
datefmt = maybe_sub_pct_y("%Y-%m-%d %H:%M:%S")
|
432
|
+
match obj:
|
433
|
+
case None:
|
434
|
+
basicConfig(format=format_, datefmt=datefmt, style="{", level=level)
|
435
|
+
case Logger() as logger:
|
436
|
+
logger.setLevel(level)
|
437
|
+
logger.addHandler(handler := StreamHandler())
|
438
|
+
basic_config(
|
439
|
+
obj=handler,
|
440
|
+
format_=format_,
|
441
|
+
whenever=whenever,
|
442
|
+
level=level,
|
443
|
+
plain=plain,
|
444
|
+
)
|
445
|
+
case str() as name:
|
446
|
+
basic_config(
|
447
|
+
obj=get_logger(logger=name),
|
448
|
+
format_=format_,
|
449
|
+
whenever=whenever,
|
450
|
+
level=level,
|
451
|
+
plain=plain,
|
452
|
+
)
|
453
|
+
case Handler() as handler:
|
454
|
+
handler.setLevel(level)
|
455
|
+
if plain:
|
456
|
+
formatter = Formatter(fmt=format_, datefmt=datefmt, style="{")
|
457
|
+
else:
|
458
|
+
try:
|
459
|
+
from coloredlogs import ColoredFormatter
|
460
|
+
except ModuleNotFoundError: # pragma: no cover
|
461
|
+
formatter = Formatter(fmt=format_, datefmt=datefmt, style="{")
|
462
|
+
else:
|
463
|
+
formatter = ColoredFormatter(
|
464
|
+
fmt=format_, datefmt=datefmt, style="{"
|
465
|
+
)
|
466
|
+
handler.setFormatter(formatter)
|
467
|
+
case _ as never:
|
468
|
+
assert_never(never)
|
433
469
|
|
434
470
|
|
435
471
|
##
|
@@ -473,7 +509,7 @@ class FilterForKeyError(Exception):
|
|
473
509
|
|
474
510
|
def get_default_logging_path() -> Path:
|
475
511
|
"""Get the logging default path."""
|
476
|
-
return
|
512
|
+
return get_root().joinpath(".logs")
|
477
513
|
|
478
514
|
|
479
515
|
##
|
@@ -520,11 +556,11 @@ def setup_logging(
|
|
520
556
|
console_level: LogLevel | None = "INFO",
|
521
557
|
console_filters: Iterable[_FilterType] | None = None,
|
522
558
|
console_fmt: str = "❯ {_zoned_datetime_str} | {name}:{funcName}:{lineno} | {message}", # noqa: RUF001
|
523
|
-
files_dir:
|
524
|
-
files_when: _When =
|
559
|
+
files_dir: MaybeCallablePathLike | None = get_default_logging_path,
|
560
|
+
files_when: _When = _WHEN,
|
525
561
|
files_interval: int = 1,
|
526
|
-
files_backup_count: int =
|
527
|
-
files_max_bytes: int =
|
562
|
+
files_backup_count: int = _BACKUP_COUNT,
|
563
|
+
files_max_bytes: int = _MAX_BYTES,
|
528
564
|
files_filters: Iterable[_FilterType] | None = None,
|
529
565
|
files_fmt: str = "{_zoned_datetime_str} | {name}:{funcName}:{lineno} | {levelname:8} | {message}",
|
530
566
|
filters: MaybeIterable[_FilterType] | None = None,
|
@@ -616,7 +652,7 @@ def setup_logging(
|
|
616
652
|
logger_use.addHandler(console_high_and_exc_handler)
|
617
653
|
|
618
654
|
# debug & info
|
619
|
-
directory =
|
655
|
+
directory = get_path(path=files_dir) # skipif-ci-and-windows
|
620
656
|
levels: list[LogLevel] = ["DEBUG", "INFO"] # skipif-ci-and-windows
|
621
657
|
for level, (subpath, files_or_plain_formatter) in product( # skipif-ci-and-windows
|
622
658
|
levels, [(Path(), files_formatter), (Path("plain"), plain_formatter)]
|
@@ -754,29 +790,6 @@ class _AdvancedLogRecord(LogRecord):
|
|
754
790
|
cls.time_zone = time_zone.key # skipif-ci-and-windows
|
755
791
|
super().__init_subclass__(**kwargs) # skipif-ci-and-windows
|
756
792
|
|
757
|
-
@override
|
758
|
-
def getMessage(self) -> str:
|
759
|
-
"""Return the message for this LogRecord."""
|
760
|
-
msg = str(self.msg) # pragma: no cover
|
761
|
-
if self.args: # pragma: no cover
|
762
|
-
try:
|
763
|
-
return msg % self.args # compability for 3rd party code
|
764
|
-
except ValueError as error:
|
765
|
-
if len(error.args) == 0:
|
766
|
-
raise
|
767
|
-
first = error.args[0]
|
768
|
-
if search("unsupported format character", first):
|
769
|
-
return msg.format(*self.args)
|
770
|
-
raise
|
771
|
-
except TypeError as error:
|
772
|
-
if len(error.args) == 0:
|
773
|
-
raise
|
774
|
-
first = error.args[0]
|
775
|
-
if search("not all arguments converted", first):
|
776
|
-
return msg.format(*self.args)
|
777
|
-
raise
|
778
|
-
return msg # pragma: no cover
|
779
|
-
|
780
793
|
@classmethod
|
781
794
|
def get_now(cls) -> Any:
|
782
795
|
"""Get the current zoned datetime."""
|
utilities/pathlib.py
CHANGED
@@ -1,15 +1,22 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from
|
3
|
+
from collections.abc import Callable
|
4
|
+
from contextlib import contextmanager, suppress
|
5
|
+
from dataclasses import dataclass
|
4
6
|
from itertools import chain
|
5
7
|
from os import chdir
|
8
|
+
from os.path import expandvars
|
6
9
|
from pathlib import Path
|
7
|
-
from
|
10
|
+
from re import IGNORECASE, search
|
11
|
+
from subprocess import PIPE, CalledProcessError, check_output
|
12
|
+
from typing import TYPE_CHECKING, assert_never, overload, override
|
13
|
+
|
14
|
+
from utilities.sentinel import Sentinel, sentinel
|
8
15
|
|
9
16
|
if TYPE_CHECKING:
|
10
17
|
from collections.abc import Iterator, Sequence
|
11
18
|
|
12
|
-
from utilities.types import
|
19
|
+
from utilities.types import MaybeCallablePathLike, PathLike
|
13
20
|
|
14
21
|
PWD = Path.cwd()
|
15
22
|
|
@@ -25,20 +32,83 @@ def ensure_suffix(path: PathLike, suffix: str, /) -> Path:
|
|
25
32
|
return path.with_name(name)
|
26
33
|
|
27
34
|
|
28
|
-
|
29
|
-
|
30
|
-
|
35
|
+
##
|
36
|
+
|
37
|
+
|
38
|
+
def expand_path(path: PathLike, /) -> Path:
|
39
|
+
"""Expand a path."""
|
40
|
+
path = str(path)
|
41
|
+
path = expandvars(path)
|
42
|
+
return Path(path).expanduser()
|
43
|
+
|
31
44
|
|
45
|
+
##
|
32
46
|
|
33
|
-
|
34
|
-
|
47
|
+
|
48
|
+
@overload
|
49
|
+
def get_path(*, path: MaybeCallablePathLike | None) -> Path: ...
|
50
|
+
@overload
|
51
|
+
def get_path(*, path: Sentinel) -> Sentinel: ...
|
52
|
+
def get_path(
|
53
|
+
*, path: MaybeCallablePathLike | None | Sentinel = sentinel
|
54
|
+
) -> Path | None | Sentinel:
|
55
|
+
"""Get the path."""
|
35
56
|
match path:
|
57
|
+
case Path() | Sentinel():
|
58
|
+
return path
|
59
|
+
case str():
|
60
|
+
return Path(path)
|
36
61
|
case None:
|
37
62
|
return Path.cwd()
|
38
|
-
case
|
39
|
-
return
|
40
|
-
case _:
|
41
|
-
|
63
|
+
case Callable() as func:
|
64
|
+
return get_path(path=func())
|
65
|
+
case _ as never:
|
66
|
+
assert_never(never)
|
67
|
+
|
68
|
+
|
69
|
+
##
|
70
|
+
|
71
|
+
|
72
|
+
def get_root(*, path: MaybeCallablePathLike | None = None) -> Path:
|
73
|
+
"""Get the root of a path."""
|
74
|
+
path = get_path(path=path)
|
75
|
+
try:
|
76
|
+
output = check_output(
|
77
|
+
["git", "rev-parse", "--show-toplevel"], stderr=PIPE, cwd=path, text=True
|
78
|
+
)
|
79
|
+
except CalledProcessError as error:
|
80
|
+
# newer versions of git report "Not a git repository", whilst older
|
81
|
+
# versions report "not a git repository"
|
82
|
+
if not search("fatal: not a git repository", error.stderr, flags=IGNORECASE):
|
83
|
+
raise # pragma: no cover
|
84
|
+
else:
|
85
|
+
return Path(output.strip("\n"))
|
86
|
+
all_paths = list(chain([path], path.parents))
|
87
|
+
with suppress(StopIteration):
|
88
|
+
return next(
|
89
|
+
p for p in all_paths if any(p_i.name == ".envrc" for p_i in p.iterdir())
|
90
|
+
)
|
91
|
+
raise GetRootError(path=path)
|
92
|
+
|
93
|
+
|
94
|
+
@dataclass(kw_only=True, slots=True)
|
95
|
+
class GetRootError(Exception):
|
96
|
+
path: PathLike
|
97
|
+
|
98
|
+
@override
|
99
|
+
def __str__(self) -> str:
|
100
|
+
return f"Unable to determine root from {str(self.path)!r}"
|
101
|
+
|
102
|
+
|
103
|
+
##
|
104
|
+
|
105
|
+
|
106
|
+
def list_dir(path: PathLike, /) -> Sequence[Path]:
|
107
|
+
"""List the contents of a directory."""
|
108
|
+
return sorted(Path(path).iterdir())
|
109
|
+
|
110
|
+
|
111
|
+
##
|
42
112
|
|
43
113
|
|
44
114
|
@contextmanager
|
@@ -52,4 +122,4 @@ def temp_cwd(path: PathLike, /) -> Iterator[None]:
|
|
52
122
|
chdir(prev)
|
53
123
|
|
54
124
|
|
55
|
-
__all__ = ["ensure_suffix", "
|
125
|
+
__all__ = ["PWD", "ensure_suffix", "expand_path", "get_path", "list_dir", "temp_cwd"]
|
utilities/pyinstrument.py
CHANGED
@@ -7,23 +7,25 @@ from typing import TYPE_CHECKING
|
|
7
7
|
from pyinstrument.profiler import Profiler
|
8
8
|
|
9
9
|
from utilities.datetime import serialize_compact
|
10
|
-
from utilities.pathlib import
|
10
|
+
from utilities.pathlib import get_path
|
11
11
|
from utilities.tzlocal import get_now_local
|
12
12
|
|
13
13
|
if TYPE_CHECKING:
|
14
14
|
from collections.abc import Iterator
|
15
15
|
|
16
|
-
from utilities.types import
|
16
|
+
from utilities.types import MaybeCallablePathLike
|
17
17
|
|
18
18
|
|
19
19
|
@contextmanager
|
20
|
-
def profile(*, path:
|
20
|
+
def profile(*, path: MaybeCallablePathLike | None = Path.cwd) -> Iterator[None]:
|
21
21
|
"""Profile the contents of a block."""
|
22
22
|
from utilities.atomicwrites import writer
|
23
23
|
|
24
24
|
with Profiler() as profiler:
|
25
25
|
yield
|
26
|
-
filename =
|
26
|
+
filename = get_path(path=path).joinpath(
|
27
|
+
f"profile__{serialize_compact(get_now_local())}.html"
|
28
|
+
)
|
27
29
|
with writer(filename) as temp, temp.open(mode="w") as fh:
|
28
30
|
_ = fh.write(profiler.output_html())
|
29
31
|
|
utilities/pytest_regressions.py
CHANGED
@@ -10,8 +10,8 @@ from pytest import fixture
|
|
10
10
|
from pytest_regressions.file_regression import FileRegressionFixture
|
11
11
|
|
12
12
|
from utilities.functions import ensure_str
|
13
|
-
from utilities.git import get_repo_root
|
14
13
|
from utilities.operator import is_equal
|
14
|
+
from utilities.pathlib import get_root
|
15
15
|
from utilities.pytest import node_id_to_path
|
16
16
|
|
17
17
|
if TYPE_CHECKING:
|
@@ -153,7 +153,7 @@ def polars_regression(
|
|
153
153
|
|
154
154
|
def _get_path(request: FixtureRequest, /) -> Path:
|
155
155
|
tail = node_id_to_path(request.node.nodeid, head=_PATH_TESTS)
|
156
|
-
return
|
156
|
+
return get_root().joinpath(_PATH_TESTS, "regressions", tail)
|
157
157
|
|
158
158
|
|
159
159
|
__all__ = [
|
utilities/python_dotenv.py
CHANGED
@@ -2,29 +2,33 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from os import environ
|
5
|
+
from pathlib import Path
|
5
6
|
from typing import TYPE_CHECKING, override
|
6
7
|
|
7
8
|
from dotenv import dotenv_values
|
8
9
|
|
9
10
|
from utilities.dataclasses import _ParseDataClassMissingValuesError, parse_dataclass
|
10
|
-
from utilities.git import get_repo_root
|
11
11
|
from utilities.iterables import MergeStrMappingsError, merge_str_mappings
|
12
|
-
from utilities.pathlib import
|
12
|
+
from utilities.pathlib import get_root
|
13
13
|
from utilities.reprlib import get_repr
|
14
14
|
|
15
15
|
if TYPE_CHECKING:
|
16
16
|
from collections.abc import Mapping
|
17
17
|
from collections.abc import Set as AbstractSet
|
18
|
-
from pathlib import Path
|
19
18
|
|
20
|
-
from utilities.types import
|
19
|
+
from utilities.types import (
|
20
|
+
MaybeCallablePathLike,
|
21
|
+
ParseObjectExtra,
|
22
|
+
StrMapping,
|
23
|
+
TDataclass,
|
24
|
+
)
|
21
25
|
|
22
26
|
|
23
27
|
def load_settings(
|
24
28
|
cls: type[TDataclass],
|
25
29
|
/,
|
26
30
|
*,
|
27
|
-
|
31
|
+
path: MaybeCallablePathLike | None = Path.cwd,
|
28
32
|
globalns: StrMapping | None = None,
|
29
33
|
localns: StrMapping | None = None,
|
30
34
|
warn_name_errors: bool = False,
|
@@ -33,7 +37,7 @@ def load_settings(
|
|
33
37
|
extra_parsers: ParseObjectExtra | None = None,
|
34
38
|
) -> TDataclass:
|
35
39
|
"""Load a set of settings from the `.env` file."""
|
36
|
-
path =
|
40
|
+
path = get_root(path=path).joinpath(".env")
|
37
41
|
if not path.exists():
|
38
42
|
raise _LoadSettingsFileNotFoundError(path=path) from None
|
39
43
|
maybe_values_dotenv = dotenv_values(path)
|