pipu-cli 0.1.dev7__py3-none-any.whl → 0.2.0__py3-none-any.whl

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