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.
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/PKG-INFO +1 -1
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/PKG-INFO +1 -1
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/dncli.py +140 -10
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/pyproject.toml +1 -1
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_helpers.py +114 -3
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/LICENSE +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/README.md +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/setup.cfg +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_cert.py +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_cli.py +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_config.py +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_generate.py +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.5.1 → devnomads_cli-0.5.3}/tests/test_hook.py +0 -0
- {devnomads_cli-0.5.1 → 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
|
|
@@ -211,16 +212,74 @@ def render(
|
|
|
211
212
|
out_console.print(str(data), soft_wrap=True)
|
|
212
213
|
|
|
213
214
|
|
|
214
|
-
def
|
|
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,
|
|
220
|
-
|
|
221
|
-
):
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
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
|