pipu-cli 0.2.0__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.
pipu_cli/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from .config import LOG_LEVEL
3
3
 
4
- __version__ = '0.2.0'
4
+ __version__ = '0.2.1'
5
5
 
6
6
 
7
7
  # Configure logging
pipu_cli/cache.py CHANGED
@@ -1,15 +1,18 @@
1
- """Package metadata caching for pipu.
1
+ """Package version caching for pipu.
2
2
 
3
- This module provides caching of package version information to speed up
3
+ This module provides caching of latest package versions from PyPI to speed up
4
4
  repeated runs of pipu. The cache is per-environment, identified by the
5
5
  Python executable path, making it compatible with venv, conda, mise, etc.
6
+
7
+ The cache stores only the latest available versions - constraint resolution
8
+ is performed at upgrade time with the current installed package state.
6
9
  """
7
10
 
8
11
  import hashlib
9
12
  import json
10
13
  import logging
11
14
  import sys
12
- from dataclasses import dataclass, field, asdict
15
+ from dataclasses import dataclass, asdict
13
16
  from datetime import datetime, timezone
14
17
  from pathlib import Path
15
18
  from typing import Dict, List, Optional, Any
@@ -22,24 +25,15 @@ from pipu_cli.config import DEFAULT_CACHE_TTL, CACHE_BASE_DIR
22
25
  logger = logging.getLogger(__name__)
23
26
 
24
27
 
25
- @dataclass
26
- class CachedPackage:
27
- """Cached information about a single package."""
28
- name: str
29
- installed_version: str
30
- latest_version: str
31
- is_upgradable: bool
32
- is_editable: bool
33
- checked_at: str # ISO format timestamp
34
-
35
-
36
28
  @dataclass
37
29
  class CacheData:
38
- """Complete cache data structure."""
30
+ """Cache data structure - stores latest versions from PyPI."""
39
31
  environment_id: str
40
32
  python_executable: str
41
33
  updated_at: str # ISO format timestamp
42
- packages: Dict[str, Dict[str, Any]] = field(default_factory=dict)
34
+ include_prereleases: bool
35
+ # Maps package name (lowercase) to latest version string
36
+ latest_versions: Dict[str, str]
43
37
 
44
38
 
45
39
  def get_environment_id() -> str:
@@ -52,7 +46,6 @@ def get_environment_id() -> str:
52
46
  :returns: Short hash identifying the environment
53
47
  """
54
48
  executable = sys.executable
55
- # Create a short hash of the executable path
56
49
  hash_obj = hashlib.sha256(executable.encode())
57
50
  return hash_obj.hexdigest()[:12]
58
51
 
@@ -99,17 +92,19 @@ def load_cache() -> Optional[CacheData]:
99
92
  environment_id=data["environment_id"],
100
93
  python_executable=data["python_executable"],
101
94
  updated_at=data["updated_at"],
102
- packages=data.get("packages", {})
95
+ include_prereleases=data.get("include_prereleases", False),
96
+ latest_versions=data.get("latest_versions", {})
103
97
  )
104
98
  except (json.JSONDecodeError, KeyError, TypeError) as e:
105
99
  logger.debug(f"Failed to load cache: {e}")
106
100
  return None
107
101
 
108
102
 
109
- def save_cache(packages: Dict[str, Dict[str, Any]]) -> Path:
110
- """Save package data to the cache.
103
+ def save_cache(latest_versions: Dict[str, str], include_prereleases: bool = False) -> Path:
104
+ """Save latest version data to the cache.
111
105
 
112
- :param packages: Dictionary mapping package names to their cached info
106
+ :param latest_versions: Dictionary mapping package names (lowercase) to latest version strings
107
+ :param include_prereleases: Whether prereleases were included in version check
113
108
  :returns: Path to the saved cache file
