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