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,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)
|