114
109
  """
115
110
  cache_dir = get_cache_dir()
@@ -121,7 +116,8 @@ def save_cache(packages: Dict[str, Dict[str, Any]]) -> Path:
121
116
  environment_id=get_environment_id(),
122
117
  python_executable=sys.executable,
123
118
  updated_at=datetime.now(timezone.utc).isoformat(),
124
- packages=packages
119
+ include_prereleases=include_prereleases,
120
+ latest_versions=latest_versions
125
121
  )
126
122
 
127
123
  with open(cache_path, 'w') as f:
@@ -143,7 +139,6 @@ def is_cache_fresh(ttl_seconds: int = DEFAULT_CACHE_TTL) -> bool:
143
139
 
144
140
  try:
145
141
  updated_at = datetime.fromisoformat(cache.updated_at)
146
- # Ensure updated_at is timezone-aware
147
142
  if updated_at.tzinfo is None:
148
143
  updated_at = updated_at.replace(tzinfo=timezone.utc)
149
144
 
@@ -237,61 +232,6 @@ def clear_all_caches() -> int:
237
232
  return count
238
233
 
239
234
 
240
- def get_cached_package(name: str) -> Optional[Dict[str, Any]]:
241
- """Get cached data for a specific package.
242
-
243
- :param name: Package name (case-insensitive)
244
- :returns: Package cache data or None
245
- """
246
- cache = load_cache()
247
- if cache is None:
248
- return None
249
-
250
- # Normalize name for lookup
251
- name_lower = name.lower()
252
- return cache.packages.get(name_lower)
253
-
254
-
255
- def build_cache_from_results(
256
- installed_packages: List[Any],
257
- latest_versions: Dict[Any, Any],
258
- upgradable_packages: List[Any]
259
- ) -> Dict[str, Dict[str, Any]]:
260
- """Build cache data from pipu's package analysis results.
261
-
262
- :param installed_packages: List of InstalledPackage objects
263
- :param latest_versions: Dict mapping InstalledPackage to LatestVersionInfo
264
- :param upgradable_packages: List of UpgradePackageInfo objects
265
- :returns: Dictionary suitable for save_cache()
266
- """
267
- # Create lookup for upgradable packages
268
- upgradable_names = {pkg.name.lower() for pkg in upgradable_packages}
269
-
270
- packages = {}
271
- now = datetime.now(timezone.utc).isoformat()
272
-
273
- for installed in installed_packages:
274
- name_lower = installed.name.lower()
275
-
276
- # Find latest version if available
277
- latest_version = None
278
- for inst_pkg, latest_info in latest_versions.items():
279
- if inst_pkg.name.lower() == name_lower:
280
- latest_version = str(latest_info.version)
281
- break
282
-
283
- packages[name_lower] = {
284
- "name": installed.name,
285
- "installed_version": str(installed.version),
286
- "latest_version": latest_version or str(installed.version),
287
- "is_upgradable": name_lower in upgradable_names,
288
- "is_editable": installed.is_editable,
289
- "checked_at": now
290
- }
291
-
292
- return packages
293
-
294
-
295
235
  def get_cache_info() -> Dict[str, Any]:
296
236
  """Get information about the current cache.
297
237
 
@@ -300,7 +240,7 @@ def get_cache_info() -> Dict[str, Any]:
300
240
  cache = load_cache()
301
241
  cache_path = get_cache_path()
302
242
 
