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.
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/PKG-INFO +1 -1
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/PKG-INFO +1 -1
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/dncli.py +109 -7
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/pyproject.toml +1 -1
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_helpers.py +80 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/LICENSE +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/README.md +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/setup.cfg +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_cert.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_cli.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_config.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_generate.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_hook.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.3}/tests/test_transfer.py +0 -0
|
@@ -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
|
|
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, ...)
|
|
248
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|