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