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/internals.py DELETED
@@ -1,819 +0,0 @@
1
- from pip._internal.metadata import get_default_environment
2
- from pip._internal.index.package_finder import PackageFinder
3
- from pip._internal.index.collector import LinkCollector
4
- from pip._internal.models.search_scope import SearchScope
5
- from pip._internal.network.session import PipSession
6
- from pip._internal.models.selection_prefs import SelectionPreferences
7
- from pip._internal.configuration import Configuration
8
- from rich.console import Console
9
- from rich.table import Table
10
- from typing import List, Dict, Any, Optional, Union, Set, Callable, Tuple
11
- from packaging.version import Version, InvalidVersion
12
- from packaging.specifiers import SpecifierSet, InvalidSpecifier
13
- import logging
14
- import time
15
- import threading
16
- from queue import Queue, Empty
17
-
18
- # Import configuration
19
- from .config import (
20
- DEFAULT_NETWORK_TIMEOUT,
21
- DEFAULT_NETWORK_RETRIES,
22
- MAX_CONSECUTIVE_NETWORK_ERRORS,
23
- RETRY_DELAY,
24
- SUBPROCESS_TIMEOUT
25
- )
26
- from .thread_safe import ThreadSafeCache
27
-
28
- # Set up module logger
29
- logger = logging.getLogger(__name__)
30
-
31
-
32
- def _call_with_timeout(func: Callable, timeout: float, *args, **kwargs):
33
- """
34
- Execute a function with a hard timeout by running it in a daemon thread.
35
-
36
- :param func: Function to execute
37
- :param timeout: Timeout in seconds
38
- :param args: Positional arguments for the function
39
- :param kwargs: Keyword arguments for the function
40
- :returns: Result from the function
41
- :raises TimeoutError: If the function doesn't complete within timeout
42
- :raises RuntimeError: If the function completes but produces no result
43
- """
44
- result_queue = Queue()
45
- exception_queue = Queue()
46
- completed = threading.Event()
47
-
48
- def wrapper():
49
- try:
50
- result = func(*args, **kwargs)
51
- result_queue.put(result)
52
- except Exception as e:
53
- exception_queue.put(e)
54
- finally:
55
- completed.set()
56
-
57
- thread = threading.Thread(target=wrapper, daemon=True)
58
- thread.start()
59
- thread.join(timeout=timeout)
60
-
61
- # Check if the function completed
62
- if not completed.is_set():
63
- # Thread is still running - timeout occurred
64
- raise TimeoutError(f"Operation timed out after {timeout} seconds")
65
-
66
- # Check if an exception was raised (check this first)
67
- try:
68
- exception = exception_queue.get_nowait()
69
- raise exception
70
- except Empty:
71
- pass
72
-
73
- # Get the result
74
- try:
75
- return result_queue.get_nowait()
76
- except Empty:
77
- raise RuntimeError("Function completed but produced no result")
78
-
79
-
80
- def _check_constraint_satisfaction(version: str, constraint: str) -> bool:
81
- """
82
- Check if a version satisfies the given constraint.
83
-
84
- :param version: Version string to check
85
- :param constraint: Constraint specification (e.g., ">=1.0.0,<2.0.0")
86
- :returns: True if version satisfies constraint, False otherwise
87
- """
88
- try:
89
- pkg_version = Version(version)
90
- specifier_set = SpecifierSet(constraint)
91
- return pkg_version in specifier_set
92
- except (InvalidVersion, InvalidSpecifier):
93
- # If we can't parse the version or constraint, assume it doesn't satisfy
94
- return False
95
-
96
-
97
- def get_constraint_color(version: str, constraint: Optional[str]) -> str:
98
- """
99
- Get the appropriate color for displaying a version based on constraint satisfaction.
100
-
101
- :param version: Version string to check
102
- :param constraint: Optional constraint specification
103
- :returns: Color name ("green" if satisfied/no constraint, "red" if violated)
104
- """
105
- if not constraint:
106
- return "green"
107
-
108
- return "green" if _check_constraint_satisfaction(version, constraint) else "red"
109
-
110
-
111
- def format_invalid_when_display(invalid_when: Optional[str]) -> str:
112
- """
113
- Format 'Invalid When' trigger list for display with appropriate color coding.
114
-
115
- :param invalid_when: Comma-separated list of trigger packages or None
116
- :returns: Formatted string with yellow color markup or dim dash
117
- """
118
- if invalid_when:
119
- return f"[yellow]{invalid_when}[/yellow]"
120
- else:
121
- return "[dim]-[/dim]"
122
-
123
-
124
- def _format_constraint_for_display(constraint: Optional[str], latest_version: str) -> str:
125
- """
126
- Format constraint for display with appropriate color coding.
127
-
128
- :param constraint: Constraint specification or None
129
- :param latest_version: The latest available version
130
- :returns: Formatted constraint string with color markup
131
- """
132
- if not constraint:
133
- return "[dim]-[/dim]"
134
-
135
- # Check if the latest version satisfies the constraint
136
- try:
137
- satisfies = _check_constraint_satisfaction(latest_version, constraint)
138
- if satisfies:
139
- return f"[green]{constraint}[/green]"
140
- else:
141
- return f"[red]{constraint}[/red]"
142
- except Exception as e:
143
- logger.debug(f"Error checking constraint satisfaction for {constraint}: {e}")
144
- return f"[yellow]{constraint}[/yellow]"
145
-
146
-
147
- def list_outdated(
148
- console: Optional[Console] = None,
149
- print_table: bool = True,
150
- constraints: Optional[Dict[str, str]] = None,
151
- ignores: Optional[Union[List[str], Set[str]]] = None,
152
- pre: bool = False,
153
- progress_callback: Optional[Callable[[str], None]] = None,
154
- result_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
155
- timeout: int = DEFAULT_NETWORK_TIMEOUT,
156
- retries: int = DEFAULT_NETWORK_RETRIES,
157
- cancel_event: Optional[Any] = None,
158
- invalidation_triggers: Optional[Dict[str, List[str]]] = None
159
- ) -> List[Dict[str, Any]]:
160
- """
161
- Check for outdated packages and optionally print results.
162
-
163
- Queries the configured package indexes to find packages that have newer versions
164
- available than what is currently installed. Respects pip configuration for
165
- index URLs and trusted hosts. Filters out packages that would violate constraints
166
- if updated to the latest version and excludes ignored packages entirely.
167
-
168
- :param console: Rich console object for output. If None, creates a new one.
169
- :param print_table: Whether to print the results table. Defaults to True.
170
- :param constraints: Dictionary mapping package names to version constraints.
171
- If None, no constraint filtering is applied.
172
- :param ignores: List of package names to ignore completely. If None, no packages are ignored.
173
- :param pre: Include pre-release versions. Defaults to False.
174
- :param progress_callback: Optional callback function to receive progress updates. Called with package name being checked.
175
- :param result_callback: Optional callback function to receive individual package results as they're processed.
176
- :param timeout: Network timeout in seconds for checking each package. Defaults to 10 seconds.
177
- :param retries: Number of retries before raising an error on network failure. Defaults to 0.
178
- :param cancel_event: Optional threading.Event to signal cancellation. If set, the function will exit early.
179
- :param invalidation_triggers: Optional dictionary mapping package names to lists of trigger package names.
180
- :returns: List of dictionaries containing outdated package information.
181
- Each dict has keys: name, version, latest_version, latest_filetype, constraint, invalid_when, editable
182
- :raises ConnectionError: If network errors occur after all retries are exhausted
183
- :raises Exception: May raise exceptions from pip internals during package discovery
184
- """
185
- if console is None:
186
- console = Console(width=120)
187
-
188
- # Get installed packages using importlib.metadata
189
- env = get_default_environment()
190
- installed_dists = env.iter_all_distributions()
191
-
192
- # Read pip configuration to get index URLs
193
- try:
194
- config = Configuration(isolated=False, load_only=None)
195
- config.load()
196
- except Exception:
197
- # If we can't load configuration (permissions, malformed config, etc.), use defaults
198
- config = None
199
-
200
- # Get index URLs from configuration safely
201
- try:
202
- index_url = config.get_value("global.index-url") if config else None
203
- except Exception:
204
- index_url = None
205
- index_url = index_url or "https://pypi.org/simple/"
206
-
207
- try:
208
- extra_index_urls = config.get_value("global.extra-index-url") if config else []
209
- except Exception:
210
- extra_index_urls = []
211
- extra_index_urls = extra_index_urls or []
212
-
213
- # Parse extra_index_urls - pip config returns multi-line values as a single string
214
- if isinstance(extra_index_urls, str):
215
- # Split by newlines and clean up each URL (remove comments and whitespace)
216
- extra_index_urls = [
217
- url.strip()
218
- for url in extra_index_urls.split('\n')
219
- if url.strip() and not url.strip().startswith('#')
220
- ]
221
- elif not isinstance(extra_index_urls, list):
222
- extra_index_urls = []
223
-
224
- # Combine all index URLs
225
- all_index_urls = [index_url] + extra_index_urls
226
-
227
- # Get trusted hosts from configuration safely
228
- try:
229
- trusted_hosts = config.get_value("global.trusted-host") if config else []
230
- except Exception:
231
- trusted_hosts = []
232
- trusted_hosts = trusted_hosts or []
233
-
234
- # Parse trusted_hosts - pip config returns multi-line values as a single string
235
- if isinstance(trusted_hosts, str):
236
- # Split by newlines and clean up each host (remove comments and whitespace)
237
- trusted_hosts = [
238
- host.strip()
239
- for host in trusted_hosts.split('\n')
240
- if host.strip() and not host.strip().startswith('#')
241
- ]
242
- elif not isinstance(trusted_hosts, list):
243
- trusted_hosts = []
244
-
245
- # Set up pip session and package finder to check for updates
246
- try:
247
- session = PipSession()
248
- # Set timeout on the session
249
- session.timeout = timeout
250
-
251
- # Add trusted hosts to the session
252
- for host in trusted_hosts:
253
- # Strip whitespace and skip empty strings
254
- host = host.strip()
255
- if host:
256
- session.add_trusted_host(host, source="pip configuration")
257
- except Exception as e:
258
- # If we can't create a session (network issues, permissions, etc.), raise error
259
- raise ConnectionError(f"Failed to create network session: {e}") from e
260
-
261
- selection_prefs = SelectionPreferences(allow_yanked=False)
262
-
263
- search_scope = SearchScope.create(
264
- find_links=[],
265
- index_urls=all_index_urls,
266
- no_index=False
267
- )
268
-
269
- link_collector = LinkCollector(
270
- session=session,
271
- search_scope=search_scope
272
- )
273
-
274
- package_finder = PackageFinder.create(
275
- link_collector=link_collector,
276
- selection_prefs=selection_prefs
277
- )
278
-
279
- # Use provided constraints or default to empty dict
280
- if constraints is None:
281
- constraints = {}
282
-
283
- # Use provided ignores or default to empty set
284
- if ignores is None:
285
- ignores = set()
286
- # Normalize ignores to lowercase for case-insensitive matching and ensure O(1) lookups
287
- ignores_lower = {pkg.lower() for pkg in ignores}
288
-
289
- # Use provided invalidation triggers or default to empty dict
290
- if invalidation_triggers is None:
291
- invalidation_triggers = {}
292
-
293
- # Detect editable packages for preservation during updates
294
- editable_packages = get_editable_packages()
295
-
296
- outdated_packages = []
297
-
298
- # Track consecutive network errors to fail fast
299
- consecutive_network_errors = 0
300
-
301
- # Show spinner while checking for updates
302
- with console.status("[bold green]Checking for package updates...") as status:
303
- for dist in installed_dists:
304
- # Check for cancellation at the start of each iteration
305
- if cancel_event and cancel_event.is_set():
306
- logger.info("Package check cancelled by user")
307
- break
308
-
309
- try:
310
- package_name = dist.metadata["name"]
311
- package_name_lower = package_name.lower()
312
-
313
- # Normalize package name for constraint lookups
314
- from packaging.utils import canonicalize_name
315
- package_name_canonical = canonicalize_name(package_name)
316
-
317
- # Skip ignored packages completely
318
- if package_name_lower in ignores_lower:
319
- continue
320
-
321
- # Update status with current package being checked
322
- status.update(f"[bold green]Checking {package_name}...")
323
-
324
- # Call progress callback if provided (stop status temporarily to avoid conflicts)
325
- if progress_callback:
326
- status.stop()
327
- progress_callback(package_name)
328
- status.start()
329
-
330
- # Find the best candidate (latest version) with retry logic
331
- candidates = None
332
- last_error = None
333
- for attempt in range(retries + 1):
334
- try:
335
- # Use hard timeout wrapper to ensure we don't hang
336
- logger.debug(f"About to check {package_name} with {timeout}s timeout (attempt {attempt + 1}/{retries + 1})")
337
- candidates = _call_with_timeout(
338
- package_finder.find_all_candidates,
339
- timeout,
340
- dist.canonical_name
341
- )
342
- logger.debug(f"Successfully retrieved candidates for {package_name}")
343
- # Success - reset consecutive error counter
344
- consecutive_network_errors = 0
345
- break
346
- except (TimeoutError, Exception) as e:
347
- logger.debug(f"Error checking {package_name}: {type(e).__name__}: {e}")
348
- last_error = e
349
- # Check if it's a network-related error (TimeoutError always is)
350
- error_str = str(e).lower()
351
- is_network_error = isinstance(e, TimeoutError) or any(keyword in error_str for keyword in [
352
- 'connection', 'timeout', 'network', 'unreachable',
353
- 'proxy', 'ssl', 'certificate', 'dns', 'resolve'
354
- ])
355
-
356
- if is_network_error:
357
- consecutive_network_errors += 1
358
- if consecutive_network_errors >= MAX_CONSECUTIVE_NETWORK_ERRORS:
359
- # Too many consecutive failures - raise error
360
- raise ConnectionError(
361
- f"Network connectivity issues detected after checking {package_name}. "
362
- f"Failed to reach package index. Please check your network connection "
363
- f"and proxy settings (HTTP_PROXY, HTTPS_PROXY)."
364
- ) from e
365
-
366
- # Not the last retry - wait briefly before retrying
367
- if attempt < retries:
368
- time.sleep(RETRY_DELAY)
369
- continue
370
-
371
- # Non-network error or last retry - skip this package
372
- if attempt >= retries:
373
- logger.debug(f"Failed to check {package_name} after {retries + 1} attempts: {e}")
374
- break
375
-
376
- if candidates is None:
377
- # All retries exhausted, skip this package
378
- continue
379
-
380
- if candidates:
381
- # Filter candidates based on pre-release preference
382
- if not pre:
383
- # Exclude pre-release versions (alpha, beta, dev, rc)
384
- stable_candidates = []
385
- for candidate in candidates:
386
- try:
387
- version_obj = Version(str(candidate.version))
388
- if not version_obj.is_prerelease:
389
- stable_candidates.append(candidate)
390
- except InvalidVersion:
391
- # If version parsing fails, skip this candidate
392
- continue
393
- # Use stable candidates if available, otherwise fall back to all candidates
394
- candidates_to_check = stable_candidates if stable_candidates else candidates
395
- else:
396
- candidates_to_check = candidates
397
-
398
- # Get the latest version from filtered candidates
399
- if candidates_to_check:
400
- latest_candidate = max(candidates_to_check, key=lambda c: c.version)
401
- latest_version = str(latest_candidate.version)
402
- current_version = str(dist.version)
403
-
404
- # Determine the actual file type of the latest candidate
405
- file_type = "unknown"
406
- if hasattr(latest_candidate, 'link') and latest_candidate.link:
407
- filename = latest_candidate.link.filename
408
- if filename.endswith('.whl'):
409
- file_type = "wheel"
410
- elif filename.endswith(('.tar.gz', '.zip')):
411
- file_type = "sdist"
412
- elif filename.endswith('.egg'):
413
- file_type = "egg"
414
- else:
415
- # Extract file extension for other types
416
- if '.' in filename:
417
- file_type = filename.split('.')[-1]
418
- else:
419
- file_type = "unknown"
420
- else:
421
- # Fallback to wheel if no link information available
422
- file_type = "wheel"
423
-
424
- if latest_version != current_version:
425
- # Check if there's a constraint for this package
426
- constraint = constraints.get(package_name_canonical)
427
-
428
- # Include all outdated packages - constraints will be shown in the table
429
- # with appropriate color coding (red=violating, green=satisfying)
430
- # Get invalidation triggers for this package
431
- package_triggers = invalidation_triggers.get(package_name_canonical, [])
432
- invalid_when_display = ", ".join(package_triggers) if package_triggers else None
433
-
434
- package_result = {
435
- "name": package_name,
436
- "version": current_version,
437
- "latest_version": latest_version,
438
- "latest_filetype": file_type,
439
- "constraint": constraint,
440
- "invalid_when": invalid_when_display,
441
- "editable": package_name_canonical in editable_packages
442
- }
443
- outdated_packages.append(package_result)
444
-
445
- # Call result callback with individual package result
446
- if result_callback:
447
- status.stop()
448
- result_callback(package_result)
449
- status.start()
450
- else:
451
- # Package is up to date - call callback with current info
452
- if result_callback:
453
- constraint = constraints.get(package_name_canonical)
454
- up_to_date_result = {
455
- "name": package_name,
456
- "version": current_version,
457
- "latest_version": current_version,
458
- "latest_filetype": file_type,
459
- "constraint": constraint,
460
- "editable": package_name_canonical in editable_packages
461
- }
462
- status.stop()
463
- result_callback(up_to_date_result)
464
- status.start()
465
-
466
- except ConnectionError:
467
- # Re-raise ConnectionError so it propagates to the caller
468
- raise
469
- except Exception:
470
- # Skip packages that can't be checked
471
- continue
472
-
473
- # Sort packages alphabetically by name
474
- outdated_packages.sort(key=lambda x: x["name"].lower())
475
-
476
- # Print results if requested
477
- if print_table:
478
- if not outdated_packages:
479
- console.print("[green]All packages are up to date![/green]")
480
- else:
481
- # Create a rich table matching TUI styling
482
- table = Table(title="Outdated Packages")
483
- table.add_column("", width=3) # Selection indicator column
484
- table.add_column("Package", style="cyan", no_wrap=True)
485
- table.add_column("Version", style="magenta")
486
- table.add_column("Latest", no_wrap=True) # Color conditionally per row
487
- table.add_column("Type", style="yellow")
488
- table.add_column("Constraint", no_wrap=True)
489
- table.add_column("Invalid When", no_wrap=True)
490
-
491
- for package in outdated_packages:
492
- constraint = package.get("constraint")
493
- latest_version = package["latest_version"]
494
-
495
- # Determine if package will be updated (same logic as TUI)
496
- if constraint:
497
- will_update = _check_constraint_satisfaction(latest_version, constraint)
498
- else:
499
- will_update = True
500
-
501
- # Show indicator: ✓ for packages that will be updated
502
- if will_update:
503
- indicator = "[bold green]✓[/bold green]"
504
- else:
505
- indicator = "[dim]✗[/dim]"
506
-
507
- # Format latest version with conditional coloring (matching TUI)
508
- color = get_constraint_color(latest_version, constraint)
509
- latest_display = f"[{color}]{latest_version}[/{color}]"
510
-
511
- # Format constraint display
512
- constraint_display = _format_constraint_for_display(constraint, latest_version)
513
-
514
- # Format invalid when display (matching TUI)
515
- invalid_when = package.get("invalid_when")
516
- invalid_when_display = format_invalid_when_display(invalid_when)
517
-
518
- table.add_row(
519
- indicator,
520
- package["name"],
521
- package["version"],
522
- latest_display,
523
- package["latest_filetype"],
524
- constraint_display,
525
- invalid_when_display
526
- )
527
-
528
- console.print(table)
529
-
530
- # Print legend explaining the indicators
531
- console.print("\n[dim]Legend:[/dim]")
532
- console.print(" [bold green]✓[/bold green] = Will be updated | [dim]✗[/dim] = Blocked by constraint")
533
-
534
- return outdated_packages
535
-
536
-
537
- # Thread-safe cache for editable packages to avoid repeated subprocess calls
538
- from .config import EDITABLE_PACKAGES_CACHE_TTL
539
-
540
- # Initialize thread-safe cache
541
- _editable_packages_cache = ThreadSafeCache[Dict[str, str]](ttl=EDITABLE_PACKAGES_CACHE_TTL)
542
-
543
-
544
- def _fetch_editable_packages() -> Dict[str, str]:
545
- """
546
- Internal function to fetch editable packages from pip.
547
-
548
- This is the factory function used by the cache.
549
-
550
- :returns: Dictionary mapping package names (canonical) to their project locations
551
- :raises RuntimeError: If unable to query pip for editable packages
552
- """
553
- import subprocess
554
- import sys
555
- from packaging.utils import canonicalize_name
556
-
557
- editable_packages = {}
558
-
559
- try:
560
- # Use pip list --editable to get definitive list of editable packages
561
- result = subprocess.run([
562
- sys.executable, '-m', 'pip', 'list', '--editable'
563
- ], capture_output=True, text=True, check=True, timeout=SUBPROCESS_TIMEOUT)
564
-
565
- # Parse the output to get package names and locations
566
- lines = result.stdout.strip().split('\n')
567
-
568
- # Find the header line and skip it
569
- header_found = False
570
- for line in lines:
571
- line = line.strip()
572
- if not line:
573
- continue
574
-
575
- # Skip header lines (look for "Package" header or separator lines)
576
- if not header_found:
577
- if line.startswith('Package') or line.startswith('-'):
578
- header_found = True
579
- continue
580
-
581
- # Skip separator lines
582
- if line.startswith('-'):
583
- continue
584
-
585
- # Parse package lines: "package_name version /path/to/project"
586
- parts = line.split()
587
- if len(parts) >= 3:
588
- pkg_name = parts[0]
589
- # The location is everything from the 3rd part onwards (in case paths have spaces)
590
- location = ' '.join(parts[2:])
591
-
592
- # Canonicalize package name for consistent lookups
593
- canonical_name = canonicalize_name(pkg_name)
594
- editable_packages[canonical_name] = location
595
-
596
- except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
597
- logger.warning(f"Could not detect editable packages: {e}")
598
- # Return empty dict on error - caller can handle gracefully
599
- return {}
600
- except (OSError, ValueError) as e:
601
- logger.error(f"Unexpected error detecting editable packages: {e}")
602
- return {}
603
-
604
- return editable_packages
605
-
606
-
607
- def get_editable_packages() -> Dict[str, str]:
608
- """
609
- Detect packages installed in editable mode.
610
-
611
- Uses pip list --editable to get a definitive list of packages installed
612
- in development/editable mode. These packages maintain a live link to their
613
- source code directory and should be reinstalled with -e flag to preserve
614
- this behavior.
615
-
616
- Results are cached for 60 seconds to avoid repeated subprocess calls.
617
- The cache is thread-safe for concurrent access.
618
-
619
- :returns: Dictionary mapping package names (canonical) to their project locations
620
- """
621
- try:
622
- return _editable_packages_cache.get(_fetch_editable_packages).copy()
623
- except Exception as e:
624
- logger.warning(f"Failed to get editable packages: {e}")
625
- return {}
626
-
627
-
628
- def is_package_editable(package_name: str, editable_packages: Optional[Dict[str, str]] = None) -> bool:
629
- """
630
- Check if a package is installed in editable mode.
631
-
632
- :param package_name: Name of the package to check
633
- :param editable_packages: Optional cached dict of editable packages (from get_editable_packages)
634
- :returns: True if package is installed in editable mode, False otherwise
635
- """
636
- from packaging.utils import canonicalize_name
637
-
638
- if editable_packages is None:
639
- editable_packages = get_editable_packages()
640
-
641
- canonical_name = canonicalize_name(package_name)
642
- return canonical_name in editable_packages
643
-
644
-
645
- def update_packages_preserving_editable(
646
- packages_to_update: List[Dict[str, Any]],
647
- console: Optional[Console] = None,
648
- timeout: Optional[int] = None,
649
- cancel_event: Optional[Any] = None
650
- ) -> Tuple[List[str], List[str]]:
651
- """
652
- Update packages while preserving editable installations.
653
-
654
- For packages installed in editable mode, this function will:
655
- 1. Check if they're in editable mode
656
- 2. Use the original source directory with pip install -e
657
- 3. For regular packages, use normal pip install
658
-
659
- When updating packages, constraints for those packages are temporarily excluded
660
- to avoid conflicts between constraints and package dependencies.
661
-
662
- :param packages_to_update: List of package dictionaries with keys: name, latest_version, editable
663
- :param console: Optional Rich console for output
664
- :param timeout: Optional timeout for pip operations
665
- :param cancel_event: Optional threading.Event to signal cancellation
666
- :returns: Tuple of (successful_updates, failed_updates) with package names
667
- """
668
- import subprocess
669
- import sys
670
- import tempfile
671
- import os
672
- from packaging.utils import canonicalize_name
673
- from pathlib import Path
674
-
675
- if console is None:
676
- console = Console()
677
-
678
- successful_updates = []
679
- failed_updates = []
680
-
681
- # Get all current constraints and create a filtered version that excludes packages being updated
682
- from .package_constraints import read_constraints
683
- all_constraints = read_constraints()
684
-
685
- # Get canonical names of packages being updated
686
- packages_being_updated = {canonicalize_name(pkg["name"]) for pkg in packages_to_update}
687
-
688
- # Filter out constraints for packages being updated to avoid conflicts
689
- filtered_constraints = {
690
- pkg: constraint
691
- for pkg, constraint in all_constraints.items()
692
- if pkg not in packages_being_updated
693
- }
694
-
695
- # Create a temporary constraints file if there are any constraints to apply
696
- constraint_file = None
697
- constraint_file_path = None
698
- try:
699
- if filtered_constraints:
700
- constraint_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
701
- constraint_file_path = constraint_file.name
702
- for pkg, constraint in filtered_constraints.items():
703
- constraint_file.write(f"{pkg}{constraint}\n")
704
- constraint_file.close()
705
- console.print(f"[dim]Using filtered constraints (excluding {len(packages_being_updated)} package(s) being updated)[/dim]")
706
-
707
- # Helper function to run subprocess with cancellation support
708
- def run_with_cancel(cmd, timeout=None):
709
- """Run a subprocess command that can be cancelled."""
710
- # Set up environment with constraint file if available
711
- env = os.environ.copy()
712
- if constraint_file_path:
713
- env['PIP_CONSTRAINT'] = constraint_file_path
714
-
715
- process = subprocess.Popen(
716
- cmd,
717
- stdout=subprocess.PIPE,
718
- stderr=subprocess.PIPE,
719
- text=True,
720
- env=env
721
- )
722
-
723
- try:
724
- stdout, stderr = process.communicate(timeout=timeout)
725
- return process.returncode, stdout, stderr
726
- except subprocess.TimeoutExpired:
727
- process.kill()
728
- process.communicate() # Clean up
729
- raise
730
- except:
731
- # If we're interrupted or cancelled, kill the process
732
- if process.poll() is None: # Process still running
733
- process.kill()
734
- try:
735
- process.communicate(timeout=1)
736
- except:
737
- pass
738
- raise
739
-
740
- # Get current editable packages to find source directories
741
- editable_packages = get_editable_packages()
742
-
743
- for package_info in packages_to_update:
744
- # Check for cancellation before processing each package
745
- if cancel_event and cancel_event.is_set():
746
- console.print("[yellow]Update cancelled by user[/yellow]")
747
- break
748
-
749
- package_name = package_info["name"]
750
- latest_version = package_info.get("latest_version")
751
- is_editable = package_info.get("editable", False)
752
-
753
- try:
754
- console.print(f"Updating {package_name}...")
755
-
756
- if is_editable:
757
- # Package is editable, reinstall from source directory
758
- canonical_name = canonicalize_name(package_name)
759
- source_path = editable_packages.get(canonical_name)
760
-
761
- if source_path:
762
- console.print(f" 📝 Reinstalling editable package from: {source_path}")
763
-
764
- # First uninstall the current version
765
- uninstall_cmd = [sys.executable, "-m", "pip", "uninstall", package_name, "-y"]
766
- returncode, stdout, stderr = run_with_cancel(uninstall_cmd, timeout=timeout)
767
-
768
- if returncode != 0:
769
- console.print(f" [red]Failed to uninstall {package_name}: {stderr}[/red]")
770
- failed_updates.append(package_name)
771
- continue
772
-
773
- # Then reinstall in editable mode
774
- install_cmd = [sys.executable, "-m", "pip", "install", "-e", source_path]
775
- returncode, stdout, stderr = run_with_cancel(install_cmd, timeout=timeout)
776
-
777
- if returncode == 0:
778
- console.print(f" [green]✓ Successfully updated editable {package_name}[/green]")
779
- successful_updates.append(package_name)
780
- else:
781
- console.print(f" [red]Failed to reinstall editable {package_name}: {stderr}[/red]")
782
- failed_updates.append(package_name)
783
- else:
784
- console.print(f" [yellow]Could not find source path for editable {package_name}, updating normally[/yellow]")
785
- # Fall through to normal update
786
- is_editable = False
787
-
788
- if not is_editable:
789
- # Regular package update
790
- # Use --upgrade instead of pinning to specific versions to allow pip's
791
- # dependency resolver to handle interdependent packages correctly
792
- # (e.g., pydantic and pydantic-core, boto3 and botocore)
793
- install_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", package_name]
794
-
795
- returncode, stdout, stderr = run_with_cancel(install_cmd, timeout=timeout)
796
-
797
- if returncode == 0:
798
- console.print(f" [green]✓ Successfully updated {package_name}[/green]")
799
- successful_updates.append(package_name)
800
- else:
801
- console.print(f" [red]Failed to update {package_name}: {stderr}[/red]")
802
- failed_updates.append(package_name)
803
-
804
- except subprocess.TimeoutExpired:
805
- console.print(f" [red]Timeout updating {package_name}[/red]")
806
- failed_updates.append(package_name)
807
- except Exception as e:
808
- console.print(f" [red]Error updating {package_name}: {e}[/red]")
809
- failed_updates.append(package_name)
810
-
811
- return successful_updates, failed_updates
812
-
813
- finally:
814
- # Clean up temporary constraint file
815
- if constraint_file_path and os.path.exists(constraint_file_path):
816
- try:
817
- os.unlink(constraint_file_path)
818
- except Exception:
819
- pass # Best effort cleanup