octopize.deploy_tool 0.1.0__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.
- octopize_avatar_deploy/__init__.py +26 -0
- octopize_avatar_deploy/cli_test_harness.py +217 -0
- octopize_avatar_deploy/configure.py +705 -0
- octopize_avatar_deploy/defaults.yaml +63 -0
- octopize_avatar_deploy/download_templates.py +251 -0
- octopize_avatar_deploy/input_gatherer.py +324 -0
- octopize_avatar_deploy/printer.py +216 -0
- octopize_avatar_deploy/state_manager.py +136 -0
- octopize_avatar_deploy/steps/__init__.py +25 -0
- octopize_avatar_deploy/steps/authentik.py +46 -0
- octopize_avatar_deploy/steps/authentik_blueprint.py +79 -0
- octopize_avatar_deploy/steps/base.py +199 -0
- octopize_avatar_deploy/steps/database.py +37 -0
- octopize_avatar_deploy/steps/email.py +88 -0
- octopize_avatar_deploy/steps/logging.py +32 -0
- octopize_avatar_deploy/steps/required.py +81 -0
- octopize_avatar_deploy/steps/storage.py +32 -0
- octopize_avatar_deploy/steps/telemetry.py +58 -0
- octopize_avatar_deploy/steps/user.py +89 -0
- octopize_avatar_deploy/version_compat.py +344 -0
- octopize_deploy_tool-0.1.0.dist-info/METADATA +346 -0
- octopize_deploy_tool-0.1.0.dist-info/RECORD +24 -0
- octopize_deploy_tool-0.1.0.dist-info/WHEEL +4 -0
- octopize_deploy_tool-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Default Configuration Values for Avatar Deployment
|
|
2
|
+
# Version: 1.0.0 (MAJOR.MINOR.PATCH)
|
|
3
|
+
# Compatible with script version: >=1.0.0,<2.0.0
|
|
4
|
+
|
|
5
|
+
version: "1.0.0"
|
|
6
|
+
|
|
7
|
+
# Image versions - updated regularly with new releases
|
|
8
|
+
images:
|
|
9
|
+
api: "2.20.1"
|
|
10
|
+
web: "0.40.0"
|
|
11
|
+
pdfgenerator: "latest"
|
|
12
|
+
seaweedfs: "0.2.0"
|
|
13
|
+
authentik: "2025.10.2"
|
|
14
|
+
|
|
15
|
+
# Email configuration defaults
|
|
16
|
+
email:
|
|
17
|
+
provider: "aws" # 'aws' or 'smtp'
|
|
18
|
+
smtp:
|
|
19
|
+
host: "email-smtp.eu-west-3.amazonaws.com"
|
|
20
|
+
port: "587"
|
|
21
|
+
use_tls: "true"
|
|
22
|
+
start_tls: "false"
|
|
23
|
+
verify: "true"
|
|
24
|
+
sender_email: "noreply@octopize.io"
|
|
25
|
+
|
|
26
|
+
# Telemetry configuration (for usage analytics)
|
|
27
|
+
telemetry:
|
|
28
|
+
enabled: true # Ask user if they want to enable telemetry
|
|
29
|
+
endpoint_url: "https://s3.fr-par.scw.cloud"
|
|
30
|
+
region: "fr-par"
|
|
31
|
+
|
|
32
|
+
# Application settings (defaults when no preset is selected)
|
|
33
|
+
application:
|
|
34
|
+
use_console_logging: "false"
|
|
35
|
+
log_level: "INFO"
|
|
36
|
+
sentry_enabled: "true"
|
|
37
|
+
email_authentication: "true"
|
|
38
|
+
|
|
39
|
+
# Deployment presets - pre-configured settings for common scenarios
|
|
40
|
+
presets:
|
|
41
|
+
default:
|
|
42
|
+
description: "Production-ready configuration with telemetry and monitoring"
|
|
43
|
+
application:
|
|
44
|
+
use_console_logging: "false"
|
|
45
|
+
sentry_enabled: "true"
|
|
46
|
+
telemetry:
|
|
47
|
+
enabled: true
|
|
48
|
+
|
|
49
|
+
dev-mode:
|
|
50
|
+
description: "Development mode with console logging, no external services"
|
|
51
|
+
application:
|
|
52
|
+
use_console_logging: "true"
|
|
53
|
+
sentry_enabled: "false"
|
|
54
|
+
telemetry:
|
|
55
|
+
enabled: false
|
|
56
|
+
|
|
57
|
+
airgapped:
|
|
58
|
+
description: "Air-gapped deployment without external monitoring or telemetry"
|
|
59
|
+
application:
|
|
60
|
+
use_console_logging: "false"
|
|
61
|
+
sentry_enabled: "false"
|
|
62
|
+
telemetry:
|
|
63
|
+
enabled: false
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provide deployment templates from various sources.
|
|
3
|
+
|
|
4
|
+
This module handles obtaining necessary template files either from GitHub
|
|
5
|
+
or from a local directory (for testing).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import urllib.request
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# GitHub raw content URL base
|
|
14
|
+
GITHUB_RAW_BASE = "https://raw.githubusercontent.com/octopize/avatar-deployment"
|
|
15
|
+
DEFAULT_BRANCH = "main"
|
|
16
|
+
|
|
17
|
+
# Files to download from the docker/templates/ directory
|
|
18
|
+
REQUIRED_FILES = [
|
|
19
|
+
".env.template",
|
|
20
|
+
"nginx.conf.template",
|
|
21
|
+
"docker-compose.yml",
|
|
22
|
+
".template-version",
|
|
23
|
+
"authentik/octopize-avatar-blueprint.yaml.j2",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TemplateProvider(ABC):
|
|
28
|
+
"""Abstract base class for template providers."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, verbose: bool = False):
|
|
31
|
+
"""
|
|
32
|
+
Initialize template provider.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
verbose: Print progress information
|
|
36
|
+
"""
|
|
37
|
+
self.verbose = verbose
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def provide_file(self, filename: str, destination: Path) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Provide a single file to the destination.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
filename: Name of file to provide
|
|
46
|
+
destination: Local path where file should be saved
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if successful, False otherwise
|
|
50
|
+
"""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def provide_all(self, output_dir: Path) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Provide all required template files.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
output_dir: Directory where files should be saved
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if all files provided successfully
|
|
62
|
+
"""
|
|
63
|
+
output_dir = Path(output_dir)
|
|
64
|
+
success = True
|
|
65
|
+
|
|
66
|
+
if self.verbose:
|
|
67
|
+
print(f"\nProviding templates to {output_dir}/")
|
|
68
|
+
print("=" * 60)
|
|
69
|
+
|
|
70
|
+
for filename in REQUIRED_FILES:
|
|
71
|
+
destination = output_dir / filename
|
|
72
|
+
if not self.provide_file(filename, destination):
|
|
73
|
+
success = False
|
|
74
|
+
print(f"⚠ Warning: Failed to provide {filename}")
|
|
75
|
+
|
|
76
|
+
if self.verbose:
|
|
77
|
+
if success:
|
|
78
|
+
print("\n✓ All templates provided successfully")
|
|
79
|
+
else:
|
|
80
|
+
print("\n⚠ Some templates failed")
|
|
81
|
+
|
|
82
|
+
return success
|
|
83
|
+
|
|
84
|
+
def check_cached_templates(self, output_dir: Path) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Check if templates are already cached locally.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
output_dir: Directory to check for cached templates
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if all required files exist
|
|
93
|
+
"""
|
|
94
|
+
output_dir = Path(output_dir)
|
|
95
|
+
for filename in REQUIRED_FILES:
|
|
96
|
+
if not (output_dir / filename).exists():
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GitHubTemplateProvider(TemplateProvider):
|
|
102
|
+
"""Downloads deployment templates from GitHub."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, branch: str = DEFAULT_BRANCH, verbose: bool = False):
|
|
105
|
+
"""
|
|
106
|
+
Initialize GitHub template provider.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
branch: Git branch to download from (default: main)
|
|
110
|
+
verbose: Print download progress
|
|
111
|
+
"""
|
|
112
|
+
super().__init__(verbose=verbose)
|
|
113
|
+
self.branch = branch
|
|
114
|
+
self.base_url = f"{GITHUB_RAW_BASE}/{branch}/docker/templates"
|
|
115
|
+
|
|
116
|
+
def provide_file(self, filename: str, destination: Path) -> bool:
|
|
117
|
+
"""
|
|
118
|
+
Download a single file from GitHub.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
filename: Name of file to download (in docker/templates/ directory)
|
|
122
|
+
destination: Local path where file should be saved
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if successful, False otherwise
|
|
126
|
+
"""
|
|
127
|
+
url = f"{self.base_url}/{filename}"
|
|
128
|
+
|
|
129
|
+
if self.verbose:
|
|
130
|
+
print(f"Downloading {filename}...")
|
|
131
|
+
print(f" URL: {url}")
|
|
132
|
+
print(f" Destination: {destination}")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(url, timeout=10) as response:
|
|
136
|
+
content = response.read()
|
|
137
|
+
|
|
138
|
+
# Ensure parent directory exists
|
|
139
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
|
|
141
|
+
# Write file
|
|
142
|
+
destination.write_bytes(content)
|
|
143
|
+
|
|
144
|
+
if self.verbose:
|
|
145
|
+
print(f" ✓ Downloaded {len(content)} bytes")
|
|
146
|
+
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
except Exception as e:
|
|
150
|
+
if self.verbose:
|
|
151
|
+
print(f" ✗ Failed: {e}")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def check_cached_templates(self, output_dir: Path) -> bool:
|
|
155
|
+
"""
|
|
156
|
+
Check if templates are already cached locally.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
output_dir: Directory to check for cached templates
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if all required files exist
|
|
163
|
+
"""
|
|
164
|
+
output_dir = Path(output_dir)
|
|
165
|
+
for filename in REQUIRED_FILES:
|
|
166
|
+
if not (output_dir / filename).exists():
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class LocalTemplateProvider(TemplateProvider):
|
|
172
|
+
"""Provides templates from a local directory (for testing)."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, source_dir: Path | str, verbose: bool = False):
|
|
175
|
+
"""
|
|
176
|
+
Initialize local template provider.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
source_dir: Local directory containing template files
|
|
180
|
+
verbose: Print progress information
|
|
181
|
+
"""
|
|
182
|
+
super().__init__(verbose=verbose)
|
|
183
|
+
self.source_dir = Path(source_dir)
|
|
184
|
+
|
|
185
|
+
def provide_file(self, filename: str, destination: Path) -> bool:
|
|
186
|
+
"""
|
|
187
|
+
Copy a single file from source to destination.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
filename: Name of file to copy
|
|
191
|
+
destination: Local path where file should be saved
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
True if successful, False otherwise
|
|
195
|
+
"""
|
|
196
|
+
source = self.source_dir / filename
|
|
197
|
+
|
|
198
|
+
if self.verbose:
|
|
199
|
+
print(f"Copying {filename}...")
|
|
200
|
+
print(f" Source: {source}")
|
|
201
|
+
print(f" Destination: {destination}")
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
if not source.exists():
|
|
205
|
+
raise FileNotFoundError(f"Source file not found: {source}")
|
|
206
|
+
|
|
207
|
+
# Ensure parent directory exists
|
|
208
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
|
|
210
|
+
# Copy file
|
|
211
|
+
shutil.copy2(source, destination)
|
|
212
|
+
|
|
213
|
+
if self.verbose:
|
|
214
|
+
print(f" ✓ Copied {source.stat().st_size} bytes")
|
|
215
|
+
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
if self.verbose:
|
|
220
|
+
print(f" ✗ Failed: {e}")
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def download_templates(
|
|
225
|
+
output_dir: Path,
|
|
226
|
+
force: bool = False,
|
|
227
|
+
branch: str = DEFAULT_BRANCH,
|
|
228
|
+
verbose: bool = False,
|
|
229
|
+
) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Download deployment templates from GitHub.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
output_dir: Directory where templates should be saved
|
|
235
|
+
force: Force download even if files already exist
|
|
236
|
+
branch: Git branch to download from
|
|
237
|
+
verbose: Print progress information
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
True if successful
|
|
241
|
+
"""
|
|
242
|
+
provider = GitHubTemplateProvider(branch=branch, verbose=verbose)
|
|
243
|
+
|
|
244
|
+
# Check if already cached
|
|
245
|
+
if not force and provider.check_cached_templates(output_dir):
|
|
246
|
+
if verbose:
|
|
247
|
+
print(f"Templates already cached in {output_dir}/")
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
# Download
|
|
251
|
+
return provider.provide_all(output_dir)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Input gathering abstraction for deployment tool.
|
|
3
|
+
|
|
4
|
+
Provides pluggable input gathering through Protocol pattern, enabling:
|
|
5
|
+
- Standard console input for production
|
|
6
|
+
- Mock input for testing
|
|
7
|
+
- Rich library input with enhanced features
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class InputGatherer(Protocol):
|
|
21
|
+
"""
|
|
22
|
+
Protocol defining input gathering interface.
|
|
23
|
+
|
|
24
|
+
This allows different implementations (console, mock, rich) while
|
|
25
|
+
maintaining type safety through Protocol pattern.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def prompt(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
default: str | None = None,
|
|
32
|
+
validate: Callable[[str], tuple[bool, str]] | None = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Prompt user for input with optional default value and validation.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
message: The prompt message
|
|
39
|
+
default: Default value to use if user presses Enter (None = required field)
|
|
40
|
+
validate: Optional validation function that returns (is_valid, error_message)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
User's input or default value
|
|
44
|
+
"""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def prompt_yes_no(self, message: str, default: bool = True) -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Prompt user for yes/no input.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
message: The prompt message
|
|
53
|
+
default: Default value if user presses Enter
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True for yes, False for no
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Prompt user to choose from list of options.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
message: The prompt message
|
|
66
|
+
choices: List of valid choices
|
|
67
|
+
default: Default choice if user presses Enter
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Selected choice
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ConsoleInputGatherer:
|
|
76
|
+
"""
|
|
77
|
+
Standard console-based input gatherer using built-in input().
|
|
78
|
+
|
|
79
|
+
This is the default implementation for production use.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def prompt(
|
|
83
|
+
self,
|
|
84
|
+
message: str,
|
|
85
|
+
default: str | None = None,
|
|
86
|
+
validate: Callable[[str], tuple[bool, str]] | None = None,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Prompt user for input with optional default value and validation."""
|
|
89
|
+
# Debug logging
|
|
90
|
+
if os.environ.get("AVATAR_DEPLOY_DEBUG_PROMPTS") == "1":
|
|
91
|
+
debug_msg = f"[PROMPT] {message}"
|
|
92
|
+
if default is not None:
|
|
93
|
+
debug_msg += f" (default: {default!r})"
|
|
94
|
+
print(debug_msg, file=sys.stderr)
|
|
95
|
+
|
|
96
|
+
while True:
|
|
97
|
+
if default is not None: # Has a default (including empty string)
|
|
98
|
+
prompt_text = (
|
|
99
|
+
f"{message} [{default}]: " if default else f"{message} [press Enter to skip]: "
|
|
100
|
+
)
|
|
101
|
+
response = input(prompt_text).strip()
|
|
102
|
+
value = response if response else default
|
|
103
|
+
else: # No default - required field
|
|
104
|
+
response = input(f"{message}: ").strip()
|
|
105
|
+
if not response:
|
|
106
|
+
print(" ⚠ This value is required")
|
|
107
|
+
continue
|
|
108
|
+
value = response
|
|
109
|
+
|
|
110
|
+
# Validate if validator provided
|
|
111
|
+
if validate:
|
|
112
|
+
is_valid, error_msg = validate(value)
|
|
113
|
+
if not is_valid:
|
|
114
|
+
print(f" ⚠ {error_msg}")
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
def prompt_yes_no(self, message: str, default: bool = True) -> bool:
|
|
120
|
+
"""Prompt user for yes/no input."""
|
|
121
|
+
default_str = "Y/n" if default else "y/N"
|
|
122
|
+
response = input(f"{message} [{default_str}]: ").strip().lower()
|
|
123
|
+
|
|
124
|
+
if not response:
|
|
125
|
+
return default
|
|
126
|
+
|
|
127
|
+
return response in ["y", "yes", "true", "1"]
|
|
128
|
+
|
|
129
|
+
def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
|
|
130
|
+
"""Prompt user to choose from list of options."""
|
|
131
|
+
print(f"\n{message}")
|
|
132
|
+
for i, choice in enumerate(choices, 1):
|
|
133
|
+
marker = " (default)" if choice == default else ""
|
|
134
|
+
print(f" {i}. {choice}{marker}")
|
|
135
|
+
|
|
136
|
+
while True:
|
|
137
|
+
response = input(f"\nSelect [1-{len(choices)}]: ").strip()
|
|
138
|
+
|
|
139
|
+
if not response and default:
|
|
140
|
+
return default
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
choice_num = int(response)
|
|
144
|
+
if 1 <= choice_num <= len(choices):
|
|
145
|
+
return choices[choice_num - 1]
|
|
146
|
+
else:
|
|
147
|
+
print(f" ⚠ Please enter a number between 1 and {len(choices)}")
|
|
148
|
+
except ValueError:
|
|
149
|
+
print(" ⚠ Please enter a valid number")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MockInputGatherer:
|
|
153
|
+
"""
|
|
154
|
+
Mock input gatherer for testing.
|
|
155
|
+
|
|
156
|
+
Returns pre-configured responses in sequence. Useful for testing
|
|
157
|
+
interactive flows without manual input.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, responses: list[str | bool]):
|
|
161
|
+
"""
|
|
162
|
+
Initialize mock input gatherer.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
responses: List of pre-configured responses to return in sequence
|
|
166
|
+
"""
|
|
167
|
+
self.responses = responses
|
|
168
|
+
self.current_index = 0
|
|
169
|
+
|
|
170
|
+
def _get_next_response(self) -> str | bool:
|
|
171
|
+
"""Get next response from the queue."""
|
|
172
|
+
if self.current_index >= len(self.responses):
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"MockInputGatherer ran out of responses (asked {self.current_index + 1} times)"
|
|
175
|
+
)
|
|
176
|
+
response = self.responses[self.current_index]
|
|
177
|
+
self.current_index += 1
|
|
178
|
+
return response
|
|
179
|
+
|
|
180
|
+
def prompt(
|
|
181
|
+
self,
|
|
182
|
+
message: str,
|
|
183
|
+
default: str | None = None,
|
|
184
|
+
validate: Callable[[str], tuple[bool, str]] | None = None,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Return next mocked response."""
|
|
187
|
+
# Debug logging with response preview
|
|
188
|
+
if os.environ.get("AVATAR_DEPLOY_DEBUG_PROMPTS") == "1":
|
|
189
|
+
response_preview = (
|
|
190
|
+
self.responses[self.current_index]
|
|
191
|
+
if self.current_index < len(self.responses)
|
|
192
|
+
else f"<default: {default!r}>"
|
|
193
|
+
)
|
|
194
|
+
print(
|
|
195
|
+
f"[PROMPT #{self.current_index + 1}] {message} => {response_preview!r}",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
response = self._get_next_response()
|
|
200
|
+
|
|
201
|
+
if isinstance(response, bool):
|
|
202
|
+
raise TypeError(
|
|
203
|
+
"Expected string response, got bool: True"
|
|
204
|
+
if response
|
|
205
|
+
else "Expected string response, got bool: False"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Use default if response is empty string and default is provided
|
|
209
|
+
value = response if response else (default if default is not None else response)
|
|
210
|
+
|
|
211
|
+
# Validate if validator provided (for testing validation logic)
|
|
212
|
+
if validate:
|
|
213
|
+
is_valid, error_msg = validate(value)
|
|
214
|
+
if not is_valid:
|
|
215
|
+
raise ValueError(f"MockInputGatherer: Validation failed for '{value}': {error_msg}")
|
|
216
|
+
|
|
217
|
+
return value
|
|
218
|
+
|
|
219
|
+
def prompt_yes_no(self, message: str, default: bool = True) -> bool:
|
|
220
|
+
"""Return next mocked boolean response."""
|
|
221
|
+
response = self._get_next_response()
|
|
222
|
+
if isinstance(response, str):
|
|
223
|
+
# Convert string to boolean if needed
|
|
224
|
+
return response.lower() in ["y", "yes", "true", "1"]
|
|
225
|
+
return response
|
|
226
|
+
|
|
227
|
+
def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
|
|
228
|
+
"""Return next mocked choice response."""
|
|
229
|
+
response = self._get_next_response()
|
|
230
|
+
if isinstance(response, bool):
|
|
231
|
+
raise TypeError(f"Expected string response, got bool: {response}")
|
|
232
|
+
|
|
233
|
+
# If response is empty and default is provided, return default
|
|
234
|
+
if not response and default:
|
|
235
|
+
return default
|
|
236
|
+
|
|
237
|
+
# If response is a number, treat it as choice index (1-based)
|
|
238
|
+
try:
|
|
239
|
+
choice_num = int(response)
|
|
240
|
+
if 1 <= choice_num <= len(choices):
|
|
241
|
+
return choices[choice_num - 1]
|
|
242
|
+
except (ValueError, TypeError):
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
# Otherwise, return the response as-is (assuming it's a valid choice)
|
|
246
|
+
return response
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class RichInputGatherer:
|
|
250
|
+
"""
|
|
251
|
+
Rich-based input gatherer with enhanced prompts.
|
|
252
|
+
|
|
253
|
+
Uses the 'rich' library to provide beautiful interactive prompts with:
|
|
254
|
+
- Styled prompts with colors
|
|
255
|
+
- Better formatting
|
|
256
|
+
- Input validation
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self):
|
|
260
|
+
"""Initialize RichInputGatherer with a Console instance."""
|
|
261
|
+
self.console = Console()
|
|
262
|
+
|
|
263
|
+
def prompt(
|
|
264
|
+
self,
|
|
265
|
+
message: str,
|
|
266
|
+
default: str | None = None,
|
|
267
|
+
validate: Callable[[str], tuple[bool, str]] | None = None,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Prompt user for input with optional default value and validation."""
|
|
270
|
+
while True:
|
|
271
|
+
if default is not None: # Has a default (including empty string)
|
|
272
|
+
if default:
|
|
273
|
+
result = Prompt.ask(f"[cyan]{message}[/cyan]", default=default)
|
|
274
|
+
else:
|
|
275
|
+
# Empty string default - show skip hint
|
|
276
|
+
result = Prompt.ask(f"[cyan]{message} (press Enter to skip)[/cyan]", default="")
|
|
277
|
+
else: # No default - required field
|
|
278
|
+
# Rich Prompt requires at least empty string, so we loop
|
|
279
|
+
result = ""
|
|
280
|
+
while not result:
|
|
281
|
+
result = Prompt.ask(f"[cyan]{message}[/cyan]").strip()
|
|
282
|
+
if not result:
|
|
283
|
+
self.console.print("[yellow]⚠ This value is required[/yellow]")
|
|
284
|
+
|
|
285
|
+
# Validate if validator provided
|
|
286
|
+
if validate:
|
|
287
|
+
is_valid, error_msg = validate(result)
|
|
288
|
+
if not is_valid:
|
|
289
|
+
self.console.print(f"[yellow]⚠ {error_msg}[/yellow]")
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
def prompt_yes_no(self, message: str, default: bool = True) -> bool:
|
|
295
|
+
"""Prompt user for yes/no input."""
|
|
296
|
+
return Confirm.ask(f"[cyan]{message}[/cyan]", default=default)
|
|
297
|
+
|
|
298
|
+
def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
|
|
299
|
+
"""Prompt user to choose from list of options."""
|
|
300
|
+
self.console.print(f"\n[cyan]{message}[/cyan]")
|
|
301
|
+
for i, choice in enumerate(choices, 1):
|
|
302
|
+
marker = " [dim](default)[/dim]" if choice == default else ""
|
|
303
|
+
self.console.print(f" [bold]{i}[/bold]. {choice}{marker}")
|
|
304
|
+
|
|
305
|
+
# Determine default index
|
|
306
|
+
default_index = None
|
|
307
|
+
if default and default in choices:
|
|
308
|
+
default_index = choices.index(default) + 1
|
|
309
|
+
|
|
310
|
+
while True:
|
|
311
|
+
if default_index:
|
|
312
|
+
choice_num = IntPrompt.ask(
|
|
313
|
+
f"\n[cyan]Select[/cyan] [dim]\\[1-{len(choices)}][/dim]",
|
|
314
|
+
default=default_index,
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
choice_num = IntPrompt.ask(f"\n[cyan]Select[/cyan] [dim]\\[1-{len(choices)}][/dim]")
|
|
318
|
+
|
|
319
|
+
if 1 <= choice_num <= len(choices):
|
|
320
|
+
return choices[choice_num - 1]
|
|
321
|
+
else:
|
|
322
|
+
self.console.print(
|
|
323
|
+
f"[yellow]⚠ Please enter a number between 1 and {len(choices)}[/yellow]"
|
|
324
|
+
)
|