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/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
@@ -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__ = []