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.
pipu_cli/cli.py CHANGED
@@ -1,905 +1,955 @@
1
+ """CLI interface for pipu using rich_click."""
2
+
3
+ import json
4
+ import logging
1
5
  import sys
2
- from typing import List
6
+ import time
7
+ from typing import Optional
8
+
3
9
  import rich_click as click
4
- from .internals import list_outdated
5
- from .package_constraints import (
6
- read_constraints,
7
- read_ignores,
8
- read_invalidation_triggers,
9
- _get_constraint_invalid_when,
10
- _set_constraint_invalid_when
10
+ from rich.console import Console
11
+ from rich.logging import RichHandler
12
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
13
+ from rich.table import Table
14
+
15
+ from pipu_cli.package_management import (
16
+ inspect_installed_packages,
17
+ get_latest_versions,
18
+ get_latest_versions_parallel,
19
+ resolve_upgradable_packages,
20
+ resolve_upgradable_packages_with_reasons,
21
+ install_packages,
22
+ )
23
+ from pipu_cli.pretty import (
24
+ print_upgradable_packages_table,
25
+ print_upgrade_results,
26
+ print_blocked_packages_table,
27
+ ConsoleStream,
28
+ select_packages_interactively,
29
+ )
30
+ from pipu_cli.output import JsonOutputFormatter
31
+ from pipu_cli.config_file import load_config, get_config_value
32
+ from pipu_cli.config import DEFAULT_CACHE_TTL
33
+ from pipu_cli.cache import (
34
+ is_cache_fresh,
35
+ load_cache,
36
+ save_cache,
37
+ build_cache_from_results,
38
+ get_cache_info,
39
+ format_cache_age,
40
+ get_cache_age_seconds,
41
+ clear_cache,
42
+ clear_all_caches,
11
43
  )
12
- from .common import console
13
- from pip._internal.commands.install import InstallCommand
14
44
 
15
45
 
16
- def _install_packages(package_names: List[str], packages_being_updated: List[str] | None = None) -> int:
17
- """
18
- Install packages using pip API with filtered constraints.
46
+ # Configure rich_click
47
+ click.rich_click.USE_RICH_MARKUP = True
48
+ click.rich_click.SHOW_ARGUMENTS = True
49
+ click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
19
50
 
20
- :param package_names: List of package names to install
21
- :param packages_being_updated: List of package names being updated (to exclude from constraints)
22
- :returns: Exit code (0 for success, non-zero for failure)
23
- """
24
- import tempfile
25
- import os
26
- from packaging.utils import canonicalize_name
27
-
28
- # If packages_being_updated not provided, use package_names
29
- if packages_being_updated is None:
30
- packages_being_updated = package_names
31
-
32
- # Get all current constraints and filter out packages being updated
33
- from .package_constraints import read_constraints
34
- all_constraints = read_constraints()
35
-
36
- # Get canonical names of packages being updated
37
- packages_being_updated_canonical = {canonicalize_name(pkg) for pkg in packages_being_updated}
38
-
39
- # Filter out constraints for packages being updated to avoid conflicts
40
- filtered_constraints = {
41
- pkg: constraint
42
- for pkg, constraint in all_constraints.items()
43
- if pkg not in packages_being_updated_canonical
44
- }
45
-
46
- # Create a temporary constraints file if there are any constraints to apply
47
- constraint_file_path = None
48
- try:
49
- if filtered_constraints:
50
- with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
51
- constraint_file_path = f.name
52
- for pkg, constraint in filtered_constraints.items():
53
- f.write(f"{pkg}{constraint}\n")
54
-
55
- # Set environment variable for pip to use the filtered constraints
56
- os.environ['PIP_CONSTRAINT'] = constraint_file_path
57
- console.print(f"[dim]Using filtered constraints (excluding {len(packages_being_updated_canonical)} package(s) being updated)[/dim]")
58
-
59
- install_cmd = InstallCommand("install", "Install packages")
60
- install_args = ["--upgrade"] + package_names
61
- return install_cmd.main(install_args)
62
-
63
- finally:
64
- # Clean up: remove the constraint file and unset environment variable
65
- if constraint_file_path:
66
- if 'PIP_CONSTRAINT' in os.environ:
67
- del os.environ['PIP_CONSTRAINT']
68
- if os.path.exists(constraint_file_path):
69
- try:
70
- os.unlink(constraint_file_path)
71
- except Exception:
72
- pass # Best effort cleanup
73
-
74
-
75
- def launch_tui() -> None:
76
- """
77
- Launch the main TUI interface.
78
- """
79
- try:
80
- # Check for invalid constraints and triggers from removed/renamed packages
81
- from .package_constraints import cleanup_invalid_constraints_and_triggers
82
- _, _, cleanup_summary = cleanup_invalid_constraints_and_triggers()
83
- if cleanup_summary:
84
- console.print(f"[yellow]🧹 {cleanup_summary}[/yellow]")
85
- console.print("[dim]Press any key to continue...[/dim]")
86
- input() # Wait for user acknowledgment before launching TUI
87
-
88
- from .ui import main_tui_app
89
- main_tui_app()
90
- except Exception as e:
91
- console.print(f"[red]Error launching TUI: {e}[/red]")
92
- sys.exit(1)
93
51
 
52
+ def parse_package_spec(spec: str) -> tuple[str, Optional[str]]:
53
+ """Parse a package specification like 'requests==2.31.0' or 'requests>=2.30'.
54
+
55
+ :param spec: Package specification string
56
+ :returns: Tuple of (package_name, version_constraint or None)
57
+ """
58
+ # Common specifier patterns (check longest operators first to avoid partial matches)
59
+ for op in ['==', '>=', '<=', '~=', '!=', '>', '<']:
60
+ if op in spec:
61
+ parts = spec.split(op, 1)
62
+ return (parts[0].strip(), op + parts[1].strip())
94
63
 
64
+ return (spec.strip(), None)
95
65
 
96
66
 
97
67
  @click.group(invoke_without_command=True)
98
68
  @click.pass_context
99
- def cli(ctx):
69
+ def cli(ctx: click.Context) -> None:
100
70
  """
101
- pipu - Python package updater with constraint management
71
+ [bold cyan]pipu[/bold cyan] - A cute Python package updater
102
72
 
103
- If no command is specified, launches the interactive TUI.
104
- """
105
- if ctx.invoked_subcommand is None:
106
- # No subcommand was invoked, launch the TUI
107
- launch_tui()
73
+ Automatically checks for package updates and upgrades them with proper
74
+ constraint resolution.
108
75
 
76
+ [bold]Commands:[/bold]
77
+ pipu update Refresh package version cache
78
+ pipu upgrade Upgrade packages (default command)
79
+ pipu rollback Restore packages to a previous state
109
80
 
110
- @cli.command(name='list')
111
- @click.option('--pre', is_flag=True, help='Include pre-release versions (alpha, beta, rc, dev)')
112
- @click.option('--debug', is_flag=True, help='Print debug information as packages are checked')
113
- def list_packages(pre, debug):
114
- """
115
- List outdated packages
116
-
117
- Displays a formatted table of installed packages that have newer versions
118
- available on the configured package indexes. By default, pre-release versions
119
- are excluded unless --pre is specified.
81
+ Run [cyan]pipu <command> --help[/cyan] for command-specific help.
120
82
  """
121
- try:
122
- # Check for invalid constraints and triggers from removed/renamed packages
123
- from .package_constraints import cleanup_invalid_constraints_and_triggers
124
- _, _, cleanup_summary = cleanup_invalid_constraints_and_triggers()
125
- if cleanup_summary:
126
- console.print(f"[yellow]🧹 {cleanup_summary}[/yellow]")
127
-
128
- # Read constraints, ignores, and invalidation triggers from configuration
129
- constraints = read_constraints()
130
- ignores = read_ignores()
131
- invalidation_triggers = read_invalidation_triggers()
132
-
133
- # Set up debug callbacks if debug mode is enabled
134
- progress_callback = None
135
- result_callback = None
136
- if debug:
137
- def debug_progress(package_name):
138
- console.print(f"[dim]DEBUG: Checking {package_name}...[/dim]")
139
-
140
- def debug_callback(package_result):
141
- pkg_name = package_result.get('name', 'unknown')
142
- current_ver = package_result.get('version', 'unknown')
143
- latest_ver = package_result.get('latest_version', 'unknown')
144
- if current_ver != latest_ver:
145
- console.print(f"[dim]DEBUG: {pkg_name}: {current_ver} -> {latest_ver}[/dim]")
146
- else:
147
- console.print(f"[dim]DEBUG: {pkg_name}: {current_ver} (up-to-date)[/dim]")
148
-
149
- progress_callback = debug_progress
150
- result_callback = debug_callback
151
-
152
- # Use the internals function to get outdated packages and print the table
153
- outdated_packages = list_outdated(
154
- console=console,
155
- print_table=True,
156
- constraints=constraints,
157
- ignores=ignores,
158
- pre=pre,
159
- progress_callback=progress_callback,
160
- result_callback=result_callback,
161
- invalidation_triggers=invalidation_triggers
162
- )
163
-
164
- # The function already prints the table, so we just return the data
165
- return outdated_packages
166
-
167
- except Exception as e:
168
- console.print(f"[red]Unexpected error: {e}[/red]")
169
- sys.exit(1)
83
+ # If no subcommand provided, default to upgrade
84
+ if ctx.invoked_subcommand is None:
85
+ ctx.invoke(upgrade)
170
86
 
171
87
 
172
88
  @cli.command()
173
- @click.option('--pre', is_flag=True, help='Include pre-release versions (alpha, beta, rc, dev)')
174
- @click.option('-y', '--yes', is_flag=True, help='Skip confirmation prompt and install all updates')
175
- def update(pre, yes):
89
+ @click.option(
90
+ "--timeout",
91
+ type=int,
92
+ default=10,
93
+ help="Network timeout in seconds for package queries"
94
+ )
95
+ @click.option(
96
+ "--pre",
97
+ is_flag=True,
98
+ help="Include pre-release versions"
99
+ )
100
+ @click.option(
101
+ "--parallel",
102
+ type=int,
103
+ default=1,
104
+ help="Number of parallel requests for version checking (default: 1)"
105
+ )
106
+ @click.option(
107
+ "--debug",
108
+ is_flag=True,
109
+ help="Enable debug logging"
110
+ )
111
+ @click.option(
112
+ "--output",
113
+ type=click.Choice(["human", "json"]),
114
+ default="human",
115
+ help="Output format (human-readable or json)"
116
+ )
117
+ def update(timeout: int, pre: bool, parallel: int, debug: bool, output: str) -> None:
176
118
  """
177
- Update outdated packages
119
+ Refresh the package version cache.
178
120
 
179
- Lists all outdated packages and prompts for confirmation before installing
180
- updates. Respects version constraints from configuration files.
121
+ Fetches the latest version information from PyPI for all installed
122
+ packages and stores it locally. This speeds up subsequent upgrade
123
+ commands by avoiding repeated network requests.
181
124
 
182
- For interactive package selection, run 'pipu' with no arguments to launch the TUI.
125
+ [bold]Examples:[/bold]
126
+ pipu update Update cache with defaults
127
+ pipu update --parallel 4 Update with parallel requests
128
+ pipu update --pre Include pre-release versions
183
129
  """
184
- try:
185
- # Check for invalid constraints and triggers from removed/renamed packages
186
- from .package_constraints import cleanup_invalid_constraints_and_triggers
187
- _, _, cleanup_summary = cleanup_invalid_constraints_and_triggers()
188
- if cleanup_summary:
189
- console.print(f"[yellow]🧹 {cleanup_summary}[/yellow]")
190
-
191
- # Read constraints, ignores, and invalidation triggers from configuration
192
- constraints = read_constraints()
193
- ignores = read_ignores()
194
- invalidation_triggers = read_invalidation_triggers()
195
-
196
- # Get outdated packages
197
- outdated_packages = list_outdated(
198
- console=console, print_table=True, constraints=constraints, ignores=ignores, pre=pre, invalidation_triggers=invalidation_triggers
130
+ console = Console()
131
+
132
+ # Load configuration file
133
+ config = load_config()
134
+ if timeout == 10:
135
+ timeout = get_config_value(config, 'timeout', 10)
136
+ if not pre:
137
+ pre = get_config_value(config, 'pre', False)
138
+ if not debug:
139
+ debug = get_config_value(config, 'debug', False)
140
+ if parallel == 1:
141
+ parallel = get_config_value(config, 'parallel', 1)
142
+
143
+ # Configure logging
144
+ if debug and output != "json":
145
+ logging.basicConfig(
146
+ level=logging.DEBUG,
147
+ format='%(message)s',
148
+ handlers=[RichHandler(console=console, show_time=False, show_path=False, markup=True)]
199
149
  )
150
+ logging.getLogger('pip._internal').setLevel(logging.WARNING)
151
+ logging.getLogger('pip._vendor').setLevel(logging.WARNING)
152
+ console.print("[dim]Debug mode enabled[/dim]\n")
200
153
 
201
- if not outdated_packages:
202
- console.print("[green]All packages are already up to date![/green]")
203
- return
204
-
205
- # Determine which packages to update
206
- packages_to_update = outdated_packages
207
-
208
- if not yes:
209
- # Standard confirmation mode
210
- console.print()
211
- response = click.confirm("Do you want to update these packages?", default=False)
212
- if not response:
213
- console.print("[yellow]Update cancelled.[/yellow]")
214
- return
215
-
216
- # Validate constraint compatibility before installation
217
- console.print()
218
- console.print("[bold blue]Validating constraint compatibility...[/bold blue]")
219
-
220
- from .package_constraints import validate_package_installation, get_constraint_violation_summary
221
-
222
- # Extract package names for validation
223
- package_names_to_install = [pkg['name'] for pkg in packages_to_update]
224
-
225
- # Check for constraint violations
226
- safe_packages, invalidated_constraints = validate_package_installation(package_names_to_install)
227
-
228
- if invalidated_constraints:
229
- # Show constraint violations
230
- console.print("[bold red]⚠ Constraint Violations Detected![/bold red]")
231
- console.print(get_constraint_violation_summary(invalidated_constraints))
232
-
233
- # Filter out packages that would violate constraints
234
- violating_package_names = set()
235
- for violators in invalidated_constraints.values():
236
- violating_package_names.update(pkg.lower() for pkg in violators)
237
-
238
- # Keep only safe packages
239
- original_count = len(packages_to_update)
240
- packages_to_update = [
241
- pkg for pkg in packages_to_update
242
- if pkg['name'].lower() not in violating_package_names
243
- ]
244
-
245
- blocked_count = original_count - len(packages_to_update)
246
-
247
- if packages_to_update:
248
- console.print(f"\n[yellow]Proceeding with {len(packages_to_update)} packages that don't violate constraints.")
249
- console.print(f"Blocked {blocked_count} packages due to constraint violations.[/yellow]")
154
+ try:
155
+ # Step 1: Inspect installed packages
156
+ if output != "json":
157
+ console.print("[bold]Step 1/3:[/bold] Inspecting installed packages...")
158
+
159
+ step1_start = time.time()
160
+ if output != "json":
161
+ with Progress(
162
+ SpinnerColumn(),
163
+ TextColumn("[progress.description]{task.description}"),
164
+ console=console,
165
+ transient=True
166
+ ) as progress:
167
+ task = progress.add_task("Loading packages...", total=None)
168
+ installed_packages = inspect_installed_packages(timeout=timeout)
169
+ progress.update(task, completed=True)
170
+ else:
171
+ installed_packages = inspect_installed_packages(timeout=timeout)
172
+ step1_time = time.time() - step1_start
173
+
174
+ num_installed = len(installed_packages)
175
+ if output != "json":
176
+ console.print(f" Found {num_installed} installed packages")
177
+ if debug:
178
+ console.print(f" [dim]Time: {step1_time:.2f}s[/dim]")
179
+
180
+ if not installed_packages:
181
+ if output == "json":
182
+ print('{"error": "No packages found"}')
250
183
  else:
251
- console.print(f"\n[red]All {blocked_count} packages would violate constraints. No packages will be installed.[/red]")
252
- return
253
-
254
- # Install updates
255
- console.print()
256
- console.print("[bold green]Installing updates...[/bold green]")
257
-
258
- # Create list of package names to install
259
- # Use --upgrade without version pinning to allow pip's dependency resolver
260
- # to handle interdependent packages (e.g., pydantic and pydantic-core)
261
- package_names = [package['name'] for package in packages_to_update]
262
-
263
- # Install packages using pip API with filtered constraints
264
- exit_code = _install_packages(package_names, packages_being_updated=package_names)
184
+ console.print("[yellow]No packages found.[/yellow]")
185
+ sys.exit(0)
186
+
187
+ # Step 2: Check for updates
188
+ if output != "json":
189
+ console.print("\n[bold]Step 2/3:[/bold] Fetching latest versions...")
190
+
191
+ step2_start = time.time()
192
+ if output != "json":
193
+ with Progress(
194
+ TextColumn("[progress.description]{task.description}"),
195
+ BarColumn(),
196
+ TaskProgressColumn(),
197
+ console=console,
198
+ transient=True
199
+ ) as progress:
200
+ task = progress.add_task("Checking packages...", total=len(installed_packages))
201
+
202
+ def update_progress(current, total):
203
+ progress.update(task, completed=current)
204
+
205
+ if parallel > 1:
206
+ latest_versions = get_latest_versions_parallel(
207
+ installed_packages, timeout=timeout, include_prereleases=pre,
208
+ max_workers=parallel, progress_callback=update_progress
209
+ )
210
+ else:
211
+ latest_versions = get_latest_versions(
212
+ installed_packages, timeout=timeout, include_prereleases=pre,
213
+ progress_callback=update_progress
214
+ )
215
+ else:
216
+ if parallel > 1:
217
+ latest_versions = get_latest_versions_parallel(
218
+ installed_packages, timeout=timeout, include_prereleases=pre, max_workers=parallel
219
+ )
220
+ else:
221
+ latest_versions = get_latest_versions(
222
+ installed_packages, timeout=timeout, include_prereleases=pre
223
+ )
224
+ step2_time = time.time() - step2_start
225
+
226
+ num_updates = len(latest_versions)
227
+ if output != "json":
228
+ console.print(f" Found {num_updates} packages with newer versions available")
229
+ if debug:
230
+ console.print(f" [dim]Time: {step2_time:.2f}s[/dim]")
231
+
232
+ # Step 3: Resolve and cache
233
+ if output != "json":
234
+ console.print("\n[bold]Step 3/3:[/bold] Resolving constraints and saving cache...")
235
+
236
+ step3_start = time.time()
237
+ all_upgradable = resolve_upgradable_packages(latest_versions, installed_packages)
238
+ upgradable_packages = [pkg for pkg in all_upgradable if pkg.upgradable]
239
+ step3_time = time.time() - step3_start
240
+
241
+ # Build and save cache
242
+ cache_data = build_cache_from_results(installed_packages, latest_versions, upgradable_packages)
243
+ cache_path = save_cache(cache_data)
244
+
245
+ num_upgradable = len(upgradable_packages)
246
+
247
+ if output == "json":
248
+ result = {
249
+ "status": "success",
250
+ "packages_checked": num_installed,
251
+ "packages_with_updates": num_updates,
252
+ "packages_upgradable": num_upgradable,
253
+ "cache_path": str(cache_path)
254
+ }
255
+ print(json.dumps(result, indent=2))
256
+ else:
257
+ console.print(f" {num_upgradable} packages can be safely upgraded")
258
+ if debug:
259
+ console.print(f" [dim]Time: {step3_time:.2f}s[/dim]")
260
+ console.print(f" [dim]Cache saved to: {cache_path}[/dim]")
265
261
 
266
- if exit_code == 0:
267
- console.print("[bold green]✓ All packages updated successfully![/bold green]")
262
+ console.print("\n[bold green]Package list updated![/bold green] Run [cyan]pipu upgrade[/cyan] to upgrade your packages.")
268
263
 
269
- # Clean up constraints whose invalidation triggers have been satisfied
270
- from .package_constraints import post_install_cleanup
271
- post_install_cleanup(console)
264
+ total_time = step1_time + step2_time + step3_time
265
+ if debug:
266
+ console.print(f"[dim]Total time: {total_time:.2f}s[/dim]")
272
267
 
273
- else:
274
- console.print("[bold red]✗ Some packages failed to update.[/bold red]")
275
- sys.exit(exit_code)
268
+ sys.exit(0)
276
269
 
270
+ except KeyboardInterrupt:
271
+ console.print("\n[yellow]Interrupted by user.[/yellow]")
272
+ sys.exit(130)
277
273
  except Exception as e:
278
- console.print(f"[red]Unexpected error: {e}[/red]")
274
+ if output == "json":
275
+ print(json.dumps({"error": str(e)}))
276
+ else:
277
+ console.print(f"\n[bold red]Error:[/bold red] {e}")
279
278
  sys.exit(1)
280
279
 
281
280
 
282
281
  @cli.command()
283
- @click.argument('constraint_specs', nargs=-1, required=False)
284
- @click.option('--env', help='Target environment section (defaults to current environment or global)')
285
- @click.option('--list', 'list_constraints', is_flag=True, help='List existing constraints for specified environment (or all environments if no --env specified)')
286
- @click.option('--remove', 'remove_constraints', is_flag=True, help='Remove constraints for specified packages')
287
- @click.option('--remove-all', 'remove_all_constraints', is_flag=True, help='Remove all constraints from specified environment (or all environments if no --env specified)')
288
- @click.option('--yes', '-y', 'skip_confirmation', is_flag=True, help='Skip confirmation prompt')
289
- @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.')
290
- def constrain(constraint_specs, env, list_constraints, remove_constraints, remove_all_constraints, skip_confirmation, invalidation_triggers):
291
- """
292
- Add or update package constraints in pip configuration
293
-
294
- Sets version constraints for packages in the pip configuration file.
295
- Constraints prevent packages from being updated beyond specified versions.
296
-
297
- Note: Automatic constraints from installed packages are now discovered
298
- on every pipu execution and do not need to be manually added. The constraints
299
- you add here will override any automatic constraints.
300
-
301
- \b
302
- Examples:
303
- pipu constrain "requests==2.31.0"
304
- pipu constrain "numpy>=1.20.0" "pandas<2.0.0"
305
- pipu constrain "django~=4.1.0" --env production
306
- pipu constrain "flask<2" --invalidates-when "other_package>=1" --invalidates-when "another_package>1.5"
307
- pipu constrain --list
308
- pipu constrain --list --env production
309
- pipu constrain --remove requests numpy
310
- pipu constrain --remove django --env production
311
- pipu constrain --remove-all --env production
312
- pipu constrain --remove-all --yes
313
-
314
- \f
315
- :param constraint_specs: One or more constraint specifications or package names (for --remove)
316
- :param env: Target environment section name
317
- :param list_constraints: List existing constraints instead of adding new ones
318
- :param remove_constraints: Remove constraints for specified packages
319
- :param remove_all_constraints: Remove all constraints from environment(s)
320
- :param skip_confirmation: Skip confirmation prompt for --remove-all
321
- :raises SystemExit: Exits with code 1 if an error occurs
282
+ @click.argument('packages', nargs=-1)
283
+ @click.option(
284
+ "--timeout",
285
+ type=int,
286
+ default=10,
287
+ help="Network timeout in seconds for package queries"
288
+ )
289
+ @click.option(
290
+ "--pre",
291
+ is_flag=True,
292
+ help="Include pre-release versions"
293
+ )
294
+ @click.option(
295
+ "--yes", "-y",
296
+ is_flag=True,
297
+ help="Automatically confirm upgrade without prompting"
298
+ )
299
+ @click.option(
300
+ "--debug",
301
+ is_flag=True,
302
+ help="Enable debug logging and show performance timing"
303
+ )
304
+ @click.option(
305
+ "--dry-run",
306
+ is_flag=True,
307
+ help="Show what would be upgraded without actually upgrading"
308
+ )
309
+ @click.option(
310
+ "--exclude",
311
+ type=str,
312
+ default="",
313
+ help="Comma-separated list of packages to exclude from upgrade"
314
+ )
315
+ @click.option(
316
+ "--show-blocked",
317
+ is_flag=True,
318
+ help="Show packages that cannot be upgraded and why"
319
+ )
320
+ @click.option(
321
+ "--output",
322
+ type=click.Choice(["human", "json"]),
323
+ default="human",
324
+ help="Output format (human-readable or json)"
325
+ )
326
+ @click.option(
327
+ "--update-requirements",
328
+ type=click.Path(exists=True),
329
+ default=None,
330
+ help="Update the specified requirements.txt file with new versions"
331
+ )
332
+ @click.option(
333
+ "--parallel",
334
+ type=int,
335
+ default=1,
336
+ help="Number of parallel requests for version checking (default: 1)"
337
+ )
338
+ @click.option(
339
+ "--interactive", "-i",
340
+ is_flag=True,
341
+ help="Interactively select packages to upgrade"
342
+ )
343
+ @click.option(
344
+ "--no-cache",
345
+ is_flag=True,
346
+ help="Skip cache and fetch fresh version data"
347
+ )
348
+ @click.option(
349
+ "--cache-ttl",
350
+ type=int,
351
+ default=None,
352
+ help=f"Cache freshness threshold in seconds (default: {DEFAULT_CACHE_TTL})"
353
+ )
354
+ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug: bool, dry_run: bool,
355
+ exclude: str, show_blocked: bool, output: str, update_requirements: Optional[str],
356
+ parallel: int, interactive: bool, no_cache: bool, cache_ttl: Optional[int]) -> None:
322
357
  """
323
- try:
324
- # Validate mutually exclusive options
325
- # Note: constraint_specs with --remove are package names, not constraint specs
326
- has_constraint_specs_for_adding = bool(constraint_specs) and not (remove_constraints or remove_all_constraints)
327
- active_options = [list_constraints, remove_constraints, remove_all_constraints, has_constraint_specs_for_adding]
328
- if sum(active_options) > 1:
329
- console.print("[red]Error: Cannot use --list, --remove, --remove-all, and constraint specs together. Use only one at a time.[/red]")
330
- sys.exit(1)
358
+ Upgrade installed packages.
331
359
 
332
- # Validate --invalidates-when can only be used when adding constraints
333
- if invalidation_triggers and (list_constraints or remove_constraints or remove_all_constraints):
334
- console.print("[red]Error: --invalidates-when cannot be used with --list, --remove, or --remove-all.[/red]")
335
- sys.exit(1)
360
+ By default, upgrades all packages that have newer versions available.
361
+ Optionally specify PACKAGES to upgrade only those packages.
336
362
 
337
- if invalidation_triggers and not has_constraint_specs_for_adding:
338
- console.print("[red]Error: --invalidates-when can only be used when adding constraint specifications.[/red]")
339
- sys.exit(1)
363
+ Uses cached version data if available and fresh. Run [cyan]pipu update[/cyan]
364
+ to refresh the cache manually.
340
365
 
341
- # Handle --list option
342
- if list_constraints:
343
- from .package_constraints import list_all_constraints
344
-
345
- console.print("[bold blue]Listing constraints from pip configuration...[/bold blue]")
366
+ [bold]Examples:[/bold]
367
+ pipu upgrade Upgrade all packages
368
+ pipu upgrade requests numpy Upgrade specific packages
369
+ pipu upgrade --dry-run Preview without installing
370
+ pipu upgrade -i Interactive package selection
371
+ pipu upgrade --no-cache Force fresh version check
372
+ """
373
+ console = Console()
374
+
375
+ # Load configuration file
376
+ config = load_config()
377
+
378
+ # Apply config file values for options at defaults
379
+ if timeout == 10:
380
+ timeout = get_config_value(config, 'timeout', 10)
381
+ if not exclude:
382
+ exclude_list = get_config_value(config, 'exclude', [])
383
+ if exclude_list:
384
+ exclude = ','.join(exclude_list)
385
+ if not pre:
386
+ pre = get_config_value(config, 'pre', False)
387
+ if not yes:
388
+ yes = get_config_value(config, 'yes', False)
389
+ if not debug:
390
+ debug = get_config_value(config, 'debug', False)
391
+ if not dry_run:
392
+ dry_run = get_config_value(config, 'dry_run', False)
393
+ if not show_blocked:
394
+ show_blocked = get_config_value(config, 'show_blocked', False)
395
+ if output == "human":
396
+ output = get_config_value(config, 'output', 'human')
397
+ if cache_ttl is None:
398
+ cache_ttl = get_config_value(config, 'cache_ttl', DEFAULT_CACHE_TTL)
399
+
400
+ # Check if caching is enabled
401
+ cache_enabled = get_config_value(config, 'cache_enabled', True) and not no_cache
402
+
403
+ # Initialize JSON formatter if needed
404
+ json_formatter = JsonOutputFormatter() if output == "json" else None
405
+
406
+ # Interactive mode only works in human output mode
407
+ if interactive and output == "json":
408
+ console.print("[yellow]Warning: --interactive is not compatible with --output json. Ignoring --interactive.[/yellow]")
409
+ interactive = False
410
+
411
+ # Configure logging
412
+ if debug and output != "json":
413
+ logging.basicConfig(
414
+ level=logging.DEBUG,
415
+ format='%(message)s',
416
+ handlers=[RichHandler(console=console, show_time=False, show_path=False, markup=True)]
417
+ )
418
+ logging.getLogger('pip._internal').setLevel(logging.WARNING)
419
+ logging.getLogger('pip._vendor').setLevel(logging.WARNING)
420
+ console.print("[dim]Debug mode enabled[/dim]\n")
346
421
 
347
- all_constraints = list_all_constraints(env)
422
+ try:
423
+ # Check cache freshness
424
+ effective_cache_ttl = DEFAULT_CACHE_TTL if cache_ttl is None else cache_ttl
425
+ use_cache = cache_enabled and is_cache_fresh(effective_cache_ttl)
426
+
427
+ if use_cache and output != "json":
428
+ cache_age = get_cache_age_seconds()
429
+ console.print(f"[dim]Using cached data ({format_cache_age(cache_age)})[/dim]\n")
430
+
431
+ # Step 1: Inspect installed packages
432
+ if output != "json":
433
+ console.print("[bold]Step 1/5:[/bold] Inspecting installed packages...")
434
+
435
+ step1_start = time.time()
436
+ if output != "json":
437
+ with Progress(
438
+ SpinnerColumn(),
439
+ TextColumn("[progress.description]{task.description}"),
440
+ console=console,
441
+ transient=True
442
+ ) as progress:
443
+ task = progress.add_task("Loading packages...", total=None)
444
+ installed_packages = inspect_installed_packages(timeout=timeout)
445
+ progress.update(task, completed=True)
446
+ else:
447
+ installed_packages = inspect_installed_packages(timeout=timeout)
448
+ step1_time = time.time() - step1_start
449
+
450
+ num_installed = len(installed_packages)
451
+ if output != "json":
452
+ console.print(f" Found {num_installed} installed packages")
453
+ if debug:
454
+ console.print(f" [dim]Time: {step1_time:.2f}s[/dim]")
455
+
456
+ if not installed_packages:
457
+ if output == "json":
458
+ print('{"error": "No packages found"}')
459
+ else:
460
+ console.print("[yellow]No packages found.[/yellow]")
461
+ sys.exit(0)
348
462
 
349
- if not all_constraints:
350
- if env:
351
- console.print(f"[yellow]No constraints found for environment '[bold]{env}[/bold]'.[/yellow]")
463
+ # Step 2: Check for updates (from cache or network)
464
+ if output != "json":
465
+ if use_cache:
466
+ console.print("\n[bold]Step 2/5:[/bold] Loading cached version data...")
467
+ else:
468
+ console.print("\n[bold]Step 2/5:[/bold] Checking for updates...")
469
+
470
+ step2_start = time.time()
471
+ latest_versions: dict = {}
472
+
473
+ if use_cache:
474
+ # Load from cache
475
+ cache_data = load_cache()
476
+ if cache_data and cache_data.packages:
477
+ # Reconstruct latest_versions from cache
478
+ # We need to fetch fresh for accurate constraint resolution
479
+ # Cache is mainly to avoid the slow PyPI queries
480
+ if output != "json":
481
+ with Progress(
482
+ TextColumn("[progress.description]{task.description}"),
483
+ BarColumn(),
484
+ TaskProgressColumn(),
485
+ console=console,
486
+ transient=True
487
+ ) as progress:
488
+ task = progress.add_task("Checking packages...", total=len(installed_packages))
489
+
490
+ def update_progress(current, total):
491
+ progress.update(task, completed=current)
492
+
493
+ if parallel > 1:
494
+ latest_versions = get_latest_versions_parallel(
495
+ installed_packages, timeout=timeout, include_prereleases=pre,
496
+ max_workers=parallel, progress_callback=update_progress
497
+ )
498
+ else:
499
+ latest_versions = get_latest_versions(
500
+ installed_packages, timeout=timeout, include_prereleases=pre,
501
+ progress_callback=update_progress
502
+ )
352
503
  else:
353
- console.print("[yellow]No constraints found in any environment.[/yellow]")
354
- return
355
-
356
- # Display constraints
357
- for env_name, constraints in all_constraints.items():
358
- console.print(f"\n[bold cyan]Environment: {env_name}[/bold cyan]")
359
- if constraints:
360
- for package, constraint_spec in sorted(constraints.items()):
361
- console.print(f" {package}{constraint_spec}")
504
+ if parallel > 1:
505
+ latest_versions = get_latest_versions_parallel(
506
+ installed_packages, timeout=timeout, include_prereleases=pre, max_workers=parallel
507
+ )
508
+ else:
509
+ latest_versions = get_latest_versions(
510
+ installed_packages, timeout=timeout, include_prereleases=pre
511
+ )
512
+ else:
513
+ use_cache = False
514
+
515
+ if not use_cache:
516
+ # Fetch from network
517
+ if output != "json":
518
+ with Progress(
519
+ TextColumn("[progress.description]{task.description}"),
520
+ BarColumn(),
521
+ TaskProgressColumn(),
522
+ console=console,
523
+ transient=True
524
+ ) as progress:
525
+ task = progress.add_task("Checking packages...", total=len(installed_packages))
526
+
527
+ def update_progress(current, total):
528
+ progress.update(task, completed=current)
529
+
530
+ if parallel > 1:
531
+ latest_versions = get_latest_versions_parallel(
532
+ installed_packages, timeout=timeout, include_prereleases=pre,
533
+ max_workers=parallel, progress_callback=update_progress
534
+ )
535
+ else:
536
+ latest_versions = get_latest_versions(
537
+ installed_packages, timeout=timeout, include_prereleases=pre,
538
+ progress_callback=update_progress
539
+ )
540
+ else:
541
+ if parallel > 1:
542
+ latest_versions = get_latest_versions_parallel(
543
+ installed_packages, timeout=timeout, include_prereleases=pre, max_workers=parallel
544
+ )
362
545
  else:
363
- console.print(" [dim]No constraints[/dim]")
364
-
365
- return
366
-
367
- # Handle --remove option
368
- if remove_constraints:
369
- if not constraint_specs:
370
- console.print("[red]Error: At least one package name must be specified for removal.[/red]")
371
- sys.exit(1)
372
-
373
- from .package_constraints import remove_constraints_from_config, parse_invalidation_triggers_storage, get_current_environment_name, parse_inline_constraints
374
-
375
- package_names = list(constraint_specs)
376
- console.print(f"[bold blue]Removing constraints for {len(package_names)} package(s)...[/bold blue]")
546
+ latest_versions = get_latest_versions(
547
+ installed_packages, timeout=timeout, include_prereleases=pre
548
+ )
377
549
 
378
- try:
379
- config_path, removed_constraints, removed_triggers = remove_constraints_from_config(package_names, env)
550
+ step2_time = time.time() - step2_start
380
551
 
381
- if not removed_constraints:
382
- console.print("[yellow]No constraints were removed (packages not found in constraints).[/yellow]")
383
- return
552
+ num_updates = len(latest_versions)
553
+ if output != "json":
554
+ console.print(f" Found {num_updates} packages with newer versions available")
555
+ if debug:
556
+ console.print(f" [dim]Time: {step2_time:.2f}s[/dim]")
384
557
 
385
- # Triggers are already cleaned up by remove_constraints_from_config
386
-
387
- # Display summary
388
- console.print("\n[bold green]✓ Constraints removed successfully![/bold green]")
389
- console.print(f"[bold]File:[/bold] {config_path}")
390
-
391
- # Show removed constraints
392
- console.print("\n[bold]Constraints removed:[/bold]")
393
- for package, constraint_spec in removed_constraints.items():
394
- console.print(f" [red]Removed[/red]: {package}{constraint_spec}")
558
+ if not latest_versions:
559
+ if output == "json":
560
+ print('{"upgradable": [], "upgradable_count": 0, "message": "All packages are up to date"}')
561
+ else:
562
+ console.print("\n[bold green]All packages are up to date![/bold green]")
563
+ sys.exit(0)
564
+
565
+ # Step 3: Resolve upgradable packages
566
+ if output != "json":
567
+ console.print("\n[bold]Step 3/5:[/bold] Resolving dependency constraints...")
568
+ step3_start = time.time()
569
+
570
+ if show_blocked:
571
+ upgradable_packages, blocked_packages = resolve_upgradable_packages_with_reasons(
572
+ latest_versions, installed_packages
573
+ )
574
+ else:
575
+ all_upgradable = resolve_upgradable_packages(latest_versions, installed_packages)
576
+ upgradable_packages = [pkg for pkg in all_upgradable if pkg.upgradable]
577
+ blocked_packages = []
578
+
579
+ step3_time = time.time() - step3_start
580
+
581
+ # Update cache with fresh data (if we fetched from network)
582
+ if not use_cache and cache_enabled:
583
+ cache_data = build_cache_from_results(installed_packages, latest_versions, upgradable_packages)
584
+ save_cache(cache_data)
585
+
586
+ # Apply exclusions
587
+ excluded_names = set()
588
+ if exclude:
589
+ excluded_names = {name.strip().lower() for name in exclude.split(',')}
590
+ if debug and excluded_names:
591
+ console.print(f" [dim]Excluding: {', '.join(sorted(excluded_names))}[/dim]")
592
+
593
+ # Filter to only upgradable packages (excluding excluded ones)
594
+ can_upgrade = [pkg for pkg in upgradable_packages if pkg.name.lower() not in excluded_names]
595
+
596
+ # Parse package specifications and filter to specific packages if provided
597
+ package_constraints = {}
598
+ if packages:
599
+ requested_packages = set()
600
+ for spec in packages:
601
+ name, constraint = parse_package_spec(spec)
602
+ requested_packages.add(name.lower())
603
+ if constraint:
604
+ package_constraints[name.lower()] = constraint
605
+
606
+ can_upgrade = [pkg for pkg in can_upgrade if pkg.name.lower() in requested_packages]
607
+
608
+ if debug:
609
+ console.print(f" [dim]Filtering to: {', '.join(packages)}[/dim]")
610
+ if package_constraints:
611
+ console.print(f" [dim]Version constraints: {package_constraints}[/dim]")
612
+
613
+ if not can_upgrade:
614
+ if output == "json":
615
+ assert json_formatter is not None
616
+ json_data = json_formatter.format_all(
617
+ upgradable=[],
618
+ blocked=blocked_packages if show_blocked else None
619
+ )
620
+ print(json_data)
621
+ else:
622
+ console.print("\n[yellow]No packages can be upgraded (all blocked by constraints).[/yellow]")
623
+ if show_blocked and blocked_packages:
624
+ console.print()
625
+ print_blocked_packages_table(blocked_packages, console=console)
626
+ sys.exit(0)
627
+
628
+ num_upgradable = len(can_upgrade)
629
+ if output != "json":
630
+ console.print(f" {num_upgradable} packages can be safely upgraded")
631
+ if debug:
632
+ console.print(f" [dim]Time: {step3_time:.2f}s[/dim]")
633
+
634
+ # Step 4: Display table and ask for confirmation
635
+ if output == "json":
636
+ assert json_formatter is not None
637
+ if dry_run:
638
+ json_data = json_formatter.format_all(
639
+ upgradable=can_upgrade,
640
+ blocked=blocked_packages if show_blocked else None
641
+ )
642
+ print(json_data)
643
+ sys.exit(0)
644
+ else:
645
+ console.print("\n[bold]Step 4/5:[/bold] Packages ready for upgrade:\n")
646
+ print_upgradable_packages_table(can_upgrade, console=console)
395
647
 
396
- # Show removed invalidation triggers if any
397
- if removed_triggers:
398
- console.print("\n[bold]Invalidation triggers removed:[/bold]")
399
- for package, triggers in removed_triggers.items():
400
- for trigger in triggers:
401
- console.print(f" [red]Removed trigger[/red]: {trigger}")
648
+ if show_blocked and blocked_packages:
649
+ console.print()
650
+ print_blocked_packages_table(blocked_packages, console=console)
402
651
 
403
- # Show which environment was updated
404
- if env:
405
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
406
- else:
407
- from .package_constraints import get_current_environment_name
408
- current_env = get_current_environment_name()
409
- if current_env and current_env != "global":
410
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
411
- else:
412
- console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
413
-
414
- return
415
-
416
- except ValueError as e:
417
- console.print(f"[red]Error: {e}[/red]")
418
- sys.exit(1)
419
- except IOError as e:
420
- console.print(f"[red]Error writing configuration: {e}[/red]")
421
- sys.exit(1)
422
-
423
- # Handle --remove-all option
424
- if remove_all_constraints:
425
- from .package_constraints import remove_all_constraints_from_config, list_all_constraints, parse_invalidation_triggers_storage
426
-
427
- # Get confirmation if not using --yes and removing from all environments
428
- if not env and not skip_confirmation:
429
- # Show what will be removed
430
- try:
431
- all_constraints = list_all_constraints()
432
- if not all_constraints:
433
- console.print("[yellow]No constraints found in any environment.[/yellow]")
434
- return
435
-
436
- console.print("[bold red]WARNING: This will remove ALL constraints from ALL environments![/bold red]")
437
- console.print("\n[bold]Constraints that will be removed:[/bold]")
438
-
439
- total_constraints = 0
440
- for env_name, constraints in all_constraints.items():
441
- console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
442
- for package, constraint_spec in sorted(constraints.items()):
443
- console.print(f" {package}{constraint_spec}")
444
- total_constraints += 1
445
-
446
- console.print(f"\n[bold]Total: {total_constraints} constraint(s) in {len(all_constraints)} environment(s)[/bold]")
447
-
448
- if not click.confirm("\nAre you sure you want to remove all constraints?"):
449
- console.print("[yellow]Operation cancelled.[/yellow]")
450
- return
451
-
452
- except Exception:
453
- # If we can't list constraints, ask for generic confirmation
454
- if not click.confirm("Are you sure you want to remove all constraints from all environments?"):
455
- console.print("[yellow]Operation cancelled.[/yellow]")
456
- return
457
-
458
- console.print(f"[bold blue]Removing all constraints from {'all environments' if not env else env}...[/bold blue]")
459
-
460
- try:
461
- config_path, removed_constraints, removed_triggers_by_env = remove_all_constraints_from_config(env)
462
-
463
- if not removed_constraints:
464
- if env:
465
- console.print(f"[yellow]No constraints found in environment '{env}'.[/yellow]")
466
- else:
467
- console.print("[yellow]No constraints found in any environment.[/yellow]")
468
- return
469
-
470
- # Triggers are already cleaned up by remove_all_constraints_from_config
471
-
472
- # Display summary
473
- console.print("\n[bold green]✓ All constraints removed successfully![/bold green]")
474
- console.print(f"[bold]File:[/bold] {config_path}")
475
-
476
- # Show removed constraints
477
- console.print("\n[bold]Constraints removed:[/bold]")
478
- total_removed = 0
479
- for env_name, constraints in removed_constraints.items():
480
- console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
481
- for package, constraint_spec in sorted(constraints.items()):
482
- console.print(f" [red]Removed[/red]: {package}{constraint_spec}")
483
- total_removed += 1
484
-
485
- console.print(f"\n[bold]Total removed: {total_removed} constraint(s) from {len(removed_constraints)} environment(s)[/bold]")
486
-
487
- # Show removed invalidation triggers if any
488
- if removed_triggers_by_env:
489
- console.print("\n[bold]Invalidation triggers removed:[/bold]")
490
- total_triggers_removed = 0
491
- for env_name, env_triggers in removed_triggers_by_env.items():
492
- console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
493
- for package, triggers in env_triggers.items():
494
- for trigger in triggers:
495
- console.print(f" [red]Removed trigger[/red]: {trigger}")
496
- total_triggers_removed += 1
497
- console.print(f"\n[bold]Total triggers removed: {total_triggers_removed}[/bold]")
498
-
499
- # Show which environment(s) were updated
500
- if env:
501
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
502
- else:
503
- environments_updated = list(removed_constraints.keys())
504
- console.print(f"\n[bold cyan]Environments updated:[/bold cyan] {', '.join(sorted(environments_updated))}")
652
+ if interactive:
653
+ can_upgrade = select_packages_interactively(can_upgrade, console)
654
+ if not can_upgrade:
655
+ console.print("[yellow]No packages selected for upgrade.[/yellow]")
656
+ sys.exit(0)
505
657
 
506
- return
658
+ if dry_run:
659
+ console.print("\n[bold cyan]Dry run complete.[/bold cyan] No packages were modified.")
660
+ sys.exit(0)
507
661
 
508
- except ValueError as e:
509
- console.print(f"[red]Error: {e}[/red]")
510
- sys.exit(1)
511
- except IOError as e:
512
- console.print(f"[red]Error writing configuration: {e}[/red]")
513
- sys.exit(1)
662
+ # Skip confirmation if interactive mode (already confirmed) or --yes flag
663
+ if not yes and not interactive and output != "json":
664
+ console.print()
665
+ confirm = click.confirm("Do you want to proceed with the upgrade?", default=True)
666
+ if not confirm:
667
+ console.print("[yellow]Upgrade cancelled.[/yellow]")
668
+ sys.exit(0)
669
+
670
+ # Step 5: Install packages
671
+ if output != "json":
672
+ console.print("\n[bold]Step 5/5:[/bold] Upgrading packages...\n")
673
+ step5_start = time.time()
674
+
675
+ # Save state for potential rollback
676
+ from pipu_cli.rollback import save_state
677
+ pre_upgrade_packages = [
678
+ {"name": pkg.name, "version": str(pkg.version)}
679
+ for pkg in can_upgrade
680
+ ]
681
+ save_state(pre_upgrade_packages, "Pre-upgrade state")
682
+
683
+ stream = ConsoleStream(console) if output != "json" else None
684
+ results = install_packages(
685
+ can_upgrade,
686
+ output_stream=stream,
687
+ timeout=300,
688
+ version_constraints=package_constraints if package_constraints else None
689
+ )
690
+ step5_time = time.time() - step5_start
691
+
692
+ # Update requirements file if requested
693
+ if update_requirements:
694
+ from pathlib import Path
695
+ from pipu_cli.requirements import update_requirements_file
696
+ req_path = Path(update_requirements)
697
+ updated = update_requirements_file(req_path, results)
698
+ if updated and output != "json":
699
+ console.print(f"\n[bold green]Updated {updated} package(s) in {update_requirements}[/bold green]")
700
+
701
+ # Print results summary
702
+ if output == "json":
703
+ assert json_formatter is not None
704
+ json_data = json_formatter.format_all(
705
+ upgradable=can_upgrade,
706
+ blocked=blocked_packages if show_blocked else None,
707
+ results=results
708
+ )
709
+ print(json_data)
710
+ else:
711
+ print_upgrade_results(results, console=console)
514
712
 
515
- constraint_list = list(constraint_specs)
713
+ if debug:
714
+ console.print(f"\n[dim]Step 5 time: {step5_time:.2f}s[/dim]")
715
+ total_time = step1_time + step2_time + step3_time + step5_time
716
+ console.print(f"[dim]Total time: {total_time:.2f}s[/dim]")
516
717
 
517
- if not constraint_list:
518
- console.print("[red]Error: At least one constraint must be specified.[/red]")
718
+ # Exit with appropriate code
719
+ failed = [pkg for pkg in results if not pkg.upgraded]
720
+ if failed:
519
721
  sys.exit(1)
520
-
521
- console.print("[bold blue]Adding constraints to pip configuration...[/bold blue]")
522
-
523
- # Use backend function for constraint addition
524
- from .package_constraints import (
525
- add_constraints_to_config,
526
- validate_invalidation_triggers,
527
- get_recommended_pip_config_path,
528
- get_current_environment_name,
529
- parse_invalidation_triggers_storage,
530
- merge_invalidation_triggers,
531
- format_invalidation_triggers,
532
- parse_inline_constraints,
533
- parse_requirement_line
534
- )
535
- import configparser
536
-
537
- # Get the recommended config file path early for error handling
538
- config_path = get_recommended_pip_config_path()
539
-
540
- try:
541
- # Validate invalidation triggers if provided
542
- validated_triggers = []
543
- if invalidation_triggers:
544
- validated_triggers = validate_invalidation_triggers(list(invalidation_triggers))
545
-
546
- # Add constraints using the backend function
547
- config_path, changes = add_constraints_to_config(constraint_list, env)
548
-
549
- # Handle invalidation triggers if provided
550
- if validated_triggers:
551
- # Load the config and add triggers for the constrained packages
552
- config = configparser.ConfigParser()
553
- config.read(config_path)
554
-
555
- # Determine target environment section
556
- if env is None:
557
- env = get_current_environment_name()
558
- section_name = env if env else 'global'
559
-
560
- # Get existing invalidation triggers
561
- existing_triggers_storage = {}
562
- existing_value = _get_constraint_invalid_when(config, section_name)
563
- if existing_value:
564
- existing_triggers_storage = parse_invalidation_triggers_storage(existing_value)
565
-
566
- # Get current constraints to find the packages that were added/updated
567
- current_constraints = {}
568
- if config.has_option(section_name, 'constraints'):
569
- constraints_value = config.get(section_name, 'constraints')
570
- if any(op in constraints_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
571
- current_constraints = parse_inline_constraints(constraints_value)
572
-
573
- # Process triggers for each package that was specified (whether changed or not)
574
- updated_triggers_storage = existing_triggers_storage.copy()
575
-
576
- # Get all package names from the constraint list
577
- all_package_names = set()
578
- for spec in constraint_list:
579
- parsed = parse_requirement_line(spec)
580
- if parsed:
581
- all_package_names.add(parsed['name'].lower())
582
-
583
- for package_name in all_package_names:
584
- # Get existing triggers for this package
585
- existing_package_triggers = existing_triggers_storage.get(package_name, [])
586
-
587
- # Merge with new triggers
588
- merged_triggers = merge_invalidation_triggers(existing_package_triggers, validated_triggers)
589
-
590
- if merged_triggers:
591
- updated_triggers_storage[package_name] = merged_triggers
592
-
593
- # Format and store the triggers
594
- if updated_triggers_storage:
595
- trigger_entries = []
596
- for package_name, triggers in updated_triggers_storage.items():
597
- # Get the constraint for this package to format properly
598
- if package_name in current_constraints:
599
- package_constraint = current_constraints[package_name]
600
- formatted_entry = format_invalidation_triggers(f"{package_name}{package_constraint}", triggers)
601
- if formatted_entry:
602
- trigger_entries.append(formatted_entry)
603
-
604
- triggers_value = ','.join(trigger_entries) if trigger_entries else ''
605
- _set_constraint_invalid_when(config, section_name, triggers_value)
606
-
607
- # Write the updated config file
608
- with open(config_path, 'w', encoding='utf-8') as f:
609
- config.write(f)
610
-
611
- except Exception as e:
612
- if isinstance(e, ValueError):
613
- raise e
614
- else:
615
- raise IOError(f"Failed to write pip config file '{config_path}': {e}")
616
-
617
- if not changes and not validated_triggers:
618
- console.print("[yellow]No changes made - all constraints already exist with the same values.[/yellow]")
619
- return
620
-
621
- # Display summary
622
- console.print("\n[bold green]✓ Configuration updated successfully![/bold green]")
623
- console.print(f"[bold]File:[/bold] {config_path}")
624
-
625
- # Show changes
626
- if changes:
627
- console.print("\n[bold]Constraints modified:[/bold]")
628
- for package, (action, constraint) in changes.items():
629
- action_color = "green" if action == "added" else "yellow"
630
- console.print(f" [{action_color}]{action.title()}[/{action_color}]: {package}{constraint}")
631
-
632
- # Show invalidation triggers if added
633
- if validated_triggers:
634
- console.print("\n[bold]Invalidation triggers added:[/bold]")
635
- for trigger in validated_triggers:
636
- console.print(f" [cyan]Trigger[/cyan]: {trigger}")
637
-
638
- # Show which environment was updated
639
- if env:
640
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
641
722
  else:
642
- current_env = get_current_environment_name()
643
- if current_env and current_env != "global":
644
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
645
- else:
646
- console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
647
-
648
- except ValueError as e:
649
- console.print(f"[red]Error: {e}[/red]")
650
- sys.exit(1)
651
- except IOError as e:
652
- console.print(f"[red]Error writing configuration: {e}[/red]")
653
- sys.exit(1)
723
+ sys.exit(0)
724
+
725
+ except KeyboardInterrupt:
726
+ console.print("\n[yellow]Interrupted by user.[/yellow]")
727
+ sys.exit(130)
728
+ except click.Abort:
729
+ console.print("\n[yellow]Update cancelled by user[/yellow]")
730
+ sys.exit(130)
654
731
  except Exception as e:
655
- console.print(f"[red]Unexpected error: {e}[/red]")
732
+ console.print(f"\n[bold red]Error:[/bold red] {e}")
656
733
  sys.exit(1)
657
734
 
658
735
 
659
736
  @cli.command()
660
- @click.argument('package_names', nargs=-1, required=False)
661
- @click.option('--env', help='Target environment section (defaults to current environment or global)')
662
- @click.option('--list', 'list_ignores', is_flag=True, help='List existing ignores for specified environment (or all environments if no --env specified)')
663
- @click.option('--remove', 'remove_ignores', is_flag=True, help='Remove ignores for specified packages')
664
- @click.option('--remove-all', 'remove_all_ignores', is_flag=True, help='Remove all ignores from specified environment (or all environments if no --env specified)')
665
- @click.option('--yes', '-y', 'skip_confirmation', is_flag=True, help='Skip confirmation prompt')
666
- def ignore(package_names, env, list_ignores, remove_ignores, remove_all_ignores, skip_confirmation):
737
+ @click.option(
738
+ "--list", "-l",
739
+ "list_states_flag",
740
+ is_flag=True,
741
+ help="List all saved rollback states"
742
+ )
743
+ @click.option(
744
+ "--dry-run",
745
+ is_flag=True,
746
+ help="Show what would be restored without actually restoring"
747
+ )
748
+ @click.option(
749
+ "--yes", "-y",
750
+ is_flag=True,
751
+ help="Automatically confirm rollback without prompting"
752
+ )
753
+ @click.option(
754
+ "--state",
755
+ type=str,
756
+ default=None,
757
+ help="Specific state file to rollback to (use --list to see available states)"
758
+ )
759
+ def rollback(list_states_flag: bool, dry_run: bool, yes: bool, state: Optional[str]) -> None:
667
760
  """
668
- Add or remove package ignores in pip configuration
669
-
670
- Manages packages that should be ignored during update operations.
671
- Ignored packages will be skipped when checking for outdated packages.
672
-
673
- \b
674
- Examples:
675
- pipu ignore requests numpy
676
- pipu ignore flask --env production
677
- pipu ignore --list
678
- pipu ignore --list --env production
679
- pipu ignore --remove requests numpy
680
- pipu ignore --remove flask --env production
681
- pipu ignore --remove-all --env production
682
- pipu ignore --remove-all --yes
683
-
684
- \f
685
- :param package_names: One or more package names to ignore or remove
686
- :param env: Target environment section name
687
- :param list_ignores: List existing ignores instead of adding new ones
688
- :param remove_ignores: Remove ignores for specified packages
689
- :param remove_all_ignores: Remove all ignores from environment(s)
690
- :param skip_confirmation: Skip confirmation prompt for --remove-all
691
- :raises SystemExit: Exits with code 1 if an error occurs
761
+ Restore packages to a previous state.
762
+
763
+ Before each upgrade, pipu saves the current package versions. Use this
764
+ command to restore packages to their pre-upgrade state.
765
+
766
+ [bold]Examples:[/bold]
767
+ pipu rollback --list List all saved states
768
+ pipu rollback --dry-run Preview what would be restored
769
+ pipu rollback --yes Rollback without confirmation
770
+ pipu rollback --state FILE Rollback to a specific state
692
771
  """
693
- try:
694
- # Validate mutually exclusive options
695
- active_options = [list_ignores, remove_ignores, remove_all_ignores, bool(package_names and not (list_ignores or remove_ignores or remove_all_ignores))]
696
- if sum(active_options) > 1:
697
- console.print("[red]Error: Cannot use --list, --remove, --remove-all, and package names together. Use only one at a time.[/red]")
772
+ from pipu_cli.rollback import get_latest_state, rollback_to_state, list_states as get_states, ROLLBACK_DIR
773
+
774
+ console = Console()
775
+
776
+ # List saved states if requested
777
+ if list_states_flag:
778
+ states = get_states()
779
+ if not states:
780
+ console.print("[yellow]No saved states found.[/yellow]")
781
+ console.print(f"[dim]States are saved in: {ROLLBACK_DIR}[/dim]")
782
+ sys.exit(0)
783
+
784
+ table = Table(title="[bold]Saved Rollback States[/bold]")
785
+ table.add_column("#", style="dim", width=3)
786
+ table.add_column("State File", style="cyan")
787
+ table.add_column("Timestamp", style="green")
788
+ table.add_column("Packages", style="magenta", justify="right")
789
+ table.add_column("Description", style="dim")
790
+
791
+ for idx, s in enumerate(states, 1):
792
+ ts = s["timestamp"]
793
+ if len(ts) == 15 and ts[8] == "_":
794
+ formatted_ts = f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:15]}"
795
+ else:
796
+ formatted_ts = ts
797
+
798
+ table.add_row(
799
+ str(idx),
800
+ s["file"],
801
+ formatted_ts,
802
+ str(s["package_count"]),
803
+ s["description"] or "-"
804
+ )
805
+
806
+ console.print(table)
807
+ console.print(f"\n[dim]States saved in: {ROLLBACK_DIR}[/dim]")
808
+ console.print("[dim]Use --state <filename> to rollback to a specific state[/dim]")
809
+ sys.exit(0)
810
+
811
+ # Get the state to rollback to
812
+ if state:
813
+ state_path = ROLLBACK_DIR / state
814
+ if not state_path.exists():
815
+ console.print(f"[red]State file not found:[/red] {state}")
816
+ console.print("[dim]Use 'pipu rollback --list' to see available states[/dim]")
698
817
  sys.exit(1)
699
818
 
700
- # Handle --list option
701
- if list_ignores:
702
- from .package_constraints import list_all_ignores
819
+ with open(state_path, 'r') as f:
820
+ state_data = json.load(f)
821
+ else:
822
+ state_data = get_latest_state()
703
823
 
704
- console.print("[bold blue]Listing ignores from pip configuration...[/bold blue]")
824
+ if state_data is None:
825
+ console.print("[yellow]No saved state found.[/yellow]")
826
+ console.print("[dim]A state is automatically saved before each upgrade.[/dim]")
827
+ sys.exit(0)
705
828
 
706
- all_ignores = list_all_ignores(env)
829
+ # Show what will be rolled back
830
+ packages = state_data.get("packages", [])
831
+ timestamp = state_data.get("timestamp", "unknown")
832
+ description = state_data.get("description", "")
707
833
 
708
- if not all_ignores:
709
- if env:
710
- console.print(f"[yellow]No ignores found for environment '[bold]{env}[/bold]'.[/yellow]")
711
- else:
712
- console.print("[yellow]No ignores found in any environment.[/yellow]")
713
- return
714
-
715
- # Display ignores
716
- for env_name, ignores in all_ignores.items():
717
- console.print(f"\n[bold cyan]Environment: {env_name}[/bold cyan]")
718
- if ignores:
719
- for package in sorted(ignores):
720
- console.print(f" {package}")
721
- else:
722
- console.print(" [dim]No ignores[/dim]")
834
+ if len(timestamp) == 15 and timestamp[8] == "_":
835
+ formatted_ts = f"{timestamp[:4]}-{timestamp[4:6]}-{timestamp[6:8]} {timestamp[9:11]}:{timestamp[11:13]}:{timestamp[13:15]}"
836
+ else:
837
+ formatted_ts = timestamp
723
838
 
724
- return
839
+ console.print(f"\n[bold]Rollback State:[/bold] {formatted_ts}")
840
+ if description:
841
+ console.print(f"[dim]{description}[/dim]")
842
+ console.print()
725
843
 
726
- # Handle --remove option
727
- if remove_ignores:
728
- if not package_names:
729
- console.print("[red]Error: At least one package name must be specified for removal.[/red]")
730
- sys.exit(1)
844
+ table = Table(title=f"[bold]{len(packages)} Package(s) to Restore[/bold]")
845
+ table.add_column("Package", style="cyan")
846
+ table.add_column("Version", style="green")
731
847
 
732
- from .package_constraints import remove_ignores_from_config
848
+ for pkg in packages:
849
+ table.add_row(pkg["name"], pkg["version"])
733
850
 
734
- packages_list = list(package_names)
735
- console.print(f"[bold blue]Removing ignores for {len(packages_list)} package(s)...[/bold blue]")
851
+ console.print(table)
736
852
 
737
- try:
738
- config_path, removed_packages = remove_ignores_from_config(packages_list, env)
853
+ if dry_run:
854
+ console.print("\n[bold cyan]Dry run complete.[/bold cyan] No packages were modified.")
855
+ sys.exit(0)
739
856
 
740
- if not removed_packages:
741
- console.print("[yellow]No ignores were removed (packages not found in ignores).[/yellow]")
742
- return
857
+ if not yes:
858
+ console.print()
859
+ confirm = click.confirm("Do you want to proceed with the rollback?", default=True)
860
+ if not confirm:
861
+ console.print("[yellow]Rollback cancelled.[/yellow]")
862
+ sys.exit(0)
743
863
 
744
- # Display summary
745
- console.print("\n[bold green]✓ Ignores removed successfully![/bold green]")
746
- console.print(f"[bold]File:[/bold] {config_path}")
864
+ console.print("\n[bold]Rolling back packages...[/bold]\n")
747
865
 
748
- # Show removed ignores
749
- console.print("\n[bold]Ignores removed:[/bold]")
750
- for package in removed_packages:
751
- console.print(f" [red]Removed[/red]: {package}")
866
+ rolled_back = rollback_to_state(state_data, dry_run=False)
752
867
 
753
- # Show which environment was updated
754
- if env:
755
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
756
- else:
757
- from .package_constraints import get_current_environment_name
758
- current_env = get_current_environment_name()
759
- if current_env and current_env != "global":
760
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
761
- else:
762
- console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
763
-
764
- return
765
-
766
- except ValueError as e:
767
- console.print(f"[red]Error: {e}[/red]")
768
- sys.exit(1)
769
- except IOError as e:
770
- console.print(f"[red]Error writing configuration: {e}[/red]")
771
- sys.exit(1)
772
-
773
- # Handle --remove-all option
774
- if remove_all_ignores:
775
- from .package_constraints import remove_all_ignores_from_config, list_all_ignores
776
-
777
- # Get confirmation if not using --yes and removing from all environments
778
- if not env and not skip_confirmation:
779
- # Show what will be removed
780
- try:
781
- all_ignores = list_all_ignores()
782
- if not all_ignores:
783
- console.print("[yellow]No ignores found in any environment.[/yellow]")
784
- return
785
-
786
- console.print("[bold red]WARNING: This will remove ALL ignores from ALL environments![/bold red]")
787
- console.print("\n[bold]Ignores that will be removed:[/bold]")
788
-
789
- total_ignores = 0
790
- for env_name, ignores in all_ignores.items():
791
- console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
792
- for package in sorted(ignores):
793
- console.print(f" {package}")
794
- total_ignores += 1
795
-
796
- console.print(f"\n[bold]Total: {total_ignores} ignore(s) in {len(all_ignores)} environment(s)[/bold]")
797
-
798
- if not click.confirm("\nAre you sure you want to remove all ignores?"):
799
- console.print("[yellow]Operation cancelled.[/yellow]")
800
- return
801
-
802
- except Exception:
803
- # If we can't list ignores, ask for generic confirmation
804
- if not click.confirm("Are you sure you want to remove all ignores from all environments?"):
805
- console.print("[yellow]Operation cancelled.[/yellow]")
806
- return
807
-
808
- console.print(f"[bold blue]Removing all ignores from {'all environments' if not env else env}...[/bold blue]")
809
-
810
- try:
811
- config_path, removed_ignores = remove_all_ignores_from_config(env)
812
-
813
- if not removed_ignores:
814
- if env:
815
- console.print(f"[yellow]No ignores found in environment '{env}'.[/yellow]")
816
- else:
817
- console.print("[yellow]No ignores found in any environment.[/yellow]")
818
- return
819
-
820
- # Display summary
821
- console.print("\n[bold green]✓ All ignores removed successfully![/bold green]")
822
- console.print(f"[bold]File:[/bold] {config_path}")
823
-
824
- # Show removed ignores
825
- console.print("\n[bold]Ignores removed:[/bold]")
826
- total_removed = 0
827
- for env_name, ignores in removed_ignores.items():
828
- console.print(f"\n[bold cyan]{env_name}:[/bold cyan]")
829
- for package in sorted(ignores):
830
- console.print(f" [red]Removed[/red]: {package}")
831
- total_removed += 1
832
-
833
- console.print(f"\n[bold]Total removed: {total_removed} ignore(s) from {len(removed_ignores)} environment(s)[/bold]")
834
-
835
- # Show which environment(s) were updated
836
- if env:
837
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
838
- else:
839
- environments_updated = list(removed_ignores.keys())
840
- console.print(f"\n[bold cyan]Environments updated:[/bold cyan] {', '.join(sorted(environments_updated))}")
868
+ if rolled_back:
869
+ console.print(f"\n[bold green]Successfully rolled back {len(rolled_back)} package(s):[/bold green]")
870
+ for pkg in rolled_back:
871
+ console.print(f" - {pkg}")
872
+ else:
873
+ console.print("[yellow]No packages were rolled back.[/yellow]")
841
874
 
842
- return
875
+ sys.exit(0)
843
876
 
844
- except ValueError as e:
845
- console.print(f"[red]Error: {e}[/red]")
846
- sys.exit(1)
847
- except IOError as e:
848
- console.print(f"[red]Error writing configuration: {e}[/red]")
849
- sys.exit(1)
850
877
 
851
- # Handle adding ignores (default behavior)
852
- if not package_names:
853
- console.print("[red]Error: At least one package name must be specified.[/red]")
854
- sys.exit(1)
878
+ @cli.command()
879
+ def cache() -> None:
880
+ """
881
+ Show cache information.
855
882
 
856
- from .package_constraints import add_ignores_to_config, get_current_environment_name
883
+ Displays details about the package version cache for the current
884
+ Python environment, including age and freshness status.
857
885
 
858
- packages_list = list(package_names)
859
- console.print("[bold blue]Adding ignores to pip configuration...[/bold blue]")
886
+ [bold]Examples:[/bold]
887
+ pipu cache Show cache status
888
+ pipu clean Clear current environment cache
889
+ pipu clean --all Clear all environment caches
890
+ """
891
+ console = Console()
860
892
 
861
- # Get the recommended config file path early for error handling
862
- from .package_constraints import get_recommended_pip_config_path
863
- config_path = get_recommended_pip_config_path()
893
+ # Show cache info
894
+ info = get_cache_info()
864
895
 
865
- try:
866
- config_path, changes = add_ignores_to_config(packages_list, env)
896
+ console.print("[bold]Cache Information[/bold]\n")
897
+ console.print(f" Environment ID: [cyan]{info['environment_id']}[/cyan]")
898
+ console.print(f" Python: [dim]{info['python_executable']}[/dim]")
899
+ console.print(f" Cache path: [dim]{info['path']}[/dim]")
867
900
 
868
- if not any(action == 'added' for action in changes.values()):
869
- console.print("[yellow]No changes made - all packages are already ignored.[/yellow]")
870
- return
901
+ if info['exists']:
902
+ console.print("\n [green]Cache exists[/green]")
903
+ console.print(f" Updated: {info.get('age_human', 'unknown')}")
904
+ console.print(f" Packages cached: {info.get('package_count', 0)}")
871
905
 
872
- # Display summary
873
- console.print("\n[bold green]✓ Configuration updated successfully![/bold green]")
874
- console.print(f"[bold]File:[/bold] {config_path}")
906
+ # Check if fresh
907
+ if is_cache_fresh():
908
+ console.print(" Status: [green]Fresh[/green] (within TTL)")
909
+ else:
910
+ console.print(" Status: [yellow]Stale[/yellow] (will refresh on next upgrade)")
911
+ else:
912
+ console.print("\n [yellow]No cache[/yellow]")
913
+ console.print(" Run [cyan]pipu update[/cyan] to create cache")
875
914
 
876
- # Show changes
877
- console.print("\n[bold]Ignores modified:[/bold]")
878
- for package, action in changes.items():
879
- if action == 'added':
880
- console.print(f" [green]Added[/green]: {package}")
881
- elif action == 'already_exists':
882
- console.print(f" [yellow]Already ignored[/yellow]: {package}")
915
+ sys.exit(0)
883
916
 
884
- # Show which environment was updated
885
- if env:
886
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {env}")
887
- else:
888
- current_env = get_current_environment_name()
889
- if current_env and current_env != "global":
890
- console.print(f"\n[bold cyan]Environment updated:[/bold cyan] {current_env}")
891
- else:
892
- console.print("\n[bold cyan]Environment updated:[/bold cyan] global")
893
917
 
894
- except IOError as e:
895
- console.print(f"[red]Error writing configuration: {e}[/red]")
896
- sys.exit(1)
918
+ @cli.command()
919
+ @click.option(
920
+ "--all", "-a",
921
+ "clean_all",
922
+ is_flag=True,
923
+ help="Clean up files for all environments"
924
+ )
925
+ def clean(clean_all: bool) -> None:
926
+ """
927
+ Clean up pipu caches and temporary files.
897
928
 
898
- except Exception as e:
899
- console.print(f"[red]Unexpected error: {e}[/red]")
900
- sys.exit(1)
929
+ Removes cached package version data and other temporary files
930
+ created by pipu. By default, cleans up files for the current
931
+ Python environment only.
932
+
933
+ [bold]Examples:[/bold]
934
+ pipu clean Clean current environment
935
+ pipu clean --all Clean all environments
936
+ """
937
+ console = Console()
938
+
939
+ if clean_all:
940
+ count = clear_all_caches()
941
+ if count > 0:
942
+ console.print(f"[bold green]Cleared {count} cache(s).[/bold green]")
943
+ else:
944
+ console.print("[yellow]No caches to clear.[/yellow]")
945
+ else:
946
+ if clear_cache():
947
+ console.print("[bold green]Cache cleared for current environment.[/bold green]")
948
+ else:
949
+ console.print("[yellow]No cache to clear for current environment.[/yellow]")
950
+
951
+ sys.exit(0)
901
952
 
902
953
 
903
- # This allows the module to be run with: python -m pipu_cli.cli
904
- if __name__ == '__main__':
954
+ if __name__ == "__main__":
905
955
  cli()