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/results.py ADDED
@@ -0,0 +1,143 @@
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
+ """Public API return types for NAPT.
16
+
17
+ This module defines dataclasses for return values from public API functions.
18
+ These types represent the results of operations like discovery, building,
19
+ packaging, and validation.
20
+
21
+ All dataclasses are frozen (immutable) to prevent accidental mutation of
22
+ return values.
23
+
24
+ Example:
25
+ Using result types:
26
+ ```python
27
+ from pathlib import Path
28
+ from napt.core import discover_recipe
29
+ from napt.results import DiscoverResult
30
+
31
+ result: DiscoverResult = discover_recipe(
32
+ Path("recipes/Google/chrome.yaml"),
33
+ Path("./downloads")
34
+ )
35
+ print(result.version) # Attribute access, not dict access
36
+ ```
37
+
38
+ Note:
39
+ Only public API return types belong in this module. Domain types
40
+ (like DiscoveredVersion) and internal types (like LoadContext) should
41
+ remain co-located with their related logic.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from dataclasses import dataclass
47
+ from pathlib import Path
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class DiscoverResult:
52
+ """Result from discovering a version and downloading an installer.
53
+
54
+ Attributes:
55
+ app_name: Application display name.
56
+ app_id: Unique application identifier.
57
+ strategy: Discovery strategy used (e.g., "web_scrape", "api_github").
58
+ version: Extracted version string.
59
+ version_source: How version was determined (e.g., "regex_in_url", "msi").
60
+ file_path: Path to the downloaded installer file.
61
+ sha256: SHA-256 hash of the downloaded file.
62
+ status: Always "success" for successful discovery.
63
+ """
64
+
65
+ app_name: str
66
+ app_id: str
67
+ strategy: str
68
+ version: str
69
+ version_source: str
70
+ file_path: Path
71
+ sha256: str
72
+ status: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class BuildResult:
77
+ """Result from building a PSADT package.
78
+
79
+ Attributes:
80
+ app_id: Unique application identifier.
81
+ app_name: Application display name.
82
+ version: Application version.
83
+ build_dir: Path to the build directory (packagefiles subdirectory).
84
+ psadt_version: PSADT version used for the build.
85
+ status: Build status (typically "success").
86
+ build_types: The build types setting used ("both", "app_only", or
87
+ "update_only").
88
+ detection_script_path: Path to the generated detection script, if
89
+ created. None if detection script generation was skipped or failed
90
+ (and fail_on_error was False), or if build_types is "update_only".
91
+ requirements_script_path: Path to the generated requirements script, if
92
+ created. None if requirements script generation was skipped or
93
+ failed (and fail_on_error was False), or if build_types is
94
+ "app_only".
95
+ """
96
+
97
+ app_id: str
98
+ app_name: str
99
+ version: str
100
+ build_dir: Path
101
+ psadt_version: str
102
+ status: str
103
+ build_types: str
104
+ detection_script_path: Path | None = None
105
+ requirements_script_path: Path | None = None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class PackageResult:
110
+ """Result from creating a .intunewin package.
111
+
112
+ Attributes:
113
+ build_dir: Path to the build directory.
114
+ package_path: Path to the created .intunewin file.
115
+ app_id: Unique application identifier.
116
+ version: Application version.
117
+ status: Packaging status (typically "success").
118
+ """
119
+
120
+ build_dir: Path
121
+ package_path: Path
122
+ app_id: str
123
+ version: str
124
+ status: str
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ValidationResult:
129
+ """Result from validating a recipe.
130
+
131
+ Attributes:
132
+ status: Validation status ("valid" or "invalid").
133
+ errors: List of error messages (empty if valid).
134
+ warnings: List of warning messages.
135
+ app_count: Number of apps in the recipe.
136
+ recipe_path: String path to the validated recipe file.
137
+ """
138
+
139
+ status: str
140
+ errors: list[str]
141
+ warnings: list[str]
142
+ app_count: int
143
+ recipe_path: str
napt/state/__init__.py ADDED
@@ -0,0 +1,58 @@
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
+ """State tracking and version management for NAPT.
16
+
17
+ This module provides state persistence for tracking discovered application
18
+ versions, ETags, and file metadata between runs. This enables:
19
+
20
+ - Efficient conditional downloads (HTTP 304 Not Modified)
21
+ - Version change detection
22
+ - Bandwidth optimization for scheduled workflows
23
+
24
+ The state file is a JSON file that stores:
25
+
26
+ - Discovered versions from vendors
27
+ - HTTP ETags and Last-Modified headers for conditional requests
28
+ - File paths and SHA-256 hashes for cached installers
29
+ - Last checked timestamps for monitoring
30
+
31
+ State tracking is enabled by default and can be disabled with --stateless flag.
32
+
33
+ Example:
34
+ Basic usage:
35
+ ```python
36
+ from pathlib import Path
37
+ from napt.state import load_state, save_state
38
+
39
+ state = load_state(Path("state/versions.json"))
40
+
41
+ app_id = "napt-chrome"
42
+ cache = state.get("apps", {}).get(app_id)
43
+
44
+ state["apps"][app_id] = {
45
+ "url": "https://dl.google.com/chrome.msi",
46
+ "etag": 'W/"abc123"',
47
+ "sha256": "abc123...",
48
+ "known_version": "130.0.0"
49
+ }
50
+
51
+ save_state(state, Path("state/versions.json"))
52
+ ```
53
+
54
+ """
55
+
56
+ from .tracker import StateTracker, load_state, save_state
57
+
58
+ __all__ = ["StateTracker", "load_state", "save_state"]
napt/state/tracker.py ADDED
@@ -0,0 +1,371 @@
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
+ """State tracking implementation for NAPT.
16
+
17
+ This module implements the state persistence layer for tracking discovered
18
+ application versions, ETags, and download metadata between runs.
19
+
20
+ The state file supports two optimization approaches:
21
+
22
+ - VERSION-FIRST (url_pattern, api_github, api_json): Uses known_version for comparison
23
+ - FILE-FIRST (url_download): Uses etag/last_modified for HTTP conditional requests
24
+
25
+ Key Features:
26
+
27
+ - JSON-based state storage (fast parsing, standard library)
28
+ - Automatic ETag/Last-Modified tracking for conditional requests
29
+ - Version change detection for version-first strategies
30
+ - Robust error handling (corrupted files, missing data)
31
+ - Auto-creation of state files and directories
32
+
33
+ Example:
34
+ High-level API with StateTracker:
35
+ ```python
36
+ from pathlib import Path
37
+ from napt.state import StateTracker
38
+
39
+ tracker = StateTracker(Path("state/versions.json"))
40
+ tracker.load()
41
+
42
+ # Get cache for conditional requests
43
+ cache = tracker.get_cache("napt-chrome")
44
+
45
+ # Update after discovery
46
+ tracker.update_cache("napt-chrome", version="130.0.0", ...)
47
+ tracker.save()
48
+ ```
49
+
50
+ Low-level API with functions:
51
+ ```python
52
+ from pathlib import Path
53
+ from napt.state import load_state, save_state
54
+
55
+ state = load_state(Path("state/versions.json"))
56
+ # ... modify state dict ...
57
+ save_state(state, Path("state/versions.json"))
58
+ ```
59
+
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ from datetime import UTC, datetime
65
+ import json
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ from napt import __version__
70
+ from napt.exceptions import PackagingError
71
+
72
+
73
+ class StateTracker:
74
+ """Manages application state tracking with automatic persistence.
75
+
76
+ This class provides a high-level interface for loading, querying, and
77
+ updating the state file. It handles file I/O, error recovery, and
78
+ provides convenience methods for common operations.
79
+
80
+ Attributes:
81
+ state_file: Path to the JSON state file.
82
+ state: In-memory state dictionary.
83
+
84
+ Example:
85
+ Basic usage:
86
+ ```python
87
+ from pathlib import Path
88
+
89
+ tracker = StateTracker(Path("state/versions.json"))
90
+ tracker.load()
91
+ cache = tracker.get_cache("napt-chrome")
92
+ tracker.update_cache(
93
+ "napt-chrome",
94
+ url="https://...",
95
+ sha256="...",
96
+ known_version="130.0.0"
97
+ )
98
+ tracker.save()
99
+ ```
100
+
101
+ """
102
+
103
+ def __init__(self, state_file: Path):
104
+ """Initialize state tracker.
105
+
106
+ Args:
107
+ state_file: Path to JSON state file. Created if doesn't exist.
108
+
109
+ """
110
+ self.state_file = state_file
111
+ self.state: dict[str, Any] = {}
112
+
113
+ def load(self) -> dict[str, Any]:
114
+ """Load state from file.
115
+
116
+ Creates default state structure if file doesn't exist.
117
+ Handles corrupted files by creating backup and starting fresh.
118
+
119
+ Returns:
120
+ Loaded state dictionary.
121
+
122
+ Raises:
123
+ OSError: If file permissions prevent reading.
124
+
125
+ """
126
+ try:
127
+ self.state = load_state(self.state_file)
128
+ except FileNotFoundError:
129
+ # First run, create default state
130
+ self.state = create_default_state()
131
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
132
+ self.save()
133
+ except json.JSONDecodeError as err:
134
+ # Corrupted file, backup and create new
135
+ backup = self.state_file.with_suffix(".json.backup")
136
+ self.state_file.rename(backup)
137
+ self.state = create_default_state()
138
+ self.save()
139
+ raise PackagingError(
140
+ f"Corrupted state file backed up to {backup}. "
141
+ f"Created fresh state file."
142
+ ) from err
143
+
144
+ return self.state
145
+
146
+ def save(self) -> None:
147
+ """Save current state to file.
148
+
149
+ Updates metadata.last_updated timestamp automatically.
150
+ Creates parent directories if needed.
151
+
152
+ Raises:
153
+ OSError: If file permissions prevent writing.
154
+
155
+ """
156
+ # Update metadata
157
+ self.state.setdefault("metadata", {})
158
+ self.state["metadata"]["last_updated"] = datetime.now(UTC).isoformat()
159
+
160
+ # Ensure parent directory exists
161
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
162
+
163
+ save_state(self.state, self.state_file)
164
+
165
+ def get_cache(self, recipe_id: str) -> dict[str, Any] | None:
166
+ """Get cached information for a recipe.
167
+
168
+ Args:
169
+ recipe_id: Recipe identifier (from recipe's 'id' field).
170
+
171
+ Returns:
172
+ Cached data if available, None otherwise.
173
+
174
+ Example:
175
+ Retrieve cached information:
176
+ ```python
177
+ cache = tracker.get_cache("napt-chrome")
178
+ if cache:
179
+ etag = cache.get('etag')
180
+ known_version = cache.get('known_version')
181
+ ```
182
+
183
+ """
184
+ return self.state.get("apps", {}).get(recipe_id)
185
+
186
+ def update_cache(
187
+ self,
188
+ recipe_id: str,
189
+ url: str,
190
+ sha256: str,
191
+ etag: str | None = None,
192
+ last_modified: str | None = None,
193
+ known_version: str | None = None,
194
+ strategy: str | None = None,
195
+ ) -> None:
196
+ """Update cached information for a recipe.
197
+
198
+ Args:
199
+ recipe_id: Recipe identifier.
200
+ url: Download URL for provenance tracking. For version-first strategies
201
+ (url_pattern, api_github, api_json), this is the actual download URL
202
+ from version_info. For file-first (url_download), this is source.url.
203
+ sha256: SHA-256 hash of file (for integrity checks).
204
+ etag: ETag header from download response. Used by url_download for HTTP 304
205
+ conditional requests. Saved but unused by version-first strategies.
206
+ last_modified: Last-Modified header from download response.
207
+ Used by url_download as fallback for conditional requests.
208
+ Saved but unused by version-first.
209
+ known_version: Version string. PRIMARY cache key for
210
+ version-first strategies (compared to skip downloads).
211
+ Informational only for url_download.
212
+ strategy: Discovery strategy used (for debugging).
213
+
214
+ Example:
215
+ Update cache entry:
216
+ ```python
217
+ tracker.update_cache(
218
+ "napt-chrome",
219
+ url="https://dl.google.com/chrome.msi",
220
+ sha256="abc123...",
221
+ etag='W/"def456"',
222
+ known_version="130.0.0"
223
+ )
224
+ ```
225
+
226
+ Note:
227
+ Schema v2: Removed file_path, last_checked, and renamed
228
+ version -> known_version.
229
+
230
+ Field usage differs by strategy type:
231
+
232
+ - Version-first: known_version is PRIMARY cache key,
233
+ etag/last_modified unused
234
+ - File-first: etag/last_modified are PRIMARY cache keys,
235
+ known_version informational
236
+
237
+ Filesystem is the source of truth; state is for optimization only.
238
+
239
+ """
240
+ if "apps" not in self.state:
241
+ self.state["apps"] = {}
242
+
243
+ cache_entry = {
244
+ "url": url,
245
+ "etag": etag,
246
+ "last_modified": last_modified,
247
+ "sha256": sha256,
248
+ }
249
+
250
+ # Optional fields (only add if provided)
251
+ if known_version is not None:
252
+ cache_entry["known_version"] = known_version
253
+ if strategy is not None:
254
+ cache_entry["strategy"] = strategy
255
+
256
+ self.state["apps"][recipe_id] = cache_entry
257
+
258
+ def has_version_changed(self, recipe_id: str, new_version: str) -> bool:
259
+ """Check if discovered version differs from cached known_version.
260
+
261
+ Args:
262
+ recipe_id: Recipe identifier.
263
+ new_version: Newly discovered version.
264
+
265
+ Returns:
266
+ True if version changed or no cached version exists.
267
+
268
+ Example:
269
+ Check if version has changed:
270
+ ```python
271
+ if tracker.has_version_changed("napt-chrome", "130.0.0"):
272
+ print("New version available!")
273
+ ```
274
+
275
+ Note:
276
+ Uses 'known_version' field which is informational only.
277
+ Real version should be extracted from filesystem during build.
278
+
279
+ """
280
+ cache = self.get_cache(recipe_id)
281
+ if not cache:
282
+ return True # No cache, treat as changed
283
+
284
+ return cache.get("known_version") != new_version
285
+
286
+
287
+ def create_default_state() -> dict[str, Any]:
288
+ """Create a default empty state structure.
289
+
290
+ Returns:
291
+ Empty state with metadata section.
292
+
293
+ Example:
294
+ Create default state structure:
295
+ ```python
296
+ state = create_default_state()
297
+ state["apps"] = {}
298
+ ```
299
+
300
+ """
301
+ return {
302
+ "metadata": {
303
+ "napt_version": __version__,
304
+ "schema_version": "2",
305
+ "last_updated": datetime.now(UTC).isoformat(),
306
+ },
307
+ "apps": {},
308
+ }
309
+
310
+
311
+ def load_state(state_file: Path) -> dict[str, Any]:
312
+ """Load state from JSON file.
313
+
314
+ Args:
315
+ state_file: Path to JSON state file.
316
+
317
+ Returns:
318
+ Loaded state dictionary.
319
+
320
+ Raises:
321
+ FileNotFoundError: If state file doesn't exist.
322
+ json.JSONDecodeError: If file contains invalid JSON.
323
+ OSError: If file cannot be read due to permissions.
324
+
325
+ Example:
326
+ Load state from file:
327
+ ```python
328
+ from pathlib import Path
329
+
330
+ state = load_state(Path("state/versions.json"))
331
+ apps = state.get("apps", {})
332
+ ```
333
+
334
+ """
335
+ with open(state_file, encoding="utf-8") as f:
336
+ return json.load(f)
337
+
338
+
339
+ def save_state(state: dict[str, Any], state_file: Path) -> None:
340
+ """Save state to JSON file with pretty-printing.
341
+
342
+ Creates parent directories if needed. Uses 2-space indentation
343
+ and sorted keys for consistent diffs in version control.
344
+
345
+ Args:
346
+ state: State dictionary to save.
347
+ state_file: Path to JSON state file.
348
+
349
+ Raises:
350
+ OSError: If file cannot be written due to permissions.
351
+
352
+ Example:
353
+ Save state to file:
354
+ ```python
355
+ from pathlib import Path
356
+
357
+ state = {"metadata": {}, "apps": {}}
358
+ save_state(state, Path("state/versions.json"))
359
+ ```
360
+
361
+ Note:
362
+ - Uses 2-space indentation for readability
363
+ - Sorts keys alphabetically for consistent diffs
364
+ - Adds trailing newline for git compatibility
365
+
366
+ """
367
+ state_file.parent.mkdir(parents=True, exist_ok=True)
368
+
369
+ with open(state_file, "w", encoding="utf-8") as f:
370
+ json.dump(state, f, indent=2, sort_keys=True)
371
+ f.write("\n") # Trailing newline for git