Coverage for seedboxsync/cli/cli.py: 91%

69 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 17:46 +0000

1# 

2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr> 

3# 

4# For the full copyright and license information, please view the LICENSE 

5# file that was distributed with this source code. 

6# 

7"""Cli module.""" 

8 

9from importlib import import_module 

10import os 

11from pathlib import Path 

12from typing import Any, ClassVar, cast 

13import click 

14from flask.cli import FlaskGroup 

15from seedboxsync.cli.context import Context 

16 

17 

18def pass_context(func: Any) -> Any: 

19 """ 

20 Decorate a callback to receive the custom SeedboxSync Click context. 

21 

22 Args: 

23 func: The callback to decorate. 

24 

25 Returns: 

26 The decorated callback. 

27 """ 

28 return click.pass_context(func) 

29 

30 

31class Cli(FlaskGroup): 

32 """ 

33 SeedboxSync command-line interface. 

34 

35 This class customizes Flask's default CLI by: 

36 - using the SeedboxSync context implementation; 

37 - hiding Flask-specific global options; 

38 - automatically loading commands from the ``commands`` package; 

39 - using custom command and group classes by default. 

40 """ 

41 

42 context_class = Context 

43 _CMD_FOLDER = Path(__file__).resolve().parent / "commands" 

44 _HIDDEN_FLASK_OPTIONS: ClassVar[frozenset[str]] = frozenset( 

45 { 

46 "--app", 

47 "-A", 

48 "--env-file", 

49 "-e", 

50 } 

51 ) 

52 

53 def __init__(self, *args: Any, **kwargs: Any) -> None: 

54 """ 

55 Initialize the CLI and remove Flask-specific global options. 

56 

57 Args: 

58 *args (Any): Positional arguments forwarded to ``FlaskGroup``. 

59 **kwargs (Any): Keyword arguments forwarded to ``FlaskGroup``. 

60 """ 

61 super().__init__(*args, **kwargs) 

62 

63 self.params = [parameter for parameter in self.params if not self._is_hidden_flask_option(parameter)] 

64 

65 def _is_hidden_flask_option(self, parameter: click.Parameter) -> bool: 

66 """ 

67 Determine whether a Click parameter is a hidden Flask option. 

68 

69 Args: 

70 parameter (click.Parameter): The Click parameter to inspect. 

71 

72 Returns: 

73 ``True`` if the parameter should be hidden, otherwise ``False``. 

74 """ 

75 if not isinstance(parameter, click.Option): 

76 return False 

77 

78 option_names = set(parameter.opts) | set(parameter.secondary_opts) 

79 

80 return bool(option_names & self._HIDDEN_FLASK_OPTIONS) 

81 

82 def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 

83 """ 

84 Parse command-line arguments without Flask's implicit global options. 

85 

86 Args: 

87 ctx (click.Context,): The Click context. 

88 args (list): The command-line arguments. 

89 

90 Returns: 

91 The parsed arguments. 

92 """ 

93 return click.Group.parse_args(self, ctx, args) 

94 

95 def command(self, *args: Any, **kwargs: Any) -> Any: 

96 """ 

97 Create a command using the custom SeedboxSync command class. 

98 

99 Args: 

100 *args (Any): Positional arguments forwarded to Click. 

101 **kwargs (Any): Keyword arguments forwarded to Click. 

102 

103 Returns: 

104 The decorated command. 

105 """ 

106 kwargs.setdefault("cls", Command) 

107 return super().command(*args, **kwargs) 

108 

109 def group(self, *args: Any, **kwargs: Any) -> Any: 

110 """ 

111 Create a group using the custom SeedboxSync group class. 

112 

113 Args: 

114 *args (Any): Positional arguments forwarded to Click. 

115 **kwargs (Any): Keyword arguments forwarded to Click. 

116 

117 Returns: 

118 The decorated group. 

119 """ 

120 kwargs.setdefault("cls", Group) 

121 return super().group(*args, **kwargs) 

122 

123 def list_commands(self, ctx: click.Context) -> list[str]: 

124 """ 

125 Return the list of available SeedboxSync commands. 

126 

127 Args: 

128 ctx (click.Context): The Click context. 

129 

130 Returns: 

131 A sorted list of available command names. 

132 """ 

133 rv = [] 

134 

