hop3-cli 0.4.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hop3_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Copyright (c) 2023-2025, Abilian SAS
@@ -0,0 +1,47 @@
1
+ # Copyright (c) 2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Command processing for the Hop3 CLI.
6
+
7
+ This package handles command parsing and local command execution:
8
+ - local: Commands handled locally without server RPC
9
+ - help: Help flag handling and help output injection
10
+ - destructive: Confirmation prompts for destructive commands
11
+ - flags: CLI flag parsing (--json, --quiet, -y, etc.)
12
+ - arguments: Argument generation (e.g., deploy archive)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .arguments import generate_archive, get_extra_args, pack_repository
18
+ from .destructive import confirm_destructive_action, is_destructive_command
19
+ from .flags import CliFlags, parse_flags
20
+ from .help import (
21
+ handle_help_flags,
22
+ inject_local_commands_into_help,
23
+ is_help_command,
24
+ )
25
+ from .local import (
26
+ LOCAL_COMMANDS,
27
+ LOCAL_COMMANDS_INFO,
28
+ handle_local_command,
29
+ is_local_command,
30
+ )
31
+
32
+ __all__ = [
33
+ "LOCAL_COMMANDS",
34
+ "LOCAL_COMMANDS_INFO",
35
+ "CliFlags",
36
+ "confirm_destructive_action",
37
+ "generate_archive",
38
+ "get_extra_args",
39
+ "handle_help_flags",
40
+ "handle_local_command",
41
+ "inject_local_commands_into_help",
42
+ "is_destructive_command",
43
+ "is_help_command",
44
+ "is_local_command",
45
+ "pack_repository",
46
+ "parse_flags",
47
+ ]
@@ -0,0 +1,190 @@
1
+ # Copyright (c) 2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Argument generation for CLI commands."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import io
11
+ import tarfile
12
+ from pathlib import Path
13
+
14
+ import pathspec
15
+
16
+ from hop3_cli.types import JsonDict
17
+
18
+ __all__ = ["generate_archive", "get_extra_args", "pack_repository"]
19
+
20
+
21
+ def get_extra_args(args: list[str], verbosity: int = 1) -> JsonDict:
22
+ """Generate a dictionary of extra arguments for RPC commands.
23
+
24
+ Args:
25
+ args: Command-line arguments
26
+ verbosity: Verbosity level (0=quiet, 1=normal, 2=verbose, 3=debug)
27
+
28
+ Returns:
29
+ Dictionary with extra arguments. Verbosity is always included as it's
30
+ used by the server to set the logging context for all commands.
31
+ """
32
+ # Always include verbosity - server extracts it and uses it as context
33
+ extra_args: JsonDict = {"verbosity": verbosity}
34
+
35
+ if not args:
36
+ return extra_args
37
+
38
+ command = args[0]
39
+
40
+ match command:
41
+ case "deploy":
42
+ # Parse deploy-specific flags
43
+ # args[0]="deploy", args[1]=app_name, remaining args may include --env and directory
44
+ env_vars, remaining_args = _parse_deploy_args(args[1:])
45
+
46
+ # Directory is the last non-flag argument (if any)
47
+ directory = Path(remaining_args[-1]) if len(remaining_args) > 1 else Path()
48
+ extra_args["repository"] = pack_repository(directory)
49
+
50
+ # Include env vars if any were specified
51
+ if env_vars:
52
+ extra_args["env_vars"] = env_vars
53
+
54
+ return extra_args
55
+
56
+
57
+ def _parse_deploy_args(args: list[str]) -> tuple[dict[str, str], list[str]]:
58
+ """Parse deploy command arguments, extracting --env flags.
59
+
60
+ Args:
61
+ args: Arguments after 'deploy' command (app_name, --env flags, directory)
62
+
63
+ Returns:
64
+ Tuple of (env_vars dict, remaining args without --env flags)
65
+
66
+ Example:
67
+ >>> _parse_deploy_args(['myapp', '--env', 'FOO=bar', '--env', 'BAZ=qux', '.'])
68
+ ({'FOO': 'bar', 'BAZ': 'qux'}, ['myapp', '.'])
69
+ """
70
+ env_vars: dict[str, str] = {}
71
+ remaining: list[str] = []
72
+ i = 0
73
+
74
+ while i < len(args):
75
+ arg = args[i]
76
+
77
+ if arg in {"--env", "-e"}:
78
+ # Next argument should be KEY=VALUE
79
+ if i + 1 < len(args):
80
+ env_spec = args[i + 1]
81
+ if "=" in env_spec:
82
+ key, _, value = env_spec.partition("=")
83
+ env_vars[key] = value
84
+ i += 2
85
+ else:
86
+ i += 1 # Skip malformed --env without value
87
+ elif arg.startswith("--env="):
88
+ # Handle --env=KEY=VALUE format
89
+ env_spec = arg[6:] # Remove --env=
90
+ if "=" in env_spec:
91
+ key, _, value = env_spec.partition("=")
92
+ env_vars[key] = value
93
+ i += 1
94
+ else:
95
+ remaining.append(arg)
96
+ i += 1
97
+
98
+ return env_vars, remaining
99
+
100
+
101
+ def pack_repository(directory: Path = Path()) -> str:
102
+ """Pack a directory into a base64-encoded tar.gz archive.
103
+
104
+ Args:
105
+ directory: Directory to pack (defaults to current directory)
106
+
107
+ Returns:
108
+ Base64-encoded tar.gz archive
109
+ """
110
+ tar_gz = generate_archive(directory)
111
+ return base64.b64encode(tar_gz).decode("ascii")
112
+
113
+
114
+ def generate_archive(source_dir: Path) -> bytes:
115
+ """
116
+ Creates an in-memory tar.gz archive of a source directory as a bytes object,
117
+ excluding files and directories specified in a .gitignore file.
118
+
119
+ Args:
120
+ source_dir: The path to the directory to archive.
121
+
122
+ Returns:
123
+ The content of the .tar.gz archive as a bytes object.
124
+
125
+ Raises:
126
+ ValueError: If the source_dir is not a valid directory.
127
+ FileNotFoundError: If the source_dir does not exist.
128
+ """
129
+ source_dir = Path(source_dir).resolve()
130
+
131
+ if not source_dir.exists():
132
+ msg = f"Source directory not found: {source_dir}"
133
+ raise FileNotFoundError(msg)
134
+ if not source_dir.is_dir():
135
+ msg = f"Source path is not a directory: {source_dir}"
136
+ raise ValueError(msg)
137
+
138
+ # --- 1. Load .gitignore rules ---
139
+ spec = get_ignored_spec(source_dir)
140
+
141
+ # --- 2. Walk the directory and gather files to include ---
142
+ files_to_add = get_files_to_add(source_dir, spec)
143
+
144
+ # --- 3. Create the tar.gz archive in memory ---
145
+ fileobj = io.BytesIO()
146
+
147
+ # The 'w:gz' mode creates a gzip-compressed tar file.
148
+ # We pass our BytesIO object as the file to write to.
149
+ with tarfile.open(fileobj=fileobj, mode="w:gz") as tar:
150
+ for file_path in files_to_add:
151
+ relative_path = file_path.relative_to(source_dir)
152
+ arcname = Path() / relative_path
153
+ tar.add(file_path, arcname=str(arcname))
154
+
155
+ return fileobj.getvalue()
156
+
157
+
158
+ def get_ignored_spec(source_dir: Path) -> pathspec.PathSpec | None:
159
+ """Load .gitignore rules from a directory."""
160
+ gitignore_path = source_dir / ".gitignore"
161
+ spec: pathspec.PathSpec | None = None
162
+ if gitignore_path.is_file():
163
+ with gitignore_path.open(encoding="utf-8") as f:
164
+ spec = pathspec.PathSpec.from_lines("gitignore", f)
165
+ return spec
166
+
167
+
168
+ def get_files_to_add(source_dir: Path, spec: pathspec.PathSpec | None) -> list[Path]:
169
+ """Get list of files to add to archive, excluding gitignored files."""
170
+ files_to_add: list[Path] = []
171
+ for file_path in source_dir.rglob("*"):
172
+ relative_path = file_path.relative_to(source_dir)
173
+ relative_str = str(relative_path)
174
+
175
+ # Always exclude .git directory (not deployment material)
176
+ if relative_str.startswith(".git") and (
177
+ relative_str == ".git" or relative_str.startswith(".git/")
178
+ ):
179
+ continue
180
+
181
+ # Let pathspec determine if the file should be ignored
182
+ if spec and spec.match_file(relative_str):
183
+ continue
184
+
185
+ # We only add files to the tar, not directories
186
+ if not file_path.is_file():
187
+ continue
188
+
189
+ files_to_add.append(file_path)
190
+ return files_to_add
@@ -0,0 +1,100 @@
1
+ # Copyright (c) 2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Destructive command handling and confirmation prompts."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from hop3_cli.ui.prompts import confirm, show_destructive_warning, type_to_confirm
12
+
13
+ if TYPE_CHECKING:
14
+ from hop3_cli.ui.rich_printer import RichPrinter
15
+
16
+
17
+ def is_destructive_command(cli_args: list[str]) -> bool:
18
+ """Check if the command is destructive (requires confirmation).
19
+
20
+ Args:
21
+ cli_args: Command-line arguments
22
+
23
+ Returns:
24
+ True if command is destructive, False otherwise
25
+ """
26
+ if not cli_args:
27
+ return False
28
+
29
+ command = cli_args[0]
30
+
31
+ # List of destructive commands that require confirmation
32
+ destructive_commands = {
33
+ "app:destroy",
34
+ "destroy", # Alias for app:destroy
35
+ "backup:delete",
36
+ "services:destroy",
37
+ }
38
+
39
+ return command in destructive_commands
40
+
41
+
42
+ def confirm_destructive_action(cli_args: list[str], printer: RichPrinter) -> bool:
43
+ """Prompt user to confirm a destructive action.
44
+
45
+ Args:
46
+ cli_args: Command-line arguments
47
+ printer: Printer for output (for JSON mode detection)
48
+
49
+ Returns:
50
+ True if user confirmed, False if cancelled
51
+ """
52
+ if printer.json_output:
53
+ # In JSON mode, auto-confirm (user should use -y flag)
54
+ return True
55
+
56
+ command = cli_args[0]
57
+ args = cli_args[1:]
58
+
59
+ # app:destroy or destroy command - requires type-to-confirm
60
+ if command in {"app:destroy", "destroy"}:
61
+ if not args:
62
+ # No app name provided, let server handle error
63
+ return True
64
+
65
+ app_name = args[0]
66
+ show_destructive_warning(
67
+ "destroy",
68
+ f"app '{app_name}'",
69
+ "All files, data, and configuration will be permanently deleted.",
70
+ )
71
+ return type_to_confirm(f"Type '{app_name}' to confirm:", app_name)
72
+
73
+ # backup:delete command
74
+ if command == "backup:delete":
75
+ if not args:
76
+ return True
77
+
78
+ backup_id = args[0]
79
+ show_destructive_warning(
80
+ "delete",
81
+ f"backup '{backup_id}'",
82
+ "This backup cannot be recovered once deleted.",
83
+ )
84
+ return confirm("Are you sure you want to delete this backup?")
85
+
86
+ # services:destroy command
87
+ if command == "services:destroy":
88
+ if not args:
89
+ return True
90
+
91
+ addon_name = args[0]
92
+ show_destructive_warning(
93
+ "destroy",
94
+ f"service '{addon_name}'",
95
+ "All data in this service will be permanently deleted.",
96
+ )
97
+ return type_to_confirm(f"Type '{addon_name}' to confirm:", addon_name)
98
+
99
+ # Unknown destructive command (shouldn't happen)
100
+ return confirm("This action cannot be undone. Continue?")
@@ -0,0 +1,122 @@
1
+ # Copyright (c) 2023-2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """CLI flag parsing and handling."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ def _get_env_verbosity() -> int | None:
14
+ """Get verbosity from HOP3_VERBOSITY environment variable.
15
+
16
+ Returns:
17
+ Verbosity level (0-3) or None if not set or invalid
18
+ """
19
+ env_val = os.environ.get("HOP3_VERBOSITY", "").strip()
20
+ if not env_val:
21
+ return None
22
+ try:
23
+ level = int(env_val)
24
+ return max(0, min(3, level)) # Clamp to 0-3
25
+ except ValueError:
26
+ return None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CliFlags:
31
+ """CLI flags that control output and behavior."""
32
+
33
+ json_output: bool = False # --json, -j: Machine-readable JSON output
34
+ skip_confirm: bool = False # -y, --yes, --force: Skip confirmation prompts
35
+
36
+ # Verbosity is now stored as a level (0=quiet, 1=normal, 2=verbose, 3=debug)
37
+ # This allows -vv, -vvv, -qq, etc.
38
+ verbosity: int = field(default_factory=lambda: _get_env_verbosity() or 1)
39
+
40
+ @property
41
+ def quiet(self) -> bool:
42
+ """True if verbosity is 0 (quiet mode)."""
43
+ return self.verbosity == 0
44
+
45
+ @property
46
+ def verbose(self) -> bool:
47
+ """True if verbosity is 2 or higher (verbose mode)."""
48
+ return self.verbosity >= 2
49
+
50
+ @property
51
+ def debug(self) -> bool:
52
+ """True if verbosity is 3 (debug mode)."""
53
+ return self.verbosity >= 3
54
+
55
+
56
+ def parse_flags(args: list[str]) -> tuple[CliFlags, list[str]]:
57
+ """Parse CLI flags from arguments and return flags + remaining args.
58
+
59
+ Supports:
60
+ --json, -j: Machine-readable JSON output
61
+ -y, --yes, --force: Skip confirmation prompts
62
+ -v, --verbose: Increase verbosity (can stack: -vv, -vvv)
63
+ -q, --quiet: Decrease verbosity (can stack: -qq)
64
+ --debug: Maximum verbosity (level 3)
65
+
66
+ Environment variable:
67
+ HOP3_VERBOSITY: Set default verbosity level (0-3)
68
+
69
+ Args:
70
+ args: Command-line arguments (e.g., ['deploy', 'my-app', '--json', '-y'])
71
+
72
+ Returns:
73
+ Tuple of (CliFlags, remaining_args)
74
+ remaining_args has flags removed
75
+
76
+ Examples:
77
+ >>> parse_flags(['deploy', 'my-app', '--json'])
78
+ (CliFlags(json_output=True, ...), ['deploy', 'my-app'])
79
+
80
+ >>> parse_flags(['destroy', 'my-app', '-y', '-vv'])
81
+ (CliFlags(verbosity=3, skip_confirm=True, ...), ['destroy', 'my-app'])
82
+ """
83
+ json_output = False
84
+ skip_confirm = False
85
+
86
+ # Start with environment default or normal (1)
87
+ verbosity = _get_env_verbosity() or 1
88
+
89
+ # Flags to recognize
90
+ json_flags = {"--json", "-j"}
91
+ yes_flags = {"-y", "--yes", "--force"}
92
+
93
+ # Filter out flags from args
94
+ remaining_args = []
95
+ for arg in args:
96
+ if arg in json_flags:
97
+ json_output = True
98
+ elif arg in yes_flags:
99
+ skip_confirm = True
100
+ elif arg == "--debug":
101
+ verbosity = 3
102
+ elif arg == "--verbose":
103
+ verbosity = max(verbosity, 2)
104
+ elif arg == "--quiet":
105
+ verbosity = 0
106
+ elif arg.startswith("-") and all(c == "v" for c in arg[1:]):
107
+ # Handle -v, -vv, -vvv
108
+ verbosity = min(3, 1 + len(arg) - 1) # -v=2, -vv=3, -vvv=3
109
+ elif arg.startswith("-") and all(c == "q" for c in arg[1:]):
110
+ # Handle -q, -qq (but -q is enough for quiet=0)
111
+ verbosity = 0
112
+ else:
113
+ # Not a flag, keep it
114
+ remaining_args.append(arg)
115
+
116
+ flags = CliFlags(
117
+ json_output=json_output,
118
+ skip_confirm=skip_confirm,
119
+ verbosity=verbosity,
120
+ )
121
+
122
+ return flags, remaining_args
@@ -0,0 +1,182 @@
1
+ # Copyright (c) 2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Help flag handling and help output injection."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from .local import LOCAL_COMMANDS_INFO
10
+
11
+
12
+ def handle_help_flags(args: list[str]) -> list[str]:
13
+ """Convert --help/-h flags to help command invocations.
14
+
15
+ Examples:
16
+ ["--help"] -> ["help"]
17
+ ["-h"] -> ["help"]
18
+ ["run", "--help"] -> ["help", "run"]
19
+ ["run", "-h"] -> ["help", "run"]
20
+ ["run", "myapp", "--help"] -> ["help", "run"] # help for run, not run with --help
21
+
22
+ Args:
23
+ args: Command-line arguments
24
+
25
+ Returns:
26
+ Modified arguments with --help converted to help command
27
+ """
28
+ if not args:
29
+ return args
30
+
31
+ # Handle --version and -V flags
32
+ if "--version" in args or "-V" in args:
33
+ return ["version"]
34
+
35
+ # Check if --help or -h is anywhere in the args
36
+ if "--help" in args or "-h" in args:
37
+ # Remove --help and -h from args
38
+ filtered_args = [arg for arg in args if arg not in {"--help", "-h"}]
39
+
40
+ if not filtered_args:
41
+ # Just "--help" with no command -> show general help
42
+ return ["help"]
43
+ # "command --help" -> "help command"
44
+ # Only use the first argument as the command name
45
+ return ["help", filtered_args[0]]
46
+
47
+ return args
48
+
49
+
50
+ def is_help_command(cli_args: list[str]) -> bool:
51
+ """Check if this is a help command (with or without --all flag).
52
+
53
+ Args:
54
+ cli_args: Command-line arguments
55
+
56
+ Returns:
57
+ True if this is a help command that should have local commands injected
58
+ """
59
+ if not cli_args:
60
+ return False
61
+ # Match "help" or "help --all" but not "help <command>"
62
+ if cli_args[0] != "help":
63
+ return False
64
+ # "help" alone or "help --all"
65
+ return len(cli_args) == 1 or cli_args == ["help", "--all"]
66
+
67
+
68
+ def inject_local_commands_into_help(result: list[dict]) -> list[dict]:
69
+ """Inject local CLI commands into the help output from the server.
70
+
71
+ Local commands (init, login, settings) are handled by the CLI and don't
72
+ exist on the server, so we add them to help output for discoverability.
73
+
74
+ Args:
75
+ result: The help response from the server
76
+
77
+ Returns:
78
+ Modified result with local commands injected alphabetically
79
+ """
80
+ modified_result = []
81
+ for item in result:
82
+ if item.get("t") != "text":
83
+ modified_result.append(item)
84
+ continue
85
+
86
+ text = item.get("text", "")
87
+ if "\n" in text and "COMMANDS" in text:
88
+ new_text = _process_help_text_with_local_commands(text, LOCAL_COMMANDS_INFO)
89
+ modified_result.append({"t": "text", "text": new_text})
90
+ else:
91
+ modified_result.append(item)
92
+
93
+ return modified_result
94
+
95
+
96
+ def _process_help_text_with_local_commands(
97
+ text: str,
98
+ local_commands: dict[str, str],
99
+ ) -> str:
100
+ """Process help text and inject local commands into COMMANDS section."""
101
+ lines = text.split("\n")
102
+ new_lines = []
103
+ in_commands_section = False
104
+ injected: set[str] = set()
105
+
106
+ for line in lines:
107
+ if line.strip() in {"COMMANDS", "ALL COMMANDS"}:
108
+ in_commands_section = True
109
+ new_lines.append(line)
110
+ continue
111
+
112
+ if in_commands_section and line.strip() and not line.startswith(" "):
113
+ # Leaving COMMANDS section - inject remaining commands first
114
+ new_lines.extend(_inject_remaining_commands(local_commands, injected))
115
+ in_commands_section = False
116
+
117
+ if in_commands_section and _is_command_line(line):
118
+ current_cmd = _get_command_name(line)
119
+ if current_cmd:
120
+ new_lines.extend(
121
+ _inject_commands_before(current_cmd, local_commands, injected)
122
+ )
123
+
124
+ new_lines.append(line)
125
+
126
+ # If still in commands section at end, inject remaining
127
+ if in_commands_section:
128
+ remaining = _inject_remaining_commands(local_commands, injected)
129
+ if remaining:
130
+ # Insert after last command line
131
+ insert_idx = len(new_lines)
132
+ for i in range(len(new_lines) - 1, -1, -1):
133
+ if _is_command_line(new_lines[i]):
134
+ insert_idx = i + 1
135
+ break
136
+ for j, cmd_line in enumerate(remaining):
137
+ new_lines.insert(insert_idx + j, cmd_line)
138
+
139
+ return "\n".join(new_lines)
140
+
141
+
142
+ def _inject_remaining_commands(
143
+ local_commands: dict[str, str],
144
+ injected: set[str],
145
+ ) -> list[str]:
146
+ """Return all local commands not yet injected."""
147
+ lines = []
148
+ for cmd in sorted(local_commands.keys()):
149
+ if cmd not in injected:
150
+ lines.append(_format_help_command(cmd, local_commands[cmd]))
151
+ injected.add(cmd)
152
+ return lines
153
+
154
+
155
+ def _is_command_line(line: str) -> bool:
156
+ """Check if a line is a command entry (indented, non-empty)."""
157
+ return line.startswith(" ") and bool(line.strip())
158
+
159
+
160
+ def _get_command_name(line: str) -> str | None:
161
+ """Extract command name from a help line."""
162
+ parts = line.strip().split(None, 1)
163
+ return parts[0] if parts else None
164
+
165
+
166
+ def _inject_commands_before(
167
+ current_cmd: str,
168
+ local_commands: dict[str, str],
169
+ injected: set[str],
170
+ ) -> list[str]:
171
+ """Return local commands that should appear before current_cmd alphabetically."""
172
+ lines = []
173
+ for cmd in sorted(local_commands.keys()):
174
+ if cmd not in injected and cmd < current_cmd:
175
+ lines.append(_format_help_command(cmd, local_commands[cmd]))
176
+ injected.add(cmd)
177
+ return lines
178
+
179
+
180
+ def _format_help_command(name: str, description: str) -> str:
181
+ """Format a command entry for help output."""
182
+ return f" {name:16} {description}"