devnomads-cli 0.5.1__tar.gz → 0.5.3__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 (20) hide show
  1. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/PKG-INFO +1 -1
  2. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/PKG-INFO +1 -1
  3. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/dncli.py +140 -10
  4. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/pyproject.toml +1 -1
  5. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_helpers.py +114 -3
  6. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/LICENSE +0 -0
  7. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/README.md +0 -0
  8. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/setup.cfg +0 -0
  14. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_cert.py +0 -0
  15. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_cli.py +0 -0
  16. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_config.py +0 -0
  17. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_generate.py +0 -0
  18. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_generated_cli.py +0 -0
  19. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -44,6 +44,7 @@ from devnomads.api import Client as ApiClient
44
44
  from devnomads.api import DevNomadsError
45
45
  from devnomads.api.client import _unwrap as _lib_unwrap
46
46
  from devnomads.dns import Dns, challenge_name
47
+ from rich import box
47
48
  from rich.console import Console
48
49
  from rich.markup import escape
49
50
  from rich.table import Table
@@ -211,16 +212,74 @@ def render(
211
212
  out_console.print(str(data), soft_wrap=True)
212
213
 
213
214
 
214
- def _cell(value: Any) -> str:
215
+ def _summarize_item(item: dict[str, Any]) -> str:
216
+ """Compact one-line ``key=value`` view of an object's scalar fields for a
217
+ table cell. Nested lists/objects collapse to a count so the line stays
218
+ short; None and empty fields are dropped."""
219
+
220
+ parts = []
221
+ for key, val in item.items():
222
+ if val is None or val == "":
223
+ continue
224
+ if isinstance(val, bool):
225
+ parts.append(f"{key}={'yes' if val else 'no'}")
226
+ elif isinstance(val, dict):
227
+ if val:
228
+ parts.append(f"{key}={{{len(val)}}}")
229
+ elif isinstance(val, list):
230
+ if val:
231
+ parts.append(f"{key}=[{len(val)}]")
232
+ else:
233
+ parts.append(f"{key}={val}")
234
+ return " ".join(parts)
235
+
236
+
237
+ def _summarize_list(items: list[dict[str, Any]]) -> str:
238
+ """Compact list-view summary of an object array: the count, plus a grouped
239
+ state breakdown when the items carry a state-like field (e.g. instances).
240
+ Full per-item detail is available via the matching ``show`` command."""
241
+
242
+ n = len(items)
243
+ key = next(
244
+ (
245
+ k
246
+ for k in ("state_last_known", "state", "status", "phase")
247
+ if any(k in it for it in items)
248
+ ),
249
+ None,
250
+ )
251
+ if key is None:
252
+ return str(n)
253
+ counts: dict[str, int] = {}
254
+ for item in items:
255
+ value = str(item.get(key, ""))
256
+ counts[value] = counts.get(value, 0) + 1
257
+ if len(counts) == 1:
258
+ return f"{n} {next(iter(counts))}".strip()
259
+ return ", ".join(f"{count} {value}" for value, count in counts.items())
260
+
261
+
262
+ def _cell(value: Any, *, compact: bool = False) -> str:
263
+ """Render a value for a table cell. ``compact`` (list views) collapses an
264
+ object array to a count summary; otherwise each item gets its own line."""
265
+
215
266
  if value is None:
216
267
  return ""
217
268
  if isinstance(value, bool):
218
269
  return "yes" if value else "no"
219
- if isinstance(value, list) and not any(
220
- isinstance(item, (dict, list)) for item in value
221
- ):
222
- return ", ".join(str(item) for item in value)
223
- if isinstance(value, (dict, list)):
270
+ if isinstance(value, dict):
271
+ return _summarize_item(value)
272
+ if isinstance(value, list):
273
+ if not value:
274
+ return ""
275
+ if all(isinstance(item, dict) for item in value):
276
+ # a list of objects (instances, mailboxes, ips, ...): a count
277
+ # summary in lists, one line per item in detail views.
278
+ if compact:
279
+ return _summarize_list(value)
280
+ return "\n".join(_summarize_item(item) for item in value)
281
+ if not any(isinstance(item, (dict, list)) for item in value):
282
+ return ", ".join(str(item) for item in value)
224
283
  return json.dumps(value)
225
284
  return str(value)
226
285
 
@@ -244,15 +303,86 @@ def _flatten_row(row: dict[str, Any]) -> dict[str, Any]:
244
303
  return flat
245
304
 
246
305
 
306
+ def _is_object_list(value: Any) -> bool:
307
+ return (
308
+ isinstance(value, list)
309
+ and len(value) > 0
310
+ and all(isinstance(item, dict) for item in value)
311
+ )
312
+
313
+
314
+ def _object_table(items: list[dict[str, Any]]) -> Table:
315
+ """A compact sub-table for an array of objects, used inside the detail
316
+ view so nested data (instances, mailboxes, ips, ...) reads as a table
317
+ rather than one long line."""
318
+
319
+ cols = _auto_columns(items)
320
+ if not cols: # all columns dropped (e.g. identical rows): show everything
321
+ cols = list(dict.fromkeys(key for item in items for key in item))
322
+ table = Table(box=box.SIMPLE_HEAD, show_header=True, pad_edge=False)
323
+ for col in cols:
324
+ table.add_column(col)
325
+ for item in items:
326
+ table.add_row(*(_cell(item.get(col), compact=True) for col in cols))
327
+ return table
328
+
329
+
247
330
  def _render_kv(data: dict[str, Any], title: str | None) -> None:
248
331
  table = Table(title=title, show_header=False)
249
332
  table.add_column(style="bold")
250
- table.add_column()
333
+ table.add_column(overflow="fold")
251
334
  for key, value in _flatten_row(data).items():
252
- table.add_row(key, _cell(value))
335
+ if value is None or value == "" or value == [] or value == {}:
336
+ continue # drop empty fields so the detail view stays readable
337
+ cell: Any = _object_table(value) if _is_object_list(value) else _cell(value)
338
+ table.add_row(key, cell)
253
339
  out_console.print(table)
254
340
 
255
341
 
342
+ def _auto_columns(rows: list[dict[str, Any]]) -> list[str]:
343
+ """Choose the columns worth showing in a list view, from the data itself.
344
+
345
+ Three things get dropped, because a list is an overview and the full record
346
+ is always available via the matching ``show`` command (or ``-o json``):
347
+
348
+ * columns empty in every row (no information at all);
349
+ * columns holding a list of objects (instances, mailboxes, ips, ...) -
350
+ these are detail, belonging in the ``show`` view, not the overview;
351
+ * columns whose value never changes across rows - they cost width without
352
+ helping tell the rows apart (the first column is kept as an anchor).
353
+ """
354
+
355
+ cols: list[str] = []
356
+ for row in rows:
357
+ if isinstance(row, dict):
358
+ for key in row:
359
+ if key not in cols:
360
+ cols.append(key)
361
+
362
+ def empty(value: Any) -> bool:
363
+ return value is None or value == "" or value == [] or value == {}
364
+
365
+ def is_object_list(col: str) -> bool:
366
+ return any(
367
+ isinstance(row.get(col), list)
368
+ and row.get(col)
369
+ and all(isinstance(item, dict) for item in row[col])
370
+ for row in rows
371
+ )
372
+
373
+ cols = [
374
+ c
375
+ for c in cols
376
+ if any(not empty(row.get(c)) for row in rows) and not is_object_list(c)
377
+ ]
378
+ if len(rows) > 1 and cols:
379
+ anchor = cols[0]
380
+ rendered = {c: {_cell(row.get(c)) for row in rows} for c in cols}
381
+ if any(len(values) > 1 for values in rendered.values()):
382
+ cols = [c for c in cols if c == anchor or len(rendered[c]) > 1]
383
+ return cols
384
+
385
+
256
386
  def _render_rows(
257
387
  rows: list[dict[str, Any]], columns: list[str] | None, title: str | None
258
388
  ) -> None:
@@ -260,12 +390,12 @@ def _render_rows(
260
390
  err_console.print("[dim]no results[/]")
261
391
  return
262
392
  rows = [_flatten_row(row) if isinstance(row, dict) else row for row in rows]
263
- cols = columns or list(rows[0].keys())
393
+ cols = columns if columns is not None else _auto_columns(rows)
264
394
  table = Table(title=title)
265
395
  for col in cols:
266
396
  table.add_column(col)
267
397
  for row in rows:
268
- table.add_row(*(_cell(row.get(col)) for col in cols))
398
+ table.add_row(*(_cell(row.get(col), compact=True) for col in cols))
269
399
  out_console.print(table)
270
400
 
271
401
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.5.1"
3
+ version = "0.5.3"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -4,9 +4,12 @@ import pytest
4
4
 
5
5
  from dncli import (
6
6
  CliError,
7
+ _auto_columns,
7
8
  _cell,
8
9
  _flatten_row,
10
+ _is_object_list,
9
11
  _mask,
12
+ _object_table,
10
13
  _unwrap,
11
14
  build_rrset,
12
15
  flatten_rrsets,
@@ -167,11 +170,119 @@ def test_cell():
167
170
  assert _cell(True) == "yes"
168
171
  assert _cell(False) == "no"
169
172
  assert _cell(42) == "42"
170
- assert _cell({"a": 1}) == '{"a": 1}'
171
- # scalar lists read as comma-separated values, nested ones stay JSON
173
+ # a bare object renders as a compact key=value summary
174
+ assert _cell({"a": 1}) == "a=1"
175
+ # scalar lists read as comma-separated values
172
176
  assert _cell(["instance_7:80", "instance_8:80"]) == "instance_7:80, instance_8:80"
173
177
  assert _cell([]) == ""
174
- assert _cell([{"a": 1}]) == '[{"a": 1}]'
178
+
179
+
180
+ def test_cell_list_of_objects_is_summarized():
181
+ # one readable line per item, not a raw JSON blob
182
+ ips = [
183
+ {"type": "v4", "address": "185.223.163.238"},
184
+ {"type": "v6", "address": "2a10:8c80:0:138::1"},
185
+ ]
186
+ assert _cell(ips) == (
187
+ "type=v4 address=185.223.163.238\ntype=v6 address=2a10:8c80:0:138::1"
188
+ )
189
+
190
+
191
+ def test_cell_summary_drops_empty_and_counts_nested():
192
+ item = {
193
+ "id": 7,
194
+ "image": "geleijn-it/website",
195
+ "ended_at": None,
196
+ "has_pending_changes": 0,
197
+ "volumes": [{"id": 10}],
198
+ "meta": {},
199
+ }
200
+ # None dropped, 0 kept, non-empty nested list shown as a count, empty dict dropped
201
+ assert (
202
+ _cell([item])
203
+ == "id=7 image=geleijn-it/website has_pending_changes=0 volumes=[1]"
204
+ )
205
+
206
+
207
+ def test_cell_mixed_list_stays_json():
208
+ assert _cell([{"a": 1}, "scalar"]) == '[{"a": 1}, "scalar"]'
209
+
210
+
211
+ def test_cell_compact_summarizes_object_list_with_state():
212
+ # list view: count + grouped state, full detail lives in `show`
213
+ insts = [
214
+ {"id": 7, "state_last_known": "running"},
215
+ {"id": 8, "state_last_known": "running"},
216
+ {"id": 9, "state_last_known": "nostate"},
217
+ ]
218
+ assert _cell(insts, compact=True) == "2 running, 1 nostate"
219
+ same = [{"state_last_known": "running"}] * 3
220
+ assert _cell(same, compact=True) == "3 running"
221
+
222
+
223
+ def test_cell_compact_without_state_is_just_count():
224
+ ips = [{"type": "v4", "address": "1.2.3.4"}, {"type": "v6", "address": "::1"}]
225
+ assert _cell(ips, compact=True) == "2"
226
+
227
+
228
+ def test_cell_detail_keeps_per_item_lines():
229
+ # default (compact=False, used by `show`) still lists every item
230
+ insts = [{"id": 7, "state_last_known": "running"}]
231
+ assert _cell(insts) == "id=7 state_last_known=running"
232
+
233
+
234
+ def test_auto_columns_drops_empty_constant_and_object_lists():
235
+ rows = [
236
+ {"id": 1, "port": 80, "registry": "same", "blank": "", "instances": [{"i": 7}]},
237
+ {"id": 2, "port": 81, "registry": "same", "blank": "", "instances": [{"i": 8}]},
238
+ ]
239
+ # registry is constant, blank is empty, instances is an object list -> all
240
+ # dropped; id (anchor) and port (varies) remain.
241
+ assert _auto_columns(rows) == ["id", "port"]
242
+
243
+
244
+ def test_auto_columns_keeps_anchor_even_if_constant():
245
+ rows = [{"id": 1, "x": 9, "y": "a"}, {"id": 1, "x": 9, "y": "b"}]
246
+ # x is constant -> dropped; id is constant but the anchor -> kept
247
+ assert _auto_columns(rows) == ["id", "y"]
248
+
249
+
250
+ def test_auto_columns_single_row_keeps_nonempty_scalars():
251
+ rows = [{"id": 1, "name": "x", "blank": None, "items": [{"a": 1}]}]
252
+ # one row: no constant-drop, but empty and object-list columns still go
253
+ assert _auto_columns(rows) == ["id", "name"]
254
+
255
+
256
+ def test_is_object_list():
257
+ assert _is_object_list([{"a": 1}]) is True
258
+ assert _is_object_list([1, 2]) is False
259
+ assert _is_object_list([]) is False
260
+ assert _is_object_list("x") is False
261
+
262
+
263
+ def test_object_table_uses_auto_columns():
264
+ items = [
265
+ {"emailaddress": "a@x", "quota": 1, "domain": "x"},
266
+ {"emailaddress": "b@x", "quota": 2, "domain": "x"},
267
+ ]
268
+ table = _object_table(items)
269
+ # constant `domain` dropped; the differing fields are kept as headers
270
+ assert [c.header for c in table.columns] == ["emailaddress", "quota"]
271
+
272
+
273
+ def test_render_kv_drops_empty_and_nests_objects(capsys):
274
+ from dncli import AppState, OutputFormat, render
275
+
276
+ data = {
277
+ "id": 1,
278
+ "ended_at": None,
279
+ "note": "",
280
+ "ips": [{"type": "v4", "address": "10.0.0.1"}],
281
+ }
282
+ render(AppState(output=OutputFormat.table), data, title="srv")
283
+ out = capsys.readouterr().out
284
+ assert "ended_at" not in out and "note" not in out # empty fields dropped
285
+ assert "ips" in out and "10.0.0.1" in out # object array rendered
175
286
 
176
287
 
177
288
  def test_flatten_row_merges_nested_objects():
File without changes
File without changes
File without changes