Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 19 additions & 33 deletions aai_cli/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand All @@ -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()}"
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down
44 changes: 10 additions & 34 deletions aai_cli/commands/audit.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
26 changes: 15 additions & 11 deletions aai_cli/commands/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions aai_cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand Down
23 changes: 23 additions & 0 deletions aai_cli/timeparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
7 changes: 0 additions & 7 deletions tests/test_account_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}]
Expand Down
4 changes: 0 additions & 4 deletions tests/test_audit_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({}) == ""
Expand Down
25 changes: 25 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions tests/test_timeparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == ""
Loading