pipu-cli 0.2.0__py3-none-any.whl → 0.2.2__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.2'
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
-
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
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)
240
234
 
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
 
@@ -278,6 +267,225 @@ def update(timeout: int, pre: bool, parallel: int, debug: bool, output: str) ->
278
267
  sys.exit(1)
279
268
 
280
269
 
270
+ # --- Helper functions for upgrade command ---
271
+
272
+ def _step1_inspect_packages(
273
+ console: Console, output: str, timeout: int, debug: bool
274
+ ) -> tuple[list, float]:
275
+ """Step 1: Inspect installed packages."""
276
+ if output != "json":
277
+ console.print("[bold]Step 1/5:[/bold] Inspecting installed packages...")
278
+
279
+ step_start = time.time()
280
+ if output != "json":
281
+ with Progress(
282
+ SpinnerColumn(),
283
+ TextColumn("[progress.description]{task.description}"),
284
+ console=console,
285
+ transient=True
286
+ ) as progress:
287
+ progress.add_task("Loading packages...", total=None)
288
+ installed_packages = inspect_installed_packages(timeout=timeout)
289
+ else:
290
+ installed_packages = inspect_installed_packages(timeout=timeout)
291
+ step_time = time.time() - step_start
292
+
293
+ if output != "json":
294
+ console.print(f" Found {len(installed_packages)} installed packages")
295
+ if debug:
296
+ console.print(f" [dim]Time: {step_time:.2f}s[/dim]")
297
+
298
+ return installed_packages, step_time
299
+
300
+
301
+ def _step2_get_latest_versions(
302
+ console: Console, output: str, debug: bool,
303
+ installed_packages: list, use_cache: bool, cache_enabled: bool,
304
+ timeout: int, pre: bool, parallel: int
305
+ ) -> tuple[dict, float, bool]:
306
+ """Step 2: Get latest versions from cache or network."""
307
+ if output != "json":
308
+ if use_cache:
309
+ console.print("\n[bold]Step 2/5:[/bold] Loading cached version data...")
310
+ else:
311
+ console.print("\n[bold]Step 2/5:[/bold] Fetching latest versions from PyPI...")
312
+
313
+ step_start = time.time()
314
+ latest_versions: dict = {}
315
+ cache_was_used = False
316
+
317
+ if use_cache:
318
+ cache_data = load_cache()
319
+ if cache_data and cache_data.latest_versions:
320
+ for installed_pkg in installed_packages:
321
+ name_lower = installed_pkg.name.lower()
322
+ if name_lower in cache_data.latest_versions:
323
+ cached_version = cache_data.latest_versions[name_lower]
324
+ try:
325
+ latest_ver = Version(cached_version)
326
+ if latest_ver > installed_pkg.version:
327
+ latest_pkg = Package(name=installed_pkg.name, version=latest_ver)
328
+ latest_versions[installed_pkg] = latest_pkg
329
+ except Exception:
330
+ pass
331
+ cache_was_used = True
332
+ else:
333
+ use_cache = False
334
+
335
+ if not use_cache:
336
+ if output != "json":
337
+ with Progress(
338
+ TextColumn("[progress.description]{task.description}"),
339
+ BarColumn(),
340
+ TaskProgressColumn(),
341
+ console=console,
342
+ transient=True
343
+ ) as progress:
344
+ task = progress.add_task("Checking packages...", total=len(installed_packages))
345
+
346
+ def update_progress(current: int, total: int) -> None:
347
+ progress.update(task, completed=current)
348
+
349
+ if parallel > 1:
350
+ latest_versions = get_latest_versions_parallel(
351
+ installed_packages, timeout=timeout, include_prereleases=pre,
352
+ max_workers=parallel, progress_callback=update_progress
353
+ )
354
+ else:
355
+ latest_versions = get_latest_versions(
356
+ installed_packages, timeout=timeout, include_prereleases=pre,
357
+ progress_callback=update_progress
358
+ )
359
+ else:
360
+ if parallel > 1:
361
+ latest_versions = get_latest_versions_parallel(
362
+ installed_packages, timeout=timeout, include_prereleases=pre, max_workers=parallel
363
+ )
364
+ else:
365
+ latest_versions = get_latest_versions(
366
+ installed_packages, timeout=timeout, include_prereleases=pre
367
+ )
368
+
369
+ if cache_enabled:
370
+ version_cache = build_version_cache(latest_versions)
371
+ save_cache(version_cache, include_prereleases=pre)
372
+
373
+ step_time = time.time() - step_start
374
+
375
+ if output != "json":
376
+ console.print(f" Found {len(latest_versions)} packages with newer versions available")
377
+ if cache_was_used:
378
+ console.print(" [dim](from cache)[/dim]")
379
+ if debug:
380
+ console.print(f" [dim]Time: {step_time:.2f}s[/dim]")
381
+
382
+ return latest_versions, step_time, cache_was_used
383
+
384
+
385
+ def _step3_resolve_packages(
386
+ console: Console, output: str, debug: bool,
387
+ latest_versions: dict, installed_packages: list, show_blocked: bool,
388
+ exclude: str, packages: tuple
389
+ ) -> tuple[list, list, dict, float]:
390
+ """Step 3: Resolve upgradable packages and apply filters."""
391
+ if output != "json":
392
+ console.print("\n[bold]Step 3/5:[/bold] Resolving dependency constraints...")
393
+ step_start = time.time()
394
+
395
+ if show_blocked:
396
+ upgradable_packages, blocked_packages = resolve_upgradable_packages_with_reasons(
397
+ latest_versions, installed_packages
398
+ )
399
+ else:
400
+ all_upgradable = resolve_upgradable_packages(latest_versions, installed_packages)
401
+ upgradable_packages = [pkg for pkg in all_upgradable if pkg.upgradable]
402
+ blocked_packages = []
403
+
404
+ step_time = time.time() - step_start
405
+
406
+ # Apply exclusions
407
+ excluded_names = set()
408
+ if exclude:
409
+ excluded_names = {name.strip().lower() for name in exclude.split(',')}
410
+ if debug and excluded_names:
411
+ console.print(f" [dim]Excluding: {', '.join(sorted(excluded_names))}[/dim]")
412
+
413
+ can_upgrade = [pkg for pkg in upgradable_packages if pkg.name.lower() not in excluded_names]
414
+
415
+ # Parse package specifications and filter
416
+ package_constraints: dict = {}
417
+ if packages:
418
+ requested_packages = set()
419
+ for spec in packages:
420
+ name, constraint = parse_package_spec(spec)
421
+ requested_packages.add(name.lower())
422
+ if constraint:
423
+ package_constraints[name.lower()] = constraint
424
+
425
+ can_upgrade = [pkg for pkg in can_upgrade if pkg.name.lower() in requested_packages]
426
+
427
+ if debug:
428
+ console.print(f" [dim]Filtering to: {', '.join(packages)}[/dim]")
429
+ if package_constraints:
430
+ console.print(f" [dim]Version constraints: {package_constraints}[/dim]")
431
+
432
+ if output != "json":
433
+ console.print(f" {len(can_upgrade)} packages can be safely upgraded")
434
+ if debug:
435
+ console.print(f" [dim]Time: {step_time:.2f}s[/dim]")
436
+
437
+ return can_upgrade, blocked_packages, package_constraints, step_time
438
+
439
+
440
+ def _step5_install_packages(
441
+ console: Console, output: str,
442
+ can_upgrade: list, package_constraints: dict
443
+ ) -> tuple[list, float]:
444
+ """Step 5: Install/upgrade packages."""
445
+ editable_packages = [pkg for pkg in can_upgrade if pkg.is_editable]
446
+ non_editable_packages = [pkg for pkg in can_upgrade if not pkg.is_editable]
447
+
448
+ if output != "json":
449
+ total_to_upgrade = len(non_editable_packages) + len(editable_packages)
450
+ console.print(f"[bold]Step 5/5:[/bold] Upgrading {total_to_upgrade} package(s)...\n")
451
+ step_start = time.time()
452
+
453
+ # Save state for potential rollback
454
+ from pipu_cli.rollback import save_state
455
+ pre_upgrade_packages = [
456
+ {"name": pkg.name, "version": str(pkg.version)}
457
+ for pkg in can_upgrade
458
+ ]
459
+ save_state(pre_upgrade_packages, "Pre-upgrade state")
460
+
461
+ stream = ConsoleStream(console) if output != "json" else None
462
+ results = []
463
+
464
+ if non_editable_packages:
465
+ if output != "json":
466
+ console.print(f"Upgrading {len(non_editable_packages)} regular package(s)...\n")
467
+ regular_results = install_packages(
468
+ non_editable_packages,
469
+ output_stream=stream,
470
+ timeout=300,
471
+ version_constraints=package_constraints if package_constraints else None
472
+ )
473
+ results.extend(regular_results)
474
+
475
+ if editable_packages:
476
+ if output != "json":
477
+ console.print(f"\nReinstalling {len(editable_packages)} editable package(s)...\n")
478
+ editable_results = reinstall_editable_packages(
479
+ editable_packages,
480
+ output_stream=stream,
481
+ timeout=300
482
+ )
483
+ results.extend(editable_results)
484
+
485
+ step_time = time.time() - step_start
486
+ return results, step_time
487
+
488
+
281
489
  @cli.command()
