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,304 @@
|
|
|
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
|
+
"""URL download discovery strategy for NAPT.
|
|
16
|
+
|
|
17
|
+
This is a FILE-FIRST strategy that downloads an installer from a fixed HTTP(S)
|
|
18
|
+
URL and extracts version information from the downloaded file. Uses HTTP ETag
|
|
19
|
+
conditional requests to avoid re-downloading unchanged files.
|
|
20
|
+
|
|
21
|
+
Key Advantages:
|
|
22
|
+
|
|
23
|
+
- Works with any fixed URL (version not required in URL)
|
|
24
|
+
- Extracts accurate version directly from installer metadata
|
|
25
|
+
- Uses ETag-based conditional requests for efficiency (~500ms vs full download)
|
|
26
|
+
- Simple and reliable for vendors with stable download URLs
|
|
27
|
+
- Fallback strategy when version not available via API/URL pattern
|
|
28
|
+
|
|
29
|
+
Supported Version Extraction:
|
|
30
|
+
|
|
31
|
+
- **MSI files** (`.msi` extension): Automatically detected, extracts
|
|
32
|
+
ProductVersion property from MSI files
|
|
33
|
+
- **Other file types**: Not supported. Use a version-first strategy
|
|
34
|
+
(api_github, api_json, web_scrape) or ensure file is an MSI installer.
|
|
35
|
+
- **(Future)** EXE files: Auto-detect and extract FileVersion from PE headers
|
|
36
|
+
|
|
37
|
+
Use Cases:
|
|
38
|
+
|
|
39
|
+
- Google Chrome: Fixed enterprise MSI URL, version embedded in MSI
|
|
40
|
+
- Mozilla Firefox: Fixed enterprise MSI URL, version embedded in MSI
|
|
41
|
+
- Vendors with stable download URLs and embedded version metadata
|
|
42
|
+
- When version not available via API, URL pattern, or GitHub tags
|
|
43
|
+
|
|
44
|
+
Recipe Configuration:
|
|
45
|
+
|
|
46
|
+
source:
|
|
47
|
+
strategy: url_download
|
|
48
|
+
url: "https://vendor.com/installer.msi" # Required: download URL
|
|
49
|
+
|
|
50
|
+
Configuration Fields:
|
|
51
|
+
|
|
52
|
+
- **url** (str, required): HTTP(S) URL to download the installer from. The URL
|
|
53
|
+
should be stable and point to the latest version.
|
|
54
|
+
|
|
55
|
+
**Version Extraction:** Automatically detected by file extension. MSI files
|
|
56
|
+
(`.msi` extension) have versions extracted from ProductVersion property.
|
|
57
|
+
Other file types are not supported for version extraction - use a
|
|
58
|
+
version-first strategy (api_github, api_json, web_scrape) instead.
|
|
59
|
+
|
|
60
|
+
Error Handling:
|
|
61
|
+
|
|
62
|
+
- ConfigError: Missing or invalid configuration fields
|
|
63
|
+
- NetworkError: Download failures, version extraction errors
|
|
64
|
+
- Errors are chained with 'from err' for better debugging
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
In a recipe YAML:
|
|
68
|
+
```yaml
|
|
69
|
+
apps:
|
|
70
|
+
- name: "My App"
|
|
71
|
+
id: "my-app"
|
|
72
|
+
source:
|
|
73
|
+
strategy: url_download
|
|
74
|
+
url: "https://example.com/myapp-setup.msi"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
From Python:
|
|
78
|
+
```python
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
from napt.discovery.url_download import UrlDownloadStrategy
|
|
81
|
+
|
|
82
|
+
strategy = UrlDownloadStrategy()
|
|
83
|
+
app_config = {
|
|
84
|
+
"source": {
|
|
85
|
+
"url": "https://example.com/app.msi",
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# With cache for ETag optimization
|
|
90
|
+
cache = {"etag": 'W/"abc123"', "sha256": "..."}
|
|
91
|
+
discovered, file_path, sha256, headers = strategy.discover_version(
|
|
92
|
+
app_config, Path("./downloads"), cache=cache
|
|
93
|
+
)
|
|
94
|
+
print(f"Version {discovered.version} at {file_path}")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
From Python (using core orchestration):
|
|
98
|
+
```python
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
from napt.core import discover_recipe
|
|
101
|
+
|
|
102
|
+
# Automatically uses ETag optimization
|
|
103
|
+
result = discover_recipe(Path("recipe.yaml"), Path("./downloads"))
|
|
104
|
+
print(f"Version {result.version} at {result.file_path}")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Note:
|
|
108
|
+
- Must download file to extract version (architectural constraint)
|
|
109
|
+
- ETag optimization reduces bandwidth but still requires network round-trip
|
|
110
|
+
- Core orchestration automatically provides cached ETag if available
|
|
111
|
+
- Server must support ETag or Last-Modified headers for optimization
|
|
112
|
+
- If server doesn't support conditional requests, full download occurs every time
|
|
113
|
+
- Consider version-first strategies (web_scrape, api_github, api_json) for
|
|
114
|
+
better performance when version available via web scraping or API
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
from __future__ import annotations
|
|
119
|
+
|
|
120
|
+
from pathlib import Path
|
|
121
|
+
from typing import Any
|
|
122
|
+
|
|
123
|
+
from napt.exceptions import ConfigError, NetworkError
|
|
124
|
+
from napt.io import NotModifiedError, download_file
|
|
125
|
+
from napt.versioning.keys import DiscoveredVersion
|
|
126
|
+
from napt.versioning.msi import version_from_msi_product_version
|
|
127
|
+
|
|
128
|
+
from .base import register_strategy
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class UrlDownloadStrategy:
|
|
132
|
+
"""Discovery strategy for static HTTP(S) URLs.
|
|
133
|
+
|
|
134
|
+
Configuration example:
|
|
135
|
+
source:
|
|
136
|
+
strategy: url_download
|
|
137
|
+
url: "https://example.com/installer.msi"
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def discover_version(
|
|
141
|
+
self,
|
|
142
|
+
app_config: dict[str, Any],
|
|
143
|
+
output_dir: Path,
|
|
144
|
+
cache: dict[str, Any] | None = None,
|
|
145
|
+
) -> tuple[DiscoveredVersion, Path, str, dict]:
|
|
146
|
+
"""Download from static URL and extract version from the file.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
app_config: App configuration containing source.url and
|
|
150
|
+
source.version.
|
|
151
|
+
output_dir: Directory to save the downloaded file.
|
|
152
|
+
cache: Cached state with etag, last_modified,
|
|
153
|
+
file_path, and sha256 for conditional requests. If provided
|
|
154
|
+
and file is unchanged (HTTP 304), the cached file is returned.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A tuple (version_info, file_path, sha256, headers), where
|
|
158
|
+
version_info contains the discovered version information,
|
|
159
|
+
file_path is the Path to the downloaded file, sha256 is the
|
|
160
|
+
SHA-256 hash, and headers contains HTTP response headers.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ConfigError: If required config fields are missing or invalid.
|
|
164
|
+
NetworkError: If download or version extraction fails.
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
from napt.logging import get_global_logger
|
|
168
|
+
|
|
169
|
+
logger = get_global_logger()
|
|
170
|
+
source = app_config.get("source", {})
|
|
171
|
+
url = source.get("url")
|
|
172
|
+
if not url:
|
|
173
|
+
raise ConfigError("url_download strategy requires 'source.url' in config")
|
|
174
|
+
|
|
175
|
+
logger.verbose("DISCOVERY", "Strategy: url_download (file-first)")
|
|
176
|
+
logger.verbose("DISCOVERY", f"Source URL: {url}")
|
|
177
|
+
|
|
178
|
+
# Extract ETag/Last-Modified from cache if available
|
|
179
|
+
etag = cache.get("etag") if cache else None
|
|
180
|
+
last_modified = cache.get("last_modified") if cache else None
|
|
181
|
+
|
|
182
|
+
if etag:
|
|
183
|
+
logger.verbose("DISCOVERY", f"Using cached ETag: {etag}")
|
|
184
|
+
if last_modified:
|
|
185
|
+
logger.verbose("DISCOVERY", f"Using cached Last-Modified: {last_modified}")
|
|
186
|
+
|
|
187
|
+
# Download the file (with conditional request if cache available)
|
|
188
|
+
try:
|
|
189
|
+
file_path, sha256, headers = download_file(
|
|
190
|
+
url,
|
|
191
|
+
output_dir,
|
|
192
|
+
etag=etag,
|
|
193
|
+
last_modified=last_modified,
|
|
194
|
+
)
|
|
195
|
+
except NotModifiedError:
|
|
196
|
+
# File unchanged (HTTP 304), use cached version
|
|
197
|
+
# Use convention-based path: derive filename from URL
|
|
198
|
+
logger.verbose(
|
|
199
|
+
"DISCOVERY", "File not modified (HTTP 304), using cached version"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if not cache or "sha256" not in cache:
|
|
203
|
+
raise NetworkError(
|
|
204
|
+
"Cache indicates file not modified, but missing SHA-256. "
|
|
205
|
+
"Try running with --stateless to force re-download."
|
|
206
|
+
) from None
|
|
207
|
+
|
|
208
|
+
# Derive file path from URL (convention-based, schema v2)
|
|
209
|
+
from urllib.parse import urlparse
|
|
210
|
+
|
|
211
|
+
filename = Path(urlparse(url).path).name
|
|
212
|
+
cached_file = output_dir / filename
|
|
213
|
+
|
|
214
|
+
if not cached_file.exists():
|
|
215
|
+
raise NetworkError(
|
|
216
|
+
f"Cached file {cached_file} not found. "
|
|
217
|
+
f"File may have been deleted. Try running with --stateless."
|
|
218
|
+
) from None
|
|
219
|
+
|
|
220
|
+
# Extract version from cached file (auto-detect by extension)
|
|
221
|
+
if cached_file.suffix.lower() == ".msi":
|
|
222
|
+
logger.verbose(
|
|
223
|
+
"DISCOVERY", "Auto-detected MSI file, extracting version"
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
discovered = version_from_msi_product_version(cached_file)
|
|
227
|
+
except Exception as err:
|
|
228
|
+
raise NetworkError(
|
|
229
|
+
f"Failed to extract MSI ProductVersion from cached "
|
|
230
|
+
f"file {cached_file}: {err}"
|
|
231
|
+
) from err
|
|
232
|
+
else:
|
|
233
|
+
raise ConfigError(
|
|
234
|
+
f"Cannot extract version from file type: {cached_file.suffix!r}. "
|
|
235
|
+
f"url_download strategy currently supports MSI files only. "
|
|
236
|
+
f"For other file types, use a version-first strategy (api_github, "
|
|
237
|
+
f"api_json, web_scrape) or ensure the file is an MSI installer."
|
|
238
|
+
) from None
|
|
239
|
+
|
|
240
|
+
# Return cached info with preserved headers (prevents overwriting ETag)
|
|
241
|
+
# When 304, no new headers received, so return cached values to
|
|
242
|
+
# preserve them
|
|
243
|
+
preserved_headers = {}
|
|
244
|
+
if cache.get("etag"):
|
|
245
|
+
preserved_headers["ETag"] = cache["etag"]
|
|
246
|
+
if cache.get("last_modified"):
|
|
247
|
+
preserved_headers["Last-Modified"] = cache["last_modified"]
|
|
248
|
+
|
|
249
|
+
return discovered, cached_file, cache["sha256"], preserved_headers
|
|
250
|
+
except Exception as err:
|
|
251
|
+
if isinstance(err, (NetworkError, ConfigError)):
|
|
252
|
+
raise
|
|
253
|
+
raise NetworkError(f"Failed to download {url}: {err}") from err
|
|
254
|
+
|
|
255
|
+
# File was downloaded (not cached), extract version from it (auto-detect by extension)
|
|
256
|
+
if file_path.suffix.lower() == ".msi":
|
|
257
|
+
logger.verbose("DISCOVERY", "Auto-detected MSI file, extracting version")
|
|
258
|
+
try:
|
|
259
|
+
discovered = version_from_msi_product_version(file_path)
|
|
260
|
+
except Exception as err:
|
|
261
|
+
raise NetworkError(
|
|
262
|
+
f"Failed to extract MSI ProductVersion from {file_path}: {err}"
|
|
263
|
+
) from err
|
|
264
|
+
else:
|
|
265
|
+
raise ConfigError(
|
|
266
|
+
f"Cannot extract version from file type: {file_path.suffix!r}. "
|
|
267
|
+
f"url_download strategy currently supports MSI files only. "
|
|
268
|
+
f"For other file types, use a version-first strategy (api_github, "
|
|
269
|
+
f"api_json, web_scrape) or ensure the file is an MSI installer."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return discovered, file_path, sha256, headers
|
|
273
|
+
|
|
274
|
+
def validate_config(self, app_config: dict[str, Any]) -> list[str]:
|
|
275
|
+
"""Validate url_download strategy configuration.
|
|
276
|
+
|
|
277
|
+
Checks for required fields and correct types without making network calls.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
app_config: The app configuration from the recipe.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
List of error messages (empty if valid).
|
|
284
|
+
|
|
285
|
+
"""
|
|
286
|
+
errors = []
|
|
287
|
+
source = app_config.get("source", {})
|
|
288
|
+
|
|
289
|
+
# Check required fields
|
|
290
|
+
if "url" not in source:
|
|
291
|
+
errors.append("Missing required field: source.url")
|
|
292
|
+
elif not isinstance(source["url"], str):
|
|
293
|
+
errors.append("source.url must be a string")
|
|
294
|
+
elif not source["url"].strip():
|
|
295
|
+
errors.append("source.url cannot be empty")
|
|
296
|
+
|
|
297
|
+
# Version extraction is now auto-detected by file extension
|
|
298
|
+
# No version configuration validation needed
|
|
299
|
+
|
|
300
|
+
return errors
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# Register this strategy when the module is imported
|
|
304
|
+
register_strategy("url_download", UrlDownloadStrategy)
|