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
« 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."""
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
18def pass_context(func: Any) -> Any:
19 """
20 Decorate a callback to receive the custom SeedboxSync Click context.
22 Args:
23 func: The callback to decorate.
25 Returns:
26 The decorated callback.
27 """
28 return click.pass_context(func)
31class Cli(FlaskGroup):
32 """
33 SeedboxSync command-line interface.
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 """
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 )
53 def __init__(self, *args: Any, **kwargs: Any) -> None:
54 """
55 Initialize the CLI and remove Flask-specific global options.
57 Args:
58 *args (Any): Positional arguments forwarded to ``FlaskGroup``.
59 **kwargs (Any): Keyword arguments forwarded to ``FlaskGroup``.
60 """
61 super().__init__(*args, **kwargs)
63 self.params = [parameter for parameter in self.params if not self._is_hidden_flask_option(parameter)]
65 def _is_hidden_flask_option(self, parameter: click.Parameter) -> bool:
66 """
67 Determine whether a Click parameter is a hidden Flask option.
69 Args:
70 parameter (click.Parameter): The Click parameter to inspect.
72 Returns:
73 ``True`` if the parameter should be hidden, otherwise ``False``.
74 """
75 if not isinstance(parameter, click.Option):
76 return False
78 option_names = set(parameter.opts) | set(parameter.secondary_opts)
80 return bool(option_names & self._HIDDEN_FLASK_OPTIONS)
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.
86 Args:
87 ctx (click.Context,): The Click context.
88 args (list): The command-line arguments.
90 Returns:
91 The parsed arguments.
92 """
93 return click.Group.parse_args(self, ctx, args)
95 def command(self, *args: Any, **kwargs: Any) -> Any:
96 """
97 Create a command using the custom SeedboxSync command class.
99 Args:
100 *args (Any): Positional arguments forwarded to Click.
101 **kwargs (Any): Keyword arguments forwarded to Click.
103 Returns:
104 The decorated command.
105 """
106 kwargs.setdefault("cls", Command)
107 return super().command(*args, **kwargs)
109 def group(self, *args: Any, **kwargs: Any) -> Any:
110 """
111 Create a group using the custom SeedboxSync group class.
113 Args:
114 *args (Any): Positional arguments forwarded to Click.
115 **kwargs (Any): Keyword arguments forwarded to Click.
117 Returns:
118 The decorated group.
119 """
120 kwargs.setdefault("cls", Group)
121 return super().group(*args, **kwargs)
123 def list_commands(self, ctx: click.Context) -> list[str]:
124 """
125 Return the list of available SeedboxSync commands.
127 Args:
128 ctx (click.Context): The Click context.
130 Returns:
131 A sorted list of available command names.
132 """
133 rv = []
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:])
139 rv.sort()
140 return rv
142 def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
143 """
144 Load a command module dynamically.
146 Args:
147 ctx (click.Context): The Click context.
148 name (str): The command name.
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)
161 def invoke(self, ctx: click.Context) -> Any:
162 """
163 Invoke the selected command and handle user interruptions gracefully.
165 Args:
166 ctx (click.Context): The Click context.
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
178class Command(click.Command):
179 """SeedboxSync Click command using the custom context implementation."""
181 context_class = Context
184class Group(click.Group):
185 """SeedboxSync Click command group using the custom context implementation."""
187 context_class = Context
189 def command(self, *args: Any, **kwargs: Any) -> Any:
190 """
191 Create a command using the SeedboxSync command class by default.
193 Args:
194 *args (Any): Positional arguments forwarded to Click.
195 **kwargs (Any): Keyword arguments forwarded to Click.
197 Returns:
198 The decorated command.
199 """
200 kwargs.setdefault("cls", Command)
201 return super().command(*args, **kwargs)
203 def group(self, *args: Any, **kwargs: Any) -> Any:
204 """
205 Create a group using the SeedboxSync group class by default.
207 Args:
208 *args (Any): Positional arguments forwarded to Click.
209 **kwargs (Any): Keyword arguments forwarded to Click.
211 Returns:
212 The decorated group.
213 """
214 kwargs.setdefault("cls", Group)
215 return super().group(*args, **kwargs)
218def command(*args: Any, **kwargs: Any) -> Any:
219 """
220 Create a Click command using the SeedboxSync command class.
222 Args:
223 *args (Any): Positional arguments forwarded to Click.
224 **kwargs (Any): Keyword arguments forwarded to Click.
226 Returns:
227 The decorated command.
228 """
229 kwargs.setdefault("cls", Command)
230 return click.command(*args, **kwargs)
233def group(*args: Any, **kwargs: Any) -> Any:
234 """
235 Create a Click group using the SeedboxSync group class.
237 Args:
238 *args (Any): Positional arguments forwarded to Click.
239 **kwargs (Any): Keyword arguments forwarded to Click.
241 Returns:
242 The decorated group.
243 """
244 kwargs.setdefault("cls", Group)
245 return click.group(*args, **kwargs)
248def check_root_warning() -> None:
249 """
250 Check if the script is being run as root and display a warning message.
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 )