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/io/download.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
"""Robust HTTP(S) file download for NAPT.
|
|
16
|
+
|
|
17
|
+
This module provides production-grade file downloading with features designed
|
|
18
|
+
for reliability, reproducibility, and efficiency in automated packaging workflows.
|
|
19
|
+
|
|
20
|
+
Key Features:
|
|
21
|
+
|
|
22
|
+
- **Retry Logic with Exponential Backoff** - Automatically retries on
|
|
23
|
+
transient failures (429, 500, 502, 503, 504) with exponential backoff.
|
|
24
|
+
Configurable via urllib3.util.Retry.
|
|
25
|
+
- **Conditional Requests (HTTP 304 Not Modified)** - Supports ETag and
|
|
26
|
+
Last-Modified headers to avoid re-downloading unchanged files.
|
|
27
|
+
- **Atomic Writes** - Downloads to temporary .part files with atomic rename
|
|
28
|
+
on success to prevent partial files.
|
|
29
|
+
- **Integrity Verification** - SHA-256 hashing during download with
|
|
30
|
+
optional checksum validation. Corrupted files are automatically removed.
|
|
31
|
+
- **Smart Filename Detection** - Respects Content-Disposition headers,
|
|
32
|
+
falls back to URL path, handles edge cases.
|
|
33
|
+
- **Stable ETags** - Forces Accept-Encoding: identity to avoid
|
|
34
|
+
representation-specific ETags and prevent false cache misses.
|
|
35
|
+
|
|
36
|
+
NotModifiedError is raised when a conditional request returns HTTP 304
|
|
37
|
+
(not an error condition). The module defines DEFAULT_CHUNK (1 MiB) as the
|
|
38
|
+
stream chunk size, balancing memory usage vs. progress granularity.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
Basic download:
|
|
42
|
+
```python
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from napt.io import download_file
|
|
45
|
+
|
|
46
|
+
path, sha256, headers = download_file(
|
|
47
|
+
url="https://example.com/installer.msi",
|
|
48
|
+
destination_folder=Path("./downloads"),
|
|
49
|
+
)
|
|
50
|
+
print(f"Downloaded to {path}")
|
|
51
|
+
print(f"SHA-256: {sha256}")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Conditional download (avoid re-downloading):
|
|
55
|
+
```python
|
|
56
|
+
from napt.io import NotModifiedError
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
path, sha256, headers = download_file(
|
|
60
|
+
url="https://example.com/installer.msi",
|
|
61
|
+
destination_folder=Path("./downloads"),
|
|
62
|
+
etag=previous_etag,
|
|
63
|
+
)
|
|
64
|
+
except NotModifiedError:
|
|
65
|
+
print("File unchanged, using cached version")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Checksum validation:
|
|
69
|
+
```python
|
|
70
|
+
try:
|
|
71
|
+
path, sha256, headers = download_file(
|
|
72
|
+
url="https://example.com/installer.msi",
|
|
73
|
+
destination_folder=Path("./downloads"),
|
|
74
|
+
expected_sha256="abc123...",
|
|
75
|
+
)
|
|
76
|
+
except NetworkError as e:
|
|
77
|
+
print(f"Checksum mismatch: {e}")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Note:
|
|
81
|
+
Progress output goes to stdout (can be captured/redirected). User-Agent
|
|
82
|
+
identifies NAPT to help with debugging/support. All HTTP errors are
|
|
83
|
+
chained for better debugging. Timeouts are per-request, not total
|
|
84
|
+
download time.
|
|
85
|
+
|
|
86
|
+
Identity encoding stabilizes ETags: CDNs like Cloudflare compute
|
|
87
|
+
representation-specific ETags, so requesting gzip vs identity yields
|
|
88
|
+
different ETags for the same content, causing unnecessary re-downloads.
|
|
89
|
+
|
|
90
|
+
Atomic writes prevent partial files from appearing in the destination,
|
|
91
|
+
which is critical for automation where another process might start using
|
|
92
|
+
a file before download completes. Stream hashing computes SHA-256 while
|
|
93
|
+
streaming to avoid a second file read, improving I/O efficiency for
|
|
94
|
+
large installers.
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
from __future__ import annotations
|
|
99
|
+
|
|
100
|
+
from collections.abc import Iterable
|
|
101
|
+
import hashlib
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
import time
|
|
104
|
+
from urllib.parse import urlparse
|
|
105
|
+
|
|
106
|
+
import requests
|
|
107
|
+
from requests.adapters import HTTPAdapter
|
|
108
|
+
from urllib3.util.retry import Retry
|
|
109
|
+
|
|
110
|
+
from napt.exceptions import ConfigError, NetworkError
|
|
111
|
+
|
|
112
|
+
# Stream size per chunk (1 MiB). Tune up/down if needed.
|
|
113
|
+
DEFAULT_CHUNK = 1024 * 1024
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class NotModifiedError(Exception):
|
|
117
|
+
"""Raised when a conditional request (If-None-Match / If-Modified-Since)
|
|
118
|
+
returns HTTP 304 Not Modified. Caller can treat this as "no work to do".
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _filename_from_cd(content_disposition: str) -> str | None:
|
|
123
|
+
"""Extracts a filename from a Content-Disposition header if present.
|
|
124
|
+
|
|
125
|
+
Parses headers like 'attachment; filename="setup.msi"' and returns the
|
|
126
|
+
filename value.
|
|
127
|
+
"""
|
|
128
|
+
if not content_disposition:
|
|
129
|
+
return None
|
|
130
|
+
parts = [s.strip() for s in content_disposition.split(";")]
|
|
131
|
+
for part in parts:
|
|
132
|
+
if part.lower().startswith("filename="):
|
|
133
|
+
value = part.split("=", 1)[1].strip().strip('"')
|
|
134
|
+
return value or None
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _filename_from_url(url: str) -> str:
|
|
139
|
+
"""Derive a filename from the URL path. Fallback to a generic name if empty."""
|
|
140
|
+
name = Path(urlparse(url).path).name
|
|
141
|
+
return name or "download.bin"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _sha256_iter(chunks: Iterable[bytes]) -> str:
|
|
145
|
+
"""Compute SHA-256 from an iterator of byte chunks (stream-friendly)."""
|
|
146
|
+
h = hashlib.sha256()
|
|
147
|
+
for c in chunks:
|
|
148
|
+
h.update(c)
|
|
149
|
+
return h.hexdigest()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def make_session() -> requests.Session:
|
|
153
|
+
"""Create a requests.Session with sane retry/backoff defaults.
|
|
154
|
+
|
|
155
|
+
Retries on common transient status codes, applies exponential backoff,
|
|
156
|
+
and sets a helpful User-Agent to avoid being blocked.
|
|
157
|
+
|
|
158
|
+
Note:
|
|
159
|
+
Forces Accept-Encoding: identity to request raw (uncompressed) bytes.
|
|
160
|
+
Many CDNs compute representation-specific ETags (e.g., gzip vs identity),
|
|
161
|
+
which can cause conditional requests to miss and trigger unnecessary
|
|
162
|
+
re-downloads. Pinning identity stabilizes ETags for binary installers
|
|
163
|
+
(MSI/EXE/MSIX/ZIP), which are already compressed.
|
|
164
|
+
"""
|
|
165
|
+
s = requests.Session()
|
|
166
|
+
retries = Retry(
|
|
167
|
+
total=5,
|
|
168
|
+
backoff_factor=0.5,
|
|
169
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
170
|
+
allowed_methods=("GET", "HEAD"),
|
|
171
|
+
raise_on_status=False,
|
|
172
|
+
)
|
|
173
|
+
s.headers.update(
|
|
174
|
+
{
|
|
175
|
+
"User-Agent": "napt/0.1 (+https://github.com/RogerCibrian/notapkgtool)",
|
|
176
|
+
# Request the raw, uncompressed representation to keep ETags stable
|
|
177
|
+
# across runs and avoid spurious 200s when a CDN flips to gzip.
|
|
178
|
+
"Accept-Encoding": "identity",
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
s.mount("http://", HTTPAdapter(max_retries=retries))
|
|
182
|
+
s.mount("https://", HTTPAdapter(max_retries=retries))
|
|
183
|
+
return s
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def download_file(
|
|
187
|
+
url: str,
|
|
188
|
+
destination_folder: Path,
|
|
189
|
+
*,
|
|
190
|
+
expected_sha256: str | None = None,
|
|
191
|
+
validate_content_type: bool = False,
|
|
192
|
+
timeout: int = 60,
|
|
193
|
+
etag: str | None = None,
|
|
194
|
+
last_modified: str | None = None,
|
|
195
|
+
) -> tuple[Path, str, dict]:
|
|
196
|
+
"""Download a URL to destination_folder with robustness and reproducibility.
|
|
197
|
+
|
|
198
|
+
Follows redirects and retries transient failures. Writes to <filename>.part
|
|
199
|
+
then renames to <filename> on success (atomic). Sends conditional headers
|
|
200
|
+
if etag/last_modified provided. Validates checksum if expected_sha256 is set.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
url: Source URL.
|
|
204
|
+
destination_folder: Folder to save into (created if missing).
|
|
205
|
+
expected_sha256: Optional known SHA-256 (hex). If set and mismatched,
|
|
206
|
+
raises NetworkError.
|
|
207
|
+
validate_content_type: If True, rejects responses with text/html content-type.
|
|
208
|
+
timeout: Per-request timeout (seconds).
|
|
209
|
+
etag: Previous ETag to use for If-None-Match (conditional GET).
|
|
210
|
+
last_modified: Previous Last-Modified to use for If-Modified-Since
|
|
211
|
+
(conditional GET).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Tuple of file path, SHA-256 hash, and HTTP response headers.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
NotModifiedError: On HTTP 304 (conditional request satisfied).
|
|
218
|
+
NetworkError: For non-2xx responses (after retries) or checksum mismatch.
|
|
219
|
+
ConfigError: For content-type mismatch.
|
|
220
|
+
|
|
221
|
+
"""
|
|
222
|
+
from napt.logging import get_global_logger
|
|
223
|
+
|
|
224
|
+
logger = get_global_logger()
|
|
225
|
+
destination_folder = Path(destination_folder)
|
|
226
|
+
destination_folder.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
|
|
228
|
+
headers: dict[str, str] = {}
|
|
229
|
+
if etag:
|
|
230
|
+
headers["If-None-Match"] = etag
|
|
231
|
+
logger.verbose("HTTP", f"Using conditional request with ETag: {etag}")
|
|
232
|
+
elif last_modified:
|
|
233
|
+
headers["If-Modified-Since"] = last_modified
|
|
234
|
+
logger.verbose(
|
|
235
|
+
"HTTP", f"Using conditional request with Last-Modified: {last_modified}"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
logger.verbose("HTTP", f"GET {url}")
|
|
239
|
+
logger.verbose(
|
|
240
|
+
"HTTP",
|
|
241
|
+
"Request headers: Accept-Encoding: identity, User-Agent: napt/0.1.0",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
with make_session() as session:
|
|
245
|
+
# Stream response so we can hash while writing.
|
|
246
|
+
resp = session.get(
|
|
247
|
+
url, stream=True, allow_redirects=True, timeout=timeout, headers=headers
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Log redirects
|
|
251
|
+
if len(resp.history) > 0:
|
|
252
|
+
for hist in resp.history:
|
|
253
|
+
logger.verbose(
|
|
254
|
+
"HTTP",
|
|
255
|
+
(
|
|
256
|
+
f"Redirect {hist.status_code} -> "
|
|
257
|
+
f"{hist.headers.get('Location', 'unknown')}"
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Conditional request satisfied: nothing changed since last time.
|
|
262
|
+
if resp.status_code == 304:
|
|
263
|
+
logger.verbose("HTTP", "Response: 304 Not Modified")
|
|
264
|
+
resp.close()
|
|
265
|
+
raise NotModifiedError("Remote content not modified (HTTP 304).")
|
|
266
|
+
|
|
267
|
+
# Raise for other HTTP errors after retries.
|
|
268
|
+
try:
|
|
269
|
+
resp.raise_for_status()
|
|
270
|
+
except requests.HTTPError as err:
|
|
271
|
+
# Chain for better context.
|
|
272
|
+
raise NetworkError(f"download failed for {url}: {err}") from err
|
|
273
|
+
|
|
274
|
+
logger.verbose("HTTP", f"Response: {resp.status_code} {resp.reason}")
|
|
275
|
+
|
|
276
|
+
# Content-Disposition beats URL when naming the file.
|
|
277
|
+
cd_name = _filename_from_cd(resp.headers.get("Content-Disposition", ""))
|
|
278
|
+
filename = cd_name or _filename_from_url(resp.url)
|
|
279
|
+
target = destination_folder / filename
|
|
280
|
+
|
|
281
|
+
# Log response details
|
|
282
|
+
content_length = resp.headers.get("Content-Length", "unknown")
|
|
283
|
+
if content_length != "unknown":
|
|
284
|
+
size_mb = int(content_length) / (1024 * 1024)
|
|
285
|
+
logger.verbose(
|
|
286
|
+
"HTTP", f"Content-Length: {content_length} ({size_mb:.1f} MB)"
|
|
287
|
+
)
|
|
288
|
+
etag_value = resp.headers.get("ETag", "not provided")
|
|
289
|
+
logger.verbose("HTTP", f"ETag: {etag_value}")
|
|
290
|
+
cd_header = resp.headers.get("Content-Disposition", "not provided")
|
|
291
|
+
logger.verbose("HTTP", f"Content-Disposition: {cd_header}")
|
|
292
|
+
|
|
293
|
+
# Optional content-type sanity check.
|
|
294
|
+
if validate_content_type:
|
|
295
|
+
ctype = resp.headers.get("Content-Type", "")
|
|
296
|
+
if "text/html" in ctype.lower():
|
|
297
|
+
resp.close()
|
|
298
|
+
raise ConfigError(f"expected binary, got content-type={ctype}")
|
|
299
|
+
|
|
300
|
+
total_size = int(resp.headers.get("Content-Length", "0") or 0)
|
|
301
|
+
|
|
302
|
+
tmp = target.with_suffix(target.suffix + ".part")
|
|
303
|
+
logger.verbose("FILE", f"Downloading to: {tmp}")
|
|
304
|
+
|
|
305
|
+
sha = hashlib.sha256()
|
|
306
|
+
downloaded = 0
|
|
307
|
+
last_percent = -1
|
|
308
|
+
started_at = time.time()
|
|
309
|
+
|
|
310
|
+
with tmp.open("wb") as f:
|
|
311
|
+
for chunk in resp.iter_content(chunk_size=DEFAULT_CHUNK):
|
|
312
|
+
if not chunk:
|
|
313
|
+
continue
|
|
314
|
+
f.write(chunk)
|
|
315
|
+
sha.update(chunk)
|
|
316
|
+
downloaded += len(chunk)
|
|
317
|
+
|
|
318
|
+
# Optional lightweight progress indicator.
|
|
319
|
+
if total_size:
|
|
320
|
+
pct = int(downloaded * 100 / total_size)
|
|
321
|
+
if pct != last_percent:
|
|
322
|
+
print(f"download progress: {pct}%", end="\r")
|
|
323
|
+
last_percent = pct
|
|
324
|
+
|
|
325
|
+
# Cleanup response socket.
|
|
326
|
+
resp.close()
|
|
327
|
+
|
|
328
|
+
digest = sha.hexdigest()
|
|
329
|
+
logger.verbose("FILE", f"SHA-256: {digest} (computed during download)")
|
|
330
|
+
|
|
331
|
+
# Atomically "commit" the file.
|
|
332
|
+
logger.verbose("FILE", f"Atomic rename: {tmp.name} -> {target.name}")
|
|
333
|
+
tmp.replace(target)
|
|
334
|
+
|
|
335
|
+
# Validate checksum if the caller expects a specific digest.
|
|
336
|
+
if expected_sha256 and digest.lower() != expected_sha256.lower():
|
|
337
|
+
logger.verbose(
|
|
338
|
+
"FILE", f"Checksum mismatch! Expected: {expected_sha256}, Got: {digest}"
|
|
339
|
+
)
|
|
340
|
+
try:
|
|
341
|
+
target.unlink()
|
|
342
|
+
except OSError:
|
|
343
|
+
pass
|
|
344
|
+
raise NetworkError(
|
|
345
|
+
f"sha256 mismatch for {filename}: got {digest}, "
|
|
346
|
+
f"expected {expected_sha256}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
elapsed = time.time() - started_at
|
|
350
|
+
# Always show completion message
|
|
351
|
+
logger.step(1, 1, f"Download complete: {target} ({digest}) in {elapsed:.1f}s")
|
|
352
|
+
# Show detailed info in verbose mode
|
|
353
|
+
logger.verbose("FILE", f"Download complete: {target}")
|
|
354
|
+
logger.verbose("FILE", f"Time elapsed: {elapsed:.1f}s")
|
|
355
|
+
|
|
356
|
+
# Hand back headers the caller may want to persist (ETag, Last-Modified).
|
|
357
|
+
return target, digest, dict(resp.headers)
|
napt/io/upload.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
"""File upload functionality for NAPT.
|
|
16
|
+
|
|
17
|
+
This module provides file upload capabilities for deploying packages to
|
|
18
|
+
Intune and other storage providers. Currently focused on Microsoft Intune
|
|
19
|
+
Win32 app deployment with support for .intunewin package uploads.
|
|
20
|
+
|
|
21
|
+
The module handles authentication, chunked uploads, encryption requirements,
|
|
22
|
+
and retry logic specific to Intune's Graph API endpoints.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
Basic Intune upload:
|
|
26
|
+
```python
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from napt.io.upload import upload_to_intune
|
|
29
|
+
|
|
30
|
+
result = upload_to_intune(
|
|
31
|
+
intunewin_path=Path("./MyApp.intunewin"),
|
|
32
|
+
app_id="12345678-1234-1234-1234-123456789abc",
|
|
33
|
+
access_token="eyJ0eXAiOi...",
|
|
34
|
+
)
|
|
35
|
+
print(f"Upload complete: {result}")
|
|
36
|
+
```
|
|
37
|
+
"""
|
napt/logging.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
"""Logging interface for NAPT.
|
|
16
|
+
|
|
17
|
+
This module provides a configurable logging interface that library modules
|
|
18
|
+
can use for output without depending on the CLI. The logger can be configured
|
|
19
|
+
globally or passed as a parameter for better isolation.
|
|
20
|
+
|
|
21
|
+
The logger supports four output levels:
|
|
22
|
+
|
|
23
|
+
- Step: Always printed (for progress indicators)
|
|
24
|
+
- Warning: Always printed (for important warnings that users should see)
|
|
25
|
+
- Verbose: Only printed when verbose mode is enabled
|
|
26
|
+
- Debug: Only printed when debug mode is enabled (implies verbose)
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
Configure global logger:
|
|
30
|
+
```python
|
|
31
|
+
from napt.logging import get_logger, set_global_logger
|
|
32
|
+
|
|
33
|
+
logger = get_logger(verbose=True, debug=False)
|
|
34
|
+
set_global_logger(logger)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use in library code:
|
|
38
|
+
```python
|
|
39
|
+
from napt.logging import get_logger
|
|
40
|
+
|
|
41
|
+
logger = get_logger()
|
|
42
|
+
logger.step(1, 4, "Loading configuration...")
|
|
43
|
+
logger.warning("DETECTION", "Could not extract MSI metadata")
|
|
44
|
+
logger.verbose("STATE", "Loaded state from file")
|
|
45
|
+
logger.debug("VERSION", "Trying backend: msilib...")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use with dependency injection:
|
|
49
|
+
|
|
50
|
+
def my_function(logger=None):
|
|
51
|
+
if logger is None:
|
|
52
|
+
logger = get_logger()
|
|
53
|
+
logger.verbose("MODULE", "Processing...")
|
|
54
|
+
|
|
55
|
+
Note:
|
|
56
|
+
The default logger is silent (verbose=False, debug=False), so library
|
|
57
|
+
functions won't print anything unless explicitly configured. The CLI
|
|
58
|
+
configures the global logger when commands are executed.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
from typing import Protocol
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Logger(Protocol):
|
|
67
|
+
"""Protocol for logger implementations."""
|
|
68
|
+
|
|
69
|
+
def step(self, step: int, total: int, message: str) -> None:
|
|
70
|
+
"""Print a step indicator for non-verbose mode.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
step: Current step number (1-based).
|
|
74
|
+
total: Total number of steps.
|
|
75
|
+
message: Step description.
|
|
76
|
+
"""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def warning(self, prefix: str, message: str) -> None:
|
|
80
|
+
"""Print a warning message (always visible).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
prefix: Message prefix (e.g., "DETECTION", "BUILD").
|
|
84
|
+
message: Warning message.
|
|
85
|
+
"""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
def verbose(self, prefix: str, message: str) -> None:
|
|
89
|
+
"""Print a verbose log message.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
prefix: Message prefix (e.g., "STATE", "BUILD").
|
|
93
|
+
message: Log message.
|
|
94
|
+
"""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
def debug(self, prefix: str, message: str) -> None:
|
|
98
|
+
"""Print a debug log message.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
prefix: Message prefix (e.g., "VERSION", "HTTP").
|
|
102
|
+
message: Log message.
|
|
103
|
+
"""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DefaultLogger:
|
|
108
|
+
"""Default logger implementation that prints to stdout.
|
|
109
|
+
|
|
110
|
+
This logger respects verbose and debug flags and formats output
|
|
111
|
+
consistently with the CLI output format.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, verbose: bool = False, debug: bool = False) -> None:
|
|
115
|
+
"""Initialize logger with verbosity settings.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
verbose: If True, print verbose messages.
|
|
119
|
+
debug: If True, print debug messages (implies verbose).
|
|
120
|
+
"""
|
|
121
|
+
self._verbose = verbose or debug
|
|
122
|
+
self._debug = debug
|
|
123
|
+
|
|
124
|
+
def step(self, step: int, total: int, message: str) -> None:
|
|
125
|
+
"""Print a step indicator for non-verbose mode."""
|
|
126
|
+
print(f"[{step}/{total}] {message}")
|
|
127
|
+
|
|
128
|
+
def warning(self, prefix: str, message: str) -> None:
|
|
129
|
+
"""Print a warning message (always visible)."""
|
|
130
|
+
print(f"[{prefix}] {message}")
|
|
131
|
+
|
|
132
|
+
def verbose(self, prefix: str, message: str) -> None:
|
|
133
|
+
"""Print a verbose log message (only when verbose mode is active)."""
|
|
134
|
+
if self._verbose:
|
|
135
|
+
print(f"[{prefix}] {message}")
|
|
136
|
+
|
|
137
|
+
def debug(self, prefix: str, message: str) -> None:
|
|
138
|
+
"""Print a debug log message (only when debug mode is active)."""
|
|
139
|
+
if self._debug:
|
|
140
|
+
print(f"[{prefix}] {message}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SilentLogger:
|
|
144
|
+
"""Logger that suppresses all output.
|
|
145
|
+
|
|
146
|
+
Useful for programmatic usage when output is not desired.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def step(self, step: int, total: int, message: str) -> None:
|
|
150
|
+
"""Suppress step output."""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def warning(self, prefix: str, message: str) -> None:
|
|
154
|
+
"""Suppress warning output."""
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def verbose(self, prefix: str, message: str) -> None:
|
|
158
|
+
"""Suppress verbose output."""
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def debug(self, prefix: str, message: str) -> None:
|
|
162
|
+
"""Suppress debug output."""
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Global logger instance (defaults to silent)
|
|
167
|
+
_global_logger: Logger = SilentLogger()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_logger(verbose: bool = False, debug: bool = False) -> Logger:
|
|
171
|
+
"""Get a logger instance with specified verbosity.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
verbose: If True, logger will print verbose messages.
|
|
175
|
+
debug: If True, logger will print debug messages (implies verbose).
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
A logger instance configured with the specified verbosity.
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
Get a verbose logger:
|
|
182
|
+
```python
|
|
183
|
+
logger = get_logger(verbose=True)
|
|
184
|
+
logger.verbose("MODULE", "Processing...")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Get a debug logger:
|
|
188
|
+
```python
|
|
189
|
+
logger = get_logger(debug=True)
|
|
190
|
+
logger.debug("MODULE", "Debug info...")
|
|
191
|
+
```
|
|
192
|
+
"""
|
|
193
|
+
return DefaultLogger(verbose=verbose, debug=debug)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_global_logger() -> Logger:
|
|
197
|
+
"""Get the global logger instance.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The current global logger instance.
|
|
201
|
+
|
|
202
|
+
Note:
|
|
203
|
+
The default global logger is silent. Use set_global_logger() to
|
|
204
|
+
configure it, or pass a logger instance directly to functions.
|
|
205
|
+
"""
|
|
206
|
+
return _global_logger
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def set_global_logger(logger: Logger) -> None:
|
|
210
|
+
"""Set the global logger instance.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
logger: Logger instance to use as the global logger.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
Configure global logger from CLI:
|
|
217
|
+
```python
|
|
218
|
+
from napt.logging import get_logger, set_global_logger
|
|
219
|
+
|
|
220
|
+
logger = get_logger(verbose=args.verbose, debug=args.debug)
|
|
221
|
+
set_global_logger(logger)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Note:
|
|
225
|
+
This affects all library functions that use get_logger() without
|
|
226
|
+
passing a logger instance. For better isolation, pass logger
|
|
227
|
+
instances directly to functions instead of using the global logger.
|
|
228
|
+
"""
|
|
229
|
+
global _global_logger
|
|
230
|
+
_global_logger = logger
|
napt/policy/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
"""Deployment policy and update management for NAPT.
|
|
16
|
+
|
|
17
|
+
This module provides policy enforcement for application updates including
|
|
18
|
+
version comparison strategies, hash-based change detection, and deployment
|
|
19
|
+
wave/ring management.
|
|
20
|
+
|
|
21
|
+
Modules:
|
|
22
|
+
updates: Update policies for deciding when to stage new application versions.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
Basic usage:
|
|
26
|
+
```python
|
|
27
|
+
from napt.policy import UpdatePolicy, should_stage
|
|
28
|
+
|
|
29
|
+
policy = UpdatePolicy(
|
|
30
|
+
strategy="version_then_hash",
|
|
31
|
+
comparator="semver",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
stage_it = should_stage(
|
|
35
|
+
remote_version="1.2.0",
|
|
36
|
+
remote_hash="abc123...",
|
|
37
|
+
current_version="1.1.9",
|
|
38
|
+
current_hash="def456...",
|
|
39
|
+
policy=policy,
|
|
40
|
+
)
|
|
41
|
+
print(f"Should stage: {stage_it}") # True
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# Future: Import when updates.py is fully implemented
|
|
47
|
+
# from .updates import UpdatePolicy, should_stage
|
|
48
|
+
# __all__ = ["UpdatePolicy", "should_stage"]
|
|
49
|
+
|
|
50
|
+
__all__ = []
|