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.
- napt/__init__.py +91 -0
- napt/build/__init__.py +47 -0
- napt/build/manager.py +1087 -0
- napt/build/packager.py +315 -0
- napt/build/template.py +301 -0
- napt/cli.py +602 -0
- napt/config/__init__.py +42 -0
- napt/config/loader.py +465 -0
- napt/core.py +385 -0
- napt/detection.py +630 -0
- napt/discovery/__init__.py +86 -0
- napt/discovery/api_github.py +445 -0
- napt/discovery/api_json.py +452 -0
- napt/discovery/base.py +244 -0
- napt/discovery/url_download.py +304 -0
- napt/discovery/web_scrape.py +467 -0
- napt/exceptions.py +149 -0
- napt/io/__init__.py +42 -0
- napt/io/download.py +357 -0
- napt/io/upload.py +37 -0
- napt/logging.py +230 -0
- napt/policy/__init__.py +50 -0
- napt/policy/updates.py +126 -0
- napt/psadt/__init__.py +43 -0
- napt/psadt/release.py +309 -0
- napt/requirements.py +566 -0
- napt/results.py +143 -0
- napt/state/__init__.py +58 -0
- napt/state/tracker.py +371 -0
- napt/validation.py +467 -0
- napt/versioning/__init__.py +115 -0
- napt/versioning/keys.py +309 -0
- napt/versioning/msi.py +725 -0
- napt-0.3.1.dist-info/METADATA +114 -0
- napt-0.3.1.dist-info/RECORD +38 -0
- napt-0.3.1.dist-info/WHEEL +4 -0
- napt-0.3.1.dist-info/entry_points.txt +3 -0
- napt-0.3.1.dist-info/licenses/LICENSE +202 -0
|
@@ -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]()
|