303
- info = {
243
+ info: Dict[str, Any] = {
304
244
  "exists": cache is not None,
305
245
  "path": str(cache_path),
306
246
  "environment_id": get_environment_id(),
@@ -309,8 +249,27 @@ def get_cache_info() -> Dict[str, Any]:
309
249
 
310
250
  if cache:
311
251
  info["updated_at"] = cache.updated_at
312
- info["package_count"] = len(cache.packages)
313
- info["age_seconds"] = get_cache_age_seconds()
314
- info["age_human"] = format_cache_age(info["age_seconds"])
252
+ info["package_count"] = len(cache.latest_versions)
253
+ info["include_prereleases"] = cache.include_prereleases
254
+ age_seconds = get_cache_age_seconds()
255
+ info["age_seconds"] = age_seconds
256
+ info["age_human"] = format_cache_age(age_seconds)
315
257
 
316
258
  return info
259
+
260
+
261
+ def build_version_cache(
262
+ latest_versions: Dict[Any, Any]
263
+ ) -> Dict[str, str]:
264
+ """Build cache data from pipu's version check results.
265
+
266
+ :param latest_versions: Dict mapping InstalledPackage to Package with latest version
267
+ :returns: Dictionary mapping package names (lowercase) to latest version strings
268
+ """
269
+ result: Dict[str, str] = {}
270
+
271
+ for installed_pkg, latest_pkg in latest_versions.items():
272
+ name_lower = installed_pkg.name.lower()
273
+ result[name_lower] = str(latest_pkg.version)
274
+
275
+ return result
pipu_cli/cli.py CHANGED
@@ -13,13 +13,16 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskPr
13
13
  from rich.table import Table
14
14
 
15
15
  from pipu_cli.package_management import (
16
+ Package,
16
17
  inspect_installed_packages,
17
18
  get_latest_versions,
18
19
  get_latest_versions_parallel,
19
20
  resolve_upgradable_packages,
20
21
  resolve_upgradable_packages_with_reasons,
21
22
  install_packages,
23
+ reinstall_editable_packages,
22
24
  )
25
+ from packaging.version import Version
23
26
  from pipu_cli.pretty import (
24
27
  print_upgradable_packages_table,
25
28
  print_upgrade_results,
@@ -34,7 +37,7 @@ from pipu_cli.cache import (
34
37
  is_cache_fresh,
35
38
  load_cache,
36
39
  save_cache,
37
- build_cache_from_results,
40
+ build_version_cache,
38
41
  get_cache_info,
39
42
  format_cache_age,
40
43
  get_cache_age_seconds,
@@ -122,6 +125,8 @@ def update(timeout: int, pre: bool, parallel: int, debug: bool, output: str) ->
122
125
  packages and stores it locally. This speeds up subsequent upgrade
123
126
  commands by avoiding repeated network requests.
124
127
 
128
+ Constraint resolution is performed at upgrade time, not during update.
129
+
125
130
  [bold]Examples:[/bold]
126
131
  pipu update Update cache with defaults
127
132
  pipu update --parallel 4 Update with parallel requests
@@ -154,7 +159,7 @@ def update(timeout: int, pre: bool, parallel: int, debug: bool, output: str) ->
154
159
  try:
155
160
  # Step 1: Inspect installed packages
156
161
  if output != "json":
157
- console.print("[bold]Step 1/3:[/bold] Inspecting installed packages...")
162
+ console.print("[bold]Step 1/2:[/bold] Inspecting installed packages...")
158
163
 
159
164
  step1_start = time.time()
160
165
  if output != "json":
@@ -184,9 +189,9 @@ def update(timeout: int, pre: bool, parallel: int, debug: bool, output: str) ->
184
189
  console.print("[yellow]No packages found.[/yellow]")
185
190
  sys.exit(0)
186
191
 
187
- # Step 2: Check for updates
192
+ # Step 2: Fetch latest versions from PyPI and save to cache
188
193
  if output != "json":
189
- console.print("\n[bold]Step 2/3:[/bold] Fetching latest versions...")
194
+ console.print("\n[bold]Step 2/2:[/bold] Fetching latest versions from PyPI...")
190
195
 
191
196
  step2_start = time.time()
192
197
  if output != "json":
@@ -199,7 +204,7 @@ def update(timeout: int, pre: bool, parallel: int, debug: bool, output: str) ->
199
204
  ) as progress:
200
205
  task = progress.add_task("Checking packages...", total=len(installed_packages))
201
206
 
202
- def update_progress(current, total):
207
+ def update_progress(current: int, total: int) -> None:
203
208
  progress.update(task, completed=current)
204
209
 
205
210
  if parallel > 1:
@@ -223,45 +228,29 @@ def update(timeout: int, pre: bool, parallel: int, debug: bool, output: str) ->
223
228
  )
224
229
  step2_time = time.time() - step2_start
225
230
 
226
- num_updates = len(latest_versions)
227
- if output != "json":
228
- console.print(f" Found {num_updates} packages with newer versions available")
229
- if debug:
230
- console.print(f" [dim]Time: {step2_time:.2f}s[/dim]")
231
+ # Build and save cache (only latest versions, no constraint resolution)
232
+ cache_data = build_version_cache(latest_versions)
233
+ cache_path = save_cache(cache_data, include_prereleases=pre)
231
234
 
232
- # Step 3: Resolve and cache
233
- if output != "json":
234
- console.print("\n[bold]Step 3/3:[/bold] Resolving constraints and saving cache...")
235
-
236
- step3_start = time.time()
237
- all_upgradable = resolve_upgradable_packages(latest_versions, installed_packages)
238
- upgradable_packages = [pkg for pkg in all_upgradable if pkg.upgradable]
239
- step3_time = time.time() - step3_start
240
-
241
- # Build and save cache
242
- cache_data = build_cache_from_results(installed_packages, latest_versions, upgradable_packages)
243
- cache_path = save_cache(cache_data)
244
-
245
- num_upgradable = len(upgradable_packages)
235
+ num_with_updates = len(latest_versions)
246
236
 
247
237
  if output == "json":
248
238
  result = {
249
239
  "status": "success",
250
240
  "packages_checked": num_installed,
251
- "packages_with_updates": num_updates,
252
- "packages_upgradable": num_upgradable,
241
+ "packages_with_updates": num_with_updates,
253
242
  "cache_path": str(cache_path)
254
243
  }
255
244
  print(json.dumps(result, indent=2))
256
245
  else:
257
- console.print(f" {num_upgradable} packages can be safely upgraded")
246
+ console.print(f" Cached {num_with_updates} packages with updates available")
258
247
  if debug:
259
- console.print(f" [dim]Time: {step3_time:.2f}s[/dim]")
248
+ console.print(f" [dim]Time: {step2_time:.2f}s[/dim]")
260
249
  console.print(f" [dim]Cache saved to: {cache_path}[/dim]")
261
250
 
262
- console.print("\n[bold green]Package list updated![/bold green] Run [cyan]pipu upgrade[/cyan] to upgrade your packages.")
251
+ console.print("\n[bold green]Cache updated![/bold green] Run [cyan]pipu upgrade[/cyan] to upgrade your packages.")
263
252
 
264
- total_time = step1_time + step2_time + step3_time
253
+ total_time = step1_time + step2_time
265
254
  if debug:
266
255
  console.print(f"[dim]Total time: {total_time:.2f}s[/dim]")
267
256
 
@@ -460,55 +449,39 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
460
449
  console.print("[yellow]No packages found.[/yellow]")
461
450
  sys.exit(0)
462
451
 
463
- # Step 2: Check for updates (from cache or network)
452
+ # Step 2: Get latest versions (from cache or network)
464
453
  if output != "json":
465
454
  if use_cache:
466
455
  console.print("\n[bold]Step 2/5:[/bold] Loading cached version data...")
467
456
  else:
468
- console.print("\n[bold]Step 2/5:[/bold] Checking for updates...")
457
+ console.print("\n[bold]Step 2/5:[/bold] Fetching latest versions from PyPI...")
469
458
 
470
459
  step2_start = time.time()
471
460
  latest_versions: dict = {}
461
+ cache_was_used = False
472
462
 
473
463
  if use_cache:
474
- # Load from cache
464
+ # Load latest versions from cache (skip PyPI queries entirely)
475
465
  cache_data = load_cache()
476
- if cache_data and cache_data.packages:
477
- # Reconstruct latest_versions from cache
478
- # We need to fetch fresh for accurate constraint resolution
479
- # Cache is mainly to avoid the slow PyPI queries
480
- if output != "json":
481
- with Progress(
482
- TextColumn("[progress.description]{task.description}"),
483
- BarColumn(),
484
- TaskProgressColumn(),
485
- console=console,
486
- transient=True
487
- ) as progress:
488
- task = progress.add_task("Checking packages...", total=len(installed_packages))
489
-
490
- def update_progress(current, total):
491
- progress.update(task, completed=current)
492
-
493
- if parallel > 1:
494
- latest_versions = get_latest_versions_parallel(
495
- installed_packages, timeout=timeout, include_prereleases=pre,
496
- max_workers=parallel, progress_callback=update_progress
497
- )
498
- else:
499
- latest_versions = get_latest_versions(
500
- installed_packages, timeout=timeout, include_prereleases=pre,
501
- progress_callback=update_progress
502
- )
503
- else:
504
- if parallel > 1:
505
- latest_versions = get_latest_versions_parallel(
506
- installed_packages, timeout=timeout, include_prereleases=pre, max_workers=parallel
507
- )
508
- else:
509
- latest_versions = get_latest_versions(
510
- installed_packages, timeout=timeout, include_prereleases=pre
511
- )
466
+ if cache_data and cache_data.latest_versions:
467
+ # Reconstruct latest_versions dict from cache
468
+ # Maps InstalledPackage -> Package with latest version
469
+ for installed_pkg in installed_packages:
470
+ name_lower = installed_pkg.name.lower()
471
+ if name_lower in cache_data.latest_versions:
472
+ cached_version = cache_data.latest_versions[name_lower]
473
+ try:
474
+ latest_ver = Version(cached_version)
475
+ # Only include if it's actually newer
476
+ if latest_ver > installed_pkg.version:
477
+ latest_pkg = Package(
478
+ name=installed_pkg.name,
479
+ version=latest_ver
480
+ )
481
+ latest_versions[installed_pkg] = latest_pkg
482
+ except Exception:
483
+ pass # Skip invalid versions
484
+ cache_was_used = True
512
485
  else:
513
486
  use_cache = False
514
487
 
@@ -524,7 +497,7 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
524
497
  ) as progress:
525
498
  task = progress.add_task("Checking packages...", total=len(installed_packages))
526
499
 
527
- def update_progress(current, total):
500
+ def update_progress(current: int, total: int) -> None:
528
501
  progress.update(task, completed=current)
529
502
 
530
503
  if parallel > 1:
@@ -547,11 +520,18 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
547
520
  installed_packages, timeout=timeout, include_prereleases=pre
548
521
  )
