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
42 changes: 28 additions & 14 deletions packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@
BaseResult,
Config,
DeepgramClient,
get_output_format,
)
from rich.console import Console
from rich.table import Table

from .models import BalanceInfo, BillingResult

console = Console()
# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV
# output that callers pipe into jq and friends.
status_console = Console(stderr=True)


class BillingCommand(BaseCommand):
Expand Down Expand Up @@ -116,7 +120,7 @@ def handle(
return result

except Exception as e:
console.print(f"[red]Error:[/red] {e}")
status_console.print(f"[red]Error:[/red] {e}")
return BaseResult(status="error", message=str(e))

def _show_balances(
Expand All @@ -125,21 +129,16 @@ def _show_balances(
project_id: str | None,
result: BillingResult,
) -> None:
console.print("[blue]Fetching balances...[/blue]")
status_console.print("[blue]Fetching balances...[/blue]")
data = client.get_balances(project_id=project_id)

balances_raw = data.get("balances", [])
if not balances_raw:
console.print("[yellow]No balances found[/yellow]")
status_console.print("[yellow]No balances found[/yellow]")
return

table = Table(
title="Account Balances", show_header=True, header_style="bold blue"
)
table.add_column("Balance ID", style="dim")
table.add_column("Amount", justify="right", style="green")
table.add_column("Units")

# Always populate the returned result; only the human table render
# is gated on default mode (json/yaml/csv are emitted by the framework).
for b in balances_raw:
b_data = (
b
Expand All @@ -152,9 +151,19 @@ def _show_balances(
units=b_data.get("units", ""),
)
result.balances.append(info)
table.add_row(info.balance_id, f"{info.amount:,.2f}", info.units)

console.print(table)
if get_output_format() == "default":
table = Table(
title="Account Balances", show_header=True, header_style="bold blue"
)
table.add_column("Balance ID", style="dim")
table.add_column("Amount", justify="right", style="green")
table.add_column("Units")

for info in result.balances:
table.add_row(info.balance_id, f"{info.amount:,.2f}", info.units)

console.print(table)

def _show_breakdown(
self,
Expand All @@ -166,7 +175,7 @@ def _show_breakdown(
grouping: str | None = None,
) -> None:

console.print("[blue]Fetching billing breakdown...[/blue]")
status_console.print("[blue]Fetching billing breakdown...[/blue]")
data = client.get_billing_breakdown(
project_id=project_id,
start=start,
Expand All @@ -176,6 +185,11 @@ def _show_breakdown(

result.breakdown = data

# Human display only in default mode; json/yaml/csv are emitted by the
# framework from result.breakdown.
if get_output_format() != "default":
return

# Display breakdown summary
resolution = data.get("resolution", {})
if resolution:
Expand Down Expand Up @@ -205,6 +219,6 @@ def _show_breakdown(

console.print(table)
else:
console.print(
status_console.print(
"[dim]No breakdown data available for the specified period[/dim]"
)
70 changes: 66 additions & 4 deletions packages/deepctl-cmd-billing/tests/unit/test_billing_command.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for billing command."""

from unittest.mock import Mock
from unittest.mock import Mock, patch

import pytest
from deepctl_cmd_billing.command import BillingCommand
Expand Down Expand Up @@ -222,9 +222,7 @@ def test_handle_breakdown_with_dates(
grouping="tags",
)

def test_handle_error(
self, command, mock_config, mock_auth_manager, mock_client
):
def test_handle_error(self, command, mock_config, mock_auth_manager, mock_client):
"""Test client exception returns error status."""
mock_client.get_balances.side_effect = Exception("API connection failed")

Expand Down Expand Up @@ -284,3 +282,67 @@ def test_billing_result_serialization(self):
assert data["balances"][0]["balance_id"] == "bal-1"
assert data["balances"][0]["amount"] == 100.0
assert data["breakdown"]["resolution"]["period"] == "monthly"


class TestBillingBreakdownStreamRouting:
"""In default mode the breakdown header and table share the stdout stream.

The 'Billing Period' header must go to the stdout ``console`` (with the
table), not ``status_console`` (stderr) — otherwise a default-mode
``dg billing --breakdown > out.txt`` keeps the table but silently drops
the header. In json/yaml/csv modes nothing is rendered (the framework
serialises the returned result), so no human output touches either stream.
"""

@pytest.fixture
def command(self):
return BillingCommand()

@staticmethod
def _breakdown_response():
return {
"resolution": {"period": "monthly", "amount": 1},
"results": [
{"start": "2026-08-01", "amount": 12.5, "units": "usd"},
],
}

@patch("deepctl_cmd_billing.command.get_output_format", return_value="default")
@patch("deepctl_cmd_billing.command.status_console")
@patch("deepctl_cmd_billing.command.console")
def test_default_mode_period_on_stdout_not_stderr(
self, mock_console, mock_status_console, _fmt, command
):
client = Mock(spec=DeepgramClient)
client.get_billing_breakdown.return_value = self._breakdown_response()

command._show_breakdown(client, None, BillingResult(status="success"))

stdout_text = " ".join(
str(c.args[0]) for c in mock_console.print.call_args_list if c.args
)
stderr_text = " ".join(
str(c.args[0]) for c in mock_status_console.print.call_args_list if c.args
)
# Header rides stdout with the table...
assert "Billing Period" in stdout_text
# ...and never leaks to stderr; only the "Fetching" chrome does.
assert "Billing Period" not in stderr_text
assert "Fetching billing breakdown" in stderr_text

@patch("deepctl_cmd_billing.command.get_output_format", return_value="json")
@patch("deepctl_cmd_billing.command.status_console")
@patch("deepctl_cmd_billing.command.console")
def test_json_mode_renders_no_human_output(
self, mock_console, mock_status_console, _fmt, command
):
client = Mock(spec=DeepgramClient)
client.get_billing_breakdown.return_value = self._breakdown_response()
result = BillingResult(status="success")

command._show_breakdown(client, None, result)

# Result still carries the breakdown for the framework to serialise.
assert result.breakdown == self._breakdown_response()
# No table/header printed to stdout in json mode.
mock_console.print.assert_not_called()
94 changes: 53 additions & 41 deletions packages/deepctl-cmd-members/src/deepctl_cmd_members/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@
BaseResult,
Config,
DeepgramClient,
get_output_format,
)
from rich.console import Console
from rich.table import Table

from .models import InviteInfo, MemberInfo, MembersResult

console = Console()
# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV
# output that callers pipe into jq and friends.
status_console = Console(stderr=True)


class MembersCommand(BaseCommand):
Expand Down Expand Up @@ -141,29 +145,21 @@ def handle(
return self._list_members(client, project_id)

except Exception as e:
console.print(f"[red]Error:[/red] {e}")
status_console.print(f"[red]Error:[/red] {e}")
return BaseResult(status="error", message=str(e))

def _list_members(
self, client: DeepgramClient, project_id: str | None
) -> BaseResult:
console.print("[blue]Fetching project members...[/blue]")
status_console.print("[blue]Fetching project members...[/blue]")
result = client.list_members(project_id=project_id)

members_raw = result.get("members", [])
if not members_raw:
console.print("[yellow]No members found[/yellow]")
status_console.print("[yellow]No members found[/yellow]")
return MembersResult(status="info", message="No members found")

member_models: list[MemberInfo] = []
table = Table(
title="Project Members", show_header=True, header_style="bold blue"
)
table.add_column("Name", style="green")
table.add_column("Email")
table.add_column("Scopes")
table.add_column("Member ID", style="dim")

for m in members_raw:
m_data = (
m
Expand All @@ -179,16 +175,29 @@ def _list_members(
)
member_models.append(info)

name = f"{info.first_name} {info.last_name}".strip() or "(no name)"
table.add_row(
name,
info.email,
", ".join(info.scopes) if info.scopes else "-",
info.member_id,
# Render the human table only in default mode. For json/yaml/csv the
# framework serialises the returned result to stdout, so printing the
# table here would prepend non-parseable text to that output.
if get_output_format() == "default":
table = Table(
title="Project Members", show_header=True, header_style="bold blue"
)
table.add_column("Name", style="green")
table.add_column("Email")
table.add_column("Scopes")
table.add_column("Member ID", style="dim")

for info in member_models:
name = f"{info.first_name} {info.last_name}".strip() or "(no name)"
table.add_row(
name,
info.email,
", ".join(info.scopes) if info.scopes else "-",
info.member_id,
)

console.print(table)
console.print(f"\n[dim]{len(member_models)} member(s)[/dim]")
console.print(table)
console.print(f"\n[dim]{len(member_models)} member(s)[/dim]")

return MembersResult(
status="success", members=member_models, count=len(member_models)
Expand All @@ -203,15 +212,15 @@ def _invite_member(
dry_run: bool = False,
) -> BaseResult:
if dry_run:
console.print("[yellow]Dry run — no changes made[/yellow]")
console.print(f" Would invite: {email} (scope: {scope})")
status_console.print("[yellow]Dry run — no changes made[/yellow]")
status_console.print(f" Would invite: {email} (scope: {scope})")
return BaseResult(
status="dry_run", message=f"Dry run: would invite {email}"
)

console.print(f"[blue]Inviting {email} with scope '{scope}'...[/blue]")
status_console.print(f"[blue]Inviting {email} with scope '{scope}'...[/blue]")
client.create_invite(email=email, scope=scope, project_id=project_id)
console.print(f"[green]Invitation sent to {email}[/green]")
status_console.print(f"[green]Invitation sent to {email}[/green]")
return BaseResult(status="success", message=f"Invited {email}")

def _remove_member(
Expand All @@ -223,8 +232,8 @@ def _remove_member(
dry_run: bool = False,
) -> BaseResult:
if dry_run:
console.print("[yellow]Dry run — no changes made[/yellow]")
console.print(f" Would remove member: {member_id}")
status_console.print("[yellow]Dry run — no changes made[/yellow]")
status_console.print(f" Would remove member: {member_id}")
return BaseResult(
status="dry_run", message=f"Dry run: would remove member {member_id}"
)
Expand All @@ -234,29 +243,23 @@ def _remove_member(
):
return BaseResult(status="cancelled", message="Cancelled by user")

console.print(f"[blue]Removing member {member_id}...[/blue]")
status_console.print(f"[blue]Removing member {member_id}...[/blue]")
client.remove_member(member_id, project_id=project_id)
console.print(f"[green]Member {member_id} removed[/green]")
status_console.print(f"[green]Member {member_id} removed[/green]")
return BaseResult(status="success", message=f"Member {member_id} removed")

def _list_invites(
self, client: DeepgramClient, project_id: str | None
) -> BaseResult:
console.print("[blue]Fetching pending invites...[/blue]")
status_console.print("[blue]Fetching pending invites...[/blue]")
result = client.list_invites(project_id=project_id)

invites_raw = result.get("invites", [])
if not invites_raw:
console.print("[yellow]No pending invites[/yellow]")
status_console.print("[yellow]No pending invites[/yellow]")
return MembersResult(status="info", message="No pending invites")

invite_models: list[InviteInfo] = []
table = Table(
title="Pending Invites", show_header=True, header_style="bold blue"
)
table.add_column("Email", style="green")
table.add_column("Scope")

for inv in invites_raw:
inv_data = (
inv
Expand All @@ -268,9 +271,18 @@ def _list_invites(
scope=inv_data.get("scope", ""),
)
invite_models.append(info)
table.add_row(info.email, info.scope)

console.print(table)
if get_output_format() == "default":
table = Table(
title="Pending Invites", show_header=True, header_style="bold blue"
)
table.add_column("Email", style="green")
table.add_column("Scope")

for info in invite_models:
table.add_row(info.email, info.scope)

console.print(table)

return MembersResult(
status="success", invites=invite_models, count=len(invite_models)
Expand All @@ -285,8 +297,8 @@ def _revoke_invite(
dry_run: bool = False,
) -> BaseResult:
if dry_run:
console.print("[yellow]Dry run — no changes made[/yellow]")
console.print(f" Would revoke invite for: {email}")
status_console.print("[yellow]Dry run — no changes made[/yellow]")
status_console.print(f" Would revoke invite for: {email}")
return BaseResult(
status="dry_run",
message=f"Dry run: would revoke invite for {email}",
Expand All @@ -295,7 +307,7 @@ def _revoke_invite(
if not yes and not self.confirm(f"Revoke invite for {email}?", default=False):
return BaseResult(status="cancelled", message="Cancelled by user")

console.print(f"[blue]Revoking invite for {email}...[/blue]")
status_console.print(f"[blue]Revoking invite for {email}...[/blue]")
client.delete_invite(email, project_id=project_id)
console.print(f"[green]Invite for {email} revoked[/green]")
status_console.print(f"[green]Invite for {email} revoked[/green]")
return BaseResult(status="success", message=f"Invite for {email} revoked")
Loading
Loading