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/__init__.py +2 -2
- pipu_cli/cache.py +316 -0
- pipu_cli/cli.py +863 -813
- pipu_cli/config.py +7 -58
- pipu_cli/config_file.py +80 -0
- pipu_cli/output.py +99 -0
- pipu_cli/package_management.py +1145 -0
- pipu_cli/pretty.py +286 -0
- pipu_cli/requirements.py +100 -0
- pipu_cli/rollback.py +110 -0
- pipu_cli-0.2.0.dist-info/METADATA +422 -0
- pipu_cli-0.2.0.dist-info/RECORD +16 -0
- pipu_cli/common.py +0 -4
- pipu_cli/internals.py +0 -815
- pipu_cli/package_constraints.py +0 -2296
- pipu_cli/thread_safe.py +0 -243
- pipu_cli/ui/__init__.py +0 -51
- pipu_cli/ui/apps.py +0 -1464
- pipu_cli/ui/constants.py +0 -33
- pipu_cli/ui/modal_dialogs.py +0 -1375
- pipu_cli/ui/table_widgets.py +0 -344
- pipu_cli/utils.py +0 -169
- pipu_cli-0.1.dev7.dist-info/METADATA +0 -517
- pipu_cli-0.1.dev7.dist-info/RECORD +0 -19
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/WHEEL +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
]
|