pywhales 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. pywhales/__init__.py +48 -0
  2. pywhales/client_config.py +325 -0
  3. pywhales/command_line_entrypoint.py +8 -0
  4. pywhales/components/__init__.py +0 -0
  5. pywhales/components/_swarm_inspect.py +78 -0
  6. pywhales/components/buildx/__init__.py +0 -0
  7. pywhales/components/buildx/cli_wrapper.py +1123 -0
  8. pywhales/components/buildx/imagetools/__init__.py +0 -0
  9. pywhales/components/buildx/imagetools/cli_wrapper.py +117 -0
  10. pywhales/components/buildx/imagetools/models.py +40 -0
  11. pywhales/components/buildx/models.py +27 -0
  12. pywhales/components/compose/__init__.py +0 -0
  13. pywhales/components/compose/cli_wrapper.py +1534 -0
  14. pywhales/components/compose/models.py +147 -0
  15. pywhales/components/config/__init__.py +0 -0
  16. pywhales/components/config/cli_wrapper.py +186 -0
  17. pywhales/components/config/models.py +30 -0
  18. pywhales/components/container/__init__.py +0 -0
  19. pywhales/components/container/cli_wrapper.py +2533 -0
  20. pywhales/components/container/models.py +312 -0
  21. pywhales/components/container/top.py +85 -0
  22. pywhales/components/context/__init__.py +0 -0
  23. pywhales/components/context/cli_wrapper.py +249 -0
  24. pywhales/components/context/models.py +27 -0
  25. pywhales/components/image/__init__.py +0 -0
  26. pywhales/components/image/cli_wrapper.py +1058 -0
  27. pywhales/components/image/models.py +47 -0
  28. pywhales/components/image/streams.py +149 -0
  29. pywhales/components/manifest/__init__.py +0 -0
  30. pywhales/components/manifest/cli_wrapper.py +180 -0
  31. pywhales/components/manifest/models.py +9 -0
  32. pywhales/components/network/__init__.py +0 -0
  33. pywhales/components/network/cli_wrapper.py +365 -0
  34. pywhales/components/network/models.py +36 -0
  35. pywhales/components/node/__init__.py +0 -0
  36. pywhales/components/node/cli_wrapper.py +274 -0
  37. pywhales/components/node/models.py +91 -0
  38. pywhales/components/plugin/__init__.py +0 -0
  39. pywhales/components/plugin/cli_wrapper.py +308 -0
  40. pywhales/components/plugin/models.py +39 -0
  41. pywhales/components/secret/__init__.py +0 -0
  42. pywhales/components/secret/cli_wrapper.py +176 -0
  43. pywhales/components/secret/models.py +19 -0
  44. pywhales/components/service/__init__.py +0 -0
  45. pywhales/components/service/cli_wrapper.py +617 -0
  46. pywhales/components/service/models.py +93 -0
  47. pywhales/components/stack/__init__.py +0 -0
  48. pywhales/components/stack/cli_wrapper.py +170 -0
  49. pywhales/components/swarm/__init__.py +0 -0
  50. pywhales/components/swarm/cli_wrapper.py +229 -0
  51. pywhales/components/system/__init__.py +0 -0
  52. pywhales/components/system/cli_wrapper.py +192 -0
  53. pywhales/components/system/models.py +214 -0
  54. pywhales/components/task/__init__.py +0 -0
  55. pywhales/components/task/cli_wrapper.py +117 -0
  56. pywhales/components/task/models.py +158 -0
  57. pywhales/components/top.py +172 -0
  58. pywhales/components/trust/__init__.py +0 -0
  59. pywhales/components/trust/cli_wrapper.py +22 -0
  60. pywhales/components/volume/__init__.py +0 -0
  61. pywhales/components/volume/cli_wrapper.py +393 -0
  62. pywhales/components/volume/models.py +16 -0
  63. pywhales/docker_client.py +391 -0
  64. pywhales/exceptions.py +62 -0
  65. pywhales/py.typed +1 -0
  66. pywhales/test_utils.py +25 -0
  67. pywhales/utils.py +541 -0
  68. pywhales-0.2.1.dist-info/METADATA +206 -0
  69. pywhales-0.2.1.dist-info/RECORD +73 -0
  70. pywhales-0.2.1.dist-info/WHEEL +5 -0
  71. pywhales-0.2.1.dist-info/entry_points.txt +2 -0
  72. pywhales-0.2.1.dist-info/licenses/LICENSE +21 -0
  73. pywhales-0.2.1.dist-info/top_level.txt +1 -0
