pipu-cli 0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pipu_cli/internals.py ADDED
@@ -0,0 +1,750 @@
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
+ # Ensure extra_index_urls is a list
214
+ if isinstance(extra_index_urls, str):
215
+ extra_index_urls = [extra_index_urls]
216
+
217
+ # Combine all index URLs
218
+ all_index_urls = [index_url] + extra_index_urls
219
+
220
+ # Get trusted hosts from configuration safely
221
+ try:
222
+ trusted_hosts = config.get_value("global.trusted-host") if config else []
223
+ except Exception:
224
+ trusted_hosts = []
225
+ trusted_hosts = trusted_hosts or []
226
+ if isinstance(trusted_hosts, str):
227
+ trusted_hosts = [trusted_hosts]
228
+
229
+ # Set up pip session and package finder to check for updates
230
+ try:
231
+ session = PipSession()
232
+ # Set timeout on the session
233
+ session.timeout = timeout
234
+ except Exception as e:
235
+ # If we can't create a session (network issues, permissions, etc.), raise error
236
+ raise ConnectionError(f"Failed to create network session: {e}") from e
237
+
238
+ selection_prefs = SelectionPreferences(allow_yanked=False)
239
+
240
+ search_scope = SearchScope.create(
241
+ find_links=[],
242
+ index_urls=all_index_urls,
243
+ no_index=False
244
+ )
245
+
246
+ link_collector = LinkCollector(
247
+ session=session,
248
+ search_scope=search_scope
249
+ )
250
+
251
+ package_finder = PackageFinder.create(
252
+ link_collector=link_collector,
253
+ selection_prefs=selection_prefs
254
+ )
255
+
256
+ # Use provided constraints or default to empty dict
257
+ if constraints is None:
258
+ constraints = {}
259
+
260
+ # Use provided ignores or default to empty set
261
+ if ignores is None:
262
+ ignores = set()
263
+ # Normalize ignores to lowercase for case-insensitive matching and ensure O(1) lookups
264
+ ignores_lower = {pkg.lower() for pkg in ignores}
265
+
266
+ # Use provided invalidation triggers or default to empty dict
267
+ if invalidation_triggers is None:
268
+ invalidation_triggers = {}
269
+
270
+ # Detect editable packages for preservation during updates
271
+ editable_packages = get_editable_packages()
272
+
273
+ outdated_packages = []
274
+
275
+ # Track consecutive network errors to fail fast
276
+ consecutive_network_errors = 0
277
+
278
+ # Show spinner while checking for updates
279
+ with console.status("[bold green]Checking for package updates...") as status:
280
+ for dist in installed_dists:
281
+ # Check for cancellation at the start of each iteration
282
+ if cancel_event and cancel_event.is_set():
283
+ logger.info("Package check cancelled by user")
284
+ break
285
+
286
+ try:
287
+ package_name = dist.metadata["name"]
288
+ package_name_lower = package_name.lower()
289
+
290
+ # Normalize package name for constraint lookups
291
+ from packaging.utils import canonicalize_name
292
+ package_name_canonical = canonicalize_name(package_name)
293
+
294
+ # Skip ignored packages completely
295
+ if package_name_lower in ignores_lower:
296
+ continue
297
+
298
+ # Update status with current package being checked
299
+ status.update(f"[bold green]Checking {package_name}...")
300
+
301
+ # Call progress callback if provided (stop status temporarily to avoid conflicts)
302
+ if progress_callback:
303
+ status.stop()
304
+ progress_callback(package_name)
305
+ status.start()
306
+
307
+ # Find the best candidate (latest version) with retry logic
308
+ candidates = None
309
+ last_error = None
310
+ for attempt in range(retries + 1):
311
+ try:
312
+ # Use hard timeout wrapper to ensure we don't hang
313
+ logger.debug(f"About to check {package_name} with {timeout}s timeout (attempt {attempt + 1}/{retries + 1})")
314
+ candidates = _call_with_timeout(
315
+ package_finder.find_all_candidates,
316
+ timeout,
317
+ dist.canonical_name
318
+ )
319
+ logger.debug(f"Successfully retrieved candidates for {package_name}")
320
+ # Success - reset consecutive error counter
321
+ consecutive_network_errors = 0
322
+ break
323
+ except (TimeoutError, Exception) as e:
324
+ logger.debug(f"Error checking {package_name}: {type(e).__name__}: {e}")
325
+ last_error = e
326
+ # Check if it's a network-related error (TimeoutError always is)
327
+ error_str = str(e).lower()
328
+ is_network_error = isinstance(e, TimeoutError) or any(keyword in error_str for keyword in [
329
+ 'connection', 'timeout', 'network', 'unreachable',
330
+ 'proxy', 'ssl', 'certificate', 'dns', 'resolve'
331
+ ])
332
+
333
+ if is_network_error:
334
+ consecutive_network_errors += 1
335
+ if consecutive_network_errors >= MAX_CONSECUTIVE_NETWORK_ERRORS:
336
+ # Too many consecutive failures - raise error
337
+ raise ConnectionError(
338
+ f"Network connectivity issues detected after checking {package_name}. "
339
+ f"Failed to reach package index. Please check your network connection "
340
+ f"and proxy settings (HTTP_PROXY, HTTPS_PROXY)."
341
+ ) from e
342
+
343
+ # Not the last retry - wait briefly before retrying
344
+ if attempt < retries:
345
+ time.sleep(RETRY_DELAY)
346
+ continue
347
+
348
+ # Non-network error or last retry - skip this package
349
+ if attempt >= retries:
350
+ logger.debug(f"Failed to check {package_name} after {retries + 1} attempts: {e}")
351
+ break
352
+
353
+ if candidates is None:
354
+ # All retries exhausted, skip this package
355
+ continue
356
+
357
+ if candidates:
358
+ # Filter candidates based on pre-release preference
359
+ if not pre:
360
+ # Exclude pre-release versions (alpha, beta, dev, rc)
361
+ stable_candidates = []
362
+ for candidate in candidates:
363
+ try:
364
+ version_obj = Version(str(candidate.version))
365
+ if not version_obj.is_prerelease:
366
+ stable_candidates.append(candidate)
367
+ except InvalidVersion:
368
+ # If version parsing fails, skip this candidate
369
+ continue
370
+ # Use stable candidates if available, otherwise fall back to all candidates
371
+ candidates_to_check = stable_candidates if stable_candidates else candidates
372
+ else:
373
+ candidates_to_check = candidates
374
+
375
+ # Get the latest version from filtered candidates
376
+ if candidates_to_check:
377
+ latest_candidate = max(candidates_to_check, key=lambda c: c.version)
378
+ latest_version = str(latest_candidate.version)
379
+ current_version = str(dist.version)
380
+
381
+ # Determine the actual file type of the latest candidate
382
+ file_type = "unknown"
383
+ if hasattr(latest_candidate, 'link') and latest_candidate.link:
384
+ filename = latest_candidate.link.filename
385
+ if filename.endswith('.whl'):
386
+ file_type = "wheel"
387
+ elif filename.endswith(('.tar.gz', '.zip')):
388
+ file_type = "sdist"
389
+ elif filename.endswith('.egg'):
390
+ file_type = "egg"
391
+ else:
392
+ # Extract file extension for other types
393
+ if '.' in filename:
394
+ file_type = filename.split('.')[-1]
395
+ else:
396
+ file_type = "unknown"
397
+ else:
398
+ # Fallback to wheel if no link information available
399
+ file_type = "wheel"
400
+
401
+ if latest_version != current_version:
402
+ # Check if there's a constraint for this package
403
+ constraint = constraints.get(package_name_canonical)
404
+
405
+ # Include all outdated packages - constraints will be shown in the table
406
+ # with appropriate color coding (red=violating, green=satisfying)
407
+ # Get invalidation triggers for this package
408
+ package_triggers = invalidation_triggers.get(package_name_canonical, [])
409
+ invalid_when_display = ", ".join(package_triggers) if package_triggers else None
410
+
411
+ package_result = {
412
+ "name": package_name,
413
+ "version": current_version,
414
+ "latest_version": latest_version,
415
+ "latest_filetype": file_type,
416
+ "constraint": constraint,
417
+ "invalid_when": invalid_when_display,
418
+ "editable": package_name_canonical in editable_packages
419
+ }
420
+ outdated_packages.append(package_result)
421
+
422
+ # Call result callback with individual package result
423
+ if result_callback:
424
+ status.stop()
425
+ result_callback(package_result)
426
+ status.start()
427
+ else:
428
+ # Package is up to date - call callback with current info
429
+ if result_callback:
430
+ constraint = constraints.get(package_name_canonical)
431
+ up_to_date_result = {
432
+ "name": package_name,
433
+ "version": current_version,
434
+ "latest_version": current_version,
435
+ "latest_filetype": file_type,
436
+ "constraint": constraint,
437
+ "editable": package_name_canonical in editable_packages
438
+ }
439
+ status.stop()
440
+ result_callback(up_to_date_result)
441
+ status.start()
442
+
443
+ except ConnectionError:
444
+ # Re-raise ConnectionError so it propagates to the caller
445
+ raise
446
+ except Exception:
447
+ # Skip packages that can't be checked
448
+ continue
449
+
450
+ # Sort packages alphabetically by name
451
+ outdated_packages.sort(key=lambda x: x["name"].lower())
452
+
453
+ # Print results if requested
454
+ if print_table:
455
+ if not outdated_packages:
456
+ console.print("[green]All packages are up to date![/green]")
457
+ else:
458
+ # Create a rich table matching TUI styling
459
+ table = Table(title="Outdated Packages")
460
+ table.add_column("", width=3) # Selection indicator column
461
+ table.add_column("Package", style="cyan", no_wrap=True)
462
+ table.add_column("Version", style="magenta")
463
+ table.add_column("Latest", no_wrap=True) # Color conditionally per row
464
+ table.add_column("Type", style="yellow")
465
+ table.add_column("Constraint", no_wrap=True)
466
+ table.add_column("Invalid When", no_wrap=True)
467
+
468
+ for package in outdated_packages:
469
+ constraint = package.get("constraint")
470
+ latest_version = package["latest_version"]
471
+
472
+ # Determine if package will be updated (same logic as TUI)
473
+ if constraint:
474
+ will_update = _check_constraint_satisfaction(latest_version, constraint)
475
+ else:
476
+ will_update = True
477
+
478
+ # Show indicator: ✓ for packages that will be updated
479
+ if will_update:
480
+ indicator = "[bold green]✓[/bold green]"
481
+ else:
482
+ indicator = "[dim]✗[/dim]"
483
+
484
+ # Format latest version with conditional coloring (matching TUI)
485
+ color = get_constraint_color(latest_version, constraint)
486
+ latest_display = f"[{color}]{latest_version}[/{color}]"
487
+
488
+ # Format constraint display
489
+ constraint_display = _format_constraint_for_display(constraint, latest_version)
490
+
491
+ # Format invalid when display (matching TUI)
492
+ invalid_when = package.get("invalid_when")
493
+ invalid_when_display = format_invalid_when_display(invalid_when)
494
+
495
+ table.add_row(
496
+ indicator,
497
+ package["name"],
498
+ package["version"],
499
+ latest_display,
500
+ package["latest_filetype"],
501
+ constraint_display,
502
+ invalid_when_display
503
+ )
504
+
505
+ console.print(table)
506
+
507
+ # Print legend explaining the indicators
508
+ console.print("\n[dim]Legend:[/dim]")
509
+ console.print(" [bold green]✓[/bold green] = Will be updated | [dim]✗[/dim] = Blocked by constraint")
510
+
511
+ return outdated_packages
512
+
513
+
514
+ # Thread-safe cache for editable packages to avoid repeated subprocess calls
515
+ from .config import EDITABLE_PACKAGES_CACHE_TTL
516
+
517
+ # Initialize thread-safe cache
518
+ _editable_packages_cache = ThreadSafeCache[Dict[str, str]](ttl=EDITABLE_PACKAGES_CACHE_TTL)
519
+
520
+
521
+ def _fetch_editable_packages() -> Dict[str, str]:
522
+ """
523
+ Internal function to fetch editable packages from pip.
524
+
525
+ This is the factory function used by the cache.
526
+
527
+ :returns: Dictionary mapping package names (canonical) to their project locations
528
+ :raises RuntimeError: If unable to query pip for editable packages
529
+ """
530
+ import subprocess
531
+ import sys
532
+ from packaging.utils import canonicalize_name
533
+
534
+ editable_packages = {}
535
+
536
+ try:
537
+ # Use pip list --editable to get definitive list of editable packages
538
+ result = subprocess.run([
539
+ sys.executable, '-m', 'pip', 'list', '--editable'
540
+ ], capture_output=True, text=True, check=True, timeout=SUBPROCESS_TIMEOUT)
541
+
542
+ # Parse the output to get package names and locations
543
+ lines = result.stdout.strip().split('\n')
544
+
545
+ # Find the header line and skip it
546
+ header_found = False
547
+ for line in lines:
548
+ line = line.strip()
549
+ if not line:
550
+ continue
551
+
552
+ # Skip header lines (look for "Package" header or separator lines)
553
+ if not header_found:
554
+ if line.startswith('Package') or line.startswith('-'):
555
+ header_found = True
556
+ continue
557
+
558
+ # Skip separator lines
559
+ if line.startswith('-'):
560
+ continue
561
+
562
+ # Parse package lines: "package_name version /path/to/project"
563
+ parts = line.split()
564
+ if len(parts) >= 3:
565
+ pkg_name = parts[0]
566
+ # The location is everything from the 3rd part onwards (in case paths have spaces)
567
+ location = ' '.join(parts[2:])
568
+
569
+ # Canonicalize package name for consistent lookups
570
+ canonical_name = canonicalize_name(pkg_name)
571
+ editable_packages[canonical_name] = location
572
+
573
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
574
+ logger.warning(f"Could not detect editable packages: {e}")
575
+ # Return empty dict on error - caller can handle gracefully
576
+ return {}
577
+ except (OSError, ValueError) as e:
578
+ logger.error(f"Unexpected error detecting editable packages: {e}")
579
+ return {}
580
+
581
+ return editable_packages
582
+
583
+
584
+ def get_editable_packages() -> Dict[str, str]:
585
+ """
586
+ Detect packages installed in editable mode.
587
+
588
+ Uses pip list --editable to get a definitive list of packages installed
589
+ in development/editable mode. These packages maintain a live link to their
590
+ source code directory and should be reinstalled with -e flag to preserve
591
+ this behavior.
592
+
593
+ Results are cached for 60 seconds to avoid repeated subprocess calls.
594
+ The cache is thread-safe for concurrent access.
595
+
596
+ :returns: Dictionary mapping package names (canonical) to their project locations
597
+ """
598
+ try:
599
+ return _editable_packages_cache.get(_fetch_editable_packages).copy()
600
+ except Exception as e:
601
+ logger.warning(f"Failed to get editable packages: {e}")
602
+ return {}
603
+
604
+
605
+ def is_package_editable(package_name: str, editable_packages: Optional[Dict[str, str]] = None) -> bool:
606
+ """
607
+ Check if a package is installed in editable mode.
608
+
609
+ :param package_name: Name of the package to check
610
+ :param editable_packages: Optional cached dict of editable packages (from get_editable_packages)
611
+ :returns: True if package is installed in editable mode, False otherwise
612
+ """
613
+ from packaging.utils import canonicalize_name
614
+
615
+ if editable_packages is None:
616
+ editable_packages = get_editable_packages()
617
+
618
+ canonical_name = canonicalize_name(package_name)
619
+ return canonical_name in editable_packages
620
+
621
+
622
+ def update_packages_preserving_editable(
623
+ packages_to_update: List[Dict[str, Any]],
624
+ console: Optional[Console] = None,
625
+ timeout: Optional[int] = None,
626
+ cancel_event: Optional[Any] = None
627
+ ) -> Tuple[List[str], List[str]]:
628
+ """
629
+ Update packages while preserving editable installations.
630
+
631
+ For packages installed in editable mode, this function will:
632
+ 1. Check if they're in editable mode
633
+ 2. Use the original source directory with pip install -e
634
+ 3. For regular packages, use normal pip install
635
+
636
+ :param packages_to_update: List of package dictionaries with keys: name, latest_version, editable
637
+ :param console: Optional Rich console for output
638
+ :param timeout: Optional timeout for pip operations
639
+ :param cancel_event: Optional threading.Event to signal cancellation
640
+ :returns: Tuple of (successful_updates, failed_updates) with package names
641
+ """
642
+ import subprocess
643
+ import sys
644
+
645
+ if console is None:
646
+ console = Console()
647
+
648
+ successful_updates = []
649
+ failed_updates = []
650
+
651
+ # Helper function to run subprocess with cancellation support
652
+ def run_with_cancel(cmd, timeout=None):
653
+ """Run a subprocess command that can be cancelled."""
654
+ process = subprocess.Popen(
655
+ cmd,
656
+ stdout=subprocess.PIPE,
657
+ stderr=subprocess.PIPE,
658
+ text=True
659
+ )
660
+
661
+ try:
662
+ stdout, stderr = process.communicate(timeout=timeout)
663
+ return process.returncode, stdout, stderr
664
+ except subprocess.TimeoutExpired:
665
+ process.kill()
666
+ process.communicate() # Clean up
667
+ raise
668
+ except:
669
+ # If we're interrupted or cancelled, kill the process
670
+ if process.poll() is None: # Process still running
671
+ process.kill()
672
+ try:
673
+ process.communicate(timeout=1)
674
+ except:
675
+ pass
676
+ raise
677
+
678
+ # Get current editable packages to find source directories
679
+ editable_packages = get_editable_packages()
680
+
681
+ for package_info in packages_to_update:
682
+ # Check for cancellation before processing each package
683
+ if cancel_event and cancel_event.is_set():
684
+ console.print("[yellow]Update cancelled by user[/yellow]")
685
+ break
686
+
687
+ package_name = package_info["name"]
688
+ latest_version = package_info.get("latest_version")
689
+ is_editable = package_info.get("editable", False)
690
+
691
+ try:
692
+ console.print(f"Updating {package_name}...")
693
+
694
+ if is_editable:
695
+ # Package is editable, reinstall from source directory
696
+ from packaging.utils import canonicalize_name
697
+ canonical_name = canonicalize_name(package_name)
698
+ source_path = editable_packages.get(canonical_name)
699
+
700
+ if source_path:
701
+ console.print(f" 📝 Reinstalling editable package from: {source_path}")
702
+
703
+ # First uninstall the current version
704
+ uninstall_cmd = [sys.executable, "-m", "pip", "uninstall", package_name, "-y"]
705
+ returncode, stdout, stderr = run_with_cancel(uninstall_cmd, timeout=timeout)
706
+
707
+ if returncode != 0:
708
+ console.print(f" [red]Failed to uninstall {package_name}: {stderr}[/red]")
709
+ failed_updates.append(package_name)
710
+ continue
711
+
712
+ # Then reinstall in editable mode
713
+ install_cmd = [sys.executable, "-m", "pip", "install", "-e", source_path]
714
+ returncode, stdout, stderr = run_with_cancel(install_cmd, timeout=timeout)
715
+
716
+ if returncode == 0:
717
+ console.print(f" [green]✓ Successfully updated editable {package_name}[/green]")
718
+ successful_updates.append(package_name)
719
+ else:
720
+ console.print(f" [red]Failed to reinstall editable {package_name}: {stderr}[/red]")
721
+ failed_updates.append(package_name)
722
+ else:
723
+ console.print(f" [yellow]Could not find source path for editable {package_name}, updating normally[/yellow]")
724
+ # Fall through to normal update
725
+ is_editable = False
726
+
727
+ if not is_editable:
728
+ # Regular package update
729
+ if latest_version:
730
+ install_cmd = [sys.executable, "-m", "pip", "install", f"{package_name}=={latest_version}"]
731
+ else:
732
+ install_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", package_name]
733
+
734
+ returncode, stdout, stderr = run_with_cancel(install_cmd, timeout=timeout)
735
+
736
+ if returncode == 0:
737
+ console.print(f" [green]✓ Successfully updated {package_name}[/green]")
738
+ successful_updates.append(package_name)
739
+ else:
740
+ console.print(f" [red]Failed to update {package_name}: {stderr}[/red]")
741
+ failed_updates.append(package_name)
742
+
743
+ except subprocess.TimeoutExpired:
744
+ console.print(f" [red]Timeout updating {package_name}[/red]")
745
+ failed_updates.append(package_name)
746
+ except Exception as e:
747
+ console.print(f" [red]Error updating {package_name}: {e}[/red]")
748
+ failed_updates.append(package_name)
749
+
750
+ return successful_updates, failed_updates