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