135 for path in self._CMD_FOLDER.iterdir(): 

136 if path.is_file() and path.suffix == ".py" and path.stem.startswith("cmd_"): 

137 rv.append(path.stem[4:]) 

138 

139 rv.sort() 

140 return rv 

141 

142 def get_command(self, ctx: click.Context, name: str) -> click.Command | None: 

143 """ 

144 Load a command module dynamically. 

145 

146 Args: 

147 ctx (click.Context): The Click context. 

148 name (str): The command name. 

149 

150 Returns: 

151 The loaded Click command, or ``None`` if the command cannot be loaded. 

152 """ 

153 try: 

154 mod = import_module(f"seedboxsync.cli.commands.cmd_{name}") 

155 except ImportError as e: 

156 click.echo(e, err=True) 

157 click.echo(f"Command '{name}' not found.", err=True) 

158 return None 

159 return cast(click.Command, mod.cli) 

160 

161 def invoke(self, ctx: click.Context) -> Any: 

162 """ 

163 Invoke the selected command and handle user interruptions gracefully. 

164 

165 Args: 

166 ctx (click.Context): The Click context. 

167 

168 Returns: 

169 The command result. 

170 """ 

171 try: 

172 return super().invoke(ctx) 

173 except KeyboardInterrupt as exc: 

174 click.secho("\nInterrupted by user.", fg="yellow") 

175 raise ctx.exit(130) from exc 

176 

177 

178class Command(click.Command): 

179 """SeedboxSync Click command using the custom context implementation.""" 

180 

181 context_class = Context 

182 

183 

184class Group(click.Group): 

185 """SeedboxSync Click command group using the custom context implementation.""" 

186 

187 context_class = Context 

188 

189 def command(self, *args: Any, **kwargs: Any) -> Any: 

190 """ 

191 Create a command using the SeedboxSync command class by default. 

192 

193 Args: 

194 *args (Any): Positional arguments forwarded to Click. 

195 **kwargs (Any): Keyword arguments forwarded to Click. 

196 

197 Returns: 

198 The decorated command. 

199 """ 

200 kwargs.setdefault("cls", Command) 

201 return super().command(*args, **kwargs) 

202 

203 def group(self, *args: Any, **kwargs: Any) -> Any: 

204 """ 

205 Create a group using the SeedboxSync group class by default. 

206 

207 Args: 

208 *args (Any): Positional arguments forwarded to Click. 

209 **kwargs (Any): Keyword arguments forwarded to Click. 

210 

211 Returns: 

212 The decorated group. 

213 """ 

214 kwargs.setdefault("cls", Group) 

215 return super().group(*args, **kwargs) 

216 

217 

218def command(*args: Any, **kwargs: Any) -> Any: 

219 """ 

220 Create a Click command using the SeedboxSync command class. 

221 

222 Args: 

223 *args (Any): Positional arguments forwarded to Click. 

224 **kwargs (Any): Keyword arguments forwarded to Click. 

225 

226 Returns: 

227 The decorated command. 

228 """ 

229 kwargs.setdefault("cls", Command) 

230 return click.command(*args, **kwargs) 

231 

232 

233def group(*args: Any, **kwargs: Any) -> Any: 

234 """ 

235 Create a Click group using the SeedboxSync group class. 

236 

237 Args: 

238 *args (Any): Positional arguments forwarded to Click. 

239 **kwargs (Any): Keyword arguments forwarded to Click. 

240 

241 Returns: 

242 The decorated group. 

243 """ 

244 kwargs.setdefault("cls", Group) 

245 return click.group(*args, **kwargs) 

246 

247 

248def check_root_warning() -> None: 

249 """ 

250 Check if the script is being run as root and display a warning message. 

251 

252 This function checks the effective user ID (UID) on POSIX systems. 

253 If the process is executed with root privileges (UID 0), a warning 

254 is printed to stderr advising against running as root and providing 

255 guidance for Docker container usage. 

256 """ 

257 if hasattr(os, "geteuid") and os.geteuid() == 0: 

258 click.secho( 

259 "⚠️ Warning: You are running SeedboxSync as root. This is discouraged. " 

260 "If you are using Docker, you may have forgotten to pass the --user flag " 

261 "with the appropriate UID.", 

262 fg="yellow", 

263 bold=True, 

264 err=True, 

265 )