dkview 3.0.0b5__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.
dkview/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dkview: readable, colored output for docker commands."""
2
+
3
+ __version__ = "3.0.0b5"
dkview/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
dkview/ansi.py ADDED
@@ -0,0 +1,124 @@
1
+ """Terminal colors and helpers that measure text without escape codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import sys
9
+
10
+ RESET = "\033[0m"
11
+ BOLD = "\033[1m"
12
+ DIM = "\033[2m"
13
+ RED = "\033[31m"
14
+ GREEN = "\033[32m"
15
+ YELLOW = "\033[33m"
16
+ BLUE = "\033[34m"
17
+ MAGENTA = "\033[35m"
18
+ CYAN = "\033[36m"
19
+ REVERSE = "\033[7m"
20
+
21
+ ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
22
+
23
+
24
+ def _enable_windows_ansi() -> bool:
25
+ """Windows 10+ consoles understand color codes once asked to.
26
+
27
+ Returns False when they can't (old Windows), so colors stay off there.
28
+ """
29
+ if os.name != "nt":
30
+ return True
31
+ try:
32
+ import ctypes
33
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
34
+ handle = kernel32.GetStdHandle(-11) # stdout
35
+ mode = ctypes.c_uint32()
36
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
37
+ return False
38
+ return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
39
+ except Exception:
40
+ return False
41
+
42
+
43
+ # Plain-ASCII stand-ins, for outputs that can't show box drawing or symbols
44
+ # (a pipe or an old console on Windows that uses a legacy code page).
45
+ # Characters that can sit inside a table cell map to one character.
46
+ ASCII = str.maketrans({
47
+ "┌": "+", "┬": "+", "┐": "+", "├": "+", "┼": "+", "┤": "+", "└": "+", "┴": "+",
48
+ "┘": "+", "─": "-", "│": "|", "●": "*", "○": "o", "✓": "OK", "✗": "X",
49
+ "·": "-", "…": "~", "×": "x", "→": "->", "⚠": "!",
50
+ })
51
+
52
+
53
+ class _AsciiWriter:
54
+ """Wraps a text stream, writing ASCII stand-ins for what it can't encode."""
55
+
56
+ def __init__(self, stream) -> None:
57
+ self._stream = stream
58
+
59
+ def write(self, text: str) -> int:
60
+ return self._stream.write(text.translate(ASCII))
61
+
62
+ def __getattr__(self, name: str):
63
+ return getattr(self._stream, name)
64
+
65
+
66
+ def _can_show(stream, sample: str = "┌─●✓") -> bool:
67
+ try:
68
+ sample.encode(getattr(stream, "encoding", None) or "ascii")
69
+ return True
70
+ except (LookupError, UnicodeEncodeError):
71
+ return False
72
+
73
+
74
+ def prepare_output() -> None:
75
+ """Never crash on a character the output's encoding lacks: use ASCII
76
+ stand-ins for box lines and symbols, and '?' for anything else."""
77
+ for name in ("stdout", "stderr"):
78
+ stream = getattr(sys, name)
79
+ if stream is None or _can_show(stream):
80
+ continue
81
+ if hasattr(stream, "reconfigure"):
82
+ try:
83
+ stream.reconfigure(errors="replace")
84
+ except (ValueError, OSError):
85
+ pass
86
+ setattr(sys, name, _AsciiWriter(stream))
87
+
88
+
89
+ class _Colors:
90
+ """Process-wide switch for colored output."""
91
+
92
+ def __init__(self) -> None:
93
+ self.enabled = (sys.stdout.isatty() and "NO_COLOR" not in os.environ
94
+ and _enable_windows_ansi())
95
+
96
+
97
+ colors = _Colors()
98
+
99
+
100
+ def paint(text: str, *codes: str) -> str:
101
+ if not colors.enabled or not text or not codes:
102
+ return text
103
+ return "".join(codes) + text + RESET
104
+
105
+
106
+ def strip(text: str) -> str:
107
+ return ANSI_RE.sub("", text)
108
+
109
+
110
+ def visible_len(text: str) -> int:
111
+ return len(strip(text))
112
+
113
+
114
+ def pad(text: str, width: int) -> str:
115
+ """ljust that ignores ANSI escape codes when measuring."""
116
+ return text + " " * max(0, width - visible_len(text))
117
+
118
+
119
+ def terminal_size() -> "os.terminal_size":
120
+ return shutil.get_terminal_size((120, 40))
121
+
122
+
123
+ def terminal_width() -> int:
124
+ return terminal_size().columns
dkview/cli.py ADDED
@@ -0,0 +1,261 @@
1
+ """Command-line entry point: decide what to do with the command given."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import re
8
+ import shlex
9
+ import sys
10
+ from typing import List, Optional, Sequence
11
+
12
+ from . import __version__, docker, engine
13
+ from .ansi import colors, prepare_output
14
+ from .features import clean, dashboard, doctor, errors, images, inspect, live, logs, shell, tables
15
+ from .options import Options
16
+ from .runner import passthrough
17
+
18
+ EPILOG = """\
19
+ examples:
20
+ dkview docker ps formatted, colored table
21
+ dkview ps -a same; "docker" can be left out
22
+ dkview --short --cols name,status,image ps
23
+ dkview --sort cpu --desc stats live stats, busiest first (Ctrl+C quits)
24
+ dkview --watch service ls redraw every 2 seconds
25
+ dkview inspect web short summary; add --full for every field
26
+ dkview --grep timeout logs -f api
27
+ dkview dash overview of the whole host (--watch for live)
28
+ dkview images --group one row per repository with total sizes
29
+ dkview clean --dry-run old unused image tags that would be removed
30
+ dkview clean --keep 5 remove them, keeping the newest 5 per repository
31
+ dkview doctor failing swarm services with the real error
32
+ dkview errors errors and warnings in all logs, last 30 minutes
33
+ dkview errors orders --since 2d one stack, service or container
34
+ dkview shell-init make plain "docker ps" use dkview
35
+
36
+ dkview's own options go before the command. Default options can be set in
37
+ the DKVIEW_OPTS environment variable, e.g. DKVIEW_OPTS="--short".
38
+ """
39
+
40
+
41
+ # Commands that are dkview's own rather than docker's.
42
+ OWN_COMMANDS = {"dash", "clean", "doctor", "errors", "shell-init"}
43
+
44
+
45
+ def build_parser() -> argparse.ArgumentParser:
46
+ p = argparse.ArgumentParser(
47
+ prog="dkview",
48
+ description="Readable, colored output for docker commands.",
49
+ epilog=EPILOG,
50
+ formatter_class=argparse.RawDescriptionHelpFormatter,
51
+ )
52
+ view = p.add_argument_group("table view")
53
+ view.add_argument("--cols", metavar="A,B,...",
54
+ help="show only these columns, in this order (names can be shortened)")
55
+ view.add_argument("--sort", metavar="COL", help="sort rows by a column (numbers, sizes and ages sort by value)")
56
+ view.add_argument("--desc", action="store_true", help="sort largest first")
57
+ view.add_argument("--grep", metavar="REGEX",
58
+ help="keep only rows (or log lines) matching this, case-insensitive")
59
+ view.add_argument("--short", action="store_true",
60
+ help="hide the registry host in image names")
61
+ view.add_argument("--long-times", action="store_true",
62
+ help="keep docker's '4 minutes ago' instead of '4m ago'")
63
+ view.add_argument("--trunc", action="store_true",
64
+ help="keep docker's own truncation of long values")
65
+ view.add_argument("--width", type=int, help="table width (default: terminal width)")
66
+
67
+ mode = p.add_argument_group("modes")
68
+ mode.add_argument("-w", "--watch", action="store_true",
69
+ help="redraw the output every few seconds until Ctrl+C")
70
+ mode.add_argument("-n", "--interval", type=float, default=2.0, metavar="SEC",
71
+ help="seconds between redraws (default: 2)")
72
+ mode.add_argument("--once", action="store_true",
73
+ help="docker stats: print one snapshot instead of a live view")
74
+ mode.add_argument("--full", action="store_true",
75
+ help="inspect: show every field as a tree instead of a summary")
76
+ mode.add_argument("--raw", action="store_true",
77
+ help="run the command untouched")
78
+ mode.add_argument("--group", action="store_true",
79
+ help="images: one row per repository with total and unused size")
80
+
81
+ cleaning = p.add_argument_group("dkview clean")
82
+ cleaning.add_argument("--keep", type=int, default=3, metavar="N",
83
+ help="keep the newest N tags of each repository (default: 3)")
84
+ cleaning.add_argument("--dry-run", action="store_true",
85
+ help="only show what would be deleted")
86
+ cleaning.add_argument("-y", "--yes", action="store_true",
87
+ help="delete without asking")
88
+
89
+ logs_group = p.add_argument_group("dkview errors")
90
+ logs_group.add_argument("--since", metavar="TIME",
91
+ help="how far back to read logs: 30m, 6h, 2d, 1w or a date "
92
+ f"(default: {errors.DEFAULT_SINCE})")
93
+
94
+ out = p.add_argument_group("output")
95
+ out.add_argument("--no-color", action="store_true", help="disable colors")
96
+ out.add_argument("--color", action="store_true",
97
+ help="force colors even when not printing to a terminal")
98
+ out.add_argument("-V", "--version", action="version", version=f"dkview {__version__}")
99
+
100
+ p.add_argument("command", nargs=argparse.REMAINDER, help="the command to run")
101
+ return p
102
+
103
+
104
+ def to_options(args: argparse.Namespace) -> Options:
105
+ return Options(
106
+ width=args.width,
107
+ cols=[c.strip() for c in (args.cols or "").split(",") if c.strip()],
108
+ sort=args.sort,
109
+ desc=args.desc,
110
+ grep=re.compile(args.grep, re.IGNORECASE) if args.grep else None,
111
+ short=args.short,
112
+ humanize=not args.long_times,
113
+ trunc=args.trunc,
114
+ watch=args.watch,
115
+ once=args.once,
116
+ interval=max(0.5, args.interval),
117
+ full=args.full,
118
+ group=args.group,
119
+ keep=max(0, args.keep),
120
+ dry_run=args.dry_run,
121
+ yes=args.yes,
122
+ since=args.since,
123
+ )
124
+
125
+
126
+ def run(argv: Sequence[str]) -> int:
127
+ parser = build_parser()
128
+ args = parser.parse_args(shlex.split(os.environ.get("DKVIEW_OPTS", "")) + list(argv))
129
+
130
+ if args.no_color:
131
+ colors.enabled = False
132
+ elif args.color:
133
+ colors.enabled = True
134
+
135
+ try:
136
+ opts = to_options(args)
137
+ except re.error as error:
138
+ parser.error(f"--grep: {error}")
139
+
140
+ command: List[str] = list(args.command)
141
+ if command[:1] == ["--"]:
142
+ command = command[1:]
143
+ if not command:
144
+ parser.print_help()
145
+ return 2
146
+
147
+ if command[0] == "errors":
148
+ return run_errors(parser, command[1:], opts)
149
+
150
+ if command[0] in OWN_COMMANDS and len(command) > 1 and command[0] != "shell-init":
151
+ # `dkview clean --keep 5`: options after dkview's own commands are dkview's.
152
+ before = list(argv)[:len(argv) - len(args.command)]
153
+ return run(before + command[1:] + [command[0]])
154
+
155
+ if command[0] == "clean":
156
+ return clean.run([engine.name()], opts)
157
+ if command[0] == "doctor":
158
+ return doctor.run([engine.name()], opts)
159
+ if command[0] == "dash":
160
+ frame = lambda width: dashboard.frame(opts, width) # noqa: E731
161
+ if opts.watch:
162
+ return live.run(frame, "dkview dash", opts.interval)
163
+ sys.stdout.write(frame(opts.width))
164
+ return 0
165
+
166
+ if command[0] == "shell-init":
167
+ return shell_init(command[1:])
168
+
169
+ command = docker.expand_shortcut(command)
170
+
171
+ if args.raw:
172
+ return passthrough(command)
173
+
174
+ if not docker.is_docker(command):
175
+ return run_table(command, opts)
176
+
177
+ kind = docker.classify(command).name
178
+ if kind == "logs":
179
+ return logs.run(command, opts)
180
+ if kind == "inspect":
181
+ return inspect.run(command, opts)
182
+ if kind == "stats":
183
+ live_stats = not opts.once and sys.stdout.isatty() and "--no-stream" not in command
184
+ if live_stats or opts.watch:
185
+ return live.run(tables.snapshot(command, opts), " ".join(command), opts.interval)
186
+ return tables.run(command, opts)
187
+ if kind == "table" and images.is_image_list(command):
188
+ if "--group" in command: # also accepted after the command
189
+ command = [a for a in command if a != "--group"]
190
+ opts.group = True
191
+ if opts.group:
192
+ return images.run_group(command, opts)
193
+ if kind == "table":
194
+ return run_table(command, opts)
195
+ return passthrough(command)
196
+
197
+
198
+ def run_errors(parser: argparse.ArgumentParser, rest: List[str], opts: Options) -> int:
199
+ """`dkview errors [TARGET...]`: targets and options can be mixed freely."""
200
+ sub = argparse.ArgumentParser(prog="dkview errors", description=errors.__doc__,
201
+ formatter_class=argparse.RawDescriptionHelpFormatter)
202
+ sub.add_argument("targets", nargs="*", metavar="NAME",
203
+ help="stack, service or container name or ID (default: everything)")
204
+ sub.add_argument("--since", metavar="TIME",
205
+ help=f"30m, 6h, 2d, 1w or a date (default: {errors.DEFAULT_SINCE})")
206
+ sub.add_argument("--grep", metavar="REGEX", help="count only lines matching this")
207
+ sub.add_argument("--full", action="store_true", help="show every kind of message")
208
+ sub.add_argument("--width", type=int, help="table width")
209
+ sub.add_argument("--no-color", action="store_true", help="disable colors")
210
+ sub.add_argument("--color", action="store_true", help="force colors")
211
+ args = sub.parse_intermixed_args(rest)
212
+
213
+ if args.since:
214
+ opts.since = args.since
215
+ if args.grep:
216
+ try:
217
+ opts.grep = re.compile(args.grep, re.IGNORECASE)
218
+ except re.error as error:
219
+ sub.error(f"--grep: {error}")
220
+ opts.full = opts.full or args.full
221
+ opts.width = args.width or opts.width
222
+ if args.no_color:
223
+ colors.enabled = False
224
+ elif args.color:
225
+ colors.enabled = True
226
+ return errors.run([engine.name()], args.targets, opts)
227
+
228
+
229
+ def run_table(command: List[str], opts: Options) -> int:
230
+ if opts.watch:
231
+ return live.run(tables.snapshot(command, opts), " ".join(command), opts.interval)
232
+ return tables.run(command, opts)
233
+
234
+
235
+ def shell_init(rest: List[str]) -> int:
236
+ name: Optional[str] = rest[0] if rest else None
237
+ if name is None:
238
+ sys.stdout.write(shell.USAGE)
239
+ return 0
240
+ if name not in shell.SCRIPTS:
241
+ sys.stderr.write(f"dkview: unsupported shell '{name}' (bash, zsh, fish, powershell)\n")
242
+ return 2
243
+ sys.stdout.write(shell.script(name))
244
+ return 0
245
+
246
+
247
+ def main(argv: Optional[Sequence[str]] = None) -> int:
248
+ prepare_output()
249
+ try:
250
+ return run(sys.argv[1:] if argv is None else argv)
251
+ except KeyboardInterrupt:
252
+ return 130
253
+ except BrokenPipeError:
254
+ # Output piped into `head` or `less` that quit early.
255
+ devnull = os.open(os.devnull, os.O_WRONLY)
256
+ os.dup2(devnull, sys.stdout.fileno())
257
+ return 0
258
+
259
+
260
+ if __name__ == "__main__":
261
+ raise SystemExit(main())
dkview/docker.py ADDED
@@ -0,0 +1,190 @@
1
+ """Understanding docker command lines: which subcommand, and how to run it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List, Sequence
7
+
8
+ from . import engine
9
+
10
+ # Options that take a separate value (`--context prod`, `-f file.yml`,
11
+ # `--filter status=exited`). The value is skipped when looking for the
12
+ # subcommand words.
13
+ VALUE_OPTIONS = {
14
+ "-c", "--context", "-H", "--host", "--config", "-l", "--log-level",
15
+ "--tlscacert", "--tlscert", "--tlskey",
16
+ "-f", "--file", "--filter", "-p", "--project-name",
17
+ "--project-directory", "--env-file", "--profile",
18
+ "-n", "--tail", "--since", "--until",
19
+ }
20
+
21
+ LIST_VERBS = {"ls", "list"}
22
+
23
+ # Commands whose output is a table. Anything not listed here (run, exec,
24
+ # build, events, typos, --help...) runs straight through untouched.
25
+ TABLE_COMMANDS = {
26
+ "ps": None, "images": None, "stats": None, "top": None,
27
+ "history": None, "search": None,
28
+ "container": LIST_VERBS | {"ps", "stats", "top"},
29
+ "image": LIST_VERBS | {"history"},
30
+ "volume": LIST_VERBS,
31
+ "network": LIST_VERBS,
32
+ "service": LIST_VERBS | {"ps"},
33
+ "node": LIST_VERBS | {"ps"},
34
+ "stack": LIST_VERBS | {"ps", "services"},
35
+ "secret": LIST_VERBS,
36
+ "config": LIST_VERBS,
37
+ "context": LIST_VERBS,
38
+ "plugin": LIST_VERBS,
39
+ "system": {"df"},
40
+ "compose": {"ps", "ls", "images", "top", "stats"},
41
+ "buildx": LIST_VERBS,
42
+ }
43
+
44
+ INSPECT_OBJECTS = {"container", "image", "service", "network", "volume",
45
+ "node", "secret", "config", "plugin", "context"}
46
+
47
+ LOG_OBJECTS = {"container", "service", "compose"}
48
+
49
+ # Every top-level docker command, so `dkview ps` can mean `docker ps`.
50
+ DOCKER_COMMANDS = {
51
+ "attach", "build", "builder", "buildx", "commit", "compose", "config",
52
+ "container", "context", "cp", "create", "diff", "events", "exec",
53
+ "export", "history", "image", "images", "import", "info", "inspect",
54
+ "kill", "load", "login", "logout", "logs", "manifest", "network",
55
+ "node", "pause", "plugin", "port", "ps", "pull", "push", "rename",
56
+ "restart", "rm", "rmi", "run", "save", "search", "secret", "service",
57
+ "stack", "start", "stats", "stop", "swarm", "system", "tag", "top",
58
+ "trust", "unpause", "update", "version", "volume", "wait",
59
+ }
60
+
61
+
62
+ def is_docker(argv: Sequence[str]) -> bool:
63
+ """docker, or podman, which takes the same commands."""
64
+ return bool(argv) and engine.kind(argv[0]) != ""
65
+
66
+
67
+ def expand_shortcut(argv: List[str]) -> List[str]:
68
+ """`dkview ps -a` -> `docker ps -a` (or `podman ps -a`)."""
69
+ if argv and argv[0] in DOCKER_COMMANDS:
70
+ return [engine.name()] + argv
71
+ return argv
72
+
73
+
74
+ def words(argv: Sequence[str], limit: int = 2) -> List[str]:
75
+ """First non-option words after `docker`.
76
+
77
+ `docker --context prod service ls -q` -> ['service', 'ls']
78
+ """
79
+ found: List[str] = []
80
+ skip = False
81
+ for arg in argv[1:]:
82
+ if skip:
83
+ skip = False
84
+ elif arg in VALUE_OPTIONS:
85
+ skip = True
86
+ elif not arg.startswith("-"):
87
+ found.append(arg)
88
+ if len(found) == limit:
89
+ break
90
+ return found
91
+
92
+
93
+ def has_option(argv: Sequence[str], *names: str) -> bool:
94
+ return any(a in names or any(a.startswith(n + "=") for n in names
95
+ if n.startswith("--"))
96
+ for a in argv)
97
+
98
+
99
+ def user_chose_format(argv: Sequence[str]) -> bool:
100
+ return has_option(argv, "--format", "-q", "--quiet", "--pretty")
101
+
102
+
103
+ @dataclass
104
+ class Kind:
105
+ """What dkview should do with a docker command."""
106
+
107
+ name: str # "table", "stats", "logs", "inspect" or "passthrough"
108
+
109
+
110
+ def classify(argv: Sequence[str]) -> Kind:
111
+ w = words(argv)
112
+ if not w or has_option(argv, "-h", "--help"):
113
+ return Kind("passthrough")
114
+
115
+ first = w[0]
116
+ second = w[1] if len(w) > 1 else None
117
+
118
+ if first == "logs" or (first in LOG_OBJECTS and second == "logs"):
119
+ return Kind("logs")
120
+
121
+ if first == "inspect" or (first in INSPECT_OBJECTS and second == "inspect"):
122
+ if user_chose_format(argv):
123
+ return Kind("passthrough")
124
+ return Kind("inspect")
125
+
126
+ if user_chose_format(argv) and not _is_table_format(argv):
127
+ return Kind("passthrough")
128
+
129
+ if first not in TABLE_COMMANDS:
130
+ return Kind("passthrough")
131
+ verbs = TABLE_COMMANDS[first]
132
+ if verbs is not None and second not in verbs:
133
+ return Kind("passthrough")
134
+
135
+ # Layouts that are not a single table.
136
+ if [first, second] == ["system", "df"] and has_option(argv, "-v", "--verbose"):
137
+ return Kind("passthrough")
138
+ if has_option(argv, "--tree"):
139
+ return Kind("passthrough")
140
+
141
+ if first == "stats" or (first in ("container", "compose") and second == "stats"):
142
+ return Kind("stats")
143
+ return Kind("table")
144
+
145
+
146
+ def _is_table_format(argv: Sequence[str]) -> bool:
147
+ """`--format 'table {{.Names}}\t{{.Status}}'` still prints a table."""
148
+ for i, arg in enumerate(argv):
149
+ value = None
150
+ if arg == "--format" and i + 1 < len(argv):
151
+ value = argv[i + 1]
152
+ elif arg.startswith("--format="):
153
+ value = arg.split("=", 1)[1]
154
+ if value is not None:
155
+ return value.strip().startswith("table")
156
+ return False
157
+
158
+
159
+ # Commands that accept --no-trunc. dkview asks for full values and wraps
160
+ # them itself, then shortens IDs back to docker's usual 12 characters.
161
+ NO_TRUNC = {
162
+ ("ps",), ("images",), ("history",), ("search",),
163
+ ("container", "ls"), ("container", "ps"), ("container", "list"),
164
+ ("image", "ls"), ("image", "list"), ("image", "history"),
165
+ ("service", "ps"), ("stack", "ps"), ("node", "ps"),
166
+ ("network", "ls"), ("network", "list"), ("compose", "ps"),
167
+ }
168
+
169
+
170
+ def supports_no_trunc(argv: Sequence[str]) -> bool:
171
+ w = words(argv)
172
+ return tuple(w[:1]) in NO_TRUNC or tuple(w[:2]) in NO_TRUNC
173
+
174
+
175
+ def prepare(argv: Sequence[str], trunc: bool) -> List[str]:
176
+ """Flags that make docker print one complete snapshot we can format."""
177
+ argv = list(argv)
178
+ kind = classify(argv)
179
+
180
+ # `docker stats` refreshes forever by default; take a single sample.
181
+ if kind.name == "stats" and "--no-stream" not in argv:
182
+ argv.append("--no-stream")
183
+
184
+ # Full values so COMMAND or ERROR are not cut short; dkview wraps
185
+ # long values itself.
186
+ if (kind.name == "table" and supports_no_trunc(argv) and not trunc
187
+ and not has_option(argv, "--no-trunc", "--format", "-q", "--quiet")):
188
+ argv.append("--no-trunc")
189
+
190
+ return argv
dkview/engine.py ADDED
@@ -0,0 +1,34 @@
1
+ """Which container engine to run: docker, or podman where docker is missing.
2
+
3
+ DKVIEW_ENGINE=podman (or a full path) picks one explicitly.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+
11
+ ENGINES = ("docker", "podman")
12
+
13
+
14
+ def name() -> str:
15
+ chosen = os.environ.get("DKVIEW_ENGINE", "").strip()
16
+ if chosen:
17
+ return chosen
18
+ for engine in ENGINES:
19
+ if shutil.which(engine):
20
+ return engine
21
+ return "docker" # not installed: let docker's own "not found" error show
22
+
23
+
24
+ def kind(program: str) -> str:
25
+ """'docker', 'podman' or '' for /usr/bin/podman, docker.exe, ..."""
26
+ base = program.replace("\\", "/").rsplit("/", 1)[-1].lower()
27
+ for ext in (".exe", ".cmd", ".bat"):
28
+ if base.endswith(ext):
29
+ base = base[:-len(ext)]
30
+ return base if base in ENGINES else ""
31
+
32
+
33
+ def is_podman(argv) -> bool:
34
+ return bool(argv) and kind(argv[0]) == "podman"
File without changes