From a943894eee2f008282d83906c0aac937fe08f543 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 22:56:15 +0000 Subject: [PATCH] Add -o field projection to list/account read commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read commands that previously only had --json (transcripts list, sessions list/get, balance, usage, limits, keys list, audit) now accept -o FIELDS to project columns straight out of the JSON, so a "grab one column" pipeline no longer needs an external jq. - core/project.py: dependency-free projection — comma-separated field specs, dotted paths into nested objects, pipe-friendly scalar rendering (None -> empty column, JSON booleans lowercased, nested values re-serialized as JSON), and a shape-aware dispatch (list -> one line per row, object -> one line). - output.emit gains a `fields` arg that takes precedence over --json; the shared options.fields_option() factory keeps the -o contract uniform across commands. - Shipped epilog examples and REFERENCE.md drop the `--json | jq` column grabs in favor of `-o`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GmpgwWwuRStqTN3ZuPE8BE --- REFERENCE.md | 18 ++++ aai_cli/commands/account.py | 10 ++- aai_cli/commands/audit.py | 4 +- aai_cli/commands/keys.py | 5 +- aai_cli/commands/sessions.py | 15 ++-- aai_cli/commands/transcripts.py | 7 +- aai_cli/core/project.py | 81 ++++++++++++++++++ aai_cli/options.py | 18 ++++ aai_cli/ui/output.py | 33 +++++++- .../test_snapshots_help_account.ambr | 45 ++++++---- .../test_snapshots_help_history.ambr | 37 ++++---- tests/test_account_command.py | 34 ++++++++ tests/test_audit_command.py | 15 ++++ tests/test_keys.py | 18 ++++ tests/test_project.py | 84 +++++++++++++++++++ tests/test_sessions_command.py | 25 ++++++ tests/test_transcripts.py | 18 ++++ 17 files changed, 418 insertions(+), 49 deletions(-) create mode 100644 aai_cli/core/project.py create mode 100644 tests/test_project.py diff --git a/REFERENCE.md b/REFERENCE.md index 6a89200b..93000877 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -69,6 +69,24 @@ your local browser. the output shape. One-shot commands emit a single JSON object on stdout; errors and warnings are single JSON objects on stderr. +### Field projection (`-o`) + +The list/account read commands — `assembly transcripts list`, `assembly +sessions list`/`get`, `assembly balance`, `assembly usage`, `assembly limits`, +`assembly keys list`, and `assembly audit` — also accept `-o FIELDS` to project +columns straight out of the JSON, so a "grab one column" pipeline needs no +external `jq`. Pass a single field (`-o id`) or a comma-separated list (`-o +id,status`); dotted paths (`-o transform.model`) reach nested objects. A list +result prints one tab-separated line per row, a single record one line; a +missing field (or `null`) is an empty column, and a nested object/list is +re-serialized as compact JSON. `-o` takes precedence over `--json`. + +```sh +assembly transcripts list -o id | head -1 # newest transcript id +assembly keys list -o id,name # idname per key +assembly balance -o balance_in_cents # the raw integer +``` + Streaming commands emit newline-delimited JSON (NDJSON), one event per line, each carrying a `"type"` field to dispatch on: diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 170d8235..6db08d17 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -141,13 +141,14 @@ class _Usage(BaseModel): epilog=examples_epilog( [ ("Show your remaining balance", "assembly balance"), - ("Get the raw cents for scripting", "assembly balance --json | jq '.balance_in_cents'"), + ("Get the raw cents for scripting", "assembly balance -o balance_in_cents"), ] ), ) def balance( ctx: typer.Context, json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """Show your remaining account balance""" @@ -159,6 +160,7 @@ def body(state: AppState, json_mode: bool) -> None: data, lambda _d: f"Balance: [aai.success]${cents / 100:,.2f}[/aai.success]", json_mode=json_mode, + fields=fields, ) run_command(ctx, body, json=json_out) @@ -194,6 +196,7 @@ def usage( help="Include zero-usage windows (matches --include-logins on `assembly audit`)", ), json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """Show usage over a date range (default: last 30 days)""" @@ -254,7 +257,7 @@ def render(d: dict[str, object]) -> object: hidden_note = output.hidden_note(hidden_count, "zero-usage window", "--include-zero") return output.stack(summary, table, hidden_note) - output.emit(data, render, json_mode=json_mode) + output.emit(data, render, json_mode=json_mode, fields=fields) run_command(ctx, body, json=json_out) @@ -271,6 +274,7 @@ def render(d: dict[str, object]) -> object: def limits( ctx: typer.Context, json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """Show your account's rate limits per service""" @@ -292,6 +296,6 @@ def render(d: dict[str, object]) -> object: ) return table - output.emit(data, render, json_mode=json_mode) + output.emit(data, render, json_mode=json_mode, fields=fields) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index 37417237..60bffd68 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -90,6 +90,7 @@ def _audit_rows(payload: Mapping[str, object]) -> list[dict[str, object]]: ("Include login events", "assembly audit --include-logins"), ("Filter by action", "assembly audit --action token.create"), ("Filter by resource, as JSON", "assembly audit --resource token --json"), + ("Pull action and actor as columns", "assembly audit -o action_taken,actor_id"), ] ), ) @@ -102,6 +103,7 @@ def audit( False, "--include-logins", help="Show successful login events" ), json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """List recent audit-log entries for your account""" @@ -133,6 +135,6 @@ def render(data: list[dict[str, object]]) -> object: ) return output.stack(table, hidden_note) - output.emit(rows, render, json_mode=json_mode) + output.emit(rows, render, json_mode=json_mode, fields=fields) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index cd4d953c..5f3941b6 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -58,13 +58,14 @@ def _default_project_id(account_id: int, jwt: str) -> int: [ ("List your API keys (masked)", "assembly keys list"), ("As JSON for scripting", "assembly keys list --json"), - ("Get key ids to use with rename", "assembly keys list --json | jq '.[].id'"), + ("Get key ids to use with rename", "assembly keys list -o id"), ] ), ) def list_( ctx: typer.Context, json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """List API keys across your projects (shown masked)""" @@ -100,7 +101,7 @@ def render(data: list[dict[str, object]]) -> object: ) return table - output.emit(rows, render, json_mode=json_mode) + output.emit(rows, render, json_mode=json_mode, fields=fields) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index f5fa1c5f..5d089d6c 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -59,11 +59,11 @@ class SessionStatus(enum.StrEnum): ("Find failed sessions", "assembly sessions list --status error"), ( "Inspect the most recent session", - "assembly sessions get $(assembly sessions list --json | jq -r '.[0].session_id')", + "assembly sessions get $(assembly sessions list -o session_id | head -1)", ), ( - "Total audio across recent sessions (seconds)", - "assembly sessions list --json | jq '[.[].audio_duration_sec] | add'", + "Pull session ids and durations as columns", + "assembly sessions list -o session_id,audio_duration_sec", ), ] ), @@ -75,6 +75,7 @@ def list_( None, "--status", help="Only show sessions with this status" ), json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """List recent streaming sessions""" @@ -108,7 +109,7 @@ def render(data: list[dict[str, object]]) -> object: ) return table - output.emit(rows, render, json_mode=json_mode) + output.emit(rows, render, json_mode=json_mode, fields=fields) run_command(ctx, body, json=json_out) @@ -118,9 +119,10 @@ def render(data: list[dict[str, object]]) -> object: [ ("Show one session's details", "assembly sessions get sess_5551234"), ("Raw JSON for one session", "assembly sessions get sess_5551234 --json"), + ("Grab one field", "assembly sessions get sess_5551234 -o audio_duration_sec"), ( "Drill into the latest session", - "assembly sessions get $(assembly sessions list --json | jq -r '.[0].session_id')", + "assembly sessions get $(assembly sessions list -o session_id | head -1)", ), ] ) @@ -129,6 +131,7 @@ def get( ctx: typer.Context, session_id: str = typer.Argument(..., help="Streaming session id"), json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """Show details for one streaming session""" @@ -144,6 +147,6 @@ def render(d: dict[str, object]) -> Table: table.add_row(label, escape("" if value is None else str(value))) return table - output.emit(data, render, json_mode=json_mode) + output.emit(data, render, json_mode=json_mode, fields=fields) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index 50cde060..ece29e66 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -29,11 +29,11 @@ [ ("List your recent transcripts", "assembly transcripts list"), ("Show more at once", "assembly transcripts list --limit 50"), - ("Grab the latest transcript id", "assembly transcripts list --json | jq -r '.[0].id'"), + ("Grab the latest transcript id", "assembly transcripts list -o id | head -1"), ( "Summarize your latest transcript", 'assembly llm "summarize" --transcript-id ' - "$(assembly transcripts list --json | jq -r '.[0].id')", + "$(assembly transcripts list -o id | head -1)", ), ] ), @@ -42,6 +42,7 @@ def list_( ctx: typer.Context, limit: int = typer.Option(10, "--limit", help="How many transcripts to show", min=1), json_out: bool = options.json_option(), + fields: str | None = options.fields_option(), ) -> None: """List recent transcripts""" @@ -61,7 +62,7 @@ def render(data: list[dict[str, object]]) -> object: ) return table - output.emit(rows, render, json_mode=json_mode) + output.emit(rows, render, json_mode=json_mode, fields=fields) run_command(ctx, body, json=json_out) diff --git a/aai_cli/core/project.py b/aai_cli/core/project.py new file mode 100644 index 00000000..fc076b3d --- /dev/null +++ b/aai_cli/core/project.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from aai_cli.core import jsonshape +from aai_cli.core.errors import UsageError + +# Columns within one projected record are tab-separated, so a multi-field row +# (`-o id,status`) stays parseable with `cut -f` and pastes into a spreadsheet. +COLUMN_SEP = "\t" + + +def parse_fields(spec: str) -> list[str]: + """Parse a comma-separated ``-o`` field spec into a list of field paths. + + Whitespace around each name is trimmed and empty segments dropped, so + ``-o "id, status"`` and ``-o id,status`` are equivalent. An all-empty spec + (e.g. ``-o ,``) is a usage error rather than a silently empty projection. + """ + fields = [part.strip() for part in spec.split(",")] + fields = [field for field in fields if field] + if not fields: + raise UsageError( + "No fields given to -o.", + suggestion="Pass one or more field names, e.g. -o id or -o id,status.", + ) + return fields + + +def _lookup(record: dict[str, object], path: str) -> object: + """Resolve a dotted ``path`` against a JSON object, or ``None`` if a step is missing. + + Dotted access (``a.b``) descends into nested objects, so a top-level field like + ``session_id`` and a nested one like ``transform.model`` both work; a path that + runs off a non-object (or names a missing key) yields ``None``, rendered as an + empty column rather than raising. + """ + value: object = record + for key in path.split("."): + mapping = jsonshape.as_mapping(value) + if mapping is None or key not in mapping: + return None + value = mapping[key] + return value + + +def render_value(value: object) -> str: + """Render one projected value as a pipe-friendly scalar. + + ``None`` becomes an empty column, JSON booleans render lowercased + (``true``/``false``) so they read like the ``--json`` payload, and a nested + object/list is re-serialized as compact JSON rather than Python ``repr``. + """ + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + return jsonshape.dumps(value) + + +def project_record(record: dict[str, object], fields: list[str]) -> str: + """One tab-separated line of the selected ``fields`` from a single JSON object.""" + return COLUMN_SEP.join(render_value(_lookup(record, field)) for field in fields) + + +def project_rows(rows: list[dict[str, object]], fields: list[str]) -> list[str]: + """One projected line per object in ``rows`` (a list result).""" + return [project_record(row, fields) for row in rows] + + +def project_any(data: object, fields: list[str]) -> list[str]: + """Project ``fields`` from a JSON value, dispatching on its shape. + + A single object yields one line; a list yields one line per object (non-object + items drop out); anything else (a bare scalar) yields no lines. Lets the output + layer stay shape-agnostic — it just prints whatever lines come back. + """ + mapping = jsonshape.as_mapping(data) + if mapping is not None: + return [project_record(mapping, fields)] + return project_rows(jsonshape.mapping_list(data), fields) diff --git a/aai_cli/options.py b/aai_cli/options.py index a21af548..03824a07 100644 --- a/aai_cli/options.py +++ b/aai_cli/options.py @@ -19,6 +19,24 @@ def json_option(help_text: str = "Output raw JSON") -> bool: return flag +def fields_option() -> str | None: + """The ``-o/--output`` field projection shared by the list/account read commands. + + Lets ``assembly transcripts list -o id`` or ``assembly sessions list -o + session_id,status`` replace a ``--json | jq`` column grab: it projects the named + fields out of the same JSON the command would emit, one tab-separated line per + record. Comma-separated for multiple fields; dotted paths reach nested objects. + """ + value: str | None = typer.Option( + None, + "-o", + "--output", + help="Project fields from the JSON result (comma-separated, e.g. id,status)", + metavar="FIELDS", + ) + return value + + def chars_per_caption_option() -> int | None: """The ``--chars-per-caption`` knob for the ``-o srt``/``-o vtt`` subtitle exports.""" value: int | None = typer.Option( diff --git a/aai_cli/ui/output.py b/aai_cli/ui/output.py index 2c67e37b..2bf9d253 100644 --- a/aai_cli/ui/output.py +++ b/aai_cli/ui/output.py @@ -12,7 +12,7 @@ from rich.text import Text from aai_cli import __version__ -from aai_cli.core import choices, env, jsonshape, stdio +from aai_cli.core import choices, env, jsonshape, project, stdio from aai_cli.ui import theme if TYPE_CHECKING: @@ -196,13 +196,40 @@ def stack(*items: RenderableType | None) -> RenderableType: 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: +def emit[T]( + data: T, + human_renderer: Callable[[T], object], + *, + json_mode: bool, + fields: str | None = None, +) -> None: + """Emit ``data`` in one of three shapes: projected fields, raw JSON, or human render. + + ``fields`` (a comma-separated ``-o`` spec) wins when set, so a read command's + ``-o id,status`` projection takes precedence over ``--json`` and prints + pipe-friendly columns; otherwise ``json_mode`` selects the raw JSON dump, and + the default is the command's human renderer. + """ + if fields is not None: + emit_fields(data, project.parse_fields(fields)) + elif json_mode: print(jsonshape.dumps(data)) else: console.print(human_renderer(data)) +def emit_fields(data: object, fields: list[str]) -> None: + """Project ``fields`` from JSON ``data`` to stdout — one tab-separated line per + list item, or a single line for one record. + + Backs the ``-o id,status`` projection that lets read commands drop the external + ``jq`` from a 'grab a column' pipeline. A list result yields one line per row; a + single object yields one line. + """ + for line in project.project_any(data, fields): + print(line) + + def emit_ndjson(obj: object) -> None: """Write one newline-delimited JSON record to stdout, flushed for live pipelines.""" print(jsonshape.dumps(obj), flush=True) diff --git a/tests/__snapshots__/test_snapshots_help_account.ambr b/tests/__snapshots__/test_snapshots_help_account.ambr index 24e9a07b..254c96ea 100644 --- a/tests/__snapshots__/test_snapshots_help_account.ambr +++ b/tests/__snapshots__/test_snapshots_help_account.ambr @@ -13,6 +13,9 @@ │ --resource TEXT Filter by raw resource type │ │ --include-logins Show successful login events │ │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON │ + │ result (comma-separated, │ + │ e.g. id,status) │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -27,6 +30,8 @@ $ assembly audit --action token.create Filter by resource, as JSON $ assembly audit --resource token --json + Pull action and actor as columns + $ assembly audit -o action_taken,actor_id @@ -40,15 +45,17 @@ Show your remaining account balance ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json -j Output raw JSON │ - │ --help Show this message and exit. │ + │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON result │ + │ (comma-separated, e.g. id,status) │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples Show your remaining balance $ assembly balance Get the raw cents for scripting - $ assembly balance --json | jq '.balance_in_cents' + $ assembly balance -o balance_in_cents @@ -90,8 +97,10 @@ List API keys across your projects (shown masked) ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json -j Output raw JSON │ - │ --help Show this message and exit. │ + │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON result │ + │ (comma-separated, e.g. id,status) │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -100,7 +109,7 @@ As JSON for scripting $ assembly keys list --json Get key ids to use with rename - $ assembly keys list --json | jq '.[].id' + $ assembly keys list -o id @@ -138,8 +147,10 @@ Show your account's rate limits per service ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json -j Output raw JSON │ - │ --help Show this message and exit. │ + │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON result │ + │ (comma-separated, e.g. id,status) │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -205,14 +216,16 @@ Show usage over a date range (default: last 30 days) ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --start TEXT Start date (YYYY-MM-DD). Default: 30d │ - │ ago. │ - │ --end TEXT End date (YYYY-MM-DD). Default: today. │ - │ --window TEXT Window size: 'day', 'week', or 'month' │ - │ --include-zero,--all Include zero-usage windows (matches │ - │ --include-logins on `assembly audit`) │ - │ --json -j Output raw JSON │ - │ --help Show this message and exit. │ + │ --start TEXT Start date (YYYY-MM-DD). Default: 30d │ + │ ago. │ + │ --end TEXT End date (YYYY-MM-DD). Default: today. │ + │ --window TEXT Window size: 'day', 'week', or 'month' │ + │ --include-zero,--all Include zero-usage windows (matches │ + │ --include-logins on `assembly audit`) │ + │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON result │ + │ (comma-separated, e.g. id,status) │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples diff --git a/tests/__snapshots__/test_snapshots_help_history.ambr b/tests/__snapshots__/test_snapshots_help_history.ambr index 39522856..10d8ae9c 100644 --- a/tests/__snapshots__/test_snapshots_help_history.ambr +++ b/tests/__snapshots__/test_snapshots_help_history.ambr @@ -10,8 +10,10 @@ │ * session_id TEXT Streaming session id [required] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json -j Output raw JSON │ - │ --help Show this message and exit. │ + │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON result │ + │ (comma-separated, e.g. id,status) │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -19,9 +21,10 @@ $ assembly sessions get sess_5551234 Raw JSON for one session $ assembly sessions get sess_5551234 --json + Grab one field + $ assembly sessions get sess_5551234 -o audio_duration_sec Drill into the latest session - $ assembly sessions get $(assembly sessions list --json | jq -r - '.[0].session_id') + $ assembly sessions get $(assembly sessions list -o session_id | head -1) @@ -40,6 +43,9 @@ │ --status [created|completed|error] Only show sessions with this │ │ status │ │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON │ + │ result (comma-separated, e.g. │ + │ id,status) │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -49,10 +55,9 @@ Find failed sessions $ assembly sessions list --status error Inspect the most recent session - $ assembly sessions get $(assembly sessions list --json | jq -r - '.[0].session_id') - Total audio across recent sessions (seconds) - $ assembly sessions list --json | jq '[.[].audio_duration_sec] | add' + $ assembly sessions get $(assembly sessions list -o session_id | head -1) + Pull session ids and durations as columns + $ assembly sessions list -o session_id,audio_duration_sec @@ -130,10 +135,12 @@ List recent transcripts ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --limit INTEGER RANGE [x>=1] How many transcripts to show │ - │ [default: 10] │ - │ --json -j Output raw JSON │ - │ --help Show this message and exit. │ + │ --limit INTEGER RANGE [x>=1] How many transcripts to show │ + │ [default: 10] │ + │ --json -j Output raw JSON │ + │ --output -o FIELDS Project fields from the JSON result │ + │ (comma-separated, e.g. id,status) │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -142,10 +149,10 @@ Show more at once $ assembly transcripts list --limit 50 Grab the latest transcript id - $ assembly transcripts list --json | jq -r '.[0].id' + $ assembly transcripts list -o id | head -1 Summarize your latest transcript - $ assembly llm "summarize" --transcript-id $(assembly transcripts list --json - | jq -r '.[0].id') + $ assembly llm "summarize" --transcript-id $(assembly transcripts list -o id | + head -1) diff --git a/tests/test_account_command.py b/tests/test_account_command.py index 90f290cd..7157a696 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -439,3 +439,37 @@ def test_usage_accepts_each_known_window(mocker, window): result = runner.invoke(app, ["usage", "--window", window, "--json"]) assert result.exit_code == 0 assert get_usage.call_args[0][3] == window # passed through to AMS unchanged + + +def test_balance_projects_field(mocker): + _auth() + mocker.patch( + "aai_cli.commands.account.ams.get_balance", + autospec=True, + return_value={"account_id": 42, "balance_in_cents": 2575}, + ) + result = runner.invoke(app, ["balance", "-o", "balance_in_cents"]) + assert result.exit_code == 0 + # The bare scalar, not the "$25.75" human line nor a JSON object. + assert result.output == "2575\n" + + +def test_limits_projects_nested_field(mocker): + _auth() + mocker.patch( + "aai_cli.commands.account.ams.get_rate_limits", + autospec=True, + return_value={"account_id": 42, "rate_limits": [{"service": "transcript"}]}, + ) + result = runner.invoke(app, ["limits", "-o", "account_id"]) + assert result.exit_code == 0 + assert result.output == "42\n" + + +def test_usage_projects_field(mocker): + _auth() + payload = {"usage_items": [{"line_items": [{"price": 10.0}]}], "currency": "usd"} + mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True, return_value=payload) + result = runner.invoke(app, ["usage", "-o", "currency"]) + assert result.exit_code == 0 + assert result.output == "usd\n" diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py index 74a57246..6dbfcade 100644 --- a/tests/test_audit_command.py +++ b/tests/test_audit_command.py @@ -212,3 +212,18 @@ def test_audit_default_limit_is_20(mocker): result = runner.invoke(app, ["audit", "--json"]) assert result.exit_code == 0 list_logs.assert_called_once_with("jwt", limit=20, action_taken=None, resource_type=None) + + +def test_audit_projects_fields(mocker): + _auth() + payload = { + "data": [ + {"id": 1, "action_taken": "token.create", "actor_id": 7}, + {"id": 2, "action_taken": "login.succeeded", "actor_id": 9}, + ] + } + mocker.patch("aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value=payload) + result = runner.invoke(app, ["audit", "-o", "action_taken,actor_id"]) + assert result.exit_code == 0 + # Projection emits every row (login filtering only applies to the human table). + assert result.output == "token.create\t7\nlogin.succeeded\t9\n" diff --git a/tests/test_keys.py b/tests/test_keys.py index 5df1cfe4..d2e6d49b 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -238,3 +238,21 @@ def test_bare_keys_command_shows_subcommand_help(): assert "create" in result.output assert "rename" in result.output assert "Missing command" not in result.output + + +def test_keys_list_projects_field(mocker): + _auth() + projects = [ + { + "project": {"id": 1, "name": "Default"}, + "tokens": [ + {"id": 10, "name": "ci", "api_key": "sk_abcdef1234", "is_disabled": False}, + {"id": 11, "name": "prod", "api_key": "sk_zzzzzz9876", "is_disabled": True}, + ], + } + ] + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + result = runner.invoke(app, ["keys", "list", "-o", "id,disabled"]) + assert result.exit_code == 0 + # disabled renders as the lowercased JSON boolean, not Python's True/False. + assert result.output == "10\tfalse\n11\ttrue\n" diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 00000000..0eb5dc21 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,84 @@ +import pytest + +from aai_cli.core import project +from aai_cli.core.errors import UsageError +from aai_cli.ui import output + + +def test_parse_fields_splits_and_trims(): + # Comma-split with surrounding whitespace trimmed and empty segments dropped. + assert project.parse_fields(" id , status ") == ["id", "status"] + assert project.parse_fields("id") == ["id"] + assert project.parse_fields("a,,b") == ["a", "b"] + + +def test_parse_fields_rejects_empty_spec(): + with pytest.raises(UsageError) as exc: + project.parse_fields(" , ") + assert "-o" in exc.value.message + + +def test_lookup_descends_dotted_paths_and_yields_none_when_missing(): + record = {"a": {"b": "nested"}, "top": 1} + assert project._lookup(record, "top") == 1 + assert project._lookup(record, "a.b") == "nested" + # A missing key, and a path that runs off a non-object, both yield None. + assert project._lookup(record, "a.z") is None + assert project._lookup(record, "missing") is None + assert project._lookup(record, "top.deeper") is None + + +def test_render_value_scalars_and_containers(): + assert project.render_value(None) == "" + # JSON booleans render lowercased so they read like the --json payload. + assert project.render_value(True) == "true" + assert project.render_value(False) == "false" + assert project.render_value("text") == "text" + assert project.render_value(7) == "7" + assert project.render_value(1.5) == "1.5" + # A nested object/list re-serializes as JSON, not Python repr. + assert project.render_value({"a": 1}) == '{"a": 1}' + assert project.render_value([1, 2]) == "[1, 2]" + + +def test_project_record_tab_separates_columns(): + record = {"id": 1, "status": "done", "flag": True, "none": None} + assert project.project_record(record, ["id", "status"]) == "1\tdone" + # Missing field and None both become empty columns; tab is the separator. + assert project.project_record(record, ["flag", "none", "missing"]) == "true\t\t" + + +def test_project_rows_one_line_per_record(): + rows = [{"id": 1, "status": "done"}, {"id": 2}] + assert project.project_rows(rows, ["id", "status"]) == ["1\tdone", "2\t"] + + +def test_project_any_dispatches_on_shape(): + # A single object -> one line; a list -> one line per row. + assert project.project_any({"id": 9, "name": "k"}, ["id", "name"]) == ["9\tk"] + assert project.project_any([{"id": 1}, {"id": 2}], ["id"]) == ["1", "2"] + # A bare scalar has nothing to project -> no lines. + assert project.project_any("scalar", ["id"]) == [] + + +def test_emit_fields_lists_and_records(capsys): + output.emit_fields([{"id": 1}, {"id": 2}], ["id"]) + assert capsys.readouterr().out == "1\n2\n" + output.emit_fields({"id": 9, "name": "k"}, ["id", "name"]) + assert capsys.readouterr().out == "9\tk\n" + # A non-list, non-object value has nothing to project, so emits nothing. + output.emit_fields("not-json", ["id"]) + assert capsys.readouterr().out == "" + + +def test_emit_fields_precedence_over_json(capsys): + # When fields is set it wins over json_mode: columns, not a JSON dump. + output.emit([{"id": 1}], lambda _d: "human", json_mode=True, fields="id") + assert capsys.readouterr().out == "1\n" + + +def test_emit_without_fields_keeps_json_and_human(capsys): + output.emit({"id": 1}, lambda _d: "human-line", json_mode=True, fields=None) + assert capsys.readouterr().out.strip() == '{"id": 1}' + output.emit({"id": 1}, lambda _d: "human-line", json_mode=False, fields=None) + assert "human-line" in capsys.readouterr().out diff --git a/tests/test_sessions_command.py b/tests/test_sessions_command.py index ca235fae..6e80eb9b 100644 --- a/tests/test_sessions_command.py +++ b/tests/test_sessions_command.py @@ -214,3 +214,28 @@ def test_sessions_no_subcommand_shows_help(): result = runner.invoke(app, ["sessions"]) assert "Missing command" not in result.output assert "list" in result.output and "get" in result.output + + +def test_sessions_list_projects_fields(mocker): + _auth() + payload = { + "data": [ + {"session_id": "s_1", "status": "completed", "audio_duration_sec": 12.0}, + {"session_id": "s_2", "status": "error", "audio_duration_sec": 0.0}, + ] + } + mocker.patch( + "aai_cli.commands.sessions.ams.list_streaming", autospec=True, return_value=payload + ) + result = runner.invoke(app, ["sessions", "list", "-o", "session_id,audio_duration_sec"]) + assert result.exit_code == 0 + assert result.output == "s_1\t12.0\ns_2\t0.0\n" + + +def test_sessions_get_projects_field(mocker): + _auth() + detail = {"session_id": "s_1", "status": "completed", "audio_duration_sec": 30.0} + mocker.patch("aai_cli.commands.sessions.ams.get_streaming", autospec=True, return_value=detail) + result = runner.invoke(app, ["sessions", "get", "s_1", "-o", "audio_duration_sec"]) + assert result.exit_code == 0 + assert result.output == "30.0\n" diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 63924c54..3cca015f 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -279,3 +279,21 @@ def test_transcripts_no_subcommand_shows_help(): result = runner.invoke(app, ["transcripts"]) assert "Missing command" not in result.output assert "list" in result.output and "get" in result.output + + +def test_transcripts_list_projects_fields(mocker): + # -o projects columns from the same JSON --json emits, dropping the jq column-grab. + config.set_api_key("default", "sk_live") + rows = [ + {"id": "t_1", "status": "completed", "created": "2026-06-01T00:00:00Z"}, + {"id": "t_2", "status": "queued", "created": "2026-06-02T00:00:00Z"}, + ] + mocker.patch( + "aai_cli.commands.transcripts.client.list_transcripts", autospec=True, return_value=rows + ) + one = runner.invoke(app, ["transcripts", "list", "-o", "id"]) + assert one.exit_code == 0 + assert one.output == "t_1\nt_2\n" + multi = runner.invoke(app, ["transcripts", "list", "-o", "id,status"]) + assert multi.exit_code == 0 + assert multi.output == "t_1\tcompleted\nt_2\tqueued\n"