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.
@@ -0,0 +1,1145 @@
1
+ """Package management functions for pipu-cli."""
2
+
3
+ import logging
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, IO, List, Optional, Protocol, Callable, runtime_checkable
9
+
10
+ from packaging.utils import canonicalize_name
11
+ from packaging.version import Version, InvalidVersion
12
+ from packaging.requirements import Requirement, InvalidRequirement
13
+ from packaging.specifiers import SpecifierSet, InvalidSpecifier
14
+ from pip._internal.metadata import get_default_environment
15
+ from pip._internal.configuration import Configuration
16
+ from pip._internal.index.package_finder import PackageFinder
17
+ from pip._internal.index.collector import LinkCollector
18
+ from pip._internal.models.search_scope import SearchScope
19
+ from pip._internal.network.session import PipSession
20
+ from pip._internal.models.selection_prefs import SelectionPreferences
21
+
22
+ # Set up module logger
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @runtime_checkable
27
+ class OutputStream(Protocol):
28
+ """Protocol for output streams used in package installation."""
29
+ def write(self, text: str, /) -> int | None:
30
+ """Write text to the stream.
31
+
32
+ Args:
33
+ text: The text to write (positional-only to match StringIO signature).
34
+
35
+ Returns:
36
+ The number of characters written (like StringIO) or None.
37
+ """
38
+ ...
39
+
40
+ def flush(self) -> None:
41
+ """Flush the stream."""
42
+ ...
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Package:
47
+ """Information about a package."""
48
+ name: str
49
+ version: Version
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class InstalledPackage(Package):
54
+ """Information about an installed package."""
55
+ constrained_dependencies: Dict[str, str] = field(default_factory=dict, hash=False, compare=False)
56
+ is_editable: bool = False
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class UpgradePackageInfo(Package):
61
+ """Information about an installed package that can be upgraded."""
62
+ upgradable: bool
63
+ latest_version: Version
64
+ is_editable: bool = False
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class UpgradedPackage(Package):
69
+ """Information about a package that has been upgraded."""
70
+ upgraded: bool
71
+ previous_version: Version
72
+ is_editable: bool = False
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class BlockedPackageInfo(Package):
77
+ """Information about a package that cannot be upgraded."""
78
+ latest_version: Version
79
+ blocked_by: List[str] # List of "package_name (constraint)" strings
80
+ is_editable: bool = False
81
+
82
+
83
+ def inspect_installed_packages(timeout: int = 10) -> List[InstalledPackage]:
84
+ """
85
+ Inspect currently installed Python packages and return detailed information.
86
+
87
+ This function uses pip's internal APIs to gather information about all installed
88
+ packages in the current environment, including their versions, editable status,
89
+ and constrained dependencies.
90
+
91
+ :param timeout: Timeout in seconds for subprocess calls (default: 10)
92
+ :returns: List of PackageInfo objects containing package details
93
+ :raises RuntimeError: If unable to inspect installed packages
94
+ """
95
+ try:
96
+ # Get editable packages first
97
+ editable_packages = _get_editable_packages(timeout)
98
+
99
+ # Get all installed packages
100
+ env = get_default_environment()
101
+ installed_dists = list(env.iter_all_distributions())
102
+
103
+ packages = []
104
+
105
+ for dist in installed_dists:
106
+ try:
107
+ # Get package name
108
+ package_name = dist.metadata["name"]
109
+ canonical_name = canonicalize_name(package_name)
110
+
111
+ # Get package version
112
+ try:
113
+ package_version = Version(str(dist.version))
114
+ except InvalidVersion:
115
+ logger.warning(f"Invalid version for {package_name}: {dist.version}. Skipping.")
116
+ continue
117
+
118
+ # Check if package is editable
119
+ is_editable = canonical_name in editable_packages
120
+
121
+ # Extract constrained dependencies
122
+ constrained_dependencies = _extract_constrained_dependencies(dist)
123
+
124
+ # Create PackageInfo object
125
+ package_info = InstalledPackage(
126
+ name=package_name,
127
+ version=package_version,
128
+ is_editable=is_editable,
129
+ constrained_dependencies=constrained_dependencies
130
+ )
131
+
132
+ packages.append(package_info)
133
+
134
+ except Exception as e:
135
+ logger.warning(f"Error processing package {dist.metadata.get('name', 'unknown')}: {e}")
136
+ continue
137
+
138
+ # Sort packages alphabetically by name
139
+ packages.sort(key=lambda p: p.name.lower())
140
+
141
+ return packages
142
+
143
+ except Exception as e:
144
+ raise RuntimeError(f"Failed to inspect installed packages: {e}") from e
145
+
146
+
147
+ def _get_editable_packages(timeout: int) -> Dict[str, str]:
148
+ """
149
+ Get packages installed in editable mode using pip list --editable.
150
+
151
+ :param timeout: Timeout in seconds for subprocess call
152
+ :returns: Dictionary mapping canonical package names to their source locations
153
+ """
154
+ editable_packages = {}
155
+
156
+ try:
157
+ # Use pip list --editable to get editable packages
158
+ result = subprocess.run(
159
+ [sys.executable, '-m', 'pip', 'list', '--editable'],
160
+ capture_output=True,
161
+ text=True,
162
+ check=True,
163
+ timeout=timeout
164
+ )
165
+
166
+ # Parse the output
167
+ lines = result.stdout.strip().split('\n')
168
+
169
+ # Find and skip the header
170
+ header_found = False
171
+ for line in lines:
172
+ line = line.strip()
173
+ if not line:
174
+ continue
175
+
176
+ # Skip header lines
177
+ if not header_found:
178
+ if line.startswith('Package') or line.startswith('-'):
179
+ header_found = True
180
+ continue
181
+
182
+ # Skip separator lines
183
+ if line.startswith('-'):
184
+ continue
185
+
186
+ # Parse package lines: "package_name version /path/to/project"
187
+ parts = line.split()
188
+ if len(parts) >= 3:
189
+ pkg_name = parts[0]
190
+ location = ' '.join(parts[2:])
191
+ canonical_name = canonicalize_name(pkg_name)
192
+ editable_packages[canonical_name] = location
193
+
194
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
195
+ logger.warning(f"Could not detect editable packages: {e}")
196
+ return {}
197
+ except Exception as e:
198
+ logger.error(f"Unexpected error detecting editable packages: {e}")
199
+ return {}
200
+
201
+ return editable_packages
202
+
203
+
204
+ def _extract_constrained_dependencies(dist: Any) -> Dict[str, str]:
205
+ """
206
+ Extract constrained dependencies from a package's metadata.
207
+
208
+ A dependency is considered "constrained" if it has any version specifier
209
+ (e.g., "requests>=2.28.0", "numpy>=1.20.0,<2.0.0", "pandas==1.5.0").
210
+
211
+ Only unconditional dependencies and dependencies whose markers are satisfied
212
+ in the current environment are included. Dependencies that are conditional on
213
+ extras (e.g., "dask<2025.3.0; extra == 'dask'") are skipped because we cannot
214
+ determine which extras were installed.
215
+
216
+ The constraint strings returned can be used with packaging.specifiers.SpecifierSet
217
+ for version comparison operations.
218
+
219
+ :param dist: Distribution object from pip's metadata API
220
+ :returns: Dictionary mapping dependency names to their constraint specifiers
221
+ """
222
+ constrained_dependencies = {}
223
+
224
+ try:
225
+ # Get the Requires-Dist metadata
226
+ requires = dist.metadata.get_all("Requires-Dist")
227
+ if not requires:
228
+ return constrained_dependencies
229
+
230
+ for req_string in requires:
231
+ try:
232
+ # Parse the requirement
233
+ req = Requirement(req_string)
234
+
235
+ # Skip requirements with markers that don't apply
236
+ if req.marker:
237
+ marker_str = str(req.marker)
238
+ # Skip extra-only dependencies - we can't know which extras were installed
239
+ # These look like: extra == "dev", extra == 'test', etc.
240
+ if 'extra' in marker_str:
241
+ logger.debug(f"Skipping extra-only dependency: {req_string}")
242
+ continue
243
+ # For other markers (e.g., python_version, sys_platform), evaluate them
244
+ try:
245
+ if not req.marker.evaluate():
246
+ logger.debug(f"Skipping dependency with unsatisfied marker: {req_string}")
247
+ continue
248
+ except Exception as e:
249
+ logger.debug(f"Could not evaluate marker for {req_string}: {e}")
250
+ # If we can't evaluate, skip to be conservative
251
+ continue
252
+
253
+ # Check if this requirement has any version specifier
254
+ if req.specifier:
255
+ # Convert the specifier to a string (e.g., ">=1.0.0,<2.0.0")
256
+ constraint_str = str(req.specifier)
257
+ canonical_dep_name = canonicalize_name(req.name)
258
+ constrained_dependencies[canonical_dep_name] = constraint_str
259
+
260
+ except InvalidRequirement as e:
261
+ logger.warning(f"Invalid requirement specification: {req_string}. Error: {e}")
262
+ continue
263
+
264
+ except Exception as e:
265
+ logger.warning(f"Error extracting dependencies for {dist.metadata.get('name', 'unknown')}: {e}")
266
+
267
+ return constrained_dependencies
268
+
269
+
270
+ def get_latest_versions_parallel(
271
+ installed_packages: List[InstalledPackage],
272
+ timeout: int = 10,
273
+ include_prereleases: bool = False,
274
+ max_workers: int = 10,
275
+ progress_callback: Optional[Callable] = None
276
+ ) -> Dict[InstalledPackage, Package]:
277
+ """
278
+ Get the latest available versions for a list of installed packages using parallel queries.
279
+
280
+ This function queries PyPI (or configured package indexes) to find the latest
281
+ version available for each installed package using concurrent requests. It respects
282
+ pip configuration settings including index-url, extra-index-url, and trusted-host.
283
+
284
+ :param installed_packages: List of InstalledPackage objects to check
285
+ :param timeout: Network timeout in seconds for package queries (default: 10)
286
+ :param include_prereleases: Whether to include pre-release versions (default: False)
287
+ :param max_workers: Maximum concurrent requests (default: 10)
288
+ :param progress_callback: Optional thread-safe callback function(current, total) for progress updates
289
+ :returns: Dictionary mapping InstalledPackage objects to Package objects with latest version
290
+ :raises ConnectionError: If unable to connect to package indexes
291
+ :raises RuntimeError: If unable to load pip configuration
292
+ """
293
+ from concurrent.futures import ThreadPoolExecutor, as_completed
294
+
295
+ # Load pip configuration to get index URLs and trusted hosts
296
+ try:
297
+ config = Configuration(isolated=False, load_only=None)
298
+ config.load()
299
+ except Exception as e:
300
+ logger.warning(f"Could not load pip configuration: {e}")
301
+ config = None
302
+
303
+ # Get index URL (primary package index)
304
+ index_url = None
305
+ if config:
306
+ try:
307
+ index_url = config.get_value("global.index-url")
308
+ except Exception:
309
+ pass
310
+ index_url = index_url or "https://pypi.org/simple/"
311
+
312
+ # Get extra index URLs (additional package indexes)
313
+ extra_index_urls = []
314
+ if config:
315
+ try:
316
+ raw_extra_urls = config.get_value("global.extra-index-url")
317
+ if raw_extra_urls:
318
+ # Handle both string and list formats
319
+ if isinstance(raw_extra_urls, str):
320
+ # Split by newlines and filter out comments/empty lines
321
+ extra_index_urls = [
322
+ url.strip()
323
+ for url in raw_extra_urls.split('\n')
324
+ if url.strip() and not url.strip().startswith('#')
325
+ ]
326
+ elif isinstance(raw_extra_urls, list):
327
+ extra_index_urls = raw_extra_urls
328
+ except Exception:
329
+ pass
330
+
331
+ # Combine all index URLs
332
+ all_index_urls = [index_url] + extra_index_urls
333
+
334
+ # Get trusted hosts (hosts that don't require HTTPS verification)
335
+ trusted_hosts = []
336
+ if config:
337
+ try:
338
+ raw_trusted_hosts = config.get_value("global.trusted-host")
339
+ if raw_trusted_hosts:
340
+ # Handle both string and list formats
341
+ if isinstance(raw_trusted_hosts, str):
342
+ # Split by newlines and filter out comments/empty lines
343
+ trusted_hosts = [
344
+ host.strip()
345
+ for host in raw_trusted_hosts.split('\n')
346
+ if host.strip() and not host.strip().startswith('#')
347
+ ]
348
+ elif isinstance(raw_trusted_hosts, list):
349
+ trusted_hosts = raw_trusted_hosts
350
+ except Exception:
351
+ pass
352
+
353
+ # Create pip session for network requests
354
+ try:
355
+ session = PipSession()
356
+ session.timeout = timeout
357
+
358
+ # Add trusted hosts to session
359
+ for host in trusted_hosts:
360
+ host = host.strip()
361
+ if host:
362
+ session.add_trusted_host(host, source="pip configuration")
363
+ except Exception as e:
364
+ raise ConnectionError(f"Failed to create network session: {e}") from e
365
+
366
+ # Set up package finder with configured indexes
367
+ selection_prefs = SelectionPreferences(
368
+ allow_yanked=False,
369
+ allow_all_prereleases=include_prereleases
370
+ )
371
+
372
+ search_scope = SearchScope.create(
373
+ find_links=[],
374
+ index_urls=all_index_urls,
375
+ no_index=False
376
+ )
377
+
378
+ link_collector = LinkCollector(
379
+ session=session,
380
+ search_scope=search_scope
381
+ )
382
+
383
+ package_finder = PackageFinder.create(
384
+ link_collector=link_collector,
385
+ selection_prefs=selection_prefs
386
+ )
387
+
388
+ # Thread-safe result storage and progress tracking
389
+ result: Dict[InstalledPackage, Package] = {}
390
+ result_lock = threading.Lock()
391
+ progress_lock = threading.Lock()
392
+ completed_count = [0] # Mutable container for thread-safe counter
393
+ total_packages = len(installed_packages)
394
+
395
+ def check_package(installed_pkg: InstalledPackage) -> Optional[tuple[InstalledPackage, Package]]:
396
+ """Check a single package for updates."""
397
+ try:
398
+ # Get canonical name for querying
399
+ canonical_name = canonicalize_name(installed_pkg.name)
400
+
401
+ # Find all available versions
402
+ candidates = package_finder.find_all_candidates(canonical_name)
403
+
404
+ if not candidates:
405
+ logger.debug(f"No candidates found for {installed_pkg.name}")
406
+ return None
407
+
408
+ # Filter out pre-releases if not requested
409
+ if not include_prereleases:
410
+ stable_candidates = []
411
+ for candidate in candidates:
412
+ try:
413
+ version_obj = Version(str(candidate.version))
414
+ if not version_obj.is_prerelease:
415
+ stable_candidates.append(candidate)
416
+ except InvalidVersion:
417
+ continue
418
+
419
+ # Use stable candidates if available, otherwise use all
420
+ candidates = stable_candidates if stable_candidates else candidates
421
+
422
+ # Get the latest version
423
+ if candidates:
424
+ latest_candidate = max(candidates, key=lambda c: c.version)
425
+ latest_version = Version(str(latest_candidate.version))
426
+
427
+ # Create Package object with latest version
428
+ latest_package = Package(
429
+ name=installed_pkg.name,
430
+ version=latest_version
431
+ )
432
+
433
+ logger.debug(f"Found latest version for {installed_pkg.name}: {latest_version}")
434
+ return (installed_pkg, latest_package)
435
+
436
+ except Exception as e:
437
+ logger.warning(f"Error checking {installed_pkg.name}: {e}")
438
+ return None
439
+
440
+ return None
441
+
442
+ # Execute parallel queries
443
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
444
+ # Submit all tasks
445
+ futures = {
446
+ executor.submit(check_package, pkg): pkg
447
+ for pkg in installed_packages
448
+ }
449
+
450
+ # Process results as they complete
451
+ for future in as_completed(futures):
452
+ result_tuple = future.result()
453
+
454
+ # Update result if package was found
455
+ if result_tuple:
456
+ installed_pkg, latest_pkg = result_tuple
457
+ with result_lock:
458
+ result[installed_pkg] = latest_pkg
459
+
460
+ # Update progress
461
+ with progress_lock:
462
+ completed_count[0] += 1
463
+ if progress_callback:
464
+ progress_callback(completed_count[0], total_packages)
465
+
466
+ return result
467
+
468
+
469
+ def get_latest_versions(
470
+ installed_packages: List[InstalledPackage],
471
+ timeout: int = 10,
472
+ include_prereleases: bool = False,
473
+ progress_callback: Optional[Callable] = None
474
+ ) -> Dict[InstalledPackage, Package]:
475
+ """
476
+ Get the latest available versions for a list of installed packages.
477
+
478
+ This function queries PyPI (or configured package indexes) to find the latest
479
+ version available for each installed package. It respects pip configuration
480
+ settings including index-url, extra-index-url, and trusted-host.
481
+
482
+ :param installed_packages: List of InstalledPackage objects to check
483
+ :param timeout: Network timeout in seconds for package queries (default: 10)
484
+ :param include_prereleases: Whether to include pre-release versions (default: False)
485
+ :param progress_callback: Optional callback function(current, total) for progress updates
486
+ :returns: Dictionary mapping InstalledPackage objects to Package objects with latest version
487
+ :raises ConnectionError: If unable to connect to package indexes
488
+ :raises RuntimeError: If unable to load pip configuration
489
+ """
490
+ # Load pip configuration to get index URLs and trusted hosts
491
+ try:
492
+ config = Configuration(isolated=False, load_only=None)
493
+ config.load()
494
+ except Exception as e:
495
+ logger.warning(f"Could not load pip configuration: {e}")
496
+ config = None
497
+
498
+ # Get index URL (primary package index)
499
+ index_url = None
500
+ if config:
501
+ try:
502
+ index_url = config.get_value("global.index-url")
503
+ except Exception:
504
+ pass
505
+ index_url = index_url or "https://pypi.org/simple/"
506
+
507
+ # Get extra index URLs (additional package indexes)
508
+ extra_index_urls = []
509
+ if config:
510
+ try:
511
+ raw_extra_urls = config.get_value("global.extra-index-url")
512
+ if raw_extra_urls:
513
+ # Handle both string and list formats
514
+ if isinstance(raw_extra_urls, str):
515
+ # Split by newlines and filter out comments/empty lines
516
+ extra_index_urls = [
517
+ url.strip()
518
+ for url in raw_extra_urls.split('\n')
519
+ if url.strip() and not url.strip().startswith('#')
520
+ ]
521
+ elif isinstance(raw_extra_urls, list):
522
+ extra_index_urls = raw_extra_urls
523
+ except Exception:
524
+ pass
525
+
526
+ # Combine all index URLs
527
+ all_index_urls = [index_url] + extra_index_urls
528
+
529
+ # Get trusted hosts (hosts that don't require HTTPS verification)
530
+ trusted_hosts = []
531
+ if config:
532
+ try:
533
+ raw_trusted_hosts = config.get_value("global.trusted-host")
534
+ if raw_trusted_hosts:
535
+ # Handle both string and list formats
536
+ if isinstance(raw_trusted_hosts, str):
537
+ # Split by newlines and filter out comments/empty lines
538
+ trusted_hosts = [
539
+ host.strip()
540
+ for host in raw_trusted_hosts.split('\n')
541
+ if host.strip() and not host.strip().startswith('#')
542
+ ]
543
+ elif isinstance(raw_trusted_hosts, list):
544
+ trusted_hosts = raw_trusted_hosts
545
+ except Exception:
546
+ pass
547
+
548
+ # Create pip session for network requests
549
+ try:
550
+ session = PipSession()
551
+ session.timeout = timeout
552
+
553
+ # Add trusted hosts to session
554
+ for host in trusted_hosts:
555
+ host = host.strip()
556
+ if host:
557
+ session.add_trusted_host(host, source="pip configuration")
558
+ except Exception as e:
559
+ raise ConnectionError(f"Failed to create network session: {e}") from e
560
+
561
+ # Set up package finder with configured indexes
562
+ selection_prefs = SelectionPreferences(
563
+ allow_yanked=False,
564
+ allow_all_prereleases=include_prereleases
565
+ )
566
+
567
+ search_scope = SearchScope.create(
568
+ find_links=[],
569
+ index_urls=all_index_urls,
570
+ no_index=False
571
+ )
572
+
573
+ link_collector = LinkCollector(
574
+ session=session,
575
+ search_scope=search_scope
576
+ )
577
+
578
+ package_finder = PackageFinder.create(
579
+ link_collector=link_collector,
580
+ selection_prefs=selection_prefs
581
+ )
582
+
583
+ # Query latest version for each package
584
+ result: Dict[InstalledPackage, Package] = {}
585
+ total_packages = len(installed_packages)
586
+
587
+ for idx, installed_pkg in enumerate(installed_packages):
588
+ # Report progress if callback provided
589
+ if progress_callback:
590
+ progress_callback(idx, total_packages)
591
+
592
+ try:
593
+ # Get canonical name for querying
594
+ canonical_name = canonicalize_name(installed_pkg.name)
595
+
596
+ # Find all available versions
597
+ candidates = package_finder.find_all_candidates(canonical_name)
598
+
599
+ if not candidates:
600
+ logger.debug(f"No candidates found for {installed_pkg.name}")
601
+ continue
602
+
603
+ # Filter out pre-releases if not requested
604
+ if not include_prereleases:
605
+ stable_candidates = []
606
+ for candidate in candidates:
607
+ try:
608
+ version_obj = Version(str(candidate.version))
609
+ if not version_obj.is_prerelease:
610
+ stable_candidates.append(candidate)
611
+ except InvalidVersion:
612
+ continue
613
+
614
+ # Use stable candidates if available, otherwise use all
615
+ candidates = stable_candidates if stable_candidates else candidates
616
+
617
+ # Get the latest version
618
+ if candidates:
619
+ latest_candidate = max(candidates, key=lambda c: c.version)
620
+ latest_version = Version(str(latest_candidate.version))
621
+
622
+ # Create Package object with latest version
623
+ latest_package = Package(
624
+ name=installed_pkg.name,
625
+ version=latest_version
626
+ )
627
+
628
+ result[installed_pkg] = latest_package
629
+ logger.debug(f"Found latest version for {installed_pkg.name}: {latest_version}")
630
+
631
+ except Exception as e:
632
+ logger.warning(f"Error checking {installed_pkg.name}: {e}")
633
+ continue
634
+
635
+ # Report completion
636
+ if progress_callback:
637
+ progress_callback(total_packages, total_packages)
638
+
639
+ return result
640
+
641
+
642
+ def resolve_upgradable_packages(
643
+ upgrade_candidates: Dict[InstalledPackage, Package],
644
+ all_installed: List[InstalledPackage]
645
+ ) -> List[UpgradePackageInfo]:
646
+ """
647
+ Resolve which packages can be safely upgraded considering dependency constraints.
648
+
649
+ This function uses a fixed-point iteration algorithm to handle circular dependencies.
650
+ It repeatedly refines the set of upgradable packages until it stabilizes (reaches a
651
+ fixed point where no more packages need to be removed).
652
+
653
+ A package can be upgraded if:
654
+ 1. Its new version doesn't violate constraints from packages NOT being upgraded, OR
655
+ 2. ALL packages whose constraints would be violated ARE being upgraded
656
+
657
+ The algorithm:
658
+ 1. Start with all packages that have newer versions available
659
+ 2. Check constraints for each package against current upgrading set
660
+ 3. Remove packages that violate constraints
661
+ 4. Repeat steps 2-3 until no changes occur (fixed point)
662
+
663
+ Examples:
664
+ - If Package A constrains "B<2.0" and B upgrades to 1.9: B is upgradable (constraint satisfied)
665
+ - If Package A constrains "B<2.0" and B upgrades to 2.5, and A is NOT upgrading: B is NOT upgradable
666
+ - If Package A constrains "B<2.0" and B upgrades to 2.5, and A IS upgrading: B is upgradable
667
+ - If A requires B==1.0 and B wants to upgrade, C depends on B:
668
+ * B is NOT upgradable (violates A's exact constraint)
669
+ * C cannot use "B is upgrading" to justify its upgrade
670
+ * Fixed-point iteration removes B from upgrading set in first pass
671
+ * Second pass sees B not upgrading, removes C if it violates B's constraints
672
+
673
+ Performance:
674
+ - Time complexity: O(n * m * k) where:
675
+ * n = number of packages with updates available
676
+ * m = number of iterations (typically 1-3, max n)
677
+ * k = average constraints per package (typically small)
678
+ - Space complexity: O(n) for upgrading_packages set and constraints_on map
679
+ - Convergence: Guaranteed (monotonically shrinking set, terminates when empty or stable)
680
+ - Practical performance: Fast for typical package sets (tested with 182 packages)
681
+
682
+ :param upgrade_candidates: Dict mapping installed packages to their latest available versions
683
+ :param all_installed: List of all installed packages (for constraint checking)
684
+ :returns: List of UpgradePackageInfo objects indicating which packages can be upgraded
685
+ """
686
+ # Build a reverse dependency map: package_name -> [(constraining_package, specifier)]
687
+ # This tells us which packages have constraints on a given package
688
+ constraints_on: Dict[str, List[tuple[InstalledPackage, str]]] = {}
689
+
690
+ for pkg in all_installed:
691
+ for dep_name, specifier_str in pkg.constrained_dependencies.items():
692
+ if dep_name not in constraints_on:
693
+ constraints_on[dep_name] = []
694
+ constraints_on[dep_name].append((pkg, specifier_str))
695
+
696
+ # Filter to only actual upgrades (latest > installed)
697
+ actual_upgrades = {
698
+ pkg: latest_pkg
699
+ for pkg, latest_pkg in upgrade_candidates.items()
700
+ if latest_pkg.version > pkg.version
701
+ }
702
+
703
+ # Fixed-point iteration: start with all actual upgrades, iteratively remove violators
704
+ upgrading_packages = {canonicalize_name(pkg.name) for pkg in actual_upgrades.keys()}
705
+
706
+ max_iterations = len(upgrading_packages) + 1 # Safety limit
707
+ iteration = 0
708
+
709
+ while iteration < max_iterations:
710
+ iteration += 1
711
+ packages_to_remove = set()
712
+
713
+ # Check each potential upgrade against current upgrading set
714
+ for installed_pkg, latest_pkg in actual_upgrades.items():
715
+ canonical_name = canonicalize_name(installed_pkg.name)
716
+
717
+ # Skip if already removed in a previous iteration
718
+ if canonical_name not in upgrading_packages:
719
+ continue
720
+
721
+ latest_version = latest_pkg.version
722
+
723
+ # Check all constraints on this package
724
+ if canonical_name in constraints_on:
725
+ for constraining_pkg, specifier_str in constraints_on[canonical_name]:
726
+ try:
727
+ specifier = SpecifierSet(specifier_str)
728
+ satisfies = latest_version in specifier
729
+
730
+ if not satisfies:
731
+ # Constraint violated - check if constraining package is being upgraded
732
+ constraining_canonical = canonicalize_name(constraining_pkg.name)
733
+ if constraining_canonical not in upgrading_packages:
734
+ # Constraint violated by non-upgrading package - cannot upgrade
735
+ packages_to_remove.add(canonical_name)
736
+ logger.debug(
737
+ f"Iteration {iteration}: Cannot upgrade {installed_pkg.name} to {latest_version}: "
738
+ f"violates constraint {specifier_str} from {constraining_pkg.name} "
739
+ f"which is not being upgraded"
740
+ )
741
+ break
742
+ else:
743
+ # Constraint violated but constraining package is being upgraded
744
+ logger.debug(
745
+ f"Iteration {iteration}: Can upgrade {installed_pkg.name} to {latest_version}: "
746
+ f"violates constraint {specifier_str} from {constraining_pkg.name} "
747
+ f"but {constraining_pkg.name} is also being upgraded"
748
+ )
749
+
750
+ except (InvalidSpecifier, Exception) as e:
751
+ logger.warning(
752
+ f"Invalid specifier '{specifier_str}' for {canonical_name} "
753
+ f"from {constraining_pkg.name}: {e}"
754
+ )
755
+ # If we can't parse the specifier, be conservative and block the upgrade
756
+ # unless the constraining package is being upgraded
757
+ constraining_canonical = canonicalize_name(constraining_pkg.name)
758
+ if constraining_canonical not in upgrading_packages:
759
+ packages_to_remove.add(canonical_name)
760
+ break
761
+
762
+ # Remove packages that violate constraints
763
+ if not packages_to_remove:
764
+ # Fixed point reached - no more packages to remove
765
+ logger.debug(f"Fixed point reached after {iteration} iteration(s)")
766
+ break
767
+
768
+ logger.debug(f"Iteration {iteration}: Removing {len(packages_to_remove)} package(s): {packages_to_remove}")
769
+ upgrading_packages -= packages_to_remove
770
+
771
+ if iteration >= max_iterations:
772
+ logger.warning(f"Fixed-point iteration did not converge after {max_iterations} iterations")
773
+
774
+ # Build result list with upgradability determined by final upgrading set
775
+ result = []
776
+ for installed_pkg, latest_pkg in upgrade_candidates.items():
777
+ canonical_name = canonicalize_name(installed_pkg.name)
778
+ latest_version = latest_pkg.version
779
+
780
+ # Check if this is actually an upgrade and made it through fixed-point iteration
781
+ can_upgrade = (
782
+ latest_version > installed_pkg.version and
783
+ canonical_name in upgrading_packages
784
+ )
785
+
786
+ result.append(UpgradePackageInfo(
787
+ name=installed_pkg.name,
788
+ version=installed_pkg.version,
789
+ upgradable=can_upgrade,
790
+ latest_version=latest_version,
791
+ is_editable=installed_pkg.is_editable
792
+ ))
793
+
794
+ return result
795
+
796
+
797
+ def resolve_upgradable_packages_with_reasons(
798
+ upgrade_candidates: Dict[InstalledPackage, Package],
799
+ all_installed: List[InstalledPackage]
800
+ ) -> tuple[List[UpgradePackageInfo], List[BlockedPackageInfo]]:
801
+ """
802
+ Resolve upgradable packages and provide detailed blocking reasons.
803
+
804
+ Returns both upgradable packages and blocked packages with reasons.
805
+
806
+ :param upgrade_candidates: Dict mapping installed packages to their latest available versions
807
+ :param all_installed: List of all installed packages (for constraint checking)
808
+ :returns: Tuple of (upgradable_packages, blocked_packages_with_reasons)
809
+ """
810
+ # Build a reverse dependency map
811
+ constraints_on: Dict[str, List[tuple[InstalledPackage, str]]] = {}
812
+
813
+ for pkg in all_installed:
814
+ for dep_name, specifier_str in pkg.constrained_dependencies.items():
815
+ if dep_name not in constraints_on:
816
+ constraints_on[dep_name] = []
817
+ constraints_on[dep_name].append((pkg, specifier_str))
818
+
819
+ # Filter to only actual upgrades
820
+ actual_upgrades = {
821
+ pkg: latest_pkg
822
+ for pkg, latest_pkg in upgrade_candidates.items()
823
+ if latest_pkg.version > pkg.version
824
+ }
825
+
826
+ # Track blocking reasons for each package
827
+ blocking_reasons: Dict[str, List[str]] = {}
828
+
829
+ # Fixed-point iteration
830
+ upgrading_packages = {canonicalize_name(pkg.name) for pkg in actual_upgrades.keys()}
831
+ max_iterations = len(upgrading_packages) + 1
832
+ iteration = 0
833
+
834
+ while iteration < max_iterations:
835
+ iteration += 1
836
+ packages_to_remove = set()
837
+
838
+ for installed_pkg, latest_pkg in actual_upgrades.items():
839
+ canonical_name = canonicalize_name(installed_pkg.name)
840
+
841
+ if canonical_name not in upgrading_packages:
842
+ continue
843
+
844
+ latest_version = latest_pkg.version
845
+
846
+ if canonical_name in constraints_on:
847
+ for constraining_pkg, specifier_str in constraints_on[canonical_name]:
848
+ try:
849
+ specifier = SpecifierSet(specifier_str)
850
+ satisfies = latest_version in specifier
851
+
852
+ if not satisfies:
853
+ constraining_canonical = canonicalize_name(constraining_pkg.name)
854
+ if constraining_canonical not in upgrading_packages:
855
+ packages_to_remove.add(canonical_name)
856
+ # Track blocking reason
857
+ reason = f"{constraining_pkg.name} requires {specifier_str}"
858
+ if canonical_name not in blocking_reasons:
859
+ blocking_reasons[canonical_name] = []
860
+ blocking_reasons[canonical_name].append(reason)
861
+ break
862
+ except (InvalidSpecifier, Exception):
863
+ constraining_canonical = canonicalize_name(constraining_pkg.name)
864
+ if constraining_canonical not in upgrading_packages:
865
+ packages_to_remove.add(canonical_name)
866
+ reason = f"{constraining_pkg.name} (invalid constraint)"
867
+ if canonical_name not in blocking_reasons:
868
+ blocking_reasons[canonical_name] = []
869
+ blocking_reasons[canonical_name].append(reason)
870
+ break
871
+
872
+ if not packages_to_remove:
873
+ break
874
+
875
+ upgrading_packages -= packages_to_remove
876
+
877
+ # Build result lists
878
+ upgradable = []
879
+ blocked = []
880
+
881
+ for installed_pkg, latest_pkg in upgrade_candidates.items():
882
+ canonical_name = canonicalize_name(installed_pkg.name)
883
+ latest_version = latest_pkg.version
884
+
885
+ is_actual_upgrade = latest_version > installed_pkg.version
886
+ can_upgrade = is_actual_upgrade and canonical_name in upgrading_packages
887
+
888
+ if can_upgrade:
889
+ upgradable.append(UpgradePackageInfo(
890
+ name=installed_pkg.name,
891
+ version=installed_pkg.version,
892
+ upgradable=True,
893
+ latest_version=latest_version,
894
+ is_editable=installed_pkg.is_editable
895
+ ))
896
+ elif is_actual_upgrade:
897
+ # Blocked package
898
+ reasons = blocking_reasons.get(canonical_name, ["Unknown constraint"])
899
+ blocked.append(BlockedPackageInfo(
900
+ name=installed_pkg.name,
901
+ version=installed_pkg.version,
902
+ latest_version=latest_version,
903
+ blocked_by=reasons,
904
+ is_editable=installed_pkg.is_editable
905
+ ))
906
+
907
+ return upgradable, blocked
908
+
909
+
910
+ def _stream_reader(
911
+ pipe: IO[str],
912
+ stream: Optional[OutputStream],
913
+ lock: threading.Lock
914
+ ) -> None:
915
+ """
916
+ Read lines from a pipe and write to a stream with thread-safe locking.
917
+
918
+ This helper function is used to read output from subprocess pipes (stdout/stderr)
919
+ and write it to an output stream in real-time. The lock ensures thread-safe
920
+ access when multiple threads write to the same stream.
921
+
922
+ :param pipe: Input pipe to read from (stdout or stderr from subprocess)
923
+ :param stream: Output stream to write to (or None to discard)
924
+ :param lock: Threading lock for synchronized writes
925
+ """
926
+ try:
927
+ for line in iter(pipe.readline, ''):
928
+ if line and stream:
929
+ with lock:
930
+ stream.write(line)
931
+ stream.flush()
932
+ except Exception as e:
933
+ logger.warning(f"Error reading from pipe: {e}")
934
+ finally:
935
+ pipe.close()
936
+
937
+
938
+ def install_packages(
939
+ packages_to_upgrade: List[UpgradePackageInfo],
940
+ output_stream: Optional[OutputStream] = None,
941
+ timeout: int = 300,
942
+ version_constraints: Optional[Dict[str, str]] = None
943
+ ) -> List[UpgradedPackage]:
944
+ """
945
+ Install/upgrade packages using pip.
946
+
947
+ This function upgrades all packages in a single pip command to allow pip's
948
+ dependency resolver to handle mutual constraints properly. After installation,
949
+ it checks which packages were successfully upgraded by comparing installed
950
+ versions with previous versions.
951
+
952
+ :param packages_to_upgrade: List of UpgradePackageInfo objects to upgrade
953
+ :param output_stream: Optional stream implementing write() and flush() for live progress updates
954
+ :param timeout: Timeout in seconds for the installation (default: 300)
955
+ :param version_constraints: Optional dict mapping package names (lowercase) to version specifiers (e.g., "==2.31.0")
956
+ :returns: List of UpgradedPackage objects with upgrade status
957
+ :raises RuntimeError: If pip command cannot be executed
958
+ """
959
+ if not packages_to_upgrade:
960
+ return []
961
+
962
+ # Build a map of package name (canonical) to package info
963
+ package_map = {
964
+ canonicalize_name(pkg.name): pkg
965
+ for pkg in packages_to_upgrade
966
+ }
967
+
968
+ # Construct pip install command with all packages at once
969
+ # This allows pip to resolve mutual constraints properly
970
+ # Apply version constraints if provided
971
+ package_specs = []
972
+ for pkg in packages_to_upgrade:
973
+ pkg_name_lower = pkg.name.lower()
974
+ if version_constraints and pkg_name_lower in version_constraints:
975
+ # Use the specified version constraint
976
+ constraint = version_constraints[pkg_name_lower]
977
+ package_specs.append(f"{pkg.name}{constraint}")
978
+ else:
979
+ # Just upgrade to latest
980
+ package_specs.append(pkg.name)
981
+
982
+ cmd = [
983
+ sys.executable, '-m', 'pip', 'install',
984
+ '--upgrade'
985
+ ] + package_specs
986
+
987
+ process = None
988
+ try:
989
+ # Write initial message to output stream
990
+ if output_stream:
991
+ output_stream.write(f"Upgrading {len(package_specs)} package(s)...\n")
992
+ output_stream.flush()
993
+
994
+ # Run pip install with real-time output streaming
995
+ process = subprocess.Popen(
996
+ cmd,
997
+ stdout=subprocess.PIPE,
998
+ stderr=subprocess.PIPE,
999
+ text=True,
1000
+ bufsize=1 # Line-buffered
1001
+ )
1002
+
1003
+ # Create threads for concurrent reading of stdout and stderr
1004
+ lock = threading.Lock()
1005
+ stdout_thread = threading.Thread(
1006
+ target=_stream_reader,
1007
+ args=(process.stdout, output_stream, lock),
1008
+ daemon=True
1009
+ )
1010
+ stderr_thread = threading.Thread(
1011
+ target=_stream_reader,
1012
+ args=(process.stderr, output_stream, lock),
1013
+ daemon=True
1014
+ )
1015
+
1016
+ # Start threads
1017
+ stdout_thread.start()
1018
+ stderr_thread.start()
1019
+
1020
+ # Wait for threads to complete
1021
+ stdout_thread.join()
1022
+ stderr_thread.join()
1023
+
1024
+ # Wait for process to finish
1025
+ returncode = process.wait(timeout=timeout)
1026
+
1027
+ # Check overall installation status
1028
+ if returncode != 0:
1029
+ # Entire installation failed
1030
+ logger.warning(f"Package upgrade failed with return code {returncode}")
1031
+ # Mark all packages as not upgraded
1032
+ return [
1033
+ UpgradedPackage(
1034
+ name=pkg.name,
1035
+ version=pkg.version,
1036
+ upgraded=False,
1037
+ previous_version=pkg.version,
1038
+ is_editable=pkg.is_editable
1039
+ )
1040
+ for pkg in packages_to_upgrade
1041
+ ]
1042
+
1043
+ # Installation succeeded - now determine which packages were actually upgraded
1044
+ # Query current installed versions
1045
+ env = get_default_environment()
1046
+ current_versions = {}
1047
+
1048
+ for dist in env.iter_all_distributions():
1049
+ try:
1050
+ package_name = dist.metadata["name"]
1051
+ canonical_name = canonicalize_name(package_name)
1052
+
1053
+ # Only track packages we attempted to upgrade
1054
+ if canonical_name in package_map:
1055
+ try:
1056
+ current_version = Version(str(dist.version))
1057
+ current_versions[canonical_name] = current_version
1058
+ except InvalidVersion:
1059
+ logger.warning(f"Invalid version for {package_name}: {dist.version}")
1060
+ continue
1061
+ except Exception as e:
1062
+ logger.warning(f"Error processing package {dist.metadata.get('name', 'unknown')}: {e}")
1063
+ continue
1064
+
1065
+ # Build results by comparing current vs previous versions
1066
+ results = []
1067
+ for pkg_info in packages_to_upgrade:
1068
+ canonical_name = canonicalize_name(pkg_info.name)
1069
+ previous_version = pkg_info.version
1070
+
1071
+ # Check if package was upgraded
1072
+ current_version = current_versions.get(canonical_name)
1073
+
1074
+ if current_version is not None and current_version > previous_version:
1075
+ # Package was successfully upgraded
1076
+ upgraded_pkg = UpgradedPackage(
1077
+ name=pkg_info.name,
1078
+ version=current_version,
1079
+ upgraded=True,
1080
+ previous_version=previous_version,
1081
+ is_editable=pkg_info.is_editable
1082
+ )
1083
+ results.append(upgraded_pkg)
1084
+ logger.info(f"Successfully upgraded {pkg_info.name} from {previous_version} to {current_version}")
1085
+ else:
1086
+ # Package was not upgraded (constraints prevented it, or already at target)
1087
+ actual_version = current_version if current_version is not None else previous_version
1088
+ upgraded_pkg = UpgradedPackage(
1089
+ name=pkg_info.name,
1090
+ version=actual_version,
1091
+ upgraded=False,
1092
+ previous_version=previous_version,
1093
+ is_editable=pkg_info.is_editable
1094
+ )
1095
+ results.append(upgraded_pkg)
1096
+ logger.info(f"Package {pkg_info.name} was not upgraded (still at {actual_version})")
1097
+
1098
+ return results
1099
+
1100
+ except subprocess.TimeoutExpired:
1101
+ # Timeout occurred - kill the process and ensure cleanup
1102
+ if process is not None:
1103
+ try:
1104
+ process.kill()
1105
+ process.wait() # Ensure process is cleaned up
1106
+ except Exception as e:
1107
+ logger.warning(f"Error cleaning up timed-out process: {e}")
1108
+
1109
+ if output_stream:
1110
+ output_stream.write("ERROR: Timeout during package upgrade\n")
1111
+ output_stream.flush()
1112
+
1113
+ logger.error("Timeout during package upgrade")
1114
+
1115
+ # Mark all packages as not upgraded
1116
+ return [
1117
+ UpgradedPackage(
1118
+ name=pkg.name,
1119
+ version=pkg.version,
1120
+ upgraded=False,
1121
+ previous_version=pkg.version,
1122
+ is_editable=pkg.is_editable
1123
+ )
1124
+ for pkg in packages_to_upgrade
1125
+ ]
1126
+
1127
+ except Exception as e:
1128
+ # Other errors
1129
+ if output_stream:
1130
+ output_stream.write(f"ERROR: Failed to upgrade packages: {e}\n")
1131
+ output_stream.flush()
1132
+
1133
+ logger.error(f"Error upgrading packages: {e}")
1134
+
1135
+ # Mark all packages as not upgraded
1136
+ return [
1137
+ UpgradedPackage(
1138
+ name=pkg.name,
1139
+ version=pkg.version,
1140
+ upgraded=False,
1141
+ previous_version=pkg.version,
1142
+ is_editable=pkg.is_editable
1143
+ )
1144
+ for pkg in packages_to_upgrade
1145
+ ]