11import logging
2+ import os
23import 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
514from 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
720from 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
10305class 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-
29319def 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-
75362def get_rich_toolkit () -> RichToolkit :
76363 theme = RichToolkitTheme (
77364 style = TaggedStyle (tag_width = 11 ),
0 commit comments