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.
@@ -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"]
@@ -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
@@ -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
- __all__ = ["Watchdog"]
24
-
25
- DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock"
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 local socket.
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 or DEFAULT_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 = DockerClient(base_url=self.docker_host)
68
+ self._client_instance = DockerClientProvider.create_client(self.docker_host)
67
69
  return self._client_instance
68
70
 
69
71
  @staticmethod
@@ -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",
@@ -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,7 @@
1
+ """
2
+ External command execution.
3
+ """
4
+
5
+ from corekit.process.runner import CommandRunner
6
+
7
+ __all__ = ["CommandRunner"]
@@ -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
@@ -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",
@@ -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="").value
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=0).value
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=0.0).value
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
@@ -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.5.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=LtrmEvqorme1-YwL0jGNFLEASdgn69s0eYuh490anmg,104
55
- corekit/docker/watchdog.py,sha256=kAOF9x-h_oQmyKrkcc4eQ1_HIofRDyYyrfeAULyyBtQ,8855
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=8q9EX-0bzOiJI1FOzCbViQFwx53Ugs8NH482Cvpci6k,995
87
- corekit/exceptions/base.py,sha256=k0AfTfPrLWR6x-l2qvvjqD_sup0fiWhqu1OoNaNM-Lk,5596
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=IllQEAWtcT72vVUdRaMwfF5VlhzMJ16VIKDhqvWG9Cw,612
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=4EK7QNWP16U6ZKno_ZKTDfgSflc9W2PFMPskc0kvSdw,470
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=lNdmVmQBUFckkCIjzxYLHpLe06rudXS9t_evVEv-le4,2419
168
- corekit/utils/coercion.py,sha256=lnAdPuY7Hha81of1I8oPIu6C8ow6JqWWpuF1sPX17Ek,3084
169
- corekit/utils/collections.py,sha256=4ieN3r44Y1DPoJQYpoXn8mL_nTKd5i9GjAoBOMXwd40,8335
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.5.0.dist-info/licenses/LICENSE,sha256=357LYxbxAQZ95q5cV-8JK6j-KNgiE6AteRodjEsm-kE,1072
179
- python_corekit-0.5.0.dist-info/METADATA,sha256=GFJChQrVckK79FiPnCddTLGr36xpxik6BqrtAjskE_A,15185
180
- python_corekit-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
181
- python_corekit-0.5.0.dist-info/top_level.txt,sha256=SDK4o8BoaI47E9tPbiNCbbWoc2y6N_wCjwnIEfr-GWI,8
182
- python_corekit-0.5.0.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5