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 ADDED
@@ -0,0 +1,7 @@
1
+ try:
2
+ from ._version import __version__
3
+ except ImportError:
4
+ # fallback for development
5
+ __version__ = "unknown"
6
+
7
+ __all__ = ["__version__"]
envira/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Main entry point for the envira package.
4
+
5
+ This allows the package to be run with:
6
+ python -m envira
7
+ """
8
+
9
+ import sys
10
+ from .cli import main
11
+
12
+ if __name__ == "__main__":
13
+ sys.exit(main())
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
@@ -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]")