Coverage for tests/test_cli.py: 100%

282 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-09 00:25 +0000

1import io 1abcdefg

2import json 1abcdefg

3import subprocess 1abcdefg

4import sys 1abcdefg

5from collections.abc import Callable, Iterator 1abcdefg

6from datetime import datetime, timezone 1abcdefg

7from inspect import signature 1abcdefg

8from types import FunctionType 1abcdefg

9from typing import Any 1abcdefg

10 

11import pytest 1abcdefg

12import typer 1abcdefg

13from pydantic import BaseModel 1abcdefg

14from rich.console import Console, RenderableType 1abcdefg

15from rich.text import Text 1abcdefg

16from rich.theme import Theme 1abcdefg

17from rich_toolkit import RichToolkit 1abcdefg

18from rich_toolkit.container import Container 1abcdefg

19from rich_toolkit.element import Element 1abcdefg

20from rich_toolkit.input import Input 1abcdefg

21from rich_toolkit.progress import Progress 1abcdefg

22from typer.testing import CliRunner 1abcdefg

23 

24from fastapi_cloud_cli.cli import app 1abcdefg

25from fastapi_cloud_cli.utils import cli as cli_utils 1abcdefg

26from fastapi_cloud_cli.utils.cli import ( 1abcdefg

27 ERROR_BULLET, 

28 FastAPIRichToolkit, 

29 FastAPIStyle, 

30 IndentedBlock, 

31 get_details_table, 

32 get_rich_toolkit, 

33) 

34from fastapi_cloud_cli.utils.config import get_version_check_cache_path 1abcdefg

35from fastapi_cloud_cli.utils.errors import ErrorCode 1abcdefg

36from fastapi_cloud_cli.utils.execution import JsonOutputOption 1abcdefg

37from fastapi_cloud_cli.utils.version_check import write_latest_version_cache 1abcdefg

38 

39runner = CliRunner() 1abcdefg

40 

41 

42def _get_command_name(callback: FunctionType, configured_name: str | None) -> str: 1abcdefg

43 if configured_name is not None: 1abcdefg

44 return configured_name 1abcdefg

45 

46 return callback.__name__.replace("_", "-") 1abcdefg

47 

48 

49def _iter_cli_commands( 1abcdefg

50 typer_app: typer.Typer, path: tuple[str, ...] = () 

51) -> Iterator[tuple[str, FunctionType]]: 

52 for command_info in typer_app.registered_commands: 1abcdefg

53 assert isinstance(command_info.callback, FunctionType) 1abcdefg

54 callback = command_info.callback 1abcdefg

55 command_path = ( 1abcdefg

56 *path, 

57 _get_command_name(callback, command_info.name), 

58 ) 

59 

60 yield " ".join(command_path), callback 1abcdefg

61 

62 for group_info in typer_app.registered_groups: 1abcdefg

63 group_name = group_info.name 1abcdefg

64 group_app = group_info.typer_instance 1abcdefg

65 

66 assert isinstance(group_name, str) 1abcdefg

67 assert isinstance(group_app, typer.Typer) 1abcdefg

68 

69 yield from _iter_cli_commands(group_app, (*path, group_name)) 1abcdefg

70 

71 

72def _has_json_output_option(callback: Callable[..., Any]) -> bool: 1abcdefg

73 return any( 2zcAcBcCcDcEcFc

74 parameter.annotation == JsonOutputOption 

75 for parameter in signature(callback).parameters.values() 

76 ) 

77 

78 

79class AuthStatus(BaseModel): 1abcdefg

80 authenticated: bool 1abcdefg

81 

82 

83class MetricsOutput(BaseModel): 1abcdefg

84 cpu: float 1abcdefg

85 

86 

87def render_plain(renderable: RenderableType, *, width: int = 80) -> str: 1abcdefg

88 console = Console(width=width, color_system=None) 2h o 6bEbFb-bi p 7bGbHb.bj q 8bIbJb/bk r 9bKbLb:bl s !bMbNb;bm t #bObPb=bn u $bQbRb?b

