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,86 @@
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 strategies for NAPT.
16
+
17
+ This package provides a pluggable strategy pattern for discovering application
18
+ versions and downloading installers from various sources. Strategies are divided
19
+ into two types: version-first (can determine version without downloading) and
20
+ file-first (must download to extract version).
21
+
22
+ Strategy Pattern:
23
+ Discovery strategies implement one of two approaches:
24
+
25
+ VERSION-FIRST (api_github, api_json, web_scrape):
26
+ - Implement get_version_info() -> VersionInfo
27
+ - Can determine version and download URL without downloading installer
28
+ - Core orchestration checks version first, then decides whether to download
29
+ - Enables zero-bandwidth update checks when version unchanged
30
+
31
+ FILE-FIRST (url_download):
32
+ - Implement discover_version() -> tuple[DiscoveredVersion, Path, str, dict]
33
+ - Must download installer to extract version from file metadata
34
+ - Uses HTTP ETag conditional requests for efficiency
35
+
36
+ The strategy registry allows dynamic lookup based on the strategy name
37
+ in the recipe configuration.
38
+
39
+ Available Strategies:
40
+ url_download : UrlDownloadStrategy (FILE-FIRST)
41
+ Download from a fixed URL and extract version from the file itself.
42
+ Supports MSI ProductVersion extraction. Uses ETag caching.
43
+ api_github : ApiGithubStrategy (VERSION-FIRST)
44
+ Fetch from GitHub releases API and extract version from tags.
45
+ Fast API-based version checks (~100ms).
46
+ api_json : ApiJsonStrategy (VERSION-FIRST)
47
+ Query JSON API endpoints for version and download URL.
48
+ Fast API-based version checks (~100ms).
49
+ web_scrape : WebScrapeStrategy (VERSION-FIRST)
50
+ Scrape vendor download pages to find links and extract versions.
51
+ Works for vendors without APIs or static URLs.
52
+
53
+ Example:
54
+ Register and use a custom strategy:
55
+
56
+ from napt.discovery import get_strategy
57
+ from pathlib import Path
58
+
59
+ # Get a strategy by name (auto-registered on import)
60
+ strategy = get_strategy("url_download")
61
+
62
+ # Use it to discover a version
63
+ app_config = {
64
+ "source": {
65
+ "strategy": "url_download",
66
+ "url": "https://example.com/app.msi",
67
+ }
68
+ }
69
+
70
+ discovered, file_path, sha256 = strategy.discover_version(
71
+ app_config, Path("./downloads")
72
+ )
73
+ print(f"Version: {discovered.version}")
74
+
75
+ """
76
+
77
+ # Import strategy modules to trigger self-registration
78
+ from . import (
79
+ api_github, # noqa: F401
80
+ api_json, # noqa: F401
81
+ url_download, # noqa: F401
82
+ web_scrape, # noqa: F401
83
+ )
84
+ from .base import DiscoveryStrategy, get_strategy
85
+
86
+ __all__ = ["DiscoveryStrategy", "get_strategy"]
@@ -0,0 +1,445 @@
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
+ """GitHub API discovery strategy for NAPT.
16
+
17
+ This is a VERSION-FIRST strategy that queries the GitHub API 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 (GitHub API call ~100ms)
24
+ - Can skip downloads entirely when version unchanged
25
+ - Direct access to latest releases via stable GitHub API
26
+ - Version extraction from Git tags (semantic versioning friendly)
27
+ - Asset pattern matching for multi-platform releases
28
+ - Optional authentication for higher rate limits
29
+ - No web scraping required
30
+ - Ideal for CI/CD with scheduled checks
31
+
32
+ Supported Version Extraction:
33
+
34
+ - Tag-based: Extract version from release tag names
35
+ - Supports named capture groups: (?P<version>...)
36
+ - Default pattern strips "v" prefix: v1.2.3 -> 1.2.3
37
+ - Falls back to full tag if no pattern match
38
+
39
+ Use Cases:
40
+
41
+ - Open-source projects (Git, VS Code, Node.js, etc.)
42
+ - Projects with GitHub releases (Firefox, Chrome alternatives)
43
+ - Vendors who publish installers as release assets
44
+ - Projects with semantic versioned tags
45
+ - CI/CD pipelines with frequent version checks
46
+
47
+ Recipe Configuration:
48
+ ```yaml
49
+ source:
50
+ strategy: api_github
51
+ repo: "git-for-windows/git" # Required: owner/repo
52
+ asset_pattern: "Git-.*-64-bit\\.exe$" # Required: regex for asset
53
+ version_pattern: "v?([0-9.]+)" # Optional: version extraction
54
+ prerelease: false # Optional: include prereleases
55
+ token: "${GITHUB_TOKEN}" # Optional: auth token
56
+ ```
57
+
58
+ Configuration Fields:
59
+
60
+ - **repo** (str, required): GitHub repository in "owner/name" format
61
+ (e.g., "git-for-windows/git")
62
+ - **asset_pattern** (str, required): Regular expression to match asset
63
+ filename. If multiple assets match, the first match is used. Example:
64
+ ".*-x64\\.msi$" matches assets ending with "-x64.msi"
65
+ - **version_pattern** (str, optional): Regular expression to extract version
66
+ from the release tag name. Use a named capture group (?P<version>...) or
67
+ the entire match. Default: "v?([0-9.]+)" strips optional "v" prefix.
68
+ Example: "release-([0-9.]+)" for tags like "release-1.2.3".
69
+ - **prerelease** (bool, optional): If True, include pre-release versions. If False
70
+ (default), only stable releases are considered. Uses GitHub's prerelease flag.
71
+ - **token** (str, optional): GitHub personal access token for authentication.
72
+ Increases rate limit from 60 to 5000 requests per hour. Can use environment
73
+ variable substitution: "${GITHUB_TOKEN}". No special permissions needed for
74
+ public repositories.
75
+
76
+ Error Handling:
77
+
78
+ - ValueError: Missing or invalid configuration fields
79
+ - RuntimeError: API failures, no releases, no matching assets
80
+ - Errors are chained with 'from err' for better debugging
81
+
82
+ Rate Limits:
83
+
84
+ - Unauthenticated: 60 requests/hour per IP
85
+ - Authenticated: 5000 requests/hour per token
86
+ - Tip: Use a token for production use or frequent checks
87
+
88
+ Example:
89
+ In a recipe YAML:
90
+ ```yaml
91
+ apps:
92
+ - name: "Git for Windows"
93
+ id: "git"
94
+ source:
95
+ strategy: api_github
96
+ repo: "git-for-windows/git"
97
+ asset_pattern: "Git-.*-64-bit\\.exe$"
98
+ ```
99
+
100
+ From Python (version-first approach):
101
+ ```python
102
+ from napt.discovery.api_github import ApiGithubStrategy
103
+ from napt.io import download_file
104
+
105
+ strategy = ApiGithubStrategy()
106
+ app_config = {
107
+ "source": {
108
+ "repo": "git-for-windows/git",
109
+ "asset_pattern": ".*-64-bit\\.exe$",
110
+ }
111
+ }
112
+
113
+ # Get version WITHOUT downloading
114
+ version_info = strategy.get_version_info(app_config)
115
+ print(f"Latest version: {version_info.version}")
116
+
117
+ # Download only if needed
118
+ if need_to_download:
119
+ file_path, sha256, headers = download_file(
120
+ version_info.download_url, Path("./downloads")
121
+ )
122
+ print(f"Downloaded to {file_path}")
123
+ ```
124
+
125
+ From Python (using core orchestration):
126
+ ```python
127
+ from pathlib import Path
128
+ from napt.core import discover_recipe
129
+
130
+ # Automatically uses version-first optimization
131
+ result = discover_recipe(Path("recipe.yaml"), Path("./downloads"))
132
+ print(f"Version {result.version} at {result.file_path}")
133
+ ```
134
+
135
+ Note:
136
+ Version discovery via API only (no download required).
137
+ Core orchestration automatically skips download if version unchanged.
138
+ The GitHub API is stable and well-documented. Releases are fetched in order
139
+ (latest first). Asset matching is case-sensitive by default (use (?i) for
140
+ case-insensitive). Consider url_download if you need a direct download URL instead.
141
+
142
+ """
143
+
144
+ from __future__ import annotations
145
+
146
+ import os
147
+ import re
148
+ from typing import Any
149
+
150
+ import requests
151
+
152
+ from napt.exceptions import ConfigError, NetworkError
153
+ from napt.versioning.keys import VersionInfo
154
+
155
+ from .base import register_strategy
156
+
157
+
158
+ class ApiGithubStrategy:
159
+ """Discovery strategy for GitHub releases.
160
+
161
+ Configuration example:
162
+ source:
163
+ strategy: api_github
164
+ repo: "owner/repository"
165
+ asset_pattern: ".*\\.msi$"
166
+ version_pattern: "v?([0-9.]+)"
167
+ prerelease: false
168
+ token: "${GITHUB_TOKEN}"
169
+ """
170
+
171
+ def get_version_info(
172
+ self,
173
+ app_config: dict[str, Any],
174
+ ) -> VersionInfo:
175
+ """Fetch latest release from GitHub API without downloading
176
+ (version-first path).
177
+
178
+ This method queries the GitHub API for the latest release and extracts
179
+ the version from the tag name and the download URL from matching assets.
180
+ If the version matches cached state, the download can be skipped entirely.
181
+
182
+ Args:
183
+ app_config: App configuration containing source.repo and
184
+ optional fields.
185
+
186
+ Returns:
187
+ Version info with version string, download URL, and
188
+ source name.
189
+
190
+ Raises:
191
+ ValueError: If required config fields are missing, invalid, or if
192
+ no matching assets are found.
193
+ RuntimeError: If API call fails or release has no assets.
194
+
195
+ Example:
196
+ Get version from GitHub releases:
197
+ ```python
198
+ strategy = ApiGithubStrategy()
199
+ config = {
200
+ "source": {
201
+ "repo": "owner/repo",
202
+ "asset_pattern": ".*\\.msi$"
203
+ }
204
+ }
205
+ version_info = strategy.get_version_info(config)
206
+ # version_info.version returns: '1.0.0'
207
+ ```
208
+
209
+ """
210
+ from napt.logging import get_global_logger
211
+
212
+ logger = get_global_logger()
213
+ # Validate configuration
214
+ source = app_config.get("source", {})
215
+ repo = source.get("repo")
216
+ if not repo:
217
+ raise ConfigError("api_github strategy requires 'source.repo' in config")
218
+
219
+ # Validate repo format
220
+ if "/" not in repo or repo.count("/") != 1:
221
+ raise ConfigError(
222
+ f"Invalid repo format: {repo!r}. Expected 'owner/repository'"
223
+ )
224
+
225
+ # Optional configuration
226
+ asset_pattern = source.get("asset_pattern")
227
+ if not asset_pattern:
228
+ raise ConfigError(
229
+ "api_github strategy requires 'source.asset_pattern' in config"
230
+ )
231
+
232
+ version_pattern = source.get("version_pattern", r"v?([0-9.]+)")
233
+ prerelease = source.get("prerelease", False)
234
+ token = source.get("token")
235
+
236
+ # Expand environment variables in token (e.g., ${GITHUB_TOKEN})
237
+ if token:
238
+ if token.startswith("${") and token.endswith("}"):
239
+ env_var = token[2:-1]
240
+ token = os.environ.get(env_var)
241
+ if not token:
242
+ logger.verbose(
243
+ "DISCOVERY",
244
+ f"Warning: Environment variable {env_var} not set",
245
+ )
246
+
247
+ logger.verbose("DISCOVERY", "Strategy: api_github (version-first)")
248
+ logger.verbose("DISCOVERY", f"Repository: {repo}")
249
+ logger.verbose("DISCOVERY", f"Version pattern: {version_pattern}")
250
+ if asset_pattern:
251
+ logger.verbose("DISCOVERY", f"Asset pattern: {asset_pattern}")
252
+ if prerelease:
253
+ logger.verbose("DISCOVERY", "Including pre-releases")
254
+
255
+ # Fetch latest release from GitHub API
256
+ api_url = f"https://api.github.com/repos/{repo}/releases/latest"
257
+ headers = {
258
+ "Accept": "application/vnd.github+json",
259
+ "X-GitHub-Api-Version": "2022-11-28",
260
+ }
261
+
262
+ # Add authentication if token provided
263
+ if token:
264
+ headers["Authorization"] = f"token {token}"
265
+ logger.verbose("DISCOVERY", "Using authenticated API request")
266
+
267
+ logger.verbose("DISCOVERY", f"Fetching release from: {api_url}")
268
+
269
+ try:
270
+ response = requests.get(api_url, headers=headers, timeout=30)
271
+ response.raise_for_status()
272
+ except requests.exceptions.HTTPError as err:
273
+ if response.status_code == 404:
274
+ raise NetworkError(
275
+ f"Repository {repo!r} not found or has no releases"
276
+ ) from err
277
+ elif response.status_code == 403:
278
+ raise NetworkError(
279
+ f"GitHub API rate limit exceeded. Consider using a token. "
280
+ f"Status: {response.status_code}"
281
+ ) from err
282
+ else:
283
+ raise NetworkError(
284
+ f"GitHub API request failed: {response.status_code} "
285
+ f"{response.reason}"
286
+ ) from err
287
+ except requests.exceptions.RequestException as err:
288
+ raise NetworkError(f"Failed to fetch GitHub release: {err}") from err
289
+
290
+ release_data = response.json()
291
+
292
+ # Check if this is a prerelease and we don't want those
293
+ if release_data.get("prerelease", False) and not prerelease:
294
+ raise NetworkError(
295
+ f"Latest release is a pre-release and prerelease=false. "
296
+ f"Tag: {release_data.get('tag_name')}"
297
+ )
298
+
299
+ # Extract version from tag name
300
+ tag_name = release_data.get("tag_name", "")
301
+ if not tag_name:
302
+ raise NetworkError("Release has no tag_name field")
303
+
304
+ logger.verbose("DISCOVERY", f"Release tag: {tag_name}")
305
+
306
+ try:
307
+ pattern = re.compile(version_pattern)
308
+ match = pattern.search(tag_name)
309
+ if not match:
310
+ raise ConfigError(
311
+ f"Version pattern {version_pattern!r} did not match "
312
+ f"tag {tag_name!r}"
313
+ )
314
+
315
+ # Try to get named capture group 'version' first, else use group 1,
316
+ # else full match
317
+ if "version" in pattern.groupindex:
318
+ version_str = match.group("version")
319
+ elif pattern.groups > 0:
320
+ version_str = match.group(1)
321
+ else:
322
+ version_str = match.group(0)
323
+
324
+ except re.error as err:
325
+ raise ConfigError(
326
+ f"Invalid version_pattern regex: {version_pattern!r}"
327
+ ) from err
328
+ except (ValueError, IndexError) as err:
329
+ raise ConfigError(
330
+ f"Failed to extract version from tag {tag_name!r} "
331
+ f"using pattern {version_pattern!r}: {err}"
332
+ ) from err
333
+
334
+ logger.verbose("DISCOVERY", f"Extracted version: {version_str}")
335
+
336
+ # Find matching asset
337
+ assets = release_data.get("assets", [])
338
+ if not assets:
339
+ raise NetworkError(
340
+ f"Release {tag_name} has no assets. "
341
+ f"Check if assets were uploaded to the release."
342
+ )
343
+
344
+ logger.verbose("DISCOVERY", f"Release has {len(assets)} asset(s)")
345
+
346
+ # Match asset by pattern
347
+ matched_asset = None
348
+ try:
349
+ pattern = re.compile(asset_pattern)
350
+ except re.error as err:
351
+ raise ConfigError(
352
+ f"Invalid asset_pattern regex: {asset_pattern!r}"
353
+ ) from err
354
+
355
+ for asset in assets:
356
+ asset_name = asset.get("name", "")
357
+ if pattern.search(asset_name):
358
+ matched_asset = asset
359
+ logger.verbose("DISCOVERY", f"Matched asset: {asset_name}")
360
+ break
361
+
362
+ if not matched_asset:
363
+ available = [a.get("name", "(unnamed)") for a in assets]
364
+ raise ConfigError(
365
+ f"No assets matched pattern {asset_pattern!r}. "
366
+ f"Available assets: {', '.join(available)}"
367
+ )
368
+
369
+ # Get download URL
370
+ download_url = matched_asset.get("browser_download_url")
371
+ if not download_url:
372
+ raise NetworkError(f"Asset {matched_asset.get('name')} has no download URL")
373
+
374
+ logger.verbose("DISCOVERY", f"Download URL: {download_url}")
375
+
376
+ return VersionInfo(
377
+ version=version_str,
378
+ download_url=download_url,
379
+ source="api_github",
380
+ )
381
+
382
+ def validate_config(self, app_config: dict[str, Any]) -> list[str]:
383
+ """Validate api_github strategy configuration.
384
+
385
+ Checks for required fields and correct types without making network calls.
386
+
387
+ Args:
388
+ app_config: The app configuration from the recipe.
389
+
390
+ Returns:
391
+ List of error messages (empty if valid).
392
+
393
+ """
394
+ errors = []
395
+ source = app_config.get("source", {})
396
+
397
+ # Check required fields
398
+ if "repo" not in source:
399
+ errors.append("Missing required field: source.repo")
400
+ elif not isinstance(source["repo"], str):
401
+ errors.append("source.repo must be a string")
402
+ elif not source["repo"].strip():
403
+ errors.append("source.repo cannot be empty")
404
+ else:
405
+ # Validate repo format
406
+ repo = source["repo"]
407
+ if repo.count("/") != 1:
408
+ errors.append(
409
+ "source.repo must be in format 'owner/repo' (e.g., 'git/git')"
410
+ )
411
+
412
+ if "asset_pattern" not in source:
413
+ errors.append("Missing required field: source.asset_pattern")
414
+ elif not isinstance(source["asset_pattern"], str):
415
+ errors.append("source.asset_pattern must be a string")
416
+ elif not source["asset_pattern"].strip():
417
+ errors.append("source.asset_pattern cannot be empty")
418
+ else:
419
+ # Validate regex pattern syntax
420
+ pattern = source["asset_pattern"]
421
+ import re
422
+
423
+ try:
424
+ re.compile(pattern)
425
+ except re.error as err:
426
+ errors.append(f"Invalid asset_pattern regex: {err}")
427
+
428
+ # Optional fields validation
429
+ if "version_pattern" in source:
430
+ if not isinstance(source["version_pattern"], str):
431
+ errors.append("source.version_pattern must be a string")
432
+ else:
433
+ pattern = source["version_pattern"]
434
+ import re
435
+
436
+ try:
437
+ re.compile(pattern)
438
+ except re.error as err:
439
+ errors.append(f"Invalid version_pattern regex: {err}")
440
+
441
+ return errors
442
+
443
+
444
+ # Register this strategy when the module is imported
445
+ register_strategy("api_github", ApiGithubStrategy)