549
522
 
523
+ # Update cache with fresh data
524
+ if cache_enabled:
525
+ version_cache = build_version_cache(latest_versions)
526
+ save_cache(version_cache, include_prereleases=pre)
527
+
550
528
  step2_time = time.time() - step2_start
551
529
 
552
530
  num_updates = len(latest_versions)
553
531
  if output != "json":
554
532
  console.print(f" Found {num_updates} packages with newer versions available")
533
+ if cache_was_used:
534
+ console.print(" [dim](from cache)[/dim]")
555
535
  if debug:
556
536
  console.print(f" [dim]Time: {step2_time:.2f}s[/dim]")
557
537
 
@@ -578,11 +558,6 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
578
558
 
579
559
  step3_time = time.time() - step3_start
580
560
 
581
- # Update cache with fresh data (if we fetched from network)
582
- if not use_cache and cache_enabled:
583
- cache_data = build_cache_from_results(installed_packages, latest_versions, upgradable_packages)
584
- save_cache(cache_data)
585
-
586
561
  # Apply exclusions
587
562
  excluded_names = set()
588
563
  if exclude:
@@ -667,9 +642,14 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
667
642
  console.print("[yellow]Upgrade cancelled.[/yellow]")
668
643
  sys.exit(0)
669
644
 
645
+ # Separate editable and non-editable packages
646
+ editable_packages = [pkg for pkg in can_upgrade if pkg.is_editable]
647
+ non_editable_packages = [pkg for pkg in can_upgrade if not pkg.is_editable]
648
+
670
649
  # Step 5: Install packages
