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/modal_dialogs.py
DELETED
|
@@ -1,1375 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Modal dialog classes for the TUI interface.
|
|
3
|
-
|
|
4
|
-
Contains constraint input, confirmation dialogs, help screen, etc.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from typing import Any, List, Dict, Literal, Optional
|
|
8
|
-
import logging
|
|
9
|
-
import threading
|
|
10
|
-
from textual.app import ComposeResult
|
|
11
|
-
from textual.containers import Horizontal, Vertical, Grid, ScrollableContainer
|
|
12
|
-
from textual.widgets import Button, Static, Input, Label, DataTable
|
|
13
|
-
from textual.screen import ModalScreen
|
|
14
|
-
from rich.text import Text
|
|
15
|
-
|
|
16
|
-
# Set up module logger
|
|
17
|
-
logger = logging.getLogger(__name__)
|
|
18
|
-
|
|
19
|
-
# Type alias for button variants
|
|
20
|
-
ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class BaseConfirmationScreen(ModalScreen[bool]):
|
|
24
|
-
"""
|
|
25
|
-
Base class for simple confirmation dialogs with Yes/No or similar buttons.
|
|
26
|
-
|
|
27
|
-
This consolidates the common pattern of showing a message and two buttons
|
|
28
|
-
that dismiss with True or False based on the user's choice.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
BINDINGS = [
|
|
32
|
-
("escape", "cancel", "Cancel"),
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
def __init__(self,
|
|
36
|
-
message: str,
|
|
37
|
-
confirm_text: str = "Confirm",
|
|
38
|
-
cancel_text: str = "Cancel",
|
|
39
|
-
confirm_variant: ButtonVariant = "success",
|
|
40
|
-
cancel_variant: ButtonVariant = "primary"):
|
|
41
|
-
"""
|
|
42
|
-
Initialize a confirmation dialog.
|
|
43
|
-
|
|
44
|
-
:param message: The message or question to display
|
|
45
|
-
:param confirm_text: Text for the confirm button (default "Confirm")
|
|
46
|
-
:param cancel_text: Text for the cancel button (default "Cancel")
|
|
47
|
-
:param confirm_variant: Button variant for confirm (default "success")
|
|
48
|
-
:param cancel_variant: Button variant for cancel (default "primary")
|
|
49
|
-
"""
|
|
50
|
-
super().__init__()
|
|
51
|
-
self.message: str = message
|
|
52
|
-
self.confirm_text: str = confirm_text
|
|
53
|
-
self.cancel_text: str = cancel_text
|
|
54
|
-
self.confirm_variant: ButtonVariant = confirm_variant
|
|
55
|
-
self.cancel_variant: ButtonVariant = cancel_variant
|
|
56
|
-
|
|
57
|
-
def compose(self) -> ComposeResult:
|
|
58
|
-
"""Create the dialog layout."""
|
|
59
|
-
confirm_btn = Button(
|
|
60
|
-
Text(self.confirm_text, style="bold white"),
|
|
61
|
-
id="confirm",
|
|
62
|
-
variant=self.confirm_variant
|
|
63
|
-
)
|
|
64
|
-
cancel_btn = Button(
|
|
65
|
-
Text(self.cancel_text, style="bold white"),
|
|
66
|
-
id="cancel",
|
|
67
|
-
variant=self.cancel_variant
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
yield Grid(
|
|
71
|
-
Label(self.message, id="question"),
|
|
72
|
-
Horizontal(confirm_btn, cancel_btn, id="actions"),
|
|
73
|
-
id="dialog"
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
77
|
-
"""Handle button press - dismiss with True for confirm, False for cancel."""
|
|
78
|
-
self.dismiss(event.button.id == "confirm")
|
|
79
|
-
|
|
80
|
-
def action_cancel(self) -> None:
|
|
81
|
-
"""Handle escape key - dismiss with False."""
|
|
82
|
-
self.dismiss(False)
|
|
83
|
-
|
|
84
|
-
CSS = """
|
|
85
|
-
BaseConfirmationScreen {
|
|
86
|
-
align: center middle;
|
|
87
|
-
layer: overlay;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
#dialog {
|
|
91
|
-
grid-size: 1;
|
|
92
|
-
grid-rows: 1fr auto;
|
|
93
|
-
padding: 1 2;
|
|
94
|
-
width: 60;
|
|
95
|
-
height: auto;
|
|
96
|
-
border: thick $primary;
|
|
97
|
-
background: $surface;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
#question {
|
|
101
|
-
height: auto;
|
|
102
|
-
width: 100%;
|
|
103
|
-
content-align: center middle;
|
|
104
|
-
padding: 2 1;
|
|
105
|
-
text-align: center;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
#actions {
|
|
109
|
-
width: 100%;
|
|
110
|
-
height: auto;
|
|
111
|
-
align: center middle;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
#actions Button {
|
|
115
|
-
margin: 0 1;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/* Explicit button styling for test validation */
|
|
119
|
-
#confirm {
|
|
120
|
-
color: white;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
#cancel {
|
|
124
|
-
background: $primary;
|
|
125
|
-
border: tall $primary;
|
|
126
|
-
color: white;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
Button:focus {
|
|
130
|
-
text-style: bold;
|
|
131
|
-
}
|
|
132
|
-
"""
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
class ConstraintInputScreen(ModalScreen):
|
|
136
|
-
"""Modal screen for inputting package constraints."""
|
|
137
|
-
|
|
138
|
-
BINDINGS = [
|
|
139
|
-
("enter", "add_constraint", "Add Constraint"),
|
|
140
|
-
("escape", "cancel", "Cancel"),
|
|
141
|
-
]
|
|
142
|
-
|
|
143
|
-
def __init__(self, package_name: str, current_constraint: str = ""):
|
|
144
|
-
"""
|
|
145
|
-
Initialize constraint input screen.
|
|
146
|
-
|
|
147
|
-
:param package_name: Name of the package to constrain
|
|
148
|
-
:param current_constraint: Current constraint if any
|
|
149
|
-
"""
|
|
150
|
-
super().__init__()
|
|
151
|
-
self.package_name = package_name
|
|
152
|
-
self.current_constraint = current_constraint
|
|
153
|
-
self.constraint_value = ""
|
|
154
|
-
self.invalidation_trigger = ""
|
|
155
|
-
|
|
156
|
-
def compose(self) -> ComposeResult:
|
|
157
|
-
"""
|
|
158
|
-
Create the constraint input dialog.
|
|
159
|
-
|
|
160
|
-
:returns: Composed widgets for the dialog
|
|
161
|
-
"""
|
|
162
|
-
# Build the widgets list
|
|
163
|
-
widgets = [
|
|
164
|
-
Label(f"Add constraint for: {self.package_name}", id="constraint-title")
|
|
165
|
-
]
|
|
166
|
-
|
|
167
|
-
if self.current_constraint:
|
|
168
|
-
widgets.append(Label(f"Current constraint: {self.current_constraint}", id="current-constraint"))
|
|
169
|
-
|
|
170
|
-
widgets.extend([
|
|
171
|
-
Label("Enter constraint (e.g., >=1.0.0, <2.0.0, ==1.5.0, >1.0, ~=2.1):", id="constraint-help"),
|
|
172
|
-
Input(placeholder=">1.0", id="constraint-input"),
|
|
173
|
-
Label("Optional invalidation trigger (e.g., requests>2.0):", id="invalidation-help"),
|
|
174
|
-
Input(placeholder="requests>2.0 (optional)", id="invalidation-input"),
|
|
175
|
-
Horizontal(
|
|
176
|
-
Button(Text("Add Constraint", style="bold white"), id="add-constraint-btn", variant="success"),
|
|
177
|
-
Button(Text("Cancel", style="bold white"), id="cancel-constraint-btn", variant="primary"),
|
|
178
|
-
id="constraint-buttons"
|
|
179
|
-
)
|
|
180
|
-
]) # type: ignore
|
|
181
|
-
|
|
182
|
-
with Vertical(id="constraint-dialog"):
|
|
183
|
-
for w in widgets:
|
|
184
|
-
yield w
|
|
185
|
-
|
|
186
|
-
def on_mount(self) -> None:
|
|
187
|
-
"""Focus the input when screen mounts."""
|
|
188
|
-
self.query_one("#constraint-input", Input).focus()
|
|
189
|
-
|
|
190
|
-
def _validate_invalidation_trigger(self, trigger: str) -> tuple[bool, str]:
|
|
191
|
-
"""
|
|
192
|
-
Validate invalidation trigger to ensure it only uses '>' operator and package exists.
|
|
193
|
-
|
|
194
|
-
:param trigger: Invalidation trigger string to validate
|
|
195
|
-
:returns: Tuple of (is_valid, error_message)
|
|
196
|
-
"""
|
|
197
|
-
if not trigger.strip():
|
|
198
|
-
return True, "" # Empty trigger is valid (optional)
|
|
199
|
-
|
|
200
|
-
from ..package_constraints import parse_requirement_line, validate_package_exists
|
|
201
|
-
parsed = parse_requirement_line(trigger.strip())
|
|
202
|
-
if not parsed:
|
|
203
|
-
return False, "Invalid trigger format. Use format like 'package>1.0'"
|
|
204
|
-
|
|
205
|
-
# Check that the package exists
|
|
206
|
-
package_name = parsed['name']
|
|
207
|
-
exists, error_msg = validate_package_exists(package_name)
|
|
208
|
-
if not exists:
|
|
209
|
-
return False, error_msg
|
|
210
|
-
|
|
211
|
-
constraint = parsed['constraint']
|
|
212
|
-
# Check that only '>' operator is used (not '>=', '<', '<=', '==', '!=', '~=')
|
|
213
|
-
if not constraint.startswith('>') or constraint.startswith('>='):
|
|
214
|
-
return False, "Invalidation trigger must use only '>' operator (e.g., 'package>1.0')"
|
|
215
|
-
|
|
216
|
-
# Additional check to ensure it's exactly '>' and not '>='
|
|
217
|
-
if '>=' in constraint or '<' in constraint or '==' in constraint or '!=' in constraint or '~=' in constraint:
|
|
218
|
-
return False, "Invalidation trigger must use only '>' operator (e.g., 'package>1.0')"
|
|
219
|
-
|
|
220
|
-
return True, ""
|
|
221
|
-
|
|
222
|
-
def _handle_constraint_submission(self) -> None:
|
|
223
|
-
"""Handle constraint submission with validation."""
|
|
224
|
-
constraint_input = self.query_one("#constraint-input", Input)
|
|
225
|
-
invalidation_input = self.query_one("#invalidation-input", Input)
|
|
226
|
-
|
|
227
|
-
self.constraint_value = constraint_input.value.strip()
|
|
228
|
-
self.invalidation_trigger = invalidation_input.value.strip()
|
|
229
|
-
|
|
230
|
-
if not self.constraint_value:
|
|
231
|
-
self.app.notify("Constraint cannot be empty", severity="error")
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
# Validate that the constraint package exists
|
|
235
|
-
from ..package_constraints import parse_requirement_line, validate_package_exists
|
|
236
|
-
constraint_spec = f"{self.package_name}{self.constraint_value}"
|
|
237
|
-
parsed_constraint = parse_requirement_line(constraint_spec)
|
|
238
|
-
if parsed_constraint:
|
|
239
|
-
constraint_package = parsed_constraint['name']
|
|
240
|
-
exists, error_msg = validate_package_exists(constraint_package)
|
|
241
|
-
if not exists:
|
|
242
|
-
self.app.notify(f"Constraint package error: {error_msg}", severity="error")
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
# Validate invalidation trigger if provided
|
|
246
|
-
if self.invalidation_trigger:
|
|
247
|
-
is_valid, error_msg = self._validate_invalidation_trigger(self.invalidation_trigger)
|
|
248
|
-
if not is_valid:
|
|
249
|
-
self.app.notify(f"Invalid trigger: {error_msg}", severity="error")
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
# Return both constraint and trigger as a tuple
|
|
253
|
-
result = (self.constraint_value, self.invalidation_trigger) if self.invalidation_trigger else self.constraint_value
|
|
254
|
-
self.dismiss(result)
|
|
255
|
-
|
|
256
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
257
|
-
"""Handle input submission."""
|
|
258
|
-
if event.input.id == "constraint-input":
|
|
259
|
-
self._handle_constraint_submission()
|
|
260
|
-
elif event.input.id == "invalidation-input":
|
|
261
|
-
self._handle_constraint_submission()
|
|
262
|
-
|
|
263
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
264
|
-
"""Handle button press events."""
|
|
265
|
-
if event.button.id == "add-constraint-btn":
|
|
266
|
-
self._handle_constraint_submission()
|
|
267
|
-
elif event.button.id == "cancel-constraint-btn":
|
|
268
|
-
self.dismiss(None)
|
|
269
|
-
|
|
270
|
-
def action_add_constraint(self) -> None:
|
|
271
|
-
"""Add constraint action (triggered by Enter key)."""
|
|
272
|
-
self._handle_constraint_submission()
|
|
273
|
-
|
|
274
|
-
def action_cancel(self) -> None:
|
|
275
|
-
"""Cancel action (triggered by Escape key)."""
|
|
276
|
-
self.dismiss(None)
|
|
277
|
-
|
|
278
|
-
CSS = """
|
|
279
|
-
ConstraintInputScreen {
|
|
280
|
-
align: center middle;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
#constraint-dialog {
|
|
284
|
-
width: 70;
|
|
285
|
-
max-width: 90%;
|
|
286
|
-
height: auto;
|
|
287
|
-
min-height: 16;
|
|
288
|
-
max-height: 80%;
|
|
289
|
-
padding: 1 2;
|
|
290
|
-
background: $surface;
|
|
291
|
-
border: thick $background 80%;
|
|
292
|
-
content-align: left top;
|
|
293
|
-
dock: none;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
#constraint-title {
|
|
297
|
-
text-style: bold;
|
|
298
|
-
text-align: center;
|
|
299
|
-
padding: 0;
|
|
300
|
-
margin: 0 0 1 0;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
#constraint-help, #invalidation-help {
|
|
304
|
-
text-style: italic;
|
|
305
|
-
padding: 0;
|
|
306
|
-
margin: 0 0 0 0;
|
|
307
|
-
color: $text 70%;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
#current-constraint {
|
|
311
|
-
color: $warning;
|
|
312
|
-
text-style: italic;
|
|
313
|
-
padding: 0;
|
|
314
|
-
margin: 0 0 1 0;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
#constraint-input, #invalidation-input {
|
|
318
|
-
height: 3;
|
|
319
|
-
min-height: 3;
|
|
320
|
-
max-height: 3;
|
|
321
|
-
padding: 0 1;
|
|
322
|
-
background: $surface-lighten-1;
|
|
323
|
-
color: $text;
|
|
324
|
-
border: solid $primary;
|
|
325
|
-
content-align: left middle;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
#constraint-buttons {
|
|
329
|
-
padding: 0;
|
|
330
|
-
margin: 1 0 0 0;
|
|
331
|
-
height: 3;
|
|
332
|
-
content-align: center middle;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
#constraint-buttons > Button {
|
|
336
|
-
width: 1fr;
|
|
337
|
-
height: 3;
|
|
338
|
-
margin: 0 1;
|
|
339
|
-
text-align: center;
|
|
340
|
-
text-style: bold;
|
|
341
|
-
color: white;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/* Ensure button text is visible */
|
|
345
|
-
ConstraintInputScreen Button > .label,
|
|
346
|
-
ConstraintInputScreen Button .button--label {
|
|
347
|
-
color: white !important;
|
|
348
|
-
text-style: bold;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/* Consistent focus highlighting for tab navigation */
|
|
352
|
-
ConstraintInputScreen Button:focus {
|
|
353
|
-
text-style: bold !important;
|
|
354
|
-
color: white !important;
|
|
355
|
-
border: thick $accent !important;
|
|
356
|
-
}
|
|
357
|
-
"""
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
class HelpScreen(ModalScreen):
|
|
361
|
-
"""Modal screen showing keyboard shortcuts and help information."""
|
|
362
|
-
|
|
363
|
-
BINDINGS = [
|
|
364
|
-
("escape,h", "dismiss", "Close Help"),
|
|
365
|
-
]
|
|
366
|
-
|
|
367
|
-
def compose(self) -> ComposeResult:
|
|
368
|
-
"""Create the help dialog with comprehensive information."""
|
|
369
|
-
with Vertical(id="help-dialog"):
|
|
370
|
-
yield Label("pipu - Package Management Help", id="help-title")
|
|
371
|
-
|
|
372
|
-
with ScrollableContainer(id="help-content"):
|
|
373
|
-
# Create help table
|
|
374
|
-
help_table = DataTable(id="help-table")
|
|
375
|
-
help_table.add_column("Key", width=12)
|
|
376
|
-
help_table.add_column("Action", width=20)
|
|
377
|
-
help_table.add_column("Description", width=50)
|
|
378
|
-
|
|
379
|
-
# Add keyboard shortcuts
|
|
380
|
-
shortcuts = [
|
|
381
|
-
("↑/↓", "Navigate", "Move cursor up/down through package list"),
|
|
382
|
-
("Space", "Toggle Selection", "Select/deselect package for update"),
|
|
383
|
-
("U", "Update Selected", "Start updating all selected packages"),
|
|
384
|
-
("C", "Add Constraint", "Add version constraint to current package"),
|
|
385
|
-
("D", "Delete Constraint", "Delete constraint from current package"),
|
|
386
|
-
("R", "Remove All Constraints", "Remove all constraints from configuration"),
|
|
387
|
-
("X", "Uninstall", "Uninstall the currently selected package"),
|
|
388
|
-
("F", "Filter Outdated", "Show only packages with available updates"),
|
|
389
|
-
("S", "Show All", "Show all installed packages"),
|
|
390
|
-
("H", "Help", "Show this help dialog"),
|
|
391
|
-
("Q/Esc", "Quit", "Exit the application"),
|
|
392
|
-
]
|
|
393
|
-
|
|
394
|
-
for key, action, description in shortcuts:
|
|
395
|
-
help_table.add_row(key, action, description)
|
|
396
|
-
|
|
397
|
-
yield help_table
|
|
398
|
-
|
|
399
|
-
yield Label("Features Overview:", id="features-title")
|
|
400
|
-
yield Static(
|
|
401
|
-
"• Constraints: Add version constraints (e.g., >=1.0.0, <2.0.0) to prevent unwanted updates\n"
|
|
402
|
-
"• Auto-Discovered Constraints: Constraints are automatically discovered from installed packages on every run\n"
|
|
403
|
-
"• Invalidation Triggers: Constraints can be automatically removed when trigger packages are updated\n"
|
|
404
|
-
"• Real-time Updates: Package information updates as checks complete\n"
|
|
405
|
-
"• Smart Selection: Packages are auto-selected only if they satisfy existing constraints\n"
|
|
406
|
-
"• Filter Modes: View all packages or filter to show only those with updates available",
|
|
407
|
-
id="features-text"
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
yield Label("Auto-Discovered Constraints - How They Work:", id="auto-constraints-title")
|
|
411
|
-
yield Static(
|
|
412
|
-
"Auto-discovered constraints are automatically generated each time pipu runs by analyzing\n"
|
|
413
|
-
"your installed packages and their dependencies. They are transient and never written to config.\n\n"
|
|
414
|
-
"How Auto-Discovery Works:\n"
|
|
415
|
-
"1. Scans all installed packages and their version requirements on every pipu execution\n"
|
|
416
|
-
"2. Identifies packages that depend on specific versions of other packages\n"
|
|
417
|
-
"3. Creates temporary constraints to prevent breaking these dependencies\n"
|
|
418
|
-
"4. Merges with your manual constraints (manual constraints always take precedence)\n\n"
|
|
419
|
-
"Example: If package 'requests' requires 'urllib3>=1.21.1,<3', pipu will automatically\n"
|
|
420
|
-
"apply a constraint for urllib3 to prevent updates that could break requests.\n\n"
|
|
421
|
-
"Benefits:\n"
|
|
422
|
-
"• Prevents dependency conflicts during updates automatically\n"
|
|
423
|
-
"• Maintains package compatibility without manual intervention\n"
|
|
424
|
-
"• Reduces the risk of broken installations\n"
|
|
425
|
-
"• Always reflects current package state (no stale constraints)",
|
|
426
|
-
id="auto-constraints-text"
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
yield Label("Constraint Invalidation Triggers:", id="triggers-title")
|
|
430
|
-
yield Static(
|
|
431
|
-
"Invalidation triggers automatically remove constraints when specific conditions are met.\n\n"
|
|
432
|
-
"How Triggers Work:\n"
|
|
433
|
-
"1. Each auto constraint will have at least one package that 'triggers' its invalidation\n"
|
|
434
|
-
"2. When a trigger package is updated, its related constraints are removed\n"
|
|
435
|
-
"3. This prevents outdated constraints from blocking future updates\n\n"
|
|
436
|
-
"Example Workflow:\n"
|
|
437
|
-
"• Auto constraint created: 'urllib3<2.0.0' (triggered by requests v2.28.0)\n"
|
|
438
|
-
"• Later, requests is updated to v2.31.0 (which supports urllib3 v2.x)\n"
|
|
439
|
-
"• The urllib3 constraint is automatically removed\n"
|
|
440
|
-
"• urllib3 can now be updated to newer versions\n\n"
|
|
441
|
-
"Why This Matters:\n"
|
|
442
|
-
"• Constraints become outdated as dependencies evolve\n"
|
|
443
|
-
"• Manual constraint management is error-prone and time-consuming\n"
|
|
444
|
-
"• Triggers ensure constraints stay relevant and don't block legitimate updates\n"
|
|
445
|
-
"• Maintains the balance between stability and staying current",
|
|
446
|
-
id="triggers-text"
|
|
447
|
-
)
|
|
448
|
-
|
|
449
|
-
yield Label("Tips for Best Results:", id="tips-title")
|
|
450
|
-
yield Static(
|
|
451
|
-
"• Use 'F' to filter and focus on packages that actually need updates\n"
|
|
452
|
-
"• Auto-discovered constraints protect dependencies automatically (no manual action needed)\n"
|
|
453
|
-
"• Review constraint colors: green = can update, red = blocked by constraint\n"
|
|
454
|
-
"• Use 'C' to add custom manual constraints for packages you want to pin\n"
|
|
455
|
-
"• Check 'Invalid When' column to understand when constraints will be removed",
|
|
456
|
-
id="tips-text"
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
with Horizontal(id="help-buttons"):
|
|
460
|
-
yield Button(Text("Close", style="bold white"), id="close-help-btn", variant="primary")
|
|
461
|
-
|
|
462
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
463
|
-
"""Handle button press events."""
|
|
464
|
-
if event.button.id == "close-help-btn":
|
|
465
|
-
self.dismiss()
|
|
466
|
-
|
|
467
|
-
async def action_dismiss(self, result: Any = None) -> None:
|
|
468
|
-
"""Close the help dialog."""
|
|
469
|
-
self.dismiss(result)
|
|
470
|
-
|
|
471
|
-
CSS = """
|
|
472
|
-
HelpScreen {
|
|
473
|
-
align: center middle;
|
|
474
|
-
layer: overlay;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
#help-dialog {
|
|
478
|
-
padding: 2;
|
|
479
|
-
width: 90;
|
|
480
|
-
max-width: 95%;
|
|
481
|
-
height: auto;
|
|
482
|
-
min-height: 30;
|
|
483
|
-
max-height: 90%;
|
|
484
|
-
border: thick $primary;
|
|
485
|
-
background: $surface;
|
|
486
|
-
content-align: left top;
|
|
487
|
-
dock: none;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
#help-title {
|
|
491
|
-
text-style: bold;
|
|
492
|
-
text-align: center;
|
|
493
|
-
padding: 0 0 1 0;
|
|
494
|
-
color: $text;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
#help-content {
|
|
498
|
-
padding: 0 1 1 1;
|
|
499
|
-
background: transparent;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
#help-table {
|
|
503
|
-
margin: 0 0 1 0;
|
|
504
|
-
background: transparent;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
#features-title, #auto-constraints-title, #triggers-title, #tips-title {
|
|
508
|
-
text-style: bold;
|
|
509
|
-
padding: 1 0 0 0;
|
|
510
|
-
color: $accent;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
#features-text, #auto-constraints-text, #triggers-text, #tips-text {
|
|
514
|
-
padding: 0 0 1 0;
|
|
515
|
-
color: $text;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
#help-buttons {
|
|
519
|
-
padding: 1 0 0 0;
|
|
520
|
-
height: 5;
|
|
521
|
-
align: center middle;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
#help-buttons Button {
|
|
525
|
-
width: 20;
|
|
526
|
-
margin: 0 2;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/* Consistent button styling */
|
|
530
|
-
#close-help-btn {
|
|
531
|
-
background: $primary;
|
|
532
|
-
border: tall $primary;
|
|
533
|
-
color: white;
|
|
534
|
-
text-style: bold;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
#close-help-btn:focus {
|
|
538
|
-
text-style: bold !important;
|
|
539
|
-
color: white !important;
|
|
540
|
-
border: thick $accent !important;
|
|
541
|
-
}
|
|
542
|
-
"""
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
class DeleteConstraintConfirmScreen(ModalScreen[bool]):
|
|
546
|
-
"""Modal screen to confirm constraint deletion."""
|
|
547
|
-
|
|
548
|
-
BINDINGS = [
|
|
549
|
-
("escape", "cancel", "Cancel"),
|
|
550
|
-
]
|
|
551
|
-
|
|
552
|
-
def __init__(self, package_name: str, constraint: str):
|
|
553
|
-
super().__init__()
|
|
554
|
-
self.package_name = package_name
|
|
555
|
-
self.constraint = constraint
|
|
556
|
-
|
|
557
|
-
def compose(self) -> ComposeResult:
|
|
558
|
-
# Use rich.Text with inline styling + Horizontal container to escape grid row styling
|
|
559
|
-
confirm = Button(Text("Yes, Delete", style="bold white"), id="confirm", variant="error")
|
|
560
|
-
cancel = Button(Text("Cancel", style="bold white"), id="cancel", variant="primary")
|
|
561
|
-
|
|
562
|
-
yield Grid(
|
|
563
|
-
Label(f"Delete constraint '{self.constraint}' for '{self.package_name}'?", id="question"),
|
|
564
|
-
Label("This will remove the constraint and any invalidation triggers.", id="warning"),
|
|
565
|
-
Horizontal(
|
|
566
|
-
confirm,
|
|
567
|
-
cancel,
|
|
568
|
-
id="actions",
|
|
569
|
-
),
|
|
570
|
-
id="dialog",
|
|
571
|
-
)
|
|
572
|
-
|
|
573
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
574
|
-
if event.button.id == "confirm":
|
|
575
|
-
self.dismiss(True)
|
|
576
|
-
else:
|
|
577
|
-
self.dismiss(False)
|
|
578
|
-
|
|
579
|
-
def action_cancel(self) -> None:
|
|
580
|
-
"""Cancel constraint deletion (triggered by Escape key)."""
|
|
581
|
-
self.dismiss(False)
|
|
582
|
-
|
|
583
|
-
CSS = """
|
|
584
|
-
DeleteConstraintConfirmScreen {
|
|
585
|
-
align: center middle;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
DeleteConstraintConfirmScreen Button {
|
|
589
|
-
width: 100%;
|
|
590
|
-
height: 3;
|
|
591
|
-
margin: 1 0;
|
|
592
|
-
text-align: center;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/* Let variants handle label styling
|
|
596
|
-
DeleteConstraintConfirmScreen Button > .label,
|
|
597
|
-
DeleteConstraintConfirmScreen Button .button--label {
|
|
598
|
-
color: white !important;
|
|
599
|
-
text-style: bold;
|
|
600
|
-
opacity: 1.0 !important;
|
|
601
|
-
}
|
|
602
|
-
*/
|
|
603
|
-
|
|
604
|
-
/* Explicit button styling for test validation */
|
|
605
|
-
#actions > #confirm {
|
|
606
|
-
background: $error;
|
|
607
|
-
border: tall $error;
|
|
608
|
-
color: white;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
#actions > #cancel {
|
|
612
|
-
background: $primary;
|
|
613
|
-
border: tall $primary;
|
|
614
|
-
color: white;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
#actions {
|
|
618
|
-
column-span: 2; /* the row spans both columns */
|
|
619
|
-
height: 4;
|
|
620
|
-
content-align: center middle;
|
|
621
|
-
padding: 0 1;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
#actions > Button {
|
|
625
|
-
width: 1fr;
|
|
626
|
-
height: 3;
|
|
627
|
-
margin: 0 1;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
#dialog {
|
|
631
|
-
grid-size: 2;
|
|
632
|
-
grid-gutter: 1 2;
|
|
633
|
-
grid-rows: 1fr 1fr 4;
|
|
634
|
-
padding: 0 1;
|
|
635
|
-
width: 70;
|
|
636
|
-
height: 13;
|
|
637
|
-
border: thick $background 80%;
|
|
638
|
-
background: $surface;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
#question {
|
|
642
|
-
column-span: 2;
|
|
643
|
-
height: 1fr;
|
|
644
|
-
width: 1fr;
|
|
645
|
-
content-align: center middle;
|
|
646
|
-
text-style: bold;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
#warning {
|
|
650
|
-
column-span: 2;
|
|
651
|
-
height: 1fr;
|
|
652
|
-
width: 1fr;
|
|
653
|
-
content-align: center middle;
|
|
654
|
-
color: $warning;
|
|
655
|
-
text-style: italic;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/* Safety net for text color and focus consistency */
|
|
659
|
-
DeleteConstraintConfirmScreen Button,
|
|
660
|
-
DeleteConstraintConfirmScreen Button > .label,
|
|
661
|
-
DeleteConstraintConfirmScreen Button .button--label {
|
|
662
|
-
color: white !important;
|
|
663
|
-
text-style: bold;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/* Consistent focus highlighting for tab navigation */
|
|
667
|
-
DeleteConstraintConfirmScreen Button:focus {
|
|
668
|
-
text-style: bold !important;
|
|
669
|
-
color: white !important;
|
|
670
|
-
border: thick $accent !important;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
"""
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
class RemoveAllConstraintsConfirmScreen(ModalScreen[bool]):
|
|
677
|
-
"""Modal screen to confirm removal of all constraints."""
|
|
678
|
-
|
|
679
|
-
BINDINGS = [
|
|
680
|
-
("escape", "cancel", "Cancel"),
|
|
681
|
-
]
|
|
682
|
-
|
|
683
|
-
def __init__(self, constraint_count: int):
|
|
684
|
-
super().__init__()
|
|
685
|
-
self.constraint_count = constraint_count
|
|
686
|
-
|
|
687
|
-
def compose(self) -> ComposeResult:
|
|
688
|
-
# Use rich.Text with inline styling + Horizontal container for consistent modal styling
|
|
689
|
-
confirm = Button(Text("Yes, Remove All", style="bold white"), id="confirm", variant="error")
|
|
690
|
-
cancel = Button(Text("Cancel", style="bold white"), id="cancel", variant="primary")
|
|
691
|
-
|
|
692
|
-
with Vertical(id="remove-all-dialog"):
|
|
693
|
-
yield Label(f"Remove all {self.constraint_count} constraints?", id="question")
|
|
694
|
-
yield Label("This will remove ALL constraints and invalidation triggers from your pip configuration.", id="warning")
|
|
695
|
-
yield Label("This action cannot be undone!", id="final-warning")
|
|
696
|
-
with Horizontal(id="actions"):
|
|
697
|
-
yield confirm
|
|
698
|
-
yield cancel
|
|
699
|
-
|
|
700
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
701
|
-
if event.button.id == "confirm":
|
|
702
|
-
self.dismiss(True)
|
|
703
|
-
else:
|
|
704
|
-
self.dismiss(False)
|
|
705
|
-
|
|
706
|
-
def action_cancel(self) -> None:
|
|
707
|
-
"""Cancel removal of all constraints (triggered by Escape key)."""
|
|
708
|
-
self.dismiss(False)
|
|
709
|
-
|
|
710
|
-
CSS = """
|
|
711
|
-
RemoveAllConstraintsConfirmScreen {
|
|
712
|
-
align: center middle;
|
|
713
|
-
layer: overlay;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
#remove-all-dialog {
|
|
717
|
-
padding: 2;
|
|
718
|
-
width: 60;
|
|
719
|
-
max-width: 90%;
|
|
720
|
-
height: auto;
|
|
721
|
-
min-height: 16;
|
|
722
|
-
max-height: 80%;
|
|
723
|
-
border: thick $primary;
|
|
724
|
-
background: $surface;
|
|
725
|
-
content-align: left top;
|
|
726
|
-
dock: none;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
#question {
|
|
730
|
-
text-style: bold;
|
|
731
|
-
text-align: center;
|
|
732
|
-
padding: 0 0 1 0;
|
|
733
|
-
color: $text;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
#warning {
|
|
737
|
-
text-align: center;
|
|
738
|
-
padding: 0 0 1 0;
|
|
739
|
-
color: $warning;
|
|
740
|
-
text-style: italic;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
#final-warning {
|
|
744
|
-
text-align: center;
|
|
745
|
-
padding: 0 0 1 0;
|
|
746
|
-
color: $error;
|
|
747
|
-
text-style: bold;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
#actions {
|
|
751
|
-
padding: 1 0 0 0;
|
|
752
|
-
height: 5;
|
|
753
|
-
align: center middle;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
#actions Button {
|
|
757
|
-
width: 20;
|
|
758
|
-
margin: 0 2;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
/* Explicit button styling for consistency */
|
|
762
|
-
#actions > #confirm {
|
|
763
|
-
background: $error;
|
|
764
|
-
border: tall $error;
|
|
765
|
-
color: white;
|
|
766
|
-
text-style: bold;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
#actions > #cancel {
|
|
770
|
-
background: $primary;
|
|
771
|
-
border: tall $primary;
|
|
772
|
-
color: white;
|
|
773
|
-
text-style: bold;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/* Safety net for text color and focus consistency */
|
|
777
|
-
RemoveAllConstraintsConfirmScreen Button,
|
|
778
|
-
RemoveAllConstraintsConfirmScreen Button > .label,
|
|
779
|
-
RemoveAllConstraintsConfirmScreen Button .button--label {
|
|
780
|
-
color: white !important;
|
|
781
|
-
text-style: bold;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/* Consistent focus highlighting for tab navigation */
|
|
785
|
-
RemoveAllConstraintsConfirmScreen Button:focus {
|
|
786
|
-
text-style: bold !important;
|
|
787
|
-
color: white !important;
|
|
788
|
-
border: thick $accent !important;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
"""
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
class UninstallConfirmScreen(BaseConfirmationScreen):
|
|
795
|
-
"""Modal screen to confirm package uninstall."""
|
|
796
|
-
|
|
797
|
-
def __init__(self, package_name: str):
|
|
798
|
-
message = f"Are you sure you want to uninstall '{package_name}'?"
|
|
799
|
-
super().__init__(
|
|
800
|
-
message=message,
|
|
801
|
-
confirm_text="Yes, Uninstall",
|
|
802
|
-
cancel_text="Cancel",
|
|
803
|
-
confirm_variant="error",
|
|
804
|
-
cancel_variant="primary"
|
|
805
|
-
)
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
class UpdateConfirmScreen(ModalScreen[bool]):
|
|
809
|
-
"""Modal screen to confirm package updates."""
|
|
810
|
-
|
|
811
|
-
BINDINGS = [
|
|
812
|
-
("escape", "cancel", "Cancel"),
|
|
813
|
-
]
|
|
814
|
-
|
|
815
|
-
def __init__(self, selected_packages: List[Dict[str, Any]]):
|
|
816
|
-
super().__init__()
|
|
817
|
-
self.selected_packages = selected_packages
|
|
818
|
-
|
|
819
|
-
def compose(self) -> ComposeResult:
|
|
820
|
-
package_count = len(self.selected_packages)
|
|
821
|
-
|
|
822
|
-
if package_count <= 3:
|
|
823
|
-
package_list = ", ".join([pkg["name"] for pkg in self.selected_packages])
|
|
824
|
-
else:
|
|
825
|
-
package_list = ", ".join([pkg["name"] for pkg in self.selected_packages[:3]])
|
|
826
|
-
package_list += f" and {package_count - 3} more"
|
|
827
|
-
|
|
828
|
-
# Create summary of what will be updated
|
|
829
|
-
editable_count = sum(1 for pkg in self.selected_packages if pkg.get("editable", False))
|
|
830
|
-
constrained_count = sum(1 for pkg in self.selected_packages if pkg.get("constraint"))
|
|
831
|
-
|
|
832
|
-
summary_parts = [f"{package_count} packages"]
|
|
833
|
-
if editable_count > 0:
|
|
834
|
-
summary_parts.append(f"{editable_count} editable")
|
|
835
|
-
if constrained_count > 0:
|
|
836
|
-
summary_parts.append(f"{constrained_count} with constraints")
|
|
837
|
-
|
|
838
|
-
summary = f"Update {', '.join(summary_parts)}?"
|
|
839
|
-
|
|
840
|
-
confirm = Button(Text("Yes, Update", style="bold white"), id="confirm", variant="success")
|
|
841
|
-
cancel = Button(Text("Cancel", style="bold white"), id="cancel", variant="primary")
|
|
842
|
-
|
|
843
|
-
yield Grid(
|
|
844
|
-
Label(f"{summary}\n\nPackages: {package_list}", id="question"),
|
|
845
|
-
Horizontal(
|
|
846
|
-
confirm,
|
|
847
|
-
cancel,
|
|
848
|
-
id="actions",
|
|
849
|
-
),
|
|
850
|
-
id="dialog",
|
|
851
|
-
)
|
|
852
|
-
|
|
853
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
854
|
-
if event.button.id == "confirm":
|
|
855
|
-
self.dismiss(True)
|
|
856
|
-
else:
|
|
857
|
-
self.dismiss(False)
|
|
858
|
-
|
|
859
|
-
def action_cancel(self) -> None:
|
|
860
|
-
"""Cancel package update (triggered by Escape key)."""
|
|
861
|
-
self.dismiss(False)
|
|
862
|
-
|
|
863
|
-
CSS = """
|
|
864
|
-
UpdateConfirmScreen {
|
|
865
|
-
align: center middle;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
UpdateConfirmScreen Button {
|
|
869
|
-
height: 3;
|
|
870
|
-
margin: 0 1;
|
|
871
|
-
text-align: center;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
#actions {
|
|
875
|
-
column-span: 2; /* the row spans both columns */
|
|
876
|
-
height: 4;
|
|
877
|
-
content-align: center middle;
|
|
878
|
-
padding: 0 1;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
#actions > Button {
|
|
882
|
-
width: 1fr;
|
|
883
|
-
height: 3;
|
|
884
|
-
margin: 0 1;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
/* Explicit button styling for confirmation */
|
|
888
|
-
#actions > #confirm {
|
|
889
|
-
background: $success;
|
|
890
|
-
border: tall $success;
|
|
891
|
-
color: white;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
#actions > #cancel {
|
|
895
|
-
background: $primary;
|
|
896
|
-
border: tall $primary;
|
|
897
|
-
color: white;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
#dialog {
|
|
901
|
-
grid-size: 2;
|
|
902
|
-
grid-gutter: 1 2;
|
|
903
|
-
grid-rows: 1fr 4;
|
|
904
|
-
padding: 0 1;
|
|
905
|
-
width: 60;
|
|
906
|
-
height: 15; /* Slightly taller for update details */
|
|
907
|
-
border: thick $background 80%;
|
|
908
|
-
background: $surface;
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
#question {
|
|
912
|
-
column-span: 2;
|
|
913
|
-
height: 1fr;
|
|
914
|
-
width: 1fr;
|
|
915
|
-
content-align: center middle;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/* Safety net for text color and focus consistency */
|
|
919
|
-
UpdateConfirmScreen Button,
|
|
920
|
-
UpdateConfirmScreen Button > .label,
|
|
921
|
-
UpdateConfirmScreen Button .button--label {
|
|
922
|
-
color: white !important;
|
|
923
|
-
text-style: bold;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/* Consistent focus highlighting for tab navigation */
|
|
927
|
-
UpdateConfirmScreen Button:focus {
|
|
928
|
-
text-style: bold !important;
|
|
929
|
-
color: white !important;
|
|
930
|
-
border: thick $accent !important;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
"""
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
class PackageUpdateScreen(ModalScreen[None]):
|
|
937
|
-
"""Full-screen modal for showing package update progress and handling cleanup."""
|
|
938
|
-
|
|
939
|
-
BINDINGS = [
|
|
940
|
-
("escape", "handle_escape", "Cancel/Exit"),
|
|
941
|
-
("enter", "handle_enter", "Exit (when complete)"),
|
|
942
|
-
]
|
|
943
|
-
|
|
944
|
-
def __init__(self, selected_packages: List[Dict[str, Any]]):
|
|
945
|
-
super().__init__()
|
|
946
|
-
self.selected_packages = selected_packages
|
|
947
|
-
self.update_complete = False
|
|
948
|
-
self.successful_updates: List[str] = []
|
|
949
|
-
self.failed_updates: List[str] = []
|
|
950
|
-
self.cancel_event: Optional[threading.Event] = None
|
|
951
|
-
self._log_content = "" # Track log content internally
|
|
952
|
-
|
|
953
|
-
def compose(self) -> ComposeResult:
|
|
954
|
-
package_count = len(self.selected_packages)
|
|
955
|
-
|
|
956
|
-
yield Grid(
|
|
957
|
-
Static(f"Updating {package_count} Packages", id="title"),
|
|
958
|
-
Static("Preparing updates...", id="status"),
|
|
959
|
-
ScrollableContainer(
|
|
960
|
-
Static("", id="progress-log"),
|
|
961
|
-
id="log-container"
|
|
962
|
-
),
|
|
963
|
-
Static("Press Escape to cancel updates", id="footer"),
|
|
964
|
-
id="update-dialog",
|
|
965
|
-
)
|
|
966
|
-
|
|
967
|
-
def on_mount(self) -> None:
|
|
968
|
-
"""Start the update process when the screen is mounted."""
|
|
969
|
-
logger.info(f"PackageUpdateScreen mounted with {len(self.selected_packages)} packages")
|
|
970
|
-
self._log_message(f"📋 Preparing to update {len(self.selected_packages)} packages...")
|
|
971
|
-
# Use a lambda to avoid the call_later passing extra arguments
|
|
972
|
-
self.call_later(lambda: self._start_update_process())
|
|
973
|
-
|
|
974
|
-
def _start_update_process(self) -> None:
|
|
975
|
-
"""Start the package update process in a worker thread."""
|
|
976
|
-
logger.info("Starting update process...")
|
|
977
|
-
self._update_status("Starting package updates...")
|
|
978
|
-
self._log_message("🚀 Beginning package update process...")
|
|
979
|
-
|
|
980
|
-
# Create a cancellation event
|
|
981
|
-
self.cancel_event = threading.Event()
|
|
982
|
-
|
|
983
|
-
def run_updates():
|
|
984
|
-
"""Run package updates in a worker thread."""
|
|
985
|
-
# Ensure cancel_event is set (should always be true when called from _start_update_process)
|
|
986
|
-
if self.cancel_event is None:
|
|
987
|
-
raise RuntimeError("Update process started without cancel_event being initialized")
|
|
988
|
-
|
|
989
|
-
try:
|
|
990
|
-
import subprocess
|
|
991
|
-
import sys
|
|
992
|
-
import tempfile
|
|
993
|
-
import os
|
|
994
|
-
from packaging.utils import canonicalize_name
|
|
995
|
-
|
|
996
|
-
logger.info(f"Starting batch update for {len(self.selected_packages)} packages")
|
|
997
|
-
total_packages = len(self.selected_packages)
|
|
998
|
-
|
|
999
|
-
# Get canonical names of packages being updated
|
|
1000
|
-
from ..package_constraints import read_constraints
|
|
1001
|
-
all_constraints = read_constraints()
|
|
1002
|
-
packages_being_updated = {canonicalize_name(pkg["name"]) for pkg in self.selected_packages}
|
|
1003
|
-
|
|
1004
|
-
# Filter out constraints for packages being updated to avoid conflicts
|
|
1005
|
-
filtered_constraints = {
|
|
1006
|
-
pkg: constraint
|
|
1007
|
-
for pkg, constraint in all_constraints.items()
|
|
1008
|
-
if pkg not in packages_being_updated
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
# Create a temporary constraints file if there are any constraints to apply
|
|
1012
|
-
constraint_file = None
|
|
1013
|
-
constraint_file_path = None
|
|
1014
|
-
try:
|
|
1015
|
-
if filtered_constraints:
|
|
1016
|
-
constraint_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
|
|
1017
|
-
constraint_file_path = constraint_file.name
|
|
1018
|
-
for pkg, constraint in filtered_constraints.items():
|
|
1019
|
-
constraint_file.write(f"{pkg}{constraint}\n")
|
|
1020
|
-
constraint_file.close()
|
|
1021
|
-
self.app.call_from_thread(self._log_message, f"[dim]Using filtered constraints (excluding {len(packages_being_updated)} package(s) being updated)[/dim]")
|
|
1022
|
-
|
|
1023
|
-
# Build list of package names to update
|
|
1024
|
-
# Use --upgrade instead of pinning versions to avoid dependency conflicts
|
|
1025
|
-
# when updating interdependent packages (e.g., pydantic and pydantic-core)
|
|
1026
|
-
package_names = []
|
|
1027
|
-
for pkg in self.selected_packages:
|
|
1028
|
-
package_names.append(pkg["name"])
|
|
1029
|
-
|
|
1030
|
-
self.app.call_from_thread(self._update_status, f"Updating {total_packages} packages...")
|
|
1031
|
-
self.app.call_from_thread(self._log_message, f"{'='*70}")
|
|
1032
|
-
self.app.call_from_thread(self._log_message, f"📦 Updating {total_packages} packages: {', '.join(package_names[:5])}")
|
|
1033
|
-
if len(package_names) > 5:
|
|
1034
|
-
self.app.call_from_thread(self._log_message, f" ... and {len(package_names) - 5} more")
|
|
1035
|
-
self.app.call_from_thread(self._log_message, f"{'='*70}\n")
|
|
1036
|
-
|
|
1037
|
-
# Prepare pip command to install all packages with --upgrade
|
|
1038
|
-
# This allows pip's dependency resolver to find compatible versions
|
|
1039
|
-
# for interdependent packages (e.g., pydantic requires specific pydantic-core)
|
|
1040
|
-
pip_cmd = [sys.executable, "-m", "pip", "install", "--upgrade"] + package_names
|
|
1041
|
-
|
|
1042
|
-
# Set up environment with constraint file if available
|
|
1043
|
-
env = os.environ.copy()
|
|
1044
|
-
if constraint_file_path:
|
|
1045
|
-
env['PIP_CONSTRAINT'] = constraint_file_path
|
|
1046
|
-
|
|
1047
|
-
# Run pip and capture output with proper cleanup
|
|
1048
|
-
from ..utils import ManagedProcess
|
|
1049
|
-
return_code = None
|
|
1050
|
-
|
|
1051
|
-
try:
|
|
1052
|
-
with ManagedProcess(
|
|
1053
|
-
pip_cmd,
|
|
1054
|
-
stdout=subprocess.PIPE,
|
|
1055
|
-
stderr=subprocess.STDOUT,
|
|
1056
|
-
text=True,
|
|
1057
|
-
bufsize=1,
|
|
1058
|
-
universal_newlines=True,
|
|
1059
|
-
env=env
|
|
1060
|
-
) as process:
|
|
1061
|
-
# Stream output line by line
|
|
1062
|
-
if process.stdout is None:
|
|
1063
|
-
raise RuntimeError("Failed to capture subprocess output")
|
|
1064
|
-
for line in process.stdout:
|
|
1065
|
-
if self.cancel_event.is_set():
|
|
1066
|
-
self.app.call_from_thread(self._log_message, "\n🛑 Update cancelled by user")
|
|
1067
|
-
break
|
|
1068
|
-
# Display each line of pip output
|
|
1069
|
-
self.app.call_from_thread(self._log_message, line.rstrip())
|
|
1070
|
-
|
|
1071
|
-
# Wait for process to complete
|
|
1072
|
-
return_code = process.wait()
|
|
1073
|
-
except Exception as e:
|
|
1074
|
-
logger.error(f"Error during package update: {e}")
|
|
1075
|
-
self.app.call_from_thread(self._log_message, f"\n❌ Error: {e}")
|
|
1076
|
-
return_code = 1
|
|
1077
|
-
|
|
1078
|
-
if return_code == 0:
|
|
1079
|
-
# All packages updated successfully
|
|
1080
|
-
self.successful_updates.extend(package_names)
|
|
1081
|
-
self.app.call_from_thread(self._log_message, f"\n{'='*70}")
|
|
1082
|
-
self.app.call_from_thread(self._log_message, f"✅ Successfully updated all {total_packages} packages!")
|
|
1083
|
-
self.app.call_from_thread(self._log_message, f"{'='*70}")
|
|
1084
|
-
else:
|
|
1085
|
-
# Some packages failed - pip will have shown which ones in output
|
|
1086
|
-
self.failed_updates.extend(package_names)
|
|
1087
|
-
self.app.call_from_thread(self._log_message, f"\n{'='*70}")
|
|
1088
|
-
self.app.call_from_thread(self._log_message, "❌ Update completed with errors (see above)")
|
|
1089
|
-
self.app.call_from_thread(self._log_message, f"{'='*70}")
|
|
1090
|
-
|
|
1091
|
-
# Show final results and cleanup
|
|
1092
|
-
logger.info("Update loop completed, calling _update_complete")
|
|
1093
|
-
self.app.call_from_thread(self._update_complete)
|
|
1094
|
-
|
|
1095
|
-
finally:
|
|
1096
|
-
# Clean up temporary constraint file
|
|
1097
|
-
if constraint_file_path and os.path.exists(constraint_file_path):
|
|
1098
|
-
try:
|
|
1099
|
-
os.unlink(constraint_file_path)
|
|
1100
|
-
except Exception:
|
|
1101
|
-
pass # Best effort cleanup
|
|
1102
|
-
|
|
1103
|
-
except Exception as e:
|
|
1104
|
-
logger.error(f"Error in update loop: {e}", exc_info=True)
|
|
1105
|
-
self.app.call_from_thread(self._update_error, str(e))
|
|
1106
|
-
|
|
1107
|
-
# Run the updates in a worker thread
|
|
1108
|
-
logger.info("Starting worker thread for updates...")
|
|
1109
|
-
self.run_worker(run_updates, thread=True, exclusive=False, name="package_updates")
|
|
1110
|
-
logger.info("Worker thread started")
|
|
1111
|
-
|
|
1112
|
-
def _update_status(self, message: str) -> None:
|
|
1113
|
-
"""Update the status message."""
|
|
1114
|
-
try:
|
|
1115
|
-
status_widget = self.query_one("#status", Static)
|
|
1116
|
-
status_widget.update(message)
|
|
1117
|
-
except Exception:
|
|
1118
|
-
pass
|
|
1119
|
-
|
|
1120
|
-
def _log_message(self, message: str) -> None:
|
|
1121
|
-
"""Add a message to the progress log."""
|
|
1122
|
-
try:
|
|
1123
|
-
log_widget = self.query_one("#progress-log", Static)
|
|
1124
|
-
# Get current content - use render_str() or access the internal content
|
|
1125
|
-
try:
|
|
1126
|
-
current_content = str(log_widget.render())
|
|
1127
|
-
except Exception:
|
|
1128
|
-
# Fallback: keep track of content ourselves
|
|
1129
|
-
if not hasattr(self, '_log_content'):
|
|
1130
|
-
self._log_content = ""
|
|
1131
|
-
current_content = self._log_content
|
|
1132
|
-
|
|
1133
|
-
# Append new message
|
|
1134
|
-
if current_content:
|
|
1135
|
-
new_content = f"{current_content}\n{message}"
|
|
1136
|
-
else:
|
|
1137
|
-
new_content = message
|
|
1138
|
-
|
|
1139
|
-
# Update the widget
|
|
1140
|
-
log_widget.update(new_content)
|
|
1141
|
-
|
|
1142
|
-
# Save content for next time
|
|
1143
|
-
self._log_content = new_content
|
|
1144
|
-
|
|
1145
|
-
# Auto-scroll to bottom
|
|
1146
|
-
log_container = self.query_one("#log-container", ScrollableContainer)
|
|
1147
|
-
log_container.scroll_end()
|
|
1148
|
-
except Exception as e:
|
|
1149
|
-
logger.error(f"Error updating log: {e}", exc_info=True)
|
|
1150
|
-
|
|
1151
|
-
def _update_complete(self) -> None:
|
|
1152
|
-
"""Handle completion of the update process."""
|
|
1153
|
-
self.update_complete = True
|
|
1154
|
-
|
|
1155
|
-
# Show final results
|
|
1156
|
-
success_count = len(self.successful_updates)
|
|
1157
|
-
failure_count = len(self.failed_updates)
|
|
1158
|
-
|
|
1159
|
-
if success_count > 0 and failure_count > 0:
|
|
1160
|
-
final_message = f"✅ Updated {success_count} packages, ❌ {failure_count} failed"
|
|
1161
|
-
elif success_count > 0:
|
|
1162
|
-
final_message = f"✅ Successfully updated all {success_count} packages!"
|
|
1163
|
-
elif failure_count > 0:
|
|
1164
|
-
final_message = f"❌ Failed to update {failure_count} packages"
|
|
1165
|
-
else:
|
|
1166
|
-
final_message = "⚠️ No packages were updated"
|
|
1167
|
-
|
|
1168
|
-
self._update_status(final_message)
|
|
1169
|
-
self._log_message(f"\n{final_message}")
|
|
1170
|
-
|
|
1171
|
-
# If we had successful updates, clean up invalidation triggers
|
|
1172
|
-
if self.successful_updates:
|
|
1173
|
-
self._log_message("\n🧹 Cleaning up invalidation triggers...")
|
|
1174
|
-
self._cleanup_invalid_triggers()
|
|
1175
|
-
|
|
1176
|
-
self._log_message("\n🎉 Update process complete! Press Enter or Escape to exit.")
|
|
1177
|
-
|
|
1178
|
-
# Update footer to show completion
|
|
1179
|
-
try:
|
|
1180
|
-
footer_widget = self.query_one("#footer", Static)
|
|
1181
|
-
footer_widget.update("Update complete! Press Enter or Escape to exit the application.")
|
|
1182
|
-
except Exception:
|
|
1183
|
-
pass
|
|
1184
|
-
|
|
1185
|
-
def _update_error(self, error_message: str) -> None:
|
|
1186
|
-
"""Handle update process error."""
|
|
1187
|
-
self.update_complete = True
|
|
1188
|
-
self._update_status(f"❌ Update process failed: {error_message}")
|
|
1189
|
-
self._log_message(f"❌ Fatal error: {error_message}")
|
|
1190
|
-
self._log_message("\nPress Escape to exit.")
|
|
1191
|
-
|
|
1192
|
-
# Update footer
|
|
1193
|
-
try:
|
|
1194
|
-
footer_widget = self.query_one("#footer", Static)
|
|
1195
|
-
footer_widget.update("Error occurred! Press Escape to exit the application.")
|
|
1196
|
-
except Exception:
|
|
1197
|
-
pass
|
|
1198
|
-
|
|
1199
|
-
def _cleanup_invalid_triggers(self) -> None:
|
|
1200
|
-
"""Clean up invalidation triggers that are no longer valid after updates."""
|
|
1201
|
-
try:
|
|
1202
|
-
from ..package_constraints import cleanup_invalidated_constraints
|
|
1203
|
-
|
|
1204
|
-
removed_constraints, trigger_details, summary_message = cleanup_invalidated_constraints()
|
|
1205
|
-
|
|
1206
|
-
if summary_message:
|
|
1207
|
-
self._log_message(f"🧹 {summary_message}")
|
|
1208
|
-
|
|
1209
|
-
# Show details of what was cleaned up
|
|
1210
|
-
if trigger_details:
|
|
1211
|
-
for constrained_package, satisfied_triggers in trigger_details.items():
|
|
1212
|
-
triggers_str = ", ".join(satisfied_triggers)
|
|
1213
|
-
self._log_message(f" • Removed constraint for {constrained_package} (triggers: {triggers_str})")
|
|
1214
|
-
else:
|
|
1215
|
-
self._log_message("🧹 No constraint cleanup needed")
|
|
1216
|
-
|
|
1217
|
-
except Exception as e:
|
|
1218
|
-
self._log_message(f"⚠️ Error during constraint cleanup: {e}")
|
|
1219
|
-
|
|
1220
|
-
def action_handle_escape(self) -> None:
|
|
1221
|
-
"""Handle escape key press - cancel updates or exit."""
|
|
1222
|
-
if self.update_complete:
|
|
1223
|
-
# Exit the entire application when updates are complete
|
|
1224
|
-
self.app.exit()
|
|
1225
|
-
else:
|
|
1226
|
-
# Cancel ongoing updates
|
|
1227
|
-
if self.cancel_event:
|
|
1228
|
-
self._log_message("\n🛑 Cancelling updates... please wait for current package to finish")
|
|
1229
|
-
self._update_status("Cancelling updates...")
|
|
1230
|
-
self.cancel_event.set()
|
|
1231
|
-
# Update footer
|
|
1232
|
-
try:
|
|
1233
|
-
footer_widget = self.query_one("#footer", Static)
|
|
1234
|
-
footer_widget.update("Cancelling... Press Escape again after completion to exit.")
|
|
1235
|
-
except Exception:
|
|
1236
|
-
pass
|
|
1237
|
-
|
|
1238
|
-
def action_handle_enter(self) -> None:
|
|
1239
|
-
"""Handle enter key press - exit only when update is complete."""
|
|
1240
|
-
if self.update_complete:
|
|
1241
|
-
# Only exit when updates are done
|
|
1242
|
-
self.app.exit()
|
|
1243
|
-
else:
|
|
1244
|
-
# Ignore Enter while updates are in progress
|
|
1245
|
-
pass
|
|
1246
|
-
|
|
1247
|
-
CSS = """
|
|
1248
|
-
PackageUpdateScreen {
|
|
1249
|
-
align: center middle;
|
|
1250
|
-
layer: overlay;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
#update-dialog {
|
|
1254
|
-
grid-size: 1;
|
|
1255
|
-
grid-rows: 3 2 1fr 2;
|
|
1256
|
-
padding: 1;
|
|
1257
|
-
width: 95%;
|
|
1258
|
-
height: 90%;
|
|
1259
|
-
border: thick $primary;
|
|
1260
|
-
background: $surface;
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
#title {
|
|
1264
|
-
text-align: center;
|
|
1265
|
-
text-style: bold;
|
|
1266
|
-
color: $primary;
|
|
1267
|
-
height: 3;
|
|
1268
|
-
content-align: center middle;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
#status {
|
|
1272
|
-
text-align: center;
|
|
1273
|
-
height: 2;
|
|
1274
|
-
content-align: center middle;
|
|
1275
|
-
color: $text;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
#log-container {
|
|
1279
|
-
border: solid $primary;
|
|
1280
|
-
height: 1fr;
|
|
1281
|
-
margin: 0;
|
|
1282
|
-
padding: 1;
|
|
1283
|
-
background: $background;
|
|
1284
|
-
overflow-y: auto;
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
#progress-log {
|
|
1288
|
-
color: $text;
|
|
1289
|
-
background: $background;
|
|
1290
|
-
height: auto;
|
|
1291
|
-
width: 100%;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
#footer {
|
|
1295
|
-
text-align: center;
|
|
1296
|
-
height: 2;
|
|
1297
|
-
content-align: center middle;
|
|
1298
|
-
color: $text-muted;
|
|
1299
|
-
text-style: italic;
|
|
1300
|
-
}
|
|
1301
|
-
"""
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
class NetworkErrorScreen(ModalScreen[None]):
|
|
1305
|
-
"""Modal screen to display network error and exit."""
|
|
1306
|
-
|
|
1307
|
-
BINDINGS = [
|
|
1308
|
-
("escape", "exit_app", "Exit"),
|
|
1309
|
-
("enter", "exit_app", "Exit"),
|
|
1310
|
-
]
|
|
1311
|
-
|
|
1312
|
-
def __init__(self, error_message: str):
|
|
1313
|
-
super().__init__()
|
|
1314
|
-
self.error_message = error_message
|
|
1315
|
-
|
|
1316
|
-
def compose(self) -> ComposeResult:
|
|
1317
|
-
ok_button = Button(Text("OK", style="bold white"), id="ok", variant="error")
|
|
1318
|
-
|
|
1319
|
-
with Vertical(id="network-error-dialog"):
|
|
1320
|
-
yield Label("Network Error", id="error-title")
|
|
1321
|
-
yield Label(self.error_message, id="error-message")
|
|
1322
|
-
with Horizontal(id="actions"):
|
|
1323
|
-
yield ok_button
|
|
1324
|
-
|
|
1325
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1326
|
-
if event.button.id == "ok":
|
|
1327
|
-
self.app.exit()
|
|
1328
|
-
|
|
1329
|
-
def action_exit_app(self) -> None:
|
|
1330
|
-
"""Exit the application (triggered by Escape or Enter key)."""
|
|
1331
|
-
self.app.exit()
|
|
1332
|
-
|
|
1333
|
-
CSS = """
|
|
1334
|
-
NetworkErrorScreen {
|
|
1335
|
-
align: center middle;
|
|
1336
|
-
layer: overlay;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
#network-error-dialog {
|
|
1340
|
-
padding: 2;
|
|
1341
|
-
width: 70;
|
|
1342
|
-
max-width: 90%;
|
|
1343
|
-
height: auto;
|
|
1344
|
-
min-height: 12;
|
|
1345
|
-
max-height: 80%;
|
|
1346
|
-
border: thick $error;
|
|
1347
|
-
background: $surface;
|
|
1348
|
-
content-align: left top;
|
|
1349
|
-
dock: none;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
#error-title {
|
|
1353
|
-
text-style: bold;
|
|
1354
|
-
text-align: center;
|
|
1355
|
-
padding: 0 0 1 0;
|
|
1356
|
-
color: $error;
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
#error-message {
|
|
1360
|
-
text-align: left;
|
|
1361
|
-
padding: 0 0 1 0;
|
|
1362
|
-
color: $text;
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
#actions {
|
|
1366
|
-
padding: 1 0 0 0;
|
|
1367
|
-
height: 5;
|
|
1368
|
-
align: center middle;
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
#actions Button {
|
|
1372
|
-
margin: 0 1;
|
|
1373
|
-
min-width: 12;
|
|
1374
|
-
}
|
|
1375
|
-
"""
|