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.
Files changed (161) hide show
  1. provide/foundation/__init__.py +41 -23
  2. provide/foundation/archive/__init__.py +23 -0
  3. provide/foundation/archive/base.py +70 -0
  4. provide/foundation/archive/bzip2.py +157 -0
  5. provide/foundation/archive/gzip.py +159 -0
  6. provide/foundation/archive/operations.py +334 -0
  7. provide/foundation/archive/tar.py +164 -0
  8. provide/foundation/archive/zip.py +203 -0
  9. provide/foundation/cli/__init__.py +2 -2
  10. provide/foundation/cli/commands/deps.py +13 -7
  11. provide/foundation/cli/commands/logs/__init__.py +1 -1
  12. provide/foundation/cli/commands/logs/query.py +1 -1
  13. provide/foundation/cli/commands/logs/send.py +1 -1
  14. provide/foundation/cli/commands/logs/tail.py +1 -1
  15. provide/foundation/cli/decorators.py +11 -10
  16. provide/foundation/cli/main.py +1 -1
  17. provide/foundation/cli/testing.py +2 -35
  18. provide/foundation/cli/utils.py +21 -17
  19. provide/foundation/config/__init__.py +35 -2
  20. provide/foundation/config/base.py +2 -2
  21. provide/foundation/config/converters.py +479 -0
  22. provide/foundation/config/defaults.py +67 -0
  23. provide/foundation/config/env.py +4 -19
  24. provide/foundation/config/loader.py +9 -3
  25. provide/foundation/config/sync.py +19 -4
  26. provide/foundation/console/input.py +5 -5
  27. provide/foundation/console/output.py +35 -13
  28. provide/foundation/context/__init__.py +8 -4
  29. provide/foundation/context/core.py +85 -109
  30. provide/foundation/core.py +1 -2
  31. provide/foundation/crypto/__init__.py +2 -0
  32. provide/foundation/crypto/certificates/__init__.py +34 -0
  33. provide/foundation/crypto/certificates/base.py +173 -0
  34. provide/foundation/crypto/certificates/certificate.py +290 -0
  35. provide/foundation/crypto/certificates/factory.py +213 -0
  36. provide/foundation/crypto/certificates/generator.py +138 -0
  37. provide/foundation/crypto/certificates/loader.py +130 -0
  38. provide/foundation/crypto/certificates/operations.py +198 -0
  39. provide/foundation/crypto/certificates/trust.py +107 -0
  40. provide/foundation/errors/__init__.py +2 -3
  41. provide/foundation/errors/decorators.py +0 -231
  42. provide/foundation/errors/types.py +0 -97
  43. provide/foundation/eventsets/__init__.py +0 -0
  44. provide/foundation/eventsets/display.py +84 -0
  45. provide/foundation/eventsets/registry.py +160 -0
  46. provide/foundation/eventsets/resolver.py +192 -0
  47. provide/foundation/eventsets/sets/das.py +128 -0
  48. provide/foundation/eventsets/sets/database.py +125 -0
  49. provide/foundation/eventsets/sets/http.py +153 -0
  50. provide/foundation/eventsets/sets/llm.py +139 -0
  51. provide/foundation/eventsets/sets/task_queue.py +107 -0
  52. provide/foundation/eventsets/types.py +70 -0
  53. provide/foundation/file/directory.py +13 -22
  54. provide/foundation/file/lock.py +3 -1
  55. provide/foundation/hub/components.py +77 -515
  56. provide/foundation/hub/config.py +151 -0
  57. provide/foundation/hub/discovery.py +62 -0
  58. provide/foundation/hub/handlers.py +81 -0
  59. provide/foundation/hub/lifecycle.py +194 -0
  60. provide/foundation/hub/manager.py +4 -4
  61. provide/foundation/hub/processors.py +44 -0
  62. provide/foundation/integrations/__init__.py +11 -0
  63. provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
  64. provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
  65. provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
  66. provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
  67. provide/foundation/integrations/openobserve/config.py +37 -0
  68. provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
  69. provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
  70. provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
  71. provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
  72. provide/foundation/logger/__init__.py +3 -10
  73. provide/foundation/logger/config/logging.py +68 -298
  74. provide/foundation/logger/config/telemetry.py +41 -121
  75. provide/foundation/logger/core.py +0 -2
  76. provide/foundation/logger/custom_processors.py +1 -0
  77. provide/foundation/logger/factories.py +11 -2
  78. provide/foundation/logger/processors/main.py +20 -84
  79. provide/foundation/logger/setup/__init__.py +5 -1
  80. provide/foundation/logger/setup/coordinator.py +76 -24
  81. provide/foundation/logger/setup/processors.py +2 -9
  82. provide/foundation/logger/trace.py +27 -0
  83. provide/foundation/metrics/otel.py +10 -10
  84. provide/foundation/observability/__init__.py +2 -2
  85. provide/foundation/process/__init__.py +9 -0
  86. provide/foundation/process/exit.py +47 -0
  87. provide/foundation/process/lifecycle.py +115 -59
  88. provide/foundation/resilience/__init__.py +35 -0
  89. provide/foundation/resilience/circuit.py +164 -0
  90. provide/foundation/resilience/decorators.py +220 -0
  91. provide/foundation/resilience/fallback.py +193 -0
  92. provide/foundation/resilience/retry.py +325 -0
  93. provide/foundation/streams/config.py +79 -0
  94. provide/foundation/streams/console.py +7 -8
  95. provide/foundation/streams/core.py +6 -3
  96. provide/foundation/streams/file.py +12 -2
  97. provide/foundation/testing/__init__.py +84 -2
  98. provide/foundation/testing/archive/__init__.py +24 -0
  99. provide/foundation/testing/archive/fixtures.py +217 -0
  100. provide/foundation/testing/cli.py +30 -17
  101. provide/foundation/testing/common/__init__.py +32 -0
  102. provide/foundation/testing/common/fixtures.py +236 -0
  103. provide/foundation/testing/file/__init__.py +40 -0
  104. provide/foundation/testing/file/content_fixtures.py +316 -0
  105. provide/foundation/testing/file/directory_fixtures.py +107 -0
  106. provide/foundation/testing/file/fixtures.py +52 -0
  107. provide/foundation/testing/file/special_fixtures.py +153 -0
  108. provide/foundation/testing/logger.py +117 -11
  109. provide/foundation/testing/mocking/__init__.py +46 -0
  110. provide/foundation/testing/mocking/fixtures.py +331 -0
  111. provide/foundation/testing/process/__init__.py +48 -0
  112. provide/foundation/testing/process/async_fixtures.py +405 -0
  113. provide/foundation/testing/process/fixtures.py +56 -0
  114. provide/foundation/testing/process/subprocess_fixtures.py +209 -0
  115. provide/foundation/testing/threading/__init__.py +38 -0
  116. provide/foundation/testing/threading/basic_fixtures.py +101 -0
  117. provide/foundation/testing/threading/data_fixtures.py +99 -0
  118. provide/foundation/testing/threading/execution_fixtures.py +263 -0
  119. provide/foundation/testing/threading/fixtures.py +54 -0
  120. provide/foundation/testing/threading/sync_fixtures.py +97 -0
  121. provide/foundation/testing/time/__init__.py +32 -0
  122. provide/foundation/testing/time/fixtures.py +409 -0
  123. provide/foundation/testing/transport/__init__.py +30 -0
  124. provide/foundation/testing/transport/fixtures.py +280 -0
  125. provide/foundation/tools/__init__.py +58 -0
  126. provide/foundation/tools/base.py +348 -0
  127. provide/foundation/tools/cache.py +268 -0
  128. provide/foundation/tools/downloader.py +224 -0
  129. provide/foundation/tools/installer.py +254 -0
  130. provide/foundation/tools/registry.py +223 -0
  131. provide/foundation/tools/resolver.py +321 -0
  132. provide/foundation/tools/verifier.py +186 -0
  133. provide/foundation/tracer/otel.py +7 -11
  134. provide/foundation/tracer/spans.py +2 -2
  135. provide/foundation/transport/__init__.py +155 -0
  136. provide/foundation/transport/base.py +171 -0
  137. provide/foundation/transport/client.py +266 -0
  138. provide/foundation/transport/config.py +140 -0
  139. provide/foundation/transport/errors.py +79 -0
  140. provide/foundation/transport/http.py +232 -0
  141. provide/foundation/transport/middleware.py +360 -0
  142. provide/foundation/transport/registry.py +167 -0
  143. provide/foundation/transport/types.py +45 -0
  144. provide/foundation/utils/deps.py +14 -12
  145. provide/foundation/utils/parsing.py +49 -4
  146. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +5 -28
  147. provide_foundation-0.0.0.dev2.dist-info/RECORD +225 -0
  148. provide/foundation/cli/commands/logs/generate_old.py +0 -569
  149. provide/foundation/crypto/certificates.py +0 -896
  150. provide/foundation/logger/emoji/__init__.py +0 -44
  151. provide/foundation/logger/emoji/matrix.py +0 -209
  152. provide/foundation/logger/emoji/sets.py +0 -458
  153. provide/foundation/logger/emoji/types.py +0 -56
  154. provide/foundation/logger/setup/emoji_resolver.py +0 -64
  155. provide_foundation-0.0.0.dev0.dist-info/RECORD +0 -149
  156. /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
  157. /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
  158. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
  159. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
  160. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
  161. {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)