devnomads-cli 0.5.2__tar.gz → 0.5.4__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.4}/PKG-INFO +1 -1
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/devnomads_cli.egg-info/PKG-INFO +1 -1
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/dncli.py +103 -7
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/pyproject.toml +1 -1
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/tests/test_helpers.py +80 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/LICENSE +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/README.md +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/setup.cfg +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/tests/test_cert.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/tests/test_cli.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/tests/test_config.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/tests/test_generate.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/tests/test_hook.py +0 -0
- {devnomads_cli-0.5.2 → devnomads_cli-0.5.4}/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,80 @@ 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
|
+
Two 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
|
+
|
|
352
|
+
Every other field is kept, even if its value is the same on every row.
|
|
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
|
+
return [
|
|
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
|
+
|
|
379
|
+
|
|
284
380
|
def _render_rows(
|
|
285
381
|
rows: list[dict[str, Any]], columns: list[str] | None, title: str | None
|
|
286
382
|
) -> None:
|
|
@@ -288,12 +384,12 @@ def _render_rows(
|
|
|
288
384
|
err_console.print("[dim]no results[/]")
|
|
289
385
|
return
|
|
290
386
|
rows = [_flatten_row(row) if isinstance(row, dict) else row for row in rows]
|
|
291
|
-
cols = columns
|
|
387
|
+
cols = columns if columns is not None else _auto_columns(rows)
|
|
292
388
|
table = Table(title=title)
|
|
293
389
|
for col in cols:
|
|
294
390
|
table.add_column(col)
|
|
295
391
|
for row in rows:
|
|
296
|
-
table.add_row(*(_cell(row.get(col)) for col in cols))
|
|
392
|
+
table.add_row(*(_cell(row.get(col), compact=True) for col in cols))
|
|
297
393
|
out_console.print(table)
|
|
298
394
|
|
|
299
395
|
|
|
@@ -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_and_object_lists_only():
|
|
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
|
+
# blank is empty and instances is an object list -> dropped; every other
|
|
240
|
+
# scalar is kept, including the constant `registry`.
|
|
241
|
+
assert _auto_columns(rows) == ["id", "port", "registry"]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_auto_columns_keeps_constant_scalar_columns():
|
|
245
|
+
rows = [{"id": 1, "x": 9, "y": "a"}, {"id": 1, "x": 9, "y": "b"}]
|
|
246
|
+
# constant columns are no longer dropped - normal fields always show
|
|
247
|
+
assert _auto_columns(rows) == ["id", "x", "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
|
+
# empty and object-list columns go, scalars stay
|
|
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
|
+
# all scalar sub-fields are kept (empties/object-lists would be dropped)
|
|
270
|
+
assert [c.header for c in table.columns] == ["emailaddress", "quota", "domain"]
|
|
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
|