671
650
  if output != "json":
672
- console.print("\n[bold]Step 5/5:[/bold] Upgrading packages...\n")
651
+ total_to_upgrade = len(non_editable_packages) + len(editable_packages)
652
+ console.print(f"[bold]Step 5/5:[/bold] Upgrading {total_to_upgrade} package(s)...\n")
673
653
  step5_start = time.time()
674
654
 
675
655
  # Save state for potential rollback
@@ -681,12 +661,31 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
681
661
  save_state(pre_upgrade_packages, "Pre-upgrade state")
682
662
 
683
663
  stream = ConsoleStream(console) if output != "json" else None
684
- results = install_packages(
685
- can_upgrade,
686
- output_stream=stream,
687
- timeout=300,
688
- version_constraints=package_constraints if package_constraints else None
689
- )
664
+ results = []
665
+
666
+ # First, upgrade non-editable packages via pip install --upgrade
667
+ if non_editable_packages:
668
+ if output != "json":
669
+ console.print(f"Upgrading {len(non_editable_packages)} regular package(s)...\n")
670
+ regular_results = install_packages(
671
+ non_editable_packages,
672
+ output_stream=stream,
673
+ timeout=300,
674
+ version_constraints=package_constraints if package_constraints else None
675
+ )
676
+ results.extend(regular_results)
677
+
678
+ # Then, reinstall editable packages to update their versions
679
+ if editable_packages:
680
+ if output != "json":
681
+ console.print(f"\nReinstalling {len(editable_packages)} editable package(s)...\n")
682
+ editable_results = reinstall_editable_packages(
683
+ editable_packages,
684
+ output_stream=stream,
685
+ timeout=300
686
+ )
687
+ results.extend(editable_results)
688
+
690
689
  step5_time = time.time() - step5_start
