provide-foundation 0.0.0.dev0__py3-none-any.whl → 0.0.0.dev2__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 +41 -23
- 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 +334 -0
- provide/foundation/archive/tar.py +164 -0
- provide/foundation/archive/zip.py +203 -0
- provide/foundation/cli/__init__.py +2 -2
- provide/foundation/cli/commands/deps.py +13 -7
- provide/foundation/cli/commands/logs/__init__.py +1 -1
- provide/foundation/cli/commands/logs/query.py +1 -1
- provide/foundation/cli/commands/logs/send.py +1 -1
- provide/foundation/cli/commands/logs/tail.py +1 -1
- provide/foundation/cli/decorators.py +11 -10
- provide/foundation/cli/main.py +1 -1
- provide/foundation/cli/testing.py +2 -35
- provide/foundation/cli/utils.py +21 -17
- provide/foundation/config/__init__.py +35 -2
- provide/foundation/config/base.py +2 -2
- provide/foundation/config/converters.py +479 -0
- provide/foundation/config/defaults.py +67 -0
- provide/foundation/config/env.py +4 -19
- provide/foundation/config/loader.py +9 -3
- provide/foundation/config/sync.py +19 -4
- provide/foundation/console/input.py +5 -5
- provide/foundation/console/output.py +35 -13
- provide/foundation/context/__init__.py +8 -4
- provide/foundation/context/core.py +85 -109
- 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/errors/__init__.py +2 -3
- provide/foundation/errors/decorators.py +0 -231
- provide/foundation/errors/types.py +0 -97
- 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/file/directory.py +13 -22
- provide/foundation/file/lock.py +3 -1
- provide/foundation/hub/components.py +77 -515
- provide/foundation/hub/config.py +151 -0
- provide/foundation/hub/discovery.py +62 -0
- provide/foundation/hub/handlers.py +81 -0
- provide/foundation/hub/lifecycle.py +194 -0
- provide/foundation/hub/manager.py +4 -4
- provide/foundation/hub/processors.py +44 -0
- provide/foundation/integrations/__init__.py +11 -0
- provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
- provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
- provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
- provide/foundation/integrations/openobserve/config.py +37 -0
- provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
- provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
- provide/foundation/logger/__init__.py +3 -10
- provide/foundation/logger/config/logging.py +68 -298
- provide/foundation/logger/config/telemetry.py +41 -121
- 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 +76 -24
- provide/foundation/logger/setup/processors.py +2 -9
- provide/foundation/logger/trace.py +27 -0
- provide/foundation/metrics/otel.py +10 -10
- provide/foundation/observability/__init__.py +2 -2
- provide/foundation/process/__init__.py +9 -0
- provide/foundation/process/exit.py +47 -0
- provide/foundation/process/lifecycle.py +115 -59
- provide/foundation/resilience/__init__.py +35 -0
- provide/foundation/resilience/circuit.py +164 -0
- provide/foundation/resilience/decorators.py +220 -0
- provide/foundation/resilience/fallback.py +193 -0
- provide/foundation/resilience/retry.py +325 -0
- provide/foundation/streams/config.py +79 -0
- provide/foundation/streams/console.py +7 -8
- provide/foundation/streams/core.py +6 -3
- provide/foundation/streams/file.py +12 -2
- provide/foundation/testing/__init__.py +84 -2
- provide/foundation/testing/archive/__init__.py +24 -0
- provide/foundation/testing/archive/fixtures.py +217 -0
- provide/foundation/testing/cli.py +30 -17
- provide/foundation/testing/common/__init__.py +32 -0
- provide/foundation/testing/common/fixtures.py +236 -0
- provide/foundation/testing/file/__init__.py +40 -0
- provide/foundation/testing/file/content_fixtures.py +316 -0
- provide/foundation/testing/file/directory_fixtures.py +107 -0
- provide/foundation/testing/file/fixtures.py +52 -0
- provide/foundation/testing/file/special_fixtures.py +153 -0
- provide/foundation/testing/logger.py +117 -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/async_fixtures.py +405 -0
- provide/foundation/testing/process/fixtures.py +56 -0
- provide/foundation/testing/process/subprocess_fixtures.py +209 -0
- provide/foundation/testing/threading/__init__.py +38 -0
- provide/foundation/testing/threading/basic_fixtures.py +101 -0
- provide/foundation/testing/threading/data_fixtures.py +99 -0
- provide/foundation/testing/threading/execution_fixtures.py +263 -0
- provide/foundation/testing/threading/fixtures.py +54 -0
- provide/foundation/testing/threading/sync_fixtures.py +97 -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 +268 -0
- provide/foundation/tools/downloader.py +224 -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/tracer/spans.py +2 -2
- 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 +140 -0
- provide/foundation/transport/errors.py +79 -0
- provide/foundation/transport/http.py +232 -0
- provide/foundation/transport/middleware.py +360 -0
- provide/foundation/transport/registry.py +167 -0
- provide/foundation/transport/types.py +45 -0
- provide/foundation/utils/deps.py +14 -12
- provide/foundation/utils/parsing.py +49 -4
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +5 -28
- provide_foundation-0.0.0.dev2.dist-info/RECORD +225 -0
- 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/RECORD +0 -149
- /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
- /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,224 @@
|
|
1
|
+
"""
|
2
|
+
Tool download orchestration with progress reporting.
|
3
|
+
|
4
|
+
Provides capabilities for downloading tools with progress tracking,
|
5
|
+
parallel downloads, and mirror support.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import hashlib
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
10
|
+
from pathlib import Path
|
11
|
+
from typing import Callable
|
12
|
+
|
13
|
+
from provide.foundation.errors import FoundationError
|
14
|
+
from provide.foundation.logger import get_logger
|
15
|
+
from provide.foundation.resilience import retry, fallback
|
16
|
+
from provide.foundation.transport import UniversalClient
|
17
|
+
|
18
|
+
log = get_logger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class DownloadError(FoundationError):
|
22
|
+
"""Raised when download fails."""
|
23
|
+
|
24
|
+
pass
|
25
|
+
|
26
|
+
|
27
|
+
class ToolDownloader:
|
28
|
+
"""
|
29
|
+
Advanced download capabilities for tools.
|
30
|
+
|
31
|
+
Features:
|
32
|
+
- Progress reporting with callbacks
|
33
|
+
- Parallel downloads for multiple files
|
34
|
+
- Mirror fallback support
|
35
|
+
- Checksum verification
|
36
|
+
|
37
|
+
Attributes:
|
38
|
+
client: Transport client for HTTP requests.
|
39
|
+
progress_callbacks: List of progress callback functions.
|
40
|
+
"""
|
41
|
+
|
42
|
+
def __init__(self, client: UniversalClient):
|
43
|
+
"""
|
44
|
+
Initialize the downloader.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
client: Universal client for making HTTP requests.
|
48
|
+
"""
|
49
|
+
self.client = client
|
50
|
+
self.progress_callbacks: list[Callable[[int, int], None]] = []
|
51
|
+
|
52
|
+
def add_progress_callback(self, callback: Callable[[int, int], None]) -> None:
|
53
|
+
"""
|
54
|
+
Add a progress callback.
|
55
|
+
|
56
|
+
Args:
|
57
|
+
callback: Function that receives (downloaded_bytes, total_bytes).
|
58
|
+
"""
|
59
|
+
self.progress_callbacks.append(callback)
|
60
|
+
|
61
|
+
def _report_progress(self, downloaded: int, total: int) -> None:
|
62
|
+
"""
|
63
|
+
Report progress to all callbacks.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
downloaded: Bytes downloaded so far.
|
67
|
+
total: Total bytes to download (0 if unknown).
|
68
|
+
"""
|
69
|
+
for callback in self.progress_callbacks:
|
70
|
+
try:
|
71
|
+
callback(downloaded, total)
|
72
|
+
except Exception as e:
|
73
|
+
log.warning(f"Progress callback failed: {e}")
|
74
|
+
|
75
|
+
@retry(max_attempts=3, base_delay=1.0)
|
76
|
+
def download_with_progress(
|
77
|
+
self,
|
78
|
+
url: str,
|
79
|
+
dest: Path,
|
80
|
+
checksum: str | None = None
|
81
|
+
) -> Path:
|
82
|
+
"""
|
83
|
+
Download a file with progress reporting.
|
84
|
+
|
85
|
+
Args:
|
86
|
+
url: URL to download from.
|
87
|
+
dest: Destination file path.
|
88
|
+
checksum: Optional checksum for verification.
|
89
|
+
|
90
|
+
Returns:
|
91
|
+
Path to the downloaded file.
|
92
|
+
|
93
|
+
Raises:
|
94
|
+
DownloadError: If download or verification fails.
|
95
|
+
"""
|
96
|
+
log.debug(f"Downloading {url} to {dest}")
|
97
|
+
|
98
|
+
# Ensure parent directory exists
|
99
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
100
|
+
|
101
|
+
# Stream download with progress
|
102
|
+
with self.client.stream("GET", url) as response:
|
103
|
+
# Get total size if available
|
104
|
+
total_size = int(response.headers.get("content-length", 0))
|
105
|
+
downloaded = 0
|
106
|
+
|
107
|
+
# Write to file and report progress
|
108
|
+
with dest.open("wb") as f:
|
109
|
+
for chunk in response.iter_bytes(8192):
|
110
|
+
f.write(chunk)
|
111
|
+
downloaded += len(chunk)
|
112
|
+
self._report_progress(downloaded, total_size)
|
113
|
+
|
114
|
+
# Verify checksum if provided
|
115
|
+
if checksum:
|
116
|
+
if not self.verify_checksum(dest, checksum):
|
117
|
+
dest.unlink()
|
118
|
+
raise DownloadError(f"Checksum mismatch for {url}")
|
119
|
+
|
120
|
+
log.info(f"Downloaded {url} successfully")
|
121
|
+
return dest
|
122
|
+
|
123
|
+
def verify_checksum(self, file_path: Path, expected: str) -> bool:
|
124
|
+
"""
|
125
|
+
Verify file checksum.
|
126
|
+
|
127
|
+
Args:
|
128
|
+
file_path: Path to file to verify.
|
129
|
+
expected: Expected checksum (hex string).
|
130
|
+
|
131
|
+
Returns:
|
132
|
+
True if checksum matches, False otherwise.
|
133
|
+
"""
|
134
|
+
# Default to SHA256
|
135
|
+
hasher = hashlib.sha256()
|
136
|
+
|
137
|
+
with file_path.open("rb") as f:
|
138
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
139
|
+
hasher.update(chunk)
|
140
|
+
|
141
|
+
actual = hasher.hexdigest()
|
142
|
+
return actual == expected
|
143
|
+
|
144
|
+
def download_parallel(
|
145
|
+
self,
|
146
|
+
urls: list[tuple[str, Path]]
|
147
|
+
) -> list[Path]:
|
148
|
+
"""
|
149
|
+
Download multiple files in parallel.
|
150
|
+
|
151
|
+
Args:
|
152
|
+
urls: List of (url, destination) tuples.
|
153
|
+
|
154
|
+
Returns:
|
155
|
+
List of downloaded file paths in the same order as input.
|
156
|
+
|
157
|
+
Raises:
|
158
|
+
DownloadError: If any download fails.
|
159
|
+
"""
|
160
|
+
errors = []
|
161
|
+
|
162
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
163
|
+
# Submit all downloads, maintaining order with index
|
164
|
+
futures = [
|
165
|
+
executor.submit(self.download_with_progress, url, dest)
|
166
|
+
for url, dest in urls
|
167
|
+
]
|
168
|
+
|
169
|
+
# Collect results in order
|
170
|
+
results = []
|
171
|
+
for i, future in enumerate(futures):
|
172
|
+
url, dest = urls[i]
|
173
|
+
try:
|
174
|
+
result = future.result()
|
175
|
+
results.append(result)
|
176
|
+
except Exception as e:
|
177
|
+
errors.append((url, e))
|
178
|
+
log.error(f"Failed to download {url}: {e}")
|
179
|
+
|
180
|
+
if errors:
|
181
|
+
raise DownloadError(f"Some downloads failed: {errors}")
|
182
|
+
|
183
|
+
return results
|
184
|
+
|
185
|
+
def download_with_mirrors(
|
186
|
+
self,
|
187
|
+
mirrors: list[str],
|
188
|
+
dest: Path
|
189
|
+
) -> Path:
|
190
|
+
"""
|
191
|
+
Try multiple mirrors until one succeeds using fallback pattern.
|
192
|
+
|
193
|
+
Args:
|
194
|
+
mirrors: List of mirror URLs to try.
|
195
|
+
dest: Destination file path.
|
196
|
+
|
197
|
+
Returns:
|
198
|
+
Path to downloaded file.
|
199
|
+
|
200
|
+
Raises:
|
201
|
+
DownloadError: If all mirrors fail.
|
202
|
+
"""
|
203
|
+
from provide.foundation.resilience.fallback import FallbackChain
|
204
|
+
|
205
|
+
if not mirrors:
|
206
|
+
raise DownloadError("No mirrors provided")
|
207
|
+
|
208
|
+
# Create fallback functions for each mirror
|
209
|
+
fallback_funcs = []
|
210
|
+
for mirror_url in mirrors:
|
211
|
+
def create_mirror_func(url):
|
212
|
+
def mirror_download():
|
213
|
+
log.debug(f"Trying mirror: {url}")
|
214
|
+
return self.download_with_progress(url, dest)
|
215
|
+
return mirror_download
|
216
|
+
fallback_funcs.append(create_mirror_func(mirror_url))
|
217
|
+
|
218
|
+
# Use FallbackChain to try mirrors in order
|
219
|
+
chain = FallbackChain(fallbacks=fallback_funcs[1:]) # All but first are fallbacks
|
220
|
+
|
221
|
+
try:
|
222
|
+
return chain.execute(fallback_funcs[0]) # First is primary
|
223
|
+
except Exception as e:
|
224
|
+
raise DownloadError(f"All mirrors failed: {e}")
|
@@ -0,0 +1,254 @@
|
|
1
|
+
"""
|
2
|
+
Tool installation manager for various archive formats.
|
3
|
+
|
4
|
+
Handles extraction and installation of tools from different
|
5
|
+
archive formats (zip, tar, gz, etc.) and binary files.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import shutil
|
9
|
+
import tarfile
|
10
|
+
import tempfile
|
11
|
+
import zipfile
|
12
|
+
from pathlib import Path
|
13
|
+
|
14
|
+
from provide.foundation.errors import FoundationError
|
15
|
+
from provide.foundation.logger import get_logger
|
16
|
+
from provide.foundation.tools.base import ToolMetadata
|
17
|
+
|
18
|
+
log = get_logger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class InstallError(FoundationError):
|
22
|
+
"""Raised when installation fails."""
|
23
|
+
|
24
|
+
pass
|
25
|
+
|
26
|
+
|
27
|
+
class ToolInstaller:
|
28
|
+
"""
|
29
|
+
Handle tool installation from various artifact formats.
|
30
|
+
|
31
|
+
Supports:
|
32
|
+
- ZIP archives
|
33
|
+
- TAR archives (with compression)
|
34
|
+
- Single binary files
|
35
|
+
- Platform-specific installation patterns
|
36
|
+
"""
|
37
|
+
|
38
|
+
def install(self, artifact: Path, metadata: ToolMetadata) -> Path:
|
39
|
+
"""
|
40
|
+
Install tool from artifact.
|
41
|
+
|
42
|
+
Args:
|
43
|
+
artifact: Path to downloaded artifact.
|
44
|
+
metadata: Tool metadata with installation info.
|
45
|
+
|
46
|
+
Returns:
|
47
|
+
Path to installed tool directory.
|
48
|
+
|
49
|
+
Raises:
|
50
|
+
InstallError: If installation fails.
|
51
|
+
"""
|
52
|
+
if not artifact.exists():
|
53
|
+
raise InstallError(f"Artifact not found: {artifact}")
|
54
|
+
|
55
|
+
# Determine install directory
|
56
|
+
install_dir = self.get_install_dir(metadata)
|
57
|
+
|
58
|
+
log.info(f"Installing {metadata.name} {metadata.version} to {install_dir}")
|
59
|
+
|
60
|
+
# Extract based on file type
|
61
|
+
suffix = artifact.suffix.lower()
|
62
|
+
if suffix == ".zip":
|
63
|
+
self.extract_zip(artifact, install_dir)
|
64
|
+
elif suffix in [".tar", ".gz", ".tgz", ".bz2", ".xz"]:
|
65
|
+
self.extract_tar(artifact, install_dir)
|
66
|
+
elif self.is_binary(artifact):
|
67
|
+
self.install_binary(artifact, install_dir, metadata)
|
68
|
+
else:
|
69
|
+
raise InstallError(f"Unknown artifact type: {suffix}")
|
70
|
+
|
71
|
+
# Set permissions
|
72
|
+
self.set_permissions(install_dir, metadata)
|
73
|
+
|
74
|
+
# Create symlinks if needed
|
75
|
+
self.create_symlinks(install_dir, metadata)
|
76
|
+
|
77
|
+
log.info(f"Successfully installed {metadata.name} to {install_dir}")
|
78
|
+
return install_dir
|
79
|
+
|
80
|
+
def get_install_dir(self, metadata: ToolMetadata) -> Path:
|
81
|
+
"""
|
82
|
+
Get installation directory for tool.
|
83
|
+
|
84
|
+
Args:
|
85
|
+
metadata: Tool metadata.
|
86
|
+
|
87
|
+
Returns:
|
88
|
+
Installation directory path.
|
89
|
+
"""
|
90
|
+
if metadata.install_path:
|
91
|
+
return metadata.install_path
|
92
|
+
|
93
|
+
# Default to ~/.wrknv/tools/<name>/<version>
|
94
|
+
base = Path.home() / ".wrknv" / "tools"
|
95
|
+
return base / metadata.name / metadata.version
|
96
|
+
|
97
|
+
def extract_zip(self, archive: Path, dest: Path) -> None:
|
98
|
+
"""
|
99
|
+
Extract ZIP archive.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
archive: Path to ZIP file.
|
103
|
+
dest: Destination directory.
|
104
|
+
"""
|
105
|
+
log.debug(f"Extracting ZIP {archive} to {dest}")
|
106
|
+
|
107
|
+
dest.mkdir(parents=True, exist_ok=True)
|
108
|
+
|
109
|
+
with zipfile.ZipFile(archive, "r") as zf:
|
110
|
+
# Check for unsafe paths
|
111
|
+
for member in zf.namelist():
|
112
|
+
if member.startswith("/") or ".." in member:
|
113
|
+
raise InstallError(f"Unsafe path in archive: {member}")
|
114
|
+
|
115
|
+
zf.extractall(dest)
|
116
|
+
|
117
|
+
def extract_tar(self, archive: Path, dest: Path) -> None:
|
118
|
+
"""
|
119
|
+
Extract tar archive (with optional compression).
|
120
|
+
|
121
|
+
Args:
|
122
|
+
archive: Path to tar file.
|
123
|
+
dest: Destination directory.
|
124
|
+
"""
|
125
|
+
log.debug(f"Extracting tar {archive} to {dest}")
|
126
|
+
|
127
|
+
dest.mkdir(parents=True, exist_ok=True)
|
128
|
+
|
129
|
+
# Determine mode based on extension
|
130
|
+
mode = "r"
|
131
|
+
if archive.suffix in [".gz", ".tgz"]:
|
132
|
+
mode = "r:gz"
|
133
|
+
elif archive.suffix == ".bz2":
|
134
|
+
mode = "r:bz2"
|
135
|
+
elif archive.suffix == ".xz":
|
136
|
+
mode = "r:xz"
|
137
|
+
|
138
|
+
with tarfile.open(archive, mode) as tf:
|
139
|
+
# Check for unsafe paths
|
140
|
+
for member in tf.getmembers():
|
141
|
+
if member.name.startswith("/") or ".." in member.name:
|
142
|
+
raise InstallError(f"Unsafe path in archive: {member.name}")
|
143
|
+
|
144
|
+
tf.extractall(dest)
|
145
|
+
|
146
|
+
def is_binary(self, file_path: Path) -> bool:
|
147
|
+
"""
|
148
|
+
Check if file is a binary executable.
|
149
|
+
|
150
|
+
Args:
|
151
|
+
file_path: Path to check.
|
152
|
+
|
153
|
+
Returns:
|
154
|
+
True if file appears to be binary.
|
155
|
+
"""
|
156
|
+
# Check if file has no extension or common binary extensions
|
157
|
+
if not file_path.suffix or file_path.suffix in [".exe", ".bin"]:
|
158
|
+
# Try to read first few bytes
|
159
|
+
try:
|
160
|
+
with file_path.open("rb") as f:
|
161
|
+
header = f.read(4)
|
162
|
+
# Check for common binary signatures
|
163
|
+
if header.startswith(b"\x7fELF"): # Linux ELF
|
164
|
+
return True
|
165
|
+
if header.startswith(b"MZ"): # Windows PE
|
166
|
+
return True
|
167
|
+
if header.startswith(b"\xfe\xed\xfa"): # macOS Mach-O
|
168
|
+
return True
|
169
|
+
if header.startswith(b"\xca\xfe\xba\xbe"): # macOS universal
|
170
|
+
return True
|
171
|
+
except Exception:
|
172
|
+
pass
|
173
|
+
|
174
|
+
return False
|
175
|
+
|
176
|
+
def install_binary(
|
177
|
+
self,
|
178
|
+
binary: Path,
|
179
|
+
dest: Path,
|
180
|
+
metadata: ToolMetadata
|
181
|
+
) -> None:
|
182
|
+
"""
|
183
|
+
Install single binary file.
|
184
|
+
|
185
|
+
Args:
|
186
|
+
binary: Path to binary file.
|
187
|
+
dest: Destination directory.
|
188
|
+
metadata: Tool metadata.
|
189
|
+
"""
|
190
|
+
log.debug(f"Installing binary {binary} to {dest}")
|
191
|
+
|
192
|
+
dest.mkdir(parents=True, exist_ok=True)
|
193
|
+
bin_dir = dest / "bin"
|
194
|
+
bin_dir.mkdir(exist_ok=True)
|
195
|
+
|
196
|
+
# Determine target name
|
197
|
+
target_name = metadata.executable_name or binary.name
|
198
|
+
target = bin_dir / target_name
|
199
|
+
|
200
|
+
# Copy binary
|
201
|
+
shutil.copy2(binary, target)
|
202
|
+
|
203
|
+
# Make executable
|
204
|
+
target.chmod(0o755)
|
205
|
+
|
206
|
+
def set_permissions(self, install_dir: Path, metadata: ToolMetadata) -> None:
|
207
|
+
"""
|
208
|
+
Set appropriate permissions on installed files.
|
209
|
+
|
210
|
+
Args:
|
211
|
+
install_dir: Installation directory.
|
212
|
+
metadata: Tool metadata.
|
213
|
+
"""
|
214
|
+
import platform
|
215
|
+
|
216
|
+
if platform.system() == "Windows":
|
217
|
+
return # Windows handles permissions differently
|
218
|
+
|
219
|
+
# Find executables and make them executable
|
220
|
+
bin_dir = install_dir / "bin"
|
221
|
+
if bin_dir.exists():
|
222
|
+
for file in bin_dir.iterdir():
|
223
|
+
if file.is_file():
|
224
|
+
file.chmod(0o755)
|
225
|
+
|
226
|
+
# Check for executable name in root
|
227
|
+
if metadata.executable_name:
|
228
|
+
exe_path = install_dir / metadata.executable_name
|
229
|
+
if exe_path.exists():
|
230
|
+
exe_path.chmod(0o755)
|
231
|
+
|
232
|
+
def create_symlinks(self, install_dir: Path, metadata: ToolMetadata) -> None:
|
233
|
+
"""
|
234
|
+
Create symlinks for easier access.
|
235
|
+
|
236
|
+
Args:
|
237
|
+
install_dir: Installation directory.
|
238
|
+
metadata: Tool metadata.
|
239
|
+
"""
|
240
|
+
import platform
|
241
|
+
|
242
|
+
if platform.system() == "Windows":
|
243
|
+
return # Windows doesn't support symlinks easily
|
244
|
+
|
245
|
+
# Create version-less symlink
|
246
|
+
if metadata.name and metadata.version:
|
247
|
+
parent = install_dir.parent
|
248
|
+
latest_link = parent / "latest"
|
249
|
+
|
250
|
+
if latest_link.exists() or latest_link.is_symlink():
|
251
|
+
latest_link.unlink()
|
252
|
+
|
253
|
+
latest_link.symlink_to(install_dir)
|
254
|
+
log.debug(f"Created symlink {latest_link} -> {install_dir}")
|
@@ -0,0 +1,223 @@
|
|
1
|
+
"""
|
2
|
+
Tool registry management using the foundation hub.
|
3
|
+
|
4
|
+
Provides registration and discovery of tool managers using
|
5
|
+
the main hub registry with proper dimension separation.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import importlib.metadata
|
9
|
+
from typing import Any
|
10
|
+
|
11
|
+
from provide.foundation.config import BaseConfig
|
12
|
+
from provide.foundation.hub import get_hub
|
13
|
+
from provide.foundation.logger import get_logger
|
14
|
+
from provide.foundation.tools.base import BaseToolManager
|
15
|
+
|
16
|
+
log = get_logger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
class ToolRegistry:
|
20
|
+
"""
|
21
|
+
Wrapper around the hub registry for tool management.
|
22
|
+
|
23
|
+
Uses the main hub registry with dimension="tool_manager"
|
24
|
+
to avoid namespace pollution while leveraging existing
|
25
|
+
registry infrastructure.
|
26
|
+
"""
|
27
|
+
|
28
|
+
DIMENSION = "tool_manager"
|
29
|
+
|
30
|
+
def __init__(self):
|
31
|
+
"""Initialize the tool registry."""
|
32
|
+
self.hub = get_hub()
|
33
|
+
self._discover_tools()
|
34
|
+
|
35
|
+
def _discover_tools(self) -> None:
|
36
|
+
"""
|
37
|
+
Auto-discover tool managers via entry points.
|
38
|
+
|
39
|
+
Looks for entry points in the "wrknv.tools" group
|
40
|
+
and automatically registers them.
|
41
|
+
"""
|
42
|
+
try:
|
43
|
+
# Get entry points for tool managers
|
44
|
+
if hasattr(importlib.metadata, 'entry_points'):
|
45
|
+
# Python 3.10+
|
46
|
+
eps = importlib.metadata.entry_points()
|
47
|
+
if hasattr(eps, 'select'):
|
48
|
+
# Python 3.10+ with select method
|
49
|
+
group_eps = eps.select(group="wrknv.tools")
|
50
|
+
else:
|
51
|
+
# Fallback for older API
|
52
|
+
group_eps = eps.get("wrknv.tools", [])
|
53
|
+
else:
|
54
|
+
# Python 3.8-3.9
|
55
|
+
eps = importlib.metadata.entry_points()
|
56
|
+
group_eps = eps.get("wrknv.tools", [])
|
57
|
+
|
58
|
+
for ep in group_eps:
|
59
|
+
try:
|
60
|
+
manager_class = ep.load()
|
61
|
+
self.register_tool_manager(ep.name, manager_class)
|
62
|
+
log.debug(f"Auto-discovered tool manager: {ep.name}")
|
63
|
+
except Exception as e:
|
64
|
+
log.warning(f"Failed to load tool manager {ep.name}: {e}")
|
65
|
+
except Exception as e:
|
66
|
+
log.debug(f"Entry point discovery not available: {e}")
|
67
|
+
|
68
|
+
def register_tool_manager(
|
69
|
+
self,
|
70
|
+
name: str,
|
71
|
+
manager_class: type[BaseToolManager],
|
72
|
+
aliases: list[str] | None = None
|
73
|
+
) -> None:
|
74
|
+
"""
|
75
|
+
Register a tool manager with the hub.
|
76
|
+
|
77
|
+
Args:
|
78
|
+
name: Tool name (e.g., "terraform").
|
79
|
+
manager_class: Tool manager class.
|
80
|
+
aliases: Optional aliases for the tool.
|
81
|
+
"""
|
82
|
+
# Prepare metadata
|
83
|
+
metadata = {
|
84
|
+
"tool_name": manager_class.tool_name,
|
85
|
+
"executable": manager_class.executable_name,
|
86
|
+
"platforms": manager_class.supported_platforms,
|
87
|
+
}
|
88
|
+
|
89
|
+
# Register with hub
|
90
|
+
self.hub.registry.register(
|
91
|
+
name=name,
|
92
|
+
value=manager_class,
|
93
|
+
dimension=self.DIMENSION,
|
94
|
+
metadata=metadata,
|
95
|
+
aliases=aliases,
|
96
|
+
replace=True # Allow re-registration for updates
|
97
|
+
)
|
98
|
+
|
99
|
+
log.info(f"Registered tool manager: {name}")
|
100
|
+
|
101
|
+
def get_tool_manager_class(self, name: str) -> type[BaseToolManager] | None:
|
102
|
+
"""
|
103
|
+
Get a tool manager class by name.
|
104
|
+
|
105
|
+
Args:
|
106
|
+
name: Tool name or alias.
|
107
|
+
|
108
|
+
Returns:
|
109
|
+
Tool manager class, or None if not found.
|
110
|
+
"""
|
111
|
+
return self.hub.registry.get(name, dimension=self.DIMENSION)
|
112
|
+
|
113
|
+
def create_tool_manager(
|
114
|
+
self,
|
115
|
+
name: str,
|
116
|
+
config: BaseConfig
|
117
|
+
) -> BaseToolManager | None:
|
118
|
+
"""
|
119
|
+
Create a tool manager instance.
|
120
|
+
|
121
|
+
Args:
|
122
|
+
name: Tool name or alias.
|
123
|
+
config: Configuration object.
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
Tool manager instance, or None if not found.
|
127
|
+
"""
|
128
|
+
manager_class = self.get_tool_manager_class(name)
|
129
|
+
if manager_class:
|
130
|
+
return manager_class(config)
|
131
|
+
return None
|
132
|
+
|
133
|
+
def list_tools(self) -> list[tuple[str, dict[str, Any]]]:
|
134
|
+
"""
|
135
|
+
List all registered tools.
|
136
|
+
|
137
|
+
Returns:
|
138
|
+
List of (name, metadata) tuples.
|
139
|
+
"""
|
140
|
+
tools = []
|
141
|
+
for name, entry in self.hub.registry.list_dimension(self.DIMENSION):
|
142
|
+
metadata = entry.metadata if hasattr(entry, 'metadata') else {}
|
143
|
+
tools.append((name, metadata))
|
144
|
+
return tools
|
145
|
+
|
146
|
+
def get_tool_info(self, name: str) -> dict[str, Any] | None:
|
147
|
+
"""
|
148
|
+
Get information about a specific tool.
|
149
|
+
|
150
|
+
Args:
|
151
|
+
name: Tool name or alias.
|
152
|
+
|
153
|
+
Returns:
|
154
|
+
Tool metadata dictionary, or None if not found.
|
155
|
+
"""
|
156
|
+
entry = self.hub.registry.get_entry(name, dimension=self.DIMENSION)
|
157
|
+
if entry and hasattr(entry, 'metadata'):
|
158
|
+
return entry.metadata
|
159
|
+
return None
|
160
|
+
|
161
|
+
def is_tool_registered(self, name: str) -> bool:
|
162
|
+
"""
|
163
|
+
Check if a tool is registered.
|
164
|
+
|
165
|
+
Args:
|
166
|
+
name: Tool name or alias.
|
167
|
+
|
168
|
+
Returns:
|
169
|
+
True if registered, False otherwise.
|
170
|
+
"""
|
171
|
+
return self.get_tool_manager_class(name) is not None
|
172
|
+
|
173
|
+
|
174
|
+
# Global registry instance
|
175
|
+
_tool_registry: ToolRegistry | None = None
|
176
|
+
|
177
|
+
|
178
|
+
def get_tool_registry() -> ToolRegistry:
|
179
|
+
"""
|
180
|
+
Get the global tool registry instance.
|
181
|
+
|
182
|
+
Returns:
|
183
|
+
Tool registry instance.
|
184
|
+
"""
|
185
|
+
global _tool_registry
|
186
|
+
if _tool_registry is None:
|
187
|
+
_tool_registry = ToolRegistry()
|
188
|
+
return _tool_registry
|
189
|
+
|
190
|
+
|
191
|
+
def register_tool_manager(
|
192
|
+
name: str,
|
193
|
+
manager_class: type[BaseToolManager],
|
194
|
+
aliases: list[str] | None = None
|
195
|
+
) -> None:
|
196
|
+
"""
|
197
|
+
Register a tool manager with the global registry.
|
198
|
+
|
199
|
+
Args:
|
200
|
+
name: Tool name.
|
201
|
+
manager_class: Tool manager class.
|
202
|
+
aliases: Optional aliases.
|
203
|
+
"""
|
204
|
+
registry = get_tool_registry()
|
205
|
+
registry.register_tool_manager(name, manager_class, aliases)
|
206
|
+
|
207
|
+
|
208
|
+
def get_tool_manager(
|
209
|
+
name: str,
|
210
|
+
config: BaseConfig
|
211
|
+
) -> BaseToolManager | None:
|
212
|
+
"""
|
213
|
+
Get a tool manager instance from the global registry.
|
214
|
+
|
215
|
+
Args:
|
216
|
+
name: Tool name or alias.
|
217
|
+
config: Configuration object.
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
Tool manager instance, or None if not found.
|
221
|
+
"""
|
222
|
+
registry = get_tool_registry()
|
223
|
+
return registry.create_tool_manager(name, config)
|