napt 0.3.1__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.
@@ -0,0 +1,452 @@
1
+ # Copyright 2025 Roger Cibrian
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """JSON API discovery strategy for NAPT.
16
+
17
+ This is a VERSION-FIRST strategy that queries JSON API endpoints to get version
18
+ and download URL WITHOUT downloading the installer. This enables fast version
19
+ checks and efficient caching.
20
+
21
+ Key Advantages:
22
+
23
+ - Fast version discovery (API call ~100ms)
24
+ - Can skip downloads entirely when version unchanged
25
+ - Direct API access for version and download URL
26
+ - Support for complex JSON structures with JSONPath
27
+ - Custom headers for authentication
28
+ - Support for GET and POST requests
29
+ - No file parsing required
30
+ - Ideal for CI/CD with scheduled checks
31
+
32
+ Supported Features:
33
+
34
+ - JSONPath navigation for nested structures
35
+ - Array indexing and filtering
36
+ - Custom HTTP headers (Authorization, etc.)
37
+ - POST requests with JSON body
38
+ - Environment variable expansion in values
39
+
40
+ Use Cases:
41
+
42
+ - Vendors with JSON APIs (Microsoft, Mozilla, etc.)
43
+ - Cloud services with version endpoints
44
+ - CDNs that provide metadata APIs
45
+ - Applications with update check APIs
46
+ - APIs requiring authentication or custom headers
47
+ - CI/CD pipelines with frequent version checks
48
+
49
+ Recipe Configuration:
50
+ ```yaml
51
+ source:
52
+ strategy: api_json
53
+ api_url: "https://vendor.com/api/latest"
54
+ version_path: "version" # JSONPath to version
55
+ download_url_path: "download_url" # JSONPath to URL
56
+ method: "GET" # Optional: GET or POST
57
+ headers: # Optional: custom headers
58
+ Authorization: "Bearer ${API_TOKEN}"
59
+ Accept: "application/json"
60
+ body: # Optional: POST body
61
+ platform: "windows"
62
+ arch: "x64"
63
+ timeout: 30 # Optional: timeout in seconds
64
+ ```
65
+
66
+ Configuration Fields:
67
+
68
+ - **api_url** (str, required): API endpoint URL that returns JSON with version
69
+ and download information
70
+ - **version_path** (str, required): JSONPath expression to extract version from
71
+ the API response. Examples: "version", "release.version", "data.version"
72
+ - **download_url_path** (str, required): JSONPath expression to extract
73
+ download URL from the API response. Examples: "download_url", "assets.url",
74
+ "platforms.windows.x64"
75
+ - **method** (str, optional): HTTP method to use. Either "GET" or "POST".
76
+ Default is "GET"
77
+ - **headers** (dict, optional): Custom HTTP headers to send with the request.
78
+ Useful for authentication or setting Accept headers. Values support
79
+ environment variable expansion. Example: {"Authorization": "Bearer ${API_TOKEN}"}
80
+ - **body** (dict, optional): Request body for POST requests. Sent as JSON.
81
+ Only used when method="POST". Example: {"platform": "windows", "arch": "x64"}
82
+ - **timeout** (int, optional): Request timeout in seconds. Default is 30.
83
+
84
+ JSONPath Syntax:
85
+
86
+ - Simple paths: "version", "release.version"
87
+ - Array indexing: "data.version", "releases.version"
88
+ - Nested paths: "data.latest.download.url", "response.assets.browser_download_url"
89
+
90
+ Error Handling:
91
+
92
+ - ValueError: Missing or invalid configuration, invalid JSONPath, path not found
93
+ - RuntimeError: API failures, invalid JSON response
94
+ - Errors are chained with 'from err' for better debugging
95
+
96
+ Example:
97
+ In a recipe YAML (simple API):
98
+ ```yaml
99
+ apps:
100
+ - name: "My App"
101
+ id: "my-app"
102
+ source:
103
+ strategy: api_json
104
+ api_url: "https://api.vendor.com/latest"
105
+ version_path: "version"
106
+ download_url_path: "download_url"
107
+ ```
108
+
109
+ In a recipe YAML (nested structure):
110
+ ```yaml
111
+ apps:
112
+ - name: "My App"
113
+ id: "my-app"
114
+ source:
115
+ strategy: api_json
116
+ api_url: "https://api.vendor.com/releases"
117
+ version_path: "stable.version"
118
+ download_url_path: "stable.platforms.windows.x64"
119
+ headers:
120
+ Authorization: "Bearer ${API_TOKEN}"
121
+ ```
122
+
123
+ From Python (version-first approach):
124
+ ```python
125
+ from napt.discovery.api_json import ApiJsonStrategy
126
+ from napt.io import download_file
127
+
128
+ strategy = ApiJsonStrategy()
129
+ app_config = {
130
+ "source": {
131
+ "api_url": "https://api.vendor.com/latest",
132
+ "version_path": "version",
133
+ "download_url_path": "download_url",
134
+ }
135
+ }
136
+
137
+ # Get version WITHOUT downloading
138
+ version_info = strategy.get_version_info(app_config)
139
+ print(f"Latest version: {version_info.version}")
140
+
141
+ # Download only if needed
142
+ if need_to_download:
143
+ file_path, sha256, headers = download_file(
144
+ version_info.download_url, Path("./downloads")
145
+ )
146
+ print(f"Downloaded to {file_path}")
147
+ ```
148
+
149
+ From Python (using core orchestration):
150
+ ```python
151
+ from pathlib import Path
152
+ from napt.core import discover_recipe
153
+
154
+ # Automatically uses version-first optimization
155
+ result = discover_recipe(Path("recipe.yaml"), Path("./downloads"))
156
+ print(f"Version {result.version} at {result.file_path}")
157
+ ```
158
+
159
+ Note:
160
+ - Version discovery via API only (no download required)
161
+ - Core orchestration automatically skips download if version unchanged
162
+ - JSONPath uses jsonpath-ng library for robust parsing
163
+ - Environment variable expansion works in headers and other string values
164
+ - POST body is sent as JSON (Content-Type: application/json)
165
+ - Timeout defaults to 30 seconds to prevent hanging on slow APIs
166
+
167
+ """
168
+
169
+ from __future__ import annotations
170
+
171
+ import json
172
+ import os
173
+ from typing import Any
174
+
175
+ from jsonpath_ng import parse as jsonpath_parse
176
+ import requests
177
+
178
+ from napt.exceptions import ConfigError, NetworkError
179
+ from napt.versioning.keys import VersionInfo
180
+
181
+ from .base import register_strategy
182
+
183
+
184
+ class ApiJsonStrategy:
185
+ """Discovery strategy for JSON API endpoints.
186
+
187
+ Configuration example:
188
+ source:
189
+ strategy: api_json
190
+ api_url: "https://api.vendor.com/latest"
191
+ version_path: "version"
192
+ download_url_path: "download_url"
193
+ method: "GET"
194
+ headers:
195
+ Authorization: "Bearer ${API_TOKEN}"
196
+ """
197
+
198
+ def get_version_info(
199
+ self,
200
+ app_config: dict[str, Any],
201
+ ) -> VersionInfo:
202
+ """Query JSON API for version and download URL without downloading
203
+ (version-first path).
204
+
205
+ This method calls a JSON API, extracts version and download URL using
206
+ JSONPath expressions. If the version matches cached state, the download
207
+ can be skipped entirely.
208
+
209
+ Args:
210
+ app_config: App configuration containing source.api_url,
211
+ source.version_path, and source.download_url_path.
212
+
213
+ Returns:
214
+ Version info with version string, download URL, and
215
+ source name.
216
+
217
+ Raises:
218
+ ValueError: If required config fields are missing, invalid, or if
219
+ JSONPath expressions don't match anything in the response.
220
+ RuntimeError: If API call fails (chained with 'from err').
221
+
222
+ Example:
223
+ Get version info from JSON API:
224
+ ```python
225
+ strategy = ApiJsonStrategy()
226
+ config = {
227
+ "source": {
228
+ "api_url": "https://api.vendor.com/latest",
229
+ "version_path": "version",
230
+ "download_url_path": "download_url"
231
+ }
232
+ }
233
+ version_info = strategy.get_version_info(config)
234
+ # version_info.version returns: '1.0.0'
235
+ ```
236
+
237
+ """
238
+ from napt.logging import get_global_logger
239
+
240
+ logger = get_global_logger()
241
+ # Validate configuration
242
+ source = app_config.get("source", {})
243
+ api_url = source.get("api_url")
244
+ if not api_url:
245
+ raise ConfigError("api_json strategy requires 'source.api_url' in config")
246
+
247
+ version_path = source.get("version_path")
248
+ if not version_path:
249
+ raise ConfigError(
250
+ "api_json strategy requires 'source.version_path' in config"
251
+ )
252
+
253
+ download_url_path = source.get("download_url_path")
254
+ if not download_url_path:
255
+ raise ConfigError(
256
+ "api_json strategy requires 'source.download_url_path' in config"
257
+ )
258
+
259
+ # Optional configuration
260
+ method = source.get("method", "GET").upper()
261
+ if method not in ("GET", "POST"):
262
+ raise ConfigError(f"Invalid method: {method!r}. Must be 'GET' or 'POST'")
263
+
264
+ headers = source.get("headers", {})
265
+ body = source.get("body", {})
266
+ timeout = source.get("timeout", 30)
267
+
268
+ logger.verbose("DISCOVERY", "Strategy: api_json (version-first)")
269
+ logger.verbose("DISCOVERY", f"API URL: {api_url}")
270
+ logger.verbose("DISCOVERY", f"Method: {method}")
271
+ logger.verbose("DISCOVERY", f"Version path: {version_path}")
272
+ logger.verbose("DISCOVERY", f"Download URL path: {download_url_path}")
273
+
274
+ # Expand environment variables in headers
275
+ expanded_headers = {}
276
+ for key, value in headers.items():
277
+ if (
278
+ isinstance(value, str)
279
+ and value.startswith("${")
280
+ and value.endswith("}")
281
+ ):
282
+ env_var = value[2:-1]
283
+ env_value = os.environ.get(env_var)
284
+ if not env_value:
285
+ logger.verbose(
286
+ "DISCOVERY",
287
+ f"Warning: Environment variable {env_var} not set",
288
+ )
289
+ else:
290
+ expanded_headers[key] = env_value
291
+ else:
292
+ expanded_headers[key] = value
293
+
294
+ # Make API request
295
+ logger.verbose("DISCOVERY", f"Calling API: {method} {api_url}")
296
+ try:
297
+ if method == "GET":
298
+ response = requests.get(
299
+ api_url, headers=expanded_headers, timeout=timeout
300
+ )
301
+ else: # POST
302
+ response = requests.post(
303
+ api_url,
304
+ headers=expanded_headers,
305
+ json=body,
306
+ timeout=timeout,
307
+ )
308
+ response.raise_for_status()
309
+ except requests.exceptions.HTTPError as err:
310
+ raise NetworkError(
311
+ f"API request failed: {response.status_code} {response.reason}"
312
+ ) from err
313
+ except requests.exceptions.RequestException as err:
314
+ raise NetworkError(f"Failed to call API: {err}") from err
315
+
316
+ logger.verbose("DISCOVERY", f"API response: {response.status_code} OK")
317
+
318
+ # Parse JSON response
319
+ try:
320
+ json_data = response.json()
321
+ except json.JSONDecodeError as err:
322
+ raise NetworkError(
323
+ f"Invalid JSON response from API. Response: {response.text[:200]}"
324
+ ) from err
325
+
326
+ logger.debug("DISCOVERY", f"JSON response: {json.dumps(json_data, indent=2)}")
327
+
328
+ # Extract version using JSONPath
329
+ logger.verbose("DISCOVERY", f"Extracting version from path: {version_path}")
330
+ try:
331
+ version_expr = jsonpath_parse(version_path)
332
+ version_matches = version_expr.find(json_data)
333
+
334
+ if not version_matches:
335
+ raise ConfigError(
336
+ f"Version path {version_path!r} did not match anything "
337
+ f"in API response"
338
+ )
339
+
340
+ version_str = str(version_matches[0].value)
341
+ except Exception as err:
342
+ if isinstance(err, ConfigError):
343
+ raise
344
+ raise ConfigError(
345
+ f"Failed to extract version using path {version_path!r}: {err}"
346
+ ) from err
347
+
348
+ logger.verbose("DISCOVERY", f"Extracted version: {version_str}")
349
+
350
+ # Extract download URL using JSONPath
351
+ logger.verbose(
352
+ "DISCOVERY", f"Extracting download URL from path: {download_url_path}"
353
+ )
354
+ try:
355
+ url_expr = jsonpath_parse(download_url_path)
356
+ url_matches = url_expr.find(json_data)
357
+
358
+ if not url_matches:
359
+ raise ConfigError(
360
+ f"Download URL path {download_url_path!r} did not match "
361
+ f"anything in API response"
362
+ )
363
+
364
+ download_url = str(url_matches[0].value)
365
+ except Exception as err:
366
+ if isinstance(err, ConfigError):
367
+ raise
368
+ raise ConfigError(
369
+ f"Failed to extract download URL using path "
370
+ f"{download_url_path!r}: {err}"
371
+ ) from err
372
+
373
+ logger.verbose("DISCOVERY", f"Download URL: {download_url}")
374
+
375
+ return VersionInfo(
376
+ version=version_str,
377
+ download_url=download_url,
378
+ source="api_json",
379
+ )
380
+
381
+ def validate_config(self, app_config: dict[str, Any]) -> list[str]:
382
+ """Validate api_json strategy configuration.
383
+
384
+ Checks for required fields and correct types without making network calls.
385
+
386
+ Args:
387
+ app_config: The app configuration from the recipe.
388
+
389
+ Returns:
390
+ List of error messages (empty if valid).
391
+
392
+ """
393
+ errors = []
394
+ source = app_config.get("source", {})
395
+
396
+ # Check required fields
397
+ if "api_url" not in source:
398
+ errors.append("Missing required field: source.api_url")
399
+ elif not isinstance(source["api_url"], str):
400
+ errors.append("source.api_url must be a string")
401
+ elif not source["api_url"].strip():
402
+ errors.append("source.api_url cannot be empty")
403
+
404
+ if "version_path" not in source:
405
+ errors.append("Missing required field: source.version_path")
406
+ elif not isinstance(source["version_path"], str):
407
+ errors.append("source.version_path must be a string")
408
+ elif not source["version_path"].strip():
409
+ errors.append("source.version_path cannot be empty")
410
+ else:
411
+ # Validate JSONPath syntax
412
+ from jsonpath_ng import parse as jsonpath_parse
413
+
414
+ try:
415
+ jsonpath_parse(source["version_path"])
416
+ except Exception as err:
417
+ errors.append(f"Invalid version_path JSONPath: {err}")
418
+
419
+ if "download_url_path" not in source:
420
+ errors.append("Missing required field: source.download_url_path")
421
+ elif not isinstance(source["download_url_path"], str):
422
+ errors.append("source.download_url_path must be a string")
423
+ elif not source["download_url_path"].strip():
424
+ errors.append("source.download_url_path cannot be empty")
425
+ else:
426
+ # Validate JSONPath syntax
427
+ from jsonpath_ng import parse as jsonpath_parse
428
+
429
+ try:
430
+ jsonpath_parse(source["download_url_path"])
431
+ except Exception as err:
432
+ errors.append(f"Invalid download_url_path JSONPath: {err}")
433
+
434
+ # Optional fields validation
435
+ if "method" in source:
436
+ method = source["method"]
437
+ if not isinstance(method, str):
438
+ errors.append("source.method must be a string")
439
+ elif method.upper() not in ["GET", "POST"]:
440
+ errors.append("source.method must be 'GET' or 'POST'")
441
+
442
+ if "headers" in source and not isinstance(source["headers"], dict):
443
+ errors.append("source.headers must be a dictionary")
444
+
445
+ if "body" in source and not isinstance(source["body"], dict):
446
+ errors.append("source.body must be a dictionary")
447
+
448
+ return errors
449
+
450
+
451
+ # Register this strategy when the module is imported
452
+ register_strategy("api_json", ApiJsonStrategy)
napt/discovery/base.py ADDED
@@ -0,0 +1,244 @@
1
+ # Copyright 2025 Roger Cibrian
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Discovery strategy base protocol and registry for NAPT.
16
+
17
+ This module defines the foundational components for the discovery system:
18
+
19
+ - DiscoveryStrategy protocol: Interface that all strategies must implement
20
+ - Strategy registry: Global dict mapping strategy names to implementations
21
+ - Registration and lookup functions: register_strategy() and get_strategy()
22
+
23
+ The discovery system uses a strategy pattern to support multiple ways
24
+ of obtaining application installers and their versions:
25
+
26
+ - url_download: Direct download from a static URL (FILE-FIRST)
27
+ - web_scrape: Scrape vendor download pages to find links and extract versions
28
+ (VERSION-FIRST)
29
+ - api_github: Fetch from GitHub releases API (VERSION-FIRST)
30
+ - api_json: Query JSON API endpoints for version and download URL (VERSION-FIRST)
31
+
32
+ Design Philosophy:
33
+ - Strategies are Protocol classes (structural subtyping, not inheritance)
34
+ - Registration happens at module import time (strategies self-register)
35
+ - Registry is a simple dict (no complex dependency injection needed)
36
+ - Each strategy is stateless and can be instantiated on-demand
37
+
38
+ Protocol Benefits:
39
+
40
+ Using typing.Protocol instead of ABC allows:
41
+
42
+ - Duck typing: Classes don't need explicit inheritance
43
+ - Better IDE support: Type checkers verify interface compliance
44
+ - Flexibility: Third-party code can add strategies without touching base
45
+
46
+ Example:
47
+ Implementing a custom strategy:
48
+ ```python
49
+ from napt.discovery.base import register_strategy, DiscoveryStrategy
50
+ from pathlib import Path
51
+ from typing import Any
52
+ from napt.versioning.keys import DiscoveredVersion
53
+
54
+ class MyCustomStrategy:
55
+ def discover_version(
56
+ self, app_config: dict[str, Any], output_dir: Path
57
+ ) -> tuple[DiscoveredVersion, Path, str]:
58
+ # Implement your discovery logic here
59
+ ...
60
+
61
+ # Register it (typically at module import)
62
+ register_strategy("my_custom", MyCustomStrategy)
63
+
64
+ # Now it can be used in recipes:
65
+ # source:
66
+ # strategy: my_custom
67
+ # ...
68
+ ```
69
+
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ from pathlib import Path
75
+ from typing import Any, Protocol
76
+
77
+ from napt.exceptions import ConfigError
78
+ from napt.versioning.keys import DiscoveredVersion
79
+
80
+ # -------------------------------
81
+ # Strategy Protocol
82
+ # -------------------------------
83
+
84
+
85
+ class DiscoveryStrategy(Protocol):
86
+ """Protocol for version discovery strategies.
87
+
88
+ Each strategy must implement discover_version() which downloads
89
+ and extracts version information based on the app config.
90
+
91
+ Strategies may optionally implement validate_config() to provide
92
+ strategy-specific configuration validation without network calls.
93
+ """
94
+
95
+ def discover_version(
96
+ self, app_config: dict[str, Any], output_dir: Path
97
+ ) -> tuple[DiscoveredVersion, Path, str, dict]:
98
+ """Discover and download an application version.
99
+
100
+ Args:
101
+ app_config: The app configuration from the recipe
102
+ (`config["app"]`).
103
+ output_dir: Directory to download the installer to.
104
+
105
+ Returns:
106
+ A tuple (discovered_version, file_path, sha256, headers), where
107
+ discovered_version is the version information, file_path is
108
+ the path to the downloaded file, sha256 is the SHA-256 hash,
109
+ and headers contains HTTP response headers for caching.
110
+
111
+ Raises:
112
+ ValueError: On discovery or download failures.
113
+ RuntimeError: On discovery or download failures.
114
+
115
+ """
116
+ ...
117
+
118
+ def validate_config(self, app_config: dict[str, Any]) -> list[str]:
119
+ """Validate strategy-specific configuration (optional).
120
+
121
+ This method validates the app configuration for strategy-specific
122
+ requirements without making network calls or downloading files.
123
+ Useful for quick feedback during recipe development.
124
+
125
+ Args:
126
+ app_config: The app configuration from the recipe
127
+ (`config["app"]`).
128
+
129
+ Returns:
130
+ List of error messages. Empty list if configuration is valid.
131
+ Each error should be a human-readable description of the issue.
132
+
133
+ Example:
134
+ Check required fields:
135
+ ```python
136
+ def validate_config(self, app_config):
137
+ errors = []
138
+ source = app_config.get("source", {})
139
+ if "url" not in source:
140
+ errors.append("Missing required field: source.url")
141
+ return errors
142
+ ```
143
+
144
+ Note:
145
+ This method is optional; strategies without it will skip validation.
146
+ Should NOT make network calls or download files. Should check field
147
+ presence, types, and format only. Used by 'napt validate' command
148
+ for fast recipe checking.
149
+
150
+ """
151
+ ...
152
+
153
+
154
+ # -------------------------------
155
+ # Strategy Registry
156
+ # -------------------------------
157
+
158
+ _STRATEGY_REGISTRY: dict[str, type[DiscoveryStrategy]] = {}
159
+
160
+
161
+ def register_strategy(name: str, strategy_class: type[DiscoveryStrategy]) -> None:
162
+ """Register a discovery strategy by name in the global registry.
163
+
164
+ This function should be called when a strategy module is imported,
165
+ typically at module level. Registering the same name twice will
166
+ overwrite the previous registration (allows monkey-patching for tests).
167
+
168
+ Args:
169
+ name: Strategy name (e.g., "url_download"). This is the value
170
+ used in recipe YAML files under source.strategy. Names should be
171
+ lowercase with underscores for readability.
172
+ strategy_class: The strategy class to
173
+ register. Must implement the DiscoveryStrategy protocol (have a
174
+ discover_version method with the correct signature).
175
+
176
+ Example:
177
+ Register at module import time:
178
+ ```python
179
+ # In discovery/my_strategy.py
180
+ from .base import register_strategy
181
+
182
+ class MyStrategy:
183
+ def discover_version(self, app_config, output_dir):
184
+ ...
185
+
186
+ register_strategy("my_strategy", MyStrategy)
187
+ ```
188
+
189
+ Note:
190
+ No validation is performed at registration time. Type checkers will
191
+ verify protocol compliance at static analysis time. Runtime errors
192
+ occur at strategy instantiation or invocation.
193
+
194
+ """
195
+ _STRATEGY_REGISTRY[name] = strategy_class
196
+
197
+
198
+ def get_strategy(name: str) -> DiscoveryStrategy:
199
+ """Get a discovery strategy instance by name from the global registry.
200
+
201
+ The strategy is instantiated on-demand (strategies are stateless, so
202
+ a new instance is created for each call). The strategy module must
203
+ have been imported first for registration to occur.
204
+
205
+ Args:
206
+ name: Strategy name (e.g., "url_download"). Must exactly match
207
+ a name registered via register_strategy(). Case-sensitive.
208
+
209
+ Returns:
210
+ A new instance of the requested strategy, ready
211
+ to use.
212
+
213
+ Raises:
214
+ ConfigError: If the strategy name is not registered. The error message
215
+ includes a list of available strategies for troubleshooting.
216
+
217
+ Example:
218
+ Get and use a strategy:
219
+ ```python
220
+ from napt.discovery import get_strategy
221
+ strategy = get_strategy("url_download")
222
+ # Use strategy.discover_version(...)
223
+ ```
224
+
225
+ Handle unknown strategy:
226
+ ```python
227
+ try:
228
+ strategy = get_strategy("nonexistent")
229
+ except ConfigError as e:
230
+ print(f"Strategy not found: {e}")
231
+ ```
232
+
233
+ Note:
234
+ Strategies must be registered before they can be retrieved. The
235
+ url_download strategy is auto-registered when imported. New strategies
236
+ can be added by creating a module and registering.
237
+
238
+ """
239
+ if name not in _STRATEGY_REGISTRY:
240
+ available = ", ".join(_STRATEGY_REGISTRY.keys())
241
+ raise ConfigError(
242
+ f"Unknown discovery strategy: {name!r}. Available: {available or '(none)'}"
243
+ )
244
+ return _STRATEGY_REGISTRY[name]()