envira 0.0.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.
- envira/__init__.py +7 -0
- envira/__main__.py +13 -0
- envira/_version.py +21 -0
- envira/cli/README.md +79 -0
- envira/cli/__init__.py +52 -0
- envira/cli/installer.py +75 -0
- envira/cli/models.py +22 -0
- envira/cli/runner.py +387 -0
- envira/cli/steps.py +57 -0
- envira/cli/ui.py +334 -0
- envira/cli/utils.py +99 -0
- envira/software/__init__.py +10 -0
- envira/software/base.py +50 -0
- envira/software/essentials.py +533 -0
- envira/util/__init__.py +8 -0
- envira/util/const.py +3 -0
- envira/util/impl_dispatcher.py +224 -0
- envira/util/result.py +17 -0
- envira-0.0.0.dist-info/METADATA +805 -0
- envira-0.0.0.dist-info/RECORD +24 -0
- envira-0.0.0.dist-info/WHEEL +5 -0
- envira-0.0.0.dist-info/entry_points.txt +2 -0
- envira-0.0.0.dist-info/licenses/LICENSE +661 -0
- envira-0.0.0.dist-info/top_level.txt +1 -0
envira/__init__.py
ADDED
envira/__main__.py
ADDED
envira/_version.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
|
|
5
|
+
|
|
6
|
+
TYPE_CHECKING = False
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
12
|
+
else:
|
|
13
|
+
VERSION_TUPLE = object
|
|
14
|
+
|
|
15
|
+
version: str
|
|
16
|
+
__version__: str
|
|
17
|
+
__version_tuple__: VERSION_TUPLE
|
|
18
|
+
version_tuple: VERSION_TUPLE
|
|
19
|
+
|
|
20
|
+
__version__ = version = '0.0.0'
|
|
21
|
+
__version_tuple__ = version_tuple = (0, 0, 0)
|
envira/cli/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Software Installation CLI - Modular Architecture
|
|
2
|
+
|
|
3
|
+
This directory contains the modular implementation of the Software Installation CLI, refactored from the original monolithic `cli.py` file.
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
envira/cli/
|
|
9
|
+
├── __init__.py # Main entry point and exports
|
|
10
|
+
├── models.py # Data models (InstallationStep)
|
|
11
|
+
├── utils.py # Utility functions (privilege detection, symbols, user info)
|
|
12
|
+
├── ui.py # User interface components (tables, interactive selection)
|
|
13
|
+
├── steps.py # Step preparation and dependency resolution
|
|
14
|
+
├── runner.py # Installation execution and streaming
|
|
15
|
+
├── installer.py # Main orchestrator class
|
|
16
|
+
└── README.md # This file
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Recommended (using package main)
|
|
22
|
+
```bash
|
|
23
|
+
python -m envira
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Legacy (backward compatibility)
|
|
27
|
+
```bash
|
|
28
|
+
python cli.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Module Responsibilities
|
|
32
|
+
|
|
33
|
+
### `models.py`
|
|
34
|
+
- `InstallationStep`: Data class representing a single installation step
|
|
35
|
+
|
|
36
|
+
### `utils.py`
|
|
37
|
+
- Privilege detection (`is_running_as_sudo`, `detect_privilege_level`)
|
|
38
|
+
- User context management (`get_real_user_info`)
|
|
39
|
+
- Display symbols (`get_installation_symbol`)
|
|
40
|
+
- Scope planning (`get_planned_installation_scope`, `is_software_selectable`)
|
|
41
|
+
|
|
42
|
+
### `ui.py`
|
|
43
|
+
- Keyboard input handling (`get_key`)
|
|
44
|
+
- Software table display (`show_software_table`)
|
|
45
|
+
- Interactive selection (`interactive_software_selection`)
|
|
46
|
+
- Dependency auto-selection (`auto_select_dependencies`)
|
|
47
|
+
- Status symbol generation (`get_selection_status_symbol`)
|
|
48
|
+
|
|
49
|
+
### `steps.py`
|
|
50
|
+
- Installation step preparation (`prepare_installation_steps`)
|
|
51
|
+
- Dependency resolution with proper scope checking
|
|
52
|
+
|
|
53
|
+
### `runner.py`
|
|
54
|
+
- Software installation with streaming (`install_software_with_streaming`)
|
|
55
|
+
- Installation orchestration (`run_installation`)
|
|
56
|
+
- Progress visualization and logging
|
|
57
|
+
- Installation summary (`show_installation_summary`)
|
|
58
|
+
|
|
59
|
+
### `installer.py`
|
|
60
|
+
- Main orchestrator class (`SoftwareInstaller`)
|
|
61
|
+
- Coordinates all the above modules
|
|
62
|
+
- Main CLI flow and user interaction
|
|
63
|
+
|
|
64
|
+
## Key Features
|
|
65
|
+
|
|
66
|
+
- **Modular Design**: Each module has a clear responsibility
|
|
67
|
+
- **Dependency Resolution**: Smart dependency handling that respects installation scopes
|
|
68
|
+
- **Upgrade Support**: Can upgrade already-installed software
|
|
69
|
+
- **Streaming Output**: Real-time installation progress and logs
|
|
70
|
+
- **User Context Switching**: Proper handling of sudo/user installations
|
|
71
|
+
- **Rich UI**: Beautiful terminal interface with tables and progress indicators
|
|
72
|
+
|
|
73
|
+
## Migration from Legacy CLI
|
|
74
|
+
|
|
75
|
+
The new modular structure maintains full backward compatibility while providing:
|
|
76
|
+
- Better code organization and maintainability
|
|
77
|
+
- Easier testing and debugging
|
|
78
|
+
- Clear separation of concerns
|
|
79
|
+
- Reusable components
|
envira/cli/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Software Installation CLI with Rich Visualization
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .installer import SoftwareInstaller
|
|
8
|
+
from .models import InstallationStep
|
|
9
|
+
|
|
10
|
+
__all__ = ['SoftwareInstaller', 'InstallationStep']
|
|
11
|
+
|
|
12
|
+
def get_version():
|
|
13
|
+
"""Get the version of envira"""
|
|
14
|
+
try:
|
|
15
|
+
from envira import __version__
|
|
16
|
+
return __version__
|
|
17
|
+
except ImportError:
|
|
18
|
+
return "unknown"
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
"""Main CLI entry point"""
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="envira",
|
|
24
|
+
description="Software environment management tool",
|
|
25
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
26
|
+
epilog="""
|
|
27
|
+
Examples:
|
|
28
|
+
envira # Run interactive installation
|
|
29
|
+
envira --version # Show version information
|
|
30
|
+
"""
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--version",
|
|
35
|
+
action="version",
|
|
36
|
+
version=f"envira {get_version()}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Parse arguments
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
|
|
42
|
+
# Run the installer
|
|
43
|
+
try:
|
|
44
|
+
installer = SoftwareInstaller()
|
|
45
|
+
installer.run()
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
print("\nInstallation cancelled by user")
|
|
48
|
+
return 1
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print(f"Error: {e}")
|
|
51
|
+
return 1
|
|
52
|
+
return 0
|
envira/cli/installer.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Software Installer orchestrator class
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Set, List
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Confirm
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.align import Align
|
|
11
|
+
from rich import box
|
|
12
|
+
|
|
13
|
+
from .models import InstallationStep
|
|
14
|
+
from .utils import is_running_as_sudo, detect_privilege_level
|
|
15
|
+
from .ui import show_software_table, interactive_software_selection
|
|
16
|
+
from .steps import prepare_installation_steps
|
|
17
|
+
from .runner import run_installation
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SoftwareInstaller:
|
|
21
|
+
"""Main orchestrator for software installation"""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.console = Console()
|
|
25
|
+
self.is_sudo = is_running_as_sudo()
|
|
26
|
+
self.selected_software: Set[str] = set()
|
|
27
|
+
self.installation_steps: List[InstallationStep] = []
|
|
28
|
+
|
|
29
|
+
def run(self):
|
|
30
|
+
"""Main CLI function"""
|
|
31
|
+
self.console.print()
|
|
32
|
+
self.console.print(Panel(
|
|
33
|
+
Align.center("[bold cyan]Software Installation Manager[/bold cyan]"),
|
|
34
|
+
box=box.DOUBLE
|
|
35
|
+
))
|
|
36
|
+
self.console.print()
|
|
37
|
+
|
|
38
|
+
# Show privilege level
|
|
39
|
+
privilege_info = detect_privilege_level()
|
|
40
|
+
self.console.print(f"[dim]Running as: {privilege_info}[/dim]")
|
|
41
|
+
self.console.print()
|
|
42
|
+
|
|
43
|
+
# Show software table
|
|
44
|
+
show_software_table(self.console)
|
|
45
|
+
|
|
46
|
+
# Ask if user wants to install
|
|
47
|
+
if not Confirm.ask("Do you want to install software?"):
|
|
48
|
+
self.console.print("[yellow]Installation cancelled[/yellow]")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Interactive software selection
|
|
52
|
+
self.console.print()
|
|
53
|
+
selected_software = interactive_software_selection(self.console, self.is_sudo)
|
|
54
|
+
|
|
55
|
+
if not selected_software:
|
|
56
|
+
self.console.print("[yellow]No software selected[/yellow]")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Prepare installation steps
|
|
60
|
+
steps = prepare_installation_steps(selected_software, self.is_sudo)
|
|
61
|
+
|
|
62
|
+
if not steps:
|
|
63
|
+
self.console.print("[yellow]All selected software is already installed[/yellow]")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Confirm installation
|
|
67
|
+
self.console.print()
|
|
68
|
+
self.console.print(f"[cyan]Will install {len(steps)} packages[/cyan]")
|
|
69
|
+
if not Confirm.ask("Proceed with installation?"):
|
|
70
|
+
self.console.print("[yellow]Installation cancelled[/yellow]")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Run installation
|
|
74
|
+
self.console.print()
|
|
75
|
+
run_installation(steps, self.console, self.is_sudo)
|
envira/cli/models.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for the CLI
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from ..software import Software
|
|
9
|
+
from ..util.result import Result
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class InstallationStep:
|
|
14
|
+
software: Software
|
|
15
|
+
scope: str # "sudo" or "user"
|
|
16
|
+
status: str # "pending", "running", "success", "failure", "skipped"
|
|
17
|
+
result: Optional[Result] = None
|
|
18
|
+
log_output: List[str] = None
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
if self.log_output is None:
|
|
22
|
+
self.log_output = []
|
envira/cli/runner.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Installation execution and streaming for the CLI
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, List, Callable
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.layout import Layout
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.align import Align
|
|
18
|
+
from rich import box
|
|
19
|
+
|
|
20
|
+
from ..util.result import Result, Success, Failure, Skip
|
|
21
|
+
from .models import InstallationStep
|
|
22
|
+
from .utils import get_real_user_info
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def install_software_with_streaming(step: InstallationStep, is_sudo: bool,
|
|
26
|
+
streaming_enabled: bool, callback: Optional[Callable] = None) -> Result:
|
|
27
|
+
"""Install a single software package with streaming output"""
|
|
28
|
+
# Create a wrapper class to capture the original software methods
|
|
29
|
+
original_run = subprocess.run
|
|
30
|
+
|
|
31
|
+
def streaming_run(*args, **kwargs):
|
|
32
|
+
# If capture_output is True, we want to stream instead
|
|
33
|
+
if kwargs.get('capture_output', False) and streaming_enabled:
|
|
34
|
+
try:
|
|
35
|
+
kwargs.pop('capture_output')
|
|
36
|
+
# Remove parameters that Popen doesn't accept
|
|
37
|
+
check_param = kwargs.pop('check', False)
|
|
38
|
+
kwargs['stdout'] = subprocess.PIPE
|
|
39
|
+
kwargs['stderr'] = subprocess.PIPE
|
|
40
|
+
kwargs['text'] = True
|
|
41
|
+
|
|
42
|
+
process = subprocess.Popen(*args, **kwargs)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
# If process creation fails, fall back to original behavior
|
|
45
|
+
return original_run(*args, **kwargs)
|
|
46
|
+
|
|
47
|
+
# Capture both stdout and stderr
|
|
48
|
+
captured_output = []
|
|
49
|
+
captured_errors = []
|
|
50
|
+
|
|
51
|
+
def read_stdout():
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
line = process.stdout.readline()
|
|
55
|
+
if not line: # EOF
|
|
56
|
+
break
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line:
|
|
59
|
+
captured_output.append(line)
|
|
60
|
+
step.log_output.append(line)
|
|
61
|
+
if callback:
|
|
62
|
+
callback(line)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
finally:
|
|
66
|
+
try:
|
|
67
|
+
process.stdout.close()
|
|
68
|
+
except:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def read_stderr():
|
|
72
|
+
try:
|
|
73
|
+
while True:
|
|
74
|
+
line = process.stderr.readline()
|
|
75
|
+
if not line: # EOF
|
|
76
|
+
break
|
|
77
|
+
line = line.strip()
|
|
78
|
+
if line:
|
|
79
|
+
captured_errors.append(line)
|
|
80
|
+
step.log_output.append(f"ERROR: {line}")
|
|
81
|
+
if callback:
|
|
82
|
+
callback(f"ERROR: {line}")
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
finally:
|
|
86
|
+
try:
|
|
87
|
+
process.stderr.close()
|
|
88
|
+
except:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
# Start threads to read stdout and stderr
|
|
92
|
+
stdout_thread = threading.Thread(target=read_stdout, daemon=True)
|
|
93
|
+
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
|
94
|
+
|
|
95
|
+
stdout_thread.start()
|
|
96
|
+
stderr_thread.start()
|
|
97
|
+
|
|
98
|
+
# Wait for process to complete
|
|
99
|
+
try:
|
|
100
|
+
returncode = process.wait()
|
|
101
|
+
except Exception as e:
|
|
102
|
+
# If process.wait() fails, kill the process and re-raise
|
|
103
|
+
try:
|
|
104
|
+
process.kill()
|
|
105
|
+
process.wait()
|
|
106
|
+
except:
|
|
107
|
+
pass
|
|
108
|
+
raise e
|
|
109
|
+
|
|
110
|
+
# Give threads a moment to finish reading any remaining output
|
|
111
|
+
stdout_thread.join(timeout=2.0)
|
|
112
|
+
stderr_thread.join(timeout=2.0)
|
|
113
|
+
|
|
114
|
+
if returncode != 0 and check_param:
|
|
115
|
+
# Create detailed error message
|
|
116
|
+
cmd_str = ' '.join(args[0]) if isinstance(args[0], list) else str(args[0])
|
|
117
|
+
error_details = []
|
|
118
|
+
if captured_errors:
|
|
119
|
+
error_details.extend(captured_errors[-3:]) # Last 3 error lines
|
|
120
|
+
if captured_output and not captured_errors:
|
|
121
|
+
error_details.extend(captured_output[-2:]) # Last 2 output lines if no stderr
|
|
122
|
+
|
|
123
|
+
if error_details:
|
|
124
|
+
error_msg = f"Command '{cmd_str}' failed: {'; '.join(error_details)}"
|
|
125
|
+
else:
|
|
126
|
+
error_msg = f"Command '{cmd_str}' failed with exit code {returncode}"
|
|
127
|
+
|
|
128
|
+
# Create exception with detailed message
|
|
129
|
+
exc = subprocess.CalledProcessError(returncode, args[0])
|
|
130
|
+
exc.stderr = '\n'.join(captured_errors) if captured_errors else None
|
|
131
|
+
exc.stdout = '\n'.join(captured_output) if captured_output else None
|
|
132
|
+
# Override the string representation
|
|
133
|
+
exc.__str__ = lambda: error_msg
|
|
134
|
+
raise exc
|
|
135
|
+
|
|
136
|
+
# Create a mock result object that behaves like subprocess.run result
|
|
137
|
+
class MockResult:
|
|
138
|
+
def __init__(self, returncode, stdout, stderr):
|
|
139
|
+
self.returncode = returncode
|
|
140
|
+
self.stdout = '\n'.join(stdout) if stdout else ""
|
|
141
|
+
self.stderr = '\n'.join(stderr) if stderr else ""
|
|
142
|
+
|
|
143
|
+
return MockResult(returncode, captured_output, captured_errors)
|
|
144
|
+
else:
|
|
145
|
+
return original_run(*args, **kwargs)
|
|
146
|
+
|
|
147
|
+
# Temporarily replace subprocess.run
|
|
148
|
+
subprocess.run = streaming_run
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
if step.scope == "sudo":
|
|
152
|
+
result = step.software.install_sudo()
|
|
153
|
+
else:
|
|
154
|
+
# User scope installation - check if we need to switch user context
|
|
155
|
+
if is_sudo:
|
|
156
|
+
# Running as sudo but doing user installation - we should warn or switch context
|
|
157
|
+
real_user, real_home = get_real_user_info()
|
|
158
|
+
step.log_output.append(f"User-scope installation while running as sudo - target user: {real_user}")
|
|
159
|
+
step.log_output.append(f"Target home directory: {real_home}")
|
|
160
|
+
|
|
161
|
+
# Set environment variables for the user installation
|
|
162
|
+
original_env = os.environ.copy()
|
|
163
|
+
try:
|
|
164
|
+
os.environ['HOME'] = real_home
|
|
165
|
+
os.environ['USER'] = real_user
|
|
166
|
+
result = step.software.install_user()
|
|
167
|
+
finally:
|
|
168
|
+
# Restore original environment
|
|
169
|
+
os.environ.clear()
|
|
170
|
+
os.environ.update(original_env)
|
|
171
|
+
else:
|
|
172
|
+
result = step.software.install_user()
|
|
173
|
+
return result
|
|
174
|
+
except Exception as e:
|
|
175
|
+
return Failure(str(e))
|
|
176
|
+
finally:
|
|
177
|
+
# Restore original subprocess.run
|
|
178
|
+
subprocess.run = original_run
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_installation(steps: List[InstallationStep], console: Console, is_sudo: bool):
|
|
182
|
+
"""Run the installation process with docker-like visualization"""
|
|
183
|
+
if not steps:
|
|
184
|
+
console.print("[yellow]No software to install[/yellow]")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
streaming_enabled = True # Global flag for streaming
|
|
188
|
+
|
|
189
|
+
layout = Layout()
|
|
190
|
+
layout.split_column(
|
|
191
|
+
Layout(name="header", size=3),
|
|
192
|
+
Layout(name="progress", size=len(steps) + 2),
|
|
193
|
+
Layout(name="current", size=10),
|
|
194
|
+
Layout(name="logs")
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Header
|
|
198
|
+
layout["header"].update(
|
|
199
|
+
Panel(
|
|
200
|
+
Align.center(f"[bold cyan]Installing {len(steps)} packages[/bold cyan]"),
|
|
201
|
+
box=box.DOUBLE
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Progress table
|
|
206
|
+
progress_table = Table(box=box.SIMPLE)
|
|
207
|
+
progress_table.add_column("Step", width=4)
|
|
208
|
+
progress_table.add_column("Software", style="cyan")
|
|
209
|
+
progress_table.add_column("Scope", width=6)
|
|
210
|
+
progress_table.add_column("Status", width=12)
|
|
211
|
+
|
|
212
|
+
# Current step display
|
|
213
|
+
current_text = Text()
|
|
214
|
+
|
|
215
|
+
# Logs display
|
|
216
|
+
logs_text = Text()
|
|
217
|
+
|
|
218
|
+
with Live(layout, console=console, refresh_per_second=10) as live:
|
|
219
|
+
def update_display():
|
|
220
|
+
# Update progress table
|
|
221
|
+
progress_table = Table(box=box.SIMPLE)
|
|
222
|
+
progress_table.add_column("Step", width=4)
|
|
223
|
+
progress_table.add_column("Software", style="cyan")
|
|
224
|
+
progress_table.add_column("Scope", width=6)
|
|
225
|
+
progress_table.add_column("Status", width=55) # Expanded for error messages
|
|
226
|
+
|
|
227
|
+
current_step_index = -1
|
|
228
|
+
for j, s in enumerate(steps):
|
|
229
|
+
if s.status == "running":
|
|
230
|
+
current_step_index = j
|
|
231
|
+
|
|
232
|
+
# Create status text with error details for failed steps
|
|
233
|
+
if s.status == "failure" and s.result and isinstance(s.result, Failure):
|
|
234
|
+
# Truncate long error messages for the table
|
|
235
|
+
error_msg = str(s.result.error)
|
|
236
|
+
if len(error_msg) > 50:
|
|
237
|
+
error_msg = error_msg[:47] + "..."
|
|
238
|
+
status_text = f"[red]✗ {error_msg}[/red]"
|
|
239
|
+
else:
|
|
240
|
+
status_text = {
|
|
241
|
+
"pending": "[dim]Pending[/dim]",
|
|
242
|
+
"running": "[yellow]Running...[/yellow]",
|
|
243
|
+
"success": "[green]✓ Success[/green]",
|
|
244
|
+
"failure": "[red]✗ Failed[/red]",
|
|
245
|
+
"skipped": "[blue]~ Skipped[/blue]"
|
|
246
|
+
}.get(s.status, s.status)
|
|
247
|
+
|
|
248
|
+
style = "bold" if j == current_step_index else ""
|
|
249
|
+
progress_table.add_row(
|
|
250
|
+
f"{j+1:2d}",
|
|
251
|
+
s.software.name,
|
|
252
|
+
s.scope,
|
|
253
|
+
status_text,
|
|
254
|
+
style=style
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
layout["progress"].update(Panel(progress_table, title="Installation Progress"))
|
|
258
|
+
|
|
259
|
+
# Update current step
|
|
260
|
+
if current_step_index >= 0:
|
|
261
|
+
current_step = steps[current_step_index]
|
|
262
|
+
current_text = Text()
|
|
263
|
+
current_text.append(f"Installing: ", style="bold")
|
|
264
|
+
current_text.append(f"{current_step.software.name}", style="cyan bold")
|
|
265
|
+
current_text.append(f" ({current_step.scope} scope)", style="dim")
|
|
266
|
+
layout["current"].update(Panel(current_text, title="Current Step"))
|
|
267
|
+
|
|
268
|
+
# Update logs - show recent logs from current and completed steps
|
|
269
|
+
logs_text = Text()
|
|
270
|
+
total_logs = 0
|
|
271
|
+
for log_step in steps:
|
|
272
|
+
if log_step.log_output:
|
|
273
|
+
total_logs += len(log_step.log_output)
|
|
274
|
+
logs_text.append(f"{log_step.software.name} ({log_step.scope}):\n", style="cyan bold")
|
|
275
|
+
# Show last 15 lines to prevent overflow
|
|
276
|
+
recent_logs = log_step.log_output[-15:] if len(log_step.log_output) > 15 else log_step.log_output
|
|
277
|
+
for log_line in recent_logs:
|
|
278
|
+
logs_text.append(f" {log_line}\n")
|
|
279
|
+
logs_text.append("\n")
|
|
280
|
+
|
|
281
|
+
# Add debug info about log count and streaming status
|
|
282
|
+
if total_logs == 0:
|
|
283
|
+
logs_text.append("[dim]Waiting for installation output...[/dim]\n")
|
|
284
|
+
logs_text.append(f"[dim]Streaming enabled: {streaming_enabled}[/dim]\n")
|
|
285
|
+
|
|
286
|
+
# Show total log count in title
|
|
287
|
+
log_title = f"Installation Logs ({total_logs} lines)" if total_logs > 0 else "Installation Logs"
|
|
288
|
+
layout["logs"].update(Panel(logs_text, title=log_title, box=box.SIMPLE))
|
|
289
|
+
|
|
290
|
+
def log_callback(line):
|
|
291
|
+
"""Callback for streaming log updates"""
|
|
292
|
+
# Rate limit updates to avoid overwhelming Rich
|
|
293
|
+
log_callback.last_update = getattr(log_callback, 'last_update', 0)
|
|
294
|
+
current_time = time.time()
|
|
295
|
+
if current_time - log_callback.last_update > 0.1: # Update at most every 100ms
|
|
296
|
+
update_display()
|
|
297
|
+
log_callback.last_update = current_time
|
|
298
|
+
|
|
299
|
+
for i, step in enumerate(steps):
|
|
300
|
+
step.status = "running"
|
|
301
|
+
step.log_output.append(f"Starting installation of {step.software.name}...")
|
|
302
|
+
update_display()
|
|
303
|
+
|
|
304
|
+
# Perform installation with streaming
|
|
305
|
+
step.result = install_software_with_streaming(step, is_sudo, streaming_enabled, callback=log_callback)
|
|
306
|
+
|
|
307
|
+
# Add completion message
|
|
308
|
+
step.log_output.append(f"Installation of {step.software.name} completed.")
|
|
309
|
+
|
|
310
|
+
# Update status based on result
|
|
311
|
+
if isinstance(step.result, Success):
|
|
312
|
+
step.status = "success"
|
|
313
|
+
step.log_output.append(f"✓ {step.result.value}")
|
|
314
|
+
elif isinstance(step.result, Failure):
|
|
315
|
+
step.status = "failure"
|
|
316
|
+
step.log_output.append(f"✗ Error: {step.result.error}")
|
|
317
|
+
# If streaming fails, try once more without streaming
|
|
318
|
+
if streaming_enabled:
|
|
319
|
+
step.log_output.append("Streaming failed, retrying without streaming...")
|
|
320
|
+
streaming_enabled = False # Disable streaming for remaining installations
|
|
321
|
+
# Try again without streaming
|
|
322
|
+
step.result = install_software_with_streaming(step, is_sudo, streaming_enabled)
|
|
323
|
+
if isinstance(step.result, Success):
|
|
324
|
+
step.status = "success"
|
|
325
|
+
step.log_output.append(f"✓ {step.result.value}")
|
|
326
|
+
elif isinstance(step.result, Failure):
|
|
327
|
+
step.status = "failure"
|
|
328
|
+
step.log_output.append(f"✗ Error: {step.result.error}")
|
|
329
|
+
elif isinstance(step.result, Skip):
|
|
330
|
+
step.status = "skipped"
|
|
331
|
+
step.log_output.append("~ Skipped")
|
|
332
|
+
|
|
333
|
+
update_display()
|
|
334
|
+
|
|
335
|
+
# Brief pause between installations
|
|
336
|
+
time.sleep(0.5)
|
|
337
|
+
|
|
338
|
+
# Show final summary
|
|
339
|
+
show_installation_summary(steps, console)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def show_installation_summary(steps: List[InstallationStep], console: Console):
|
|
343
|
+
"""Show installation summary"""
|
|
344
|
+
console.print()
|
|
345
|
+
|
|
346
|
+
success_count = sum(1 for step in steps if step.status == "success")
|
|
347
|
+
failure_count = sum(1 for step in steps if step.status == "failure")
|
|
348
|
+
skipped_count = sum(1 for step in steps if step.status == "skipped")
|
|
349
|
+
|
|
350
|
+
summary_table = Table(title="Installation Summary", box=box.ROUNDED)
|
|
351
|
+
summary_table.add_column("Result", style="bold")
|
|
352
|
+
summary_table.add_column("Count", justify="center")
|
|
353
|
+
summary_table.add_column("Software", style="dim")
|
|
354
|
+
|
|
355
|
+
if success_count > 0:
|
|
356
|
+
successful_software = [s.software.name for s in steps if s.status == "success"]
|
|
357
|
+
summary_table.add_row(
|
|
358
|
+
"[green]Successful[/green]",
|
|
359
|
+
str(success_count),
|
|
360
|
+
", ".join(successful_software)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if failure_count > 0:
|
|
364
|
+
failed_software = [s.software.name for s in steps if s.status == "failure"]
|
|
365
|
+
summary_table.add_row(
|
|
366
|
+
"[red]Failed[/red]",
|
|
367
|
+
str(failure_count),
|
|
368
|
+
", ".join(failed_software)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if skipped_count > 0:
|
|
372
|
+
skipped_software = [s.software.name for s in steps if s.status == "skipped"]
|
|
373
|
+
summary_table.add_row(
|
|
374
|
+
"[blue]Skipped[/blue]",
|
|
375
|
+
str(skipped_count),
|
|
376
|
+
", ".join(skipped_software)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
console.print(summary_table)
|
|
380
|
+
console.print()
|
|
381
|
+
|
|
382
|
+
if failure_count > 0:
|
|
383
|
+
console.print("[red]Some installations failed. Check the logs above for details.[/red]")
|
|
384
|
+
elif success_count > 0:
|
|
385
|
+
console.print("[green]All installations completed successfully![/green]")
|
|
386
|
+
else:
|
|
387
|
+
console.print("[blue]No installations were performed.[/blue]")
|