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 CHANGED
@@ -1,11 +1,11 @@
1
1
  import logging
2
2
  from .config import LOG_LEVEL
3
3
 
4
- __version__ = '0.1.dev6'
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