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.
@@ -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}")