pywhales/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ from .client_config import ClientNotFoundError
2
+ from .components.buildx.cli_wrapper import Builder
3
+ from .components.config.cli_wrapper import Config
4
+ from .components.container.cli_wrapper import Container, ContainerStats
5
+ from .components.context.cli_wrapper import (
6
+ Context,
7
+ DockerContextConfig,
8
+ KubernetesContextConfig,
9
+ )
10
+ from .components.image.cli_wrapper import Image
11
+ from .components.network.cli_wrapper import Network
12
+ from .components.node.cli_wrapper import Node
13
+ from .components.plugin.cli_wrapper import Plugin
14
+ from .components.secret.cli_wrapper import Secret
15
+ from .components.service.cli_wrapper import Service
16
+ from .components.stack.cli_wrapper import Stack
17
+ from .components.system.cli_wrapper import SystemInfo
18
+ from .components.task.cli_wrapper import Task
19
+ from .components.volume.cli_wrapper import Volume
20
+ from .docker_client import DockerClient, Version
21
+ from .exceptions import DockerException
22
+
23
+ # alias
24
+ docker = DockerClient(client_type="docker")
25
+
26
+ __all__ = [
27
+ "Builder",
28
+ "ClientNotFoundError",
29
+ "Config",
30
+ "Container",
31
+ "ContainerStats",
32
+ "Context",
33
+ "DockerClient",
34
+ "DockerContextConfig",
35
+ "DockerException",
36
+ "Image",
37
+ "KubernetesContextConfig",
38
+ "Network",
39
+ "Node",
40
+ "Plugin",
41
+ "Secret",
42
+ "Service",
43
+ "Stack",
44
+ "SystemInfo",
45
+ "Task",
46
+ "Version",
47
+ "Volume",
48
+ ]
@@ -0,0 +1,325 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import warnings
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import Any, Literal, override
11
+
12
+ import pydantic
13
+
14
+ from . import utils
15
+ from .utils import ValidPath, run, to_list
16
+
17
+ CACHE_VALIDITY_PERIOD: float = 0.01
18
+
19
+
20
+ class ParsingError(Exception):
21
+ pass
22
+
23
+
24
+ class ClientNotFoundError(Exception):
25
+ pass
26
+
27
+
28
+ class Command(list[object]):
29
+ def add_simple_arg(self, name: str, value: object) -> None:
30
+ if value is not None:
31
+ self.extend([name, value])
32
+
33
+ def add_flag(self, name: str, value: bool) -> None:
34
+ if value:
35
+ self.append(name)
36
+
37
+ def add_args_iterable(self, arg_name: str, values: Iterable[object]) -> None:
38
+ for value in values:
39
+ self.extend([arg_name, value])
40
+
41
+ def add_args_iterable_or_single(
42
+ self, arg_name: str, iterable_or_single: object
43
+ ) -> None:
44
+ for value in to_list(iterable_or_single):
45
+ self.extend([arg_name, value])
46
+
47
+ def add_args_mapping(
48
+ self, arg_name: str, mapping: Mapping[str, object], *, separator: str = "="
49
+ ) -> None:
50
+ self.add_args_iterable(
51
+ arg_name, utils.format_mapping_for_cli(mapping, separator)
52
+ )
53
+
54
+ @override
55
+ def __add__[ElementT](self, other: list[ElementT]) -> Command:
56
+ return Command(super().__add__(other))
57
+
58
+
59
+ @dataclass
60
+ class ClientConfig:
61
+ config: ValidPath | None = None
62
+ context: str | None = None
63
+ debug: bool | None = None
64
+ host: str | None = None
65
+ log_level: str | None = None
66
+ tls: bool | None = None
67
+ tlscacert: ValidPath | None = None
68
+ tlscert: ValidPath | None = None
69
+ tlskey: ValidPath | None = None
70
+ tlsverify: bool | None = None
71
+ client_binary_path: ValidPath | None = None
72
+ compose_files: list[ValidPath] = field(default_factory=list)
73
+ compose_profiles: list[str] = field(default_factory=list)
74
+ compose_env_file: ValidPath | None = None
75
+ compose_env_files: Sequence[ValidPath] = field(default_factory=list)
76
+ compose_project_name: str | None = None
77
+ compose_project_directory: ValidPath | None = None
78
+ compose_compatibility: bool | None = None
79
+ client_call: list[str] = field(default_factory=lambda: ["docker"])
80
+ client_type: Literal["docker", "nerdctl", "unknown"] = "unknown"
81
+ _client_call_with_path: list[Path | str] | None = None
82
+ compose_dry_run: bool = False
83
+ compose_parallel: int | None = None
84
+ compose_progress: Literal["auto", "tty", "plain", "json", "quiet"] | None = None
85
+
86
+ def get_client_call_with_path(self) -> list[Path | str]:
87
+ if self._client_call_with_path is None:
88
+ self._client_call_with_path = [
89
+ Path(self._get_docker_path()),
90
+ *self.client_call[1:],
91
+ ]
92
+
93
+ return self._client_call_with_path
94
+
95
+ def _get_docker_path(self) -> str:
96
+ which_result = shutil.which(self.client_call[0])
97
+ if which_result is None:
98
+ raise ClientNotFoundError(
99
+ f"The binary '{self.client_call[0]}' could not be found on your PATH. "
100
+ "Please ensure that your PATH has the directory of the binary you're looking for. "
101
+ "You can use `print(os.environ['PATH'])` to verify what directories are in your PATH."
102
+ )
103
+ return which_result
104
+
105
+ @property
106
+ def docker_cmd(self) -> Command:
107
+ result = Command(self.get_client_call_with_path())
108
+
109
+ if self.config is not None:
110
+ result += ["--config", self.config]
111
+
112
+ if self.context is not None:
113
+ result += ["--context", self.context]
114
+
115
+ if self.debug is True:
116
+ result.append("--debug")
117
+
118
+ if self.host is not None:
119
+ result += ["--host", self.host]
120
+
121
+ if self.log_level is not None:
122
+ result += ["--log-level", self.log_level]
123
+
124
+ if self.tls is True:
125
+ result.append("--tls")
126
+
127
+ if self.tlscacert is not None:
128
+ result += ["--tlscacert", self.tlscacert]
129
+
130
+ if self.tlscert is not None:
131
+ result += ["--tlscert", self.tlscert]
132
+
133
+ if self.tlskey is not None:
134
+ result += ["--tlskey", self.tlskey]
135
+
136
+ if self.tlsverify is True:
137
+ result.append("--tlsverify")
138
+
139
+ return result
140
+
141
+ @property
142
+ def docker_compose_cmd(self) -> Command:
143
+ return self.get_compose_command()
144
+
145
+ def get_compose_command(self, *, progress: str | None = None) -> Command:
146
+ """Build Compose globals, optionally overriding progress for one command."""
147
+ base_cmd = Command([*self.docker_cmd, "compose"])
148
+ base_cmd.add_args_iterable_or_single("--file", self.compose_files)
149
+ base_cmd.add_args_iterable_or_single("--profile", self.compose_profiles)
150
+ if self.compose_env_files:
151
+ if self.compose_env_file is not None:
152
+ warnings.warn(
153
+ "You can't set both `compose_env_file` and `compose_env_files`. Files used in `compose_env_files` will be used.",
154
+ stacklevel=2,
155
+ )
156
+ base_cmd.add_args_iterable("--env-file", self.compose_env_files)
157
+ elif self.compose_env_file is not None:
158
+ warnings.warn(
159
+ "`compose_env_file` is deprecated. Use `compose_env_files` instead.",
160
+ stacklevel=2,
161
+ )
162
+ base_cmd.add_simple_arg("--env-file", self.compose_env_file)
163
+
164
+ base_cmd.add_simple_arg("--project-name", self.compose_project_name)
165
+ base_cmd.add_simple_arg("--project-directory", self.compose_project_directory)
166
+ base_cmd.add_flag("--compatibility", self.compose_compatibility is True)
167
+ base_cmd.add_flag("--dry-run", self.compose_dry_run)
168
+ base_cmd.add_simple_arg("--parallel", self.compose_parallel)
169
+ base_cmd.add_simple_arg(
170
+ "--progress", self.compose_progress if progress is None else progress
171
+ )
172
+ return base_cmd
173
+
174
+
175
+ class DockerCLICaller:
176
+ def __init__(self, client_config: ClientConfig) -> None:
177
+ self.client_config = client_config
178
+
179
+ @property
180
+ def docker_cmd(self) -> Command:
181
+ return self.client_config.docker_cmd
182
+
183
+ @property
184
+ def docker_compose_cmd(self) -> Command:
185
+ return self.client_config.docker_compose_cmd
186
+
187
+
188
+ class ReloadableObject[InspectResultT, ReferenceT: str | None = str](DockerCLICaller):
189
+ def __init__(
190
+ self,
191
+ client_config: ClientConfig,
192
+ id_in_inspect: str,
193
+ reference_or_id: ReferenceT,
194
+ is_immutable_id: bool = False,
195
+ ) -> None:
196
+ super().__init__(client_config)
197
+ self._last_refreshed_time = datetime.min
198
+ self._inspect_result: InspectResultT | None = None
199
+ self._id_in_inspect = id_in_inspect
200
+ if is_immutable_id:
201
+ self._immutable_id: ReferenceT = reference_or_id
202
+ else:
203
+ inspect_result = self._fetch_and_parse_inspect_result(reference_or_id)
204
+ self._set_inspect_result(inspect_result)
205
+ self._immutable_id = getattr(inspect_result, id_in_inspect)
206
+
207
+ @override
208
+ def __eq__(self, other: object) -> bool:
209
+ if not isinstance(other, ReloadableObject):
210
+ return False
211
+ return (
212
+ self._get_immutable_id() == other._get_immutable_id()
213
+ and self.client_config == other.client_config
214
+ )
215
+
216
+ @override
217
+ def __str__(self) -> str:
218
+ reference = self._get_immutable_id()
219
+ if reference is None:
220
+ raise TypeError("__str__ returned non-string (type NoneType)")
221
+ return reference
222
+
223
+ def _needs_reload(self) -> bool:
224
+ return (datetime.now() - self._last_refreshed_time) >= timedelta(
225
+ seconds=CACHE_VALIDITY_PERIOD
226
+ )
227
+
228
+ def reload(self) -> None:
229
+ self._reload()
230
+
231
+ def _reload(self) -> InspectResultT:
232
+ inspect_result = self._fetch_and_parse_inspect_result(self._immutable_id)
233
+ self._set_inspect_result(inspect_result)
234
+ return inspect_result
235
+
236
+ def _fetch_and_parse_inspect_result(self, reference: ReferenceT) -> InspectResultT:
237
+ raise NotImplementedError
238
+
239
+ def _get_inspect_result(self) -> InspectResultT:
240
+ if self._inspect_result is None or self._needs_reload():
241
+ return self._reload()
242
+ return self._inspect_result
243
+
244
+ def _set_inspect_result(self, inspect_result: InspectResultT) -> None:
245
+ self._inspect_result = inspect_result
246
+ self._last_refreshed_time = datetime.now()
247
+
248
+ def _get_immutable_id(self) -> ReferenceT:
249
+ return self._immutable_id
250
+
251
+ @override
252
+ def __hash__(self) -> int:
253
+ # maybe we can do better.
254
+ return hash(self._get_immutable_id())
255
+
256
+
257
+ class ReloadableObjectFromJson[InspectResultT, ReferenceT: str | None = str](
258
+ ReloadableObject[InspectResultT, ReferenceT]
259
+ ):
260
+ def _fetch_inspect_result_json(self, reference: ReferenceT) -> dict[str, Any]:
261
+ raise NotImplementedError
262
+
263
+ def _parse_json_object(self, json_object: dict[str, Any]) -> InspectResultT:
264
+ raise NotImplementedError
265
+
266
+ @override
267
+ def _fetch_and_parse_inspect_result(self, reference: ReferenceT) -> InspectResultT:
268
+ json_object = self._fetch_inspect_result_json(reference)
269
+ return self._parse_inspect_result(json_object)
270
+
271
+ def _parse_inspect_results(
272
+ self, json_str: str, references: Sequence[str]
273
+ ) -> list[InspectResultT]:
274
+ try:
275
+ json_objects = pydantic.TypeAdapter(
276
+ list[dict[str, pydantic.JsonValue]]
277
+ ).validate_json(json_str)
278
+ except pydantic.ValidationError as err:
279
+ raise ParsingError("Invalid batch inspect JSON response") from err
280
+ if len(json_objects) != len(references):
281
+ raise ParsingError(
282
+ f"Expected {len(references)} inspect results, received {len(json_objects)}"
283
+ )
284
+ results = [self._parse_inspect_result(item) for item in json_objects]
285
+ resolved: dict[str, str] = {}
286
+ for reference, result in zip(references, results, strict=True):
287
+ identity = getattr(result, self._id_in_inspect)
288
+ if not isinstance(identity, str) or not identity:
289
+ raise ParsingError(f"Missing inspect identity for {reference!r}")
290
+ if reference in resolved and resolved[reference] != identity:
291
+ raise ParsingError(f"Inconsistent inspect identity for {reference!r}")
292
+ resolved[reference] = identity
293
+ return results
294
+
295
+ def _parse_inspect_result(self, json_object: dict[str, Any]) -> InspectResultT:
296
+ try:
297
+ return self._parse_json_object(json_object)
298
+ except pydantic.ValidationError as err:
299
+ fd, json_response_file = tempfile.mkstemp(suffix=".json", text=True)
300
+ with os.fdopen(fd, "w") as f:
301
+ json.dump(json_object, f, indent=2)
302
+
303
+ raise ParsingError(
304
+ f"There was an error parsing the json response from the Docker daemon. \n"
305
+ f"This is a bug with pywhales itself. Please head to \n"
306
+ f"https://github.com/MaraClaw/PyWhales/issues \n"
307
+ f"and open an issue. You should copy this error message and \n"
308
+ f"the json response from the Docker daemon. The json response was put \n"
309
+ f"in {json_response_file} because it's a bit too big to be printed \n"
310
+ f"on the screen. Make sure that there are no sensitive data in the \n"
311
+ f"json file before copying it in the github issue."
312
+ ) from err
313
+
314
+
315
+ def bulk_reload[InspectResultT, ReferenceT: str | None](
316
+ docker_objects: Sequence[ReloadableObjectFromJson[InspectResultT, ReferenceT]],
317
+ ) -> None:
318
+ assert len({x.client_config for x in docker_objects}) == 1
319
+ all_ids = [x._get_immutable_id() for x in docker_objects]
320
+ full_cmd = [*docker_objects[0].docker_cmd, "inspect", *all_ids]
321
+ json_str = run(full_cmd)
322
+ for json_obj, docker_object in zip(
323
+ json.loads(json_str), docker_objects, strict=False
324
+ ):
325
+ docker_object._set_inspect_result(docker_object._parse_json_object(json_obj))
@@ -0,0 +1,8 @@
1
+ def main() -> None:
2
+ print(
3
+ "The PyWhales command line interface does not perform any action. "
4
+ "If you were using it to download the docker CLI, "
5
+ "you should instead do it manually. "
6
+ "Take a look at https://github.com/MaraClaw/PyWhales/blob/HEAD/docs/template/docker_client.md#the-docker-cli "
7
+ "which has instructions to download the Docker client."
8
+ )
File without changes
@@ -0,0 +1,78 @@
1
+ from collections import Counter
2
+ from collections.abc import Sequence
3
+ from typing import assert_never
4
+
5
+ from pywhales.client_config import ParsingError, ReloadableObjectFromJson
6
+ from pywhales.components.config.models import ConfigInspectResult
7
+ from pywhales.components.node.models import NodeInspectResult
8
+ from pywhales.components.secret.models import SecretInspectResult
9
+ from pywhales.components.service.models import ServiceInspectResult
10
+
11
+ type SwarmInspectResult = (
12
+ ConfigInspectResult | NodeInspectResult | SecretInspectResult | ServiceInspectResult
13
+ )
14
+
15
+
16
+ def _inspect_name(result: SwarmInspectResult) -> str | None:
17
+ match result:
18
+ case NodeInspectResult(description=description):
19
+ return description.hostname if description is not None else None
20
+ case (
21
+ ConfigInspectResult(spec=spec)
22
+ | SecretInspectResult(spec=spec)
23
+ | ServiceInspectResult(spec=spec)
24
+ ):
25
+ return spec.name if spec is not None else None
26
+ case unreachable:
27
+ assert_never(unreachable)
28
+
29
+
30
+ def parse_swarm_inspect_results[InspectResultT: SwarmInspectResult](
31
+ parser: ReloadableObjectFromJson[InspectResultT],
32
+ json_str: str,
33
+ references: Sequence[str],
34
+ ) -> list[tuple[str, InspectResultT]]:
35
+ """Validate response slots, then resolve references independently of their order.
36
+
37
+ Unique slot labels keep the shared parser's positional duplicate check from
38
+ associating repeated request references with unrelated reordered responses.
39
+ Identity consistency and multiplicity are checked after reference resolution.
40
+ """
41
+ results = parser._parse_inspect_results(
42
+ json_str, [str(index) for index in range(len(references))]
43
+ )
44
+ snapshots: dict[str, InspectResultT] = {}
45
+ identities: list[str] = []
46
+ for result in results:
47
+ identity = result.id
48
+ if identity is None or identity == "":
49
+ raise ParsingError("Missing Swarm inspect identity")
50
+ if identity in snapshots and snapshots[identity] != result:
51
+ raise ParsingError(f"Inconsistent inspect snapshot for {identity!r}")
52
+ snapshots[identity] = result
53
+ identities.append(identity)
54
+
55
+ resolved: list[tuple[str, InspectResultT]] = []
56
+ for reference in references:
57
+ if reference in snapshots:
58
+ resolved.append((reference, snapshots[reference]))
59
+ continue
60
+ matches = [
61
+ (identity, result)
62
+ for identity, result in snapshots.items()
63
+ if reference and _inspect_name(result) == reference
64
+ ]
65
+ if not matches:
66
+ matches = [
67
+ (identity, result)
68
+ for identity, result in snapshots.items()
69
+ if reference and identity.startswith(reference)
70
+ ]
71
+ if len(matches) != 1:
72
+ raise ParsingError(
73
+ f"Expected one inspect identity for {reference!r}, received {len(matches)}"
74
+ )
75
+ resolved.append(matches[0])
76
+ if Counter(identity for identity, _ in resolved) != Counter(identities):
77
+ raise ParsingError("Inspect identities do not match requested multiplicities")
78
+ return resolved
File without changes