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