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