diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 9d32d6c5..b41c163d 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -4,7 +4,6 @@ from datetime import UTC, date, datetime, timedelta import typer -from rich.console import Group from rich.markup import escape from rich.table import Table from rich.text import Text @@ -30,17 +29,6 @@ def _utc_day_start(day: str) -> str: return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC).isoformat() -def _parse_usage_timestamp(value: object) -> datetime | None: - return timeparse.parse_iso_utc(value) - - -def _format_usage_day(value: object) -> str: - parsed = _parse_usage_timestamp(value) - if parsed is None: - return str(value or "") - return parsed.date().isoformat() - - def _format_usage_number(value: object) -> str: number = jsonshape.as_float(value) if number.is_integer(): @@ -53,10 +41,10 @@ def _usage_items(data: Mapping[str, object]) -> list[dict[str, object]]: def _window_label(item: Mapping[str, object]) -> str: - start = _parse_usage_timestamp(item.get("start_timestamp")) - end = _parse_usage_timestamp(item.get("end_timestamp")) + start = timeparse.parse_iso_utc(item.get("start_timestamp")) + end = timeparse.parse_iso_utc(item.get("end_timestamp")) if start is None or end is None: - return _format_usage_day(item.get("start_timestamp")) + return timeparse.format_utc_day(item.get("start_timestamp")) if end.date() == start.date() + timedelta(days=1): return start.date().isoformat() return f"{start.date().isoformat()} to {end.date().isoformat()}" @@ -163,21 +151,21 @@ def render(d: dict[str, object]) -> object: else [item for item in items if jsonshape.as_float(item.get("total"))] ) total = sum(jsonshape.as_float(item.get("total")) for item in items) - range_label = f"{_format_usage_day(start_date)} to {_format_usage_day(end_date)} (UTC)" + range_label = ( + f"{timeparse.format_utc_day(start_date)} to " + f"{timeparse.format_utc_day(end_date)} (UTC)" + ) summary = Text( f"Usage total: {_format_usage_number(total)} for {range_label}", style="aai.heading", ) if not shown: - if items: - return Group( - summary, - Text("No usage in this range.", style="aai.muted"), - ) - return Group( - summary, - Text("No usage windows returned for this range.", style="aai.muted"), + message = ( + "No usage in this range." + if items + else "No usage windows returned for this range." ) + return output.stack(summary, output.muted(message)) shown_with_breakdown = [(item, _line_items_summary(item)) for item in shown] show_breakdown = any(summary for _, summary in shown_with_breakdown) @@ -195,16 +183,14 @@ def render(d: dict[str, object]) -> object: if show_breakdown: row.append(escape(breakdown)) table.add_row(*row) - if hidden_count: - return Group( - summary, - table, - Text( - f"Hidden: {hidden_count} zero-usage window(s). Use --all to show them.", - style="aai.muted", - ), + hidden_note = ( + output.muted( + f"Hidden: {hidden_count} zero-usage window(s). Use --all to show them." ) - return Group(summary, table) + if hidden_count + else None + ) + return output.stack(summary, table, hidden_note) output.emit(data, render, json_mode=json_mode) diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index e8999299..0dd8bf51 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -1,12 +1,9 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import datetime import typer -from rich.console import Group from rich.markup import escape -from rich.text import Text from aai_cli import help_panels, jsonshape, output, timeparse from aai_cli.auth import ams @@ -55,18 +52,6 @@ def _is_login(entry: Mapping[str, object]) -> bool: return raw in _LOGIN_ACTIONS or _normalize_action(raw) in _LOGIN_ACTIONS -def _parse_time(value: object) -> datetime | None: - parsed = timeparse.parse_iso_utc(value) - return None if parsed is None else parsed.replace(tzinfo=None) - - -def _format_time(value: object) -> str: - parsed = _parse_time(value) - if parsed is None: - return str(value or "") - return parsed.strftime("%Y-%m-%d %H:%M:%S") - - def _actor_label(entry: Mapping[str, object]) -> str: actor_type = str(entry.get("actor_type") or "system") actor_id = entry.get("actor_id") @@ -119,39 +104,30 @@ def render(data: list[dict[str, object]]) -> object: hide_logins = not include_logins and action is None shown = [entry for entry in data if not (hide_logins and _is_login(entry))] hidden_logins = len(data) - len(shown) + hidden_note = ( + output.muted( + f"Hidden: {hidden_logins} login event(s). Use --include-logins to show them." + ) + if hidden_logins + else None + ) if not shown: message = ( "No notable audit events in the recent log." if hidden_logins else "No audit events found." ) - if hidden_logins: - return Group( - Text(message, style="aai.muted"), - Text( - f"Hidden: {hidden_logins} login event(s). Use --include-logins to show them.", - style="aai.muted", - ), - ) - return Text(message, style="aai.muted") + return output.stack(output.muted(message), hidden_note) table = output.data_table("when (UTC)", "event", "resource", "actor") for entry in shown: table.add_row( - escape(_format_time(entry.get("log_time"))), + escape(timeparse.format_utc_datetime(entry.get("log_time"))), escape(_format_action(entry.get("action_taken"))), escape(_resource_label(entry)), escape(_actor_label(entry)), ) - if hidden_logins: - return Group( - table, - Text( - f"Hidden: {hidden_logins} login event(s). Use --include-logins to show them.", - style="aai.muted", - ), - ) - return table + return output.stack(table, hidden_note) output.emit(rows, render, json_mode=json_mode) diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index 6adfeae9..cb4543c8 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -27,6 +27,20 @@ def _project_id(project: dict[str, object]) -> int | None: return None +def _default_project_id(account_id: int, jwt: str) -> int: + """The id of the account's first project, for `keys create` without ``--project``. + + No projects, an unparseable first entry, and a missing/invalid id are the same + failure — there's nothing to create a key in — so they share one error. + """ + projects = ams.list_projects(account_id, jwt) + project = jsonshape.as_mapping(projects[0].get("project")) if projects else None + pid = _project_id(project) if project is not None else None + if pid is None: + raise APIError("Your account has no project to create a key in.") + return pid + + @app.command( name="list", epilog=examples_epilog( @@ -97,17 +111,7 @@ def create( def body(state: AppState, json_mode: bool) -> None: account_id, jwt = resolve_session(state) - pid = project_id - if pid is None: - projects = ams.list_projects(account_id, jwt) - if not projects: - raise APIError("Your account has no project to create a key in.") - project = jsonshape.as_mapping(projects[0].get("project")) - if project is None: - raise APIError("Your account has no project to create a key in.") - pid = _project_id(project) - if pid is None: - raise APIError("Your account has no project to create a key in.") + pid = project_id if project_id is not None else _default_project_id(account_id, jwt) created = ams.create_token(account_id, pid, name, jwt) output.emit( created, diff --git a/aai_cli/output.py b/aai_cli/output.py index 19d4a0d7..8380e1dd 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -8,8 +8,10 @@ from typing import TYPE_CHECKING from rich import box +from rich.console import Group, RenderableType from rich.markup import escape from rich.table import Table +from rich.text import Text from aai_cli import choices, theme @@ -127,6 +129,26 @@ def detail_table() -> Table: return table +def muted(text: str) -> Text: + """A dim secondary line — empty-state messages and "Hidden: …" footnotes. + + Returns a Rich ``Text`` (not markup) so it composes into a `stack` alongside a + table, the way listing commands (`usage`, `audit`) tack a quiet note onto a view. + """ + return Text(text, style="aai.muted") + + +def stack(*items: RenderableType | None) -> RenderableType: + """Stack renderables top-to-bottom, dropping any ``None``. + + A lone surviving item is returned bare so a single table or line isn't wrapped in + a redundant `Group`. Lets a command assemble "summary + table + optional footnote" + without re-deriving the same `Group`/None branching each time. + """ + present = [item for item in items if item is not None] + return present[0] if len(present) == 1 else Group(*present) + + def emit[T](data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> None: if json_mode: print(json.dumps(data, default=str)) diff --git a/aai_cli/timeparse.py b/aai_cli/timeparse.py index d7475436..83aa2a3c 100644 --- a/aai_cli/timeparse.py +++ b/aai_cli/timeparse.py @@ -27,3 +27,26 @@ def parse_iso_utc(value: object) -> datetime | None: if parsed.tzinfo is None: return parsed.replace(tzinfo=UTC) return parsed.astimezone(UTC) + + +def format_utc_day(value: object) -> str: + """Render an ISO timestamp as its UTC calendar day (``YYYY-MM-DD``). + + Falls back to the stringified input when it isn't a parseable timestamp, so a + listing never blanks out a value it couldn't interpret. + """ + parsed = parse_iso_utc(value) + if parsed is None: + return str(value or "") + return parsed.date().isoformat() + + +def format_utc_datetime(value: object) -> str: + """Render an ISO timestamp as a UTC ``YYYY-MM-DD HH:MM:SS`` string. + + Falls back to the stringified input when it isn't a parseable timestamp. + """ + parsed = parse_iso_utc(value) + if parsed is None: + return str(value or "") + return parsed.strftime("%Y-%m-%d %H:%M:%S") diff --git a/tests/test_account_command.py b/tests/test_account_command.py index dfa39151..7aa42379 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -103,13 +103,6 @@ def test_usage_renders_table_human(monkeypatch): assert "2026-05-01" in result.output and "12.5" in result.output -def test_usage_helpers_handle_unparseable_values(): - assert account._parse_usage_timestamp(None) is None - assert account._parse_usage_timestamp("") is None - assert account._parse_usage_timestamp("not-a-date") is None - assert account._format_usage_day(None) == "" - - def test_usage_helpers_format_windows_and_line_items(): assert account._usage_items({"usage_items": "bad"}) == [] assert account._usage_items({"usage_items": [{"total": 1}, "bad"]}) == [{"total": 1}] diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py index 58b90d80..ccb462af 100644 --- a/tests/test_audit_command.py +++ b/tests/test_audit_command.py @@ -108,10 +108,6 @@ def test_audit_helpers_format_edge_cases(): assert audit._format_action("custom_event.name") == "Custom event name" assert audit._format_action(None) == "Unknown" assert audit._is_login({"action_taken": "login.succeeded"}) is True - assert audit._parse_time(None) is None - assert audit._parse_time("bad") is None - assert audit._format_time("2026-06-01T12:00:00") == "2026-06-01 12:00:00" - assert audit._format_time("bad") == "bad" assert audit._actor_label({"actor_type": "system"}) == "system" assert audit._actor_label({"actor_type": "system", "actor_id": 1}) == "system #1" assert audit._resource_label({}) == "" diff --git a/tests/test_output.py b/tests/test_output.py index 26d6a74d..0a633257 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -174,6 +174,31 @@ def test_detail_table_is_borderless_label_value_grid(): assert table.padding == (0, 3, 0, 3) +def test_muted_returns_dim_text_line(): + from rich.text import Text + + line = output.muted("Hidden: 1 window.") + assert isinstance(line, Text) + assert line.plain == "Hidden: 1 window." + assert line.style == "aai.muted" + + +def test_stack_returns_lone_item_bare(): + # A single surviving renderable isn't wrapped in a redundant Group. + table = output.data_table("id") + assert output.stack(table, None) is table + + +def test_stack_drops_none_and_groups_the_rest(): + from rich.console import Group + + first = output.muted("a") + second = output.muted("b") + result = output.stack(first, None, second) + assert isinstance(result, Group) + assert list(result.renderables) == [first, second] + + def test_emit_ndjson_writes_one_flushed_line(monkeypatch): import sys diff --git a/tests/test_timeparse.py b/tests/test_timeparse.py index 9f846f0e..589fcf19 100644 --- a/tests/test_timeparse.py +++ b/tests/test_timeparse.py @@ -33,3 +33,19 @@ def test_parse_iso_utc_rejects_non_dates(): # to str-only operations on the int and raise instead of returning None. assert timeparse.parse_iso_utc(20260601) is None assert timeparse.parse_iso_utc(["2026-06-01"]) is None + + +def test_format_utc_day_renders_calendar_day_or_falls_back(): + assert timeparse.format_utc_day("2026-06-01T12:00:00-04:00") == "2026-06-01" + assert timeparse.format_utc_day("2026-06-01") == "2026-06-01" + # Unparseable input falls back to the stringified value, and None to "". + assert timeparse.format_utc_day("bad") == "bad" + assert timeparse.format_utc_day(None) == "" + + +def test_format_utc_datetime_renders_seconds_or_falls_back(): + assert timeparse.format_utc_datetime("2026-06-01T12:00:00") == "2026-06-01 12:00:00" + # Offsets are normalized to UTC before formatting. + assert timeparse.format_utc_datetime("2026-06-01T12:00:00-04:00") == "2026-06-01 16:00:00" + assert timeparse.format_utc_datetime("bad") == "bad" + assert timeparse.format_utc_datetime(None) == ""