691
690
 
692
691
  # Update requirements file if requested
pipu_cli/config_file.py CHANGED
@@ -1,6 +1,5 @@
1
1
  """Configuration file support for pipu."""
2
2
 
3
- import os
4
3
  from pathlib import Path
5
4
  from typing import Dict, Any, Optional
6
5
 
@@ -54,6 +54,7 @@ class InstalledPackage(Package):
54
54
  """Information about an installed package."""
55
55
  constrained_dependencies: Dict[str, str] = field(default_factory=dict, hash=False, compare=False)
56
56
  is_editable: bool = False
57
+ editable_location: Optional[str] = None
57
58
 
58
59
 
59
60
  @dataclass(frozen=True)
@@ -62,6 +63,7 @@ class UpgradePackageInfo(Package):
62
63
  upgradable: bool
63
64
  latest_version: Version
64
65
  is_editable: bool = False
66
+ editable_location: Optional[str] = None
65
67
 
66
68
 
67
69
  @dataclass(frozen=True)
@@ -70,6 +72,7 @@ class UpgradedPackage(Package):
70
72
  upgraded: bool
71
73
  previous_version: Version
72
74
  is_editable: bool = False
75
+ editable_location: Optional[str] = None
73
76
 
74
77
 
75
78
  @dataclass(frozen=True)
@@ -78,6 +81,7 @@ class BlockedPackageInfo(Package):
78
81
  latest_version: Version
79
82
  blocked_by: List[str] # List of "package_name (constraint)" strings
80
83
  is_editable: bool = False
84
+ editable_location: Optional[str] = None
81
85
 
82
86
 
83
87
  def inspect_installed_packages(timeout: int = 10) -> List[InstalledPackage]:
@@ -115,8 +119,9 @@ def inspect_installed_packages(timeout: int = 10) -> List[InstalledPackage]:
115
119
  logger.warning(f"Invalid version for {package_name}: {dist.version}. Skipping.")
116
120
  continue
117
121
 
118
- # Check if package is editable
122
+ # Check if package is editable and get its location
119
123
  is_editable = canonical_name in editable_packages
124
+ editable_location = editable_packages.get(canonical_name) if is_editable else None
120
125
 
121
126
  # Extract constrained dependencies
122
127
  constrained_dependencies = _extract_constrained_dependencies(dist)
@@ -126,6 +131,7 @@ def inspect_installed_packages(timeout: int = 10) -> List[InstalledPackage]:
126
131
  name=package_name,
127
132
  version=package_version,
128
133
  is_editable=is_editable,
134
+ editable_location=editable_location,
129
135
  constrained_dependencies=constrained_dependencies
130
136
  )
131
137
 
@@ -788,7 +794,8 @@ def resolve_upgradable_packages(
788
794
  version=installed_pkg.version,
789
795
  upgradable=can_upgrade,
790
796
  latest_version=latest_version,
791
- is_editable=installed_pkg.is_editable
797
+ is_editable=installed_pkg.is_editable,
798
+ editable_location=installed_pkg.editable_location
792
799
  ))
793
800
 
794
801
  return result
