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/build/packager.py ADDED
@@ -0,0 +1,315 @@
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
+ """.intunewin package generation for NAPT.
16
+
17
+ This module handles creating .intunewin packages from built PSADT directories
18
+ using Microsoft's IntuneWinAppUtil.exe tool.
19
+
20
+ Design Principles:
21
+ - IntuneWinAppUtil.exe is cached globally (not per-build)
22
+ - Package output is named by IntuneWinAppUtil.exe: Invoke-AppDeployToolkit.intunewin
23
+ - Build directory can optionally be cleaned after packaging
24
+ - Tool is downloaded from Microsoft's official GitHub repository
25
+
26
+ Example:
27
+ Basic usage:
28
+ ```python
29
+ from pathlib import Path
30
+ from napt.build.packager import create_intunewin
31
+
32
+ result = create_intunewin(
33
+ build_dir=Path("builds/napt-chrome/141.0.7390.123"),
34
+ output_dir=Path("packages")
35
+ )
36
+
37
+ print(f"Package: {result.package_path}")
38
+ ```
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from pathlib import Path
44
+ import shutil
45
+ import subprocess
46
+
47
+ import requests
48
+
49
+ from napt.exceptions import ConfigError, NetworkError, PackagingError
50
+ from napt.results import PackageResult
51
+
52
+ # TODO: Add version tracking for IntuneWinAppUtil.exe
53
+ # Currently downloads from master branch (always latest), with no version tracking.
54
+ # Future enhancements:
55
+ # - Track tool version in cache metadata
56
+ # - Allow pinning to specific commit/release
57
+ # - Auto-detect when tool updates are available
58
+ # - Optional: Add config setting for tool version/source
59
+ INTUNEWIN_TOOL_URL = (
60
+ "https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool"
61
+ "/raw/master/IntuneWinAppUtil.exe"
62
+ )
63
+
64
+
65
+ def _verify_build_structure(build_dir: Path) -> None:
66
+ """Verify that the build directory has a valid PSADT structure.
67
+
68
+ Args:
69
+ build_dir: Build directory to verify.
70
+
71
+ Raises:
72
+ ValueError: If required files/directories are missing.
73
+ """
74
+ required = [
75
+ "PSAppDeployToolkit",
76
+ "Files",
77
+ "Invoke-AppDeployToolkit.ps1",
78
+ "Invoke-AppDeployToolkit.exe",
79
+ ]
80
+
81
+ missing = []
82
+ for item in required:
83
+ if not (build_dir / item).exists():
84
+ missing.append(item)
85
+
86
+ if missing:
87
+ raise ConfigError(
88
+ f"Invalid PSADT build directory: {build_dir}\n"
89
+ f"Missing: {', '.join(missing)}"
90
+ )
91
+
92
+
93
+ def _get_intunewin_tool(cache_dir: Path) -> Path:
94
+ """Download and cache IntuneWinAppUtil.exe.
95
+
96
+ Args:
97
+ cache_dir: Directory to cache the tool.
98
+
99
+ Returns:
100
+ Path to the IntuneWinAppUtil.exe tool.
101
+
102
+ Raises:
103
+ NetworkError: If download fails.
104
+ """
105
+ from napt.logging import get_global_logger
106
+
107
+ logger = get_global_logger()
108
+ tool_path = cache_dir / "IntuneWinAppUtil.exe"
109
+
110
+ if tool_path.exists():
111
+ logger.verbose("PACKAGE", f"Using cached IntuneWinAppUtil: {tool_path}")
112
+ return tool_path
113
+
114
+ logger.verbose("PACKAGE", "Downloading IntuneWinAppUtil.exe...")
115
+
116
+ # Download the tool
117
+ try:
118
+ response = requests.get(INTUNEWIN_TOOL_URL, timeout=60)
119
+ response.raise_for_status()
120
+ except requests.RequestException as err:
121
+ raise NetworkError(f"Failed to download IntuneWinAppUtil.exe: {err}") from err
122
+
123
+ # Save to cache
124
+ cache_dir.mkdir(parents=True, exist_ok=True)
125
+ tool_path.write_bytes(response.content)
126
+
127
+ logger.verbose("PACKAGE", f"[OK] IntuneWinAppUtil.exe cached: {tool_path}")
128
+
129
+ return tool_path
130
+
131
+
132
+ def _execute_packaging(
133
+ tool_path: Path,
134
+ source_dir: Path,
135
+ setup_file: str,
136
+ output_dir: Path,
137
+ ) -> Path:
138
+ """Execute IntuneWinAppUtil.exe to create .intunewin package.
139
+
140
+ Args:
141
+ tool_path: Path to IntuneWinAppUtil.exe.
142
+ source_dir: Source directory (build directory).
143
+ setup_file: Name of the setup file (e.g., "Invoke-AppDeployToolkit.exe").
144
+ output_dir: Output directory for .intunewin file.
145
+
146
+ Returns:
147
+ Path to the created .intunewin file.
148
+
149
+ Raises:
150
+ PackagingError: If packaging fails.
151
+ """
152
+ from napt.logging import get_global_logger
153
+
154
+ logger = get_global_logger()
155
+ output_dir.mkdir(parents=True, exist_ok=True)
156
+
157
+ # Build command
158
+ # IntuneWinAppUtil.exe -c <source> -s <setup file> -o <output> -q
159
+ cmd = [
160
+ str(tool_path),
161
+ "-c",
162
+ str(source_dir),
163
+ "-s",
164
+ setup_file,
165
+ "-o",
166
+ str(output_dir),
167
+ "-q", # Quiet mode
168
+ ]
169
+
170
+ logger.verbose("PACKAGE", f"Running: {' '.join(cmd)}")
171
+
172
+ try:
173
+ result = subprocess.run(
174
+ cmd,
175
+ capture_output=True,
176
+ text=True,
177
+ check=True,
178
+ timeout=300,
179
+ )
180
+
181
+ if result.stdout:
182
+ for line in result.stdout.strip().split("\n"):
183
+ logger.verbose("PACKAGE", f" {line}")
184
+
185
+ except subprocess.CalledProcessError as err:
186
+ error_msg = f"IntuneWinAppUtil.exe failed (exit code {err.returncode})"
187
+ if err.stderr:
188
+ error_msg += f"\n{err.stderr}"
189
+ raise PackagingError(error_msg) from err
190
+ except subprocess.TimeoutExpired as err:
191
+ raise PackagingError(
192
+ f"IntuneWinAppUtil.exe timed out after {err.timeout}s"
193
+ ) from err
194
+
195
+ # Find the generated .intunewin file
196
+ intunewin_files = list(output_dir.glob("*.intunewin"))
197
+
198
+ if not intunewin_files:
199
+ raise PackagingError(
200
+ f"IntuneWinAppUtil.exe completed but no .intunewin file found in {output_dir}"
201
+ )
202
+
203
+ # Return the most recently created file
204
+ intunewin_path = max(intunewin_files, key=lambda p: p.stat().st_mtime)
205
+ logger.verbose("PACKAGE", f"[OK] Created: {intunewin_path.name}")
206
+
207
+ return intunewin_path
208
+
209
+
210
+ def create_intunewin(
211
+ build_dir: Path,
212
+ output_dir: Path | None = None,
213
+ clean_source: bool = False,
214
+ ) -> PackageResult:
215
+ """Create a .intunewin package from a PSADT build directory.
216
+
217
+ Uses Microsoft's IntuneWinAppUtil.exe tool to package a PSADT build
218
+ directory into a .intunewin file suitable for Intune deployment.
219
+
220
+ Args:
221
+ build_dir: Path to the built PSADT package directory.
222
+ output_dir: Directory for the .intunewin output.
223
+ Default: packages/{app_id}/
224
+ clean_source: If True, remove the build directory
225
+ after packaging. Default is False.
226
+
227
+ Returns:
228
+ Package metadata including .intunewin path, app ID, and version.
229
+
230
+ Raises:
231
+ ConfigError: If build directory structure is invalid.
232
+ PackagingError: If packaging fails.
233
+ NetworkError: If IntuneWinAppUtil.exe download fails.
234
+
235
+ Example:
236
+ Basic packaging:
237
+ ```python
238
+ result = create_intunewin(
239
+ build_dir=Path("builds/napt-chrome/141.0.7390.123")
240
+ )
241
+ print(result.package_path)
242
+ # packages/napt-chrome/Invoke-AppDeployToolkit.intunewin
243
+ ```
244
+
245
+ With cleanup:
246
+ ```python
247
+ result = create_intunewin(
248
+ build_dir=Path("builds/napt-chrome/141.0.7390.123"),
249
+ clean_source=True
250
+ )
251
+ # Build directory is removed after packaging
252
+ ```
253
+
254
+ Note:
255
+ Requires build directory from 'napt build' command. IntuneWinAppUtil.exe
256
+ is downloaded and cached on first use. Setup file is always
257
+ "Invoke-AppDeployToolkit.exe". Output file is named by IntuneWinAppUtil.exe:
258
+ packages/{app_id}/Invoke-AppDeployToolkit.intunewin
259
+ """
260
+ from napt.logging import get_global_logger
261
+
262
+ logger = get_global_logger()
263
+
264
+ build_dir = build_dir.resolve()
265
+
266
+ if not build_dir.exists():
267
+ raise PackagingError(f"Build directory not found: {build_dir}")
268
+
269
+ # Extract app_id and version from directory structure (app_id/version/)
270
+ version = build_dir.name
271
+ app_id = build_dir.parent.name
272
+
273
+ logger.verbose("PACKAGE", f"Packaging {app_id} v{version}")
274
+
275
+ # Verify build structure
276
+ logger.step(1, 4, "Verifying build structure...")
277
+ _verify_build_structure(build_dir)
278
+
279
+ # Determine output directory
280
+ if output_dir is None:
281
+ output_dir = Path("packages") / app_id
282
+
283
+ output_dir = output_dir.resolve()
284
+
285
+ # Get IntuneWinAppUtil tool
286
+ logger.step(2, 4, "Getting IntuneWinAppUtil tool...")
287
+ tool_cache = Path("cache/tools")
288
+ tool_path = _get_intunewin_tool(tool_cache)
289
+
290
+ # Create .intunewin package
291
+ logger.step(3, 4, "Creating .intunewin package...")
292
+ package_path = _execute_packaging(
293
+ tool_path,
294
+ build_dir,
295
+ "Invoke-AppDeployToolkit.exe",
296
+ output_dir,
297
+ )
298
+
299
+ # Optionally clean source
300
+ if clean_source:
301
+ logger.step(4, 4, "Cleaning source build directory...")
302
+ shutil.rmtree(build_dir)
303
+ logger.verbose("PACKAGE", f"[OK] Removed build directory: {build_dir}")
304
+ else:
305
+ logger.step(4, 4, "Package complete")
306
+
307
+ logger.verbose("PACKAGE", f"[OK] Package created: {package_path}")
308
+
309
+ return PackageResult(
310
+ build_dir=build_dir,
311
+ package_path=package_path,
312
+ app_id=app_id,
313
+ version=version,
314
+ status="success",
315
+ )
napt/build/template.py ADDED
@@ -0,0 +1,301 @@
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
+ """Invoke-AppDeployToolkit.ps1 template generation for NAPT.
16
+
17
+ This module handles generating the Invoke-AppDeployToolkit.ps1 script by
18
+ reading PSADT's template, substituting configuration values, and inserting
19
+ recipe-specific install/uninstall code.
20
+
21
+ Design Principles:
22
+ - PSADT template remains pristine in cache
23
+ - Generate script by substitution, not modification
24
+ - Preserve PSADT's structure and comments
25
+ - Support dynamic values (AppScriptDate, discovered version)
26
+ - Merge org defaults with recipe overrides
27
+
28
+ Example:
29
+ Basic usage:
30
+ ```python
31
+ from pathlib import Path
32
+ from napt.build.template import generate_invoke_script
33
+
34
+ script = generate_invoke_script(
35
+ template_path=Path("cache/psadt/4.1.7/Invoke-AppDeployToolkit.ps1"),
36
+ config=recipe_config,
37
+ version="141.0.7390.123",
38
+ psadt_version="4.1.7"
39
+ )
40
+
41
+ Path("builds/app/version/Invoke-AppDeployToolkit.ps1").write_text(script)
42
+ ```
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from datetime import date
48
+ from pathlib import Path
49
+ import re
50
+ from typing import Any
51
+
52
+ from napt.exceptions import PackagingError
53
+
54
+
55
+ def _format_powershell_value(value: Any) -> str:
56
+ """Format a Python value as a PowerShell literal.
57
+
58
+ Args:
59
+ value: Python value to convert.
60
+
61
+ Returns:
62
+ PowerShell literal representation.
63
+
64
+ Example:
65
+ Format values for PowerShell:
66
+ ```python
67
+ _format_powershell_value("hello") # Returns: "'hello'"
68
+ _format_powershell_value(True) # Returns: '$true'
69
+ _format_powershell_value([0, 1, 2]) # Returns: '@(0, 1, 2)'
70
+ ```
71
+ """
72
+ if isinstance(value, bool):
73
+ return "$true" if value else "$false"
74
+ elif isinstance(value, str):
75
+ # Escape single quotes in strings
76
+ escaped = value.replace("'", "''")
77
+ return f"'{escaped}'"
78
+ elif isinstance(value, (int, float)):
79
+ return str(value)
80
+ elif isinstance(value, list):
81
+ # Format as PowerShell array
82
+ items = [_format_powershell_value(item) for item in value]
83
+ return f"@({', '.join(items)})"
84
+ elif value is None or value == "":
85
+ return "''"
86
+ else:
87
+ # Fallback: convert to string and quote
88
+ return f"'{str(value)}'"
89
+
90
+
91
+ def _build_adtsession_vars(
92
+ config: dict[str, Any], version: str, psadt_version: str
93
+ ) -> dict[str, Any]:
94
+ """Build the $adtSession hashtable variables from configuration.
95
+
96
+ Merges organization defaults with recipe-specific overrides.
97
+
98
+ Args:
99
+ config: Merged configuration (org + vendor + recipe).
100
+ version: Discovered application version.
101
+ psadt_version: PSADT version being used.
102
+
103
+ Returns:
104
+ Dictionary of variable name -> value mappings.
105
+
106
+ Note:
107
+ Organization defaults come from config['defaults']['psadt']['app_vars'].
108
+ Recipe overrides come from config['app']['psadt']['app_vars'].
109
+ Special handling for ${discovered_version} placeholder.
110
+ Auto-generates AppScriptDate if not set.
111
+ """
112
+ app = config["app"]
113
+
114
+ # Get base variables from org defaults
115
+ org_defaults = config.get("defaults", {}).get("psadt", {}).get("app_vars", {})
116
+
117
+ # Get recipe overrides
118
+ recipe_overrides = app.get("psadt", {}).get("app_vars", {})
119
+
120
+ # Merge (recipe overrides org)
121
+ merged_vars = {**org_defaults, **recipe_overrides}
122
+
123
+ # Replace ${discovered_version} placeholder
124
+ for key, value in merged_vars.items():
125
+ if isinstance(value, str) and "${discovered_version}" in value:
126
+ merged_vars[key] = value.replace("${discovered_version}", version)
127
+
128
+ # Add auto-generated fields
129
+ merged_vars.setdefault("AppScriptDate", date.today().strftime("%Y-%m-%d"))
130
+ merged_vars["DeployAppScriptVersion"] = psadt_version
131
+
132
+ # Add vendor if available
133
+ vendor = config.get("vendor") or app.get("vendor", "")
134
+ if vendor:
135
+ merged_vars.setdefault("AppVendor", vendor)
136
+
137
+ return merged_vars
138
+
139
+
140
+ def _replace_session_block(template: str, vars_dict: dict[str, Any]) -> str:
141
+ """Replace the $adtSession = @{...} block in the template.
142
+
143
+ Finds the hashtable initialization and replaces it with values from
144
+ vars_dict.
145
+
146
+ Args:
147
+ template: PSADT template script text.
148
+ vars_dict: Variable name -> value mappings.
149
+
150
+ Returns:
151
+ Script with replaced $adtSession block.
152
+
153
+ Raises:
154
+ RuntimeError: If $adtSession block cannot be found in template.
155
+ """
156
+ # Find the $adtSession = @{ ... } block
157
+ # Pattern matches from $adtSession = @{ to the closing }
158
+ pattern = r"(\$adtSession = @\{)(.*?)(\n\})"
159
+
160
+ match = re.search(pattern, template, re.DOTALL)
161
+ if not match:
162
+ raise PackagingError(
163
+ "Could not find $adtSession hashtable in PSADT template. "
164
+ "Template may be from an unsupported PSADT version."
165
+ )
166
+
167
+ # Build replacement hashtable
168
+ lines = []
169
+ for key, value in vars_dict.items():
170
+ ps_value = _format_powershell_value(value)
171
+ lines.append(f" {key} = {ps_value}")
172
+
173
+ replacement = "$adtSession = @{\n" + "\n".join(lines) + "\n}"
174
+
175
+ # Replace in template
176
+ result = re.sub(pattern, replacement, template, flags=re.DOTALL)
177
+
178
+ return result
179
+
180
+
181
+ def _insert_recipe_code(
182
+ script: str, install_code: str | None, uninstall_code: str | None
183
+ ) -> str:
184
+ """Insert recipe install/uninstall code at marker positions.
185
+
186
+ Args:
187
+ script: Generated script with placeholders.
188
+ install_code: PowerShell code for installation.
189
+ uninstall_code: PowerShell code for uninstallation.
190
+
191
+ Returns:
192
+ Script with recipe code inserted.
193
+
194
+ Note:
195
+ Replaces these PSADT markers:
196
+ - "## <Perform Installation tasks here>"
197
+ - "## <Perform Uninstallation tasks here>"
198
+ """
199
+ if install_code:
200
+ # Ensure proper indentation (4 spaces to match PSADT style)
201
+ indented_install = "\n".join(
202
+ " " + line if line.strip() else line
203
+ for line in install_code.strip().split("\n")
204
+ )
205
+
206
+ script = script.replace(
207
+ " ## <Perform Installation tasks here>",
208
+ f" ## <Perform Installation tasks here>\n{indented_install}",
209
+ )
210
+
211
+ if uninstall_code:
212
+ # Ensure proper indentation
213
+ indented_uninstall = "\n".join(
214
+ " " + line if line.strip() else line
215
+ for line in uninstall_code.strip().split("\n")
216
+ )
217
+
218
+ script = script.replace(
219
+ " ## <Perform Uninstallation tasks here>",
220
+ f" ## <Perform Uninstallation tasks here>\n{indented_uninstall}",
221
+ )
222
+
223
+ return script
224
+
225
+
226
+ def generate_invoke_script(
227
+ template_path: Path,
228
+ config: dict[str, Any],
229
+ version: str,
230
+ psadt_version: str,
231
+ ) -> str:
232
+ """Generate Invoke-AppDeployToolkit.ps1 from PSADT template and config.
233
+
234
+ Reads the PSADT template, replaces the $adtSession hashtable with
235
+ values from the configuration, and inserts recipe-specific install/
236
+ uninstall code.
237
+
238
+ Args:
239
+ template_path: Path to PSADT's Invoke-AppDeployToolkit.ps1 template.
240
+ config: Merged configuration (org + vendor + recipe).
241
+ version: Application version (from filesystem).
242
+ psadt_version: PSADT version being used.
243
+
244
+ Returns:
245
+ Generated PowerShell script text.
246
+
247
+ Raises:
248
+ PackagingError: If template doesn't exist or template parsing fails.
249
+
250
+ Example:
251
+ Generate deployment script from template:
252
+ ```python
253
+ from pathlib import Path
254
+
255
+ script = generate_invoke_script(
256
+ Path("cache/psadt/4.1.7/Invoke-AppDeployToolkit.ps1"),
257
+ config,
258
+ "141.0.7390.123",
259
+ "4.1.7"
260
+ )
261
+ ```
262
+ """
263
+ from napt.logging import get_global_logger
264
+
265
+ logger = get_global_logger()
266
+ if not template_path.exists():
267
+ raise PackagingError(f"PSADT template not found: {template_path}")
268
+
269
+ logger.verbose("BUILD", f"Reading PSADT template: {template_path.name}")
270
+
271
+ # Read template
272
+ template = template_path.read_text(encoding="utf-8")
273
+
274
+ # Build $adtSession variables
275
+ logger.verbose("BUILD", "Building $adtSession variables...")
276
+ session_vars = _build_adtsession_vars(config, version, psadt_version)
277
+
278
+ logger.debug("BUILD", "--- $adtSession Variables ---")
279
+ for key, value in session_vars.items():
280
+ logger.debug("BUILD", f" {key} = {value}")
281
+
282
+ # Replace $adtSession block
283
+ script = _replace_session_block(template, session_vars)
284
+ logger.verbose("BUILD", "[OK] Replaced $adtSession hashtable")
285
+
286
+ # Insert recipe code
287
+ app = config["app"]
288
+ psadt_config = app.get("psadt", {})
289
+ install_code = psadt_config.get("install")
290
+ uninstall_code = psadt_config.get("uninstall")
291
+
292
+ if install_code:
293
+ logger.verbose("BUILD", "Inserting install code from recipe")
294
+ if uninstall_code:
295
+ logger.verbose("BUILD", "Inserting uninstall code from recipe")
296
+
297
+ script = _insert_recipe_code(script, install_code, uninstall_code)
298
+
299
+ logger.verbose("BUILD", "[OK] Script generation complete")
300
+
301
+ return script