pipu-cli 0.1.dev0__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.
@@ -0,0 +1,1340 @@
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
+
993
+ logger.info(f"Starting batch update for {len(self.selected_packages)} packages")
994
+ total_packages = len(self.selected_packages)
995
+
996
+ # Build list of package specs to update, respecting constraints
997
+ package_specs = []
998
+ package_names = []
999
+ for pkg in self.selected_packages:
1000
+ package_names.append(pkg["name"])
1001
+ # Check if package has a constraint that should be applied instead of latest version
1002
+ constraint = pkg.get('constraint')
1003
+ if constraint:
1004
+ # Apply the constraint instead of pinning to latest version
1005
+ spec = f"{pkg['name']}{constraint}"
1006
+ else:
1007
+ # No constraint, use latest version
1008
+ spec = f"{pkg['name']}=={pkg['latest_version']}"
1009
+ package_specs.append(spec)
1010
+
1011
+ self.app.call_from_thread(self._update_status, f"Updating {total_packages} packages...")
1012
+ self.app.call_from_thread(self._log_message, f"{'='*70}")
1013
+ self.app.call_from_thread(self._log_message, f"📦 Updating {total_packages} packages: {', '.join(package_names[:5])}")
1014
+ if len(package_names) > 5:
1015
+ self.app.call_from_thread(self._log_message, f" ... and {len(package_names) - 5} more")
1016
+ self.app.call_from_thread(self._log_message, f"{'='*70}\n")
1017
+
1018
+ # Prepare pip command to install all packages at once with proper version specs
1019
+ pip_cmd = [sys.executable, "-m", "pip", "install"] + package_specs
1020
+
1021
+ # Run pip and capture output with proper cleanup
1022
+ from ..utils import ManagedProcess
1023
+ return_code = None
1024
+
1025
+ try:
1026
+ with ManagedProcess(
1027
+ pip_cmd,
1028
+ stdout=subprocess.PIPE,
1029
+ stderr=subprocess.STDOUT,
1030
+ text=True,
1031
+ bufsize=1,
1032
+ universal_newlines=True
1033
+ ) as process:
1034
+ # Stream output line by line
1035
+ if process.stdout is None:
1036
+ raise RuntimeError("Failed to capture subprocess output")
1037
+ for line in process.stdout:
1038
+ if self.cancel_event.is_set():
1039
+ self.app.call_from_thread(self._log_message, "\n🛑 Update cancelled by user")
1040
+ break
1041
+ # Display each line of pip output
1042
+ self.app.call_from_thread(self._log_message, line.rstrip())
1043
+
1044
+ # Wait for process to complete
1045
+ return_code = process.wait()
1046
+ except Exception as e:
1047
+ logger.error(f"Error during package update: {e}")
1048
+ self.app.call_from_thread(self._log_message, f"\n❌ Error: {e}")
1049
+ return_code = 1
1050
+
1051
+ if return_code == 0:
1052
+ # All packages updated successfully
1053
+ self.successful_updates.extend(package_names)
1054
+ self.app.call_from_thread(self._log_message, f"\n{'='*70}")
1055
+ self.app.call_from_thread(self._log_message, f"✅ Successfully updated all {total_packages} packages!")
1056
+ self.app.call_from_thread(self._log_message, f"{'='*70}")
1057
+ else:
1058
+ # Some packages failed - pip will have shown which ones in output
1059
+ self.failed_updates.extend(package_names)
1060
+ self.app.call_from_thread(self._log_message, f"\n{'='*70}")
1061
+ self.app.call_from_thread(self._log_message, "❌ Update completed with errors (see above)")
1062
+ self.app.call_from_thread(self._log_message, f"{'='*70}")
1063
+
1064
+ # Show final results and cleanup
1065
+ logger.info("Update loop completed, calling _update_complete")
1066
+ self.app.call_from_thread(self._update_complete)
1067
+
1068
+ except Exception as e:
1069
+ logger.error(f"Error in update loop: {e}", exc_info=True)
1070
+ self.app.call_from_thread(self._update_error, str(e))
1071
+
1072
+ # Run the updates in a worker thread
1073
+ logger.info("Starting worker thread for updates...")
1074
+ self.run_worker(run_updates, thread=True, exclusive=False, name="package_updates")
1075
+ logger.info("Worker thread started")
1076
+
1077
+ def _update_status(self, message: str) -> None:
1078
+ """Update the status message."""
1079
+ try:
1080
+ status_widget = self.query_one("#status", Static)
1081
+ status_widget.update(message)
1082
+ except Exception:
1083
+ pass
1084
+
1085
+ def _log_message(self, message: str) -> None:
1086
+ """Add a message to the progress log."""
1087
+ try:
1088
+ log_widget = self.query_one("#progress-log", Static)
1089
+ # Get current content - use render_str() or access the internal content
1090
+ try:
1091
+ current_content = str(log_widget.render())
1092
+ except Exception:
1093
+ # Fallback: keep track of content ourselves
1094
+ if not hasattr(self, '_log_content'):
1095
+ self._log_content = ""
1096
+ current_content = self._log_content
1097
+
1098
+ # Append new message
1099
+ if current_content:
1100
+ new_content = f"{current_content}\n{message}"
1101
+ else:
1102
+ new_content = message
1103
+
1104
+ # Update the widget
1105
+ log_widget.update(new_content)
1106
+
1107
+ # Save content for next time
1108
+ self._log_content = new_content
1109
+
1110
+ # Auto-scroll to bottom
1111
+ log_container = self.query_one("#log-container", ScrollableContainer)
1112
+ log_container.scroll_end()
1113
+ except Exception as e:
1114
+ logger.error(f"Error updating log: {e}", exc_info=True)
1115
+
1116
+ def _update_complete(self) -> None:
1117
+ """Handle completion of the update process."""
1118
+ self.update_complete = True
1119
+
1120
+ # Show final results
1121
+ success_count = len(self.successful_updates)
1122
+ failure_count = len(self.failed_updates)
1123
+
1124
+ if success_count > 0 and failure_count > 0:
1125
+ final_message = f"✅ Updated {success_count} packages, ❌ {failure_count} failed"
1126
+ elif success_count > 0:
1127
+ final_message = f"✅ Successfully updated all {success_count} packages!"
1128
+ elif failure_count > 0:
1129
+ final_message = f"❌ Failed to update {failure_count} packages"
1130
+ else:
1131
+ final_message = "⚠️ No packages were updated"
1132
+
1133
+ self._update_status(final_message)
1134
+ self._log_message(f"\n{final_message}")
1135
+
1136
+ # If we had successful updates, clean up invalidation triggers
1137
+ if self.successful_updates:
1138
+ self._log_message("\n🧹 Cleaning up invalidation triggers...")
1139
+ self._cleanup_invalid_triggers()
1140
+
1141
+ self._log_message("\n🎉 Update process complete! Press Enter or Escape to exit.")
1142
+
1143
+ # Update footer to show completion
1144
+ try:
1145
+ footer_widget = self.query_one("#footer", Static)
1146
+ footer_widget.update("Update complete! Press Enter or Escape to exit the application.")
1147
+ except Exception:
1148
+ pass
1149
+
1150
+ def _update_error(self, error_message: str) -> None:
1151
+ """Handle update process error."""
1152
+ self.update_complete = True
1153
+ self._update_status(f"❌ Update process failed: {error_message}")
1154
+ self._log_message(f"❌ Fatal error: {error_message}")
1155
+ self._log_message("\nPress Escape to exit.")
1156
+
1157
+ # Update footer
1158
+ try:
1159
+ footer_widget = self.query_one("#footer", Static)
1160
+ footer_widget.update("Error occurred! Press Escape to exit the application.")
1161
+ except Exception:
1162
+ pass
1163
+
1164
+ def _cleanup_invalid_triggers(self) -> None:
1165
+ """Clean up invalidation triggers that are no longer valid after updates."""
1166
+ try:
1167
+ from ..package_constraints import cleanup_invalidated_constraints
1168
+
1169
+ removed_constraints, trigger_details, summary_message = cleanup_invalidated_constraints()
1170
+
1171
+ if summary_message:
1172
+ self._log_message(f"🧹 {summary_message}")
1173
+
1174
+ # Show details of what was cleaned up
1175
+ if trigger_details:
1176
+ for constrained_package, satisfied_triggers in trigger_details.items():
1177
+ triggers_str = ", ".join(satisfied_triggers)
1178
+ self._log_message(f" • Removed constraint for {constrained_package} (triggers: {triggers_str})")
1179
+ else:
1180
+ self._log_message("🧹 No constraint cleanup needed")
1181
+
1182
+ except Exception as e:
1183
+ self._log_message(f"⚠️ Error during constraint cleanup: {e}")
1184
+
1185
+ def action_handle_escape(self) -> None:
1186
+ """Handle escape key press - cancel updates or exit."""
1187
+ if self.update_complete:
1188
+ # Exit the entire application when updates are complete
1189
+ self.app.exit()
1190
+ else:
1191
+ # Cancel ongoing updates
1192
+ if self.cancel_event:
1193
+ self._log_message("\n🛑 Cancelling updates... please wait for current package to finish")
1194
+ self._update_status("Cancelling updates...")
1195
+ self.cancel_event.set()
1196
+ # Update footer
1197
+ try:
1198
+ footer_widget = self.query_one("#footer", Static)
1199
+ footer_widget.update("Cancelling... Press Escape again after completion to exit.")
1200
+ except Exception:
1201
+ pass
1202
+
1203
+ def action_handle_enter(self) -> None:
1204
+ """Handle enter key press - exit only when update is complete."""
1205
+ if self.update_complete:
1206
+ # Only exit when updates are done
1207
+ self.app.exit()
1208
+ else:
1209
+ # Ignore Enter while updates are in progress
1210
+ pass
1211
+
1212
+ CSS = """
1213
+ PackageUpdateScreen {
1214
+ align: center middle;
1215
+ layer: overlay;
1216
+ }
1217
+
1218
+ #update-dialog {
1219
+ grid-size: 1;
1220
+ grid-rows: 3 2 1fr 2;
1221
+ padding: 1;
1222
+ width: 95%;
1223
+ height: 90%;
1224
+ border: thick $primary;
1225
+ background: $surface;
1226
+ }
1227
+
1228
+ #title {
1229
+ text-align: center;
1230
+ text-style: bold;
1231
+ color: $primary;
1232
+ height: 3;
1233
+ content-align: center middle;
1234
+ }
1235
+
1236
+ #status {
1237
+ text-align: center;
1238
+ height: 2;
1239
+ content-align: center middle;
1240
+ color: $text;
1241
+ }
1242
+
1243
+ #log-container {
1244
+ border: solid $primary;
1245
+ height: 1fr;
1246
+ margin: 0;
1247
+ padding: 1;
1248
+ background: $background;
1249
+ overflow-y: auto;
1250
+ }
1251
+
1252
+ #progress-log {
1253
+ color: $text;
1254
+ background: $background;
1255
+ height: auto;
1256
+ width: 100%;
1257
+ }
1258
+
1259
+ #footer {
1260
+ text-align: center;
1261
+ height: 2;
1262
+ content-align: center middle;
1263
+ color: $text-muted;
1264
+ text-style: italic;
1265
+ }
1266
+ """
1267
+
1268
+
1269
+ class NetworkErrorScreen(ModalScreen[None]):
1270
+ """Modal screen to display network error and exit."""
1271
+
1272
+ BINDINGS = [
1273
+ ("escape", "exit_app", "Exit"),
1274
+ ("enter", "exit_app", "Exit"),
1275
+ ]
1276
+
1277
+ def __init__(self, error_message: str):
1278
+ super().__init__()
1279
+ self.error_message = error_message
1280
+
1281
+ def compose(self) -> ComposeResult:
1282
+ ok_button = Button(Text("OK", style="bold white"), id="ok", variant="error")
1283
+
1284
+ with Vertical(id="network-error-dialog"):
1285
+ yield Label("Network Error", id="error-title")
1286
+ yield Label(self.error_message, id="error-message")
1287
+ with Horizontal(id="actions"):
1288
+ yield ok_button
1289
+
1290
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1291
+ if event.button.id == "ok":
1292
+ self.app.exit()
1293
+
1294
+ def action_exit_app(self) -> None:
1295
+ """Exit the application (triggered by Escape or Enter key)."""
1296
+ self.app.exit()
1297
+
1298
+ CSS = """
1299
+ NetworkErrorScreen {
1300
+ align: center middle;
1301
+ layer: overlay;
1302
+ }
1303
+
1304
+ #network-error-dialog {
1305
+ padding: 2;
1306
+ width: 70;
1307
+ max-width: 90%;
1308
+ height: auto;
1309
+ min-height: 12;
1310
+ max-height: 80%;
1311
+ border: thick $error;
1312
+ background: $surface;
1313
+ content-align: left top;
1314
+ dock: none;
1315
+ }
1316
+
1317
+ #error-title {
1318
+ text-style: bold;
1319
+ text-align: center;
1320
+ padding: 0 0 1 0;
1321
+ color: $error;
1322
+ }
1323
+
1324
+ #error-message {
1325
+ text-align: left;
1326
+ padding: 0 0 1 0;
1327
+ color: $text;
1328
+ }
1329
+
1330
+ #actions {
1331
+ padding: 1 0 0 0;
1332
+ height: 5;
1333
+ align: center middle;
1334
+ }
1335
+
1336
+ #actions Button {
1337
+ margin: 0 1;
1338
+ min-width: 12;
1339
+ }
1340
+ """