squarepeg 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {squarepeg-0.1.0 → squarepeg-0.2.0}/PKG-INFO +10 -1
- {squarepeg-0.1.0 → squarepeg-0.2.0}/README.md +9 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/pyproject.toml +1 -1
- squarepeg-0.2.0/squarepeg/__init__.py +6 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/cli.py +162 -39
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/events.py +1 -1
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/logs.py +2 -1
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/orphans.py +2 -2
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/runner.py +46 -10
- squarepeg-0.2.0/squarepeg/log.py +5 -0
- squarepeg-0.2.0/squarepeg/ui.py +190 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/PKG-INFO +10 -1
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/SOURCES.txt +2 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_cli.py +95 -9
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_runner.py +27 -0
- squarepeg-0.2.0/tests/test_ui.py +110 -0
- squarepeg-0.1.0/squarepeg/__init__.py +0 -1
- squarepeg-0.1.0/squarepeg/log.py +0 -6
- {squarepeg-0.1.0 → squarepeg-0.2.0}/setup.cfg +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/__main__.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/config.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/dockerargs.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/env_interpolation.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/errors.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/__init__.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/apierrors.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/dryrun.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/podstate.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/session.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/labels.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/manifest.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/merge.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/naming.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/quantities.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/runspec.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/volumes.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/dependency_links.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/entry_points.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/requires.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/top_level.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_apierrors.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_config.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_dockerargs.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_dryrun.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_env_interpolation.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_events.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_integration.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_logs.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_manifest.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_merge.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_naming.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_orphans.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_podstate.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_quantities.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_session.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_volumes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: squarepeg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -12,6 +12,15 @@ Requires-Dist: pytest>=8; extra == "dev"
|
|
|
12
12
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
13
13
|
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
14
14
|
|
|
15
|
+
```
|
|
16
|
+
_____
|
|
17
|
+
/ ___/____ ___ ______ _________ ____ ___ ____ _
|
|
18
|
+
\__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
|
|
19
|
+
___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
|
|
20
|
+
/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
|
|
21
|
+
/_/ /_/ /____/
|
|
22
|
+
```
|
|
23
|
+
|
|
15
24
|
# squarepeg
|
|
16
25
|
|
|
17
26
|
Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
```
|
|
2
|
+
_____
|
|
3
|
+
/ ___/____ ___ ______ _________ ____ ___ ____ _
|
|
4
|
+
\__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
|
|
5
|
+
___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
|
|
6
|
+
/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
|
|
7
|
+
/_/ /_/ /____/
|
|
8
|
+
```
|
|
9
|
+
|
|
1
10
|
# squarepeg
|
|
2
11
|
|
|
3
12
|
Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "squarepeg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import click
|
|
2
2
|
import yaml
|
|
3
3
|
|
|
4
|
-
from squarepeg import __version__
|
|
5
|
-
from squarepeg.config import
|
|
6
|
-
|
|
4
|
+
from squarepeg import __version__, ui
|
|
5
|
+
from squarepeg.config import (
|
|
6
|
+
coerce_bool,
|
|
7
|
+
coerce_int,
|
|
8
|
+
load_effective_config,
|
|
9
|
+
select_profile,
|
|
10
|
+
)
|
|
11
|
+
from squarepeg.dockerargs import (
|
|
12
|
+
check_supported_image,
|
|
13
|
+
parse_env_entries,
|
|
14
|
+
reject_unsupported,
|
|
15
|
+
)
|
|
7
16
|
from squarepeg.errors import SquarepegError, UsageError
|
|
8
17
|
from squarepeg.k8s.dryrun import server_dry_run
|
|
9
18
|
from squarepeg.k8s.runner import run_manifest
|
|
10
19
|
from squarepeg.k8s.session import Session
|
|
20
|
+
from squarepeg.log import chatter
|
|
11
21
|
from squarepeg.manifest import build_job, build_pod
|
|
12
22
|
from squarepeg.naming import generate_name, validate_rfc1123
|
|
13
23
|
from squarepeg.quantities import docker_cpus_to_k8s, docker_memory_to_k8s
|
|
@@ -16,7 +26,11 @@ from squarepeg.volumes import auto_mounts_from_config, parse_volume_flag
|
|
|
16
26
|
|
|
17
27
|
_CONFIG_OPTIONS = [
|
|
18
28
|
click.option(
|
|
19
|
-
"--config",
|
|
29
|
+
"--config",
|
|
30
|
+
"config_paths",
|
|
31
|
+
multiple=True,
|
|
32
|
+
type=click.Path(),
|
|
33
|
+
help="repeatable, layered left-to-right",
|
|
20
34
|
),
|
|
21
35
|
click.option("--no-default-config", "no_default_config", is_flag=True),
|
|
22
36
|
click.option("--profile", "profile", default=None),
|
|
@@ -30,7 +44,9 @@ def _add_config_options(cmd):
|
|
|
30
44
|
|
|
31
45
|
|
|
32
46
|
def _resolve_config(config_paths, no_default_config, profile):
|
|
33
|
-
effective, sources = load_effective_config(
|
|
47
|
+
effective, sources = load_effective_config(
|
|
48
|
+
config_paths, no_default_config=no_default_config
|
|
49
|
+
)
|
|
34
50
|
resolved = select_profile(effective, profile)
|
|
35
51
|
return resolved, sources
|
|
36
52
|
|
|
@@ -74,7 +90,24 @@ def _add_unsupported_options(cmd):
|
|
|
74
90
|
return cmd
|
|
75
91
|
|
|
76
92
|
|
|
77
|
-
|
|
93
|
+
_LOGO = r"""
|
|
94
|
+
_____
|
|
95
|
+
/ ___/____ ___ ______ _________ ____ ___ ____ _
|
|
96
|
+
\__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
|
|
97
|
+
___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
|
|
98
|
+
/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
|
|
99
|
+
/_/ /_/ /____/
|
|
100
|
+
""".strip("\n")
|
|
101
|
+
|
|
102
|
+
class _LogoGroup(click.Group):
|
|
103
|
+
"""Prints the ASCII logo above the 'Usage:' line, rather than as part of the help body."""
|
|
104
|
+
|
|
105
|
+
def format_usage(self, ctx, formatter):
|
|
106
|
+
formatter.write(_LOGO + "\n\n")
|
|
107
|
+
super().format_usage(ctx, formatter)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@click.group(cls=_LogoGroup)
|
|
78
111
|
@click.version_option(__version__, prog_name="squarepeg")
|
|
79
112
|
def cli():
|
|
80
113
|
"""Run a docker-run-style command as a Kubernetes Pod or Job."""
|
|
@@ -84,14 +117,38 @@ def cli():
|
|
|
84
117
|
context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True},
|
|
85
118
|
)
|
|
86
119
|
@click.option("-e", "--env", "env_entries", multiple=True, help="KEY=VALUE, repeatable")
|
|
87
|
-
@click.option(
|
|
120
|
+
@click.option(
|
|
121
|
+
"-v",
|
|
122
|
+
"--volume",
|
|
123
|
+
"volume_entries",
|
|
124
|
+
multiple=True,
|
|
125
|
+
help="[NAME|/host]:/container[:ro|rw], repeatable",
|
|
126
|
+
)
|
|
88
127
|
@click.option("--name", "name", default=None, help="Pod/Job name")
|
|
89
128
|
@click.option("-w", "--workdir", "workdir", default=None)
|
|
90
|
-
@click.option(
|
|
91
|
-
|
|
129
|
+
@click.option(
|
|
130
|
+
"--entrypoint",
|
|
131
|
+
"entrypoint",
|
|
132
|
+
default=None,
|
|
133
|
+
help="Override the image ENTRYPOINT (-> k8s 'command')",
|
|
134
|
+
)
|
|
135
|
+
@click.option(
|
|
136
|
+
"-i",
|
|
137
|
+
"--interactive",
|
|
138
|
+
"interactive",
|
|
139
|
+
is_flag=True,
|
|
140
|
+
help="accepted; stdin is not forwarded",
|
|
141
|
+
)
|
|
92
142
|
@click.option("-t", "--tty", "tty", is_flag=True)
|
|
93
|
-
@click.option(
|
|
94
|
-
|
|
143
|
+
@click.option(
|
|
144
|
+
"--rm",
|
|
145
|
+
"rm_flag",
|
|
146
|
+
is_flag=True,
|
|
147
|
+
help="accepted no-op; cleanup is already the default",
|
|
148
|
+
)
|
|
149
|
+
@click.option(
|
|
150
|
+
"--pull", "pull", type=click.Choice(["always", "missing", "never"]), default=None
|
|
151
|
+
)
|
|
95
152
|
@click.option("--cpus", "cpus", default=None)
|
|
96
153
|
@click.option("-m", "--memory", "memory", default=None)
|
|
97
154
|
@click.option("--request-cpu", "request_cpu", default=None)
|
|
@@ -100,8 +157,12 @@ def cli():
|
|
|
100
157
|
@click.option("--limit-memory", "limit_memory", default=None)
|
|
101
158
|
@click.option("--mode", "mode", type=click.Choice(["pod", "job"]), default=None)
|
|
102
159
|
@click.option("-n", "--namespace", "namespace", default=None)
|
|
103
|
-
@click.option(
|
|
104
|
-
|
|
160
|
+
@click.option(
|
|
161
|
+
"--keep", "keep", is_flag=True, help="do not delete the pod/job after it finishes"
|
|
162
|
+
)
|
|
163
|
+
@click.option(
|
|
164
|
+
"--dry-run", "dry_run", is_flag=True, help="render the manifest without creating it"
|
|
165
|
+
)
|
|
105
166
|
@click.option(
|
|
106
167
|
"--dry-run-server",
|
|
107
168
|
"dry_run_server",
|
|
@@ -110,6 +171,12 @@ def cli():
|
|
|
110
171
|
)
|
|
111
172
|
@click.option("--timeout", "timeout", type=int, default=None)
|
|
112
173
|
@click.option("--quiet", "quiet", is_flag=True)
|
|
174
|
+
@click.option(
|
|
175
|
+
"--no-color",
|
|
176
|
+
"no_color",
|
|
177
|
+
is_flag=True,
|
|
178
|
+
help="disable colour/animation in squarepeg's own stderr output",
|
|
179
|
+
)
|
|
113
180
|
@click.option("--context", "context", default=None, help="kubeconfig context to use")
|
|
114
181
|
@click.option("--container-name", "container_name", default="main")
|
|
115
182
|
@click.argument("image")
|
|
@@ -142,22 +209,28 @@ def run(
|
|
|
142
209
|
profile,
|
|
143
210
|
timeout,
|
|
144
211
|
quiet,
|
|
212
|
+
no_color,
|
|
145
213
|
context,
|
|
146
214
|
container_name,
|
|
147
215
|
image,
|
|
148
216
|
command,
|
|
149
217
|
):
|
|
150
218
|
"""Run IMAGE [COMMAND...] as a Kubernetes Pod or Job."""
|
|
219
|
+
ui.set_color_override(False if no_color else None)
|
|
151
220
|
check_supported_image(image)
|
|
152
221
|
if rm_flag and keep:
|
|
153
222
|
raise UsageError("--rm and --keep are mutually exclusive")
|
|
154
223
|
if dry_run and dry_run_server:
|
|
155
224
|
raise UsageError("--dry-run and --dry-run-server are mutually exclusive")
|
|
156
225
|
|
|
157
|
-
resolved_config, _sources = _resolve_config(
|
|
226
|
+
resolved_config, _sources = _resolve_config(
|
|
227
|
+
config_paths, no_default_config, profile
|
|
228
|
+
)
|
|
158
229
|
config_defaults = resolved_config.get("defaults") or {}
|
|
159
230
|
config_volumes = resolved_config.get("volumes") or {}
|
|
160
|
-
allow_host_path_mounts = coerce_bool(
|
|
231
|
+
allow_host_path_mounts = coerce_bool(
|
|
232
|
+
resolved_config.get("allow_host_path_mounts", False), "allow_host_path_mounts"
|
|
233
|
+
)
|
|
161
234
|
|
|
162
235
|
claims: set[str] = set()
|
|
163
236
|
|
|
@@ -166,12 +239,18 @@ def run(
|
|
|
166
239
|
claims.add(f"/spec/containers/[name={container_name}]/env/[name={key}]")
|
|
167
240
|
|
|
168
241
|
cli_volumes = [
|
|
169
|
-
parse_volume_flag(
|
|
242
|
+
parse_volume_flag(
|
|
243
|
+
v,
|
|
244
|
+
config_volumes=config_volumes,
|
|
245
|
+
allow_host_path_mounts=allow_host_path_mounts,
|
|
246
|
+
)
|
|
170
247
|
for v in volume_entries
|
|
171
248
|
]
|
|
172
249
|
for vol in cli_volumes:
|
|
173
250
|
claims.add(f"/spec/volumes/[name={vol.volume_name}]")
|
|
174
|
-
claims.add(
|
|
251
|
+
claims.add(
|
|
252
|
+
f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]"
|
|
253
|
+
)
|
|
175
254
|
|
|
176
255
|
# config-declared auto-mounts (volumes.NAME with a mount_path) are not claimed -- they're
|
|
177
256
|
# config-driven, not CLI-driven, so kubernetes.spec passthrough in the same config can still
|
|
@@ -200,7 +279,9 @@ def run(
|
|
|
200
279
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
|
|
201
280
|
if request_memory is not None:
|
|
202
281
|
resources.request_memory = request_memory
|
|
203
|
-
claims.add(
|
|
282
|
+
claims.add(
|
|
283
|
+
f"/spec/containers/[name={container_name}]/resources/requests/memory"
|
|
284
|
+
)
|
|
204
285
|
if limit_memory is not None:
|
|
205
286
|
resources.limit_memory = limit_memory
|
|
206
287
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
|
|
@@ -209,7 +290,9 @@ def run(
|
|
|
209
290
|
if cli_set_cpus and limit_cpu is None:
|
|
210
291
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
|
|
211
292
|
if cli_set_memory and request_memory is None:
|
|
212
|
-
claims.add(
|
|
293
|
+
claims.add(
|
|
294
|
+
f"/spec/containers/[name={container_name}]/resources/requests/memory"
|
|
295
|
+
)
|
|
213
296
|
if cli_set_memory and limit_memory is None:
|
|
214
297
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
|
|
215
298
|
|
|
@@ -224,7 +307,11 @@ def run(
|
|
|
224
307
|
else:
|
|
225
308
|
namespace = resolved_config.get("namespace")
|
|
226
309
|
|
|
227
|
-
pull_policy =
|
|
310
|
+
pull_policy = (
|
|
311
|
+
{"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull)
|
|
312
|
+
if pull
|
|
313
|
+
else None
|
|
314
|
+
)
|
|
228
315
|
if pull_policy is not None:
|
|
229
316
|
claims.add(f"/spec/containers/[name={container_name}]/imagePullPolicy")
|
|
230
317
|
elif "image_pull_policy" in config_defaults:
|
|
@@ -236,13 +323,25 @@ def run(
|
|
|
236
323
|
workdir = config_defaults["workdir"]
|
|
237
324
|
|
|
238
325
|
mode = mode or resolved_config.get("mode", "pod")
|
|
239
|
-
timeout =
|
|
326
|
+
timeout = (
|
|
327
|
+
timeout
|
|
328
|
+
if timeout is not None
|
|
329
|
+
else coerce_int(resolved_config.get("timeout", 300), "timeout")
|
|
330
|
+
)
|
|
240
331
|
quiet = quiet or coerce_bool(resolved_config.get("quiet", False), "quiet")
|
|
241
|
-
cleanup =
|
|
242
|
-
|
|
243
|
-
|
|
332
|
+
cleanup = (
|
|
333
|
+
False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup")
|
|
334
|
+
)
|
|
335
|
+
orphan_sweep = coerce_bool(
|
|
336
|
+
resolved_config.get("orphan_sweep", True), "orphan_sweep"
|
|
337
|
+
)
|
|
338
|
+
orphan_sweep_min_age = coerce_int(
|
|
339
|
+
resolved_config.get("orphan_sweep_min_age", 300), "orphan_sweep_min_age"
|
|
340
|
+
)
|
|
244
341
|
if orphan_sweep_min_age < 0:
|
|
245
|
-
raise UsageError(
|
|
342
|
+
raise UsageError(
|
|
343
|
+
f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}"
|
|
344
|
+
)
|
|
246
345
|
|
|
247
346
|
entrypoint_tuple = (entrypoint,) if entrypoint is not None else None
|
|
248
347
|
if entrypoint_tuple is not None:
|
|
@@ -255,11 +354,14 @@ def run(
|
|
|
255
354
|
if not interactive:
|
|
256
355
|
# some clusters reject tty=true with stdin=false; -i is accepted but never
|
|
257
356
|
# forwards real stdin, so this can hang a container that then blocks reading it.
|
|
258
|
-
|
|
259
|
-
|
|
357
|
+
# This is a real hazard warning, so it's deliberately never suppressed by
|
|
358
|
+
# --quiet (quiet=False below), unlike squarepeg's other informational chatter.
|
|
359
|
+
chatter(
|
|
360
|
+
"-t implies stdin is opened on the container (stdinOnce), but stdin is "
|
|
260
361
|
"never actually forwarded; a process that blocks reading stdin will hang. Pass -i "
|
|
261
362
|
"explicitly to acknowledge this.",
|
|
262
|
-
|
|
363
|
+
quiet=False,
|
|
364
|
+
level="warn",
|
|
263
365
|
)
|
|
264
366
|
interactive = True
|
|
265
367
|
if interactive:
|
|
@@ -311,21 +413,38 @@ def run(
|
|
|
311
413
|
raise SystemExit(exit_code)
|
|
312
414
|
|
|
313
415
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
416
|
+
def _print_config(config_paths, no_default_config, profile):
|
|
417
|
+
resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
|
|
418
|
+
for path, origin in sources:
|
|
419
|
+
chatter(f"{path} ({origin})", level="info")
|
|
420
|
+
if not sources:
|
|
421
|
+
chatter("no config files loaded", level="info")
|
|
422
|
+
click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@cli.group("config", invoke_without_command=True)
|
|
426
|
+
@_add_config_options
|
|
427
|
+
@click.pass_context
|
|
428
|
+
def config_group(ctx, config_paths, no_default_config, profile):
|
|
429
|
+
"""Inspect the resolved config (same as 'config show')."""
|
|
430
|
+
if ctx.invoked_subcommand is None:
|
|
431
|
+
_print_config(config_paths, no_default_config, profile)
|
|
432
|
+
return
|
|
433
|
+
default_map = {}
|
|
434
|
+
if config_paths:
|
|
435
|
+
default_map["config_paths"] = config_paths
|
|
436
|
+
if no_default_config:
|
|
437
|
+
default_map["no_default_config"] = no_default_config
|
|
438
|
+
if profile is not None:
|
|
439
|
+
default_map["profile"] = profile
|
|
440
|
+
ctx.default_map = {"show": default_map}
|
|
317
441
|
|
|
318
442
|
|
|
319
443
|
@config_group.command("show")
|
|
320
444
|
@_add_config_options
|
|
321
445
|
def config_show(config_paths, no_default_config, profile):
|
|
322
|
-
"""Print the merged effective config (stdout) and its sources (stderr)."""
|
|
323
|
-
|
|
324
|
-
for path, origin in sources:
|
|
325
|
-
click.echo(f"[squarepeg] {path} ({origin})", err=True)
|
|
326
|
-
if not sources:
|
|
327
|
-
click.echo("[squarepeg] no config files loaded", err=True)
|
|
328
|
-
click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False)
|
|
446
|
+
"""Print the merged effective config (stdout) and its sources (stderr). Same as bare 'config'."""
|
|
447
|
+
_print_config(config_paths, no_default_config, profile)
|
|
329
448
|
|
|
330
449
|
|
|
331
450
|
def main():
|
|
@@ -337,7 +456,11 @@ def main():
|
|
|
337
456
|
except click.exceptions.Exit as exc:
|
|
338
457
|
raise SystemExit(exc.exit_code) from exc
|
|
339
458
|
except SquarepegError as exc:
|
|
340
|
-
click.echo(
|
|
459
|
+
click.echo(
|
|
460
|
+
click.style(f"squarepeg: {exc}", fg="red", bold=True),
|
|
461
|
+
err=True,
|
|
462
|
+
color=ui.color_enabled(),
|
|
463
|
+
)
|
|
341
464
|
raise SystemExit(exc.exit_code) from exc
|
|
342
465
|
|
|
343
466
|
|
|
@@ -16,4 +16,4 @@ def print_pod_events(session, pod_name: str, *, quiet: bool = False) -> None:
|
|
|
16
16
|
return # best-effort diagnostics; never fail the run because event listing failed
|
|
17
17
|
|
|
18
18
|
for event in events.items or []:
|
|
19
|
-
chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet)
|
|
19
|
+
chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet, level="detail")
|
|
@@ -115,12 +115,13 @@ def stream_logs(session, pod_name: str, container_name: str, stop_event, *, quie
|
|
|
115
115
|
f"log stream for pod {pod_name!r} failed after {attempt - 1} reconnect attempts ({exc}); "
|
|
116
116
|
"still waiting for the pod to finish",
|
|
117
117
|
quiet=quiet,
|
|
118
|
+
level="warn",
|
|
118
119
|
)
|
|
119
120
|
return
|
|
120
121
|
if dedupe.last_ts is not None:
|
|
121
122
|
since_seconds = since_seconds_from(dedupe.last_ts)
|
|
122
123
|
backoff = min(2**attempt, MAX_BACKOFF_SECONDS)
|
|
123
|
-
chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet)
|
|
124
|
+
chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet, level="warn")
|
|
124
125
|
time.sleep(backoff)
|
|
125
126
|
finally:
|
|
126
127
|
response.close()
|
|
@@ -191,8 +191,8 @@ def sweep_orphans(
|
|
|
191
191
|
if delete_resource(session, kind, name):
|
|
192
192
|
deleted += 1
|
|
193
193
|
except Exception as exc: # one bad delete must not abort the rest of the sweep
|
|
194
|
-
chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet)
|
|
194
|
+
chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet, level="warn")
|
|
195
195
|
|
|
196
196
|
if deleted:
|
|
197
|
-
chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet)
|
|
197
|
+
chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet, level="success")
|
|
198
198
|
return deleted
|
|
@@ -8,6 +8,7 @@ import threading
|
|
|
8
8
|
from kubernetes import watch
|
|
9
9
|
from kubernetes.client.rest import ApiException
|
|
10
10
|
|
|
11
|
+
from squarepeg import ui
|
|
11
12
|
from squarepeg.errors import InterruptError, RunnerError
|
|
12
13
|
from squarepeg.k8s.apierrors import delete_resource, wrap_api_exception
|
|
13
14
|
from squarepeg.k8s.events import print_pod_events
|
|
@@ -164,9 +165,10 @@ class _InterruptHandler:
|
|
|
164
165
|
chatter(
|
|
165
166
|
f"interrupted; cleaning up {self.mode} {self.name!r} (press Ctrl+C again to leave it running)",
|
|
166
167
|
quiet=self.quiet,
|
|
168
|
+
level="warn",
|
|
167
169
|
)
|
|
168
170
|
else:
|
|
169
|
-
chatter(f"leaving {self.mode} {self.name!r} running", quiet=False)
|
|
171
|
+
chatter(f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn")
|
|
170
172
|
|
|
171
173
|
@property
|
|
172
174
|
def interrupted(self) -> bool:
|
|
@@ -184,10 +186,34 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
184
186
|
with _InterruptHandler(name, spec.mode, spec.quiet) as handler:
|
|
185
187
|
log_thread = None
|
|
186
188
|
try:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
with ui.Status(f"creating {spec.mode} {name!r} in namespace {session.namespace!r}", quiet=spec.quiet):
|
|
190
|
+
create_resource(session, spec, manifest)
|
|
191
|
+
|
|
192
|
+
if spec.mode == "job":
|
|
193
|
+
with ui.Status(
|
|
194
|
+
f"waiting for job {name!r} to create a pod",
|
|
195
|
+
quiet=spec.quiet,
|
|
196
|
+
slow_hint="the job controller has not produced a pod yet",
|
|
197
|
+
slow_after=15,
|
|
198
|
+
):
|
|
199
|
+
pod_name = discover_job_pod(session, name, spec.timeout)
|
|
200
|
+
else:
|
|
201
|
+
pod_name = name
|
|
202
|
+
|
|
203
|
+
with ui.Status(
|
|
204
|
+
f"waiting for pod {pod_name!r} to start",
|
|
205
|
+
quiet=spec.quiet,
|
|
206
|
+
slow_hint="still scheduling or pulling the image; a cold pull of a large image can take a while",
|
|
207
|
+
slow_after=20,
|
|
208
|
+
spinner_delay=0.0,
|
|
209
|
+
):
|
|
210
|
+
phase = wait_until_running_or_terminal(session, pod_name, spec.timeout, quiet=spec.quiet)
|
|
189
211
|
|
|
190
|
-
|
|
212
|
+
chatter(
|
|
213
|
+
f"streaming logs from pod {pod_name!r} (waiting for it to finish; Ctrl+C to stop)",
|
|
214
|
+
quiet=spec.quiet,
|
|
215
|
+
level="step",
|
|
216
|
+
)
|
|
191
217
|
|
|
192
218
|
log_thread = threading.Thread(
|
|
193
219
|
target=stream_logs,
|
|
@@ -206,7 +232,11 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
206
232
|
# truncate output for fast-exiting containers.
|
|
207
233
|
log_thread.join(timeout=30)
|
|
208
234
|
if log_thread.is_alive():
|
|
209
|
-
chatter(
|
|
235
|
+
chatter(
|
|
236
|
+
f"log stream for pod {pod_name!r} did not finish on its own; stopping it",
|
|
237
|
+
quiet=spec.quiet,
|
|
238
|
+
level="warn",
|
|
239
|
+
)
|
|
210
240
|
handler.stop_event.set()
|
|
211
241
|
log_thread.join(timeout=5)
|
|
212
242
|
|
|
@@ -215,19 +245,25 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
215
245
|
|
|
216
246
|
exit_code, reason = extract_exit_code(session, pod_name, spec.container_name)
|
|
217
247
|
if reason == "OOMKilled":
|
|
218
|
-
chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet)
|
|
248
|
+
chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet, level="error")
|
|
219
249
|
return exit_code
|
|
220
250
|
finally:
|
|
221
251
|
handler.stop_event.set()
|
|
222
252
|
if log_thread is not None:
|
|
223
253
|
log_thread.join(timeout=2)
|
|
224
254
|
if spec.cleanup and not handler.abandoned:
|
|
225
|
-
|
|
255
|
+
with ui.Status(f"deleting {spec.mode} {name!r}", quiet=spec.quiet):
|
|
256
|
+
cleanup(session, spec, name)
|
|
226
257
|
elif not handler.abandoned and not spec.quiet:
|
|
227
|
-
chatter(
|
|
258
|
+
chatter(
|
|
259
|
+
f"kept {spec.mode} {name!r}; inspect with 'kubectl describe {spec.mode} {name}'",
|
|
260
|
+
quiet=False,
|
|
261
|
+
level="warn",
|
|
262
|
+
)
|
|
228
263
|
if spec.orphan_sweep and not handler.abandoned:
|
|
229
264
|
own_run_id = manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
|
|
230
265
|
try:
|
|
231
|
-
|
|
266
|
+
with ui.Status("sweeping orphaned resources from previous runs", quiet=spec.quiet):
|
|
267
|
+
sweep_orphans(session, spec, exclude_run_id=own_run_id)
|
|
232
268
|
except Exception as exc:
|
|
233
|
-
chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet)
|
|
269
|
+
chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn")
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""squarepeg's own status output: coloured, levelled lines plus an optional animated
|
|
2
|
+
"what's happening now" spinner for stages that can take a noticeable while.
|
|
3
|
+
|
|
4
|
+
Everything here writes to stderr only, never stdout -- stdout is reserved for the
|
|
5
|
+
container's own output (and, for --dry-run/--dry-run-server, the manifest/response YAML),
|
|
6
|
+
and that separation must never be crossed. See squarepeg/log.py, which every existing
|
|
7
|
+
call site still imports; chatter() is now a thin wrapper around emit() below so none of
|
|
8
|
+
those call sites needed to change.
|
|
9
|
+
|
|
10
|
+
Colour/animation precedence (highest first): --quiet (nothing at all) > --no-color
|
|
11
|
+
(forces colour off) > $NO_COLOR (forces colour off) > $FORCE_COLOR (forces colour on) >
|
|
12
|
+
autodetect from sys.stderr.isatty() (click's own default behaviour).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
Level = Literal["info", "step", "wait", "success", "warn", "error", "detail"]
|
|
25
|
+
|
|
26
|
+
_STYLES: dict[Level, dict] = {
|
|
27
|
+
"info": {},
|
|
28
|
+
"step": {"fg": "cyan"},
|
|
29
|
+
"wait": {"fg": "cyan", "dim": True},
|
|
30
|
+
"success": {"fg": "green"},
|
|
31
|
+
"warn": {"fg": "yellow"},
|
|
32
|
+
"error": {"fg": "red", "bold": True},
|
|
33
|
+
"detail": {"fg": "bright_black"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_SPINNER_FRAMES_UNICODE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
37
|
+
_SPINNER_FRAMES_ASCII = ["|", "/", "-", "\\"]
|
|
38
|
+
_SPINNER_INTERVAL = 0.1
|
|
39
|
+
|
|
40
|
+
_LOCK = threading.RLock()
|
|
41
|
+
_ACTIVE: "Status | None" = None
|
|
42
|
+
|
|
43
|
+
_color_override: bool | None = None # None = no override, resolved from env/TTY instead
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_color_override(value: bool | None) -> None:
|
|
47
|
+
"""Called once from cli.run() for --no-color. None restores auto-detection."""
|
|
48
|
+
global _color_override
|
|
49
|
+
_color_override = value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def color_enabled() -> bool | None:
|
|
53
|
+
"""Tri-state, passed straight through to click.echo(color=...): True/False force
|
|
54
|
+
colour on/off, None lets click autodetect from sys.stderr.isatty()."""
|
|
55
|
+
if _color_override is not None:
|
|
56
|
+
return _color_override
|
|
57
|
+
if os.environ.get("NO_COLOR"):
|
|
58
|
+
return False
|
|
59
|
+
if os.environ.get("FORCE_COLOR"):
|
|
60
|
+
return True
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def animation_enabled() -> bool:
|
|
65
|
+
if _color_override is False:
|
|
66
|
+
return False
|
|
67
|
+
if os.environ.get("NO_COLOR"):
|
|
68
|
+
return False
|
|
69
|
+
if os.environ.get("TERM") == "dumb":
|
|
70
|
+
return False
|
|
71
|
+
return getattr(sys.stderr, "isatty", lambda: False)()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _spinner_frames() -> list[str]:
|
|
75
|
+
encoding = getattr(sys.stderr, "encoding", None) or ""
|
|
76
|
+
if "utf" in encoding.lower():
|
|
77
|
+
return _SPINNER_FRAMES_UNICODE
|
|
78
|
+
return _SPINNER_FRAMES_ASCII
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _styled_line(message: str, level: Level) -> str:
|
|
82
|
+
prefix = click.style("[squarepeg] ", dim=True)
|
|
83
|
+
body = click.style(message, **_STYLES[level])
|
|
84
|
+
return prefix + body
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def emit(message: str, *, level: Level = "info", quiet: bool = False) -> None:
|
|
88
|
+
"""Print one status line at `level`. Never touches stdout."""
|
|
89
|
+
if quiet:
|
|
90
|
+
return
|
|
91
|
+
with _LOCK:
|
|
92
|
+
if _ACTIVE is not None:
|
|
93
|
+
_ACTIVE._erase()
|
|
94
|
+
click.echo(_styled_line(message, level), file=sys.stderr, color=color_enabled())
|
|
95
|
+
# the active Status's own animation loop redraws unconditionally on its next tick,
|
|
96
|
+
# so nothing further is needed here to restore the spinner line
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Status:
|
|
100
|
+
"""Context manager for a stage that may take a while.
|
|
101
|
+
|
|
102
|
+
On a real terminal, shows an animated spinner line (stderr only) with an elapsed
|
|
103
|
+
timer and, past `slow_after` seconds, a `slow_hint`. Everywhere else (non-TTY,
|
|
104
|
+
--no-color, --quiet, TERM=dumb) it degrades to a single static line printed once on
|
|
105
|
+
entry -- including the slow_hint text upfront, since there is no timer to add it
|
|
106
|
+
later -- and nothing further is printed on exit besides the optional `success` line.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
message: str,
|
|
112
|
+
*,
|
|
113
|
+
quiet: bool = False,
|
|
114
|
+
slow_hint: str | None = None,
|
|
115
|
+
slow_after: float = 20.0,
|
|
116
|
+
spinner_delay: float = 0.4,
|
|
117
|
+
success: str | None = None,
|
|
118
|
+
):
|
|
119
|
+
self.message = message
|
|
120
|
+
self.quiet = quiet
|
|
121
|
+
self.slow_hint = slow_hint
|
|
122
|
+
self.slow_after = slow_after
|
|
123
|
+
self.spinner_delay = spinner_delay
|
|
124
|
+
self.success = success
|
|
125
|
+
self._thread: threading.Thread | None = None
|
|
126
|
+
self._stop = threading.Event()
|
|
127
|
+
self._start_time: float | None = None
|
|
128
|
+
self._line_drawn = False
|
|
129
|
+
|
|
130
|
+
def update(self, message: str) -> None:
|
|
131
|
+
with _LOCK:
|
|
132
|
+
self.message = message
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> "Status":
|
|
135
|
+
global _ACTIVE
|
|
136
|
+
if self.quiet:
|
|
137
|
+
return self
|
|
138
|
+
if not animation_enabled():
|
|
139
|
+
text = self.message
|
|
140
|
+
if self.slow_hint:
|
|
141
|
+
text = f"{self.message} ({self.slow_hint})"
|
|
142
|
+
click.echo(_styled_line(text, "wait"), file=sys.stderr, color=color_enabled())
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
self._start_time = time.monotonic()
|
|
146
|
+
with _LOCK:
|
|
147
|
+
click.echo(_styled_line(self.message, "wait"), file=sys.stderr, nl=False, color=color_enabled())
|
|
148
|
+
self._line_drawn = True
|
|
149
|
+
_ACTIVE = self
|
|
150
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
151
|
+
self._thread.start()
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def __exit__(self, exc_type, exc, tb) -> Literal[False]:
|
|
155
|
+
global _ACTIVE
|
|
156
|
+
self._stop.set()
|
|
157
|
+
if self._thread is not None:
|
|
158
|
+
self._thread.join(timeout=2)
|
|
159
|
+
with _LOCK:
|
|
160
|
+
self._erase()
|
|
161
|
+
if _ACTIVE is self:
|
|
162
|
+
_ACTIVE = None
|
|
163
|
+
if self.success is not None and exc_type is None and not self.quiet:
|
|
164
|
+
click.echo(_styled_line(self.success, "success"), file=sys.stderr, color=color_enabled())
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
def _erase(self) -> None:
|
|
168
|
+
"""Erase the currently-drawn spinner line, if any. Caller holds _LOCK."""
|
|
169
|
+
if self._line_drawn:
|
|
170
|
+
click.echo("\r\x1b[2K", file=sys.stderr, nl=False, color=color_enabled())
|
|
171
|
+
self._line_drawn = False
|
|
172
|
+
|
|
173
|
+
def _animate(self) -> None:
|
|
174
|
+
frames = itertools.cycle(_spinner_frames())
|
|
175
|
+
# Below spinner_delay we leave the plain static line (drawn in __enter__) alone --
|
|
176
|
+
# this is what keeps a fast create/delete from ever showing spinner motion at all.
|
|
177
|
+
while not self._stop.is_set() and time.monotonic() - self._start_time < self.spinner_delay:
|
|
178
|
+
self._stop.wait(_SPINNER_INTERVAL)
|
|
179
|
+
while not self._stop.is_set():
|
|
180
|
+
with _LOCK:
|
|
181
|
+
elapsed = time.monotonic() - self._start_time
|
|
182
|
+
text = self.message
|
|
183
|
+
if elapsed >= 2:
|
|
184
|
+
text = f"{text} ({int(elapsed)}s)"
|
|
185
|
+
if self.slow_hint and elapsed >= self.slow_after:
|
|
186
|
+
text = f"{text} — {self.slow_hint}"
|
|
187
|
+
line = f"\r\x1b[2K{_styled_line(f'{next(frames)} {text}', 'wait')}"
|
|
188
|
+
click.echo(line, file=sys.stderr, nl=False, color=color_enabled())
|
|
189
|
+
self._line_drawn = True
|
|
190
|
+
self._stop.wait(_SPINNER_INTERVAL)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: squarepeg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -12,6 +12,15 @@ Requires-Dist: pytest>=8; extra == "dev"
|
|
|
12
12
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
13
13
|
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
14
14
|
|
|
15
|
+
```
|
|
16
|
+
_____
|
|
17
|
+
/ ___/____ ___ ______ _________ ____ ___ ____ _
|
|
18
|
+
\__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
|
|
19
|
+
___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
|
|
20
|
+
/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
|
|
21
|
+
/_/ /_/ /____/
|
|
22
|
+
```
|
|
23
|
+
|
|
15
24
|
# squarepeg
|
|
16
25
|
|
|
17
26
|
Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
|
|
@@ -14,6 +14,7 @@ squarepeg/merge.py
|
|
|
14
14
|
squarepeg/naming.py
|
|
15
15
|
squarepeg/quantities.py
|
|
16
16
|
squarepeg/runspec.py
|
|
17
|
+
squarepeg/ui.py
|
|
17
18
|
squarepeg/volumes.py
|
|
18
19
|
squarepeg.egg-info/PKG-INFO
|
|
19
20
|
squarepeg.egg-info/SOURCES.txt
|
|
@@ -47,4 +48,5 @@ tests/test_podstate.py
|
|
|
47
48
|
tests/test_quantities.py
|
|
48
49
|
tests/test_runner.py
|
|
49
50
|
tests/test_session.py
|
|
51
|
+
tests/test_ui.py
|
|
50
52
|
tests/test_volumes.py
|
|
@@ -31,6 +31,28 @@ def test_run_dry_run_does_not_touch_a_cluster():
|
|
|
31
31
|
assert "kind: Pod" in result.output
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def _without_run_id(manifest):
|
|
35
|
+
manifest = dict(manifest)
|
|
36
|
+
manifest["metadata"] = dict(manifest["metadata"])
|
|
37
|
+
manifest["metadata"]["labels"] = {
|
|
38
|
+
k: v for k, v in manifest["metadata"]["labels"].items() if k != "squarepeg.io/run-id"
|
|
39
|
+
}
|
|
40
|
+
return manifest
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_run_dry_run_output_unaffected_by_force_color(monkeypatch):
|
|
44
|
+
"""--dry-run's YAML is machine-parseable output and must never be colourised/decorated,
|
|
45
|
+
regardless of $FORCE_COLOR -- only squarepeg's own status chatter goes through ui.py."""
|
|
46
|
+
args = ["run", "--no-default-config", "--name", "squarepeg-pinned-name", "--dry-run", "alpine"]
|
|
47
|
+
plain = CliRunner().invoke(cli, args)
|
|
48
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
49
|
+
forced = CliRunner().invoke(cli, args)
|
|
50
|
+
assert "\x1b" not in forced.output
|
|
51
|
+
# squarepeg.io/run-id is a fresh random UUID per invocation by design; everything else
|
|
52
|
+
# must be byte-identical regardless of $FORCE_COLOR.
|
|
53
|
+
assert _without_run_id(yaml.safe_load(plain.output)) == _without_run_id(yaml.safe_load(forced.output))
|
|
54
|
+
|
|
55
|
+
|
|
34
56
|
def test_run_missing_image_errors():
|
|
35
57
|
result = CliRunner().invoke(cli, ["run"])
|
|
36
58
|
assert result.exit_code != 0
|
|
@@ -221,15 +243,7 @@ def test_dry_run_server_sends_identical_manifest_to_dry_run(monkeypatch):
|
|
|
221
243
|
|
|
222
244
|
# squarepeg.io/run-id is a fresh random UUID per invocation by design; strip it before
|
|
223
245
|
# comparing, since it's the one label that's expected to legitimately differ here.
|
|
224
|
-
|
|
225
|
-
manifest = dict(manifest)
|
|
226
|
-
manifest["metadata"] = dict(manifest["metadata"])
|
|
227
|
-
manifest["metadata"]["labels"] = {
|
|
228
|
-
k: v for k, v in manifest["metadata"]["labels"].items() if k != "squarepeg.io/run-id"
|
|
229
|
-
}
|
|
230
|
-
return manifest
|
|
231
|
-
|
|
232
|
-
assert without_run_id(captured["manifest"]) == without_run_id(yaml.safe_load(dry_run_result.output))
|
|
246
|
+
assert _without_run_id(captured["manifest"]) == _without_run_id(yaml.safe_load(dry_run_result.output))
|
|
233
247
|
|
|
234
248
|
|
|
235
249
|
def test_dry_run_server_constructs_session_with_context_and_quiet(monkeypatch):
|
|
@@ -281,3 +295,75 @@ def test_dry_run_server_no_cluster_connection_exits_125(monkeypatch):
|
|
|
281
295
|
assert result.exit_code != 0
|
|
282
296
|
assert result.exception.exit_code == 125
|
|
283
297
|
assert "kubeconfig" in str(result.exception)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- config group: bare 'config' defaults to 'show' ---
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_config_bare_equals_config_show():
|
|
304
|
+
bare = CliRunner().invoke(cli, ["config", "--no-default-config"])
|
|
305
|
+
show = CliRunner().invoke(cli, ["config", "show", "--no-default-config"])
|
|
306
|
+
assert bare.exit_code == 0, bare.output
|
|
307
|
+
assert show.exit_code == 0, show.output
|
|
308
|
+
assert bare.output == show.output
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_config_bare_no_sources_message():
|
|
312
|
+
result = CliRunner().invoke(cli, ["config", "--no-default-config"])
|
|
313
|
+
assert result.exit_code == 0, result.output
|
|
314
|
+
assert "no config files loaded" in result.output
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_config_bare_respects_profile(tmp_path):
|
|
318
|
+
config_file = tmp_path / "config.yaml"
|
|
319
|
+
config_file.write_text("profiles:\n p:\n namespace: from-profile\n")
|
|
320
|
+
result = CliRunner().invoke(
|
|
321
|
+
cli, ["config", "--no-default-config", "--config", str(config_file), "--profile", "p"]
|
|
322
|
+
)
|
|
323
|
+
assert result.exit_code == 0, result.output
|
|
324
|
+
assert "from-profile" in result.output
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_config_option_before_subcommand_matches_bare(tmp_path):
|
|
328
|
+
"""Group-level options given before 'show' must still apply -- proves the default_map
|
|
329
|
+
seeding in the group callback actually wires the options through, rather than an
|
|
330
|
+
explicit 'show' silently discarding them."""
|
|
331
|
+
config_file = tmp_path / "config.yaml"
|
|
332
|
+
config_file.write_text("namespace: from-config\n")
|
|
333
|
+
before = CliRunner().invoke(cli, ["config", "--no-default-config", "--config", str(config_file), "show"])
|
|
334
|
+
bare = CliRunner().invoke(cli, ["config", "--no-default-config", "--config", str(config_file)])
|
|
335
|
+
assert before.exit_code == 0, before.output
|
|
336
|
+
assert bare.exit_code == 0, bare.output
|
|
337
|
+
assert before.output == bare.output
|
|
338
|
+
assert "from-config" in before.output
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_config_explicit_show_option_wins_over_group_level(tmp_path):
|
|
342
|
+
"""An explicit 'config show --profile q' must win over a group-level '--profile p'."""
|
|
343
|
+
config_file = tmp_path / "config.yaml"
|
|
344
|
+
config_file.write_text(
|
|
345
|
+
"profiles:\n p:\n namespace: from-p\n q:\n namespace: from-q\n"
|
|
346
|
+
)
|
|
347
|
+
result = CliRunner().invoke(
|
|
348
|
+
cli,
|
|
349
|
+
[
|
|
350
|
+
"config",
|
|
351
|
+
"--no-default-config",
|
|
352
|
+
"--config",
|
|
353
|
+
str(config_file),
|
|
354
|
+
"--profile",
|
|
355
|
+
"p",
|
|
356
|
+
"show",
|
|
357
|
+
"--profile",
|
|
358
|
+
"q",
|
|
359
|
+
],
|
|
360
|
+
)
|
|
361
|
+
assert result.exit_code == 0, result.output
|
|
362
|
+
assert "from-q" in result.output
|
|
363
|
+
assert "from-p" not in result.output
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_config_unknown_subcommand_errors():
|
|
367
|
+
result = CliRunner().invoke(cli, ["config", "bogus"])
|
|
368
|
+
assert result.exit_code != 0
|
|
369
|
+
assert "No such command 'bogus'" in result.output
|
|
@@ -294,6 +294,33 @@ def test_run_manifest_happy_path_calls_in_order(monkeypatch):
|
|
|
294
294
|
session.core.delete_namespaced_pod.assert_called_once_with("squarepeg-alpine-abc123", "default")
|
|
295
295
|
|
|
296
296
|
|
|
297
|
+
def test_run_manifest_never_writes_status_output_to_stdout(monkeypatch, capsys):
|
|
298
|
+
"""The colour/spinner mechanism must never touch stdout -- only the container's own
|
|
299
|
+
output (here faked by stream_logs) may land there."""
|
|
300
|
+
|
|
301
|
+
def fake_stream_logs(session, pod_name, container_name, stop_event, *, quiet=False):
|
|
302
|
+
import sys
|
|
303
|
+
|
|
304
|
+
sys.stdout.buffer.write(b"container output line\n")
|
|
305
|
+
sys.stdout.buffer.flush()
|
|
306
|
+
|
|
307
|
+
monkeypatch.setattr(runner, "stream_logs", fake_stream_logs)
|
|
308
|
+
session = make_session()
|
|
309
|
+
monkeypatch.setattr(runner.watch, "Watch", lambda: FakeWatch([{"object": pod_object(phase="Succeeded")}]))
|
|
310
|
+
cs = container_status(name="main", terminated_code=0, terminated_reason="Completed")
|
|
311
|
+
session.core.read_namespaced_pod.return_value = pod_object(container_statuses=[cs])
|
|
312
|
+
|
|
313
|
+
manifest = {"metadata": {"name": "squarepeg-alpine-abc123"}}
|
|
314
|
+
code = runner.run_manifest(session, basic_spec(mode="pod"), manifest)
|
|
315
|
+
|
|
316
|
+
captured = capsys.readouterr()
|
|
317
|
+
assert code == 0
|
|
318
|
+
assert captured.out == "container output line\n"
|
|
319
|
+
assert "container output line" not in captured.err
|
|
320
|
+
assert "creating" in captured.err
|
|
321
|
+
assert "deleting" in captured.err
|
|
322
|
+
|
|
323
|
+
|
|
297
324
|
def test_run_manifest_does_not_truncate_logs_for_already_terminal_pod(monkeypatch):
|
|
298
325
|
"""Regression test: when the pod is already Succeeded/Failed by the time
|
|
299
326
|
wait_until_running_or_terminal returns (typical for fast-exiting containers), the log
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from squarepeg import ui
|
|
6
|
+
|
|
7
|
+
LEVELS = ["info", "step", "wait", "success", "warn", "error", "detail"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(autouse=True)
|
|
11
|
+
def _reset_color_override():
|
|
12
|
+
ui.set_color_override(None)
|
|
13
|
+
yield
|
|
14
|
+
ui.set_color_override(None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- emit: colour / env var precedence ---
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize("level", LEVELS)
|
|
21
|
+
def test_emit_no_ansi_under_capsys_by_default(capsys, level):
|
|
22
|
+
"""capsys is a non-TTY, so click.echo's own colour autodetection should strip ANSI."""
|
|
23
|
+
ui.emit("hello world", level=level)
|
|
24
|
+
assert "\x1b" not in capsys.readouterr().err
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_emit_force_color_adds_ansi(monkeypatch, capsys):
|
|
28
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
29
|
+
ui.emit("hello", level="step")
|
|
30
|
+
assert "\x1b[36m" in capsys.readouterr().err
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_no_color_beats_force_color(monkeypatch, capsys):
|
|
34
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
35
|
+
monkeypatch.setenv("NO_COLOR", "1")
|
|
36
|
+
ui.emit("hello", level="step")
|
|
37
|
+
assert "\x1b" not in capsys.readouterr().err
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_no_color_override_beats_force_color(monkeypatch, capsys):
|
|
41
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
42
|
+
ui.set_color_override(False)
|
|
43
|
+
ui.emit("hello", level="step")
|
|
44
|
+
assert "\x1b" not in capsys.readouterr().err
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize("level", LEVELS)
|
|
48
|
+
def test_emit_quiet_prints_nothing(capsys, level):
|
|
49
|
+
ui.emit("should not appear", level=level, quiet=True)
|
|
50
|
+
assert capsys.readouterr().err == ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("level", LEVELS)
|
|
54
|
+
def test_emit_body_is_contiguous_substring_even_with_color_forced(monkeypatch, capsys, level):
|
|
55
|
+
"""Guards the never-style-a-sub-span rule: existing tests assert on exact substrings of
|
|
56
|
+
chatter output via capsys, and must keep passing even if colour is forced on."""
|
|
57
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
58
|
+
ui.emit("swept 3 orphaned resource(s) from previous runs", level=level)
|
|
59
|
+
assert "swept 3 orphaned resource(s) from previous runs" in capsys.readouterr().err
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- Status: non-TTY degrades to a single static line, no thread ---
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_status_non_tty_prints_one_static_line_including_slow_hint(capsys):
|
|
66
|
+
before = threading.active_count()
|
|
67
|
+
with ui.Status("waiting for pod to start", slow_hint="still pulling", slow_after=999):
|
|
68
|
+
assert threading.active_count() == before
|
|
69
|
+
err = capsys.readouterr().err
|
|
70
|
+
assert "waiting for pod to start" in err
|
|
71
|
+
assert "still pulling" in err
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_status_non_tty_no_thread_spawned(capsys):
|
|
75
|
+
before = threading.active_count()
|
|
76
|
+
with ui.Status("doing a thing"):
|
|
77
|
+
assert threading.active_count() == before
|
|
78
|
+
assert threading.active_count() == before
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_status_quiet_emits_nothing_including_success(capsys):
|
|
82
|
+
with ui.Status("doing a thing", quiet=True, success="done"):
|
|
83
|
+
pass
|
|
84
|
+
assert capsys.readouterr().err == ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_status_success_printed_on_clean_exit(capsys):
|
|
88
|
+
with ui.Status("doing a thing", success="all done"):
|
|
89
|
+
pass
|
|
90
|
+
assert "all done" in capsys.readouterr().err
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_status_success_not_printed_on_exception(capsys):
|
|
94
|
+
from squarepeg.errors import RunnerError
|
|
95
|
+
|
|
96
|
+
with pytest.raises(RunnerError):
|
|
97
|
+
with ui.Status("doing a thing", success="all done"):
|
|
98
|
+
raise RunnerError("boom")
|
|
99
|
+
err = capsys.readouterr().err
|
|
100
|
+
assert "all done" not in err
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_status_propagates_exceptions_and_leaves_no_live_thread():
|
|
104
|
+
from squarepeg.errors import RunnerError
|
|
105
|
+
|
|
106
|
+
before = threading.active_count()
|
|
107
|
+
with pytest.raises(RunnerError):
|
|
108
|
+
with ui.Status("doing a thing"):
|
|
109
|
+
raise RunnerError("boom")
|
|
110
|
+
assert threading.active_count() == before
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
squarepeg-0.1.0/squarepeg/log.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|