@@ -891,7 +898,8 @@ def resolve_upgradable_packages_with_reasons(
891
898
  version=installed_pkg.version,
892
899
  upgradable=True,
893
900
  latest_version=latest_version,
894
- is_editable=installed_pkg.is_editable
901
+ is_editable=installed_pkg.is_editable,
902
+ editable_location=installed_pkg.editable_location
895
903
  ))
896
904
  elif is_actual_upgrade:
897
905
  # Blocked package
@@ -901,7 +909,8 @@ def resolve_upgradable_packages_with_reasons(
901
909
  version=installed_pkg.version,
902
910
  latest_version=latest_version,
903
911
  blocked_by=reasons,
904
- is_editable=installed_pkg.is_editable
912
+ is_editable=installed_pkg.is_editable,
913
+ editable_location=installed_pkg.editable_location
905
914
  ))
906
915
 
907
916
  return upgradable, blocked
@@ -1035,7 +1044,8 @@ def install_packages(
1035
1044
  version=pkg.version,
1036
1045
  upgraded=False,
1037
1046
  previous_version=pkg.version,
1038
- is_editable=pkg.is_editable
1047
+ is_editable=pkg.is_editable,
1048
+ editable_location=pkg.editable_location
1039
1049
  )
1040
1050
  for pkg in packages_to_upgrade
1041
1051
  ]
@@ -1078,7 +1088,8 @@ def install_packages(
1078
1088
  version=current_version,
1079
1089
  upgraded=True,
1080
1090
  previous_version=previous_version,
1081
- is_editable=pkg_info.is_editable
1091
+ is_editable=pkg_info.is_editable,
1092
+ editable_location=pkg_info.editable_location
1082
1093
  )
1083
1094
  results.append(upgraded_pkg)
1084
1095
  logger.info(f"Successfully upgraded {pkg_info.name} from {previous_version} to {current_version}")
@@ -1090,7 +1101,8 @@ def install_packages(
1090
1101
  version=actual_version,
1091
1102
  upgraded=False,
1092
1103
  previous_version=previous_version,
1093
- is_editable=pkg_info.is_editable
1104
+ is_editable=pkg_info.is_editable,
1105
+ editable_location=pkg_info.editable_location
1094
1106
  )
1095
1107
  results.append(upgraded_pkg)
1096
1108
  logger.info(f"Package {pkg_info.name} was not upgraded (still at {actual_version})")
@@ -1119,7 +1131,8 @@ def install_packages(
1119
1131
  version=pkg.version,
1120
1132
  upgraded=False,
1121
1133
  previous_version=pkg.version,
1122
- is_editable=pkg.is_editable
1134
+ is_editable=pkg.is_editable,
1135
+ editable_location=pkg.editable_location
1123
1136
  )
1124
1137
  for pkg in packages_to_upgrade
1125
1138
  ]
@@ -1139,7 +1152,139 @@ def install_packages(
1139
1152
  version=pkg.version,
1140
1153
  upgraded=False,
1141
1154
  previous_version=pkg.version,
1142
- is_editable=pkg.is_editable
1155
+ is_editable=pkg.is_editable,
1156
+ editable_location=pkg.editable_location
1143
1157
  )
1144
1158
  for pkg in packages_to_upgrade
1145
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipu-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A cute Python package updater
5
5
  Author-email: Scott Arne Johnson <scott.arne.johnson@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,16 @@