89 return "".join( 2h o 6bEbFb-bi p 7bGbHb.bj q 8bIbJb/bk r 9bKbLb:bl s !bMbNb;bm t #bObPb=bn u $bQbRb?b

90 segment.text for segment in console.render(renderable, console.options) 

91 ) 

92 

93 

94def style_with_recording_console(*, force_terminal: bool) -> FastAPIStyle: 1abcdefg

95 style = FastAPIStyle() 2xbSbybTbzbUbAbVbBbWbCbXbDbYb

96 style.console = Console( 2xbSbybTbzbUbAbVbBbWbCbXbDbYb

97 force_terminal=force_terminal, 

98 record=True, 

99 width=20, 

100 color_system=None, 

101 theme=Theme({"tag.title": "#ffffff on #009485"}), 

102 ) 

103 return style 2xbSbybTbzbUbAbVbBbWbCbXbDbYb

104 

105 

106def test_shows_help() -> None: 1abcdefg

107 result = runner.invoke(app, ["cloud", "--help"]) 2@b[b]b^b_b`b{b

108 

109 assert result.exit_code == 0 2@b[b]b^b_b`b{b

110 assert "Usage:" in result.output 2@b[b]b^b_b`b{b

111 

112 

113def test_shows_help_without_args() -> None: 1abcdefg

114 result = runner.invoke(app, ["cloud"]) 2|b}b~bacbcccdc

115 

116 assert result.exit_code == 2 2|b}b~bacbcccdc

117 assert "Usage:" in result.output 2|b}b~bacbcccdc

118 

119 

120def test_script() -> None: 1abcdefg

121 result = subprocess.run( 2GcHcIcJcKcLcMc

122 [sys.executable, "-m", "coverage", "run", "-m", "fastapi_cloud_cli", "--help"], 

123 capture_output=True, 

124 encoding="utf-8", 

125 ) 

126 assert "Usage" in result.stdout 2GcHcIcJcKcLcMc

127 

128 

129def test_version() -> None: 1abcdefg

130 result = runner.invoke(app, ["cloud", "--version"]) 2ecfcgchcicjckc

131 assert result.exit_code == 0, result.output 2ecfcgchcicjckc

132 assert "FastAPI Cloud CLI version:" in result.output 2ecfcgchcicjckc

133 

134 

135@pytest.mark.parametrize( 1abcdefg

136 ("command_path", "callback"), 

137 [ 

138 pytest.param(command_path, callback, id=command_path) 

139 for command_path, callback in _iter_cli_commands(app) 

140 ], 

141) 

142def test_cli_commands_include_json_output_option( 1abcdefg

143 command_path: str, callback: Callable[..., Any] 

144) -> None: 

145 assert _has_json_output_option(callback), ( 2zcAcBcCcDcEcFc

146 f"{command_path} is missing JsonOutputOption" 

147 ) 

148 

149 

150def test_uses_header_style() -> None: 1abcdefg

151 toolkit = get_rich_toolkit() 2lcmcncocpcqcrc

152 assert isinstance(toolkit, FastAPIRichToolkit) 2lcmcncocpcqcrc

153 assert isinstance(toolkit.style, FastAPIStyle) 2lcmcncocpcqcrc

154 

155 

156def test_title_sweep_frames_keep_constant_chip_width() -> None: 1abcdefg

157 chip = " Deploy " 2Zb0b1b2b3b4b5b

158 frames = list(cli_utils._title_sweep_frames("Deploy")) 2Zb0b1b2b3b4b5b

159 

160 assert frames[0] == ("", "", " " * len(chip)) 2Zb0b1b2b3b4b5b

161 assert frames[-1] == (chip, "", "") 2Zb0b1b2b3b4b5b

162 assert all(len("".join(frame)) == len(chip) for frame in frames) 2Zb0b1b2b3b4b5b

163 assert any(shades for _, shades, _ in frames) 2Zb0b1b2b3b4b5b

164 

165 

166def test_strip_rich_markup_preserves_empty_hint() -> None: 1abcdefg

167 assert cli_utils._strip_rich_markup(None) is None 2NcOcPcQcRcScTc

168 

169 

170def test_indented_block_hangs_prefix_and_preserves_final_blank_line() -> None: 1abcdefg

171 block = IndentedBlock( 2-b.b/b:b;b=b?b

172 Text("alpha\nbeta\n"), 

173 first_prefix=Text("> "), 

174 prefix=Text("| "), 

175 ) 

176 

177 assert render_plain(block, width=20) == "> alpha\n| beta\n\u200b\n" 2-b.b/b:b;b=b?b

178 

179 

180def test_fastapi_style_returns_parent_rendered_children_without_extra_indent() -> None: 1abcdefg

181 style = FastAPIStyle() 2sctcucvcwcxcyc

182 parent = Container(style=style) 2sctcucvcwcxcyc

183 

184 assert style.render_element("child", parent=parent) == "child" 2sctcucvcwcxcyc

185 

186 

187def test_fastapi_style_uses_child_metadata_from_container() -> None: 1abcdefg

188 style = FastAPIStyle() 2EbGbIbKbMbObQb

189 container = Container(style=style) 2EbGbIbKbMbObQb

190 container.elements = [Input(value="abc", style=style, bullet=False)] 2EbGbIbKbMbObQb

191 

192 assert ( 2EbGbIbKbMbObQb

193 render_plain(style.render_element(container), width=20) == " abc\n\n\u200b\n" 

194 ) 

195 

196 

197def test_fastapi_style_renders_title_chip_and_tagged_title() -> None: 1abcdefg

198 style = FastAPIStyle(theme={"tag.title": "#ffffff on #009485"}) 26b7b8b9b!b#b$b

199 

200 assert render_plain(style.render_element("Deploy", title=True)) == " Deploy \n" 26b7b8b9b!b#b$b

201 assert ( 26b7b8b9b!b#b$b

202 render_plain( 

203 style.render_element("Deploy app", title=True, tag="cloud", emoji="🚀") 

204 ) 

205 == " cloud \n\n 🚀 Deploy app\n" 

206 ) 

207 

208 

209def test_fastapi_style_skips_title_animation_for_non_terminal_console() -> None: 1abcdefg

210 style = style_with_recording_console(force_terminal=False) 2SbTbUbVbWbXbYb

211 

212 style.render_element("Deploy", title=True, animate=True) 2SbTbUbVbWbXbYb

213 

214 assert style.console.export_text() == "" 2SbTbUbVbWbXbYb

215 

216 

217def test_fastapi_style_animates_title_sweep_in_terminal( 1abcdefg

218 monkeypatch: pytest.MonkeyPatch, 

219) -> None: 

220 monkeypatch.setattr(cli_utils, "TITLE_SWEEP_DELAY", 0) 2xbybzbAbBbCbDb

221 style = style_with_recording_console(force_terminal=True) 2xbybzbAbBbCbDb

222 

223 style._animate_title_sweep("Go") 2xbybzbAbBbCbDb

224 

225 assert " Go " in style.console.export_text() 2xbybzbAbBbCbDb

226 

227 

228def test_fastapi_style_renders_progress_and_bullet_prefixes() -> None: 1abcdefg

229 style = FastAPIStyle() 1opqrstu

230 progress = Progress("Working", style=style, console=style.console) 1opqrstu

231 

232 assert render_plain(style.render_element("plain", bullet=False)) == " plain\n" 1opqrstu

233 assert render_plain(style.render_element("plain", emoji="[green]✓[/]")) == ( 1opqrstu

234 " ✓ plain\n" 

235 ) 

236 assert render_plain( 1opqrstu

237 style.render_element(Input(value="abc", style=style, emoji="🚀")) 

238 ) == (" 🚀 abc\n") 

239 assert render_plain(style.render_element(progress)) == " 🐤 Working\n" 1opqrstu

240 assert style._get_bullet_prefix("").plain == " " 1opqrstu

241 assert style._get_bullet_prefix("🚀").plain == " 🚀 " 1opqrstu

242 

243 

244def test_fastapi_style_progress_status_emoji_states() -> None: 1abcdefg

245 style = FastAPIStyle() 1hijklmn

246 progress = Progress( 1hijklmn

247 "Working", 

248 style=style, 

249 console=style.console, 

250 ) 

251 progress.metadata["done_emoji"] = "✅" 1hijklmn

252 style.animation_counter = 0 1hijklmn

253 

254 assert style._get_progress_status_emoji(progress, done=False) == "🥚" 1hijklmn

255 assert style._get_progress_status_emoji(progress, done=True) == "✅" 1hijklmn

256 

257 progress.metadata["emoji"] = "🤏" 1hijklmn

258 assert style._get_progress_status_emoji(progress, done=False) == "🤏" 1hijklmn

259 assert style._get_progress_status_emoji(progress, done=True) == "✅" 1hijklmn

260 

261 progress._cancelled = True 1hijklmn

262 assert style._get_progress_status_emoji(progress, done=False) == "🟡" 1hijklmn

263 

264 progress._cancelled = False 1hijklmn

265 progress.is_error = True 1hijklmn

266 assert style._get_progress_status_emoji(progress, done=False) == ERROR_BULLET 1hijklmn

267 assert render_plain(style.render_element(progress)) == " ✗ Working\n" 1hijklmn

268 

269 

270@pytest.mark.parametrize("encoding", ["ascii", "cp1252", "gbk", "utf-8"]) 1abcdefg

271@pytest.mark.parametrize("emoji", ["📁", "⚡", ERROR_BULLET, "✅", "🟡", "🐤"]) 1abcdefg

272def test_bullets_can_be_written_to_encoded_streams(encoding: str, emoji: str) -> None: 1abcdefg

273 style = FastAPIStyle() 1vwxyzAB

274 with io.TextIOWrapper( 1vwxyzAB

275 io.BytesIO(), encoding=encoding, errors="strict", newline="\n" 

276 ) as stream: 

277 style.console.file = stream 1vwxyzAB

278 style.console.print(style.render_element("Working\nSecond line", emoji=emoji)) 1vwxyzAB

279 stream.flush() 1vwxyzAB

280 

281 expected_bullet = Text.from_markup(emoji).plain 1vwxyzAB

282 if encoding != "utf-8": 1vwxyzAB

283 expected_bullet = " x" if emoji == ERROR_BULLET else "*" 1vwxyzAB

284 padding = " " * (3 - Text(expected_bullet).cell_len) 1vwxyzAB

285 assert stream.buffer.getvalue().decode(encoding) == ( 1vwxyzAB

286 f" {expected_bullet}{padding}Working\n Second line\n" 

287 ) 

288 

289 

290@pytest.mark.parametrize("encoding", ["cp1252", "gbk"]) 1abcdefg

291def test_bullet_fallback_preserves_supported_text_and_layout(encoding: str) -> None: 1abcdefg

292 style = FastAPIStyle() 1CDEFGHI

293 with io.TextIOWrapper( 1CDEFGHI

294 io.BytesIO(), encoding=encoding, errors="strict", newline="\n" 

295 ) as stream: 

296 style.console.file = stream 1CDEFGHI

297 text = "café" if encoding == "cp1252" else "项目" 1CDEFGHI

298 style.console.print(style.render_element(text, emoji="*")) 1CDEFGHI

299 style.console.print(style.render_element("alpha\nbeta", bullet=False)) 1CDEFGHI

300 style.console.print( 1CDEFGHI

301 style.render_element(get_details_table([("Name", text)]), bullet=False) 

302 ) 

303 stream.flush() 1CDEFGHI

304 

305 assert stream.buffer.getvalue().decode(encoding) == ( 1CDEFGHI

306 f" * {text}\n alpha\n beta\n Name {text}\n" 

307 ) 

308 assert ( 1CDEFGHI

309 style.get_cursor_offset_for_element( 

310 Input(label="Project name", style=style, emoji="📁") 

311 ).left 

312 == 4 

313 ) 

314 

315 

316@pytest.mark.parametrize("encoding", ["cp1252", "gbk"]) 1abcdefg

317def test_error_bullet_fallback_keeps_error_style(encoding: str) -> None: 1abcdefg

318 style = FastAPIStyle() 1#$%'()*

319 with io.TextIOWrapper(io.BytesIO(), encoding=encoding) as stream: 1#$%'()*

320 style.console.file = stream 1#$%'()*

321 prefix = style._get_bullet_prefix(ERROR_BULLET) 1#$%'()*

322 

323 assert prefix.plain == " x " 1#$%'()*

324 error_style = prefix.get_style_at_offset(style.console, 2) 1#$%'()*

325 assert error_style.bold 1#$%'()*

326 assert error_style.color == style.console.get_style("error").color 1#$%'()*

327 

328 

329def test_fastapi_style_gets_cursor_offset_with_and_without_bullet_column() -> None: 1abcdefg

330 style = FastAPIStyle() 1JKLMNOP

331 

332 offset = style.get_cursor_offset_for_element(Element(metadata={"emoji": "🚀"})) 1JKLMNOP

333 assert offset.top == 0 1JKLMNOP

334 assert offset.left == 4 1JKLMNOP

335 

336 offset = style.get_cursor_offset_for_element(Element(metadata={"bullet": False})) 1JKLMNOP

337 assert offset.top == 0 1JKLMNOP

338 assert offset.left == 2 1JKLMNOP

339 

340 input_offset = style.get_cursor_offset_for_element( 1JKLMNOP

341 Input(label="Project name", style=style) 

342 ) 

343 assert input_offset.top == 2 1JKLMNOP

344 assert input_offset.left == 4 1JKLMNOP

345 

346 

347def test_toolkit_success_delegates_human_output_renderer( 1abcdefg

348 monkeypatch: pytest.MonkeyPatch, 

349) -> None: 

350 toolkit = get_rich_toolkit(minimal=True) 1+,-./:;

351 calls: list[tuple[object, object | None]] = [] 1+,-./:;

352 

353 def record_output(data: object, render_output: object | None = None) -> None: 1+,-./:;

354 calls.append((data, render_output)) 1+,-./:;

355 

356 def render_output(data: AuthStatus, toolkit: RichToolkit) -> None: ... 1+,-./:;

357 

358 monkeypatch.setattr(toolkit, "output", record_output) 1+,-./:;

359 

360 toolkit.success(AuthStatus(authenticated=True), render_output=render_output) 1+,-./:;

361 

362 assert calls == [(AuthStatus(authenticated=True), render_output)] 1+,-./:;

363 

364 

365def test_get_details_table_renders_label_value_rows() -> None: 1abcdefg

366 table = get_details_table( 2FbHbJbLbNbPbRb

367 [ 

368 ("Name", "example"), 

369 ("Region", Text("iad")), 

370 ] 

371 ) 

372 

373 assert table.row_count == 2 2FbHbJbLbNbPbRb

374 assert "Name" in render_plain(table) 2FbHbJbLbNbPbRb

375 assert "iad" in render_plain(table) 2FbHbJbLbNbPbRb

376 

377 

378def test_toolkit_success_prints_json_envelope() -> None: 1abcdefg

379 test_app = typer.Typer() 1=?@[]^_

380 

381 @test_app.command() 1=?@[]^_

382 def command(json_output: JsonOutputOption = False) -> None: 1=?@[]^_

383 with get_rich_toolkit(minimal=True, json_output=json_output) as toolkit: 1=?@[]^_

384 toolkit.success(AuthStatus(authenticated=True), hint="next step") 1=?@[]^_

385 

386 result = runner.invoke(test_app, env={"FASTAPI_CLOUD_JSON": "1"}) 1=?@[]^_

387 

388 assert result.exit_code == 0 1=?@[]^_

389 assert json.loads(result.stdout) == { 1=?@[]^_

390 "data": {"authenticated": True}, 

391 "hint": "next step", 

392 } 

393 

394 

395def test_toolkit_success_omits_empty_json_envelope_fields() -> None: 1abcdefg

396 test_app = typer.Typer() 2` { | } ~ abbb

397 

398 @test_app.command() 2` { | } ~ abbb

399 def command(json_output: JsonOutputOption = False) -> None: 2` { | } ~ abbb

400 with get_rich_toolkit(minimal=True, json_output=json_output) as toolkit: 2` { | } ~ abbb

401 toolkit.success(AuthStatus(authenticated=True)) 2` { | } ~ abbb

402 

403 result = runner.invoke(test_app, env={"FASTAPI_CLOUD_JSON": "1"}) 2` { | } ~ abbb

404 

405 assert result.exit_code == 0 2` { | } ~ abbb

406 assert json.loads(result.stdout) == { 2` { | } ~ abbb

407 "data": {"authenticated": True}, 

408 } 

409 

410 

411def test_toolkit_success_includes_non_empty_warnings() -> None: 1abcdefg

412 test_app = typer.Typer() 2cbdbebfbgbhbib

413 

414 @test_app.command() 2cbdbebfbgbhbib

415 def command(json_output: JsonOutputOption = False) -> None: 2cbdbebfbgbhbib

416 with get_rich_toolkit(minimal=True, json_output=json_output) as toolkit: 2cbdbebfbgbhbib

417 toolkit.success( 2cbdbebfbgbhbib

418 AuthStatus(authenticated=True), 

419 warnings=[{"code": "not_validated", "message": "Token not validated."}], 

420 ) 

421 

422 result = runner.invoke(test_app, env={"FASTAPI_CLOUD_JSON": "1"}) 2cbdbebfbgbhbib

423 

424 assert result.exit_code == 0 2cbdbebfbgbhbib

425 assert json.loads(result.stdout) == { 2cbdbebfbgbhbib

426 "data": {"authenticated": True}, 

427 "warnings": [{"code": "not_validated", "message": "Token not validated."}], 

428 } 

429 

430 

431def test_toolkit_success_uses_strict_json_output( 1abcdefg

432 monkeypatch: pytest.MonkeyPatch, 

433) -> None: 

434 monkeypatch.setenv("FASTAPI_CLOUD_JSON", "1") 2%b'b(b)b*b+b,b

435 monkeypatch.setenv("FASTAPI_CLOUD_DISABLE_VERSION_CHECK", "1") 2%b'b(b)b*b+b,b

436 

437 with get_rich_toolkit(minimal=True, json_output=True) as toolkit: 2%b'b(b)b*b+b,b

438 with pytest.raises(ValueError, match="Out of range float values"): 2%b'b(b)b*b+b,b

439 toolkit.success(MetricsOutput(cpu=float("inf"))) 2%b'b(b)b*b+b,b

440 

441 

442def test_toolkit_json_mode_suppresses_update_message( 1abcdefg

443 monkeypatch: pytest.MonkeyPatch, 

444) -> None: 

445 test_app = typer.Typer() 1QRSTUVW

446 monkeypatch.setenv("FASTAPI_CLOUD_DISABLE_VERSION_CHECK", "") 1QRSTUVW

447 write_latest_version_cache( 1QRSTUVW

448 get_version_check_cache_path(), 

449 latest_version="999.0.0", 

450 now=datetime.now(timezone.utc), 

451 ) 

452 

453 @test_app.command() 1QRSTUVW

454 def command(json_output: JsonOutputOption = False) -> None: 1QRSTUVW

455 with get_rich_toolkit(minimal=True, json_output=json_output) as toolkit: 1QRSTUVW

456 toolkit.success(AuthStatus(authenticated=True)) 1QRSTUVW

457 

458 result = runner.invoke(test_app, env={"FASTAPI_CLOUD_JSON": "1"}) 1QRSTUVW

459 

460 assert result.exit_code == 0 1QRSTUVW

461 assert json.loads(result.stdout) == { 1QRSTUVW

462 "data": {"authenticated": True}, 

463 } 

464 

465 

466def test_toolkit_fail_prints_json_error_and_exits() -> None: 1abcdefg

467 test_app = typer.Typer() 2jbkblbmbnbobpb

468 

469 @test_app.command() 2jbkblbmbnbobpb

470 def command(json_output: JsonOutputOption = False) -> None: 2jbkblbmbnbobpb

471 with get_rich_toolkit(minimal=True, json_output=json_output) as toolkit: 2jbkblbmbnbobpb

472 toolkit.fail( 2jbkblbmbnbobpb

473 "api_error", 

474 "[bold]A value is required.[/]", 

475 hint="Pass [blue]--value[/].", 

476 ) 

477 

478 result = runner.invoke(test_app, env={"FASTAPI_CLOUD_JSON": "1"}) 2jbkblbmbnbobpb

479 

480 assert result.exit_code == 1 2jbkblbmbnbobpb

481 assert json.loads(result.stdout) == { 2jbkblbmbnbobpb

482 "error": { 

483 "code": "api_error", 

484 "message": "A value is required.", 

485 "hint": "Pass --value.", 

486 } 

487 } 

488 

489 

490def test_toolkit_fail_uses_error_emoji( 1abcdefg

491 monkeypatch: pytest.MonkeyPatch, 

492) -> None: 

493 calls: list[dict[str, object]] = [] 2qbrbsbtbubvbwb

494 

495 with get_rich_toolkit() as toolkit: 2qbrbsbtbubvbwb

496 

497 def record_print( 2qbrbsbtbubvbwb

498 *renderables: object, 

499 end: str = "\n", 

500 **metadata: object, 

501 ) -> None: 

502 calls.append(metadata) 2qbrbsbtbubvbwb

503 

504 monkeypatch.setattr(toolkit, "print", record_print) 2qbrbsbtbubvbwb

505 

506 with pytest.raises(typer.Exit): 2qbrbsbtbubvbwb

507 toolkit.fail("api_error", "A value is required.") 2qbrbsbtbubvbwb

508 

509 assert calls[0]["emoji"] == ERROR_BULLET 2qbrbsbtbubvbwb

510 

511 

512def test_toolkit_fail_uses_custom_human_output_renderer_and_exits() -> None: 1abcdefg

513 test_app = typer.Typer() 1XYZ0123

514 

515 def render_output( 1XYZ0123

516 toolkit: RichToolkit, 

517 *, 

518 code: ErrorCode, 

519 message: str, 

520 hint: str, 

521 ) -> None: 

522 toolkit.print(f"{code}: {message} {hint}") 1XYZ0123

523 

524 @test_app.command() 1XYZ0123

525 def command() -> None: 1XYZ0123

526 with get_rich_toolkit(minimal=True) as toolkit: 1XYZ0123

527 toolkit.fail( 1XYZ0123

528 "not_logged_in", 

529 "No credentials found.", 

530 render_output=render_output, 

531 ) 

532 

533 result = runner.invoke(test_app) 1XYZ0123

534 

535 assert result.exit_code == 1 1XYZ0123

536 assert "not_logged_in: No credentials found." in result.output 1XYZ0123

537 

538 

539def test_embedded_fastapi_cli_prints_forced_update_message( 1abcdefg

540 monkeypatch: pytest.MonkeyPatch, 

541) -> None: 

542 parent_app = typer.Typer() 1456789!

543 parent_app.add_typer(app) 1456789!

544 monkeypatch.setattr(sys, "argv", ["fastapi", "cloud", "whoami"]) 1456789!

545 write_latest_version_cache( 1456789!

546 get_version_check_cache_path(), 

547 latest_version="999.0.0", 

548 now=datetime.now(timezone.utc), 

549 ) 

550 

551 result = runner.invoke( 1456789!

552 parent_app, 

553 ["cloud", "whoami"], 

554 env={"FASTAPI_CLOUD_DISABLE_VERSION_CHECK": ""}, 

555 ) 

556 

557 assert result.exit_code == 1, result.output 1456789!

558 assert "No credentials found" in result.output 1456789!

559 assert "A newer FastAPI Cloud CLI version is available" in result.output 1456789!

560 assert "→ 999.0.0" in result.output 1456789!