docker-stack 2.2.0__tar.gz → 2.2.1__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.
Files changed (33) hide show
  1. {docker_stack-2.2.0 → docker_stack-2.2.1}/PKG-INFO +1 -1
  2. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/cli.py +153 -29
  3. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/PKG-INFO +1 -1
  4. {docker_stack-2.2.0 → docker_stack-2.2.1}/setup.py +1 -1
  5. {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_docker_stack.py +43 -0
  6. {docker_stack-2.2.0 → docker_stack-2.2.1}/README.md +0 -0
  7. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/__init__.py +0 -0
  8. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/command_runner.py +0 -0
  9. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/compose.py +0 -0
  10. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/docker_objects.py +0 -0
  11. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/envsubst.py +0 -0
  12. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/envsubst_merge.py +0 -0
  13. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/helpers.py +0 -0
  14. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/login.py +0 -0
  15. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/manager_api.py +0 -0
  16. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/markers.py +0 -0
  17. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/merge_conf.py +0 -0
  18. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/registry.py +0 -0
  19. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/shell_auth.py +0 -0
  20. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack/url_parser.py +0 -0
  21. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/SOURCES.txt +0 -0
  22. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/dependency_links.txt +0 -0
  23. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/entry_points.txt +0 -0
  24. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/requires.txt +0 -0
  25. {docker_stack-2.2.0 → docker_stack-2.2.1}/docker_stack.egg-info/top_level.txt +0 -0
  26. {docker_stack-2.2.0 → docker_stack-2.2.1}/pyproject.toml +0 -0
  27. {docker_stack-2.2.0 → docker_stack-2.2.1}/setup.cfg +0 -0
  28. {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_docker_objects.py +0 -0
  29. {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_load_env.py +0 -0
  30. {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_login.py +0 -0
  31. {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_manager_api.py +0 -0
  32. {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_node_ls.py +0 -0
  33. {docker_stack-2.2.0 → docker_stack-2.2.1}/tests/test_shell_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesudip/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -137,33 +137,155 @@ def _select_node(value: str) -> str:
137
137
  return hostname
138
138
 
139
139
 
140
- def _human_age(timestamp: object) -> str:
140
+ COMMAND_DISPLAY_WIDTH = 20
141
+ SIZE_UNITS = ("B", "kB", "MB", "GB", "TB", "PB")
142
+
143
+
144
+ def _human_duration(timestamp: object) -> str:
145
+ """Mirror docker's units.HumanDuration for the CREATED column."""
141
146
  try:
142
- seconds = max(0, int(time.time()) - int(timestamp))
147
+ seconds = time.time() - float(timestamp)
143
148
  except (TypeError, ValueError):
144
- return "-"
149
+ return ""
150
+ if seconds < 1:
151
+ return "Less than a second ago"
152
+ if int(seconds) == 1:
153
+ return "1 second ago"
145
154
  if seconds < 60:
146
- return f"{seconds} seconds ago"
147
- if seconds < 3600:
148
- return f"{seconds // 60} minutes ago"
149
- if seconds < 86400:
150
- return f"{seconds // 3600} hours ago"
151
- return f"{seconds // 86400} days ago"
155
+ return f"{int(seconds)} seconds ago"
156
+ minutes = int(seconds // 60)
157
+ if minutes == 1:
158
+ return "About a minute ago"
159
+ if minutes < 60:
160
+ return f"{minutes} minutes ago"
161
+ hours = int(seconds / 3600 + 0.5)
162
+ if hours == 1:
163
+ return "About an hour ago"
164
+ if hours < 48:
165
+ return f"{hours} hours ago"
166
+ if hours < 24 * 7 * 2:
167
+ return f"{hours // 24} days ago"
168
+ if hours < 24 * 30 * 2:
169
+ return f"{hours // 24 // 7} weeks ago"
170
+ if hours < 24 * 365 * 2:
171
+ return f"{hours // 24 // 30} months ago"
172
+ return f"{hours // 24 // 365} years ago"
173
+
174
+
175
+ def _human_size(value: object) -> str:
176
+ try:
177
+ size = float(value)
178
+ except (TypeError, ValueError):
179
+ return ""
180
+ index = 0
181
+ while size >= 1000.0 and index < len(SIZE_UNITS) - 1:
182
+ size /= 1000.0
183
+ index += 1
184
+ return f"{size:.3g}{SIZE_UNITS[index]}"
185
+
186
+
187
+ def _quote_command(value: str) -> str:
188
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
189
+ return f'"{escaped}"'
190
+
191
+
192
+ def _ellipsis(value: str, width: int) -> str:
193
+ if width <= 0:
194
+ return ""
195
+ if len(value) <= width:
196
+ return value
197
+ return value[: width - 1] + "…"
198
+
199
+
200
+ def _form_port_group(key: str, first: int, last: int) -> str:
201
+ parts = key.split("/")
202
+ address = ""
203
+ protocol = parts[0]
204
+ if len(parts) > 1:
205
+ address = parts[0]
206
+ protocol = parts[1]
207
+ group = str(first) if first == last else f"{first}-{last}"
208
+ if address:
209
+ group = f"{address}:{group}->{group}"
210
+ return f"{group}/{protocol}"
152
211
 
153
212
 
154
213
  def _format_ports(ports: object) -> str:
214
+ """Mirror docker's api/types.DisplayablePorts for the PORTS column."""
155
215
  if not isinstance(ports, list):
156
216
  return ""
157
- values = []
217
+ entries = []
158
218
  for port in ports:
159
219
  if not isinstance(port, dict):
160
220
  continue
161
- private = port.get("PrivatePort")
162
- protocol = port.get("Type") or "tcp"
163
- public = port.get("PublicPort")
164
- address = port.get("IP") or "0.0.0.0"
165
- values.append(f"{address}:{public}->{private}/{protocol}" if public is not None else f"{private}/{protocol}")
166
- return ", ".join(values)
221
+ try:
222
+ private = int(port.get("PrivatePort"))
223
+ except (TypeError, ValueError):
224
+ continue
225
+ try:
226
+ public = int(port.get("PublicPort"))
227
+ except (TypeError, ValueError):
228
+ public = None
229
+ entries.append((private, str(port.get("Type") or "tcp"), str(port.get("IP") or ""), public))
230
+ entries.sort(key=lambda entry: entry[0])
231
+ groups: Dict[str, List[int]] = {}
232
+ group_keys: List[str] = []
233
+ result: List[str] = []
234
+ host_mappings: List[str] = []
235
+ for private, protocol, address, public in entries:
236
+ key = protocol
237
+ if address and public:
238
+ if public != private:
239
+ host_mappings.append(f"{address}:{public}->{private}/{protocol}")
240
+ continue
241
+ key = f"{address}/{protocol}"
242
+ group = groups.get(key)
243
+ if group is None:
244
+ groups[key] = [private, private]
245
+ group_keys.append(key)
246
+ continue
247
+ if private == group[1] + 1:
248
+ group[1] = private
249
+ continue
250
+ result.append(_form_port_group(key, group[0], group[1]))
251
+ groups[key] = [private, private]
252
+ for key in group_keys:
253
+ first, last = groups[key]
254
+ result.append(_form_port_group(key, first, last))
255
+ result.extend(host_mappings)
256
+ return ", ".join(result)
257
+
258
+
259
+ def _format_names(names: object, *, no_trunc: bool) -> str:
260
+ values = [str(name).lstrip("/") for name in names] if isinstance(names, list) else []
261
+ if not no_trunc:
262
+ for name in values:
263
+ if "/" not in name:
264
+ values = [name]
265
+ break
266
+ return ",".join(values)
267
+
268
+
269
+ def _format_image(image: object, image_id: object, *, no_trunc: bool) -> str:
270
+ value = str(image or "")
271
+ if not value:
272
+ return "<no image>"
273
+ if no_trunc:
274
+ return value
275
+ identifier = str(image_id or "")
276
+ if identifier and _short_id(identifier) == _short_id(value):
277
+ return _short_id(value)
278
+ return value
279
+
280
+
281
+ def _short_id(value: str) -> str:
282
+ return value[len("sha256:") :][:12] if value.startswith("sha256:") else value[:12]
283
+
284
+
285
+ def _print_table(columns: List[str], rows: List[Dict[str, str]]) -> None:
286
+ widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
287
+ for values in [{column: column for column in columns}, *rows]:
288
+ print(" ".join(values[column].ljust(widths[column]) for column in columns).rstrip())
167
289
 
168
290
 
169
291
  def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = False, show_size: bool = False) -> int:
@@ -172,29 +294,31 @@ def _print_cluster_containers(payload: Dict[str, object], *, no_trunc: bool = Fa
172
294
  if not isinstance(entry, dict) or not isinstance(entry.get("docker"), dict):
173
295
  continue
174
296
  item = entry["docker"]
175
- container_id = str(item.get("Id") or "-")
176
- names = item.get("Names") if isinstance(item.get("Names"), list) else []
297
+ container_id = str(item.get("Id") or "")
298
+ command = str(item.get("Command") or "")
177
299
  row = {
178
300
  "CONTAINER ID": container_id if no_trunc else container_id[:12],
179
- "IMAGE": str(item.get("Image") or "-"),
180
- "COMMAND": str(item.get("Command") or "-"),
181
- "CREATED": _human_age(item.get("Created")),
182
- "STATUS": str(item.get("Status") or item.get("State") or "-"),
301
+ "IMAGE": _format_image(item.get("Image"), item.get("ImageID"), no_trunc=no_trunc),
302
+ "COMMAND": _quote_command(command if no_trunc else _ellipsis(command, COMMAND_DISPLAY_WIDTH)),
303
+ "CREATED": _human_duration(item.get("Created")),
304
+ "STATUS": str(item.get("Status") or item.get("State") or ""),
183
305
  "PORTS": _format_ports(item.get("Ports")),
184
- "NAMES": ",".join(str(name).lstrip("/") for name in names) or "-",
185
- "NODE": str(entry.get("node_name") or entry.get("node_id") or "-"),
306
+ "NAMES": _format_names(item.get("Names"), no_trunc=no_trunc),
307
+ "NODE": str(entry.get("node_name") or entry.get("node_id") or ""),
186
308
  }
187
309
  if show_size:
188
- row["SIZE"] = str(item.get("SizeRw") if item.get("SizeRw") is not None else "-")
310
+ size = _human_size(item.get("SizeRw") or 0)
311
+ try:
312
+ virtual = float(item.get("SizeRootFs") or 0)
313
+ except (TypeError, ValueError):
314
+ virtual = 0
315
+ row["SIZE"] = f"{size} (virtual {_human_size(virtual)})" if virtual > 0 else size
189
316
  rows.append(row)
190
317
  columns = ["CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES"]
191
318
  if show_size:
192
319
  columns.append("SIZE")
193
320
  columns.append("NODE")
194
- widths = {column: max(len(column), max((len(row[column]) for row in rows), default=0)) for column in columns}
195
- print(" ".join(column.ljust(widths[column]) for column in columns))
196
- for row in rows:
197
- print(" ".join(row[column].ljust(widths[column]) for column in columns))
321
+ _print_table(columns, rows)
198
322
  errors = payload.get("node_errors") if isinstance(payload.get("node_errors"), list) else []
199
323
  for error in errors:
200
324
  if isinstance(error, dict):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesudip/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="2.2.0",
5
+ version="2.2.1",
6
6
  description="CLI for deploying and managing Docker stacks.",
7
7
  long_description=open("README.md").read(), # You can include a README file to describe your package
8
8
  long_description_content_type="text/markdown",
@@ -2,6 +2,7 @@ import base64
2
2
  import json
3
3
  import os
4
4
  import subprocess
5
+ import time
5
6
  from pathlib import Path
6
7
  from types import SimpleNamespace
7
8
 
@@ -88,6 +89,48 @@ def test_managed_docker_ps_renders_node_column(monkeypatch, capsys):
88
89
  assert "abcdef123456" in output
89
90
 
90
91
 
92
+ def test_managed_docker_ps_matches_docker_columns(monkeypatch, capsys):
93
+ client = SimpleNamespace(
94
+ supports=lambda feature: feature == "cluster_container_cli_v1",
95
+ list_containers=lambda **_kwargs: {
96
+ "containers": [
97
+ {
98
+ "node_id": "node-1",
99
+ "node_name": "worker-02",
100
+ "docker": {
101
+ "Id": "467fce208a05dddd",
102
+ "Image": "registry.example.com/app/backend:v1",
103
+ "Command": "sh -c 'java $JAVA_OPTS -jar /app/app.jar'",
104
+ "Created": int(time.time()) - 17 * 60,
105
+ "Status": "Up 17 minutes",
106
+ "Ports": [
107
+ {"PrivatePort": 8080, "Type": "tcp"},
108
+ {"PrivatePort": 8081, "Type": "tcp"},
109
+ {"IP": "0.0.0.0", "PrivatePort": 443, "PublicPort": 8443, "Type": "tcp"},
110
+ ],
111
+ "Names": ["/app_backend.1.qtfngt97xvabnhj0ehk4us6kk"],
112
+ },
113
+ }
114
+ ],
115
+ "node_errors": [],
116
+ },
117
+ )
118
+ monkeypatch.setattr("docker_stack.cli.discover_manager_client", lambda: client)
119
+
120
+ assert _managed_docker(["ps"]) == 0
121
+ header, row = capsys.readouterr().out.splitlines()
122
+
123
+ assert header.split() == [
124
+ "CONTAINER", "ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES", "NODE",
125
+ ]
126
+ assert row.startswith("467fce208a05 ")
127
+ assert '"sh -c \'java $JAVA_O\u2026"' in row
128
+ assert "17 minutes ago" in row
129
+ assert "8080-8081/tcp, 0.0.0.0:8443->443/tcp" in row
130
+ assert row.endswith("worker-02")
131
+ assert row == row.rstrip()
132
+
133
+
91
134
  def test_managed_docker_ps_forwards_global_latest_and_limit(monkeypatch):
92
135
  calls = []
93
136
  client = SimpleNamespace(
File without changes
File without changes