Skip to content

Commit 7ec35b7

Browse files
committed
💄 Bring the full FastAPI style for sharing with fastapi-cloud-cli
Shortcake-Parent: 2026-07-03-avoid-fancy-logs-for-non-tty-output
1 parent d41c95b commit 7ec35b7

3 files changed

Lines changed: 299 additions & 12 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ classifiers = [
3333
dependencies = [
3434
"typer >= 0.16.0",
3535
"uvicorn[standard] >= 0.15.0",
36-
"rich-toolkit >= 0.14.8",
36+
"rich-toolkit >= 0.20.1",
3737
"tomli >= 2.0.0; python_version < '3.11'"
3838
]
3939

‎src/fastapi_cli/utils/cli.py‎

Lines changed: 297 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,306 @@
11
import logging
2+
import os
23
import sys
3-
from typing import Any
4+
import time
5+
from collections.abc import Iterator
6+
from typing import Any, cast
47

8+
from rich._loop import loop_first_last
9+
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
10+
from rich.padding import Padding
11+
from rich.segment import Segment
12+
from rich.style import Style
13+
from rich.text import Text
514
from rich_toolkit import RichToolkit, RichToolkitTheme
6-
from rich_toolkit.styles import TaggedStyle
15+
from rich_toolkit.container import Container
16+
from rich_toolkit.element import CursorOffset, Element
17+
from rich_toolkit.input import Input
18+
from rich_toolkit.progress import Progress
19+
from rich_toolkit.styles import BaseStyle, TaggedStyle
720
from uvicorn.logging import DefaultFormatter
821

22+
logger = logging.getLogger(__name__)
23+
24+
25+
# the leading space right-aligns the one-cell ✗ within the two-cell emoji
26+
# slot, so its right edge and gap to the text match the emoji bullets
27+
ERROR_BULLET = " [bold][error]✗[/][/]"
28+
29+
30+
TITLE_SWEEP_SHADES = ("█", "▓", "▓", "▒", "░")
31+
TITLE_SWEEP_DELAY = 0.015
32+
33+
34+
def is_ci_enabled() -> bool:
35+
value = os.environ.get("CI")
36+
37+
if value is None:
38+
return False
39+
40+
return value.lower() not in {"", "0", "false", "no", "off"}
41+
42+
43+
def should_use_rich_logs() -> bool:
44+
"""Return True when stdout is a TTY and rich logs should be used, False otherwise."""
45+
return sys.stdout.isatty()
46+
47+
48+
def _title_sweep_frames(text: str) -> Iterator[tuple[str, str, str]]:
49+
"""Frames of a gradient sweep painting the title chip into existence.
50+
51+
Each frame is split into the part of the chip already swept (rendered
52+
with the chip's background), the visible sweep shades, and the still
53+
untouched tail, all together exactly as wide as the chip (one space of
54+
padding around the text) so the real chip prints cleanly over the last
55+
frame."""
56+
chip = f" {text} "
57+
width = len(chip)
58+
59+
for light_pos in range(-len(TITLE_SWEEP_SHADES), width + len(TITLE_SWEEP_SHADES)):
60+
sweep_start = light_pos - len(TITLE_SWEEP_SHADES) + 1
61+
chip_end = max(0, min(width, sweep_start))
62+
63+
shades = "".join(
64+
shade
65+
for index, shade in enumerate(TITLE_SWEEP_SHADES)
66+
if 0 <= sweep_start + index < width
67+
)
68+
tail = " " * (width - chip_end - len(shades))
69+
70+
yield chip[:chip_end], shades, tail
71+
72+
73+
class IndentedBlock:
74+
"""Indent a renderable, hanging a prefix (e.g. an emoji bullet) on the
75+
first line.
76+
77+
Blank lines stay truly empty: live renders (inputs, menus) don't end
78+
with a newline, so any padding on a final blank line would leave the
79+
terminal cursor mid-line and shift whatever gets printed next.
80+
"""
81+
82+
def __init__(
83+
self,
84+
renderable: RenderableType,
85+
*,
86+
first_prefix: Text,
87+
prefix: Text,
88+
) -> None:
89+
self.renderable = renderable
90+
self.first_prefix = first_prefix
91+
self.prefix = prefix
92+
93+
# Text renders its `end` ("\n" by default), which would break lines
94+
self.first_prefix.end = ""
95+
self.prefix.end = ""
96+
97+
def __rich_console__(
98+
self, console: Console, options: ConsoleOptions
99+
) -> RenderResult:
100+
prefix_width = max(self.first_prefix.cell_len, self.prefix.cell_len)
101+
lines = console.render_lines(
102+
self.renderable,
103+
options.update_width(options.max_width - prefix_width),
104+
pad=False,
105+
)
106+
107+
new_line = Segment.line()
108+
109+
for first, last, line in loop_first_last(lines):
110+
if any(segment.text.strip() for segment in line):
111+
yield from console.render(
112+
self.first_prefix if first else self.prefix, options
113+
)
114+
yield from line
115+
elif last:
116+
# a zero-width space stops live renders from stripping the
117+
# final blank line, keeping the cursor at column 0
118+
yield Segment("​")
119+
120+
yield new_line
121+
122+
123+
class FastAPIStyle(BaseStyle):
124+
"""Header chip + uniform indent, without the per-line tag gutter.
125+
126+
Titles render as a single chip at the top of the command's output and
127+
everything else gets a fixed left indent, so renderables don't need to
128+
be wrapped in `Padding` manually. Emojis (`emoji=` metadata, or the
129+
progress animation/done emoji) hang to the left of the text like list
130+
bullets.
131+
132+
This is the shared style used by both fastapi-cli and fastapi-cloud-cli.
133+
"""
134+
135+
content_padding = 1
136+
emoji_column_width = 3
137+
138+
animation_emojis = [
139+
"🥚",
140+
"🐣",
141+
"🐤",
142+
"🐥",
143+
"🐓",
144+
"🐔",
145+
]
146+
147+
def render_element(
148+
self,
149+
element: Any,
150+
is_active: bool = False,
151+
done: bool = False,
152+
parent: Element | None = None,
153+
**kwargs: Any,
154+
) -> RenderableType:
155+
rendered = super().render_element(
156+
element=element, is_active=is_active, done=done, parent=parent, **kwargs
157+
)
158+
159+
# progress log lines and container children are already part of
160+
# their parent's render, which gets indented as a whole
161+
if isinstance(parent, (Progress, Container)):
162+
return rendered
163+
164+
metadata = kwargs
165+
if isinstance(element, Element) and element.metadata:
166+
metadata = {**element.metadata, **metadata}
167+
168+
# Input.ask wraps the element in a metadata-less Container; pull the
169+
# child's metadata so flags like bullet= still apply
170+
if isinstance(element, Container) and element.elements:
171+
child = element.elements[0]
172+
if isinstance(child, Element) and child.metadata:
173+
metadata = {**child.metadata, **metadata}
174+
175+
if metadata.get("title", False):
176+
return self._render_title(element, metadata)
177+
178+
if isinstance(element, Progress):
179+
emoji = self._get_progress_status_emoji(element, done)
180+
else:
181+
emoji = metadata.get("emoji", "")
182+
183+
if not emoji and not metadata.get("bullet", True):
184+
# skip the bullet column and align with the title chip's text
185+
indent = Text(" " * (self.title_padding + 1))
186+
return IndentedBlock(rendered, first_prefix=indent, prefix=indent)
187+
188+
return self._render_with_emoji_bullet(rendered, emoji)
189+
190+
@property
191+
def title_padding(self) -> int:
192+
# align the chip with the emoji bullet column
193+
return self.content_padding
194+
195+
def _render_title(self, title: Any, metadata: dict[str, Any]) -> RenderableType:
196+
tag = metadata.get("tag", "")
197+
198+
if metadata.get("animate", False):
199+
self._animate_title_sweep(tag or title)
200+
201+
chip = Padding(
202+
Text(f" {tag or title} ", style="tag.title"),
203+
(0, 0, 0, self.title_padding),
204+
expand=False,
205+
)
206+
207+
if not (tag and title):
208+
return chip
209+
210+
title_text = self._render_with_emoji_bullet(
211+
Text.from_markup(f"[bold]{title}[/bold]"),
212+
metadata.get("emoji", ""),
213+
)
214+
215+
return Group(chip, "", title_text)
216+
217+
def _animate_title_sweep(self, text: str) -> None:
218+
"""Sweep a gradient across the chip's line right before it prints,
219+
painting the chip background in behind the light."""
220+
if not self.console.is_terminal or is_ci_enabled():
221+
return
222+
223+
indent = " " * self.title_padding
224+
# the shades sweep in the chip's background color over the bare
225+
# terminal, so the solid trailing edge blends into the painted chip
226+
sweep_style = Style(color=self.console.get_style("tag.title").bgcolor)
227+
228+
self.console.show_cursor(False)
229+
try:
230+
for chip, shades, tail in _title_sweep_frames(text):
231+
self.console.print(
232+
Text.assemble(
233+
indent, (chip, "tag.title"), (shades, sweep_style), tail
234+
),
235+
end="\r",
236+
)
237+
time.sleep(TITLE_SWEEP_DELAY)
238+
finally:
239+
self.console.show_cursor(True)
240+
241+
def _get_progress_status_emoji(self, element: Progress, done: bool) -> str:
242+
if element._cancelled:
243+
return "🟡"
244+
245+
if element.is_error:
246+
return ERROR_BULLET
247+
248+
if done:
249+
return cast(str, element.metadata.get("done_emoji", "🐔"))
250+
251+
if emoji := element.metadata.get("emoji"):
252+
return cast(str, emoji)
253+
254+
return self.animation_emojis[
255+
self.animation_counter % len(self.animation_emojis)
256+
]
257+
258+
def _render_with_emoji_bullet(
259+
self, rendered: RenderableType, emoji: str
260+
) -> RenderableType:
261+
return IndentedBlock(
262+
rendered,
263+
first_prefix=self._get_bullet_prefix(emoji),
264+
prefix=Text(" " * (self.content_padding + self.emoji_column_width)),
265+
)
266+
267+
def _get_bullet_prefix(self, emoji: str) -> Text:
268+
prefix = Text(" " * self.content_padding)
269+
270+
if emoji:
271+
prefix.append_text(Text.from_markup(emoji))
272+
273+
prefix.pad_right(
274+
self.content_padding + self.emoji_column_width - prefix.cell_len
275+
)
276+
277+
return prefix
278+
279+
def get_cursor_offset_for_element(
280+
self, element: Element, parent: Element | None = None
281+
) -> CursorOffset:
282+
has_bullet_column = bool(element.metadata.get("emoji")) or element.metadata.get(
283+
"bullet", True
284+
)
285+
286+
decoration_width = (
287+
self.content_padding + self.emoji_column_width
288+
if has_bullet_column
289+
else self.title_padding + 1
290+
)
291+
292+
offset = element.cursor_offset
293+
top = offset.top
294+
left = decoration_width + offset.left
295+
296+
if isinstance(element, Input) and not element.inline and element.label:
297+
label_lines = self._count_label_lines(
298+
element.label, decoration_width=decoration_width
299+
)
300+
top = label_lines + 1
301+
302+
return CursorOffset(top=top, left=left)
303+
9304

10305
class CustomFormatter(DefaultFormatter):
11306
def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -21,11 +316,6 @@ def formatMessage(self, record: logging.LogRecord) -> str:
21316
return result
22317

23318

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-
29319
def get_uvicorn_log_config() -> dict[str, Any]:
30320
return {
31321
"version": 1,
@@ -69,9 +359,6 @@ def get_uvicorn_log_config() -> dict[str, Any]:
69359
}
70360

71361

72-
logger = logging.getLogger(__name__)
73-
74-
75362
def get_rich_toolkit() -> RichToolkit:
76363
theme = RichToolkitTheme(
77364
style=TaggedStyle(tag_width=11),

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)