diff --git a/src/fastapi_cloud_cli/commands/env.py b/src/fastapi_cloud_cli/commands/env.py index f8cc9e1..b354751 100644 --- a/src/fastapi_cloud_cli/commands/env.py +++ b/src/fastapi_cloud_cli/commands/env.py @@ -4,19 +4,27 @@ import typer from pydantic import BaseModel +from rich import box +from rich.table import Table +from rich.text import Text from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import get_app_config from fastapi_cloud_cli.utils.auth import Identity from fastapi_cloud_cli.utils.cli import get_rich_toolkit +from fastapi_cloud_cli.utils.dates import format_last_updated from fastapi_cloud_cli.utils.env import validate_environment_variable_name logger = logging.getLogger(__name__) +ENV_VAR_VALUE_MAX_LENGTH = 40 + class EnvironmentVariable(BaseModel): name: str value: str | None = None + is_secret: bool = False + updated_at: str | None = None class EnvironmentVariableResponse(BaseModel): @@ -53,11 +61,47 @@ def _set_environment_variable( response.raise_for_status() +def _format_env_var_value(env_var: EnvironmentVariable) -> Text: + if env_var.value is None: + placeholder = "[secret]" if env_var.is_secret else "-" + + return Text(placeholder, style="dim") + + value = env_var.value.replace("\r", "\\r").replace("\n", "\\n") + + if len(value) > ENV_VAR_VALUE_MAX_LENGTH: + value = f"{value[: ENV_VAR_VALUE_MAX_LENGTH - 3]}..." + + return Text(value) + + +def _get_environment_variables_table( + environment_variables: list[EnvironmentVariable], +) -> Table: + table = Table( + box=box.SIMPLE_HEAD, + pad_edge=False, + show_edge=False, + ) + table.add_column("Key", no_wrap=True) + table.add_column("Value", overflow="ellipsis", max_width=ENV_VAR_VALUE_MAX_LENGTH) + table.add_column("Last updated", style="dim", no_wrap=True) + + for env_var in environment_variables: + table.add_row( + Text(env_var.name), + _format_env_var_value(env_var), + Text(format_last_updated(env_var.updated_at)), + ) + + return table + + env_app = typer.Typer() -@env_app.command() -def list( +@env_app.command("list") +def list_variables( path: Annotated[ Path | None, typer.Argument( @@ -106,11 +150,7 @@ def list( toolkit.print("No environment variables found.") return - toolkit.print("Environment variables:") - toolkit.print_line() - - for env_var in environment_variables.data: - toolkit.print(f"[bold]{env_var.name}[/]") + toolkit.print(_get_environment_variables_table(environment_variables.data)) @env_app.command() diff --git a/src/fastapi_cloud_cli/utils/dates.py b/src/fastapi_cloud_cli/utils/dates.py new file mode 100644 index 0000000..55fef43 --- /dev/null +++ b/src/fastapi_cloud_cli/utils/dates.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone + + +def format_last_updated(updated_at: str | None) -> str: + if updated_at is None: + return "-" + + try: + updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) + except ValueError: + return updated_at + + if updated.tzinfo is None: + updated = updated.replace(tzinfo=timezone.utc) + + now = datetime.now(timezone.utc) + seconds = int((now - updated).total_seconds()) + + if seconds < 60: + return "just now" + + minutes = seconds // 60 + if minutes < 60: + return _format_time_ago(minutes, "minute") + + hours = minutes // 60 + if hours < 24: + return _format_time_ago(hours, "hour") + + days = hours // 24 + if days < 30: + return _format_time_ago(days, "day") + + months = days // 30 + if months < 12: + return _format_time_ago(months, "month") + + years = days // 365 + return _format_time_ago(years, "year") + + +def _format_time_ago(value: int, unit: str) -> str: + suffix = "" if value == 1 else "s" + + return f"{value} {unit}{suffix} ago" diff --git a/tests/test_dates.py b/tests/test_dates.py new file mode 100644 index 0000000..2c0a814 --- /dev/null +++ b/tests/test_dates.py @@ -0,0 +1,27 @@ +from datetime import datetime, timezone + +import pytest +import time_machine + +from fastapi_cloud_cli.utils.dates import format_last_updated + + +@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False) +@pytest.mark.parametrize( + ("updated_at", "expected"), + [ + ("2026-05-22T11:59:30Z", "just now"), + ("2026-05-22T11:59:00Z", "1 minute ago"), + ("2026-05-22T11:30:00", "30 minutes ago"), + ("2026-05-22T10:00:00Z", "2 hours ago"), + ("2025-05-22T12:00:00Z", "1 year ago"), + ], +) +def test_format_last_updated_formats_relative_time( + updated_at: str, expected: str +) -> None: + assert format_last_updated(updated_at) == expected + + +def test_format_last_updated_returns_invalid_dates_unchanged() -> None: + assert format_last_updated("not-a-date") == "not-a-date" diff --git a/tests/test_env_list.py b/tests/test_env_list.py index 4d8c32d..66078a7 100644 --- a/tests/test_env_list.py +++ b/tests/test_env_list.py @@ -1,7 +1,9 @@ +from datetime import datetime, timezone from pathlib import Path import pytest import respx +import time_machine from httpx import Response from typer.testing import CliRunner @@ -14,6 +16,10 @@ assets_path = Path(__file__).parent / "assets" +def _normalize_output(output: str) -> str: + return "\n".join(line.rstrip() for line in output.strip("\n").splitlines()) + + def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None: result = runner.invoke(app, ["env", "list"]) @@ -85,6 +91,83 @@ def test_shows_environment_variables_names( assert "API_KEY" in result.output +@pytest.mark.respx +@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False) +def test_shows_environment_variables_in_compact_table( + logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp +) -> None: + respx_mock.get(f"/apps/{configured_app.app_id}/environment-variables/").mock( + return_value=Response( + 200, + json={ + "data": [ + { + "name": "APP_URL", + "value": "https://tryshot.app", + "updated_at": "2026-05-10T12:00:00Z", + }, + { + "name": "SENTRY_ENVIRONMENT", + "value": "production", + "updated_at": "2026-03-22T12:00:00Z", + }, + ] + }, + ) + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["env", "list"]) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == ( + "Key Value Last updated\n" + "───────────────────────────────────────────────────────\n" + "APP_URL https://tryshot.app 12 days ago\n" + "SENTRY_ENVIRONMENT production 2 months ago" + ) + + +@pytest.mark.respx +@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False) +def test_truncates_values_and_marks_secrets_in_compact_table( + logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp +) -> None: + long_value = "12345678901234567890123456789012345678901234567890" + + respx_mock.get(f"/apps/{configured_app.app_id}/environment-variables/").mock( + return_value=Response( + 200, + json={ + "data": [ + { + "name": "LONG_VALUE", + "value": long_value, + "updated_at": "2026-03-22T12:00:00Z", + }, + { + "name": "SECRET_KEY", + "is_secret": True, + "updated_at": "2026-04-22T12:00:00Z", + }, + ] + }, + ) + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["env", "list"]) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == ( + "Key Value Last updated\n" + "────────────────────────────────────────────────────────────────────\n" + "LONG_VALUE 1234567890123456789012345678901234567... 2 months ago\n" + "SECRET_KEY [secret] 1 month ago" + ) + assert long_value not in result.output + + @pytest.mark.respx def test_shows_secret_environment_variables_without_value( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp