Skip to content

Commit bc2477b

Browse files
committed
✨ Improve env list output
Shortcake-Parent: main
1 parent ecff13d commit bc2477b

3 files changed

Lines changed: 175 additions & 5 deletions

File tree

src/fastapi_cloud_cli/commands/env.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@
44

55
import typer
66
from pydantic import BaseModel
7+
from rich import box
8+
from rich.table import Table
9+
from rich.text import Text
710

811
from fastapi_cloud_cli.utils.api import APIClient
912
from fastapi_cloud_cli.utils.apps import get_app_config
1013
from fastapi_cloud_cli.utils.auth import Identity
1114
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
15+
from fastapi_cloud_cli.utils.dates import format_last_updated
1216
from fastapi_cloud_cli.utils.env import validate_environment_variable_name
1317

1418
logger = logging.getLogger(__name__)
1519

20+
ENV_VAR_VALUE_MAX_LENGTH = 40
21+
1622

1723
class EnvironmentVariable(BaseModel):
1824
name: str
1925
value: str | None = None
26+
is_secret: bool = False
27+
updated_at: str | None = None
2028

2129

2230
class EnvironmentVariableResponse(BaseModel):
@@ -53,6 +61,44 @@ def _set_environment_variable(
5361
response.raise_for_status()
5462

5563

64+
def _format_env_var_value(env_var: EnvironmentVariable) -> Text:
65+
if env_var.value is None:
66+
placeholder = "[secret]" if env_var.is_secret else "-"
67+
68+
return Text(placeholder, style="dim")
69+
70+
value = env_var.value.replace("\r", "\\r").replace("\n", "\\n")
71+
72+
if len(value) > ENV_VAR_VALUE_MAX_LENGTH:
73+
value = f"{value[: ENV_VAR_VALUE_MAX_LENGTH - 3]}..."
74+
75+
return Text(value)
76+
77+
78+
def _get_environment_variables_table(
79+
environment_variables: list[EnvironmentVariable],
80+
) -> Table:
81+
table = Table(
82+
box=box.SIMPLE_HEAD,
83+
pad_edge=False,
84+
show_edge=False,
85+
)
86+
table.add_column("Key", no_wrap=True)
87+
table.add_column(
88+
"Value", overflow="ellipsis", max_width=ENV_VAR_VALUE_MAX_LENGTH
89+
)
90+
table.add_column("Last updated", style="dim", no_wrap=True)
91+
92+
for env_var in environment_variables:
93+
table.add_row(
94+
Text(env_var.name),
95+
_format_env_var_value(env_var),
96+
Text(format_last_updated(env_var.updated_at)),
97+
)
98+
99+
return table
100+
101+
56102
env_app = typer.Typer()
57103

58104

@@ -106,11 +152,7 @@ def list(
106152
toolkit.print("No environment variables found.")
107153
return
108154

109-
toolkit.print("Environment variables:")
110-
toolkit.print_line()
111-
112-
for env_var in environment_variables.data:
113-
toolkit.print(f"[bold]{env_var.name}[/]")
155+
toolkit.print(_get_environment_variables_table(environment_variables.data))
114156

115157

116158
@env_app.command()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from datetime import datetime, timezone
2+
3+
4+
def format_last_updated(updated_at: str | None) -> str:
5+
if updated_at is None:
6+
return "-"
7+
8+
try:
9+
updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
10+
except ValueError:
11+
return updated_at
12+
13+
if updated.tzinfo is None:
14+
updated = updated.replace(tzinfo=timezone.utc)
15+
16+
now = datetime.now(timezone.utc)
17+
seconds = int((now - updated).total_seconds())
18+
19+
if seconds < 60:
20+
return "just now"
21+
22+
minutes = seconds // 60
23+
if minutes < 60:
24+
return _format_time_ago(minutes, "minute")
25+
26+
hours = minutes // 60
27+
if hours < 24:
28+
return _format_time_ago(hours, "hour")
29+
30+
days = hours // 24
31+
if days < 30:
32+
return _format_time_ago(days, "day")
33+
34+
months = days // 30
35+
if months < 12:
36+
return _format_time_ago(months, "month")
37+
38+
years = days // 365
39+
return _format_time_ago(years, "year")
40+
41+
42+
def _format_time_ago(value: int, unit: str) -> str:
43+
suffix = "" if value == 1 else "s"
44+
45+
return f"{value} {unit}{suffix} ago"

tests/test_env_list.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from datetime import datetime, timezone
12
from pathlib import Path
23

34
import pytest
45
import respx
6+
import time_machine
57
from httpx import Response
68
from typer.testing import CliRunner
79

@@ -14,6 +16,10 @@
1416
assets_path = Path(__file__).parent / "assets"
1517

1618

19+
def _normalize_output(output: str) -> str:
20+
return "\n".join(line.rstrip() for line in output.strip("\n").splitlines())
21+
22+
1723
def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None:
1824
result = runner.invoke(app, ["env", "list"])
1925

@@ -85,6 +91,83 @@ def test_shows_environment_variables_names(
8591
assert "API_KEY" in result.output
8692

8793

94+
@pytest.mark.respx
95+
@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False)
96+
def test_shows_environment_variables_in_compact_table(
97+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
98+
) -> None:
99+
respx_mock.get(f"/apps/{configured_app.app_id}/environment-variables/").mock(
100+
return_value=Response(
101+
200,
102+
json={
103+
"data": [
104+
{
105+
"name": "APP_URL",
106+
"value": "https://tryshot.app",
107+
"updated_at": "2026-05-10T12:00:00Z",
108+
},
109+
{
110+
"name": "SENTRY_ENVIRONMENT",
111+
"value": "production",
112+
"updated_at": "2026-03-22T12:00:00Z",
113+
},
114+
]
115+
},
116+
)
117+
)
118+
119+
with changing_dir(configured_app.path):
120+
result = runner.invoke(app, ["env", "list"])
121+
122+
assert result.exit_code == 0
123+
assert _normalize_output(result.output) == (
124+
"Key Value Last updated\n"
125+
"───────────────────────────────────────────────────────\n"
126+
"APP_URL https://tryshot.app 12 days ago\n"
127+
"SENTRY_ENVIRONMENT production 2 months ago"
128+
)
129+
130+
131+
@pytest.mark.respx
132+
@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False)
133+
def test_truncates_values_and_marks_secrets_in_compact_table(
134+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
135+
) -> None:
136+
long_value = "12345678901234567890123456789012345678901234567890"
137+
138+
respx_mock.get(f"/apps/{configured_app.app_id}/environment-variables/").mock(
139+
return_value=Response(
140+
200,
141+
json={
142+
"data": [
143+
{
144+
"name": "LONG_VALUE",
145+
"value": long_value,
146+
"updated_at": "2026-03-22T12:00:00Z",
147+
},
148+
{
149+
"name": "SECRET_KEY",
150+
"is_secret": True,
151+
"updated_at": "2026-04-22T12:00:00Z",
152+
},
153+
]
154+
},
155+
)
156+
)
157+
158+
with changing_dir(configured_app.path):
159+
result = runner.invoke(app, ["env", "list"])
160+
161+
assert result.exit_code == 0
162+
assert _normalize_output(result.output) == (
163+
"Key Value Last updated\n"
164+
"────────────────────────────────────────────────────────────────────\n"
165+
"LONG_VALUE 1234567890123456789012345678901234567... 2 months ago\n"
166+
"SECRET_KEY [secret] 1 month ago"
167+
)
168+
assert long_value not in result.output
169+
170+
88171
@pytest.mark.respx
89172
def test_shows_secret_environment_variables_without_value(
90173
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp

0 commit comments

Comments
 (0)