282
490
  @click.argument('packages', nargs=-1)
283
491
  @click.option(
@@ -429,29 +637,7 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
429
637
  console.print(f"[dim]Using cached data ({format_cache_age(cache_age)})[/dim]\n")
430
638
 
431
639
  # Step 1: Inspect installed packages
432
- if output != "json":
433
- console.print("[bold]Step 1/5:[/bold] Inspecting installed packages...")
434
-
435
- step1_start = time.time()
436
- if output != "json":
437
- with Progress(
438
- SpinnerColumn(),
439
- TextColumn("[progress.description]{task.description}"),
440
- console=console,
441
- transient=True
442
- ) as progress:
443
- task = progress.add_task("Loading packages...", total=None)
444
- installed_packages = inspect_installed_packages(timeout=timeout)
445
- progress.update(task, completed=True)
446
- else:
447
- installed_packages = inspect_installed_packages(timeout=timeout)
448
- step1_time = time.time() - step1_start
449
-
450
- num_installed = len(installed_packages)
451
- if output != "json":
452
- console.print(f" Found {num_installed} installed packages")
453
- if debug:
454
- console.print(f" [dim]Time: {step1_time:.2f}s[/dim]")
640
+ installed_packages, step1_time = _step1_inspect_packages(console, output, timeout, debug)
455
641
 
456
642
  if not installed_packages:
457
643
  if output == "json":
@@ -460,100 +646,11 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
460
646
  console.print("[yellow]No packages found.[/yellow]")
461
647
  sys.exit(0)
462
648
 
463
- # Step 2: Check for updates (from cache or network)
464
- if output != "json":
465
- if use_cache:
466
- console.print("\n[bold]Step 2/5:[/bold] Loading cached version data...")
467
- else:
468
- console.print("\n[bold]Step 2/5:[/bold] Checking for updates...")
469
-
470
- step2_start = time.time()
471
- latest_versions: dict = {}
472
-
473
- if use_cache:
474
- # Load from cache
475
- 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
- )
512
- else:
513
- use_cache = False
514
-
515
- if not use_cache:
516
- # Fetch from network
517
- if output != "json":
518
- with Progress(
519
- TextColumn("[progress.description]{task.description}"),
520
- BarColumn(),
521
- TaskProgressColumn(),
522
- console=console,
523
- transient=True
524
- ) as progress:
525
- task = progress.add_task("Checking packages...", total=len(installed_packages))
526
-
527
- def update_progress(current, total):
528
- progress.update(task, completed=current)
529
-
530
- if parallel > 1:
531
- latest_versions = get_latest_versions_parallel(
532
- installed_packages, timeout=timeout, include_prereleases=pre,
533
- max_workers=parallel, progress_callback=update_progress
534
- )
535
- else:
536
- latest_versions = get_latest_versions(
537
- installed_packages, timeout=timeout, include_prereleases=pre,
538
- progress_callback=update_progress
539
- )
540
- else:
541
- if parallel > 1:
542
- latest_versions = get_latest_versions_parallel(
543
- installed_packages, timeout=timeout, include_prereleases=pre, max_workers=parallel
544
- )
545
- else:
546
- latest_versions = get_latest_versions(
547
- installed_packages, timeout=timeout, include_prereleases=pre
548
- )
549
-
550
- step2_time = time.time() - step2_start
551
-
552
- num_updates = len(latest_versions)
553
- if output != "json":
554
- console.print(f" Found {num_updates} packages with newer versions available")
555
- if debug:
556
- console.print(f" [dim]Time: {step2_time:.2f}s[/dim]")
649
+ # Step 2: Get latest versions (from cache or network)
650
+ latest_versions, step2_time, _ = _step2_get_latest_versions(
651
+ console, output, debug, installed_packages, use_cache, cache_enabled,
652
+ timeout, pre, parallel
653
+ )
557
654
 
