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
54 changes: 47 additions & 7 deletions src/fastapi_cloud_cli/commands/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions src/fastapi_cloud_cli/utils/dates.py
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 27 additions & 0 deletions tests/test_dates.py
Original file line number Diff line number Diff line change
@@ -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"
83 changes: 83 additions & 0 deletions tests/test_env_list.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"])

Expand Down Expand Up @@ -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
Expand Down
Loading