provide-foundation 0.0.0.dev0__py3-none-any.whl → 0.0.0.dev1__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.
- provide/foundation/__init__.py +12 -20
- provide/foundation/archive/__init__.py +23 -0
- provide/foundation/archive/base.py +70 -0
- provide/foundation/archive/bzip2.py +157 -0
- provide/foundation/archive/gzip.py +159 -0
- provide/foundation/archive/operations.py +336 -0
- provide/foundation/archive/tar.py +164 -0
- provide/foundation/archive/zip.py +203 -0
- provide/foundation/config/base.py +2 -2
- provide/foundation/config/sync.py +19 -4
- provide/foundation/core.py +1 -2
- provide/foundation/crypto/__init__.py +2 -0
- provide/foundation/crypto/certificates/__init__.py +34 -0
- provide/foundation/crypto/certificates/base.py +173 -0
- provide/foundation/crypto/certificates/certificate.py +290 -0
- provide/foundation/crypto/certificates/factory.py +213 -0
- provide/foundation/crypto/certificates/generator.py +138 -0
- provide/foundation/crypto/certificates/loader.py +130 -0
- provide/foundation/crypto/certificates/operations.py +198 -0
- provide/foundation/crypto/certificates/trust.py +107 -0
- provide/foundation/eventsets/__init__.py +0 -0
- provide/foundation/eventsets/display.py +84 -0
- provide/foundation/eventsets/registry.py +160 -0
- provide/foundation/eventsets/resolver.py +192 -0
- provide/foundation/eventsets/sets/das.py +128 -0
- provide/foundation/eventsets/sets/database.py +125 -0
- provide/foundation/eventsets/sets/http.py +153 -0
- provide/foundation/eventsets/sets/llm.py +139 -0
- provide/foundation/eventsets/sets/task_queue.py +107 -0
- provide/foundation/eventsets/types.py +70 -0
- provide/foundation/hub/components.py +7 -133
- provide/foundation/logger/__init__.py +3 -10
- provide/foundation/logger/config/logging.py +6 -6
- provide/foundation/logger/core.py +0 -2
- provide/foundation/logger/custom_processors.py +1 -0
- provide/foundation/logger/factories.py +11 -2
- provide/foundation/logger/processors/main.py +20 -84
- provide/foundation/logger/setup/__init__.py +5 -1
- provide/foundation/logger/setup/coordinator.py +75 -23
- provide/foundation/logger/setup/processors.py +2 -9
- provide/foundation/logger/trace.py +27 -0
- provide/foundation/metrics/otel.py +10 -10
- provide/foundation/process/lifecycle.py +82 -26
- provide/foundation/testing/__init__.py +77 -0
- provide/foundation/testing/archive/__init__.py +24 -0
- provide/foundation/testing/archive/fixtures.py +217 -0
- provide/foundation/testing/common/__init__.py +34 -0
- provide/foundation/testing/common/fixtures.py +263 -0
- provide/foundation/testing/file/__init__.py +40 -0
- provide/foundation/testing/file/fixtures.py +523 -0
- provide/foundation/testing/logger.py +41 -11
- provide/foundation/testing/mocking/__init__.py +46 -0
- provide/foundation/testing/mocking/fixtures.py +331 -0
- provide/foundation/testing/process/__init__.py +48 -0
- provide/foundation/testing/process/fixtures.py +577 -0
- provide/foundation/testing/threading/__init__.py +38 -0
- provide/foundation/testing/threading/fixtures.py +520 -0
- provide/foundation/testing/time/__init__.py +32 -0
- provide/foundation/testing/time/fixtures.py +409 -0
- provide/foundation/testing/transport/__init__.py +30 -0
- provide/foundation/testing/transport/fixtures.py +280 -0
- provide/foundation/tools/__init__.py +58 -0
- provide/foundation/tools/base.py +348 -0
- provide/foundation/tools/cache.py +266 -0
- provide/foundation/tools/downloader.py +213 -0
- provide/foundation/tools/installer.py +254 -0
- provide/foundation/tools/registry.py +223 -0
- provide/foundation/tools/resolver.py +321 -0
- provide/foundation/tools/verifier.py +186 -0
- provide/foundation/tracer/otel.py +7 -11
- provide/foundation/transport/__init__.py +155 -0
- provide/foundation/transport/base.py +171 -0
- provide/foundation/transport/client.py +266 -0
- provide/foundation/transport/config.py +209 -0
- provide/foundation/transport/errors.py +79 -0
- provide/foundation/transport/http.py +232 -0
- provide/foundation/transport/middleware.py +366 -0
- provide/foundation/transport/registry.py +167 -0
- provide/foundation/transport/types.py +45 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/METADATA +5 -28
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/RECORD +85 -34
- provide/foundation/cli/commands/logs/generate_old.py +0 -569
- provide/foundation/crypto/certificates.py +0 -896
- provide/foundation/logger/emoji/__init__.py +0 -44
- provide/foundation/logger/emoji/matrix.py +0 -209
- provide/foundation/logger/emoji/sets.py +0 -458
- provide/foundation/logger/emoji/types.py +0 -56
- provide/foundation/logger/setup/emoji_resolver.py +0 -64
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/WHEEL +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/entry_points.txt +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/licenses/LICENSE +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,58 @@
|
|
1
|
+
"""
|
2
|
+
Provide Foundation Tools Module
|
3
|
+
================================
|
4
|
+
|
5
|
+
Unified tool management system for downloading, verifying, installing, and
|
6
|
+
managing development tools across the provide-io ecosystem.
|
7
|
+
|
8
|
+
This module provides:
|
9
|
+
- Base classes for tool managers
|
10
|
+
- Download orchestration with progress reporting
|
11
|
+
- Checksum and signature verification
|
12
|
+
- Installation handling for various formats
|
13
|
+
- Version resolution (latest, semver, wildcards)
|
14
|
+
- Caching with TTL support
|
15
|
+
- Tool registry integration
|
16
|
+
|
17
|
+
Example:
|
18
|
+
>>> from provide.foundation.tools import get_tool_manager
|
19
|
+
>>> from provide.foundation.config import BaseConfig
|
20
|
+
>>>
|
21
|
+
>>> config = BaseConfig()
|
22
|
+
>>> tf_manager = get_tool_manager("terraform", config)
|
23
|
+
>>> tf_manager.install("1.5.0")
|
24
|
+
PosixPath('/home/user/.wrknv/tools/terraform/1.5.0')
|
25
|
+
"""
|
26
|
+
|
27
|
+
from provide.foundation.tools.base import (
|
28
|
+
BaseToolManager,
|
29
|
+
ToolMetadata,
|
30
|
+
ToolError,
|
31
|
+
)
|
32
|
+
from provide.foundation.tools.cache import ToolCache
|
33
|
+
from provide.foundation.tools.downloader import ToolDownloader
|
34
|
+
from provide.foundation.tools.installer import ToolInstaller
|
35
|
+
from provide.foundation.tools.registry import (
|
36
|
+
get_tool_manager,
|
37
|
+
get_tool_registry,
|
38
|
+
register_tool_manager,
|
39
|
+
)
|
40
|
+
from provide.foundation.tools.resolver import VersionResolver
|
41
|
+
from provide.foundation.tools.verifier import ToolVerifier
|
42
|
+
|
43
|
+
__all__ = [
|
44
|
+
# Base classes
|
45
|
+
"BaseToolManager",
|
46
|
+
"ToolMetadata",
|
47
|
+
"ToolError",
|
48
|
+
# Components
|
49
|
+
"ToolCache",
|
50
|
+
"ToolDownloader",
|
51
|
+
"ToolInstaller",
|
52
|
+
"ToolVerifier",
|
53
|
+
"VersionResolver",
|
54
|
+
# Registry functions
|
55
|
+
"get_tool_manager",
|
56
|
+
"get_tool_registry",
|
57
|
+
"register_tool_manager",
|
58
|
+
]
|
@@ -0,0 +1,348 @@
|
|
1
|
+
"""
|
2
|
+
Base classes for tool management.
|
3
|
+
|
4
|
+
This module provides the foundation for tool managers, including
|
5
|
+
the base manager class and metadata structures.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from abc import ABC, abstractmethod
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Any
|
11
|
+
|
12
|
+
from attrs import define, field
|
13
|
+
|
14
|
+
from provide.foundation.config import BaseConfig
|
15
|
+
from provide.foundation.errors import FoundationError
|
16
|
+
from provide.foundation.logger import get_logger
|
17
|
+
|
18
|
+
log = get_logger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class ToolError(FoundationError):
|
22
|
+
"""Base exception for tool-related errors."""
|
23
|
+
|
24
|
+
pass
|
25
|
+
|
26
|
+
|
27
|
+
class ToolNotFoundError(ToolError):
|
28
|
+
"""Raised when a tool or version cannot be found."""
|
29
|
+
|
30
|
+
pass
|
31
|
+
|
32
|
+
|
33
|
+
class ToolInstallError(ToolError):
|
34
|
+
"""Raised when tool installation fails."""
|
35
|
+
|
36
|
+
pass
|
37
|
+
|
38
|
+
|
39
|
+
class ToolVerificationError(ToolError):
|
40
|
+
"""Raised when tool verification fails."""
|
41
|
+
|
42
|
+
pass
|
43
|
+
|
44
|
+
|
45
|
+
@define
|
46
|
+
class ToolMetadata:
|
47
|
+
"""
|
48
|
+
Metadata about a tool version.
|
49
|
+
|
50
|
+
Attributes:
|
51
|
+
name: Tool name (e.g., "terraform").
|
52
|
+
version: Version string (e.g., "1.5.0").
|
53
|
+
platform: Platform identifier (e.g., "linux", "darwin").
|
54
|
+
arch: Architecture (e.g., "amd64", "arm64").
|
55
|
+
checksum: Optional checksum for verification.
|
56
|
+
signature: Optional GPG/PGP signature.
|
57
|
+
download_url: URL to download the tool.
|
58
|
+
checksum_url: URL to download checksums file.
|
59
|
+
install_path: Where the tool is/will be installed.
|
60
|
+
env_vars: Environment variables to set.
|
61
|
+
dependencies: Other tools this depends on.
|
62
|
+
executable_name: Name of the executable file.
|
63
|
+
"""
|
64
|
+
|
65
|
+
name: str
|
66
|
+
version: str
|
67
|
+
platform: str
|
68
|
+
arch: str
|
69
|
+
checksum: str | None = None
|
70
|
+
signature: str | None = None
|
71
|
+
download_url: str | None = None
|
72
|
+
checksum_url: str | None = None
|
73
|
+
install_path: Path | None = None
|
74
|
+
env_vars: dict[str, str] = field(factory=dict)
|
75
|
+
dependencies: list[str] = field(factory=list)
|
76
|
+
executable_name: str | None = None
|
77
|
+
|
78
|
+
|
79
|
+
class BaseToolManager(ABC):
|
80
|
+
"""
|
81
|
+
Abstract base class for tool managers.
|
82
|
+
|
83
|
+
Provides common functionality for downloading, verifying, and installing
|
84
|
+
development tools. Subclasses must implement platform-specific logic.
|
85
|
+
|
86
|
+
Attributes:
|
87
|
+
config: Configuration object.
|
88
|
+
tool_name: Name of the tool being managed.
|
89
|
+
executable_name: Name of the executable file.
|
90
|
+
supported_platforms: List of supported platforms.
|
91
|
+
"""
|
92
|
+
|
93
|
+
# Class attributes to be overridden by subclasses
|
94
|
+
tool_name: str = ""
|
95
|
+
executable_name: str = ""
|
96
|
+
supported_platforms: list[str] = ["linux", "darwin", "windows"]
|
97
|
+
|
98
|
+
def __init__(self, config: BaseConfig):
|
99
|
+
"""
|
100
|
+
Initialize the tool manager.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
config: Configuration object containing settings.
|
104
|
+
"""
|
105
|
+
if not self.tool_name:
|
106
|
+
raise ToolError("Subclass must define tool_name")
|
107
|
+
if not self.executable_name:
|
108
|
+
raise ToolError("Subclass must define executable_name")
|
109
|
+
|
110
|
+
self.config = config
|
111
|
+
|
112
|
+
# Lazy-load components to avoid circular imports
|
113
|
+
self._cache = None
|
114
|
+
self._downloader = None
|
115
|
+
self._verifier = None
|
116
|
+
self._installer = None
|
117
|
+
self._resolver = None
|
118
|
+
|
119
|
+
log.debug(f"Initialized {self.tool_name} manager")
|
120
|
+
|
121
|
+
@property
|
122
|
+
def cache(self):
|
123
|
+
"""Get or create cache instance."""
|
124
|
+
if self._cache is None:
|
125
|
+
from provide.foundation.tools.cache import ToolCache
|
126
|
+
self._cache = ToolCache()
|
127
|
+
return self._cache
|
128
|
+
|
129
|
+
@property
|
130
|
+
def downloader(self):
|
131
|
+
"""Get or create downloader instance."""
|
132
|
+
if self._downloader is None:
|
133
|
+
from provide.foundation.tools.downloader import ToolDownloader
|
134
|
+
from provide.foundation.transport import UniversalClient
|
135
|
+
self._downloader = ToolDownloader(UniversalClient())
|
136
|
+
return self._downloader
|
137
|
+
|
138
|
+
@property
|
139
|
+
def verifier(self):
|
140
|
+
"""Get or create verifier instance."""
|
141
|
+
if self._verifier is None:
|
142
|
+
from provide.foundation.tools.verifier import ToolVerifier
|
143
|
+
self._verifier = ToolVerifier()
|
144
|
+
return self._verifier
|
145
|
+
|
146
|
+
@property
|
147
|
+
def installer(self):
|
148
|
+
"""Get or create installer instance."""
|
149
|
+
if self._installer is None:
|
150
|
+
from provide.foundation.tools.installer import ToolInstaller
|
151
|
+
self._installer = ToolInstaller()
|
152
|
+
return self._installer
|
153
|
+
|
154
|
+
@property
|
155
|
+
def resolver(self):
|
156
|
+
"""Get or create version resolver instance."""
|
157
|
+
if self._resolver is None:
|
158
|
+
from provide.foundation.tools.resolver import VersionResolver
|
159
|
+
self._resolver = VersionResolver()
|
160
|
+
return self._resolver
|
161
|
+
|
162
|
+
@abstractmethod
|
163
|
+
def get_metadata(self, version: str) -> ToolMetadata:
|
164
|
+
"""
|
165
|
+
Get metadata for a specific version.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
version: Version string to get metadata for.
|
169
|
+
|
170
|
+
Returns:
|
171
|
+
ToolMetadata object with download URLs and checksums.
|
172
|
+
"""
|
173
|
+
pass
|
174
|
+
|
175
|
+
@abstractmethod
|
176
|
+
def get_available_versions(self) -> list[str]:
|
177
|
+
"""
|
178
|
+
Get list of available versions from upstream.
|
179
|
+
|
180
|
+
Returns:
|
181
|
+
List of version strings available for download.
|
182
|
+
"""
|
183
|
+
pass
|
184
|
+
|
185
|
+
def resolve_version(self, spec: str) -> str:
|
186
|
+
"""
|
187
|
+
Resolve a version specification to a concrete version.
|
188
|
+
|
189
|
+
Args:
|
190
|
+
spec: Version specification (e.g., "latest", "~1.5.0").
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
Concrete version string.
|
194
|
+
|
195
|
+
Raises:
|
196
|
+
ToolNotFoundError: If version cannot be resolved.
|
197
|
+
"""
|
198
|
+
available = self.get_available_versions()
|
199
|
+
if not available:
|
200
|
+
raise ToolNotFoundError(f"No versions available for {self.tool_name}")
|
201
|
+
|
202
|
+
resolved = self.resolver.resolve(spec, available)
|
203
|
+
if not resolved:
|
204
|
+
raise ToolNotFoundError(
|
205
|
+
f"Cannot resolve version '{spec}' for {self.tool_name}"
|
206
|
+
)
|
207
|
+
|
208
|
+
log.debug(f"Resolved {self.tool_name} version {spec} to {resolved}")
|
209
|
+
return resolved
|
210
|
+
|
211
|
+
def install(self, version: str = "latest", force: bool = False) -> Path:
|
212
|
+
"""
|
213
|
+
Install a specific version of the tool.
|
214
|
+
|
215
|
+
Args:
|
216
|
+
version: Version to install (default: "latest").
|
217
|
+
force: Force reinstall even if cached.
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
Path to the installed tool.
|
221
|
+
|
222
|
+
Raises:
|
223
|
+
ToolInstallError: If installation fails.
|
224
|
+
"""
|
225
|
+
# Resolve version
|
226
|
+
if version in ["latest", "stable", "dev"] or version.startswith(("~", "^")):
|
227
|
+
version = self.resolve_version(version)
|
228
|
+
|
229
|
+
# Check cache unless forced
|
230
|
+
if not force:
|
231
|
+
if cached_path := self.cache.get(self.tool_name, version):
|
232
|
+
log.info(f"Using cached {self.tool_name} {version}")
|
233
|
+
return cached_path
|
234
|
+
|
235
|
+
log.info(f"Installing {self.tool_name} {version}")
|
236
|
+
|
237
|
+
# Get metadata
|
238
|
+
metadata = self.get_metadata(version)
|
239
|
+
if not metadata.download_url:
|
240
|
+
raise ToolInstallError(
|
241
|
+
f"No download URL for {self.tool_name} {version}"
|
242
|
+
)
|
243
|
+
|
244
|
+
# Download
|
245
|
+
download_path = Path("/tmp") / f"{self.tool_name}-{version}"
|
246
|
+
artifact_path = self.downloader.download_with_progress(
|
247
|
+
metadata.download_url,
|
248
|
+
download_path,
|
249
|
+
metadata.checksum
|
250
|
+
)
|
251
|
+
|
252
|
+
# Verify if checksum provided
|
253
|
+
if metadata.checksum:
|
254
|
+
if not self.verifier.verify_checksum(artifact_path, metadata.checksum):
|
255
|
+
artifact_path.unlink()
|
256
|
+
raise ToolVerificationError(
|
257
|
+
f"Checksum verification failed for {self.tool_name} {version}"
|
258
|
+
)
|
259
|
+
|
260
|
+
# Install
|
261
|
+
install_path = self.installer.install(artifact_path, metadata)
|
262
|
+
|
263
|
+
# Cache the installation
|
264
|
+
self.cache.store(self.tool_name, version, install_path)
|
265
|
+
|
266
|
+
# Clean up download
|
267
|
+
if artifact_path.exists():
|
268
|
+
artifact_path.unlink()
|
269
|
+
|
270
|
+
log.info(f"Successfully installed {self.tool_name} {version} to {install_path}")
|
271
|
+
return install_path
|
272
|
+
|
273
|
+
def uninstall(self, version: str) -> bool:
|
274
|
+
"""
|
275
|
+
Uninstall a specific version.
|
276
|
+
|
277
|
+
Args:
|
278
|
+
version: Version to uninstall.
|
279
|
+
|
280
|
+
Returns:
|
281
|
+
True if uninstalled, False if not found.
|
282
|
+
"""
|
283
|
+
# Invalidate cache
|
284
|
+
self.cache.invalidate(self.tool_name, version)
|
285
|
+
|
286
|
+
# Remove from filesystem
|
287
|
+
install_path = self.get_install_path(version)
|
288
|
+
if install_path.exists():
|
289
|
+
import shutil
|
290
|
+
shutil.rmtree(install_path)
|
291
|
+
log.info(f"Uninstalled {self.tool_name} {version}")
|
292
|
+
return True
|
293
|
+
|
294
|
+
return False
|
295
|
+
|
296
|
+
def get_install_path(self, version: str) -> Path:
|
297
|
+
"""
|
298
|
+
Get the installation path for a version.
|
299
|
+
|
300
|
+
Args:
|
301
|
+
version: Version string.
|
302
|
+
|
303
|
+
Returns:
|
304
|
+
Path where the version is/will be installed.
|
305
|
+
"""
|
306
|
+
base_path = Path.home() / ".wrknv" / "tools" / self.tool_name / version
|
307
|
+
return base_path
|
308
|
+
|
309
|
+
def is_installed(self, version: str) -> bool:
|
310
|
+
"""
|
311
|
+
Check if a version is installed.
|
312
|
+
|
313
|
+
Args:
|
314
|
+
version: Version to check.
|
315
|
+
|
316
|
+
Returns:
|
317
|
+
True if installed, False otherwise.
|
318
|
+
"""
|
319
|
+
install_path = self.get_install_path(version)
|
320
|
+
executable = install_path / "bin" / self.executable_name
|
321
|
+
return executable.exists()
|
322
|
+
|
323
|
+
def get_platform_info(self) -> dict[str, str]:
|
324
|
+
"""
|
325
|
+
Get current platform information.
|
326
|
+
|
327
|
+
Returns:
|
328
|
+
Dictionary with platform and arch keys.
|
329
|
+
"""
|
330
|
+
import platform
|
331
|
+
|
332
|
+
system = platform.system().lower()
|
333
|
+
if system == "darwin":
|
334
|
+
system = "darwin"
|
335
|
+
elif system == "linux":
|
336
|
+
system = "linux"
|
337
|
+
elif system == "windows":
|
338
|
+
system = "windows"
|
339
|
+
|
340
|
+
machine = platform.machine().lower()
|
341
|
+
if machine in ["x86_64", "amd64"]:
|
342
|
+
arch = "amd64"
|
343
|
+
elif machine in ["aarch64", "arm64"]:
|
344
|
+
arch = "arm64"
|
345
|
+
else:
|
346
|
+
arch = machine
|
347
|
+
|
348
|
+
return {"platform": system, "arch": arch}
|
@@ -0,0 +1,266 @@
|
|
1
|
+
"""
|
2
|
+
Caching system for installed tools.
|
3
|
+
|
4
|
+
Provides TTL-based caching to avoid re-downloading tools
|
5
|
+
that are already installed and valid.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import json
|
9
|
+
from datetime import datetime, timedelta
|
10
|
+
from pathlib import Path
|
11
|
+
|
12
|
+
from provide.foundation.errors import FoundationError
|
13
|
+
from provide.foundation.logger import get_logger
|
14
|
+
|
15
|
+
log = get_logger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class CacheError(FoundationError):
|
19
|
+
"""Raised when cache operations fail."""
|
20
|
+
|
21
|
+
pass
|
22
|
+
|
23
|
+
|
24
|
+
class ToolCache:
|
25
|
+
"""
|
26
|
+
Cache for installed tools with TTL support.
|
27
|
+
|
28
|
+
Tracks installed tool locations and expiration times to
|
29
|
+
avoid unnecessary re-downloads and installations.
|
30
|
+
"""
|
31
|
+
|
32
|
+
def __init__(self, cache_dir: Path | None = None):
|
33
|
+
"""
|
34
|
+
Initialize the cache.
|
35
|
+
|
36
|
+
Args:
|
37
|
+
cache_dir: Cache directory (defaults to ~/.wrknv/cache).
|
38
|
+
"""
|
39
|
+
self.cache_dir = cache_dir or (Path.home() / ".wrknv" / "cache")
|
40
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
41
|
+
|
42
|
+
self.metadata_file = self.cache_dir / "metadata.json"
|
43
|
+
self.metadata = self._load_metadata()
|
44
|
+
|
45
|
+
def _load_metadata(self) -> dict[str, dict]:
|
46
|
+
"""
|
47
|
+
Load cache metadata from disk.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
Cache metadata dictionary.
|
51
|
+
"""
|
52
|
+
if self.metadata_file.exists():
|
53
|
+
try:
|
54
|
+
with self.metadata_file.open() as f:
|
55
|
+
return json.load(f)
|
56
|
+
except Exception as e:
|
57
|
+
log.warning(f"Failed to load cache metadata: {e}")
|
58
|
+
|
59
|
+
return {}
|
60
|
+
|
61
|
+
def _save_metadata(self) -> None:
|
62
|
+
"""Save cache metadata to disk."""
|
63
|
+
try:
|
64
|
+
with self.metadata_file.open("w") as f:
|
65
|
+
json.dump(self.metadata, f, indent=2)
|
66
|
+
except Exception as e:
|
67
|
+
log.error(f"Failed to save cache metadata: {e}")
|
68
|
+
|
69
|
+
def get(self, tool: str, version: str) -> Path | None:
|
70
|
+
"""
|
71
|
+
Get cached tool path if valid.
|
72
|
+
|
73
|
+
Args:
|
74
|
+
tool: Tool name.
|
75
|
+
version: Tool version.
|
76
|
+
|
77
|
+
Returns:
|
78
|
+
Path to cached tool if valid, None otherwise.
|
79
|
+
"""
|
80
|
+
key = f"{tool}:{version}"
|
81
|
+
|
82
|
+
if entry := self.metadata.get(key):
|
83
|
+
path = Path(entry["path"])
|
84
|
+
|
85
|
+
# Check if path exists
|
86
|
+
if not path.exists():
|
87
|
+
log.debug(f"Cache miss: {key} path doesn't exist")
|
88
|
+
self.invalidate(tool, version)
|
89
|
+
return None
|
90
|
+
|
91
|
+
# Check if expired
|
92
|
+
if self._is_expired(entry):
|
93
|
+
log.debug(f"Cache miss: {key} expired")
|
94
|
+
self.invalidate(tool, version)
|
95
|
+
return None
|
96
|
+
|
97
|
+
log.debug(f"Cache hit: {key}")
|
98
|
+
return path
|
99
|
+
|
100
|
+
log.debug(f"Cache miss: {key} not in cache")
|
101
|
+
return None
|
102
|
+
|
103
|
+
def store(
|
104
|
+
self,
|
105
|
+
tool: str,
|
106
|
+
version: str,
|
107
|
+
path: Path,
|
108
|
+
ttl_days: int = 7
|
109
|
+
) -> None:
|
110
|
+
"""
|
111
|
+
Store tool in cache.
|
112
|
+
|
113
|
+
Args:
|
114
|
+
tool: Tool name.
|
115
|
+
version: Tool version.
|
116
|
+
path: Path to installed tool.
|
117
|
+
ttl_days: Time-to-live in days.
|
118
|
+
"""
|
119
|
+
key = f"{tool}:{version}"
|
120
|
+
|
121
|
+
self.metadata[key] = {
|
122
|
+
"path": str(path),
|
123
|
+
"tool": tool,
|
124
|
+
"version": version,
|
125
|
+
"cached_at": datetime.now().isoformat(),
|
126
|
+
"ttl_days": ttl_days,
|
127
|
+
}
|
128
|
+
|
129
|
+
self._save_metadata()
|
130
|
+
log.debug(f"Cached {key} at {path} (TTL: {ttl_days} days)")
|
131
|
+
|
132
|
+
def invalidate(self, tool: str, version: str | None = None) -> None:
|
133
|
+
"""
|
134
|
+
Invalidate cache entries.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
tool: Tool name.
|
138
|
+
version: Specific version, or None for all versions.
|
139
|
+
"""
|
140
|
+
if version:
|
141
|
+
# Invalidate specific version
|
142
|
+
key = f"{tool}:{version}"
|
143
|
+
if key in self.metadata:
|
144
|
+
del self.metadata[key]
|
145
|
+
log.debug(f"Invalidated cache for {key}")
|
146
|
+
else:
|
147
|
+
# Invalidate all versions of tool
|
148
|
+
keys_to_remove = [
|
149
|
+
k for k in self.metadata
|
150
|
+
if self.metadata[k].get("tool") == tool
|
151
|
+
]
|
152
|
+
for key in keys_to_remove:
|
153
|
+
del self.metadata[key]
|
154
|
+
log.debug(f"Invalidated cache for {key}")
|
155
|
+
|
156
|
+
self._save_metadata()
|
157
|
+
|
158
|
+
def _is_expired(self, entry: dict) -> bool:
|
159
|
+
"""
|
160
|
+
Check if cache entry is expired.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
entry: Cache entry dictionary.
|
164
|
+
|
165
|
+
Returns:
|
166
|
+
True if expired, False otherwise.
|
167
|
+
"""
|
168
|
+
try:
|
169
|
+
cached_at = datetime.fromisoformat(entry["cached_at"])
|
170
|
+
ttl_days = entry.get("ttl_days", 7)
|
171
|
+
|
172
|
+
if ttl_days <= 0:
|
173
|
+
# Never expires
|
174
|
+
return False
|
175
|
+
|
176
|
+
expiry = cached_at + timedelta(days=ttl_days)
|
177
|
+
return datetime.now() > expiry
|
178
|
+
except Exception as e:
|
179
|
+
log.debug(f"Error checking expiry: {e}")
|
180
|
+
return True # Treat as expired if we can't determine
|
181
|
+
|
182
|
+
def clear(self) -> None:
|
183
|
+
"""Clear all cache entries."""
|
184
|
+
self.metadata = {}
|
185
|
+
self._save_metadata()
|
186
|
+
log.info("Cleared tool cache")
|
187
|
+
|
188
|
+
def list_cached(self) -> list[dict]:
|
189
|
+
"""
|
190
|
+
List all cached tools.
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
List of cache entries with metadata.
|
194
|
+
"""
|
195
|
+
results = []
|
196
|
+
|
197
|
+
for key, entry in self.metadata.items():
|
198
|
+
# Add expiry status
|
199
|
+
entry = entry.copy()
|
200
|
+
entry["key"] = key
|
201
|
+
entry["expired"] = self._is_expired(entry)
|
202
|
+
|
203
|
+
# Calculate days until expiry
|
204
|
+
try:
|
205
|
+
cached_at = datetime.fromisoformat(entry["cached_at"])
|
206
|
+
ttl_days = entry.get("ttl_days", 7)
|
207
|
+
if ttl_days > 0:
|
208
|
+
expiry = cached_at + timedelta(days=ttl_days)
|
209
|
+
days_left = (expiry - datetime.now()).days
|
210
|
+
entry["days_until_expiry"] = max(0, days_left)
|
211
|
+
else:
|
212
|
+
entry["days_until_expiry"] = -1 # Never expires
|
213
|
+
except Exception:
|
214
|
+
entry["days_until_expiry"] = 0
|
215
|
+
|
216
|
+
results.append(entry)
|
217
|
+
|
218
|
+
return results
|
219
|
+
|
220
|
+
def get_size(self) -> int:
|
221
|
+
"""
|
222
|
+
Get total size of cached tools in bytes.
|
223
|
+
|
224
|
+
Returns:
|
225
|
+
Total size in bytes.
|
226
|
+
"""
|
227
|
+
total = 0
|
228
|
+
|
229
|
+
for entry in self.metadata.values():
|
230
|
+
path = Path(entry["path"])
|
231
|
+
if path.exists():
|
232
|
+
try:
|
233
|
+
# Calculate directory size
|
234
|
+
if path.is_dir():
|
235
|
+
total += sum(
|
236
|
+
f.stat().st_size
|
237
|
+
for f in path.rglob("*")
|
238
|
+
if f.is_file()
|
239
|
+
)
|
240
|
+
else:
|
241
|
+
total += path.stat().st_size
|
242
|
+
except Exception as e:
|
243
|
+
log.debug(f"Failed to get size of {path}: {e}")
|
244
|
+
|
245
|
+
return total
|
246
|
+
|
247
|
+
def prune_expired(self) -> int:
|
248
|
+
"""
|
249
|
+
Remove expired entries from cache.
|
250
|
+
|
251
|
+
Returns:
|
252
|
+
Number of entries removed.
|
253
|
+
"""
|
254
|
+
expired_keys = [
|
255
|
+
key for key, entry in self.metadata.items()
|
256
|
+
if self._is_expired(entry)
|
257
|
+
]
|
258
|
+
|
259
|
+
for key in expired_keys:
|
260
|
+
del self.metadata[key]
|
261
|
+
|
262
|
+
if expired_keys:
|
263
|
+
self._save_metadata()
|
264
|
+
log.info(f"Pruned {len(expired_keys)} expired cache entries")
|
265
|
+
|
266
|
+
return len(expired_keys)
|