pipu-cli 0.1.dev7__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.
@@ -1,344 +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 ..package_constraints import _get_constraint_invalid_when, _set_constraint_invalid_when
16
- from .constants import (
17
- COLUMN_SELECTION, COLUMN_CONSTRAINT, COLUMN_INVALID_WHEN
18
- )
19
-
20
- # Set up module logger
21
- logger = logging.getLogger(__name__)
22
-
23
-
24
- class PackageSelectionTable(DataTable):
25
- """Custom DataTable for package selection with keyboard navigation."""
26
-
27
- BINDINGS = [
28
- Binding("space", "toggle_selection", "Toggle Selection", show=True),
29
- Binding("enter", "confirm_selection", "Confirm", show=True),
30
- Binding("c", "add_constraint", "Add Constraint", show=True),
31
- Binding("shift+a", "select_all", "Select All", show=False),
32
- Binding("n", "select_none", "Select None", show=False),
33
- Binding("escape,q", "quit_app", "Quit", show=True),
34
- ]
35
-
36
- class SelectionChanged(Message):
37
- """Message sent when package selection changes."""
38
- def __init__(self, selected_count: int, total_count: int) -> None:
39
- """
40
- Initialize selection change message.
41
-
42
- :param selected_count: Number of selected packages
43
- :param total_count: Total number of packages
44
- """
45
- self.selected_count = selected_count
46
- self.total_count = total_count
47
- super().__init__()
48
-
49
- class ConfirmSelection(Message):
50
- """Message sent when user confirms selection."""
51
- def __init__(self, selected_packages: List[Dict[str, Any]]) -> None:
52
- """
53
- Initialize confirm selection message.
54
-
55
- :param selected_packages: List of selected package dictionaries
56
- """
57
- self.selected_packages = selected_packages
58
- super().__init__()
59
-
60
- def __init__(self, outdated_packages: List[Dict[str, Any]], *args, **kwargs):
61
- """
62
- Initialize the package selection table.
63
-
64
- :param outdated_packages: List of outdated package dictionaries
65
- """
66
- super().__init__(*args, **kwargs)
67
- self.outdated_packages = outdated_packages
68
- self.selected_packages = {}
69
- self._initialize_selection()
70
-
71
- def _initialize_selection(self) -> None:
72
- """
73
- Initialize package selection based on constraint satisfaction.
74
-
75
- Packages without constraints are auto-selected. Packages with constraints
76
- are only selected if their latest version satisfies the constraint.
77
- """
78
- for pkg in self.outdated_packages:
79
- constraint = pkg.get('constraint')
80
- if constraint:
81
- # Only pre-select if latest version satisfies constraint
82
- selected = _check_constraint_satisfaction(pkg['latest_version'], constraint)
83
- else:
84
- # No constraint - pre-select by default
85
- selected = True
86
- self.selected_packages[pkg['name']] = selected
87
-
88
- def on_mount(self) -> None:
89
- """
90
- Set up the data table when mounted.
91
-
92
- Creates columns, adds package rows with selection status,
93
- and initializes cursor position.
94
- """
95
- # Add columns with better widths
96
- self.add_column("Sel", width=4)
97
- self.add_column("Package", width=20)
98
- self.add_column("Current", width=10)
99
- self.add_column("Latest", width=10)
100
- self.add_column("Type", width=8)
101
- self.add_column("Constraint", width=20)
102
- self.add_column("Constraint Invalid When", width=30)
103
-
104
- # Add rows
105
- for pkg in self.outdated_packages:
106
- selected = self.selected_packages[pkg['name']]
107
- if selected:
108
- check_symbol = Text("✓", style="bold green")
109
- else:
110
- check_symbol = Text(" ", style="dim")
111
-
112
- # Format constraint display
113
- constraint = pkg.get('constraint')
114
- if constraint:
115
- color = get_constraint_color(pkg['latest_version'], constraint)
116
- constraint_display = f"[{color}]{constraint}[/{color}]"
117
- else:
118
- constraint_display = "[dim]-[/dim]"
119
-
120
- # Format invalid when display
121
- invalid_when = pkg.get('invalid_when')
122
- invalid_when_display = Text.from_markup(format_invalid_when_display(invalid_when))
123
-
124
- self.add_row(
125
- check_symbol,
126
- pkg['name'],
127
- pkg['version'],
128
- pkg['latest_version'],
129
- pkg['latest_filetype'],
130
- Text.from_markup(constraint_display),
131
- invalid_when_display,
132
- key=pkg['name']
133
- )
134
-
135
- # Set cursor to first row if packages exist
136
- if self.outdated_packages:
137
- self.cursor_type = "row"
138
- self.move_cursor(row=0)
139
-
140
- self._post_selection_change()
141
-
142
- def action_toggle_selection(self) -> None:
143
- """
144
- Toggle selection of the current package.
145
-
146
- Updates the visual checkmark and posts a selection change message.
147
- """
148
- if self.cursor_row is not None and self.cursor_row < len(self.outdated_packages):
149
- pkg = self.outdated_packages[self.cursor_row]
150
- pkg_name = pkg['name']
151
-
152
- # Toggle selection
153
- self.selected_packages[pkg_name] = not self.selected_packages[pkg_name]
154
-
155
- # Update the check symbol in the table with color
156
- if self.selected_packages[pkg_name]:
157
- new_symbol = Text("✓", style="bold green")
158
- else:
159
- new_symbol = Text(" ", style="dim")
160
- self.update_cell_at(cast(Coordinate, (self.cursor_row, COLUMN_SELECTION)), new_symbol)
161
-
162
- self._post_selection_change()
163
-
164
- def action_select_all(self) -> None:
165
- """
166
- Select all packages for updating.
167
-
168
- Updates all checkmarks to selected state and posts selection change message.
169
- """
170
- for i, pkg in enumerate(self.outdated_packages):
171
- self.selected_packages[pkg['name']] = True
172
- self.update_cell_at(cast(Coordinate, (i, COLUMN_SELECTION)), Text("✓", style="bold green"))
173
- self._post_selection_change()
174
-
175
- def action_select_none(self) -> None:
176
- """
177
- Deselect all packages.
178
-
179
- Updates all checkmarks to unselected state and posts selection change message.
180
- """
181
- for i, pkg in enumerate(self.outdated_packages):
182
- self.selected_packages[pkg['name']] = False
183
- self.update_cell_at(cast(Coordinate, (i, COLUMN_SELECTION)), Text(" ", style="dim"))
184
- self._post_selection_change()
185
-
186
- def action_confirm_selection(self) -> None:
187
- """
188
- Confirm the current selection.
189
-
190
- Posts a message containing the selected packages and exits the interface.
191
- """
192
- selected_pkgs = [
193
- pkg for pkg in self.outdated_packages
194
- if self.selected_packages[pkg['name']]
195
- ]
196
- self.post_message(self.ConfirmSelection(selected_pkgs))
197
-
198
- def action_add_constraint(self) -> None:
199
- """
200
- Add constraint to the currently selected package.
201
-
202
- Opens a modal dialog to input constraint specification.
203
- """
204
- if self.cursor_row is not None and self.cursor_row < len(self.outdated_packages):
205
- pkg = self.outdated_packages[self.cursor_row]
206
- current_constraint = pkg.get('constraint', '')
207
-
208
- def handle_constraint_result(result) -> None:
209
- """Handle the result from constraint input dialog."""
210
- if result:
211
- try:
212
- # Handle both string (constraint only) and tuple (constraint, trigger) results
213
- if isinstance(result, tuple):
214
- constraint, invalidation_trigger = result
215
- else:
216
- constraint = result
217
- invalidation_trigger = ""
218
-
219
- # Add constraint to configuration
220
- from ..package_constraints import add_constraints_to_config
221
- constraint_spec = f"{pkg['name']}{constraint}"
222
- config_path, _ = add_constraints_to_config([constraint_spec])
223
-
224
- # Add invalidation trigger if provided
225
- if invalidation_trigger:
226
- from ..package_constraints import format_invalidation_triggers, _get_section_name, _load_config, _write_config_file
227
-
228
- section_name = _get_section_name(None)
229
- config, _ = _load_config(create_if_missing=False)
230
-
231
- # Format the trigger entry
232
- formatted_entry = format_invalidation_triggers(constraint_spec, [invalidation_trigger])
233
- if formatted_entry:
234
- # Get existing triggers
235
- existing_triggers = _get_constraint_invalid_when(config, section_name) or ""
236
-
237
- # Add the new trigger
238
- if existing_triggers.strip():
239
- triggers_value = f"{existing_triggers},{formatted_entry}"
240
- else:
241
- triggers_value = formatted_entry
242
-
243
- _set_constraint_invalid_when(config, section_name, triggers_value)
244
- _write_config_file(config, config_path)
245
-
246
- # Update the package data and refresh display
247
- pkg['constraint'] = constraint
248
- if invalidation_trigger:
249
- pkg['invalid_when'] = invalidation_trigger
250
- self._refresh_constraint_display(self.cursor_row, pkg)
251
-
252
- # Show success message
253
- message = f"Added constraint {pkg['name']}{constraint}"
254
- if invalidation_trigger:
255
- message += f" with invalidation trigger: {invalidation_trigger}"
256
- self.app.notify(message)
257
-
258
- except Exception as e:
259
- self.app.notify(f"Error adding constraint: {e}")
260
-
261
- # Show constraint input screen
262
- from .modal_dialogs import ConstraintInputScreen
263
- self.app.push_screen(
264
- ConstraintInputScreen(pkg['name'], current_constraint),
265
- handle_constraint_result
266
- )
267
-
268
- def action_delete_constraint(self) -> None:
269
- """
270
- Delete constraint from the currently selected package.
271
-
272
- Removes the constraint and its invalidation triggers from the pip configuration.
273
- """
274
- if self.cursor_row is not None and self.cursor_row < len(self.outdated_packages):
275
- pkg = self.outdated_packages[self.cursor_row]
276
- current_constraint = pkg.get('constraint', '')
277
-
278
- if not current_constraint:
279
- self.app.notify(f"No constraint to delete for {pkg['name']}")
280
- return
281
-
282
- try:
283
- from ..package_constraints import remove_constraints_from_config
284
-
285
- # Remove constraint from configuration
286
- _, removed_constraints, removed_triggers = remove_constraints_from_config([pkg['name']])
287
-
288
- if pkg['name'].lower() in removed_constraints:
289
- # Update the package data and refresh display
290
- pkg.pop('constraint', None)
291
- pkg.pop('invalid_when', None)
292
- self._refresh_constraint_display(self.cursor_row, pkg)
293
-
294
- # Show success message
295
- trigger_count = len(removed_triggers.get(pkg['name'].lower(), []))
296
- if trigger_count > 0:
297
- self.app.notify(f"Deleted constraint and {trigger_count} invalidation triggers for {pkg['name']}")
298
- else:
299
- self.app.notify(f"Deleted constraint for {pkg['name']}")
300
- else:
301
- self.app.notify(f"No constraint found for {pkg['name']} in configuration")
302
-
303
- except Exception as e:
304
- self.app.notify(f"Error deleting constraint: {e}")
305
-
306
- def _refresh_constraint_display(self, row: int, pkg: Dict[str, Any]) -> None:
307
- """
308
- Refresh the constraint display for a specific package row.
309
-
310
- :param row: Row index to update
311
- :param pkg: Package data with updated constraint
312
- """
313
- constraint = pkg.get('constraint')
314
- if constraint:
315
- color = get_constraint_color(pkg['latest_version'], constraint)
316
- constraint_display = Text.from_markup(f"[{color}]{constraint}[/{color}]")
317
- else:
318
- constraint_display = Text.from_markup("[dim]-[/dim]")
319
-
320
- # Update the constraint column (column 5)
321
- self.update_cell_at(cast(Coordinate, (row, COLUMN_CONSTRAINT)), constraint_display)
322
-
323
- # Update the invalid when column (column 6) if it exists
324
- invalid_when = pkg.get('invalid_when')
325
- invalid_when_display = Text.from_markup(format_invalid_when_display(invalid_when))
326
- self.update_cell_at(cast(Coordinate, (row, COLUMN_INVALID_WHEN)), invalid_when_display)
327
-
328
- def action_quit_app(self) -> None:
329
- """
330
- Quit the application.
331
-
332
- Exits the TUI without making any package updates.
333
- """
334
- self.app.exit()
335
-
336
- def _post_selection_change(self) -> None:
337
- """
338
- Post a message about selection change.
339
-
340
- Counts selected packages and notifies the app about the change.
341
- """
342
- selected_count = sum(self.selected_packages.values())
343
- total_count = len(self.outdated_packages)
344
- 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}")