Coverage for src/secchi/web.py: 97%

39 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 23:28 +0000

1"""Browser dashboard launcher backed by Textual's local web server.""" 

2 

3from __future__ import annotations 

4 

5import shlex 

6import shutil 

7import subprocess 

8import sys 

9from dataclasses import dataclass 

10from pathlib import Path 

11 

12from secchi.workflows.dashboard import DashboardRequest 

13 

14 

15class TextualServeUnavailable(RuntimeError): 

16 """Raised when the Textual CLI is not installed or not on PATH.""" 

17 

18 

19@dataclass(frozen=True) 

20class WebDashboardLaunch: 

21 """Details for a generated Textual serve launch.""" 

22 

23 command: list[str] 

24 

25 

26def build_dashboard_command( 

27 request: DashboardRequest, 

28 *, 

29 package: str | None = None, 

30 registry: str | None = None, 

31 project_name: str | None = None, 

32 refresh: bool = False, 

33 security_refresh: bool = False, 

34 verbose: bool = False, 

35 log_file: Path | None = None, 

36) -> list[str]: 

37 """Build the dashboard command Textual serve should run.""" 

38 

39 command = [sys.executable, "-m", "secchi", "dashboard"] 

40 if package: 

41 command.append(package) 

42 if registry: 

43 command.extend(["--registry", registry]) 

44 if request.config_path.exists(): 

45 command.extend(["--config", str(request.config_path)]) 

46 if project_name: 

47 command.extend(["--project", project_name]) 

48 if refresh: 

49 command.append("--no-cache") 

50 elif security_refresh: 

51 command.append("--security-no-cache") 

52 if verbose: 

53 command.append("--verbose") 

54 if log_file: 

55 command.extend(["--log-file", str(log_file)]) 

56 return command 

57 

58 

59def prepare_launch( 

60 request: DashboardRequest, 

61 *, 

62 package: str | None = None, 

63 registry: str | None = None, 

64 project_name: str | None = None, 

65 refresh: bool = False, 

66 security_refresh: bool = False, 

67 verbose: bool = False, 

68 log_file: Path | None = None, 

69) -> WebDashboardLaunch: 

70 """Prepare a local Textual serve launch without starting a subprocess.""" 

71 

72 command = build_dashboard_command( 

73 request, 

74 package=package, 

75 registry=registry, 

76 project_name=project_name, 

77 refresh=refresh, 

78 security_refresh=security_refresh, 

79 verbose=verbose, 

80 log_file=log_file, 

81 ) 

82 return WebDashboardLaunch(command=command) 

83 

84 

85def run_textual_serve(command: list[str], *, port: int = 8000) -> None: 

86 """Run the dashboard through Textual's local browser server.""" 

87 

88 textual = shutil.which("textual") 

89 if textual is None: 

90 raise TextualServeUnavailable( 

91 "Browser dashboard support requires the Textual CLI. " 

92 "Run `uv sync` and use `uv run secchi web`, or install Secchi " 

93 "with a Textual version that provides `textual serve`." 

94 ) 

95 subprocess.run( 

96 [ 

97 textual, 

98 "serve", 

99 "--title", 

100 "Secchi", 

101 "--port", 

102 str(port), 

103 "--command", 

104 shlex.join(command), 

105 ], 

106 check=True, 

107 )