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