pipu-cli 0.1.dev6__py3-none-any.whl → 0.2.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.
- pipu_cli/__init__.py +2 -2
- pipu_cli/cache.py +316 -0
- pipu_cli/cli.py +867 -812
- pipu_cli/config.py +7 -58
- pipu_cli/config_file.py +80 -0
- pipu_cli/output.py +99 -0
- pipu_cli/package_management.py +1145 -0
- pipu_cli/pretty.py +286 -0
- pipu_cli/requirements.py +100 -0
- pipu_cli/rollback.py +110 -0
- pipu_cli-0.2.0.dist-info/METADATA +422 -0
- pipu_cli-0.2.0.dist-info/RECORD +16 -0
- pipu_cli/common.py +0 -4
- pipu_cli/internals.py +0 -819
- pipu_cli/package_constraints.py +0 -2286
- pipu_cli/thread_safe.py +0 -243
- pipu_cli/ui/__init__.py +0 -51
- pipu_cli/ui/apps.py +0 -1460
- pipu_cli/ui/constants.py +0 -19
- pipu_cli/ui/modal_dialogs.py +0 -1375
- pipu_cli/ui/table_widgets.py +0 -345
- pipu_cli/utils.py +0 -169
- pipu_cli-0.1.dev6.dist-info/METADATA +0 -517
- pipu_cli-0.1.dev6.dist-info/RECORD +0 -19
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/WHEEL +0 -0
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/top_level.txt +0 -0
pipu_cli/ui/table_widgets.py
DELETED
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Table widget components for the TUI interface.
|
|
3
|
-
|
|
4
|
-
Contains the main package selection table and related functionality.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from typing import List, Dict, Any, cast
|
|
8
|
-
import logging
|
|
9
|
-
from textual.widgets import DataTable
|
|
10
|
-
from textual.binding import Binding
|
|
11
|
-
from textual.message import Message
|
|
12
|
-
from textual.coordinate import Coordinate
|
|
13
|
-
from rich.text import Text
|
|
14
|
-
from ..internals import _check_constraint_satisfaction, get_constraint_color, format_invalid_when_display
|
|
15
|
-
from .constants import (
|
|
16
|
-
COLUMN_SELECTION, COLUMN_CONSTRAINT, COLUMN_INVALID_WHEN
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
# Set up module logger
|
|
20
|
-
logger = logging.getLogger(__name__)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class PackageSelectionTable(DataTable):
|
|
24
|
-
"""Custom DataTable for package selection with keyboard navigation."""
|
|
25
|
-
|
|
26
|
-
BINDINGS = [
|
|
27
|
-
Binding("space", "toggle_selection", "Toggle Selection", show=True),
|
|
28
|
-
Binding("enter", "confirm_selection", "Confirm", show=True),
|
|
29
|
-
Binding("c", "add_constraint", "Add Constraint", show=True),
|
|
30
|
-
Binding("shift+a", "select_all", "Select All", show=False),
|
|
31
|
-
Binding("n", "select_none", "Select None", show=False),
|
|
32
|
-
Binding("escape,q", "quit_app", "Quit", show=True),
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
class SelectionChanged(Message):
|
|
36
|
-
"""Message sent when package selection changes."""
|
|
37
|
-
def __init__(self, selected_count: int, total_count: int) -> None:
|
|
38
|
-
"""
|
|
39
|
-
Initialize selection change message.
|
|
40
|
-
|
|
41
|
-
:param selected_count: Number of selected packages
|
|
42
|
-
:param total_count: Total number of packages
|
|
43
|
-
"""
|
|
44
|
-
self.selected_count = selected_count
|
|
45
|
-
self.total_count = total_count
|
|
46
|
-
super().__init__()
|
|
47
|
-
|
|
48
|
-
class ConfirmSelection(Message):
|
|
49
|
-
"""Message sent when user confirms selection."""
|
|
50
|
-
def __init__(self, selected_packages: List[Dict[str, Any]]) -> None:
|
|
51
|
-
"""
|
|
52
|
-
Initialize confirm selection message.
|
|
53
|
-
|
|
54
|
-
:param selected_packages: List of selected package dictionaries
|
|
55
|
-
"""
|
|
56
|
-
self.selected_packages = selected_packages
|
|
57
|
-
super().__init__()
|
|
58
|
-
|
|
59
|
-
def __init__(self, outdated_packages: List[Dict[str, Any]], *args, **kwargs):
|
|
60
|
-
"""
|
|
61
|
-
Initialize the package selection table.
|
|
62
|
-
|
|
63
|
-
:param outdated_packages: List of outdated package dictionaries
|
|
64
|
-
"""
|
|
65
|
-
super().__init__(*args, **kwargs)
|
|
66
|
-
self.outdated_packages = outdated_packages
|
|
67
|
-
self.selected_packages = {}
|
|
68
|
-
self._initialize_selection()
|
|
69
|
-
|
|
70
|
-
def _initialize_selection(self) -> None:
|
|
71
|
-
"""
|
|
72
|
-
Initialize package selection based on constraint satisfaction.
|
|
73
|
-
|
|
74
|
-
Packages without constraints are auto-selected. Packages with constraints
|
|
75
|
-
are only selected if their latest version satisfies the constraint.
|
|
76
|
-
"""
|
|
77
|
-
for pkg in self.outdated_packages:
|
|
78
|
-
constraint = pkg.get('constraint')
|
|
79
|
-
if constraint:
|
|
80
|
-
# Only pre-select if latest version satisfies constraint
|
|
81
|
-
selected = _check_constraint_satisfaction(pkg['latest_version'], constraint)
|
|
82
|
-
else:
|
|
83
|
-
# No constraint - pre-select by default
|
|
84
|
-
selected = True
|
|
85
|
-
self.selected_packages[pkg['name']] = selected
|
|
86
|
-
|
|
87
|
-
def on_mount(self) -> None:
|
|
88
|
-
"""
|
|
89
|
-
Set up the data table when mounted.
|
|
90
|
-
|
|
91
|
-
Creates columns, adds package rows with selection status,
|
|
92
|
-
and initializes cursor position.
|
|
93
|
-
"""
|
|
94
|
-
# Add columns with better widths
|
|
95
|
-
self.add_column("Sel", width=4)
|
|
96
|
-
self.add_column("Package", width=20)
|
|
97
|
-
self.add_column("Current", width=10)
|
|
98
|
-
self.add_column("Latest", width=10)
|
|
99
|
-
self.add_column("Type", width=8)
|
|
100
|
-
self.add_column("Constraint", width=20)
|
|
101
|
-
self.add_column("Invalid When", width=25)
|
|
102
|
-
|
|
103
|
-
# Add rows
|
|
104
|
-
for pkg in self.outdated_packages:
|
|
105
|
-
selected = self.selected_packages[pkg['name']]
|
|
106
|
-
if selected:
|
|
107
|
-
check_symbol = Text("✓", style="bold green")
|
|
108
|
-
else:
|
|
109
|
-
check_symbol = Text(" ", style="dim")
|
|
110
|
-
|
|
111
|
-
# Format constraint display
|
|
112
|
-
constraint = pkg.get('constraint')
|
|
113
|
-
if constraint:
|
|
114
|
-
color = get_constraint_color(pkg['latest_version'], constraint)
|
|
115
|
-
constraint_display = f"[{color}]{constraint}[/{color}]"
|
|
116
|
-
else:
|
|
117
|
-
constraint_display = "[dim]-[/dim]"
|
|
118
|
-
|
|
119
|
-
# Format invalid when display
|
|
120
|
-
invalid_when = pkg.get('invalid_when')
|
|
121
|
-
invalid_when_display = Text.from_markup(format_invalid_when_display(invalid_when))
|
|
122
|
-
|
|
123
|
-
self.add_row(
|
|
124
|
-
check_symbol,
|
|
125
|
-
pkg['name'],
|
|
126
|
-
pkg['version'],
|
|
127
|
-
pkg['latest_version'],
|
|
128
|
-
pkg['latest_filetype'],
|
|
129
|
-
Text.from_markup(constraint_display),
|
|
130
|
-
invalid_when_display,
|
|
131
|
-
key=pkg['name']
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
# Set cursor to first row if packages exist
|
|
135
|
-
if self.outdated_packages:
|
|
136
|
-
self.cursor_type = "row"
|
|
137
|
-
self.move_cursor(row=0)
|
|
138
|
-
|
|
139
|
-
self._post_selection_change()
|
|
140
|
-
|
|
141
|
-
def action_toggle_selection(self) -> None:
|
|
142
|
-
"""
|
|
143
|
-
Toggle selection of the current package.
|
|
144
|
-
|
|
145
|
-
Updates the visual checkmark and posts a selection change message.
|
|
146
|
-
"""
|
|
147
|
-
if self.cursor_row is not None and self.cursor_row < len(self.outdated_packages):
|
|
148
|
-
pkg = self.outdated_packages[self.cursor_row]
|
|
149
|
-
pkg_name = pkg['name']
|
|
150
|
-
|
|
151
|
-
# Toggle selection
|
|
152
|
-
self.selected_packages[pkg_name] = not self.selected_packages[pkg_name]
|
|
153
|
-
|
|
154
|
-
# Update the check symbol in the table with color
|
|
155
|
-
if self.selected_packages[pkg_name]:
|
|
156
|
-
new_symbol = Text("✓", style="bold green")
|
|
157
|
-
else:
|
|
158
|
-
new_symbol = Text(" ", style="dim")
|
|
159
|
-
self.update_cell_at(cast(Coordinate, (self.cursor_row, COLUMN_SELECTION)), new_symbol)
|
|
160
|
-
|
|
161
|
-
self._post_selection_change()
|
|
162
|
-
|
|
163
|
-
def action_select_all(self) -> None:
|
|
164
|
-
"""
|
|
165
|
-
Select all packages for updating.
|
|
166
|
-
|
|
167
|
-
Updates all checkmarks to selected state and posts selection change message.
|
|
168
|
-
"""
|
|
169
|
-
for i, pkg in enumerate(self.outdated_packages):
|
|
170
|
-
self.selected_packages[pkg['name']] = True
|
|
171
|
-
self.update_cell_at(cast(Coordinate, (i, COLUMN_SELECTION)), Text("✓", style="bold green"))
|
|
172
|
-
self._post_selection_change()
|
|
173
|
-
|
|
174
|
-
def action_select_none(self) -> None:
|
|
175
|
-
"""
|
|
176
|
-
Deselect all packages.
|
|
177
|
-
|
|
178
|
-
Updates all checkmarks to unselected state and posts selection change message.
|
|
179
|
-
"""
|
|
180
|
-
for i, pkg in enumerate(self.outdated_packages):
|
|
181
|
-
self.selected_packages[pkg['name']] = False
|
|
182
|
-
self.update_cell_at(cast(Coordinate, (i, COLUMN_SELECTION)), Text(" ", style="dim"))
|
|
183
|
-
self._post_selection_change()
|
|
184
|
-
|
|
185
|
-
def action_confirm_selection(self) -> None:
|
|
186
|
-
"""
|
|
187
|
-
Confirm the current selection.
|
|
188
|
-
|
|
189
|
-
Posts a message containing the selected packages and exits the interface.
|
|
190
|
-
"""
|
|
191
|
-
selected_pkgs = [
|
|
192
|
-
pkg for pkg in self.outdated_packages
|
|
193
|
-
if self.selected_packages[pkg['name']]
|
|
194
|
-
]
|
|
195
|
-
self.post_message(self.ConfirmSelection(selected_pkgs))
|
|
196
|
-
|
|
197
|
-
def action_add_constraint(self) -> None:
|
|
198
|
-
"""
|
|
199
|
-
Add constraint to the currently selected package.
|
|
200
|
-
|
|
201
|
-
Opens a modal dialog to input constraint specification.
|
|
202
|
-
"""
|
|
203
|
-
if self.cursor_row is not None and self.cursor_row < len(self.outdated_packages):
|
|
204
|
-
pkg = self.outdated_packages[self.cursor_row]
|
|
205
|
-
current_constraint = pkg.get('constraint', '')
|
|
206
|
-
|
|
207
|
-
def handle_constraint_result(result) -> None:
|
|
208
|
-
"""Handle the result from constraint input dialog."""
|
|
209
|
-
if result:
|
|
210
|
-
try:
|
|
211
|
-
# Handle both string (constraint only) and tuple (constraint, trigger) results
|
|
212
|
-
if isinstance(result, tuple):
|
|
213
|
-
constraint, invalidation_trigger = result
|
|
214
|
-
else:
|
|
215
|
-
constraint = result
|
|
216
|
-
invalidation_trigger = ""
|
|
217
|
-
|
|
218
|
-
# Add constraint to configuration
|
|
219
|
-
from ..package_constraints import add_constraints_to_config
|
|
220
|
-
constraint_spec = f"{pkg['name']}{constraint}"
|
|
221
|
-
config_path, _ = add_constraints_to_config([constraint_spec])
|
|
222
|
-
|
|
223
|
-
# Add invalidation trigger if provided
|
|
224
|
-
if invalidation_trigger:
|
|
225
|
-
from ..package_constraints import format_invalidation_triggers, _get_section_name, _load_config, _write_config_file
|
|
226
|
-
|
|
227
|
-
section_name = _get_section_name(None)
|
|
228
|
-
config, _ = _load_config(create_if_missing=False)
|
|
229
|
-
|
|
230
|
-
# Format the trigger entry
|
|
231
|
-
formatted_entry = format_invalidation_triggers(constraint_spec, [invalidation_trigger])
|
|
232
|
-
if formatted_entry:
|
|
233
|
-
# Get existing triggers
|
|
234
|
-
existing_triggers = ""
|
|
235
|
-
if config.has_option(section_name, 'constraint_invalid_when'):
|
|
236
|
-
existing_triggers = config.get(section_name, 'constraint_invalid_when')
|
|
237
|
-
|
|
238
|
-
# Add the new trigger
|
|
239
|
-
if existing_triggers.strip():
|
|
240
|
-
triggers_value = f"{existing_triggers},{formatted_entry}"
|
|
241
|
-
else:
|
|
242
|
-
triggers_value = formatted_entry
|
|
243
|
-
|
|
244
|
-
config.set(section_name, 'constraint_invalid_when', triggers_value)
|
|
245
|
-
_write_config_file(config, config_path)
|
|
246
|
-
|
|
247
|
-
# Update the package data and refresh display
|
|
248
|
-
pkg['constraint'] = constraint
|
|
249
|
-
if invalidation_trigger:
|
|
250
|
-
pkg['invalid_when'] = invalidation_trigger
|
|
251
|
-
self._refresh_constraint_display(self.cursor_row, pkg)
|
|
252
|
-
|
|
253
|
-
# Show success message
|
|
254
|
-
message = f"Added constraint {pkg['name']}{constraint}"
|
|
255
|
-
if invalidation_trigger:
|
|
256
|
-
message += f" with invalidation trigger: {invalidation_trigger}"
|
|
257
|
-
self.app.notify(message)
|
|
258
|
-
|
|
259
|
-
except Exception as e:
|
|
260
|
-
self.app.notify(f"Error adding constraint: {e}")
|
|
261
|
-
|
|
262
|
-
# Show constraint input screen
|
|
263
|
-
from .modal_dialogs import ConstraintInputScreen
|
|
264
|
-
self.app.push_screen(
|
|
265
|
-
ConstraintInputScreen(pkg['name'], current_constraint),
|
|
266
|
-
handle_constraint_result
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
def action_delete_constraint(self) -> None:
|
|
270
|
-
"""
|
|
271
|
-
Delete constraint from the currently selected package.
|
|
272
|
-
|
|
273
|
-
Removes the constraint and its invalidation triggers from the pip configuration.
|
|
274
|
-
"""
|
|
275
|
-
if self.cursor_row is not None and self.cursor_row < len(self.outdated_packages):
|
|
276
|
-
pkg = self.outdated_packages[self.cursor_row]
|
|
277
|
-
current_constraint = pkg.get('constraint', '')
|
|
278
|
-
|
|
279
|
-
if not current_constraint:
|
|
280
|
-
self.app.notify(f"No constraint to delete for {pkg['name']}")
|
|
281
|
-
return
|
|
282
|
-
|
|
283
|
-
try:
|
|
284
|
-
from ..package_constraints import remove_constraints_from_config
|
|
285
|
-
|
|
286
|
-
# Remove constraint from configuration
|
|
287
|
-
_, removed_constraints, removed_triggers = remove_constraints_from_config([pkg['name']])
|
|
288
|
-
|
|
289
|
-
if pkg['name'].lower() in removed_constraints:
|
|
290
|
-
# Update the package data and refresh display
|
|
291
|
-
pkg.pop('constraint', None)
|
|
292
|
-
pkg.pop('invalid_when', None)
|
|
293
|
-
self._refresh_constraint_display(self.cursor_row, pkg)
|
|
294
|
-
|
|
295
|
-
# Show success message
|
|
296
|
-
trigger_count = len(removed_triggers.get(pkg['name'].lower(), []))
|
|
297
|
-
if trigger_count > 0:
|
|
298
|
-
self.app.notify(f"Deleted constraint and {trigger_count} invalidation triggers for {pkg['name']}")
|
|
299
|
-
else:
|
|
300
|
-
self.app.notify(f"Deleted constraint for {pkg['name']}")
|
|
301
|
-
else:
|
|
302
|
-
self.app.notify(f"No constraint found for {pkg['name']} in configuration")
|
|
303
|
-
|
|
304
|
-
except Exception as e:
|
|
305
|
-
self.app.notify(f"Error deleting constraint: {e}")
|
|
306
|
-
|
|
307
|
-
def _refresh_constraint_display(self, row: int, pkg: Dict[str, Any]) -> None:
|
|
308
|
-
"""
|
|
309
|
-
Refresh the constraint display for a specific package row.
|
|
310
|
-
|
|
311
|
-
:param row: Row index to update
|
|
312
|
-
:param pkg: Package data with updated constraint
|
|
313
|
-
"""
|
|
314
|
-
constraint = pkg.get('constraint')
|
|
315
|
-
if constraint:
|
|
316
|
-
color = get_constraint_color(pkg['latest_version'], constraint)
|
|
317
|
-
constraint_display = Text.from_markup(f"[{color}]{constraint}[/{color}]")
|
|
318
|
-
else:
|
|
319
|
-
constraint_display = Text.from_markup("[dim]-[/dim]")
|
|
320
|
-
|
|
321
|
-
# Update the constraint column (column 5)
|
|
322
|
-
self.update_cell_at(cast(Coordinate, (row, COLUMN_CONSTRAINT)), constraint_display)
|
|
323
|
-
|
|
324
|
-
# Update the invalid when column (column 6) if it exists
|
|
325
|
-
invalid_when = pkg.get('invalid_when')
|
|
326
|
-
invalid_when_display = Text.from_markup(format_invalid_when_display(invalid_when))
|
|
327
|
-
self.update_cell_at(cast(Coordinate, (row, COLUMN_INVALID_WHEN)), invalid_when_display)
|
|
328
|
-
|
|
329
|
-
def action_quit_app(self) -> None:
|
|
330
|
-
"""
|
|
331
|
-
Quit the application.
|
|
332
|
-
|
|
333
|
-
Exits the TUI without making any package updates.
|
|
334
|
-
"""
|
|
335
|
-
self.app.exit()
|
|
336
|
-
|
|
337
|
-
def _post_selection_change(self) -> None:
|
|
338
|
-
"""
|
|
339
|
-
Post a message about selection change.
|
|
340
|
-
|
|
341
|
-
Counts selected packages and notifies the app about the change.
|
|
342
|
-
"""
|
|
343
|
-
selected_count = sum(self.selected_packages.values())
|
|
344
|
-
total_count = len(self.outdated_packages)
|
|
345
|
-
self.post_message(self.SelectionChanged(selected_count, total_count))
|
pipu_cli/utils.py
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Utility functions for pipu with improved error handling and cross-platform support.
|
|
3
|
-
|
|
4
|
-
This module provides robust implementations of common operations with proper
|
|
5
|
-
error handling, resource cleanup, and platform compatibility.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
import logging
|
|
11
|
-
from typing import List, Optional, Tuple
|
|
12
|
-
from .config import SUBPROCESS_TIMEOUT, FORCE_KILL_TIMEOUT
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def run_subprocess_safely(
|
|
18
|
-
cmd: List[str],
|
|
19
|
-
timeout: Optional[int] = None,
|
|
20
|
-
check: bool = True,
|
|
21
|
-
capture_output: bool = True
|
|
22
|
-
) -> Tuple[int, str, str]:
|
|
23
|
-
"""
|
|
24
|
-
Run a subprocess command with proper error handling and cleanup.
|
|
25
|
-
|
|
26
|
-
:param cmd: Command and arguments as a list
|
|
27
|
-
:param timeout: Timeout in seconds (None for no timeout)
|
|
28
|
-
:param check: Whether to raise on non-zero exit
|
|
29
|
-
:param capture_output: Whether to capture stdout/stderr
|
|
30
|
-
:returns: Tuple of (return_code, stdout, stderr)
|
|
31
|
-
:raises subprocess.TimeoutExpired: If command times out
|
|
32
|
-
:raises subprocess.CalledProcessError: If check=True and command fails
|
|
33
|
-
"""
|
|
34
|
-
if timeout is None:
|
|
35
|
-
timeout = SUBPROCESS_TIMEOUT
|
|
36
|
-
|
|
37
|
-
process = None
|
|
38
|
-
try:
|
|
39
|
-
result = subprocess.run(
|
|
40
|
-
cmd,
|
|
41
|
-
capture_output=capture_output,
|
|
42
|
-
text=True,
|
|
43
|
-
timeout=timeout,
|
|
44
|
-
check=check
|
|
45
|
-
)
|
|
46
|
-
return result.returncode, result.stdout or "", result.stderr or ""
|
|
47
|
-
|
|
48
|
-
except subprocess.TimeoutExpired as e:
|
|
49
|
-
logger.warning(f"Command timed out after {timeout}s: {' '.join(cmd)}")
|
|
50
|
-
raise
|
|
51
|
-
|
|
52
|
-
except subprocess.CalledProcessError as e:
|
|
53
|
-
logger.error(f"Command failed with exit code {e.returncode}: {' '.join(cmd)}")
|
|
54
|
-
if check:
|
|
55
|
-
raise
|
|
56
|
-
return e.returncode, e.stdout or "", e.stderr or ""
|
|
57
|
-
|
|
58
|
-
except (OSError, ValueError) as e:
|
|
59
|
-
logger.error(f"Failed to execute command: {e}")
|
|
60
|
-
raise RuntimeError(f"Failed to execute subprocess: {e}") from e
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class ManagedProcess:
|
|
64
|
-
"""
|
|
65
|
-
Context manager for subprocess.Popen with guaranteed cleanup.
|
|
66
|
-
|
|
67
|
-
Ensures that processes are properly terminated even if exceptions occur.
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
def __init__(
|
|
71
|
-
self,
|
|
72
|
-
cmd: List[str],
|
|
73
|
-
timeout: Optional[float] = None,
|
|
74
|
-
**kwargs
|
|
75
|
-
):
|
|
76
|
-
"""
|
|
77
|
-
Initialize a managed process.
|
|
78
|
-
|
|
79
|
-
:param cmd: Command and arguments
|
|
80
|
-
:param timeout: Timeout for wait operations
|
|
81
|
-
:param kwargs: Additional arguments for subprocess.Popen
|
|
82
|
-
"""
|
|
83
|
-
self.cmd = cmd
|
|
84
|
-
self.timeout = timeout or SUBPROCESS_TIMEOUT
|
|
85
|
-
self.kwargs = kwargs
|
|
86
|
-
self.process: Optional[subprocess.Popen] = None
|
|
87
|
-
|
|
88
|
-
def __enter__(self) -> subprocess.Popen:
|
|
89
|
-
"""Start the process."""
|
|
90
|
-
try:
|
|
91
|
-
self.process = subprocess.Popen(self.cmd, **self.kwargs)
|
|
92
|
-
return self.process
|
|
93
|
-
except (OSError, ValueError) as e:
|
|
94
|
-
logger.error(f"Failed to start process: {e}")
|
|
95
|
-
raise RuntimeError(f"Failed to start subprocess: {e}") from e
|
|
96
|
-
|
|
97
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
98
|
-
"""Ensure process is terminated."""
|
|
99
|
-
if self.process is None:
|
|
100
|
-
return False
|
|
101
|
-
|
|
102
|
-
try:
|
|
103
|
-
# Check if process is still running
|
|
104
|
-
if self.process.poll() is None:
|
|
105
|
-
logger.debug(f"Terminating process {self.process.pid}")
|
|
106
|
-
self.process.terminate()
|
|
107
|
-
|
|
108
|
-
try:
|
|
109
|
-
self.process.wait(timeout=FORCE_KILL_TIMEOUT)
|
|
110
|
-
except subprocess.TimeoutExpired:
|
|
111
|
-
logger.warning(f"Force killing process {self.process.pid}")
|
|
112
|
-
self.process.kill()
|
|
113
|
-
try:
|
|
114
|
-
self.process.wait(timeout=1.0)
|
|
115
|
-
except subprocess.TimeoutExpired:
|
|
116
|
-
logger.error(f"Failed to kill process {self.process.pid}")
|
|
117
|
-
|
|
118
|
-
except Exception as e:
|
|
119
|
-
logger.error(f"Error during process cleanup: {e}")
|
|
120
|
-
|
|
121
|
-
return False # Don't suppress exceptions
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def safe_terminal_reset() -> None:
|
|
125
|
-
"""
|
|
126
|
-
Safely reset terminal to normal mode.
|
|
127
|
-
|
|
128
|
-
Handles platform differences and ensures no crashes on failure.
|
|
129
|
-
"""
|
|
130
|
-
try:
|
|
131
|
-
if sys.platform == 'win32':
|
|
132
|
-
# Windows: Enable ANSI escape sequences
|
|
133
|
-
try:
|
|
134
|
-
import ctypes
|
|
135
|
-
kernel32 = ctypes.windll.kernel32
|
|
136
|
-
# Enable virtual terminal processing
|
|
137
|
-
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
138
|
-
mode = ctypes.c_ulong()
|
|
139
|
-
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
|
140
|
-
mode.value |= 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
141
|
-
kernel32.SetConsoleMode(handle, mode)
|
|
142
|
-
except Exception as e:
|
|
143
|
-
logger.debug(f"Failed to enable Windows ANSI support: {e}")
|
|
144
|
-
|
|
145
|
-
# Send ANSI reset sequences (works on Unix and Windows 10+)
|
|
146
|
-
try:
|
|
147
|
-
sys.stdout.write('\033[?1049l') # Exit alternate screen
|
|
148
|
-
sys.stdout.write('\033[?25h') # Show cursor
|
|
149
|
-
sys.stdout.write('\033[0m') # Reset all attributes
|
|
150
|
-
sys.stdout.flush()
|
|
151
|
-
except (OSError, ValueError) as e:
|
|
152
|
-
logger.debug(f"Failed to send ANSI sequences: {e}")
|
|
153
|
-
|
|
154
|
-
# Unix-like systems: use stty
|
|
155
|
-
if sys.platform != 'win32':
|
|
156
|
-
try:
|
|
157
|
-
subprocess.run(
|
|
158
|
-
['stty', 'sane'],
|
|
159
|
-
capture_output=True,
|
|
160
|
-
timeout=5.0,
|
|
161
|
-
check=False
|
|
162
|
-
)
|
|
163
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
164
|
-
# stty not available or failed - not critical
|
|
165
|
-
pass
|
|
166
|
-
|
|
167
|
-
except Exception as e:
|
|
168
|
-
# Terminal reset is best-effort - log but don't crash
|
|
169
|
-
logger.debug(f"Terminal reset error: {e}")
|