1
+ pipu_cli/__init__.py,sha256=DYhOEmnqarSXQjteuoviNNHqmkBb1xyBh7E49IDBJzs,1191
2
+ pipu_cli/cache.py,sha256=d5BOItcJSlNfPEnYb4tbMmntzIDjWIl1ZUb5Xo5bJiQ,8243
3
+ pipu_cli/cli.py,sha256=kj1ANOl_s-Ac02ZR-NLLREnM2_Av__KqMMBYr3cS2sY,34761
4
+ pipu_cli/config.py,sha256=lixyWhJBz5GqdyRIygc4g5Wzc94HPC4sVFCgeINtQtw,1542
5
+ pipu_cli/config_file.py,sha256=0vJbaDS4WDR4RRYA8gKLDtQRi-Stzm9a85qjmnNCqys,2186
6
+ pipu_cli/output.py,sha256=9g64hxHIXxJlq0mmhRwZnbPMMGPpTfSRkH90rc-QPjA,3510
7
+ pipu_cli/package_management.py,sha256=TXATyeu0RmoRbN1nsDKhZCwlnrZGgj-lK5GnL7opoh8,50661
8
+ pipu_cli/pretty.py,sha256=6qBohKDtocm6vJc2rtH9RLgvvHYJiaGMnmhs6QyC0kE,9293
9
+ pipu_cli/requirements.py,sha256=zbh7XwxD9he_5csJitEGT0NfiE4qbXPw_-JSDuHv4G8,2665
10
+ pipu_cli/rollback.py,sha256=gL9ueYtAKDoALqRfJE2S5gnw1Id5QBLaPC1UL_WjzzY,2980
11
+ pipu_cli-0.2.1.dist-info/licenses/LICENSE,sha256=q6TxVbSI0WMB9ulF2V0FWQfeA5om3d-T9X7QwuhdiYE,1075
12
+ pipu_cli-0.2.1.dist-info/METADATA,sha256=G39M3WDHd-oYkPEm4-pBTF6rlPNYIjUrKr717DyByVY,11919
13
+ pipu_cli-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ pipu_cli-0.2.1.dist-info/entry_points.txt,sha256=VSv6od00zOPblnFPflNLaci4jBtQIgLYJjL1BKxLz_o,42
15
+ pipu_cli-0.2.1.dist-info/top_level.txt,sha256=z3Yce93-jGQjGRpsGZUZvbS8osh3OyS7MVpzG0uBE5M,9
16
+ pipu_cli-0.2.1.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- pipu_cli/__init__.py,sha256=oN3HVmBGVKNTLG_Ut4GZrH1W3EfUrK_MJqZQ1NIwOnc,1191
2
- pipu_cli/cache.py,sha256=kQa1GrrDcCT_CcJuMPX79BLBrcB55NArtxQTzzwyNOE,9199
3
- pipu_cli/cli.py,sha256=W1LNlo3zbO11x-AiFJHJY1Z2eFSNIVcnECLANNgPnwY,34807
4
- pipu_cli/config.py,sha256=lixyWhJBz5GqdyRIygc4g5Wzc94HPC4sVFCgeINtQtw,1542
5
- pipu_cli/config_file.py,sha256=2X2UqtJQV7M-czNTx_Hqu-rWLKkK7cn8l8kZ4HNbBkM,2196
6
- pipu_cli/output.py,sha256=9g64hxHIXxJlq0mmhRwZnbPMMGPpTfSRkH90rc-QPjA,3510
7
- pipu_cli/package_management.py,sha256=p4m7o5W-RZu4wOZMiU5Sp6WvvnZ2SbUoCIXqYIT3yQU,44822
8
- pipu_cli/pretty.py,sha256=6qBohKDtocm6vJc2rtH9RLgvvHYJiaGMnmhs6QyC0kE,9293
9
- pipu_cli/requirements.py,sha256=zbh7XwxD9he_5csJitEGT0NfiE4qbXPw_-JSDuHv4G8,2665
10
- pipu_cli/rollback.py,sha256=gL9ueYtAKDoALqRfJE2S5gnw1Id5QBLaPC1UL_WjzzY,2980
11
- pipu_cli-0.2.0.dist-info/licenses/LICENSE,sha256=q6TxVbSI0WMB9ulF2V0FWQfeA5om3d-T9X7QwuhdiYE,1075
12
- pipu_cli-0.2.0.dist-info/METADATA,sha256=ZArCxjCSlgfz4uTSYb0Szpa3_fOjpOJqaNYxjLHF_jU,11919
13
- pipu_cli-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- pipu_cli-0.2.0.dist-info/entry_points.txt,sha256=VSv6od00zOPblnFPflNLaci4jBtQIgLYJjL1BKxLz_o,42
15
- pipu_cli-0.2.0.dist-info/top_level.txt,sha256=z3Yce93-jGQjGRpsGZUZvbS8osh3OyS7MVpzG0uBE5M,9
16
- pipu_cli-0.2.0.dist-info/RECORD,,