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/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
|