python-corekit 0.5.0__py3-none-any.whl → 0.6.0__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.
- corekit/docker/__init__.py +4 -2
- corekit/docker/client.py +48 -0
- corekit/docker/constants.py +19 -0
- corekit/docker/disk/__init__.py +8 -0
- corekit/docker/disk/constants.py +14 -0
- corekit/docker/disk/inspector.py +217 -0
- corekit/docker/disk/models.py +44 -0
- corekit/docker/watchdog.py +10 -8
- corekit/exceptions/__init__.py +2 -0
- corekit/exceptions/base.py +19 -0
- corekit/files/__init__.py +3 -0
- corekit/files/disk.py +123 -0
- corekit/process/__init__.py +7 -0
- corekit/process/runner.py +47 -0
- corekit/schemas/__init__.py +2 -0
- corekit/schemas/size.py +85 -0
- corekit/utils/__init__.py +14 -0
- corekit/utils/coercion.py +14 -14
- corekit/utils/collections.py +109 -4
- {python_corekit-0.5.0.dist-info → python_corekit-0.6.0.dist-info}/METADATA +3 -2
- {python_corekit-0.5.0.dist-info → python_corekit-0.6.0.dist-info}/RECORD +24 -14
- {python_corekit-0.5.0.dist-info → python_corekit-0.6.0.dist-info}/WHEEL +1 -1
- {python_corekit-0.5.0.dist-info → python_corekit-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.5.0.dist-info → python_corekit-0.6.0.dist-info}/top_level.txt +0 -0
corekit/docker/__init__.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Docker container control.
|
|
2
|
+
Docker container control and disk inspection.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from corekit.docker.client import DockerClientProvider
|
|
6
|
+
from corekit.docker.disk import ContainerLog, DfCategory, DockerDiskInspector, ImageSize
|
|
5
7
|
from corekit.docker.watchdog import Watchdog
|
|
6
8
|
|
|
7
|
-
__all__ = ["Watchdog"]
|
|
9
|
+
__all__ = ["ContainerLog", "DfCategory", "DockerClientProvider", "DockerDiskInspector", "ImageSize", "Watchdog"]
|
corekit/docker/client.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Getting a Docker SDK client without assuming where the daemon lives.
|
|
3
|
+
|
|
4
|
+
``DockerClientProvider`` is the one place that resolves the endpoint:
|
|
5
|
+
an explicit host wins, then ``$DOCKER_HOST``, then the first socket that
|
|
6
|
+
actually exists among the usual locations (including snap's), and only
|
|
7
|
+
then the classic default. ``Watchdog`` and ``DockerDiskInspector`` both
|
|
8
|
+
build their clients through here, so a daemon that moves (classic install
|
|
9
|
+
to snap, say) is found by both.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from docker import DockerClient
|
|
17
|
+
|
|
18
|
+
from corekit.docker.constants import DEFAULT_DOCKER_HOST, DOCKER_SOCKET_CANDIDATES
|
|
19
|
+
|
|
20
|
+
__all__ = ["DockerClientProvider"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DockerClientProvider:
|
|
24
|
+
"""
|
|
25
|
+
Resolves Docker endpoints and builds SDK clients for them.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def resolve_host(docker_host: str | None = None) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Resolve the endpoint to use, without connecting to it.
|
|
32
|
+
"""
|
|
33
|
+
if docker_host:
|
|
34
|
+
return docker_host
|
|
35
|
+
from_env = os.environ.get("DOCKER_HOST")
|
|
36
|
+
if from_env:
|
|
37
|
+
return from_env
|
|
38
|
+
for candidate in DOCKER_SOCKET_CANDIDATES:
|
|
39
|
+
if candidate.exists():
|
|
40
|
+
return f"unix://{candidate}"
|
|
41
|
+
return DEFAULT_DOCKER_HOST
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def create_client(docker_host: str | None = None) -> DockerClient:
|
|
45
|
+
"""
|
|
46
|
+
Build an SDK client for the resolved endpoint.
|
|
47
|
+
"""
|
|
48
|
+
return DockerClient(base_url=DockerClientProvider.resolve_host(docker_host))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Docker daemon constants.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock"
|
|
8
|
+
|
|
9
|
+
# Candidate sockets in preference order. Snap Docker does not use
|
|
10
|
+
# /var/run/docker.sock, which is the single most common reason an SDK
|
|
11
|
+
# client "cannot connect" on an Ubuntu host.
|
|
12
|
+
DOCKER_SOCKET_CANDIDATES: tuple[Path, ...] = (
|
|
13
|
+
Path("/var/run/docker.sock"),
|
|
14
|
+
Path("/run/docker.sock"),
|
|
15
|
+
Path("/var/snap/docker/current/run/docker.sock"),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
WATCHDOG_LABEL = "watchdog"
|
|
19
|
+
WATCHDOG_MANAGED = "true"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Docker disk inspection: what is using the space under the Docker root.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.docker.disk.inspector import DockerDiskInspector
|
|
6
|
+
from corekit.docker.disk.models import ContainerLog, DfCategory, ImageSize
|
|
7
|
+
|
|
8
|
+
__all__ = ["ContainerLog", "DfCategory", "DockerDiskInspector", "ImageSize"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants for Docker disk inspection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# (display name, key in the daemon's df report, dot-path to the entry's size)
|
|
6
|
+
# Volumes nest their size under UsageData; everything else is a top-level field.
|
|
7
|
+
DF_CATEGORIES: tuple[tuple[str, str, str], ...] = (
|
|
8
|
+
("Images", "Images", "Size"),
|
|
9
|
+
("Containers", "Containers", "SizeRw"),
|
|
10
|
+
("Volumes", "Volumes", "UsageData.Size"),
|
|
11
|
+
("Build Cache", "BuildCache", "Size"),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
UNREFERENCED_IMAGE = "<none>:<none>"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Where Docker's disk went.
|
|
3
|
+
|
|
4
|
+
``DockerDiskInspector`` is the read-side counterpart to ``Watchdog``:
|
|
5
|
+
Watchdog changes container state, the inspector measures it -- Docker root
|
|
6
|
+
usage, per-container log sizes, largest volumes and images, and a
|
|
7
|
+
``docker system df``-style summary. It can also truncate container logs,
|
|
8
|
+
which is a measurement remedy rather than lifecycle control, and safe:
|
|
9
|
+
truncating in place reclaims space immediately, deleting would not.
|
|
10
|
+
|
|
11
|
+
Daemon data comes from the Docker SDK (built by ``DockerClientProvider``,
|
|
12
|
+
so snap and classic installs are both found); on-disk sizes come from
|
|
13
|
+
``corekit.files.DiskScanner``, because the daemon does not report log-file
|
|
14
|
+
sizes and its volume accounting does not match ``du``. Filesystem access
|
|
15
|
+
under the Docker root typically needs elevation, which the scanner handles.
|
|
16
|
+
|
|
17
|
+
Daemon payloads are untrusted-shaped: every field is extracted through the
|
|
18
|
+
``parse_*``/``safe_*`` utilities and ``getval`` rather than raw indexing.
|
|
19
|
+
|
|
20
|
+
inspector = DockerDiskInspector()
|
|
21
|
+
inspector.root_dir() # snap-aware: the socket is discovered
|
|
22
|
+
inspector.container_logs() # every log, biggest first
|
|
23
|
+
inspector.truncate_log("api") # after you have looked at it
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from docker import DockerClient
|
|
31
|
+
from docker.errors import APIError, NotFound
|
|
32
|
+
|
|
33
|
+
from corekit.docker.client import DockerClientProvider
|
|
34
|
+
from corekit.docker.disk import constants as disk_constants
|
|
35
|
+
from corekit.docker.disk.models import ContainerLog, DfCategory, ImageSize
|
|
36
|
+
from corekit.files.disk import DiskScanner, PathSize
|
|
37
|
+
from corekit.observability.benchmarkable import Benchmarkable
|
|
38
|
+
from corekit.utils import first_string, getval, parse_dict, safe_dict, safe_int, safe_list, safe_string
|
|
39
|
+
|
|
40
|
+
__all__ = ["DockerDiskInspector"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DockerDiskInspector(Benchmarkable):
|
|
44
|
+
"""
|
|
45
|
+
Measures Docker disk usage via the SDK plus filesystem scans.
|
|
46
|
+
|
|
47
|
+
Unlike Watchdog there is no label enforcement: measuring is read-only,
|
|
48
|
+
and the one mutating operation (log truncation) is addressed by exact
|
|
49
|
+
container name or ID, never by pattern.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
docker_host: str | None = None,
|
|
55
|
+
scanner: DiskScanner | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""
|
|
58
|
+
:param docker_host: Docker endpoint; resolved by
|
|
59
|
+
``DockerClientProvider`` (``$DOCKER_HOST``, existing sockets
|
|
60
|
+
including snap's, then the classic default) when omitted.
|
|
61
|
+
:param scanner: filesystem scanner for on-disk sizes; one with
|
|
62
|
+
elevation enabled by default.
|
|
63
|
+
"""
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.docker_host = DockerClientProvider.resolve_host(docker_host)
|
|
66
|
+
self._scanner = scanner or DiskScanner()
|
|
67
|
+
self._client_instance: DockerClient | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def _client(self) -> DockerClient:
|
|
71
|
+
"""
|
|
72
|
+
The Docker client, connected on first use.
|
|
73
|
+
"""
|
|
74
|
+
if self._client_instance is None:
|
|
75
|
+
self._client_instance = DockerClientProvider.create_client(self.docker_host)
|
|
76
|
+
return self._client_instance
|
|
77
|
+
|
|
78
|
+
# =============
|
|
79
|
+
# Daemon-level queries
|
|
80
|
+
# =============
|
|
81
|
+
|
|
82
|
+
def root_dir(self) -> Path:
|
|
83
|
+
"""
|
|
84
|
+
The daemon's DockerRootDir (e.g. /var/snap/docker/common/var-lib-docker).
|
|
85
|
+
"""
|
|
86
|
+
root = safe_string(getval(safe_dict(self._client.info()), "DockerRootDir"))
|
|
87
|
+
if not root:
|
|
88
|
+
raise RuntimeError("Daemon did not report a DockerRootDir (is docker running?)")
|
|
89
|
+
return Path(root)
|
|
90
|
+
|
|
91
|
+
def df_summary(self) -> list[DfCategory]:
|
|
92
|
+
"""
|
|
93
|
+
Aggregate the daemon's df report into per-category counts and sizes.
|
|
94
|
+
"""
|
|
95
|
+
report = parse_dict(self._client.df()).value
|
|
96
|
+
categories: list[DfCategory] = []
|
|
97
|
+
for display_name, report_key, size_path in disk_constants.DF_CATEGORIES:
|
|
98
|
+
entries = safe_list(getval(report, report_key, default=[]))
|
|
99
|
+
total = sum(safe_int(getval(entry, size_path, default=0)) for entry in entries)
|
|
100
|
+
categories.append(DfCategory(name=display_name, count=len(entries), size_bytes=total))
|
|
101
|
+
return categories
|
|
102
|
+
|
|
103
|
+
def images(self) -> list[ImageSize]:
|
|
104
|
+
"""
|
|
105
|
+
Every image with its daemon-reported size, biggest first.
|
|
106
|
+
"""
|
|
107
|
+
images = [
|
|
108
|
+
ImageSize(
|
|
109
|
+
reference=first_string(safe_list(image.tags), disk_constants.UNREFERENCED_IMAGE),
|
|
110
|
+
image_id=safe_string(image.short_id),
|
|
111
|
+
size_bytes=safe_int(getval(parse_dict(image.attrs).value, "Size")),
|
|
112
|
+
)
|
|
113
|
+
for image in self._client.images.list()
|
|
114
|
+
]
|
|
115
|
+
return sorted(images, key=lambda img: img.size_bytes, reverse=True)
|
|
116
|
+
|
|
117
|
+
# =============
|
|
118
|
+
# Filesystem-backed queries
|
|
119
|
+
# =============
|
|
120
|
+
|
|
121
|
+
def root_children(self, root: Path | None = None) -> list[PathSize]:
|
|
122
|
+
"""
|
|
123
|
+
Largest directories directly under the Docker root.
|
|
124
|
+
"""
|
|
125
|
+
return self._scanner.child_sizes(root or self.root_dir())
|
|
126
|
+
|
|
127
|
+
def volumes(self, root: Path | None = None) -> list[PathSize]:
|
|
128
|
+
"""
|
|
129
|
+
Largest Docker volumes (by on-disk size), biggest first.
|
|
130
|
+
"""
|
|
131
|
+
return self._scanner.child_sizes((root or self.root_dir()) / "volumes")
|
|
132
|
+
|
|
133
|
+
def container_logs(self) -> list[ContainerLog]:
|
|
134
|
+
"""
|
|
135
|
+
Every container's log with its size, biggest first.
|
|
136
|
+
|
|
137
|
+
Containers without a log path (non json-file drivers) or with an
|
|
138
|
+
unmeasurable log file are skipped.
|
|
139
|
+
"""
|
|
140
|
+
logs: list[ContainerLog] = []
|
|
141
|
+
try:
|
|
142
|
+
containers = self._client.containers.list(all=True)
|
|
143
|
+
except APIError as exc:
|
|
144
|
+
self.error(f"Docker API error listing containers: {exc}")
|
|
145
|
+
return []
|
|
146
|
+
for container in containers:
|
|
147
|
+
log = self._measure_container_log(container)
|
|
148
|
+
if log is not None:
|
|
149
|
+
logs.append(log)
|
|
150
|
+
return sorted(logs, key=lambda x: x.size_bytes, reverse=True)
|
|
151
|
+
|
|
152
|
+
def logs_above(self, threshold_bytes: int) -> list[ContainerLog]:
|
|
153
|
+
"""
|
|
154
|
+
Container logs strictly larger than the threshold, biggest first.
|
|
155
|
+
"""
|
|
156
|
+
return [log for log in self.container_logs() if log.size_bytes > threshold_bytes]
|
|
157
|
+
|
|
158
|
+
def log_for(self, identifier: str) -> ContainerLog:
|
|
159
|
+
"""
|
|
160
|
+
Resolve one container by name or ID and measure its log.
|
|
161
|
+
|
|
162
|
+
Raises LookupError if the container does not exist or has no log.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
container = self._client.containers.get(identifier)
|
|
166
|
+
except NotFound as exc:
|
|
167
|
+
raise LookupError(f"Container not found: {identifier}") from exc
|
|
168
|
+
|
|
169
|
+
log = self._measure_container_log(container)
|
|
170
|
+
if log is None:
|
|
171
|
+
raise LookupError(f"Container has no measurable log file (non json-file driver?): {identifier}")
|
|
172
|
+
return log
|
|
173
|
+
|
|
174
|
+
# =============
|
|
175
|
+
# Truncation
|
|
176
|
+
# =============
|
|
177
|
+
|
|
178
|
+
def truncate_log(self, identifier: str) -> ContainerLog:
|
|
179
|
+
"""
|
|
180
|
+
Truncate one container's log; returns its pre-truncate measurement.
|
|
181
|
+
"""
|
|
182
|
+
log = self.log_for(identifier)
|
|
183
|
+
self._scanner.truncate(log.log_path)
|
|
184
|
+
return log
|
|
185
|
+
|
|
186
|
+
def truncate_logs(self, logs: list[ContainerLog]) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Truncate each given container log.
|
|
189
|
+
"""
|
|
190
|
+
for log in logs:
|
|
191
|
+
self._scanner.truncate(log.log_path)
|
|
192
|
+
|
|
193
|
+
# =============
|
|
194
|
+
# Private Methods
|
|
195
|
+
# =============
|
|
196
|
+
|
|
197
|
+
def _measure_container_log(self, container: object) -> ContainerLog | None:
|
|
198
|
+
"""
|
|
199
|
+
Build a ContainerLog for one SDK container, or None when it has no
|
|
200
|
+
log path or the file cannot be measured.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
log_path_text = safe_string(getval(container, "attrs.LogPath", default=""))
|
|
204
|
+
if not log_path_text:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
log_path = Path(log_path_text)
|
|
208
|
+
size = self._scanner.file_size(log_path)
|
|
209
|
+
if size is None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
return ContainerLog(
|
|
213
|
+
container_id=safe_string(getval(container, "id")),
|
|
214
|
+
name=safe_string(getval(container, "name")).lstrip("/"),
|
|
215
|
+
log_path=log_path,
|
|
216
|
+
size_bytes=size,
|
|
217
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data shapes returned by DockerDiskInspector.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
__all__ = ["ContainerLog", "DfCategory", "ImageSize"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ContainerLog:
|
|
15
|
+
"""
|
|
16
|
+
One container's json-file log and its measured size.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
container_id: str
|
|
20
|
+
name: str
|
|
21
|
+
log_path: Path
|
|
22
|
+
size_bytes: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ImageSize:
|
|
27
|
+
"""
|
|
28
|
+
One image and its size as reported by the daemon.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
reference: str
|
|
32
|
+
image_id: str
|
|
33
|
+
size_bytes: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class DfCategory:
|
|
38
|
+
"""
|
|
39
|
+
One row of a ``docker system df``-style summary.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
count: int
|
|
44
|
+
size_bytes: int
|
corekit/docker/watchdog.py
CHANGED
|
@@ -18,13 +18,13 @@ from docker import DockerClient
|
|
|
18
18
|
from docker.errors import APIError, NotFound
|
|
19
19
|
from docker.models.containers import Container
|
|
20
20
|
|
|
21
|
+
from corekit.docker.client import DockerClientProvider
|
|
22
|
+
from corekit.docker.constants import DEFAULT_DOCKER_HOST, WATCHDOG_LABEL, WATCHDOG_MANAGED
|
|
21
23
|
from corekit.observability.benchmarkable import Benchmarkable
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
WATCHDOG_LABEL = "watchdog"
|
|
27
|
-
WATCHDOG_MANAGED = "true"
|
|
25
|
+
# DEFAULT_DOCKER_HOST / WATCHDOG_* are re-exported for backwards
|
|
26
|
+
# compatibility; the canonical definitions live in corekit.docker.constants.
|
|
27
|
+
__all__ = ["DEFAULT_DOCKER_HOST", "WATCHDOG_LABEL", "WATCHDOG_MANAGED", "Watchdog"]
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def _accept_all(*args: Any, **kwargs: Any) -> bool:
|
|
@@ -44,13 +44,15 @@ class Watchdog(Benchmarkable):
|
|
|
44
44
|
|
|
45
45
|
def __init__(self, docker_host: str | None = None, enforce_label: bool = True) -> None:
|
|
46
46
|
"""
|
|
47
|
-
:param docker_host: Docker endpoint. Defaults to the
|
|
47
|
+
:param docker_host: Docker endpoint. Defaults to the endpoint
|
|
48
|
+
resolved by ``DockerClientProvider`` (``$DOCKER_HOST``, then an
|
|
49
|
+
existing socket -- snap included -- then the classic default).
|
|
48
50
|
:param enforce_label: restrict actions to containers carrying the
|
|
49
51
|
managed label. On by default; turn it off only when this watchdog
|
|
50
52
|
is meant to control every container on the host.
|
|
51
53
|
"""
|
|
52
54
|
super().__init__()
|
|
53
|
-
self.docker_host = docker_host
|
|
55
|
+
self.docker_host = DockerClientProvider.resolve_host(docker_host)
|
|
54
56
|
self.enforce_label = enforce_label
|
|
55
57
|
self._client_instance: DockerClient | None = None
|
|
56
58
|
|
|
@@ -63,7 +65,7 @@ class Watchdog(Benchmarkable):
|
|
|
63
65
|
and inspected on a machine where Docker is not running.
|
|
64
66
|
"""
|
|
65
67
|
if self._client_instance is None:
|
|
66
|
-
self._client_instance =
|
|
68
|
+
self._client_instance = DockerClientProvider.create_client(self.docker_host)
|
|
67
69
|
return self._client_instance
|
|
68
70
|
|
|
69
71
|
@staticmethod
|
corekit/exceptions/__init__.py
CHANGED
|
@@ -7,6 +7,7 @@ a user. ``CoreHTTPException`` is the FastAPI-facing public exception.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from corekit.exceptions.base import (
|
|
10
|
+
CommandFailedError,
|
|
10
11
|
CoreException,
|
|
11
12
|
CoreHTTPException,
|
|
12
13
|
ExponentialBackoffTimeoutException,
|
|
@@ -20,6 +21,7 @@ from corekit.exceptions.types import ArbitraryCoreExceptionType, CoreExceptionTy
|
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
22
23
|
"ArbitraryCoreExceptionType",
|
|
24
|
+
"CommandFailedError",
|
|
23
25
|
"CoreException",
|
|
24
26
|
"CoreExceptionType",
|
|
25
27
|
"CoreHTTPException",
|
corekit/exceptions/base.py
CHANGED
|
@@ -27,6 +27,7 @@ from fastapi import HTTPException
|
|
|
27
27
|
from corekit.exceptions.enum import Retryability
|
|
28
28
|
|
|
29
29
|
__all__ = [
|
|
30
|
+
"CommandFailedError",
|
|
30
31
|
"CoreException",
|
|
31
32
|
"CoreHTTPException",
|
|
32
33
|
"RetryableCoreHTTPException",
|
|
@@ -112,6 +113,24 @@ class ExponentialBackoffTimeoutException(InternalCoreException):
|
|
|
112
113
|
self.attempts = attempts
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
class CommandFailedError(InternalCoreException):
|
|
117
|
+
"""
|
|
118
|
+
An external command exited non-zero, timed out, or could not be started.
|
|
119
|
+
|
|
120
|
+
``command`` is the argv that was run; ``detail`` (also carried as the
|
|
121
|
+
shared ``error`` field) is stderr text or the OS-level reason.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, command: list[str], detail: str) -> None:
|
|
125
|
+
super().__init__(
|
|
126
|
+
message=f"Command failed: {' '.join(command)}",
|
|
127
|
+
retryable=Retryability.NON_RETRYABLE,
|
|
128
|
+
error=detail,
|
|
129
|
+
)
|
|
130
|
+
self.command = command
|
|
131
|
+
self.detail = detail
|
|
132
|
+
|
|
133
|
+
|
|
115
134
|
class CoreHTTPException(HTTPException, PublicCoreException):
|
|
116
135
|
"""
|
|
117
136
|
FastAPI HTTPException that is also a PublicCoreException.
|
corekit/files/__init__.py
CHANGED
|
@@ -9,15 +9,18 @@ File reading and writing, with format handled by the manager.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from corekit.files.base import FileContent, FileError, FileManager
|
|
12
|
+
from corekit.files.disk import DiskScanner, PathSize
|
|
12
13
|
from corekit.files.enum import FileMode
|
|
13
14
|
from corekit.files.json import JsonFileManager
|
|
14
15
|
from corekit.files.toml import TomlFileManager
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
18
|
+
"DiskScanner",
|
|
17
19
|
"FileContent",
|
|
18
20
|
"FileError",
|
|
19
21
|
"FileManager",
|
|
20
22
|
"FileMode",
|
|
21
23
|
"JsonFileManager",
|
|
24
|
+
"PathSize",
|
|
22
25
|
"TomlFileManager",
|
|
23
26
|
]
|
corekit/files/disk.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Measuring and truncating files on disk.
|
|
3
|
+
|
|
4
|
+
``DiskScanner`` answers "how big is this path?" for files and directory
|
|
5
|
+
trees, and truncates files in place. Every operation tries direct access
|
|
6
|
+
first and, on failure, retries elevated through the ``CommandRunner``
|
|
7
|
+
(log files and layer directories under a Docker root are root-owned, so an
|
|
8
|
+
unprivileged process typically cannot stat them). Pass ``elevate=False``
|
|
9
|
+
to disable the retry; unmeasurable paths then report ``None`` instead of
|
|
10
|
+
prompting for a password.
|
|
11
|
+
|
|
12
|
+
Sizes for single files are apparent sizes (``stat``); directory sizes are
|
|
13
|
+
``du -sb`` totals. Both are byte counts and directly comparable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from corekit.exceptions import CommandFailedError
|
|
22
|
+
from corekit.process import CommandRunner
|
|
23
|
+
|
|
24
|
+
__all__ = ["DiskScanner", "PathSize"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class PathSize:
|
|
29
|
+
"""
|
|
30
|
+
A filesystem path with its measured size in bytes.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
path: Path
|
|
34
|
+
size_bytes: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DiskScanner:
|
|
38
|
+
"""
|
|
39
|
+
Filesystem sizing and in-place truncation, with optional elevation.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, runner: CommandRunner | None = None, *, elevate: bool = True) -> None:
|
|
43
|
+
"""
|
|
44
|
+
:param runner: command executor for the elevated fallbacks and for
|
|
45
|
+
``du``/``find``; a plain ``CommandRunner`` by default.
|
|
46
|
+
:param elevate: allow sudo retries when direct access fails.
|
|
47
|
+
"""
|
|
48
|
+
self._runner = runner or CommandRunner()
|
|
49
|
+
self._elevate = elevate
|
|
50
|
+
|
|
51
|
+
def file_size(self, path: Path) -> int | None:
|
|
52
|
+
"""
|
|
53
|
+
Size of a single file, or None if it cannot be measured.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
return path.stat().st_size
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
if not self._elevate:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
output = self._runner.run(["stat", "-c", "%s", str(path)], elevate=True)
|
|
63
|
+
return int(output.strip())
|
|
64
|
+
except (CommandFailedError, ValueError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def dir_size(self, path: Path) -> int | None:
|
|
68
|
+
"""
|
|
69
|
+
Recursive size of a path (``du -sb``), or None if unmeasurable.
|
|
70
|
+
"""
|
|
71
|
+
for elevate in (False, True):
|
|
72
|
+
if elevate and not self._elevate:
|
|
73
|
+
break
|
|
74
|
+
try:
|
|
75
|
+
output = self._runner.run(["du", "-sb", "--", str(path)], elevate=elevate)
|
|
76
|
+
return int(output.split()[0])
|
|
77
|
+
except (CommandFailedError, ValueError, IndexError):
|
|
78
|
+
continue
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def child_sizes(self, parent: Path) -> list[PathSize]:
|
|
82
|
+
"""
|
|
83
|
+
Recursive sizes of every direct child of ``parent``, biggest first.
|
|
84
|
+
"""
|
|
85
|
+
sizes: list[PathSize] = []
|
|
86
|
+
for child in self._children(parent):
|
|
87
|
+
size = self.dir_size(child)
|
|
88
|
+
if size is not None:
|
|
89
|
+
sizes.append(PathSize(path=child, size_bytes=size))
|
|
90
|
+
return sorted(sizes, key=lambda entry: entry.size_bytes, reverse=True)
|
|
91
|
+
|
|
92
|
+
def truncate(self, path: Path) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Truncate a file to zero bytes in place.
|
|
95
|
+
|
|
96
|
+
In place matters: deleting a file another process holds open does
|
|
97
|
+
not reclaim its space, truncating it does.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
with path.open("r+b") as handle:
|
|
101
|
+
handle.truncate(0)
|
|
102
|
+
return
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
if not self._elevate:
|
|
106
|
+
raise PermissionError(f"Cannot truncate {path} (elevation disabled)")
|
|
107
|
+
self._runner.run(["truncate", "-s", "0", str(path)], elevate=True)
|
|
108
|
+
|
|
109
|
+
def _children(self, parent: Path) -> list[Path]:
|
|
110
|
+
"""
|
|
111
|
+
Direct children of ``parent``, via elevated find if listing is denied.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
return sorted(parent.iterdir())
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
if not self._elevate:
|
|
118
|
+
return []
|
|
119
|
+
try:
|
|
120
|
+
output = self._runner.run(["find", str(parent), "-mindepth", "1", "-maxdepth", "1"], elevate=True)
|
|
121
|
+
except CommandFailedError:
|
|
122
|
+
return []
|
|
123
|
+
return sorted(Path(line) for line in output.splitlines() if line.strip())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Running external commands.
|
|
3
|
+
|
|
4
|
+
One place that knows how to spawn a process, capture its output, and report
|
|
5
|
+
failure -- so callers stop re-writing the same ``subprocess.run`` block with
|
|
6
|
+
slightly different error handling each time.
|
|
7
|
+
|
|
8
|
+
``elevate=True`` prefixes the command with ``sudo``. That is for filesystem
|
|
9
|
+
operations on root-owned paths (the canonical case is files under a Docker
|
|
10
|
+
root); it is not a general "run as root" switch, and commands that have a
|
|
11
|
+
proper unprivileged API should use it instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
|
|
18
|
+
from corekit.exceptions import CommandFailedError
|
|
19
|
+
|
|
20
|
+
__all__ = ["CommandRunner"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CommandRunner:
|
|
24
|
+
"""
|
|
25
|
+
Runs commands and returns stdout as text.
|
|
26
|
+
|
|
27
|
+
Deliberately a class rather than a function: tests and callers with
|
|
28
|
+
unusual needs (a recording fake, a remote executor) substitute their own
|
|
29
|
+
instance anywhere a runner is accepted.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def run(self, args: list[str], *, elevate: bool = False, timeout: float | None = None) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Run ``args`` and return stdout. Raises CommandFailedError on any
|
|
35
|
+
failure, including a missing executable or a timeout.
|
|
36
|
+
"""
|
|
37
|
+
command = ["sudo", *args] if elevate else list(args)
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=timeout)
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
raise CommandFailedError(command, f"could not execute: {exc}") from exc
|
|
42
|
+
except subprocess.TimeoutExpired as exc:
|
|
43
|
+
raise CommandFailedError(command, f"timed out after {timeout}s") from exc
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
detail = result.stderr.strip() or f"exit code {result.returncode}"
|
|
46
|
+
raise CommandFailedError(command, detail)
|
|
47
|
+
return result.stdout
|
corekit/schemas/__init__.py
CHANGED
|
@@ -7,10 +7,12 @@ raises where it is introduced rather than somewhere further along.
|
|
|
7
7
|
|
|
8
8
|
from corekit.schemas.enum import IntegerEnum, StringEnum, ValidatingEnum
|
|
9
9
|
from corekit.schemas.models.arbitrary import ArbitraryModel
|
|
10
|
+
from corekit.schemas.size import ByteSize
|
|
10
11
|
from corekit.schemas.version import SemanticVersion
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"ArbitraryModel",
|
|
15
|
+
"ByteSize",
|
|
14
16
|
"IntegerEnum",
|
|
15
17
|
"SemanticVersion",
|
|
16
18
|
"StringEnum",
|
corekit/schemas/size.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A byte size (``ByteSize.parse("100M")``, ``str(ByteSize(38 * 1024**2))``).
|
|
3
|
+
|
|
4
|
+
Units are binary throughout (1 KB = 1024 bytes), so a value round-trips:
|
|
5
|
+
``ByteSize.parse(str(size))`` returns an equal size, up to formatting
|
|
6
|
+
precision. Suffixes may be bare (``100M``), short (``100MB``), or explicitly
|
|
7
|
+
binary (``100MiB``); all three spellings mean the same thing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Self
|
|
15
|
+
|
|
16
|
+
__all__ = ["ByteSize"]
|
|
17
|
+
|
|
18
|
+
_SIZE_PATTERN = re.compile(r"^(?P<value>[0-9]+(?:\.[0-9]+)?)\s*(?P<unit>[a-zA-Z]{0,3})$")
|
|
19
|
+
_UNIT_MULTIPLIERS: dict[str, int] = {
|
|
20
|
+
"": 1,
|
|
21
|
+
"B": 1,
|
|
22
|
+
"K": 1024,
|
|
23
|
+
"KB": 1024,
|
|
24
|
+
"KIB": 1024,
|
|
25
|
+
"M": 1024**2,
|
|
26
|
+
"MB": 1024**2,
|
|
27
|
+
"MIB": 1024**2,
|
|
28
|
+
"G": 1024**3,
|
|
29
|
+
"GB": 1024**3,
|
|
30
|
+
"GIB": 1024**3,
|
|
31
|
+
"T": 1024**4,
|
|
32
|
+
"TB": 1024**4,
|
|
33
|
+
"TIB": 1024**4,
|
|
34
|
+
"P": 1024**5,
|
|
35
|
+
"PB": 1024**5,
|
|
36
|
+
"PIB": 1024**5,
|
|
37
|
+
}
|
|
38
|
+
_FORMAT_UNITS: tuple[str, ...] = ("B", "KB", "MB", "GB", "TB", "PB")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, order=True)
|
|
42
|
+
class ByteSize:
|
|
43
|
+
"""
|
|
44
|
+
An immutable byte count that parses and renders itself.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
bytes: int
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def parse(cls, value: ByteSize | int | str) -> Self:
|
|
51
|
+
"""
|
|
52
|
+
Accept a ByteSize, an int byte count, or text like ``"100M"``.
|
|
53
|
+
|
|
54
|
+
Raises ValueError for anything that is not a size.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(value, ByteSize):
|
|
57
|
+
return value
|
|
58
|
+
if isinstance(value, int):
|
|
59
|
+
return cls(bytes=value)
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
match = _SIZE_PATTERN.match(value.strip().upper())
|
|
62
|
+
if match is not None:
|
|
63
|
+
multiplier = _UNIT_MULTIPLIERS.get(match.group("unit"))
|
|
64
|
+
if multiplier is not None:
|
|
65
|
+
return cls(bytes=int(float(match.group("value")) * multiplier))
|
|
66
|
+
raise ValueError(f"Invalid byte size: {value!r} (expected e.g. 500K, 100M, 2G)")
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
value = float(self.bytes)
|
|
70
|
+
unit_index = 0
|
|
71
|
+
while value >= 1024 and unit_index < len(_FORMAT_UNITS) - 1:
|
|
72
|
+
value /= 1024
|
|
73
|
+
unit_index += 1
|
|
74
|
+
if unit_index == 0:
|
|
75
|
+
return f"{self.bytes} {_FORMAT_UNITS[unit_index]}"
|
|
76
|
+
return f"{value:.1f} {_FORMAT_UNITS[unit_index]}"
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return f"ByteSize({self})"
|
|
80
|
+
|
|
81
|
+
def __int__(self) -> int:
|
|
82
|
+
return self.bytes
|
|
83
|
+
|
|
84
|
+
def __float__(self) -> float:
|
|
85
|
+
return float(self.bytes)
|
corekit/utils/__init__.py
CHANGED
|
@@ -23,10 +23,17 @@ from corekit.utils.collections import (
|
|
|
23
23
|
MultiMatch,
|
|
24
24
|
aiterator,
|
|
25
25
|
attr_or_key,
|
|
26
|
+
first,
|
|
27
|
+
first_float,
|
|
28
|
+
first_int,
|
|
29
|
+
first_string,
|
|
30
|
+
getval,
|
|
26
31
|
iterator,
|
|
27
32
|
keygetter,
|
|
28
33
|
kv_string,
|
|
34
|
+
list_map,
|
|
29
35
|
repeated_get,
|
|
36
|
+
set_map,
|
|
30
37
|
split_list,
|
|
31
38
|
)
|
|
32
39
|
from corekit.utils.ids import (
|
|
@@ -64,15 +71,21 @@ __all__ = [
|
|
|
64
71
|
"decode_payload",
|
|
65
72
|
"encode_payload",
|
|
66
73
|
"false_validator",
|
|
74
|
+
"first",
|
|
75
|
+
"first_string",
|
|
76
|
+
"first_int",
|
|
77
|
+
"first_float",
|
|
67
78
|
"generate_session_token",
|
|
68
79
|
"generate_shortcode",
|
|
69
80
|
"generate_shortcodes",
|
|
70
81
|
"generate_uuid",
|
|
82
|
+
"getval",
|
|
71
83
|
"isoformat_now",
|
|
72
84
|
"iterator",
|
|
73
85
|
"join_lines",
|
|
74
86
|
"keygetter",
|
|
75
87
|
"kv_string",
|
|
88
|
+
"list_map",
|
|
76
89
|
"list_to_english",
|
|
77
90
|
"parse_dict",
|
|
78
91
|
"parse_docker_timestamp",
|
|
@@ -91,6 +104,7 @@ __all__ = [
|
|
|
91
104
|
"safe_set",
|
|
92
105
|
"safe_string",
|
|
93
106
|
"safe_tuple",
|
|
107
|
+
"set_map",
|
|
94
108
|
"sanitize_filename",
|
|
95
109
|
"split_list",
|
|
96
110
|
"time_now",
|
corekit/utils/coercion.py
CHANGED
|
@@ -38,7 +38,7 @@ __all__ = [
|
|
|
38
38
|
]
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def safe_string(item: Any, pretty: bool = False) -> str:
|
|
41
|
+
def safe_string(item: Any, pretty: bool = False, default: str = "") -> str:
|
|
42
42
|
"""
|
|
43
43
|
Convert anything to a string, or ``""`` if there is no sensible rendering.
|
|
44
44
|
|
|
@@ -46,44 +46,44 @@ def safe_string(item: Any, pretty: bool = False) -> str:
|
|
|
46
46
|
list instead -- ``"a, b, c"``, or ``"key: value, ..."`` for a mapping --
|
|
47
47
|
which reads better in a notification than raw JSON.
|
|
48
48
|
"""
|
|
49
|
-
return parse_string(item, pretty=pretty, default=
|
|
49
|
+
return parse_string(item, pretty=pretty, default=default).value
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def safe_int(item: Any) -> int:
|
|
52
|
+
def safe_int(item: Any, default: int = 0) -> int:
|
|
53
53
|
"""
|
|
54
54
|
Convert anything to an int, or ``0`` if it will not convert.
|
|
55
55
|
"""
|
|
56
|
-
return parse_int(item, default=
|
|
56
|
+
return parse_int(item, default=default).value
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def safe_float(item: Any) -> float:
|
|
59
|
+
def safe_float(item: Any, default: float = 0.0) -> float:
|
|
60
60
|
"""
|
|
61
61
|
Convert anything to a float, or ``0.0`` if it will not convert.
|
|
62
62
|
"""
|
|
63
|
-
return parse_float(item, default=
|
|
63
|
+
return parse_float(item, default=default).value
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
def safe_list(item: Any) -> list[Any]:
|
|
66
|
+
def safe_list(item: Any, default: list[Any] | None = None) -> list[Any]:
|
|
67
67
|
"""
|
|
68
68
|
Convert a collection to a list, or ``[]`` for anything else.
|
|
69
69
|
|
|
70
70
|
A mapping yields its values. A string is *not* treated as a collection --
|
|
71
71
|
exploding it into characters is almost never what the caller meant.
|
|
72
72
|
"""
|
|
73
|
-
return parse_list(item, default=[]).value
|
|
73
|
+
return parse_list(item, default=default if default is not None else []).value
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
def safe_tuple(item: Any) -> tuple[Any, ...]:
|
|
76
|
+
def safe_tuple(item: Any, default: tuple[Any, ...] | None = None) -> tuple[Any, ...]:
|
|
77
77
|
"""
|
|
78
78
|
Convert a collection to a tuple, or ``()`` for anything else.
|
|
79
79
|
|
|
80
80
|
A mapping yields its values. A string is *not* treated as a collection --
|
|
81
81
|
exploding it into characters is almost never what the caller meant.
|
|
82
82
|
"""
|
|
83
|
-
return parse_tuple(item, default=()).value
|
|
83
|
+
return parse_tuple(item, default=default if default is not None else ()).value
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def safe_set(item: Any) -> set[Any]:
|
|
86
|
+
def safe_set(item: Any, default: set[Any] | None = None) -> set[Any]:
|
|
87
87
|
"""
|
|
88
88
|
Convert a collection to a set, or ``set()`` for anything else.
|
|
89
89
|
|
|
@@ -91,14 +91,14 @@ def safe_set(item: Any) -> set[Any]:
|
|
|
91
91
|
exploding it into characters is almost never what the caller meant.
|
|
92
92
|
Unhashable elements yield ``set()``.
|
|
93
93
|
"""
|
|
94
|
-
return parse_set(item, default=set()).value
|
|
94
|
+
return parse_set(item, default=default if default is not None else set()).value
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
def safe_dict(item: Any) -> dict[str, Any]:
|
|
97
|
+
def safe_dict(item: Any, default: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
98
98
|
"""
|
|
99
99
|
Return the mapping unchanged, or ``{}`` for anything else.
|
|
100
100
|
|
|
101
101
|
Also accepts objects that expose ``to_dict``, ``model_dump``, or ``dict``,
|
|
102
102
|
and falls back to ``__dict__`` when present.
|
|
103
103
|
"""
|
|
104
|
-
return parse_dict(item, default={}).value
|
|
104
|
+
return parse_dict(item, default=default if default is not None else {}).value
|
corekit/utils/collections.py
CHANGED
|
@@ -4,7 +4,8 @@ Nested lookup and small collection helpers.
|
|
|
4
4
|
``repeated_get`` walks a path into nested dictionaries without a pile of
|
|
5
5
|
``.get()`` calls, and ``keygetter`` lets a step in that path match many keys at
|
|
6
6
|
once. Built for JSON from somewhere you do not control, where any level may be
|
|
7
|
-
missing or the wrong type.
|
|
7
|
+
missing or the wrong type. ``getval`` is the mixed-source counterpart: it walks
|
|
8
|
+
dicts and attribute-bearing objects alike, with ordered fallback keys.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
from __future__ import annotations
|
|
@@ -12,16 +13,25 @@ from __future__ import annotations
|
|
|
12
13
|
import inspect
|
|
13
14
|
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Mapping
|
|
14
15
|
from copy import deepcopy
|
|
15
|
-
from typing import Any, Awaitable, Callable, Iterator, Literal
|
|
16
|
+
from typing import Any, Awaitable, Callable, Iterator, Literal, Sequence
|
|
17
|
+
|
|
18
|
+
from corekit.utils.coercion import safe_float, safe_int, safe_string
|
|
16
19
|
|
|
17
20
|
__all__ = [
|
|
18
21
|
"MultiMatch",
|
|
19
22
|
"aiterator",
|
|
20
23
|
"attr_or_key",
|
|
24
|
+
"first",
|
|
25
|
+
"first_float",
|
|
26
|
+
"first_int",
|
|
27
|
+
"first_string",
|
|
28
|
+
"getval",
|
|
21
29
|
"iterator",
|
|
22
30
|
"keygetter",
|
|
23
31
|
"kv_string",
|
|
32
|
+
"list_map",
|
|
24
33
|
"repeated_get",
|
|
34
|
+
"set_map",
|
|
25
35
|
"split_list",
|
|
26
36
|
]
|
|
27
37
|
|
|
@@ -94,7 +104,7 @@ def iterator(
|
|
|
94
104
|
for key, value in obj.items():
|
|
95
105
|
if compact and value is None:
|
|
96
106
|
continue
|
|
97
|
-
if where is not None and not where(key, value): # type: ignore[call-arg, arg-type]
|
|
107
|
+
if where is not None and not bool(where(key, value)): # type: ignore[call-arg, arg-type]
|
|
98
108
|
continue
|
|
99
109
|
yield _mapping_item(key, value, mode)
|
|
100
110
|
return
|
|
@@ -102,7 +112,7 @@ def iterator(
|
|
|
102
112
|
for item in obj:
|
|
103
113
|
if compact and item is None:
|
|
104
114
|
continue
|
|
105
|
-
if where is not None and not where(item): # type: ignore[call-arg, arg-type]
|
|
115
|
+
if where is not None and not bool(where(item)): # type: ignore[call-arg, arg-type]
|
|
106
116
|
continue
|
|
107
117
|
yield item
|
|
108
118
|
|
|
@@ -166,6 +176,101 @@ async def aiterator(
|
|
|
166
176
|
yield item
|
|
167
177
|
|
|
168
178
|
|
|
179
|
+
def first(seq: Sequence[Any], default: Any = None) -> Any:
|
|
180
|
+
"""
|
|
181
|
+
Get the first item from a sequence, or ``default`` if empty.
|
|
182
|
+
"""
|
|
183
|
+
if not seq:
|
|
184
|
+
return default
|
|
185
|
+
return next(iter(seq), default)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def first_string(seq: Sequence[Any], default: str = "") -> str:
|
|
189
|
+
"""
|
|
190
|
+
First item of ``seq`` as a string, or ``default`` if empty/unconvertible.
|
|
191
|
+
"""
|
|
192
|
+
return safe_string(first(seq, default), default=default)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def first_int(seq: Sequence[Any], default: int = 0) -> int:
|
|
196
|
+
"""
|
|
197
|
+
First item of ``seq`` as an int, or ``default`` if empty/unconvertible.
|
|
198
|
+
"""
|
|
199
|
+
return safe_int(first(seq, default), default=default)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def first_float(seq: Sequence[Any], default: float = 0.0) -> float:
|
|
203
|
+
"""
|
|
204
|
+
First item of ``seq`` as a float, or ``default`` if empty/unconvertible.
|
|
205
|
+
"""
|
|
206
|
+
return safe_float(first(seq, default), default=default)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def list_map(
|
|
210
|
+
obj: Iterable[Any] | Mapping[Any, Any] | None,
|
|
211
|
+
func: Callable[[Any], Any],
|
|
212
|
+
*,
|
|
213
|
+
mode: IteratorMode = "keys",
|
|
214
|
+
where: WhereSeq | WhereMap | None = None,
|
|
215
|
+
compact: bool = False,
|
|
216
|
+
) -> list[Any]:
|
|
217
|
+
return [func(item) for item in iterator(obj, mode=mode, where=where, compact=compact)]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def set_map(
|
|
221
|
+
obj: Iterable[Any] | Mapping[Any, Any] | None,
|
|
222
|
+
func: Callable[[Any], Any],
|
|
223
|
+
*,
|
|
224
|
+
mode: IteratorMode = "keys",
|
|
225
|
+
where: WhereSeq | WhereMap | None = None,
|
|
226
|
+
compact: bool = False,
|
|
227
|
+
) -> set[Any]:
|
|
228
|
+
return {func(item) for item in iterator(obj, mode=mode, where=where, compact=compact)}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def getval(
|
|
232
|
+
obj: Any,
|
|
233
|
+
key: str,
|
|
234
|
+
default: Any = None,
|
|
235
|
+
default_factory: Callable[[], Any] | None = None,
|
|
236
|
+
fallback_keys: list[str] | None = None,
|
|
237
|
+
) -> Any:
|
|
238
|
+
"""
|
|
239
|
+
Read `key` (dot-paths supported) from a dict, pydantic model, dataclass, or any other
|
|
240
|
+
arbitrary object. Returns `default` on miss, or `default_factory`, if provided. Use
|
|
241
|
+
`default_factory` for mutable defaults so callers never share an instance. Before the
|
|
242
|
+
default is applied, we will try each item in `fallback_keys` (in order).
|
|
243
|
+
|
|
244
|
+
A key that is present with value ``None`` is a hit, not a miss: ``None``
|
|
245
|
+
is returned and neither fallbacks nor defaults apply. For dict-only walks
|
|
246
|
+
with predicate steps, use ``repeated_get`` instead.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def _get(_obj: Any, _key: str) -> Any:
|
|
250
|
+
if _obj is UNSET or _obj is None:
|
|
251
|
+
return UNSET
|
|
252
|
+
if isinstance(_obj, dict):
|
|
253
|
+
return _obj.get(_key, UNSET)
|
|
254
|
+
if hasattr(_obj, _key):
|
|
255
|
+
return getattr(_obj, _key, UNSET)
|
|
256
|
+
return UNSET
|
|
257
|
+
|
|
258
|
+
value = obj
|
|
259
|
+
for next_key in key.split("."):
|
|
260
|
+
value = _get(value, next_key)
|
|
261
|
+
|
|
262
|
+
if value is not UNSET:
|
|
263
|
+
return value
|
|
264
|
+
|
|
265
|
+
if fallback_keys:
|
|
266
|
+
next_key, *remaining = fallback_keys
|
|
267
|
+
return getval(obj, next_key, default, default_factory, remaining)
|
|
268
|
+
|
|
269
|
+
if default_factory is not None and callable(default_factory):
|
|
270
|
+
return default_factory()
|
|
271
|
+
return default
|
|
272
|
+
|
|
273
|
+
|
|
169
274
|
def kv_string(key: str, value: Any, sep: str = ": ", prefix: str = "") -> str:
|
|
170
275
|
"""
|
|
171
276
|
Format a key-value pair into a string.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-corekit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Shared foundations for Python projects: logging, benchmarking, registries, FastAPI application and routers, SQL statements and migrations, background tasks, ETL, LLM chat/tool primitives, and HTML scraping
|
|
5
5
|
Author: Steven Jacobsen
|
|
6
6
|
License-Expression: MIT
|
|
@@ -433,6 +433,7 @@ corekit/
|
|
|
433
433
|
|
|
434
434
|
observability/ Loggable, Benchmarkable, Timer
|
|
435
435
|
registry/ schemas/ utils/ registries, enums and fields, helpers
|
|
436
|
+
process/ CommandRunner (external commands)
|
|
436
437
|
data/ Dataset and its filter expressions
|
|
437
438
|
jobs/ queue-independent background tasks
|
|
438
439
|
crypto/ files/ serialization/
|
|
@@ -445,7 +446,7 @@ corekit/
|
|
|
445
446
|
http/ BaseHttpClient, BaseApiClient, retries, responses
|
|
446
447
|
|
|
447
448
|
api/ Application, lifespan, middleware, routers
|
|
448
|
-
docker/ notifications/ etl/
|
|
449
|
+
docker/ notifications/ etl/ Watchdog, DockerDiskInspector, client provider
|
|
449
450
|
llm/ messages, tools, agentic loop (no GenerationClient)
|
|
450
451
|
|
|
451
452
|
events/ log_monitor/ built on the capabilities above
|
|
@@ -51,8 +51,14 @@ corekit/data/expressions/target.py,sha256=QSl7sH1qFVUc-mL7gsasTsCahWKhHqdo0bMHt-
|
|
|
51
51
|
corekit/decorators/__init__.py,sha256=YrBcc_4wkSs7nEb3OjC7C7a8fNEvBk3RzOpD8QccHKM,83
|
|
52
52
|
corekit/decorators/exception_handling.py,sha256=p86gfvdmsmC7_va042NSoGkWrJrbLLuw0ncE131wLWU,2326
|
|
53
53
|
corekit/decorators/warnings.py,sha256=RS7AS6OS05VxiWaMjkj6kYky9FbpT4Z-Cb7TQoPOObk,1109
|
|
54
|
-
corekit/docker/__init__.py,sha256=
|
|
55
|
-
corekit/docker/
|
|
54
|
+
corekit/docker/__init__.py,sha256=UnJd13PrmnYIprkDrX34nMhCRXyWPv74X4loobMFq6w,358
|
|
55
|
+
corekit/docker/client.py,sha256=3FO_MNLbMGAfErBv78rmx6052vr5SSaEBlOoRJPVhJQ,1519
|
|
56
|
+
corekit/docker/constants.py,sha256=tIMfN5ii3ht5tiWXowW8UmjQWcr9Rmn681Llysh9qIM,523
|
|
57
|
+
corekit/docker/watchdog.py,sha256=L-yZWlbteShSdMKPDT-7JVzqfQU8MT1XDEFVWF11aY8,9268
|
|
58
|
+
corekit/docker/disk/__init__.py,sha256=ZTr77TJuUdVvv6wbWXvYW0I5oVz45EwzbGjbjrUy1vk,295
|
|
59
|
+
corekit/docker/disk/constants.py,sha256=4fMEAdWwHbiBbe7aj3vV4molxRGZ3eLl1tyIPQ47D1o,465
|
|
60
|
+
corekit/docker/disk/inspector.py,sha256=YBRCX6upUclOPyu2PT2VkfyXKUPXJUv6Wwd_XYvA7lU,8156
|
|
61
|
+
corekit/docker/disk/models.py,sha256=9z8dTUyTEp8NfMmzBul2yp8sLAcEjoMytz_gCj7veKY,733
|
|
56
62
|
corekit/etl/__init__.py,sha256=KFuGv6xstJbEJHHYo_X5akEq_b7muDyKXCGdFeZ3_0M,1369
|
|
57
63
|
corekit/etl/connection.py,sha256=_gWqR36x5f7qb5IYJjrc_7d_6oU2CXm1QeSNCCh0coo,1270
|
|
58
64
|
corekit/etl/orchestrator.py,sha256=GkecTu8gKt17ePDM9PJBUuZTPYoPMdK1yYzBHjMdw3A,7959
|
|
@@ -83,14 +89,15 @@ corekit/exceptionator/fingerprint.py,sha256=Fjf02FFOq3pm4o88F0I7QDy7Nq9EwgA01jjp
|
|
|
83
89
|
corekit/exceptionator/guard.py,sha256=SU95rYpadfqgAUogmXSQyHvHCP7xATADlQyOTwLO_Qk,1180
|
|
84
90
|
corekit/exceptionator/jobs.py,sha256=ufQZNc5bbEsl3IYyCuTtaShXh0-lSbCReTouZHqpv9k,1340
|
|
85
91
|
corekit/exceptionator/models.py,sha256=CQxKKzBBerkEqdUnuPVyXflzx2r9cjFzUpG8K8VizhE,2437
|
|
86
|
-
corekit/exceptions/__init__.py,sha256=
|
|
87
|
-
corekit/exceptions/base.py,sha256=
|
|
92
|
+
corekit/exceptions/__init__.py,sha256=_csPo9yMZrRTlz2CQfM2bePtDNGPeAksWkKy8EanhLQ,1045
|
|
93
|
+
corekit/exceptions/base.py,sha256=aq_5QSePwI1xowIUv7Tndjlv0r8YI9wT2xJL45fQa-A,6206
|
|
88
94
|
corekit/exceptions/enum.py,sha256=D-YL-cZXs-F2jS1GgRBhPWmuqBfX33B1QP61vAXdAes,324
|
|
89
95
|
corekit/exceptions/types.py,sha256=rxhQ8sghV2TCHh_vB7tX6Yb6Vkyv1PF_0PLtalyFVxY,665
|
|
90
96
|
corekit/exceptions/custom/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
91
97
|
corekit/exceptions/http/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
92
|
-
corekit/files/__init__.py,sha256=
|
|
98
|
+
corekit/files/__init__.py,sha256=SnN2PGTQ5D4nkBTb3pnXr5dPzOhpxa9ulYTDJyWLyM0,700
|
|
93
99
|
corekit/files/base.py,sha256=Ca2bhdlwntHh425PedaUG-UW2fEFZsPCss_3f7REsAE,4022
|
|
100
|
+
corekit/files/disk.py,sha256=Jcsdvy8o_UFaYFkC7uSI0vKO7MK_4fsC8I6DZNeBSKE,4176
|
|
94
101
|
corekit/files/enum.py,sha256=mcrQXcp-AizTAp4VoaVMLj9zu5D81XXMsnZwZqe6fz0,597
|
|
95
102
|
corekit/files/json.py,sha256=pRYLKNjeVmkDQsgtjBboFFY5Ljx1Pwa3MFEeQBc4o-g,909
|
|
96
103
|
corekit/files/toml.py,sha256=YFRwghMapbCRSl7srqcI7IhqaTQDyO56XRasGMu_Aug,1273
|
|
@@ -146,11 +153,14 @@ corekit/observability/timing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NM
|
|
|
146
153
|
corekit/observability/timing/constants.py,sha256=Cv6h98NHzJiTJJufVQBVMBpdI0_fNMTnxAZF0j0vrsg,22
|
|
147
154
|
corekit/observability/timing/split.py,sha256=EdzCKx4pwAkcMzG7kmicRKdoyhBTZ-JkftJpsQ73Vww,811
|
|
148
155
|
corekit/observability/timing/timer.py,sha256=rBGYYnBq-1E85Om37dkbPM9wKsXsltyeWeAQHkKSaZQ,1657
|
|
156
|
+
corekit/process/__init__.py,sha256=KRtZx452ICjWpkNhpvcySYtaNGp0WXXGRsZCY_6Y1VM,115
|
|
157
|
+
corekit/process/runner.py,sha256=4ArfL-XxVFCHX_l-gM2oN0bY3h0It0s9yvWN2R5_aLM,1808
|
|
149
158
|
corekit/registry/__init__.py,sha256=R67Jssr06Eyx2u5AbX1omm2LziWvwS_Sd7fExnNv9CM,681
|
|
150
159
|
corekit/registry/ordered.py,sha256=_KS4mWzmLgFvODW2M0_ugxZIBW_dKhwwxrVrtNqLMfs,2506
|
|
151
160
|
corekit/registry/registry.py,sha256=t1iwzxgvd-iyLIf_5wahg_P7eN9V1oumpupkAqi2GdQ,5601
|
|
152
|
-
corekit/schemas/__init__.py,sha256=
|
|
161
|
+
corekit/schemas/__init__.py,sha256=qAVoXLfxh4fJmT3IhMI-fJGnQEV-MzmQ1pYHBTc08_s,528
|
|
153
162
|
corekit/schemas/enum.py,sha256=gLc44kIXRfpDFkV51JoN4KT1CU92hnb529rd1b88fBM,2033
|
|
163
|
+
corekit/schemas/size.py,sha256=E_6kZ7yXqfxBqDdXnmiNfdzTyakv1YKtnPNigLiyEmk,2478
|
|
154
164
|
corekit/schemas/types.py,sha256=AWIxZHXUiUjJRZjmOhAdCq3RIsRzL-Fy7RvYy4i5Ozo,1347
|
|
155
165
|
corekit/schemas/version.py,sha256=InWtfKf0iyfu_eBLaFm_QNwkSjpgKc1jGJZeWYjQQUc,1753
|
|
156
166
|
corekit/schemas/dataclasses/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -164,9 +174,9 @@ corekit/serialization/enum.py,sha256=pzHuAIXeonEF7-jyN1uYddJk-xvmQGuymDf1U6RWR5w
|
|
|
164
174
|
corekit/serialization/pickle_file.py,sha256=dudKIxR8ueT3wx_IBxHJeoqnFeP1rT7dlTb_PXlSkd8,2251
|
|
165
175
|
corekit/serialization/serializable.py,sha256=viF3ZCKRJ90hk6m4lbqIfo6ifsY2c4sXr4xZrn1Yz-Q,1999
|
|
166
176
|
corekit/serialization/serializer.py,sha256=XlGE1Rq8M55Jl2a4F5MkvDcba2VI7lrh0v9kwL0DWG8,6617
|
|
167
|
-
corekit/utils/__init__.py,sha256=
|
|
168
|
-
corekit/utils/coercion.py,sha256=
|
|
169
|
-
corekit/utils/collections.py,sha256=
|
|
177
|
+
corekit/utils/__init__.py,sha256=wh-cMaWtzw1sEjQ9_O7tdQKMxqIPBNyfFcud0uOB-ZY,2633
|
|
178
|
+
corekit/utils/coercion.py,sha256=dnJ-lUW5_MOMrERhRxsL7LBwPAB-mh6Bua6eI-C-q1Q,3448
|
|
179
|
+
corekit/utils/collections.py,sha256=mj-YRUT0sgodqSvTNqJTpRs4lN1vMnJ_piOv9Vr9aRo,11609
|
|
170
180
|
corekit/utils/ids.py,sha256=BVniaYqewM-EosDxcJAT_S_7T9C1c5X8nsaVGs5up34,1731
|
|
171
181
|
corekit/utils/parsers.py,sha256=b7bCaMd7_VKaFnin6vn3v1Eol_Z0vvr3vjDO4ro5ycI,6628
|
|
172
182
|
corekit/utils/payload.py,sha256=T7EgdVU0S5h8MAzvQsmkfRPMatWqUbxjHL9hmZdM9ho,3882
|
|
@@ -175,8 +185,8 @@ corekit/utils/text.py,sha256=0322DdZGuDGEagTyejuda6QT1jbbxNIO6loC-_AGml0,2311
|
|
|
175
185
|
corekit/utils/time.py,sha256=-iPaETXcgDu7J39Kwe938JyZKhPVjl6a572BBqsqcDQ,2215
|
|
176
186
|
corekit/utils/validators.py,sha256=_HVKo3y264KGNaZ-1RyM1x46VPvu7XKcQkrHWDC9uxs,340
|
|
177
187
|
corekit/utils/void.py,sha256=eXDb2QynuguiSgHGJLdgE08MuRnxBdYebRdMD7s1m9o,178
|
|
178
|
-
python_corekit-0.
|
|
179
|
-
python_corekit-0.
|
|
180
|
-
python_corekit-0.
|
|
181
|
-
python_corekit-0.
|
|
182
|
-
python_corekit-0.
|
|
188
|
+
python_corekit-0.6.0.dist-info/licenses/LICENSE,sha256=357LYxbxAQZ95q5cV-8JK6j-KNgiE6AteRodjEsm-kE,1072
|
|
189
|
+
python_corekit-0.6.0.dist-info/METADATA,sha256=YrgBgcZgbYS5eB2yhz2B-9Jvx-K3AZ9VhGt4ihLuZCs,15304
|
|
190
|
+
python_corekit-0.6.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
191
|
+
python_corekit-0.6.0.dist-info/top_level.txt,sha256=SDK4o8BoaI47E9tPbiNCbbWoc2y6N_wCjwnIEfr-GWI,8
|
|
192
|
+
python_corekit-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|