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.
pipu_cli/cli.py ADDED
@@ -0,0 +1,861 @@
1
+ import sys
2
+ from typing import List
3
+ import rich_click as click
4
+ from .internals import list_outdated
5
+ from .package_constraints import read_constraints, read_ignores, read_invalidation_triggers
6
+ from .common import console
7
+ from pip._internal.commands.install import InstallCommand
8
+
9
+
10
+ def _install_packages(package_specs: List[str]) -> int:
11
+ """
12
+ Install packages using pip API.
13
+
14
+ :param package_specs: List of package specifications to install
15
+ :returns: Exit code (0 for success, non-zero for failure)
16
+ """
17
+ install_cmd = InstallCommand("install", "Install packages")
18
+ install_args = ["--upgrade"] + package_specs
19
+ return install_cmd.main(install_args)
20
+
21
+
22
+ def launch_tui() -> None:
23
+ """
24
+ Launch the main TUI interface.
25
+ """
26
+ try:
27
+ # Check for invalid constraints and triggers from removed/renamed packages
28
+ from .package_constraints import cleanup_invalid_constraints_and_triggers
29
+ _, _, cleanup_summary = cleanup_invalid_constraints_and_triggers()
30
+ if cleanup_summary:
31
+ console.print(f"[yellow]🧹 {cleanup_summary}[/yellow]")
32
+ console.print("[dim]Press any key to continue...[/dim]")
33
+ input() # Wait for user acknowledgment before launching TUI
34
+
35
+ from .ui import main_tui_app
36
+ main_tui_app()
37
+ except Exception as e:
38
+ console.print(f"[red]Error launching TUI: {e}[/red]")
39
+ sys.exit(1)
40
+
41
+
42
+
43
+
44
+ @click.group(invoke_without_command=True)
45
+ @click.pass_context
46
+ def cli(ctx):
47
+ """
48
+ pipu - Python package updater with constraint management
49
+
50
+ If no command is specified, launches the interactive TUI.
51
+ """
52
+ if ctx.invoked_subcommand is None:
53
+ # No subcommand was invoked, launch the TUI
54
+ launch_tui()
55
+
56
+
57
+ @cli.command(name='list')
58
+ @click.option('--pre', is_flag=True, help='Include pre-release versions (alpha, beta, rc, dev)')
59
+ @click.option('--debug', is_flag=True, help='Print debug information as packages are checked')
60
+ def list_packages(pre, debug):
61
+ """
62
+ List outdated packages
63
+
64
+ Displays a formatted table of installed packages that have newer versions
65
+ available on the configured package indexes. By default, pre-release versions
66
+ are excluded unless --pre is specified.
67
+ """
68
+ try:
69
+ # Check for invalid constraints and triggers from removed/renamed packages
70
+ from .package_constraints import cleanup_invalid_constraints_and_triggers
71
+ _, _, cleanup_summary = cleanup_invalid_constraints_and_triggers()
72
+ if cleanup_summary:
73
+ console.print(f"[yellow]🧹 {cleanup_summary}[/yellow]")
74
+
75
+ # Read constraints, ignores, and invalidation triggers from configuration
76
+ constraints = read_constraints()
77
+ ignores = read_ignores()
78
+ invalidation_triggers = read_invalidation_triggers()
79
+
80
+ # Set up debug callbacks if debug mode is enabled
81
+ progress_callback = None
82
+ result_callback = None
83
+ if debug:
84
+ def debug_progress(package_name):
85
+ console.print(f"[dim]DEBUG: Checking {package_name}...[/dim]")
86
+
87
+ def debug_callback(package_result):
88
+ pkg_name = package_result.get('name', 'unknown')
89
+ current_ver = package_result.get('version', 'unknown')
90
+ latest_ver = package_result.get('latest_version', 'unknown')
91
+ if current_ver != latest_ver:
92
+ console.print(f"[dim]DEBUG: {pkg_name}: {current_ver} -> {latest_ver}[/dim]")
93
+ else:
94
+ console.print(f"[dim]DEBUG: {pkg_name}: {current_ver} (up-to-date)[/dim]")
95
+
96
+ progress_callback = debug_progress
97
+ result_callback = debug_callback
98
+
99
+ # Use the internals function to get outdated packages and print the table
100
+ outdated_packages = list_outdated(
101
+ console=console,
102
+ print_table=True,
103
+ constraints=constraints,
104
+ ignores=ignores,
105
+ pre=pre,
106
+ progress_callback=progress_callback,
107
+ result_callback=result_callback,
108
+ invalidation_triggers=invalidation_triggers
109
+ )
110
+
111
+ # The function already prints the table, so we just return the data
112
+ return outdated_packages
113
+
114
+ except Exception as e:
115
+ console.print(f"[red]Unexpected error: {e}[/red]")
116
+ sys.exit(1)
117
+
118
+
119
+ @cli.command()
120
+ @click.option('--pre', is_flag=True, help='Include pre-release versions (alpha, beta, rc, dev)')
121
+ @click.option('-y', '--yes', is_flag=True, help='Skip confirmation prompt and install all updates')
122
+ def update(pre, yes):
123
+ """
124
+ Update outdated packages
125
+
126
+ Lists all outdated packages and prompts for confirmation before installing
127
+ updates. Respects version constraints from configuration files.
128
+
129
+ For interactive package selection, run 'pipu' with no arguments to launch the TUI.
130
+ """
131
+ try:
132
+ # Check for invalid constraints and triggers from removed/renamed packages
133
+ from .package_constraints import cleanup_invalid_constraints_and_triggers
134
+ _, _, cleanup_summary = cleanup_invalid_constraints_and_triggers()
135
+ if cleanup_summary:
136
+ console.print(f"[yellow]🧹 {cleanup_summary}[/yellow]")
137
+
138
+ # Read constraints, ignores, and invalidation triggers from configuration
139
+ constraints = read_constraints()
140
+ ignores = read_ignores()
141
+ invalidation_triggers = read_invalidation_triggers()
142
+
143
+ # Get outdated packages
144
+ outdated_packages = list_outdated(
145
+ console=console, print_table=True, constraints=constraints, ignores=ignores, pre=pre, invalidation_triggers=invalidation_triggers
146
+ )
147
+
148
+ if not outdated_packages:
149
+ console.print("[green]All packages are already up to date![/green]")
150
+ return
151
+
152
+ # Determine which packages to update
153
+ packages_to_update = outdated_packages
154
+
155
+ if not yes:
156
+ # Standard confirmation mode
157
+ console.print()
158
+ response = click.confirm("Do you want to update these packages?", default=False)
159
+ if not response:
160
+ console.print("[yellow]Update cancelled.[/yellow]")
161
+ return
162
+
163
+ # Validate constraint compatibility before installation
164
+ console.print()
165
+ console.print("[bold blue]Validating constraint compatibility...[/bold blue]")
166
+
167
+ from .package_constraints import validate_package_installation, get_constraint_violation_summary
168
+
169
+ # Extract package names for validation
170
+ package_names_to_install = [pkg['name'] for pkg in packages_to_update]
171
+
172
+ # Check for constraint violations
173
+ safe_packages, invalidated_constraints = validate_package_installation(package_names_to_install)
174
+
175
+ if invalidated_constraints:
176
+ # Show constraint violations
177
+ console.print("[bold red]âš  Constraint Violations Detected![/bold red]")
178
+ console.print(get_constraint_violation_summary(invalidated_constraints))
179
+
180
+ # Filter out packages that would violate constraints
181
+ violating_package_names = set()
182
+ for violators in invalidated_constraints.values():
183
+ violating_package_names.update(pkg.lower() for pkg in violators)
184
+
185
+ # Keep only safe packages
186
+ original_count = len(packages_to_update)
187
+ packages_to_update = [
188
+ pkg for pkg in packages_to_update
189
+ if pkg['name'].lower() not in violating_package_names
190
+ ]
191
+
192
+ blocked_count = original_count - len(packages_to_update)
193
+
194
+ if packages_to_update:
195
+ console.print(f"\n[yellow]Proceeding with {len(packages_to_update)} packages that don't violate constraints.")
196
+ console.print(f"Blocked {blocked_count} packages due to constraint violations.[/yellow]")
197
+ else:
198
+ console.print(f"\n[red]All {blocked_count} packages would violate constraints. No packages will be installed.[/red]")
199
+ return
200
+
201
+ # Install updates
202
+ console.print()
203
+ console.print("[bold green]Installing updates...[/bold green]")
204
+
205
+ # Create list of package specs to install
206
+ package_specs = []
207
+ for package in packages_to_update:
208
+ # Check if package has a constraint that should be applied instead of latest version
209
+ constraint = package.get('constraint')
210
+ if constraint:
211
+ # Apply the constraint instead of pinning to latest version
212
+ spec = f"{package['name']}{constraint}"
213
+ else:
214
+ # No constraint, use latest version
215
+ spec = f"{package['name']}=={package['latest_version']}"
216
+ package_specs.append(spec)
217
+
218
+ # Install packages using pip API
219
+ exit_code = _install_packages(package_specs)
220
+
221
+ if exit_code == 0:
222
+ console.print("[bold green]✓ All packages updated successfully![/bold green]")
223
+
224
+ # Clean up constraints whose invalidation triggers have been satisfied
225
+ from .package_constraints import post_install_cleanup
226
+ post_install_cleanup(console)
227
+
228
+ else:
229
+ console.print("[bold red]✗ Some packages failed to update.[/bold red]")
230
+ sys.exit(exit_code)
231
+
232
+ except Exception as e:
233
+ console.print(f"[red]Unexpected error: {e}[/red]")
234
+ sys.exit(1)
235
+
236
+
237
+ @cli.command()
238
+ @click.argument('constraint_specs', nargs=-1, required=False)
239
+ @click.option('--env', help='Target environment section (defaults to current environment or global)')
240
+ @click.option('--list', 'list_constraints', is_flag=True, help='List existing constraints for specified environment (or all environments if no --env specified)')
241
+ @click.option('--remove', 'remove_constraints', is_flag=True, help='Remove constraints for specified packages')
242
+ @click.option('--remove-all', 'remove_all_constraints', is_flag=True, help='Remove all constraints from specified environment (or all environments if no --env specified)')
243
+ @click.option('--yes', '-y', 'skip_confirmation', is_flag=True, help='Skip confirmation prompt')
244
+ @click.option('--invalidates-when', 'invalidation_triggers', multiple=True, help='Specify trigger conditions that invalidate this constraint (format: "package>=version" or "package>version"). Only ">=" and ">" operators allowed.')
245
+ def constrain(constraint_specs, env, list_constraints, remove_constraints, remove_all_constraints, skip_confirmation, invalidation_triggers):
246
+ """
247
+ Add or update package constraints in pip configuration
248
+
249
+ Sets version constraints for packages in the pip configuration file.
250
+ Constraints prevent packages from being updated beyond specified versions.
251
+
252
+ Note: Automatic constraints from installed packages are now discovered
253
+ on every pipu execution and do not need to be manually added. The constraints
254
+ you add here will override any automatic constraints.
255
+
256
+ \b
257
+ Examples:
258
+ pipu constrain "requests==2.31.0"
259
+ pipu constrain "numpy>=1.20.0" "pandas<2.0.0"
260
+ pipu constrain "django~=4.1.0" --env production
261
+ pipu constrain "flask<2" --invalidates-when "other_package>=1" --invalidates-when "another_package>1.5"
262
+ pipu constrain --list
263
+ pipu constrain --list --env production
264
+ pipu constrain --remove requests numpy
265
+ pipu constrain --remove django --env production
266
+ pipu constrain --remove-all --env production
267
+ pipu constrain --remove-all --yes
268
+
269
+ \f
270
+ :param constraint_specs: One or more constraint specifications or package names (for --remove)
271
+ :param env: Target environment section name
272
+ :param list_constraints: List existing constraints instead of adding new ones
273
+ :param remove_constraints: Remove constraints for specified packages
274
+ :param remove_all_constraints: Remove all constraints from environment(s)
275
+ :param skip_confirmation: Skip confirmation prompt for --remove-all
276
+ :raises SystemExit: Exits with code 1 if an error occurs
277
+ """
278
+ try:
279
+ # Validate mutually exclusive options
280
+ # Note: constraint_specs with --remove are package names, not constraint specs
281
+ has_constraint_specs_for_adding = bool(constraint_specs) and not (remove_constraints or remove_all_constraints)
282
+ active_options = [list_constraints, remove_constraints, remove_all_constraints, has_constraint_specs_for_adding]
283
+ if sum(active_options) > 1:
284
+ console.print("[red]Error: Cannot use --list, --remove, --remove-all, and constraint specs together. Use only one at a time.[/red]")
285
+ sys.exit(1)
286
+
287
+ # Validate --invalidates-when can only be used when adding constraints
288
+ if invalidation_triggers and (list_constraints or remove_constraints or remove_all_constraints):
289
+ console.print("[red]Error: --invalidates-when cannot be used with --list, --remove, or --remove-all.[/red]")
290
+ sys.exit(1)
291
+
292
+ if invalidation_triggers and not has_constraint_specs_for_adding:
293
+ console.print("[red]Error: --invalidates-when can only be used when adding constraint specifications.[/red]")
294
+ sys.exit(1)
295
+
296
+ # Handle --list option
297
+ if list_constraints:
298
+ from .package_constraints import list_all_constraints
299
+
300
+ console.print("[bold blue]Listing constraints from pip configuration...[/bold blue]")
301
+
302
+ all_constraints = list_all_constraints(env)
303
+
304
+ if not all_constraints:
305
+ if env:
306
+ console.print(f"[yellow]No constraints found for environment '[bold]{env}[/bold]'.[/yellow]")
307
+ else:
308
+ console.print("[yellow]No constraints found in any environment.[/yellow]")
309
+ return
310
+
311
+ # Display constraints
312
+ for env_name, constraints in all_constraints.items():
313
+ console.print(f"\n[bold cyan]Environment: {env_name}[/bold cyan]")
314
+ if constraints:
315
+ for package, constraint_spec in sorted(constraints.items()):
316
+ console.print(f" {package}{constraint_spec}")
317
+ else:
318
+ console.print(" [dim]No constraints[/dim]")
319
+
320
+ return
321
+
322
+ # Handle --remove option
323
+ if remove_constraints:
324
+ if not constraint_specs:
325
+ console.print("[red]Error: At least one package name must be specified for removal.[/red]")
326
+ sys.exit(1)
327
+
328
+ from .package_constraints import remove_constraints_from_config, parse_invalidation_triggers_storage, get_current_environment_name, parse_inline_constraints
329
+
330
+ package_names = list(constraint_specs)
331
+ console.print(f"[bold blue]Removing constraints for {len(package_names)} package(s)...[/bold blue]")
332
+
333
+ try:
334
+ config_path, removed_constraints, removed_triggers = remove_constraints_from_config(package_names, env)
335
+
336
+ if not removed_constraints:
337
+ console.print("[yellow]No constraints were removed (packages not found in constraints).[/yellow]")
338
+ return
339
+
340
+ # Triggers are already cleaned up by remove_constraints_from_config
341
+
342
+ # Display summary
343
+ console.print("\n[bold green]✓ Constraints removed successfully![/bold green]")
344
+ console.print(f"[bold]File:[/bold] {config_path}")
345
+
346
+ # Show removed constraints
347
+ console.print("\n[bold]Constraints removed:[/bold]")
348
+ for package, constraint_spec in removed_constraints.items():
349
+ console.print(f" [red]Removed[/red]: {package}{constraint_spec}")
350
+
351
+ # Show removed invalidation triggers if any
352
+ if removed_triggers:
353
+ console.print("\n[bold]Invalidation triggers removed:[/bold]")
354
+ for package, triggers in removed_triggers.items():
355
+ for trigger in triggers:
356
+ console.print(f" [red]Removed trigger[/red]: {trigger}")
357
+
358
+ # Show which environment was updated
359
+ if env:
360
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
361
+ else:
362
+ from .package_constraints import get_current_environment_name
363
+ current_env = get_current_environment_name()
364
+ if current_env and current_env != "global":
365
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
366
+ else:
367
+ console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
368
+
369
+ return
370
+
371
+ except ValueError as e:
372
+ console.print(f"[red]Error: {e}[/red]")
373
+ sys.exit(1)
374
+ except IOError as e:
375
+ console.print(f"[red]Error writing configuration: {e}[/red]")
376
+ sys.exit(1)
377
+
378
+ # Handle --remove-all option
379
+ if remove_all_constraints:
380
+ from .package_constraints import remove_all_constraints_from_config, list_all_constraints, parse_invalidation_triggers_storage
381
+
382
+ # Get confirmation if not using --yes and removing from all environments
383
+ if not env and not skip_confirmation:
384
+ # Show what will be removed
385
+ try:
386
+ all_constraints = list_all_constraints()
387
+ if not all_constraints:
388
+ console.print("[yellow]No constraints found in any environment.[/yellow]")
389
+ return
390
+
391
+ console.print("[bold red]WARNING: This will remove ALL constraints from ALL environments![/bold red]")
392
+ console.print("\n[bold]Constraints that will be removed:[/bold]")
393
+
394
+ total_constraints = 0
395
+ for env_name, constraints in all_constraints.items():
396
+ console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
397
+ for package, constraint_spec in sorted(constraints.items()):
398
+ console.print(f" {package}{constraint_spec}")
399
+ total_constraints += 1
400
+
401
+ console.print(f"\n[bold]Total: {total_constraints} constraint(s) in {len(all_constraints)} environment(s)[/bold]")
402
+
403
+ if not click.confirm("\nAre you sure you want to remove all constraints?"):
404
+ console.print("[yellow]Operation cancelled.[/yellow]")
405
+ return
406
+
407
+ except Exception:
408
+ # If we can't list constraints, ask for generic confirmation
409
+ if not click.confirm("Are you sure you want to remove all constraints from all environments?"):
410
+ console.print("[yellow]Operation cancelled.[/yellow]")
411
+ return
412
+
413
+ console.print(f"[bold blue]Removing all constraints from {'all environments' if not env else env}...[/bold blue]")
414
+
415
+ try:
416
+ config_path, removed_constraints, removed_triggers_by_env = remove_all_constraints_from_config(env)
417
+
418
+ if not removed_constraints:
419
+ if env:
420
+ console.print(f"[yellow]No constraints found in environment '{env}'.[/yellow]")
421
+ else:
422
+ console.print("[yellow]No constraints found in any environment.[/yellow]")
423
+ return
424
+
425
+ # Triggers are already cleaned up by remove_all_constraints_from_config
426
+
427
+ # Display summary
428
+ console.print("\n[bold green]✓ All constraints removed successfully![/bold green]")
429
+ console.print(f"[bold]File:[/bold] {config_path}")
430
+
431
+ # Show removed constraints
432
+ console.print("\n[bold]Constraints removed:[/bold]")
433
+ total_removed = 0
434
+ for env_name, constraints in removed_constraints.items():
435
+ console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
436
+ for package, constraint_spec in sorted(constraints.items()):
437
+ console.print(f" [red]Removed[/red]: {package}{constraint_spec}")
438
+ total_removed += 1
439
+
440
+ console.print(f"\n[bold]Total removed: {total_removed} constraint(s) from {len(removed_constraints)} environment(s)[/bold]")
441
+
442
+ # Show removed invalidation triggers if any
443
+ if removed_triggers_by_env:
444
+ console.print("\n[bold]Invalidation triggers removed:[/bold]")
445
+ total_triggers_removed = 0
446
+ for env_name, env_triggers in removed_triggers_by_env.items():
447
+ console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
448
+ for package, triggers in env_triggers.items():
449
+ for trigger in triggers:
450
+ console.print(f" [red]Removed trigger[/red]: {trigger}")
451
+ total_triggers_removed += 1
452
+ console.print(f"\n[bold]Total triggers removed: {total_triggers_removed}[/bold]")
453
+
454
+ # Show which environment(s) were updated
455
+ if env:
456
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
457
+ else:
458
+ environments_updated = list(removed_constraints.keys())
459
+ console.print(f"\n[bold cyan]Environments updated:[/bold cyan] {', '.join(sorted(environments_updated))}")
460
+
461
+ return
462
+
463
+ except ValueError as e:
464
+ console.print(f"[red]Error: {e}[/red]")
465
+ sys.exit(1)
466
+ except IOError as e:
467
+ console.print(f"[red]Error writing configuration: {e}[/red]")
468
+ sys.exit(1)
469
+
470
+ constraint_list = list(constraint_specs)
471
+
472
+ if not constraint_list:
473
+ console.print("[red]Error: At least one constraint must be specified.[/red]")
474
+ sys.exit(1)
475
+
476
+ console.print("[bold blue]Adding constraints to pip configuration...[/bold blue]")
477
+
478
+ # Use backend function for constraint addition
479
+ from .package_constraints import (
480
+ add_constraints_to_config,
481
+ validate_invalidation_triggers,
482
+ get_recommended_pip_config_path,
483
+ get_current_environment_name,
484
+ parse_invalidation_triggers_storage,
485
+ merge_invalidation_triggers,
486
+ format_invalidation_triggers,
487
+ parse_inline_constraints,
488
+ parse_requirement_line
489
+ )
490
+ import configparser
491
+
492
+ # Get the recommended config file path early for error handling
493
+ config_path = get_recommended_pip_config_path()
494
+
495
+ try:
496
+ # Validate invalidation triggers if provided
497
+ validated_triggers = []
498
+ if invalidation_triggers:
499
+ validated_triggers = validate_invalidation_triggers(list(invalidation_triggers))
500
+
501
+ # Add constraints using the backend function
502
+ config_path, changes = add_constraints_to_config(constraint_list, env)
503
+
504
+ # Handle invalidation triggers if provided
505
+ if validated_triggers:
506
+ # Load the config and add triggers for the constrained packages
507
+ config = configparser.ConfigParser()
508
+ config.read(config_path)
509
+
510
+ # Determine target environment section
511
+ if env is None:
512
+ env = get_current_environment_name()
513
+ section_name = env if env else 'global'
514
+
515
+ # Get existing invalidation triggers
516
+ existing_triggers_storage = {}
517
+ if config.has_option(section_name, 'constraint_invalid_when'):
518
+ existing_value = config.get(section_name, 'constraint_invalid_when')
519
+ existing_triggers_storage = parse_invalidation_triggers_storage(existing_value)
520
+
521
+ # Get current constraints to find the packages that were added/updated
522
+ current_constraints = {}
523
+ if config.has_option(section_name, 'constraints'):
524
+ constraints_value = config.get(section_name, 'constraints')
525
+ if any(op in constraints_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
526
+ current_constraints = parse_inline_constraints(constraints_value)
527
+
528
+ # Process triggers for each package that was specified (whether changed or not)
529
+ updated_triggers_storage = existing_triggers_storage.copy()
530
+
531
+ # Get all package names from the constraint list
532
+ all_package_names = set()
533
+ for spec in constraint_list:
534
+ parsed = parse_requirement_line(spec)
535
+ if parsed:
536
+ all_package_names.add(parsed['name'].lower())
537
+
538
+ for package_name in all_package_names:
539
+ # Get existing triggers for this package
540
+ existing_package_triggers = existing_triggers_storage.get(package_name, [])
541
+
542
+ # Merge with new triggers
543
+ merged_triggers = merge_invalidation_triggers(existing_package_triggers, validated_triggers)
544
+
545
+ if merged_triggers:
546
+ updated_triggers_storage[package_name] = merged_triggers
547
+
548
+ # Format and store the triggers
549
+ if updated_triggers_storage:
550
+ trigger_entries = []
551
+ for package_name, triggers in updated_triggers_storage.items():
552
+ # Get the constraint for this package to format properly
553
+ if package_name in current_constraints:
554
+ package_constraint = current_constraints[package_name]
555
+ formatted_entry = format_invalidation_triggers(f"{package_name}{package_constraint}", triggers)
556
+ if formatted_entry:
557
+ trigger_entries.append(formatted_entry)
558
+
559
+ if trigger_entries:
560
+ triggers_value = ','.join(trigger_entries)
561
+ config.set(section_name, 'constraint_invalid_when', triggers_value)
562
+
563
+ # Write the updated config file
564
+ with open(config_path, 'w', encoding='utf-8') as f:
565
+ config.write(f)
566
+
567
+ except Exception as e:
568
+ if isinstance(e, ValueError):
569
+ raise e
570
+ else:
571
+ raise IOError(f"Failed to write pip config file '{config_path}': {e}")
572
+
573
+ if not changes and not validated_triggers:
574
+ console.print("[yellow]No changes made - all constraints already exist with the same values.[/yellow]")
575
+ return
576
+
577
+ # Display summary
578
+ console.print("\n[bold green]✓ Configuration updated successfully![/bold green]")
579
+ console.print(f"[bold]File:[/bold] {config_path}")
580
+
581
+ # Show changes
582
+ if changes:
583
+ console.print("\n[bold]Constraints modified:[/bold]")
584
+ for package, (action, constraint) in changes.items():
585
+ action_color = "green" if action == "added" else "yellow"
586
+ console.print(f" [{action_color}]{action.title()}[/{action_color}]: {package}{constraint}")
587
+
588
+ # Show invalidation triggers if added
589
+ if validated_triggers:
590
+ console.print("\n[bold]Invalidation triggers added:[/bold]")
591
+ for trigger in validated_triggers:
592
+ console.print(f" [cyan]Trigger[/cyan]: {trigger}")
593
+
594
+ # Show which environment was updated
595
+ if env:
596
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
597
+ else:
598
+ current_env = get_current_environment_name()
599
+ if current_env and current_env != "global":
600
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
601
+ else:
602
+ console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
603
+
604
+ except ValueError as e:
605
+ console.print(f"[red]Error: {e}[/red]")
606
+ sys.exit(1)
607
+ except IOError as e:
608
+ console.print(f"[red]Error writing configuration: {e}[/red]")
609
+ sys.exit(1)
610
+ except Exception as e:
611
+ console.print(f"[red]Unexpected error: {e}[/red]")
612
+ sys.exit(1)
613
+
614
+
615
+ @cli.command()
616
+ @click.argument('package_names', nargs=-1, required=False)
617
+ @click.option('--env', help='Target environment section (defaults to current environment or global)')
618
+ @click.option('--list', 'list_ignores', is_flag=True, help='List existing ignores for specified environment (or all environments if no --env specified)')
619
+ @click.option('--remove', 'remove_ignores', is_flag=True, help='Remove ignores for specified packages')
620
+ @click.option('--remove-all', 'remove_all_ignores', is_flag=True, help='Remove all ignores from specified environment (or all environments if no --env specified)')
621
+ @click.option('--yes', '-y', 'skip_confirmation', is_flag=True, help='Skip confirmation prompt')
622
+ def ignore(package_names, env, list_ignores, remove_ignores, remove_all_ignores, skip_confirmation):
623
+ """
624
+ Add or remove package ignores in pip configuration
625
+
626
+ Manages packages that should be ignored during update operations.
627
+ Ignored packages will be skipped when checking for outdated packages.
628
+
629
+ \b
630
+ Examples:
631
+ pipu ignore requests numpy
632
+ pipu ignore flask --env production
633
+ pipu ignore --list
634
+ pipu ignore --list --env production
635
+ pipu ignore --remove requests numpy
636
+ pipu ignore --remove flask --env production
637
+ pipu ignore --remove-all --env production
638
+ pipu ignore --remove-all --yes
639
+
640
+ \f
641
+ :param package_names: One or more package names to ignore or remove
642
+ :param env: Target environment section name
643
+ :param list_ignores: List existing ignores instead of adding new ones
644
+ :param remove_ignores: Remove ignores for specified packages
645
+ :param remove_all_ignores: Remove all ignores from environment(s)
646
+ :param skip_confirmation: Skip confirmation prompt for --remove-all
647
+ :raises SystemExit: Exits with code 1 if an error occurs
648
+ """
649
+ try:
650
+ # Validate mutually exclusive options
651
+ active_options = [list_ignores, remove_ignores, remove_all_ignores, bool(package_names and not (list_ignores or remove_ignores or remove_all_ignores))]
652
+ if sum(active_options) > 1:
653
+ console.print("[red]Error: Cannot use --list, --remove, --remove-all, and package names together. Use only one at a time.[/red]")
654
+ sys.exit(1)
655
+
656
+ # Handle --list option
657
+ if list_ignores:
658
+ from .package_constraints import list_all_ignores
659
+
660
+ console.print("[bold blue]Listing ignores from pip configuration...[/bold blue]")
661
+
662
+ all_ignores = list_all_ignores(env)
663
+
664
+ if not all_ignores:
665
+ if env:
666
+ console.print(f"[yellow]No ignores found for environment '[bold]{env}[/bold]'.[/yellow]")
667
+ else:
668
+ console.print("[yellow]No ignores found in any environment.[/yellow]")
669
+ return
670
+
671
+ # Display ignores
672
+ for env_name, ignores in all_ignores.items():
673
+ console.print(f"\n[bold cyan]Environment: {env_name}[/bold cyan]")
674
+ if ignores:
675
+ for package in sorted(ignores):
676
+ console.print(f" {package}")
677
+ else:
678
+ console.print(" [dim]No ignores[/dim]")
679
+
680
+ return
681
+
682
+ # Handle --remove option
683
+ if remove_ignores:
684
+ if not package_names:
685
+ console.print("[red]Error: At least one package name must be specified for removal.[/red]")
686
+ sys.exit(1)
687
+
688
+ from .package_constraints import remove_ignores_from_config
689
+
690
+ packages_list = list(package_names)
691
+ console.print(f"[bold blue]Removing ignores for {len(packages_list)} package(s)...[/bold blue]")
692
+
693
+ try:
694
+ config_path, removed_packages = remove_ignores_from_config(packages_list, env)
695
+
696
+ if not removed_packages:
697
+ console.print("[yellow]No ignores were removed (packages not found in ignores).[/yellow]")
698
+ return
699
+
700
+ # Display summary
701
+ console.print("\n[bold green]✓ Ignores removed successfully![/bold green]")
702
+ console.print(f"[bold]File:[/bold] {config_path}")
703
+
704
+ # Show removed ignores
705
+ console.print("\n[bold]Ignores removed:[/bold]")
706
+ for package in removed_packages:
707
+ console.print(f" [red]Removed[/red]: {package}")
708
+
709
+ # Show which environment was updated
710
+ if env:
711
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
712
+ else:
713
+ from .package_constraints import get_current_environment_name
714
+ current_env = get_current_environment_name()
715
+ if current_env and current_env != "global":
716
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
717
+ else:
718
+ console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
719
+
720
+ return
721
+
722
+ except ValueError as e:
723
+ console.print(f"[red]Error: {e}[/red]")
724
+ sys.exit(1)
725
+ except IOError as e:
726
+ console.print(f"[red]Error writing configuration: {e}[/red]")
727
+ sys.exit(1)
728
+
729
+ # Handle --remove-all option
730
+ if remove_all_ignores:
731
+ from .package_constraints import remove_all_ignores_from_config, list_all_ignores
732
+
733
+ # Get confirmation if not using --yes and removing from all environments
734
+ if not env and not skip_confirmation:
735
+ # Show what will be removed
736
+ try:
737
+ all_ignores = list_all_ignores()
738
+ if not all_ignores:
739
+ console.print("[yellow]No ignores found in any environment.[/yellow]")
740
+ return
741
+
742
+ console.print("[bold red]WARNING: This will remove ALL ignores from ALL environments![/bold red]")
743
+ console.print("\n[bold]Ignores that will be removed:[/bold]")
744
+
745
+ total_ignores = 0
746
+ for env_name, ignores in all_ignores.items():
747
+ console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
748
+ for package in sorted(ignores):
749
+ console.print(f" {package}")
750
+ total_ignores += 1
751
+
752
+ console.print(f"\n[bold]Total: {total_ignores} ignore(s) in {len(all_ignores)} environment(s)[/bold]")
753
+
754
+ if not click.confirm("\nAre you sure you want to remove all ignores?"):
755
+ console.print("[yellow]Operation cancelled.[/yellow]")
756
+ return
757
+
758
+ except Exception:
759
+ # If we can't list ignores, ask for generic confirmation
760
+ if not click.confirm("Are you sure you want to remove all ignores from all environments?"):
761
+ console.print("[yellow]Operation cancelled.[/yellow]")
762
+ return
763
+
764
+ console.print(f"[bold blue]Removing all ignores from {'all environments' if not env else env}...[/bold blue]")
765
+
766
+ try:
767
+ config_path, removed_ignores = remove_all_ignores_from_config(env)
768
+
769
+ if not removed_ignores:
770
+ if env:
771
+ console.print(f"[yellow]No ignores found in environment '{env}'.[/yellow]")
772
+ else:
773
+ console.print("[yellow]No ignores found in any environment.[/yellow]")
774
+ return
775
+
776
+ # Display summary
777
+ console.print("\n[bold green]✓ All ignores removed successfully![/bold green]")
778
+ console.print(f"[bold]File:[/bold] {config_path}")
779
+
780
+ # Show removed ignores
781
+ console.print("\n[bold]Ignores removed:[/bold]")
782
+ total_removed = 0
783
+ for env_name, ignores in removed_ignores.items():
784
+ console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
785
+ for package in sorted(ignores):
786
+ console.print(f" [red]Removed[/red]: {package}")
787
+ total_removed += 1
788
+
789
+ console.print(f"\n[bold]Total removed: {total_removed} ignore(s) from {len(removed_ignores)} environment(s)[/bold]")
790
+
791
+ # Show which environment(s) were updated
792
+ if env:
793
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
794
+ else:
795
+ environments_updated = list(removed_ignores.keys())
796
+ console.print(f"\n[bold cyan]Environments updated:[/bold cyan] {', '.join(sorted(environments_updated))}")
797
+
798
+ return
799
+
800
+ except ValueError as e:
801
+ console.print(f"[red]Error: {e}[/red]")
802
+ sys.exit(1)
803
+ except IOError as e:
804
+ console.print(f"[red]Error writing configuration: {e}[/red]")
805
+ sys.exit(1)
806
+
807
+ # Handle adding ignores (default behavior)
808
+ if not package_names:
809
+ console.print("[red]Error: At least one package name must be specified.[/red]")
810
+ sys.exit(1)
811
+
812
+ from .package_constraints import add_ignores_to_config, get_current_environment_name
813
+
814
+ packages_list = list(package_names)
815
+ console.print("[bold blue]Adding ignores to pip configuration...[/bold blue]")
816
+
817
+ # Get the recommended config file path early for error handling
818
+ from .package_constraints import get_recommended_pip_config_path
819
+ config_path = get_recommended_pip_config_path()
820
+
821
+ try:
822
+ config_path, changes = add_ignores_to_config(packages_list, env)
823
+
824
+ if not any(action == 'added' for action in changes.values()):
825
+ console.print("[yellow]No changes made - all packages are already ignored.[/yellow]")
826
+ return
827
+
828
+ # Display summary
829
+ console.print("\n[bold green]✓ Configuration updated successfully![/bold green]")
830
+ console.print(f"[bold]File:[/bold] {config_path}")
831
+
832
+ # Show changes
833
+ console.print("\n[bold]Ignores modified:[/bold]")
834
+ for package, action in changes.items():
835
+ if action == 'added':
836
+ console.print(f" [green]Added[/green]: {package}")
837
+ elif action == 'already_exists':
838
+ console.print(f" [yellow]Already ignored[/yellow]: {package}")
839
+
840
+ # Show which environment was updated
841
+ if env:
842
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
843
+ else:
844
+ current_env = get_current_environment_name()
845
+ if current_env and current_env != "global":
846
+ console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
847
+ else:
848
+ console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
849
+
850
+ except IOError as e:
851
+ console.print(f"[red]Error writing configuration: {e}[/red]")
852
+ sys.exit(1)
853
+
854
+ except Exception as e:
855
+ console.print(f"[red]Unexpected error: {e}[/red]")
856
+ sys.exit(1)
857
+
858
+
859
+ # This allows the module to be run with: python -m pipu_cli.cli
860
+ if __name__ == '__main__':
861
+ cli()