558
655
  if not latest_versions:
559
656
  if output == "json":
@@ -563,52 +660,10 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
563
660
  sys.exit(0)
564
661
 
565
662
  # Step 3: Resolve upgradable packages
566
- if output != "json":
567
- console.print("\n[bold]Step 3/5:[/bold] Resolving dependency constraints...")
568
- step3_start = time.time()
569
-
570
- if show_blocked:
571
- upgradable_packages, blocked_packages = resolve_upgradable_packages_with_reasons(
572
- latest_versions, installed_packages
573
- )
574
- else:
575
- all_upgradable = resolve_upgradable_packages(latest_versions, installed_packages)
576
- upgradable_packages = [pkg for pkg in all_upgradable if pkg.upgradable]
577
- blocked_packages = []
578
-
579
- step3_time = time.time() - step3_start
580
-
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
- # Apply exclusions
587
- excluded_names = set()
588
- if exclude:
589
- excluded_names = {name.strip().lower() for name in exclude.split(',')}
590
- if debug and excluded_names:
591
- console.print(f" [dim]Excluding: {', '.join(sorted(excluded_names))}[/dim]")
592
-
593
- # Filter to only upgradable packages (excluding excluded ones)
594
- can_upgrade = [pkg for pkg in upgradable_packages if pkg.name.lower() not in excluded_names]
595
-
596
- # Parse package specifications and filter to specific packages if provided
597
- package_constraints = {}
598
- if packages:
599
- requested_packages = set()
600
- for spec in packages:
601
- name, constraint = parse_package_spec(spec)
602
- requested_packages.add(name.lower())
603
- if constraint:
604
- package_constraints[name.lower()] = constraint
605
-
606
- can_upgrade = [pkg for pkg in can_upgrade if pkg.name.lower() in requested_packages]
607
-
608
- if debug:
609
- console.print(f" [dim]Filtering to: {', '.join(packages)}[/dim]")
610
- if package_constraints:
611
- console.print(f" [dim]Version constraints: {package_constraints}[/dim]")
663
+ can_upgrade, blocked_packages, package_constraints, step3_time = _step3_resolve_packages(
664
+ console, output, debug, latest_versions, installed_packages, show_blocked,
665
+ exclude, packages
666
+ )
612
667
 
