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/core.py ADDED
@@ -0,0 +1,385 @@
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
+ """Core orchestration for NAPT.
16
+
17
+ This module provides high-level orchestration functions that coordinate the
18
+ complete workflow for recipe validation, package building, and deployment.
19
+
20
+ Two-Path Architecture:
21
+
22
+ The orchestration automatically selects the optimal path based on what each
23
+ discovery strategy can do:
24
+
25
+ - **Version-First Path** (web_scrape, api_github, api_json): These strategies
26
+ can check the version without downloading the file. NAPT compares the
27
+ discovered version to the cached version. If they match and the file
28
+ already exists, the download is skipped entirely. This makes update checks
29
+ very fast (~100-300ms) since no large installer files are downloaded.
30
+
31
+ - **File-First Path** (url_download): This strategy requires downloading the
32
+ file to extract the version. NAPT uses HTTP ETag headers to check if the
33
+ file has changed. If the server responds with HTTP 304 (Not Modified),
34
+ the existing cached file is reused, avoiding unnecessary re-downloads.
35
+
36
+ Design Principles:
37
+
38
+ - Each function has a single, clear responsibility
39
+ - Functions return structured data (dataclasses) for easy testing and extension
40
+ - Error handling uses exceptions; CLI layer formats for user display
41
+ - Discovery strategies are dynamically loaded via registry pattern
42
+ - Configuration is immutable once loaded
43
+
44
+ Example:
45
+ Programmatic usage:
46
+ ```python
47
+ from pathlib import Path
48
+ from napt.core import discover_recipe
49
+
50
+ result = discover_recipe(
51
+ recipe_path=Path("recipes/Google/chrome.yaml"),
52
+ output_dir=Path("./downloads"),
53
+ )
54
+
55
+ print(f"App: {result.app_name}")
56
+ print(f"Version: {result.version}")
57
+ print(f"SHA-256: {result.sha256}")
58
+
59
+ # Version-first strategies: may have skipped download if unchanged!
60
+ ```
61
+
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ from pathlib import Path
67
+
68
+ from napt import __version__
69
+ from napt.config.loader import load_effective_config
70
+ from napt.discovery import get_strategy
71
+ from napt.exceptions import ConfigError
72
+ from napt.io import download_file
73
+ from napt.logging import get_global_logger
74
+ from napt.results import DiscoverResult
75
+ from napt.state import load_state, save_state
76
+ from napt.versioning.keys import DiscoveredVersion
77
+
78
+
79
+ def derive_file_path_from_url(url: str, output_dir: Path) -> Path:
80
+ """Derive file path from URL using same logic as download_file.
81
+
82
+ This function ensures version-first strategies can locate cached files
83
+ without downloading by following the same naming convention as the
84
+ download module.
85
+
86
+ Args:
87
+ url: Download URL.
88
+ output_dir: Directory where file would be downloaded.
89
+
90
+ Returns:
91
+ Expected path to the file.
92
+
93
+ Example:
94
+ Get expected file path for a download URL:
95
+ ```python
96
+ from pathlib import Path
97
+
98
+ path = derive_file_path_from_url(
99
+ "https://example.com/app.msi",
100
+ Path("./downloads")
101
+ )
102
+ # Returns: Path('./downloads/app.msi')
103
+ ```
104
+
105
+ """
106
+ from urllib.parse import urlparse
107
+
108
+ filename = Path(urlparse(url).path).name
109
+ return output_dir / filename
110
+
111
+
112
+ def discover_recipe(
113
+ recipe_path: Path,
114
+ output_dir: Path,
115
+ state_file: Path | None = Path("state/versions.json"),
116
+ stateless: bool = False,
117
+ ) -> DiscoverResult:
118
+ """Discover the latest version by loading config and downloading installer.
119
+
120
+ This is the main entry point for the 'napt discover' command. It orchestrates
121
+ the entire discovery workflow using a two-path architecture optimized for
122
+ version-first strategies.
123
+
124
+ The function uses duck typing to detect strategy capabilities:
125
+
126
+ VERSION-FIRST PATH (if strategy has get_version_info method):
127
+
128
+ 1. Load effective configuration (org + vendor + recipe merged)
129
+ 2. Call strategy.get_version_info() to discover version (no download)
130
+ 3. Compare discovered version to cached known_version
131
+ 4. If match and file exists -> skip download entirely (fast path!)
132
+ 5. If changed or missing -> download installer via download_file()
133
+ 6. Update state and return results
134
+
135
+ FILE-FIRST PATH (if strategy has only discover_version method):
136
+
137
+ 1. Load effective configuration (org + vendor + recipe merged)
138
+ 2. Call strategy.discover_version() with cached ETag
139
+ 3. Strategy handles conditional request (HTTP 304 vs 200)
140
+ 4. Extract version from downloaded file
141
+ 5. Update state and return results
142
+
143
+ Args:
144
+ recipe_path: Path to the recipe YAML file. Must exist and be
145
+ readable. The path is resolved to absolute form.
146
+ output_dir: Directory to download the installer to. Created if
147
+ it doesn't exist. The downloaded file will be named based on
148
+ Content-Disposition header or URL path.
149
+ state_file: Path to state file for version tracking
150
+ and ETag caching. Default is "state/versions.json". Set to None
151
+ to disable.
152
+ stateless: If True, disable state tracking (no caching,
153
+ always download). Default is False.
154
+
155
+ Returns:
156
+ Discovery results and metadata including version, file path, and SHA-256 hash.
157
+
158
+ Raises:
159
+ ConfigError: On missing or invalid configuration fields (no app defined,
160
+ missing 'source.strategy' field, unknown discovery strategy name),
161
+ YAML parse errors (from config loader), or if recipe file doesn't exist.
162
+ NetworkError: On download failures or version extraction errors.
163
+
164
+ Example:
165
+ Basic version discovery:
166
+ ```python
167
+ from pathlib import Path
168
+ result = discover_recipe(
169
+ Path("recipes/Google/chrome.yaml"),
170
+ Path("./downloads")
171
+ )
172
+ print(result.version) # 141.0.7390.123
173
+ ```
174
+
175
+ Handling errors:
176
+ ```python
177
+ try:
178
+ result = discover_recipe(Path("invalid.yaml"), Path("."))
179
+ except ConfigError as e:
180
+ print(f"Config error: {e}")
181
+ except NetworkError as e:
182
+ print(f"Network error: {e}")
183
+ ```
184
+
185
+ Note:
186
+ The discovery strategy must be registered before calling this function.
187
+ Version-first strategies (web_scrape, api_github, api_json) can skip
188
+ downloads entirely when version unchanged (fast path optimization).
189
+ File-first strategy (url_download) uses ETag conditional requests.
190
+ Downloaded files are written atomically (.part then renamed). Progress
191
+ output goes to stdout via the download module. Strategy type detected
192
+ via duck typing (hasattr for get_version_info).
193
+
194
+ """
195
+ logger = get_global_logger()
196
+
197
+ # Load state file unless running in stateless mode
198
+ state = None
199
+ if not stateless and state_file:
200
+ try:
201
+ state = load_state(state_file)
202
+ logger.verbose("STATE", f"Loaded state from {state_file}")
203
+ except FileNotFoundError:
204
+ logger.verbose("STATE", f"State file not found, will create: {state_file}")
205
+ state = {
206
+ "metadata": {"napt_version": __version__, "schema_version": "2"},
207
+ "apps": {},
208
+ }
209
+ except Exception as err:
210
+ logger.warning("STATE", f"Failed to load state: {err}")
211
+ logger.verbose("STATE", "Continuing without state tracking")
212
+ state = None
213
+
214
+ # 1. Load and merge configuration
215
+ logger.step(1, 4, "Loading configuration...")
216
+ config = load_effective_config(recipe_path)
217
+
218
+ # 2. Extract the app configuration
219
+ logger.step(2, 4, "Discovering version...")
220
+ app = config.get("app")
221
+ if not app:
222
+ raise ConfigError(f"No app defined in recipe: {recipe_path}")
223
+
224
+ app_name = app.get("name", "Unknown")
225
+ app_id = app.get("id", "unknown-id")
226
+
227
+ # 3. Get the discovery strategy name
228
+ source = app.get("source", {})
229
+ strategy_name = source.get("strategy")
230
+ if not strategy_name:
231
+ raise ConfigError(f"No 'source.strategy' defined for app: {app_name}")
232
+
233
+ # 4. Get the strategy implementation
234
+ # Import strategies to ensure they're registered
235
+ import napt.discovery.api_github # noqa: F401
236
+ import napt.discovery.api_json # noqa: F401
237
+ import napt.discovery.url_download # noqa: F401
238
+ import napt.discovery.web_scrape # noqa: F401
239
+
240
+ strategy = get_strategy(strategy_name)
241
+
242
+ # Get cache for this recipe from state
243
+ cache = None
244
+ if state and app_id:
245
+ cache = state.get("apps", {}).get(app_id)
246
+ if cache:
247
+ logger.verbose("STATE", f"Using cache for {app_id}")
248
+ if cache.get("known_version"):
249
+ logger.verbose(
250
+ "STATE", f" Cached version: {cache.get('known_version')}"
251
+ )
252
+ if cache.get("etag"):
253
+ logger.verbose("STATE", f" Cached ETag: {cache.get('etag')}")
254
+
255
+ # 5. Run discovery: version-first or file-first path
256
+ logger.step(3, 4, "Discovering version...")
257
+
258
+ # Check if strategy supports version-first (has get_version_info method)
259
+ download_url = None # Track actual download URL for state file
260
+ if hasattr(strategy, "get_version_info"):
261
+ # VERSION-FIRST PATH (web_scrape, api_github, api_json)
262
+ # Get version without downloading
263
+ version_info = strategy.get_version_info(app)
264
+ download_url = version_info.download_url # Save for state file
265
+
266
+ logger.verbose("DISCOVERY", f"Version discovered: {version_info.version}")
267
+
268
+ # Check if we can use cached file (version match + file exists)
269
+ if cache and cache.get("known_version") == version_info.version:
270
+ # Derive file path from URL using same logic as download_file
271
+ file_path = derive_file_path_from_url(version_info.download_url, output_dir)
272
+
273
+ if file_path.exists():
274
+ # Fast path: version unchanged, file exists, skip download!
275
+ logger.verbose(
276
+ "CACHE",
277
+ f"Version {version_info.version} unchanged, using cached file",
278
+ )
279
+ logger.step(4, 4, "Using cached file...")
280
+ sha256 = cache.get("sha256")
281
+ discovered_version = DiscoveredVersion(
282
+ version_info.version, version_info.source
283
+ )
284
+ headers = {} # No download occurred, no headers
285
+ else:
286
+ # File was deleted, re-download
287
+ logger.verbose(
288
+ "WARNING",
289
+ f"Cached file {file_path} not found, re-downloading",
290
+ )
291
+ logger.step(4, 4, "Downloading installer...")
292
+ file_path, sha256, headers = download_file(
293
+ version_info.download_url,
294
+ output_dir,
295
+ )
296
+ discovered_version = DiscoveredVersion(
297
+ version_info.version, version_info.source
298
+ )
299
+ else:
300
+ # Version changed or no cache, download new version
301
+ if cache:
302
+ logger.verbose(
303
+ "DISCOVERY",
304
+ (
305
+ f"Version changed: {cache.get('known_version')} -> "
306
+ f"{version_info.version}"
307
+ ),
308
+ )
309
+ logger.step(4, 4, "Downloading installer...")
310
+ file_path, sha256, headers = download_file(
311
+ version_info.download_url,
312
+ output_dir,
313
+ )
314
+ discovered_version = DiscoveredVersion(
315
+ version_info.version, version_info.source
316
+ )
317
+ else:
318
+ # FILE-FIRST PATH (url_download only)
319
+ # Must download to extract version
320
+ logger.step(4, 4, "Downloading installer...")
321
+ discovered_version, file_path, sha256, headers = strategy.discover_version(
322
+ app, output_dir, cache=cache
323
+ )
324
+ download_url = str(app.get("source", {}).get("url", "")) # Use source.url
325
+
326
+ # Update state with discovered information
327
+ if state and app_id and state_file:
328
+ from datetime import UTC, datetime
329
+
330
+ if "apps" not in state:
331
+ state["apps"] = {}
332
+
333
+ # Extract ETag and Last-Modified from headers for next run
334
+ etag = headers.get("ETag")
335
+ last_modified = headers.get("Last-Modified")
336
+
337
+ if etag:
338
+ logger.verbose("STATE", f"Saving ETag for next run: {etag}")
339
+ if last_modified:
340
+ logger.verbose(
341
+ "STATE", f"Saving Last-Modified for next run: {last_modified}"
342
+ )
343
+
344
+ # Build cache entry with new schema v2
345
+ cache_entry = {
346
+ "url": download_url
347
+ or "", # Actual download URL (from version_info or source.url)
348
+ "etag": etag if etag else None, # Only useful for url_download
349
+ "last_modified": (
350
+ last_modified if last_modified else None
351
+ ), # Only useful for url_download
352
+ "sha256": sha256,
353
+ }
354
+
355
+ # Optional fields
356
+ if discovered_version.version:
357
+ cache_entry["known_version"] = discovered_version.version
358
+ if strategy_name:
359
+ cache_entry["strategy"] = strategy_name
360
+
361
+ state["apps"][app_id] = cache_entry
362
+
363
+ state["metadata"] = {
364
+ "napt_version": __version__,
365
+ "last_updated": datetime.now(UTC).isoformat(),
366
+ "schema_version": "2",
367
+ }
368
+
369
+ try:
370
+ save_state(state, state_file)
371
+ logger.verbose("STATE", f"Updated state file: {state_file}")
372
+ except Exception as err:
373
+ logger.warning("STATE", f"Failed to save state: {err}")
374
+
375
+ # 6. Return results
376
+ return DiscoverResult(
377
+ app_name=app_name,
378
+ app_id=app_id,
379
+ strategy=strategy_name,
380
+ version=discovered_version.version,
381
+ version_source=discovered_version.source,
382
+ file_path=file_path,
383
+ sha256=sha256,
384
+ status="success",
385
+ )