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/policy/updates.py ADDED
@@ -0,0 +1,126 @@
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
+ """Update decision policy for NAPT.
16
+
17
+ Determines whether a newly discovered remote artifact should be staged,
18
+ based on version, hash, and org policy.
19
+
20
+ Example:
21
+ Check if a new version should be staged:
22
+ ```python
23
+ from napt.policy.updates import should_stage, UpdatePolicy
24
+
25
+ decision = should_stage(
26
+ remote_version="124.0.6367.91",
27
+ remote_hash="abc...",
28
+ current_version="124.0.6367.70",
29
+ current_hash="def...",
30
+ policy=UpdatePolicy(
31
+ strategy="version_then_hash",
32
+ allow_same_version_hash_change=True,
33
+ comparator="semver"
34
+ ),
35
+ )
36
+ ```
37
+
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from dataclasses import dataclass
43
+ from typing import Literal
44
+
45
+ from napt.versioning import is_newer_any
46
+
47
+ Strategy = Literal["version_only", "version_then_hash", "hash_or_version", "hash_only"]
48
+ Comparator = Literal["semver", "lexicographic"]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class UpdatePolicy:
53
+ """Update policy configuration for determining when to stage new versions.
54
+
55
+ Attributes:
56
+ strategy: Strategy for version comparison (default: "version_then_hash").
57
+ allow_same_version_hash_change: Allow staging when version is same but
58
+ hash differs (default: True).
59
+ comparator: Version comparison method - "semver" or "lexicographic"
60
+ (default: "semver").
61
+ """
62
+
63
+ strategy: Strategy = "version_then_hash"
64
+ allow_same_version_hash_change: bool = True
65
+ comparator: Comparator = "semver"
66
+
67
+
68
+ def should_stage(
69
+ *,
70
+ remote_version: str,
71
+ remote_hash: str,
72
+ current_version: str | None,
73
+ current_hash: str | None,
74
+ policy: UpdatePolicy,
75
+ ) -> bool:
76
+ """Decide whether to stage a newly discovered artifact.
77
+
78
+ Compares remote version/hash against current state using the configured
79
+ policy strategy to determine if the new artifact should be staged.
80
+
81
+ Args:
82
+ remote_version: Version found during discovery.
83
+ remote_hash: SHA-256 hash of the newly downloaded artifact.
84
+ current_version: Version we last staged/deployed (None if none).
85
+ current_hash: Hash we last staged/deployed (None if none).
86
+ policy: UpdatePolicy controlling the decision algorithm.
87
+
88
+ Returns:
89
+ True if the new artifact should be staged, False otherwise.
90
+
91
+ """
92
+ # If we have no prior state, stage the first artifact.
93
+ if current_version is None and current_hash is None:
94
+ return True
95
+
96
+ # Normalized comparisons
97
+ version_changed = (
98
+ True
99
+ if current_version is None
100
+ else is_newer_any(remote_version, current_version, policy.comparator)
101
+ or remote_version != current_version
102
+ # Treat "different version string" as change even if comparator
103
+ # treats them equal
104
+ )
105
+
106
+ hash_changed = (current_hash or "").lower() != (remote_hash or "").lower()
107
+
108
+ if policy.strategy == "version_only":
109
+ return version_changed
110
+
111
+ if policy.strategy == "version_then_hash":
112
+ if version_changed:
113
+ return True
114
+ if not version_changed and policy.allow_same_version_hash_change:
115
+ # Same version string but bits changed (repack, resign, silent fix)
116
+ return hash_changed
117
+ return False
118
+
119
+ if policy.strategy == "hash_or_version":
120
+ return version_changed or hash_changed
121
+
122
+ if policy.strategy == "hash_only":
123
+ return hash_changed
124
+
125
+ # Safe default: do not stage on unknown strategy
126
+ return False
napt/psadt/__init__.py ADDED
@@ -0,0 +1,43 @@
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
+ """PSAppDeployToolkit integration for NAPT.
16
+
17
+ This module handles PSAppDeployToolkit (PSADT) release management, caching,
18
+ and integration with NAPT's build system.
19
+
20
+ Example:
21
+ Basic usage:
22
+ ```python
23
+ from pathlib import Path
24
+ from napt.psadt import get_psadt_release, fetch_latest_psadt_version
25
+
26
+ # Get latest version
27
+ latest = fetch_latest_psadt_version()
28
+ print(f"Latest PSADT: {latest}")
29
+
30
+ # Download and cache
31
+ psadt_path = get_psadt_release("latest", Path("cache/psadt"))
32
+ print(f"PSADT cached at: {psadt_path}")
33
+ ```
34
+
35
+ """
36
+
37
+ from .release import (
38
+ fetch_latest_psadt_version,
39
+ get_psadt_release,
40
+ is_psadt_cached,
41
+ )
42
+
43
+ __all__ = ["fetch_latest_psadt_version", "get_psadt_release", "is_psadt_cached"]
napt/psadt/release.py ADDED
@@ -0,0 +1,309 @@
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
+ """PSADT release management for NAPT.
16
+
17
+ This module handles fetching, downloading, and caching PSAppDeployToolkit
18
+ releases from the official GitHub repository. It reuses NAPT's existing
19
+ GitHub release discovery infrastructure for consistency.
20
+
21
+ Key Features:
22
+
23
+ - Fetch latest PSADT version from GitHub API
24
+ - Download and cache specific PSADT versions
25
+ - Extract releases to cache directory
26
+ - Version resolution ("latest" keyword support)
27
+
28
+ Example:
29
+ Get and cache PSADT releases:
30
+ ```python
31
+ from pathlib import Path
32
+ from napt.psadt import get_psadt_release, is_psadt_cached
33
+
34
+ # Get latest PSADT
35
+ psadt_dir = get_psadt_release("latest", Path("cache/psadt"))
36
+
37
+ # Get specific version
38
+ psadt_dir = get_psadt_release("4.1.7", Path("cache/psadt"))
39
+
40
+ # Check if cached
41
+ if is_psadt_cached("4.1.7", Path("cache/psadt")):
42
+ print("Already cached!")
43
+ ```
44
+
45
+ Note:
46
+ - Reuses notapkgtool.discovery.api_github for API calls
47
+ - Caches releases by version: cache/psadt/{version}/
48
+ - Downloads .zip releases and extracts to cache
49
+ - Validates extracted PSADT structure (PSAppDeployToolkit/ folder exists)
50
+
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from pathlib import Path
56
+ import re
57
+ import zipfile
58
+
59
+ import requests
60
+
61
+ from napt.exceptions import NetworkError, PackagingError
62
+
63
+ PSADT_REPO = "PSAppDeployToolkit/PSAppDeployToolkit"
64
+ PSADT_GITHUB_API = f"https://api.github.com/repos/{PSADT_REPO}/releases/latest"
65
+
66
+
67
+ def fetch_latest_psadt_version() -> str:
68
+ """Fetch the latest PSADT release version from GitHub.
69
+
70
+ Queries the GitHub API for the latest release and extracts the version
71
+ number from the tag name (e.g., "4.1.7" from tag "4.1.7").
72
+
73
+ Returns:
74
+ Version number (e.g., "4.1.7").
75
+
76
+ Raises:
77
+ RuntimeError: If the GitHub API request fails or version cannot be
78
+ extracted.
79
+
80
+ Example:
81
+ Get latest PSADT version from GitHub:
82
+ ```python
83
+ version = fetch_latest_psadt_version()
84
+ print(version) # Output: "4.1.7"
85
+ ```
86
+
87
+ Note:
88
+ - Uses GitHub's public API (60 requests/hour limit without auth)
89
+ - Version is extracted from release tag name
90
+ - For higher rate limits, set GITHUB_TOKEN environment variable
91
+
92
+ """
93
+ from napt.logging import get_global_logger
94
+
95
+ logger = get_global_logger()
96
+ logger.verbose("PSADT", f"Querying GitHub API: {PSADT_GITHUB_API}")
97
+
98
+ try:
99
+ headers = {
100
+ "Accept": "application/vnd.github+json",
101
+ "X-GitHub-Api-Version": "2022-11-28",
102
+ }
103
+
104
+ response = requests.get(PSADT_GITHUB_API, headers=headers, timeout=30)
105
+ response.raise_for_status()
106
+ except requests.RequestException as err:
107
+ raise NetworkError(
108
+ f"Failed to fetch latest PSADT release from GitHub: {err}"
109
+ ) from err
110
+
111
+ data = response.json()
112
+ tag_name = data.get("tag_name", "")
113
+
114
+ if not tag_name:
115
+ raise NetworkError("GitHub API response missing 'tag_name' field")
116
+
117
+ # Extract version from tag (e.g., "4.1.7" or "v4.1.7")
118
+ # PSADT uses tags without 'v' prefix
119
+ version_match = re.match(r"v?(\d+\.\d+\.\d+)", tag_name)
120
+ if not version_match:
121
+ raise NetworkError(f"Could not extract version from tag: {tag_name!r}")
122
+
123
+ version = version_match.group(1)
124
+ logger.verbose("PSADT", f"Latest PSADT version: {version}")
125
+
126
+ return version
127
+
128
+
129
+ def is_psadt_cached(version: str, cache_dir: Path) -> bool:
130
+ """Check if a PSADT version is already cached.
131
+
132
+ Args:
133
+ version: PSADT version to check (e.g., "4.1.7").
134
+ cache_dir: Base cache directory (e.g., Path("cache/psadt")).
135
+
136
+ Returns:
137
+ True if the version is cached and valid, False otherwise.
138
+
139
+ Example:
140
+ Check if PSADT version is cached:
141
+ ```python
142
+ from pathlib import Path
143
+
144
+ if is_psadt_cached("4.1.7", Path("cache/psadt")):
145
+ print("Already downloaded!")
146
+ ```
147
+
148
+ Note:
149
+ Validates that the cache contains the expected PSADT structure:
150
+
151
+ - PSAppDeployToolkit/ folder must exist
152
+ - PSAppDeployToolkit.psd1 manifest must exist
153
+
154
+ """
155
+ version_dir = cache_dir / version
156
+ psadt_dir = version_dir / "PSAppDeployToolkit"
157
+ manifest = psadt_dir / "PSAppDeployToolkit.psd1"
158
+
159
+ return psadt_dir.exists() and manifest.exists()
160
+
161
+
162
+ def get_psadt_release(release_spec: str, cache_dir: Path) -> Path:
163
+ """Download and extract a PSADT release to the cache directory.
164
+
165
+ Resolves "latest" to the current latest version from GitHub, then
166
+ downloads the release .zip file and extracts it to the cache.
167
+
168
+ Args:
169
+ release_spec: Version specifier - either "latest" or specific version
170
+ (e.g., "4.1.7").
171
+ cache_dir: Base cache directory for PSADT releases.
172
+
173
+ Returns:
174
+ Path to the cached PSADT directory (cache_dir/{version}).
175
+
176
+ Raises:
177
+ NetworkError: If download fails.
178
+ PackagingError: If extraction fails.
179
+ ConfigError: If release_spec is invalid.
180
+
181
+ Example:
182
+ Get latest version:
183
+ ```python
184
+ from pathlib import Path
185
+
186
+ psadt = get_psadt_release("latest", Path("cache/psadt"))
187
+ print(psadt) # Output: cache/psadt/4.1.7
188
+ ```
189
+
190
+ Get specific version:
191
+ ```python
192
+ psadt = get_psadt_release("4.1.7", Path("cache/psadt"))
193
+ ```
194
+
195
+ Note:
196
+ - Caches by version: cache/psadt/{version}/PSAppDeployToolkit/
197
+ - If already cached, returns path immediately (no re-download)
198
+ - Downloads from GitHub releases as .zip files
199
+ - Extracts entire archive to version directory
200
+
201
+ """
202
+ from napt.logging import get_global_logger
203
+
204
+ logger = get_global_logger()
205
+ # Resolve "latest" to actual version
206
+ if release_spec == "latest":
207
+ logger.verbose("PSADT", "Resolving 'latest' to current version...")
208
+ version = fetch_latest_psadt_version()
209
+ else:
210
+ version = release_spec
211
+
212
+ logger.verbose("PSADT", f"PSADT version: {version}")
213
+
214
+ # Check if already cached
215
+ if is_psadt_cached(version, cache_dir):
216
+ version_dir = cache_dir / version
217
+ logger.verbose("PSADT", f"Using cached PSADT: {version_dir}")
218
+ return version_dir
219
+
220
+ # Need to download
221
+ logger.verbose("PSADT", f"Downloading PSADT {version}...")
222
+
223
+ # Get release info from GitHub
224
+ release_url = f"https://api.github.com/repos/{PSADT_REPO}/releases/tags/{version}"
225
+
226
+ try:
227
+ headers = {
228
+ "Accept": "application/vnd.github+json",
229
+ "X-GitHub-Api-Version": "2022-11-28",
230
+ }
231
+
232
+ response = requests.get(release_url, headers=headers, timeout=30)
233
+ response.raise_for_status()
234
+ except requests.RequestException as err:
235
+ raise NetworkError(
236
+ f"Failed to fetch PSADT release {version} from GitHub: {err}"
237
+ ) from err
238
+
239
+ release_data = response.json()
240
+
241
+ # Find the Template_v4 .zip asset (the full v4 template structure)
242
+ assets = release_data.get("assets", [])
243
+ zip_asset = None
244
+
245
+ # Look for Template_v4 version specifically
246
+ for asset in assets:
247
+ name = asset.get("name", "")
248
+ if name.endswith(".zip") and "Template_v4" in name:
249
+ zip_asset = asset
250
+ break
251
+
252
+ # Fallback to any PSADT zip if Template_v4 not found
253
+ if not zip_asset:
254
+ for asset in assets:
255
+ name = asset.get("name", "")
256
+ if name.endswith(".zip") and "PSAppDeployToolkit" in name:
257
+ zip_asset = asset
258
+ break
259
+
260
+ if not zip_asset:
261
+ raise NetworkError(
262
+ f"No .zip asset found in PSADT release {version}. "
263
+ f"Available assets: {[a.get('name') for a in assets]}"
264
+ )
265
+
266
+ download_url = zip_asset.get("browser_download_url")
267
+ if not download_url:
268
+ raise NetworkError(f"Asset missing download URL: {zip_asset}")
269
+
270
+ logger.verbose("PSADT", f"Downloading: {zip_asset['name']}")
271
+
272
+ # Download the .zip file
273
+ try:
274
+ zip_response = requests.get(download_url, timeout=300)
275
+ zip_response.raise_for_status()
276
+ except requests.RequestException as err:
277
+ raise NetworkError(f"Failed to download PSADT release: {err}") from err
278
+
279
+ # Create cache directory
280
+ version_dir = cache_dir / version
281
+ version_dir.mkdir(parents=True, exist_ok=True)
282
+
283
+ # Save .zip temporarily
284
+ zip_path = version_dir / f"psadt_{version}.zip"
285
+ zip_path.write_bytes(zip_response.content)
286
+
287
+ logger.verbose("PSADT", f"Extracting to: {version_dir}")
288
+
289
+ # Extract .zip
290
+ try:
291
+ with zipfile.ZipFile(zip_path, "r") as zf:
292
+ zf.extractall(version_dir)
293
+ except zipfile.BadZipFile as err:
294
+ raise PackagingError(f"Failed to extract PSADT archive: {err}") from err
295
+ finally:
296
+ # Clean up .zip file
297
+ if zip_path.exists():
298
+ zip_path.unlink()
299
+
300
+ # Verify extracted structure
301
+ if not is_psadt_cached(version, cache_dir):
302
+ raise PackagingError(
303
+ f"PSADT extraction failed: PSAppDeployToolkit/ folder "
304
+ f"not found in {version_dir}"
305
+ )
306
+
307
+ logger.verbose("PSADT", f"PSADT {version} cached successfully")
308
+
309
+ return version_dir