Skip to content

Commit b886401

Browse files
authored
🎨 Update CLI to use new design from fastapi-cloud-cli (#470)
1 parent 2c5442f commit b886401

4 files changed

Lines changed: 162 additions & 69 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -146,19 +146,19 @@ def _run(
146146
with get_rich_toolkit() as toolkit:
147147
server_type = "development" if command == "dev" else "production"
148148

149-
toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI")
150-
toolkit.print_line()
149+
toolkit.print(f"Starting FastAPI in {server_type} mode", emoji="⚡️")
151150

151+
toolkit.print_line()
152152
toolkit.print(
153-
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
153+
"Searching for package file structure from directories with [blue]__init__.py[/blue] files",
154+
emoji="🔎",
154155
)
155156

156157
if entrypoint and (path or app):
157158
toolkit.print_line()
158159
toolkit.print(
159160
"[error]Cannot use --entrypoint together with path or --app arguments"
160161
)
161-
toolkit.print_line()
162162
raise typer.Exit(code=1)
163163

164164
try:
@@ -172,8 +172,6 @@ def _run(
172172
field = ".".join(str(loc) for loc in error["loc"])
173173
toolkit.print(f" [red]•[/red] {field}: {error['msg']}")
174174

175-
toolkit.print_line()
176-
177175
raise typer.Exit(code=1) from None
178176

179177
try:
@@ -197,45 +195,43 @@ def _run(
197195
module_data = import_data.module_data
198196
import_string = import_data.import_string
199197

200-
toolkit.print(f"Importing from {module_data.extra_sys_path}")
198+
toolkit.print_line()
199+
toolkit.print(f"Importing from {module_data.extra_sys_path}", emoji="📂")
201200
toolkit.print_line()
202201

203202
if module_data.module_paths:
204203
root_tree = _get_module_tree(module_data.module_paths)
205204

206-
toolkit.print(root_tree, tag="module")
207-
toolkit.print_line()
205+
toolkit.print(root_tree)
206+
207+
toolkit.print_line()
208208

209209
toolkit.print(
210210
"Importing the FastAPI app object from the module with the following code:",
211-
tag="code",
212211
)
213212
toolkit.print_line()
214213
toolkit.print(
215-
f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]"
214+
f"[blue]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]",
216215
)
217-
toolkit.print_line()
218216

219-
toolkit.print(
220-
f"Using import string: [blue]{import_string}[/]",
221-
tag="app",
222-
)
217+
toolkit.print_line()
218+
toolkit.print(f"Using import string: [blue]{import_string}[/]", emoji="🐍")
223219

220+
toolkit.print_line()
224221
mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source]
225222
app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source]
226-
toolkit.print_line()
227-
toolkit.print("Configuration sources:", tag="info")
223+
toolkit.print("Configuration sources:", emoji="📋")
228224
if mod_source_desc == app_source_desc:
229-
toolkit.print(f" • Import string: {mod_source_desc}")
225+
toolkit.print(f"• Import string: {mod_source_desc}")
230226
else:
231-
toolkit.print(f" • Module: {mod_source_desc}")
232-
toolkit.print(f" • App name: {app_source_desc}")
227+
toolkit.print(f"• Module: {mod_source_desc}")
228+
toolkit.print(f"• App name: {app_source_desc}")
233229

234230
if import_data.module_config_source == "auto-discovery":
235231
toolkit.print_line()
236232
toolkit.print(
237233
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
238-
tag="tip",
234+
emoji="💡",
239235
)
240236
toolkit.print_line()
241237
toolkit.print(
@@ -246,24 +242,21 @@ def _run(
246242
),
247243
"toml",
248244
theme="ansi_light",
249-
)
245+
),
250246
)
251247

252248
url = public_url.rstrip("/") if public_url else f"http://{host}:{port}"
253249
url_docs = f"{url}/docs"
254250

255251
toolkit.print_line()
256-
toolkit.print(
257-
f"Server started at [link={url}]{url}[/]",
258-
f"Documentation at [link={url_docs}]{url_docs}[/]",
259-
tag="server",
260-
)
252+
toolkit.print(f"Server started at [link={url}]{url}[/]", emoji="🌐")
253+
toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]")
261254

262255
if command == "dev":
263256
toolkit.print_line()
264257
toolkit.print(
265258
"Running in development mode, for production use: [bold]fastapi run[/]",
266-
tag="tip",
259+
emoji="💡",
267260
)
268261

269262
if not uvicorn:
@@ -272,7 +265,7 @@ def _run(
272265
) from None
273266

274267
toolkit.print_line()
275-
toolkit.print("Logs:")
268+
toolkit.print("Logs:", bullet=False)
276269
toolkit.print_line()
277270

