devnomads-cli 0.5.2__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.2 → devnomads_cli-0.5.3}/PKG-INFO +1 -1
  2. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/PKG-INFO +1 -1
  3. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/dncli.py +109 -7
  4. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/pyproject.toml +1 -1
  5. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_helpers.py +80 -0
  6. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/LICENSE +0 -0
  7. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/README.md +0 -0
  8. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/setup.cfg +0 -0
  14. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_cert.py +0 -0
  15. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_cli.py +0 -0
  16. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_config.py +0 -0
  17. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_generate.py +0 -0
  18. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_generated_cli.py +0 -0
  19. {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.5.2 → 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.2
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.2
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
@@ -233,7 +234,35 @@ def _summarize_item(item: dict[str, Any]) -> str:
233
234
  return " ".join(parts)
234
235
 
235
236
 
236
- def _cell(value: Any) -> str:
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
+
237
266
  if value is None:
238
267
  return ""
239
268
  if isinstance(value, bool):
@@ -244,8 +273,10 @@ def _cell(value: Any) -> str:
244
273
  if not value:
245
274
  return ""
246
275
  if all(isinstance(item, dict) for item in value):
247
- # a list of objects (instances, mailboxes, ips, ...) -> one
248
- # readable line per item instead of an unreadable JSON blob.
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)
249
280
  return "\n".join(_summarize_item(item) for item in value)
250
281
  if not any(isinstance(item, (dict, list)) for item in value):
251
282
  return ", ".join(str(item) for item in value)
@@ -272,15 +303,86 @@ def _flatten_row(row: dict[str, Any]) -> dict[str, Any]:
272
303
  return flat
273
304
 
274
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
+
275
330
  def _render_kv(data: dict[str, Any], title: str | None) -> None:
276
331
  table = Table(title=title, show_header=False)
277
332
  table.add_column(style="bold")
278
- table.add_column()
333
+ table.add_column(overflow="fold")
279
334
  for key, value in _flatten_row(data).items():
280
- 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)
281
339
  out_console.print(table)
282
340
 
283
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
+
284
386
  def _render_rows(
285
387
  rows: list[dict[str, Any]], columns: list[str] | None, title: str | None
286
388
  ) -> None:
@@ -288,12 +390,12 @@ def _render_rows(
288
390
  err_console.print("[dim]no results[/]")
289
391
  return
290
392
  rows = [_flatten_row(row) if isinstance(row, dict) else row for row in rows]
291
- cols = columns or list(rows[0].keys())
393
+ cols = columns if columns is not None else _auto_columns(rows)
292
394
  table = Table(title=title)
293
395
  for col in cols:
294
396
  table.add_column(col)
295
397
  for row in rows:
296
- 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))
297
399
  out_console.print(table)
298
400
 
299
401
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.5.2"
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,
@@ -205,6 +208,83 @@ def test_cell_mixed_list_stays_json():
205
208
  assert _cell([{"a": 1}, "scalar"]) == '[{"a": 1}, "scalar"]'
206
209
 
207
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
286
+
287
+
208
288
  def test_flatten_row_merges_nested_objects():
209
289
  row = {
210
290
  "service_id": 639,
File without changes
File without changes
File without changes