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,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 'Constraint 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
- """