278271
extra_uvicorn_kwargs: dict[str, Any] = (

src/fastapi_cli/utils/cli.py

Lines changed: 126 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,130 @@
22
import sys
33
from typing import Any
44

5-
from rich_toolkit import RichToolkit, RichToolkitTheme
6-
from rich_toolkit.styles import TaggedStyle
5+
from rich._loop import loop_first
6+
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
7+
from rich.segment import Segment
8+
from rich.text import Text
9+
from rich_toolkit import RichToolkit
10+
from rich_toolkit.element import Element
11+
from rich_toolkit.styles import BaseStyle
712
from uvicorn.logging import DefaultFormatter
813

14+
logger = logging.getLogger(__name__)
15+
16+
17+
def should_use_rich_logs() -> bool:
18+
"""Return True when stdout is a TTY and rich logs should be used, False otherwise."""
19+
return sys.stdout.isatty()
20+
21+
22+
class IndentedBlock:
23+
"""Indent a renderable, hanging a prefix (e.g. an emoji bullet) on the
24+
first line and aligning wrapped/extra lines under the text."""
25+
26+
def __init__(
27+
self,
28+
renderable: RenderableType,
29+
*,
30+
first_prefix: Text,
31+
prefix: Text,
32+
) -> None:
33+
self.renderable = renderable
34+
self.first_prefix = first_prefix
35+
self.prefix = prefix
36+
37+
# Text renders its `end` ("\n" by default), which would break lines
38+
self.first_prefix.end = ""
39+
self.prefix.end = ""
40+
41+
def __rich_console__(
42+
self, console: Console, options: ConsoleOptions
43+
) -> RenderResult:
44+
prefix_width = max(self.first_prefix.cell_len, self.prefix.cell_len)
45+
lines = console.render_lines(
46+
self.renderable,
47+
options.update_width(options.max_width - prefix_width),
48+
pad=False,
49+
)
50+
51+
new_line = Segment.line()
52+
53+
for first, line in loop_first(lines):
54+
if any(segment.text.strip() for segment in line):
55+
yield from console.render(
56+
self.first_prefix if first else self.prefix, options
57+
)
58+
yield from line
59+
60+
yield new_line
61+
62+
63+
class FastAPIStyle(BaseStyle):
64+
"""Uniform left indent with emoji bullets hanging to the left of the text.
65+
66+
``emoji=`` renders as a bullet in a fixed column; ``bullet=False`` drops
67+
the bullet column and just indents. This is a small, purpose-built subset
68+
of the richer style in fastapi-cloud-cli.
69+
"""
70+
71+
content_padding = 1
72+
emoji_column_width = 3
73+
74+
def render_element(
75+
self,
76+
element: Any,
77+
is_active: bool = False,
78+
done: bool = False,
79+
parent: Element | None = None,
80+
**kwargs: Any,
81+
) -> RenderableType:
82+
rendered = super().render_element(
83+
element=element, is_active=is_active, done=done, parent=parent, **kwargs
84+
)
85+
86+
emoji = kwargs.get("emoji", "")
87+
88+
if not emoji and not kwargs.get("bullet", True):
89+
indent = Text(" " * (self.content_padding + 1))
90+
return IndentedBlock(rendered, first_prefix=indent, prefix=indent)
91+
92+
return IndentedBlock(
93+
rendered,
94+
first_prefix=self._get_bullet_prefix(emoji),
95+
prefix=Text(" " * (self.content_padding + self.emoji_column_width)),
96+
)
97+
98+
def _get_bullet_prefix(self, emoji: str) -> Text:
99+
prefix = Text(" " * self.content_padding)
100+
101+
if emoji:
102+
prefix.append_text(Text.from_markup(emoji))
103+
104+
prefix.pad_right(
105+
self.content_padding + self.emoji_column_width - prefix.cell_len
106+
)
107+
108+
return prefix
109+
110+
111+
LOG_LEVEL_COLORS = {
112+
"debug": "blue",
113+
"info": "cyan",
114+
"warning": "yellow",
115+
"warn": "yellow",
116+
"error": "red",
117+
"critical": "magenta",
118+
"fatal": "magenta",
119+
}
120+
121+
122+
def _get_log_bullet(level: str) -> str:
123+
"""Colored bar rendered in the emoji bullet column, matching the log
124+
level, like the ``fastapi cloud logs`` output."""
125+
color = LOG_LEVEL_COLORS.get(level.lower(), "dim")
126+
127+
return f"[{color}]▕[/{color}]"
128+
9129

10130
class CustomFormatter(DefaultFormatter):
11131
def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -14,18 +134,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
14134

15135
def formatMessage(self, record: logging.LogRecord) -> str:
16136
message = record.getMessage()
17-
result = self.toolkit.print_as_string(message, tag=record.levelname)
137+
result = self.toolkit.print_as_string(
138+
message, emoji=_get_log_bullet(record.levelname)
139+
)
18140
# Prepend newline to fix alignment after ^C is printed by the terminal
19141
if message == "Shutting down":
20142
result = "\n" + result
21143
return result
22144

23145

24-
def should_use_rich_logs() -> bool:
25-
"""Return True when stdout is a TTY and rich logs should be used, False otherwise."""
26-
return sys.stdout.isatty()
27-
28-
29146
def get_uvicorn_log_config() -> dict[str, Any]:
30147
return {
31148
"version": 1,
@@ -69,23 +186,5 @@ def get_uvicorn_log_config() -> dict[str, Any]:
69186
}
70187

71188

72-
logger = logging.getLogger(__name__)
73-
74-
75189
def get_rich_toolkit() -> RichToolkit:
76-
theme = RichToolkitTheme(
77-
style=TaggedStyle(tag_width=11),
78-
theme={
79-
"tag.title": "white on #009485",
80-
"tag": "white on #007166",
81-
"placeholder": "grey85",
82-
"text": "white",
83-
"selected": "#007166",
84-
"result": "grey85",
85-
"progress": "on #007166",
86-
"error": "red",
87-
"log.info": "black on blue",
88-
},
89-
)
90-
91-
return RichToolkit(theme=theme)
190+
return RichToolkit(style=FastAPIStyle())

0 commit comments

Comments
 (0)