pipu-cli 0.1.dev7__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipu_cli/__init__.py +2 -2
- pipu_cli/cache.py +316 -0
- pipu_cli/cli.py +863 -813
- pipu_cli/config.py +7 -58
- pipu_cli/config_file.py +80 -0
- pipu_cli/output.py +99 -0
- pipu_cli/package_management.py +1145 -0
- pipu_cli/pretty.py +286 -0
- pipu_cli/requirements.py +100 -0
- pipu_cli/rollback.py +110 -0
- pipu_cli-0.2.0.dist-info/METADATA +422 -0
- pipu_cli-0.2.0.dist-info/RECORD +16 -0
- pipu_cli/common.py +0 -4
- pipu_cli/internals.py +0 -815
- pipu_cli/package_constraints.py +0 -2296
- pipu_cli/thread_safe.py +0 -243
- pipu_cli/ui/__init__.py +0 -51
- pipu_cli/ui/apps.py +0 -1464
- pipu_cli/ui/constants.py +0 -33
- pipu_cli/ui/modal_dialogs.py +0 -1375
- pipu_cli/ui/table_widgets.py +0 -344
- pipu_cli/utils.py +0 -169
- pipu_cli-0.1.dev7.dist-info/METADATA +0 -517
- pipu_cli-0.1.dev7.dist-info/RECORD +0 -19
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/WHEEL +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/top_level.txt +0 -0
pipu_cli/__init__.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from .config import LOG_LEVEL
|
|
3
3
|
|
|
4
|
-
__version__ = '0.
|
|
4
|
+
__version__ = '0.2.0'
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
# Configure logging
|
|
8
|
-
log = logging.getLogger("pipu")
|
|
8
|
+
log = logging.getLogger("pipu-cli")
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class LevelSpecificFormatter(logging.Formatter):
|
pipu_cli/cache.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Package metadata caching for pipu.
|
|
2
|
+
|
|
3
|
+
This module provides caching of package version information to speed up
|
|
4
|
+
repeated runs of pipu. The cache is per-environment, identified by the
|
|
5
|
+
Python executable path, making it compatible with venv, conda, mise, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Any
|
|
16
|
+
|
|
17
|
+
from packaging.version import Version
|
|
18
|
+
|
|
19
|
+
from pipu_cli.config import DEFAULT_CACHE_TTL, CACHE_BASE_DIR
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
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
|
+
@dataclass
|
|
37
|
+
class CacheData:
|
|
38
|
+
"""Complete cache data structure."""
|
|
39
|
+
environment_id: str
|
|
40
|
+
python_executable: str
|
|
41
|
+
updated_at: str # ISO format timestamp
|
|
42
|
+
packages: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_environment_id() -> str:
|
|
46
|
+
"""Get a unique identifier for the current Python environment.
|
|
47
|
+
|
|
48
|
+
Uses a hash of the Python executable path to uniquely identify
|
|
49
|
+
the environment. This works with venv, conda, mise, and other
|
|
50
|
+
environment managers.
|
|
51
|
+
|
|
52
|
+
:returns: Short hash identifying the environment
|
|
53
|
+
"""
|
|
54
|
+
executable = sys.executable
|
|
55
|
+
# Create a short hash of the executable path
|
|
56
|
+
hash_obj = hashlib.sha256(executable.encode())
|
|
57
|
+
return hash_obj.hexdigest()[:12]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_cache_dir() -> Path:
|
|
61
|
+
"""Get the cache directory for the current environment.
|
|
62
|
+
|
|
63
|
+
:returns: Path to environment-specific cache directory
|
|
64
|
+
"""
|
|
65
|
+
env_id = get_environment_id()
|
|
66
|
+
return CACHE_BASE_DIR / env_id
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_cache_path() -> Path:
|
|
70
|
+
"""Get the path to the cache file for the current environment.
|
|
71
|
+
|
|
72
|
+
:returns: Path to the cache JSON file
|
|
73
|
+
"""
|
|
74
|
+
return get_cache_dir() / "versions.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_cache() -> Optional[CacheData]:
|
|
78
|
+
"""Load cache data from disk.
|
|
79
|
+
|
|
80
|
+
:returns: CacheData object or None if cache doesn't exist or is invalid
|
|
81
|
+
"""
|
|
82
|
+
cache_path = get_cache_path()
|
|
83
|
+
|
|
84
|
+
if not cache_path.exists():
|
|
85
|
+
logger.debug(f"Cache file does not exist: {cache_path}")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with open(cache_path, 'r') as f:
|
|
90
|
+
data = json.load(f)
|
|
91
|
+
|
|
92
|
+
# Validate the cache is for the current environment
|
|
93
|
+
env_id = get_environment_id()
|
|
94
|
+
if data.get("environment_id") != env_id:
|
|
95
|
+
logger.debug("Cache environment mismatch, ignoring")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return CacheData(
|
|
99
|
+
environment_id=data["environment_id"],
|
|
100
|
+
python_executable=data["python_executable"],
|
|
101
|
+
updated_at=data["updated_at"],
|
|
102
|
+
packages=data.get("packages", {})
|
|
103
|
+
)
|
|
104
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
105
|
+
logger.debug(f"Failed to load cache: {e}")
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_cache(packages: Dict[str, Dict[str, Any]]) -> Path:
|
|
110
|
+
"""Save package data to the cache.
|
|
111
|
+
|
|
112
|
+
:param packages: Dictionary mapping package names to their cached info
|
|
113
|
+
:returns: Path to the saved cache file
|
|
114
|
+
"""
|
|
115
|
+
cache_dir = get_cache_dir()
|
|
116
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
|
|
118
|
+
cache_path = get_cache_path()
|
|
119
|
+
|
|
120
|
+
cache_data = CacheData(
|
|
121
|
+
environment_id=get_environment_id(),
|
|
122
|
+
python_executable=sys.executable,
|
|
123
|
+
updated_at=datetime.now(timezone.utc).isoformat(),
|
|
124
|
+
packages=packages
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
with open(cache_path, 'w') as f:
|
|
128
|
+
json.dump(asdict(cache_data), f, indent=2)
|
|
129
|
+
|
|
130
|
+
logger.debug(f"Cache saved to {cache_path}")
|
|
131
|
+
return cache_path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_cache_fresh(ttl_seconds: int = DEFAULT_CACHE_TTL) -> bool:
|
|
135
|
+
"""Check if the cache is fresh (within TTL).
|
|
136
|
+
|
|
137
|
+
:param ttl_seconds: Time-to-live in seconds
|
|
138
|
+
:returns: True if cache exists and is within TTL
|
|
139
|
+
"""
|
|
140
|
+
cache = load_cache()
|
|
141
|
+
if cache is None:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
updated_at = datetime.fromisoformat(cache.updated_at)
|
|
146
|
+
# Ensure updated_at is timezone-aware
|
|
147
|
+
if updated_at.tzinfo is None:
|
|
148
|
+
updated_at = updated_at.replace(tzinfo=timezone.utc)
|
|
149
|
+
|
|
150
|
+
age = datetime.now(timezone.utc) - updated_at
|
|
151
|
+
is_fresh = age.total_seconds() < ttl_seconds
|
|
152
|
+
|
|
153
|
+
logger.debug(f"Cache age: {age.total_seconds():.0f}s, TTL: {ttl_seconds}s, Fresh: {is_fresh}")
|
|
154
|
+
return is_fresh
|
|
155
|
+
except (ValueError, TypeError) as e:
|
|
156
|
+
logger.debug(f"Failed to check cache freshness: {e}")
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_cache_age_seconds() -> Optional[float]:
|
|
161
|
+
"""Get the age of the cache in seconds.
|
|
162
|
+
|
|
163
|
+
:returns: Age in seconds or None if cache doesn't exist
|
|
164
|
+
"""
|
|
165
|
+
cache = load_cache()
|
|
166
|
+
if cache is None:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
updated_at = datetime.fromisoformat(cache.updated_at)
|
|
171
|
+
if updated_at.tzinfo is None:
|
|
172
|
+
updated_at = updated_at.replace(tzinfo=timezone.utc)
|
|
173
|
+
|
|
174
|
+
age = datetime.now(timezone.utc) - updated_at
|
|
175
|
+
return age.total_seconds()
|
|
176
|
+
except (ValueError, TypeError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def format_cache_age(seconds: Optional[float]) -> str:
|
|
181
|
+
"""Format cache age as human-readable string.
|
|
182
|
+
|
|
183
|
+
:param seconds: Age in seconds
|
|
184
|
+
:returns: Formatted string like "5 minutes ago" or "2 hours ago"
|
|
185
|
+
"""
|
|
186
|
+
if seconds is None:
|
|
187
|
+
return "never"
|
|
188
|
+
|
|
189
|
+
if seconds < 60:
|
|
190
|
+
return f"{int(seconds)} seconds ago"
|
|
191
|
+
elif seconds < 3600:
|
|
192
|
+
minutes = int(seconds / 60)
|
|
193
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
|
194
|
+
elif seconds < 86400:
|
|
195
|
+
hours = int(seconds / 3600)
|
|
196
|
+
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
|
197
|
+
else:
|
|
198
|
+
days = int(seconds / 86400)
|
|
199
|
+
return f"{days} day{'s' if days != 1 else ''} ago"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def clear_cache() -> bool:
|
|
203
|
+
"""Delete the cache file for the current environment.
|
|
204
|
+
|
|
205
|
+
:returns: True if cache was deleted, False if it didn't exist
|
|
206
|
+
"""
|
|
207
|
+
cache_path = get_cache_path()
|
|
208
|
+
|
|
209
|
+
if cache_path.exists():
|
|
210
|
+
cache_path.unlink()
|
|
211
|
+
logger.debug(f"Cache cleared: {cache_path}")
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def clear_all_caches() -> int:
|
|
218
|
+
"""Delete all cache files for all environments.
|
|
219
|
+
|
|
220
|
+
:returns: Number of cache directories deleted
|
|
221
|
+
"""
|
|
222
|
+
if not CACHE_BASE_DIR.exists():
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
count = 0
|
|
226
|
+
for cache_dir in CACHE_BASE_DIR.iterdir():
|
|
227
|
+
if cache_dir.is_dir():
|
|
228
|
+
cache_file = cache_dir / "versions.json"
|
|
229
|
+
if cache_file.exists():
|
|
230
|
+
cache_file.unlink()
|
|
231
|
+
try:
|
|
232
|
+
cache_dir.rmdir()
|
|
233
|
+
count += 1
|
|
234
|
+
except OSError:
|
|
235
|
+
pass # Directory not empty
|
|
236
|
+
|
|
237
|
+
return count
|
|
238
|
+
|
|
239
|
+
|
|
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
|
+
def get_cache_info() -> Dict[str, Any]:
|
|
296
|
+
"""Get information about the current cache.
|
|
297
|
+
|
|
298
|
+
:returns: Dictionary with cache metadata
|
|
299
|
+
"""
|
|
300
|
+
cache = load_cache()
|
|
301
|
+
cache_path = get_cache_path()
|
|
302
|
+
|
|
303
|
+
info = {
|
|
304
|
+
"exists": cache is not None,
|
|
305
|
+
"path": str(cache_path),
|
|
306
|
+
"environment_id": get_environment_id(),
|
|
307
|
+
"python_executable": sys.executable,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if cache:
|
|
311
|
+
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"])
|
|
315
|
+
|
|
316
|
+
return info
|