613
668
  if not can_upgrade:
614
669
  if output == "json":
@@ -625,12 +680,6 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
625
680
  print_blocked_packages_table(blocked_packages, console=console)
626
681
  sys.exit(0)
627
682
 
628
- num_upgradable = len(can_upgrade)
629
- if output != "json":
630
- console.print(f" {num_upgradable} packages can be safely upgraded")
631
- if debug:
632
- console.print(f" [dim]Time: {step3_time:.2f}s[/dim]")
633
-
634
683
  # Step 4: Display table and ask for confirmation
635
684
  if output == "json":
636
685
  assert json_formatter is not None
@@ -668,26 +717,7 @@ def upgrade(packages: tuple[str, ...], timeout: int, pre: bool, yes: bool, debug
668
717
  sys.exit(0)
669
718
 
670
719
  # Step 5: Install packages
671
- if output != "json":
672
- console.print("\n[bold]Step 5/5:[/bold] Upgrading packages...\n")
673
- step5_start = time.time()
674
-
675
- # Save state for potential rollback
676
- from pipu_cli.rollback import save_state
677
- pre_upgrade_packages = [
678
- {"name": pkg.name, "version": str(pkg.version)}
679
- for pkg in can_upgrade
680
- ]
681
- save_state(pre_upgrade_packages, "Pre-upgrade state")
682
-
683
- 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
- )
690
- step5_time = time.time() - step5_start
720
+ results, step5_time = _step5_install_packages(console, output, can_upgrade, package_constraints)
691
721
 
692
722
  # Update requirements file if requested
693
723
  if update_requirements:
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.2
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=sGvHQxiNJy0We55W1Z7bjPQfsyJDbeFPhNxH4Qa60Io,1191
2
+ pipu_cli/cache.py,sha256=d5BOItcJSlNfPEnYb4tbMmntzIDjWIl1ZUb5Xo5bJiQ,8243
3
+ pipu_cli/cli.py,sha256=Sl3kHM5-yYVsomXYRy7KCL0e6dC_mSS5fFIdMgf6pVg,34963
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.2.dist-info/licenses/LICENSE,sha256=q6TxVbSI0WMB9ulF2V0FWQfeA5om3d-T9X7QwuhdiYE,1075
12
+ pipu_cli-0.2.2.dist-info/METADATA,sha256=HuJ0G_iQoFXXySEQVv9v0ikeFGOA2ixZNprthTx8iVA,11919
13
+ pipu_cli-0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ pipu_cli-0.2.2.dist-info/entry_points.txt,sha256=VSv6od00zOPblnFPflNLaci4jBtQIgLYJjL1BKxLz_o,42
15
+ pipu_cli-0.2.2.dist-info/top_level.txt,sha256=z3Yce93-jGQjGRpsGZUZvbS8osh3OyS7MVpzG0uBE5M,9
16
+ pipu_cli-0.2.2.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,,