state
napt.state.cache
Discovery cache persistence for NAPT.
This module implements the discovery cache: a disposable optimization file
(default cache/discovery.json) that tracks discovered versions, ETags,
and download metadata between runs. Deleting it costs one full re-download
per app and nothing else — the filesystem and deployment state remain the
source of truth.
The cache supports two optimization approaches:
- VERSION-FIRST (url_pattern, api_github, api_json): Uses known_version for comparison
- FILE-FIRST (url_download): Uses etag/last_modified for HTTP conditional requests
Key Features:
- JSON-based cache storage (fast parsing, standard library)
- Automatic ETag/Last-Modified tracking for conditional requests
- Version change detection for version-first strategies
- Robust error handling (corrupted files, missing data)
- Auto-creation of cache files and directories
Example
High-level API with DiscoveryCache:
from pathlib import Path
from napt.state import DiscoveryCache
cache = DiscoveryCache(Path("cache/discovery.json"))
cache.load()
# Get entry for conditional requests
entry = cache.get_cache("napt-chrome")
# Update after discovery
cache.update_cache("napt-chrome", version="130.0.0", ...)
cache.save()
Low-level API with functions:
DiscoveryCache
Manages the discovery cache with automatic persistence.
This class provides a high-level interface for loading, querying, and updating the cache file. It handles file I/O, error recovery, and provides convenience methods for common operations.
Attributes:
| Name | Type | Description |
|---|---|---|
cache_file |
Path to the JSON cache file. |
|
data |
dict[str, Any]
|
In-memory cache dictionary. |
Example
Basic usage:
Source code in napt/state/cache.py
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 | |
__init__
Initialize discovery cache.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cache_file
|
Path
|
Path to JSON cache file. Created if doesn't exist. |
required |
load
Load cache from file.
Creates default cache structure if file doesn't exist. Handles corrupted files by creating backup and starting fresh.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
Loaded cache dictionary. |
Raises:
| Type | Description |
|---|---|
OSError
|
If file permissions prevent reading. |
Source code in napt/state/cache.py
save
Save current cache to file.
Updates metadata.last_updated timestamp automatically. Creates parent directories if needed.
Raises:
| Type | Description |
|---|---|
OSError
|
If file permissions prevent writing. |
Source code in napt/state/cache.py
get_cache
Get cached information for a recipe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
recipe_id
|
str
|
Recipe identifier (from recipe's 'id' field). |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any] | None
|
Cached data if available, None otherwise. |
Example
Retrieve cached information:
Source code in napt/state/cache.py
update_cache
update_cache(
recipe_id: str,
url: str,
sha256: str,
etag: str | None = None,
last_modified: str | None = None,
known_version: str | None = None,
strategy: str | None = None,
) -> None
Update cached information for a recipe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
recipe_id
|
str
|
Recipe identifier. |
required |
url
|
str
|
Download URL for provenance tracking. For version-first strategies (url_pattern, api_github, api_json), this is the actual download URL from version_info. For file-first (url_download), this is discovery.url. |
required |
sha256
|
str
|
SHA-256 hash of file (for integrity checks). |
required |
etag
|
str | None
|
ETag header from download response. Used by url_download for HTTP 304 conditional requests. Saved but unused by version-first strategies. |
None
|
last_modified
|
str | None
|
Last-Modified header from download response. Used by url_download as fallback for conditional requests. Saved but unused by version-first. |
None
|
known_version
|
str | None
|
Version string. PRIMARY cache key for version-first strategies (compared to skip downloads). Informational only for url_download. |
None
|
strategy
|
str | None
|
Discovery strategy used (for debugging). |
None
|
Example
Update cache entry:
Note
Schema v2: Removed file_path, last_checked, and renamed version -> known_version.
Field usage differs by strategy type:
- Version-first: known_version is PRIMARY cache key, etag/last_modified unused
- File-first: etag/last_modified are PRIMARY cache keys, known_version informational
The cache is for optimization only; the filesystem and deployment state are the source of truth.
Source code in napt/state/cache.py
has_version_changed
Check if discovered version differs from cached known_version.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
recipe_id
|
str
|
Recipe identifier. |
required |
new_version
|
str
|
Newly discovered version. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if version changed or no cached version exists. |
Example
Check if version has changed:
Note
Uses 'known_version' field which is informational only. Real version should be extracted from filesystem during build.
Source code in napt/state/cache.py
cache_file_path
Returns the discovery cache file path from merged configuration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
dict[str, Any]
|
Merged configuration containing |
required |
Returns:
| Type | Description |
|---|---|
Path
|
Path to the discovery cache file ( |
Source code in napt/state/cache.py
create_default_cache
Create a default empty cache structure.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
Empty cache with metadata section. |
Source code in napt/state/cache.py
load_cache
Load cache from JSON file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cache_file
|
Path
|
Path to JSON cache file. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
Loaded cache dictionary. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If cache file doesn't exist. |
JSONDecodeError
|
If file contains invalid JSON. |
OSError
|
If file cannot be read due to permissions. |
Example
Load cache from file:
Source code in napt/state/cache.py
save_cache
Save cache to JSON file with pretty-printing.
Creates parent directories if needed. Uses 2-space indentation and sorted keys for consistent diffs in version control.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Cache dictionary to save. |
required |
cache_file
|
Path
|
Path to JSON cache file. |
required |
Raises:
| Type | Description |
|---|---|
OSError
|
If file cannot be written due to permissions. |
Example
Save cache to file:
Note
- Uses 2-space indentation for readability
- Sorts keys alphabetically for consistent diffs
- Adds trailing newline for git compatibility
Source code in napt/state/cache.py
napt.state.deployment
Deployment state persistence for NAPT.
This module implements per-app deployment state: authoritative records of what NAPT has published to Intune and what is awaiting publication. Unlike the discovery cache, deployment state is not regenerable.
Each app gets its own file, state/deployment/<recipe-id>.json, so that
concurrent changes to different apps never conflict and each file's diff
is scoped to one app. A file names its app once at the top (app_id
matching the filename, plus the recipe's display name, refreshed on
every save) and holds five sections:
published: The release currently in Intune, with its SHA-256 hash and Intune app IDs. Null until the first upload. Publishing uploads the release without assigning it —napt promotedeploys it through the rings afterwards.install_assigned: The release the install entry is currently assigned to (the result of apromoteplan'sassignaction).pending: The discovered release awaiting publication, with version, download URL, and SHA-256 hash. A single slot — a newer discovery replaces an unpublished candidate (newest wins). Null when nothing is awaiting publication.rings: Which version currently holds each deployment ring. Written bynapt promote.retained: Displaced versions kept in Intune for rollback.
Serialization is deterministic (fixed reading-order keys, fixed
indentation, no timestamps), so re-running a command that produces no
logical change produces a byte-identical file and a clean git diff.
Keys follow reading order — lifecycle order at the top level, version
first and hashes last inside blocks — because these files are what a
publish PR diff shows its reviewer.
Example
Recording a discovered release:
from pathlib import Path
from napt.state import (
deployment_state_path,
load_deployment_state,
record_pending,
save_deployment_state,
)
path = deployment_state_path(Path("state/deployment"), "napt-chrome")
state = load_deployment_state(path)
action = record_pending(
state,
version="130.0.0",
sha256="abc123...",
url="https://dl.google.com/chrome.msi",
)
if action:
save_deployment_state(state, path)
deployment_state_path
Returns the deployment state file path for a recipe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_dir
|
Path
|
Directory holding per-app deployment state files
(typically |
required |
recipe_id
|
str
|
Recipe identifier (from recipe's 'id' field). |
required |
Returns:
| Type | Description |
|---|---|
Path
|
Path to the app's deployment state file. |
Source code in napt/state/deployment.py
create_default_deployment_state
Creates an empty deployment state structure.
The identity fields (app_id, name) are stamped at save time —
app_id from the filename, name by whichever writer holds the
recipe configuration.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
Deployment state with no published release, no pending release, |
dict[str, Any]
|
no ring assignments, and no retained versions. |
Source code in napt/state/deployment.py
load_deployment_state
Loads deployment state for one app.
Returns a default empty structure when the file does not exist. Does not create the file — deployment state is only written when there is something to record.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_path
|
Path
|
Path to the app's deployment state file. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
Deployment state dictionary. |
Raises:
| Type | Description |
|---|---|
StateError
|
If the file exists but contains invalid JSON, its schemaVersion is missing or unsupported, or its declared app_id disagrees with the filename. Deployment state is authoritative, so a corrupted file is never silently replaced. |
Source code in napt/state/deployment.py
save_deployment_state
Saves deployment state for one app deterministically.
Creates parent directories if needed. Stamps the schema version and
the app_id (from the filename, which is the identity). Output is
byte-identical for logically identical state: keys follow reading
order, indentation is fixed at 2 spaces, rings are sorted by name,
and no timestamps or run-specific values are written.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
dict[str, Any]
|
Deployment state dictionary to save. |
required |
state_path
|
Path
|
Path to the app's deployment state file. |
required |
Raises:
| Type | Description |
|---|---|
OSError
|
If the file cannot be written due to permissions. |
Source code in napt/state/deployment.py
record_pending
Records a discovered release as the pending publication candidate.
The pending slot holds exactly one candidate and the newest discovery wins: a release that differs from both the published release and the current pending candidate replaces the pending candidate. Identity is the SHA-256 hash, not the version string, so a vendor re-release of the same version with a different binary is treated as new.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
dict[str, Any]
|
Deployment state dictionary to update in place. |
required |
version
|
str
|
Discovered version string. |
required |
sha256
|
str
|
SHA-256 hash of the discovered installer. |
required |
url
|
str
|
Download URL of the discovered installer. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
A string naming the change made ("recorded" for a first candidate, "replaced" when a candidate was overwritten, "cleared" when the vendor serves the already-published release), or None when the state did not change. |
Source code in napt/state/deployment.py
record_published
record_published(
state: dict[str, Any],
version: str,
sha256: str,
intune_app_id: str | None,
intune_update_app_id: str | None,
) -> None
Records a successful publication as the published release.
Replaces the published section and clears the pending slot when
the pending candidate is the release that was just published. A
pending candidate with a different hash (a newer discovery) is left
in place.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
dict[str, Any]
|
Deployment state dictionary to update in place. |
required |
version
|
str
|
Published version string. |
required |
sha256
|
str
|
SHA-256 hash of the published release's installer. |
required |
intune_app_id
|
str | None
|
Graph API object ID of the install entry, or None when build_types is "update_only". |
required |
intune_update_app_id
|
str | None
|
Graph API object ID of the update entry, or None when build_types is "app_only". |
required |
Source code in napt/state/deployment.py
summarize_deployment_states
Summarizes all per-app deployment state files in a directory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
deployment_dir
|
Path
|
Directory holding per-app deployment state files. |
required |
Returns:
| Type | Description |
|---|---|
list[dict[str, Any]]
|
One summary dict per app, sorted by app id, each with the app id, published version, pending version, and a ring-to-version map. Empty when the directory does not exist or holds no state. |
Raises:
| Type | Description |
|---|---|
StateError
|
On a corrupted deployment state file. |