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.
@@ -0,0 +1,705 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Avatar Deployment Configuration Tool
4
+
5
+ This script coordinates the deployment configuration process by:
6
+ 1. Loading configuration from files or user input
7
+ 2. Executing deployment steps in sequence
8
+ 3. Generating configuration files from templates
9
+ 4. Managing deployment state for resumption
10
+
11
+ Usage:
12
+ # Interactive mode
13
+ python configure.py
14
+
15
+ # Non-interactive mode with config file
16
+ python configure.py --config config.yaml
17
+
18
+ # Specify output directory
19
+ python configure.py --output-dir /app/avatar
20
+ """
21
+
22
+ import argparse
23
+ import os
24
+ import shutil
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import yaml
30
+ from jinja2 import Environment, FileSystemLoader
31
+
32
+ from octopize_avatar_deploy.download_templates import (
33
+ LocalTemplateProvider,
34
+ download_templates,
35
+ )
36
+ from octopize_avatar_deploy.input_gatherer import (
37
+ ConsoleInputGatherer,
38
+ InputGatherer,
39
+ RichInputGatherer,
40
+ )
41
+ from octopize_avatar_deploy.printer import ConsolePrinter, Printer, RichPrinter
42
+ from octopize_avatar_deploy.state_manager import DeploymentState
43
+ from octopize_avatar_deploy.steps import (
44
+ AuthentikBlueprintStep,
45
+ AuthentikStep,
46
+ DatabaseStep,
47
+ DeploymentStep,
48
+ EmailStep,
49
+ LoggingStep,
50
+ RequiredConfigStep,
51
+ StorageStep,
52
+ TelemetryStep,
53
+ UserStep,
54
+ )
55
+ from octopize_avatar_deploy.version_compat import (
56
+ SCRIPT_VERSION,
57
+ VersionError,
58
+ validate_template_version,
59
+ )
60
+
61
+
62
+ class DeploymentConfigurator:
63
+ """
64
+ Coordinates Avatar deployment configuration using modular steps.
65
+
66
+ This class acts as an executor that:
67
+ - Loads defaults and configuration
68
+ - Executes deployment steps in order
69
+ - Generates configuration files from templates
70
+ - Manages deployment state
71
+ """
72
+
73
+ # Default step classes (can be overridden in tests)
74
+ DEFAULT_STEP_CLASSES: list[type[DeploymentStep]] = [
75
+ RequiredConfigStep,
76
+ DatabaseStep,
77
+ AuthentikStep,
78
+ AuthentikBlueprintStep,
79
+ StorageStep,
80
+ EmailStep,
81
+ UserStep,
82
+ TelemetryStep,
83
+ LoggingStep,
84
+ ]
85
+
86
+ def __init__(
87
+ self,
88
+ templates_dir: Path,
89
+ output_dir: Path,
90
+ defaults_file: Path | None = None,
91
+ config: dict[str, Any] | None = None,
92
+ use_state: bool = True,
93
+ printer: Printer | None = None,
94
+ input_gatherer: InputGatherer | None = None,
95
+ step_classes: list[type[DeploymentStep]] | None = None,
96
+ ):
97
+ """
98
+ Initialize the configurator.
99
+
100
+ Args:
101
+ templates_dir: Path to templates directory
102
+ output_dir: Path where configuration files will be generated
103
+ defaults_file: Path to defaults.yaml file
104
+ config: Optional pre-loaded configuration dict
105
+ use_state: Whether to use state management for resuming
106
+ printer: Optional printer for output (defaults to ConsolePrinter)
107
+ input_gatherer: Optional input gatherer (defaults to ConsoleInputGatherer)
108
+ step_classes: Optional list of step classes to use (defaults to DEFAULT_STEP_CLASSES)
109
+ """
110
+ self.templates_dir = Path(templates_dir)
111
+ self.output_dir = Path(output_dir)
112
+ self.config = config or {}
113
+ self.use_state = use_state
114
+ self.step_classes = step_classes or self.DEFAULT_STEP_CLASSES
115
+
116
+ # Use Rich implementations if in interactive terminal, otherwise Console
117
+ if printer is None:
118
+ # Only use Rich if stdout is a TTY (interactive terminal)
119
+ if sys.stdout.isatty():
120
+ self.printer: Printer = RichPrinter()
121
+ else:
122
+ self.printer = ConsolePrinter()
123
+ else:
124
+ self.printer = printer
125
+
126
+ if input_gatherer is None:
127
+ # Only use Rich if stdin is a TTY (interactive terminal)
128
+ if sys.stdin.isatty():
129
+ self.input_gatherer: InputGatherer = RichInputGatherer()
130
+ else:
131
+ self.input_gatherer = ConsoleInputGatherer()
132
+ else:
133
+ self.input_gatherer = input_gatherer
134
+
135
+ # Initialize state manager
136
+ self.state: DeploymentState | None
137
+ if use_state:
138
+ state_file = self.output_dir / ".deployment-state.yaml"
139
+ # Derive step names from step classes
140
+ step_names = [
141
+ f"step_{i}_{step_class.name}" for i, step_class in enumerate(self.step_classes)
142
+ ]
143
+ self.state = DeploymentState(state_file, steps=step_names)
144
+ else:
145
+ self.state = None
146
+
147
+ # Load defaults
148
+ self.defaults = self._load_defaults(defaults_file)
149
+
150
+ # Initialize Jinja2 environment
151
+ self.env = Environment(
152
+ loader=FileSystemLoader(self.templates_dir),
153
+ variable_start_string="{{",
154
+ variable_end_string="}}",
155
+ trim_blocks=True,
156
+ lstrip_blocks=True,
157
+ )
158
+
159
+ def _load_defaults(self, defaults_file: Path | None = None) -> dict[str, Any]:
160
+ """Load default configuration from defaults.yaml."""
161
+ if defaults_file and defaults_file.exists():
162
+ with open(defaults_file) as f:
163
+ return yaml.safe_load(f)
164
+ else:
165
+ # Try to find defaults.yaml in the same directory as this script
166
+ script_dir = Path(__file__).parent
167
+ default_defaults = script_dir / "defaults.yaml"
168
+ if default_defaults.exists():
169
+ with open(default_defaults) as f:
170
+ return yaml.safe_load(f)
171
+ else:
172
+ raise FileNotFoundError(
173
+ f"defaults.yaml not found at {defaults_file} or {default_defaults}"
174
+ )
175
+
176
+ def render_template(self, template_name: str, output_name: str) -> None:
177
+ """
178
+ Render a template file with configuration values.
179
+
180
+ Args:
181
+ template_name: Name of the template file
182
+ output_name: Name of the output file
183
+ """
184
+ try:
185
+ template = self.env.get_template(template_name)
186
+ rendered = template.render(**self.config)
187
+
188
+ output_path = self.output_dir / output_name
189
+ output_path.parent.mkdir(parents=True, exist_ok=True)
190
+ output_path.write_text(rendered)
191
+
192
+ self.printer.print_success(f"Generated: {output_path}")
193
+ except Exception as e:
194
+ self.printer.print_error(f"Error rendering {template_name}: {e}")
195
+ raise
196
+
197
+ def generate_configs(self) -> None:
198
+ """Generate all configuration files from templates."""
199
+
200
+ self.printer.print_header("Generating Configuration Files")
201
+
202
+ # Generate .env file
203
+ self.render_template(".env.template", ".env")
204
+
205
+ # Generate nginx.conf
206
+ nginx_dir = self.output_dir / "nginx"
207
+ nginx_dir.mkdir(parents=True, exist_ok=True)
208
+ self.render_template("nginx.conf.template", "nginx/nginx.conf")
209
+
210
+ # Generate Authentik blueprint
211
+ authentik_dir = self.output_dir / "authentik"
212
+ authentik_dir.mkdir(parents=True, exist_ok=True)
213
+ self.render_template(
214
+ "authentik/octopize-avatar-blueprint.yaml.j2",
215
+ "authentik/octopize-avatar-blueprint.yaml",
216
+ )
217
+
218
+ # Copy docker-compose.yml from templates (it may be templated in the future)
219
+ docker_compose_src = self.templates_dir / "docker-compose.yml"
220
+ docker_compose_dst = self.output_dir / "docker-compose.yml"
221
+ if docker_compose_src.exists():
222
+ shutil.copy2(docker_compose_src, docker_compose_dst)
223
+ self.printer.print_success(f"Generated: {docker_compose_dst}")
224
+
225
+ self.printer.print()
226
+ self.printer.print_success("Configuration files generated successfully!")
227
+
228
+ def save_config_to_file(self, config_file: Path) -> None:
229
+ """Save current configuration to a YAML file."""
230
+ with open(config_file, "w") as f:
231
+ yaml.dump(self.config, f, default_flow_style=False, sort_keys=False)
232
+ self.printer.print()
233
+ self.printer.print_success(f"Configuration saved to {config_file}")
234
+
235
+ def write_secrets(self, secrets: dict[str, str]) -> None:
236
+ """
237
+ Write secrets to the .secrets/ directory.
238
+
239
+ Args:
240
+ secrets: Dictionary of {filename: secret_value}
241
+ """
242
+ secrets_dir = self.output_dir / ".secrets"
243
+ secrets_dir.mkdir(parents=True, exist_ok=True)
244
+
245
+ for secret_name, secret_value in secrets.items():
246
+ secret_file = secrets_dir / secret_name
247
+ secret_file.write_text(secret_value)
248
+
249
+ def run(
250
+ self,
251
+ interactive: bool = True,
252
+ config_file: Path | None = None,
253
+ save_config: bool = False,
254
+ ) -> None:
255
+ """
256
+ Run the configuration process using step-based architecture.
257
+
258
+ Args:
259
+ interactive: Whether to prompt for values interactively
260
+ config_file: Path to YAML config file to load
261
+ save_config: Whether to save configuration to file
262
+ """
263
+ # Check for existing state and prompt to resume or restart
264
+ if self.state and self.state.has_started() and not self.state.is_complete():
265
+ if interactive:
266
+ self.state.print_status()
267
+ self.printer.print_header("")
268
+ response = self.input_gatherer.prompt_yes_no(
269
+ "Resume from where you left off?", default=True
270
+ )
271
+ if not response:
272
+ self.printer.print("Starting fresh configuration...")
273
+ self.state.reset()
274
+ else:
275
+ self.printer.print("Resuming from last completed step...")
276
+ # Load saved config from state
277
+ self.config.update(self.state.get_config())
278
+ else:
279
+ # Non-interactive mode: always resume if state exists
280
+ self.config.update(self.state.get_config())
281
+
282
+ # Load configuration from file if provided
283
+ if config_file and config_file.exists():
284
+ self.printer.print(f"Loading configuration from {config_file}...")
285
+ try:
286
+ with open(config_file) as f:
287
+ loaded_config = yaml.safe_load(f) or {}
288
+ self.config.update(loaded_config)
289
+ except yaml.YAMLError as e:
290
+ raise RuntimeError(f"Failed to parse YAML config file: {e}") from e
291
+ except Exception as e:
292
+ raise RuntimeError(f"Failed to load config file: {e}") from e
293
+
294
+ # Instantiate deployment steps
295
+ steps = [
296
+ step_class(
297
+ self.output_dir,
298
+ self.defaults,
299
+ self.config,
300
+ interactive,
301
+ self.printer,
302
+ self.input_gatherer,
303
+ )
304
+ for step_class in self.step_classes
305
+ ]
306
+
307
+ self.printer.print_header("Avatar Deployment Configuration")
308
+ self.printer.print()
309
+ self.printer.print("Executing configuration steps...")
310
+ self.printer.print()
311
+
312
+ # Execute each step
313
+ all_secrets = {}
314
+ for i, step in enumerate(steps):
315
+ step_name = f"step_{i}_{step.name}"
316
+
317
+ # Skip if step already completed (when resuming)
318
+ if self.state and self.state.is_step_completed(step_name):
319
+ self.printer.print_step(step.description, skipped=True)
320
+ continue
321
+
322
+ self.printer.print_step(step.description)
323
+
324
+ # Mark step as started in state
325
+ if self.state:
326
+ self.state.mark_step_started(step_name)
327
+
328
+ # Collect configuration from step
329
+ step_config = step.collect_config()
330
+ self.config.update(step_config)
331
+
332
+ # Generate secrets from step
333
+ step_secrets = step.generate_secrets()
334
+ all_secrets.update(step_secrets)
335
+
336
+ # Validate step
337
+ if not step.validate():
338
+ raise ValueError(f"Validation failed for step: {step.name}")
339
+
340
+ # Mark step as completed and save config to state
341
+ if self.state:
342
+ self.state.mark_step_completed(step_name)
343
+ self.state.update_config(self.config)
344
+
345
+ # Generate configuration files
346
+ self.generate_configs()
347
+
348
+ # Write all secrets
349
+ if all_secrets:
350
+ self.printer.print()
351
+ self.printer.print(f"Writing {len(all_secrets)} secrets to .secrets/ directory...")
352
+ self.write_secrets(all_secrets)
353
+ self.printer.print_success("Secrets written successfully")
354
+
355
+ # Save config if requested
356
+ if save_config:
357
+ config_output = self.output_dir / "deployment-config.yaml"
358
+ self.save_config_to_file(config_output)
359
+
360
+ # Success message
361
+ self.printer.print_header("Configuration Complete!")
362
+ self.printer.print(f"\nConfiguration files generated in: {self.output_dir}")
363
+ self.printer.print("\nNext steps:")
364
+ self.printer.print("1. Review and edit the generated .env file")
365
+ self.printer.print("2. Fill in any remaining secrets in .secrets/ directory")
366
+ self.printer.print("3. Configure TLS certificates in the tls/ directory")
367
+ self.printer.print("4. Run: docker compose up -d")
368
+
369
+
370
+ class DeploymentRunner:
371
+ """
372
+ High-level orchestrator for the Avatar deployment process.
373
+
374
+ This class provides a CLI-independent entry point that coordinates:
375
+ - Template downloading from GitHub
376
+ - Template verification
377
+ - Configuration generation via DeploymentConfigurator
378
+
379
+ This allows the deployment process to be used programmatically
380
+ without depending on command-line arguments.
381
+ """
382
+
383
+ def __init__(
384
+ self,
385
+ output_dir: Path | str,
386
+ template_from: str | Path = "github",
387
+ verbose: bool = False,
388
+ printer: Printer | None = None,
389
+ input_gatherer: InputGatherer | None = None,
390
+ ):
391
+ """
392
+ Initialize the deployment runner.
393
+
394
+ Args:
395
+ output_dir: Directory where configuration files will be generated
396
+ template_from: Either 'github' to download from the repo, or a path
397
+ to local templates directory
398
+ verbose: Enable verbose output
399
+ printer: Optional printer for output (defaults to ConsolePrinter)
400
+ input_gatherer: Optional input gatherer for prompts
401
+ """
402
+ self.output_dir = Path(output_dir)
403
+ self.template_from = template_from
404
+ self.verbose = verbose
405
+
406
+ # Use Rich implementations if in interactive terminal, otherwise Console
407
+ if printer is None:
408
+ # Only use Rich if stdout is a TTY (interactive terminal)
409
+ if sys.stdout.isatty():
410
+ try:
411
+ self.printer: Printer = RichPrinter()
412
+ except ImportError:
413
+ self.printer = ConsolePrinter()
414
+ else:
415
+ self.printer = ConsolePrinter()
416
+ else:
417
+ self.printer = printer
418
+
419
+ if input_gatherer is None:
420
+ # Only use Rich if stdin is a TTY (interactive terminal)
421
+ if sys.stdin.isatty():
422
+ try:
423
+ self.input_gatherer: InputGatherer = RichInputGatherer()
424
+ except ImportError:
425
+ self.input_gatherer = ConsoleInputGatherer()
426
+ else:
427
+ self.input_gatherer = ConsoleInputGatherer()
428
+ else:
429
+ self.input_gatherer = input_gatherer
430
+
431
+ # Templates are always stored in output_dir/.avatar-templates
432
+ self.templates_dir = self.output_dir / ".avatar-templates"
433
+
434
+ def ensure_templates(self) -> bool:
435
+ """
436
+ Ensure templates are available by downloading from GitHub or copying from local path.
437
+
438
+ Returns:
439
+ True if templates are available, False otherwise
440
+ """
441
+ # Handle 'github' - download from repository
442
+ if self.template_from == "github":
443
+ if self.verbose:
444
+ self.printer.print("Downloading deployment templates from GitHub...")
445
+
446
+ success = download_templates(
447
+ output_dir=self.templates_dir,
448
+ force=False, # Use cached if available
449
+ branch="main",
450
+ verbose=self.verbose,
451
+ )
452
+
453
+ if not success:
454
+ self.printer.print_error("Failed to download templates from GitHub")
455
+ return False
456
+
457
+ return self._verify_templates()
458
+
459
+ # Handle local path - copy templates from specified directory
460
+ template_source = Path(self.template_from)
461
+ if not template_source.exists():
462
+ self.printer.print_error(f"Template source directory not found: {template_source}")
463
+ return False
464
+
465
+ if self.verbose:
466
+ self.printer.print(f"Copying templates from {template_source}")
467
+
468
+ provider = LocalTemplateProvider(source_dir=str(template_source), verbose=self.verbose)
469
+
470
+ success = provider.provide_all(self.templates_dir)
471
+ if not success:
472
+ self.printer.print_warning("Failed to copy some templates")
473
+ return False
474
+
475
+ return self._verify_templates()
476
+
477
+ def _verify_templates(self) -> bool:
478
+ """
479
+ Verify that required templates exist.
480
+
481
+ Returns:
482
+ True if templates are available, False otherwise
483
+ """
484
+ from octopize_avatar_deploy.download_templates import REQUIRED_FILES
485
+
486
+ if not self.templates_dir.exists():
487
+ if self.verbose:
488
+ self.printer.print_error(f"Templates directory not found: {self.templates_dir}")
489
+ return False
490
+
491
+ # Check for required files
492
+ missing_files = []
493
+ for filename in REQUIRED_FILES:
494
+ if not (self.templates_dir / filename).exists():
495
+ missing_files.append(filename)
496
+
497
+ if missing_files:
498
+ if self.verbose:
499
+ self.printer.print_error(
500
+ f"Missing required template files: {', '.join(missing_files)}"
501
+ )
502
+ return False
503
+
504
+ if self.verbose:
505
+ self.printer.print_success(f"Found all {len(REQUIRED_FILES)} required template files")
506
+
507
+ # Validate template version compatibility
508
+ return self._validate_template_version()
509
+
510
+ def _validate_template_version(self) -> bool:
511
+ """
512
+ Validate that the template version is compatible with the script version.
513
+
514
+ Returns:
515
+ True if compatible, False otherwise
516
+ """
517
+ version_file = self.templates_dir / ".template-version"
518
+
519
+ if not version_file.exists():
520
+ if self.verbose:
521
+ self.printer.print_warning(
522
+ "No .template-version file found, skipping version check"
523
+ )
524
+ return True
525
+
526
+ try:
527
+ validate_template_version(
528
+ version_file=version_file,
529
+ script_version=SCRIPT_VERSION,
530
+ verbose=self.verbose,
531
+ )
532
+ if self.verbose:
533
+ self.printer.print_success(
534
+ f"Template version is compatible with script version {SCRIPT_VERSION}"
535
+ )
536
+ return True
537
+ except VersionError as e:
538
+ self.printer.print_error(str(e))
539
+ return False
540
+
541
+ def run(
542
+ self,
543
+ interactive: bool = True,
544
+ config_file: Path | None = None,
545
+ save_config: bool = False,
546
+ ) -> None:
547
+ """
548
+ Run the complete deployment configuration process.
549
+
550
+ Args:
551
+ interactive: Whether to prompt for values interactively
552
+ config_file: Optional YAML config file to load
553
+ save_config: Whether to save configuration to file
554
+
555
+ Raises:
556
+ RuntimeError: If templates are not available
557
+ FileNotFoundError: If config file is specified but doesn't exist
558
+ yaml.YAMLError: If config file has invalid YAML syntax
559
+ ValueError: If config file has invalid values
560
+ KeyboardInterrupt: If user cancels the process
561
+ Exception: For other errors during configuration
562
+ """
563
+ # Validate config file if provided
564
+ if config_file is not None:
565
+ if not config_file.exists():
566
+ self.printer.print_error(f"Config file not found: {config_file}")
567
+ raise FileNotFoundError(f"Config file not found: {config_file}")
568
+
569
+ # Try to load and validate the YAML
570
+ try:
571
+ with open(config_file) as f:
572
+ config_data = yaml.safe_load(f)
573
+
574
+ if config_data is None:
575
+ self.printer.print_error(f"Config file is empty: {config_file}")
576
+ raise ValueError(f"Config file is empty: {config_file}")
577
+
578
+ if not isinstance(config_data, dict):
579
+ self.printer.print_error(
580
+ f"Config file must contain a YAML dictionary, "
581
+ f"got {type(config_data).__name__}"
582
+ )
583
+ raise ValueError(
584
+ f"Config file must contain a YAML dictionary, "
585
+ f"got {type(config_data).__name__}"
586
+ )
587
+
588
+ except yaml.YAMLError as e:
589
+ self.printer.print_error(f"Invalid YAML syntax in config file: {config_file}")
590
+ self.printer.print_error(str(e))
591
+ raise
592
+
593
+ # Ensure templates are available
594
+ if not self.ensure_templates():
595
+ raise RuntimeError(
596
+ "Templates not available. Use --template-from github to download from the "
597
+ "repository, or provide a valid local path."
598
+ )
599
+
600
+ # Create and run configurator
601
+ configurator = DeploymentConfigurator(
602
+ templates_dir=self.templates_dir,
603
+ output_dir=self.output_dir,
604
+ printer=self.printer,
605
+ input_gatherer=self.input_gatherer,
606
+ )
607
+
608
+ configurator.run(
609
+ interactive=interactive,
610
+ config_file=config_file,
611
+ save_config=save_config,
612
+ )
613
+
614
+
615
+ def main():
616
+ """CLI entry point for Avatar deployment configuration."""
617
+ parser = argparse.ArgumentParser(
618
+ description="Avatar Deployment Configuration Tool",
619
+ formatter_class=argparse.RawDescriptionHelpFormatter,
620
+ )
621
+
622
+ parser.add_argument(
623
+ "--output-dir",
624
+ type=Path,
625
+ default=Path.cwd(),
626
+ help="Output directory for generated files (default: current directory)",
627
+ )
628
+
629
+ parser.add_argument(
630
+ "--template-from",
631
+ type=str,
632
+ default="github",
633
+ help="Template source: 'github' to download from repo, or path to local templates "
634
+ "directory (default: github)",
635
+ )
636
+
637
+ parser.add_argument(
638
+ "--config",
639
+ type=Path,
640
+ help="YAML configuration file to load",
641
+ )
642
+
643
+ parser.add_argument(
644
+ "--non-interactive",
645
+ action="store_true",
646
+ help="Run in non-interactive mode (use defaults or config file)",
647
+ )
648
+
649
+ parser.add_argument(
650
+ "--save-config",
651
+ action="store_true",
652
+ help="Save configuration to deployment-config.yaml",
653
+ )
654
+
655
+ parser.add_argument(
656
+ "--verbose",
657
+ action="store_true",
658
+ help="Verbose output",
659
+ )
660
+
661
+ args = parser.parse_args()
662
+
663
+ # Check if we're in test mode
664
+ test_printer = None
665
+ test_input_gatherer = None
666
+ if os.environ.get("AVATAR_DEPLOY_TEST_MODE") == "1":
667
+ from octopize_avatar_deploy.cli_test_harness import (
668
+ get_test_input_gatherer,
669
+ get_test_printer,
670
+ )
671
+
672
+ test_printer = get_test_printer()
673
+ test_input_gatherer = get_test_input_gatherer()
674
+
675
+ # Create deployment runner
676
+ runner = DeploymentRunner(
677
+ output_dir=args.output_dir,
678
+ template_from=args.template_from,
679
+ verbose=args.verbose,
680
+ printer=test_printer,
681
+ input_gatherer=test_input_gatherer,
682
+ )
683
+
684
+ try:
685
+ runner.run(
686
+ interactive=not args.non_interactive,
687
+ config_file=args.config,
688
+ save_config=args.save_config,
689
+ )
690
+ except KeyboardInterrupt:
691
+ print("\n\nConfiguration cancelled by user.")
692
+ sys.exit(1)
693
+ except RuntimeError as e:
694
+ print(f"\n✗ Error: {e}")
695
+ sys.exit(1)
696
+ except Exception as e:
697
+ print(f"\n✗ Error: {e}")
698
+ import traceback
699
+
700
+ traceback.print_exc()
701
+ sys.exit(1)
702
+
703
+
704
+ if __name__ == "__main__":
705
+ main()