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/cli.py ADDED
@@ -0,0 +1,602 @@
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
+ """Command-line interface for NAPT.
16
+
17
+ This module provides the main CLI entry point for the napt tool, offering
18
+ commands for recipe validation, package building, and deployment management.
19
+
20
+ Commands:
21
+
22
+ validate: Validate recipe syntax and configuration
23
+ discover: Discover latest version and download installer
24
+ build: Build PSADT package from recipe
25
+ package: Create .intunewin package for Intune
26
+
27
+ Example:
28
+ Validate recipe syntax:
29
+ ```bash
30
+ $ napt validate recipes/Google/chrome.yaml
31
+ ```
32
+
33
+ Discover latest version:
34
+ ```bash
35
+ $ napt discover recipes/Google/chrome.yaml
36
+ ```
37
+
38
+ Build PSADT package:
39
+ ```bash
40
+ $ napt build recipes/Google/chrome.yaml
41
+ ```
42
+
43
+ Create .intunewin package:
44
+ ```bash
45
+ $ napt package builds/napt-chrome/142.0.7444.60/
46
+ ```
47
+
48
+ Enable verbose output:
49
+ ```bash
50
+ $ napt discover recipes/Google/chrome.yaml --verbose
51
+ ```
52
+
53
+ Enable debug output:
54
+ ```bash
55
+ $ napt discover recipes/Google/chrome.yaml --debug
56
+ ```
57
+
58
+ Exit Codes:
59
+
60
+ - 0: Success
61
+ - 1: Error (configuration, download, or validation failure)
62
+
63
+ Note:
64
+ The CLI uses argparse for command parsing (stdlib, zero dependencies).
65
+ Commands are registered with subparsers for clean organization.
66
+ Each command has its own handler function (cmd_<command>).
67
+ Verbose mode shows full tracebacks on errors for debugging.
68
+ Debug mode implies verbose mode and shows detailed configuration dumps.
69
+
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import argparse
75
+ from importlib.metadata import version
76
+ from pathlib import Path
77
+ import sys
78
+
79
+ from napt.build import build_package, create_intunewin
80
+ from napt.core import discover_recipe
81
+ from napt.exceptions import (
82
+ ConfigError,
83
+ NAPTError,
84
+ NetworkError,
85
+ PackagingError,
86
+ )
87
+ from napt.logging import get_logger, set_global_logger
88
+ from napt.validation import validate_recipe
89
+
90
+
91
+ def cmd_validate(args: argparse.Namespace) -> int:
92
+ """Handler for 'napt validate' command.
93
+
94
+ Validates recipe syntax and configuration without downloading files or
95
+ making network calls. This is useful for quick feedback during recipe
96
+ development and for CI/CD pre-checks.
97
+
98
+ Args:
99
+ args: Parsed command-line arguments containing
100
+ recipe path and verbose flag.
101
+
102
+ Returns:
103
+ Exit code (0 for valid recipe, 1 for invalid).
104
+
105
+ Note:
106
+ Prints validation results, errors, and warnings to stdout.
107
+
108
+ """
109
+ # Configure global logger
110
+ logger = get_logger(verbose=args.verbose, debug=args.debug)
111
+ set_global_logger(logger)
112
+
113
+ recipe_path = Path(args.recipe).resolve()
114
+
115
+ print(f"Validating recipe: {recipe_path}")
116
+ print()
117
+
118
+ # Validate the recipe
119
+ result = validate_recipe(recipe_path)
120
+
121
+ # Display results
122
+ print("=" * 70)
123
+ print("VALIDATION RESULTS")
124
+ print("=" * 70)
125
+ print(f"Recipe: {result.recipe_path}")
126
+ print(f"Status: {result.status.upper()}")
127
+ print(f"App Count: {result.app_count}")
128
+ print()
129
+
130
+ # Show warnings if any
131
+ if result.warnings:
132
+ print(f"Warnings ({len(result.warnings)}):")
133
+ for warning in result.warnings:
134
+ print(f" [WARNING] {warning}")
135
+ print()
136
+
137
+ # Show errors if any
138
+ if result.errors:
139
+ print(f"Errors ({len(result.errors)}):")
140
+ for error in result.errors:
141
+ print(f" [X] {error}")
142
+ print()
143
+
144
+ print("=" * 70)
145
+
146
+ if result.status == "valid":
147
+ print()
148
+ print("[SUCCESS] Recipe is valid!")
149
+ return 0
150
+ else:
151
+ print()
152
+ print(f"[FAILED] Recipe validation failed with {len(result.errors)} error(s).")
153
+ return 1
154
+
155
+
156
+ def cmd_discover(args: argparse.Namespace) -> int:
157
+ """Handler for 'napt discover' command.
158
+
159
+ Discovers the latest version of an application by querying the source
160
+ and downloading the installer. This command validates the recipe YAML,
161
+ uses the configured discovery strategy to find the latest version,
162
+ downloads the installer (or uses cached version via ETag), extracts
163
+ version information, and updates the state file with caching info.
164
+
165
+ Args:
166
+ args: Parsed command-line arguments containing
167
+ recipe path, output directory, state file path, and flags.
168
+
169
+ Returns:
170
+ Exit code (0 for success, 1 for failure).
171
+
172
+ Note:
173
+ Downloads installer file to output_dir (or uses cached version).
174
+ Updates state file with version and ETag information. Prints progress
175
+ and results to stdout. Prints errors with optional traceback if verbose/debug.
176
+
177
+ """
178
+ # Configure global logger
179
+ logger = get_logger(verbose=args.verbose, debug=args.debug)
180
+ set_global_logger(logger)
181
+
182
+ recipe_path = Path(args.recipe).resolve()
183
+ output_dir = Path(args.output_dir).resolve()
184
+
185
+ if not recipe_path.exists():
186
+ print(f"Error: Recipe file not found: {recipe_path}")
187
+ return 1
188
+
189
+ print(f"Discovering version for recipe: {recipe_path}")
190
+ print(f"Output directory: {output_dir}")
191
+ print()
192
+
193
+ try:
194
+ result = discover_recipe(
195
+ recipe_path,
196
+ output_dir,
197
+ state_file=args.state_file if not args.stateless else None,
198
+ stateless=args.stateless,
199
+ )
200
+ except (ConfigError, NetworkError, PackagingError) as err:
201
+ print(f"Error: {err}")
202
+ if args.verbose or args.debug:
203
+ import traceback
204
+
205
+ traceback.print_exc()
206
+ return 1
207
+ except NAPTError as err:
208
+ # Catch any other NAPT errors we might have missed
209
+ print(f"Error: {err}")
210
+ if args.verbose or args.debug:
211
+ import traceback
212
+
213
+ traceback.print_exc()
214
+ return 1
215
+
216
+ # Display results
217
+ print("=" * 70)
218
+ print("DISCOVERY RESULTS")
219
+ print("=" * 70)
220
+ print(f"App Name: {result.app_name}")
221
+ print(f"App ID: {result.app_id}")
222
+ print(f"Strategy: {result.strategy}")
223
+ print(f"Version: {result.version}")
224
+ print(f"Version Source: {result.version_source}")
225
+ print(f"File Path: {result.file_path}")
226
+ print(f"SHA-256: {result.sha256}")
227
+ print(f"Status: {result.status}")
228
+ print("=" * 70)
229
+ print()
230
+ print("[SUCCESS] Version discovered successfully!")
231
+
232
+ return 0
233
+
234
+
235
+ def cmd_build(args: argparse.Namespace) -> int:
236
+ """Handler for 'napt build' command.
237
+
238
+ Builds a PSADT package from a recipe and downloaded installer. This command
239
+ loads the recipe configuration, finds the downloaded installer, extracts
240
+ version from the installer file (filesystem is truth), downloads/caches
241
+ the specified PSADT release, creates build directory structure, copies
242
+ PSADT files pristine from cache, generates Invoke-AppDeployToolkit.ps1
243
+ with recipe values, copies installer to Files/ directory, and applies
244
+ custom branding.
245
+
246
+ Args:
247
+ args: Parsed command-line arguments containing
248
+ recipe path, downloads directory, output directory, and flags.
249
+
250
+ Returns:
251
+ Exit code (0 for success, 1 for failure).
252
+
253
+ Note:
254
+ Creates build directory structure. Downloads PSADT release if not cached.
255
+ Generates Invoke-AppDeployToolkit.ps1. Copies files to build directory.
256
+ Prints progress and results to stdout.
257
+
258
+ """
259
+ # Configure global logger
260
+ logger = get_logger(verbose=args.verbose, debug=args.debug)
261
+ set_global_logger(logger)
262
+
263
+ recipe_path = Path(args.recipe).resolve()
264
+ downloads_dir = Path(args.downloads_dir).resolve()
265
+ output_dir = Path(args.output_dir) if args.output_dir else None
266
+
267
+ if not recipe_path.exists():
268
+ print(f"Error: Recipe file not found: {recipe_path}")
269
+ return 1
270
+
271
+ if not downloads_dir.exists():
272
+ print(f"Error: Downloads directory not found: {downloads_dir}")
273
+ print("Run 'napt discover' first to download the installer.")
274
+ return 1
275
+
276
+ print(f"Building PSADT package for recipe: {recipe_path}")
277
+ print(f"Downloads directory: {downloads_dir}")
278
+ if output_dir:
279
+ print(f"Output directory: {output_dir}")
280
+ print()
281
+
282
+ try:
283
+ result = build_package(
284
+ recipe_path,
285
+ downloads_dir=downloads_dir,
286
+ output_dir=output_dir,
287
+ )
288
+ except (ConfigError, NetworkError, PackagingError) as err:
289
+ print(f"Error: {err}")
290
+ if args.verbose or args.debug:
291
+ import traceback
292
+
293
+ traceback.print_exc()
294
+ return 1
295
+ except NAPTError as err:
296
+ # Catch any other NAPT errors we might have missed
297
+ print(f"Error: {err}")
298
+ if args.verbose or args.debug:
299
+ import traceback
300
+
301
+ traceback.print_exc()
302
+ return 1
303
+
304
+ # Display results
305
+ print("=" * 70)
306
+ print("BUILD RESULTS")
307
+ print("=" * 70)
308
+ print(f"App Name: {result.app_name}")
309
+ print(f"App ID: {result.app_id}")
310
+ print(f"Version: {result.version}")
311
+ print(f"PSADT Version: {result.psadt_version}")
312
+ print(f"Build Directory: {result.build_dir}")
313
+ print(f"Status: {result.status}")
314
+ print("=" * 70)
315
+ print()
316
+ print("[SUCCESS] PSADT package built successfully!")
317
+
318
+ return 0
319
+
320
+
321
+ def cmd_package(args: argparse.Namespace) -> int:
322
+ """Handler for 'napt package' command.
323
+
324
+ Creates a .intunewin package from a built PSADT directory. This command
325
+ verifies the build directory has valid PSADT structure, downloads/caches
326
+ IntuneWinAppUtil.exe if needed, runs IntuneWinAppUtil.exe to create
327
+ .intunewin package, and optionally cleans the source build directory
328
+ after packaging.
329
+
330
+ Args:
331
+ args: Parsed command-line arguments containing
332
+ build directory path, output directory, clean flag, and debug flags.
333
+
334
+ Returns:
335
+ Exit code (0 for success, 1 for failure).
336
+
337
+ Note:
338
+ Creates .intunewin file in output directory. Downloads IntuneWinAppUtil.exe
339
+ if not cached. Optionally removes build directory if --clean-source.
340
+ Prints progress and results to stdout.
341
+
342
+ """
343
+ # Configure global logger
344
+ logger = get_logger(verbose=args.verbose, debug=args.debug)
345
+ set_global_logger(logger)
346
+
347
+ build_dir = Path(args.build_dir).resolve()
348
+ output_dir = Path(args.output_dir) if args.output_dir else None
349
+
350
+ if not build_dir.exists():
351
+ print(f"Error: Build directory not found: {build_dir}")
352
+ return 1
353
+
354
+ print(f"Creating .intunewin package from: {build_dir}")
355
+ if output_dir:
356
+ print(f"Output directory: {output_dir}")
357
+ print()
358
+
359
+ try:
360
+ result = create_intunewin(
361
+ build_dir,
362
+ output_dir=output_dir,
363
+ clean_source=args.clean_source,
364
+ )
365
+ except (ConfigError, NetworkError, PackagingError) as err:
366
+ print(f"Error: {err}")
367
+ if args.verbose or args.debug:
368
+ import traceback
369
+
370
+ traceback.print_exc()
371
+ return 1
372
+ except NAPTError as err:
373
+ # Catch any other NAPT errors we might have missed
374
+ print(f"Error: {err}")
375
+ if args.verbose or args.debug:
376
+ import traceback
377
+
378
+ traceback.print_exc()
379
+ return 1
380
+
381
+ # Display results
382
+ print("=" * 70)
383
+ print("PACKAGE RESULTS")
384
+ print("=" * 70)
385
+ print(f"App ID: {result.app_id}")
386
+ print(f"Version: {result.version}")
387
+ print(f"Package Path: {result.package_path}")
388
+ if args.clean_source:
389
+ print(f"Build Directory: {result.build_dir} (removed)")
390
+ else:
391
+ print(f"Build Directory: {result.build_dir}")
392
+ print(f"Status: {result.status}")
393
+ print("=" * 70)
394
+ print()
395
+ print("[SUCCESS] .intunewin package created successfully!")
396
+
397
+ return 0
398
+
399
+
400
+ def main() -> None:
401
+ """Main entry point for the napt CLI.
402
+
403
+ This function is registered as the 'napt' console script in pyproject.toml.
404
+ """
405
+ parser = argparse.ArgumentParser(
406
+ prog="napt",
407
+ description="NAPT - Not a Pkg Tool for Windows/Intune packaging with PSADT",
408
+ formatter_class=argparse.RawDescriptionHelpFormatter,
409
+ )
410
+
411
+ parser.add_argument(
412
+ "--version",
413
+ action="version",
414
+ version=f"napt {version('napt')}",
415
+ )
416
+
417
+ subparsers = parser.add_subparsers(
418
+ dest="command",
419
+ help="Available commands",
420
+ required=True,
421
+ )
422
+
423
+ # 'validate' command
424
+ parser_validate = subparsers.add_parser(
425
+ "validate",
426
+ help="Validate recipe syntax and configuration (no downloads)",
427
+ description=(
428
+ "Check recipe YAML for syntax errors and configuration issues "
429
+ "without making network calls.\n\n"
430
+ "Examples:\n"
431
+ " napt validate recipes/Google/chrome.yaml\n"
432
+ " napt validate recipes/Google/chrome.yaml --verbose\n\n"
433
+ "See docs for more examples and workflows."
434
+ ),
435
+ formatter_class=argparse.RawDescriptionHelpFormatter,
436
+ )
437
+ parser_validate.add_argument(
438
+ "recipe",
439
+ help="Path to the recipe YAML file",
440
+ )
441
+ parser_validate.add_argument(
442
+ "-v",
443
+ "--verbose",
444
+ action="store_true",
445
+ help="Show validation progress and details",
446
+ )
447
+ parser_validate.add_argument(
448
+ "-d",
449
+ "--debug",
450
+ action="store_true",
451
+ help="Show detailed debugging output (implies --verbose)",
452
+ )
453
+ parser_validate.set_defaults(func=cmd_validate)
454
+
455
+ # 'discover' command
456
+ parser_discover = subparsers.add_parser(
457
+ "discover",
458
+ help="Discover latest version and download installer",
459
+ description=(
460
+ "Find the latest version using the configured discovery strategy "
461
+ "and download the installer.\n\n"
462
+ "Examples:\n"
463
+ " napt discover recipes/Google/chrome.yaml\n"
464
+ " napt discover recipes/Google/chrome.yaml --verbose\n"
465
+ " napt discover recipes/Google/chrome.yaml --stateless\n\n"
466
+ "See docs for more examples and workflows."
467
+ ),
468
+ formatter_class=argparse.RawDescriptionHelpFormatter,
469
+ )
470
+ parser_discover.add_argument(
471
+ "recipe",
472
+ help="Path to the recipe YAML file",
473
+ )
474
+ parser_discover.add_argument(
475
+ "--output-dir",
476
+ default="./downloads",
477
+ help="Directory to save downloaded files (default: ./downloads)",
478
+ )
479
+ parser_discover.add_argument(
480
+ "--state-file",
481
+ type=Path,
482
+ default=Path("state/versions.json"),
483
+ help=(
484
+ "State file for version tracking and ETag caching "
485
+ "(default: state/versions.json)"
486
+ ),
487
+ )
488
+ parser_discover.add_argument(
489
+ "--stateless",
490
+ action="store_true",
491
+ help="Disable state tracking (no caching, always download full files)",
492
+ )
493
+ parser_discover.add_argument(
494
+ "-v",
495
+ "--verbose",
496
+ action="store_true",
497
+ help="Show progress and high-level status updates",
498
+ )
499
+ parser_discover.add_argument(
500
+ "-d",
501
+ "--debug",
502
+ action="store_true",
503
+ help="Show detailed debugging output (implies --verbose)",
504
+ )
505
+ parser_discover.set_defaults(func=cmd_discover)
506
+
507
+ # 'build' command
508
+ parser_build = subparsers.add_parser(
509
+ "build",
510
+ help="Build PSADT package from recipe and installer",
511
+ description=(
512
+ "Create a PSADT deployment package from a recipe and "
513
+ "downloaded installer.\n\n"
514
+ "Examples:\n"
515
+ " napt build recipes/Google/chrome.yaml\n"
516
+ " napt build recipes/Google/chrome.yaml --verbose\n"
517
+ " napt build recipes/Google/chrome.yaml --output-dir ./builds\n\n"
518
+ "See docs for more examples and workflows."
519
+ ),
520
+ formatter_class=argparse.RawDescriptionHelpFormatter,
521
+ )
522
+ parser_build.add_argument(
523
+ "recipe",
524
+ help="Path to the recipe YAML file",
525
+ )
526
+ parser_build.add_argument(
527
+ "--downloads-dir",
528
+ default="./downloads",
529
+ help="Directory containing the downloaded installer (default: ./downloads)",
530
+ )
531
+ parser_build.add_argument(
532
+ "--output-dir",
533
+ default=None,
534
+ help="Base directory for build output (default: from config or ./builds)",
535
+ )
536
+ parser_build.add_argument(
537
+ "-v",
538
+ "--verbose",
539
+ action="store_true",
540
+ help="Show progress and high-level status updates",
541
+ )
542
+ parser_build.add_argument(
543
+ "-d",
544
+ "--debug",
545
+ action="store_true",
546
+ help="Show detailed debugging output (implies --verbose)",
547
+ )
548
+ parser_build.set_defaults(func=cmd_build)
549
+
550
+ # 'package' command
551
+ parser_package = subparsers.add_parser(
552
+ "package",
553
+ help="Create .intunewin package from PSADT build directory",
554
+ description=(
555
+ "Package a built PSADT directory into a .intunewin file "
556
+ "for Intune deployment.\n\n"
557
+ "Examples:\n"
558
+ " napt package builds/napt-chrome/142.0.7444.60/\n"
559
+ " napt package builds/napt-chrome/142.0.7444.60/ --clean-source\n"
560
+ " napt package builds/napt-chrome/142.0.7444.60/ --verbose\n\n"
561
+ "See docs for more examples and workflows."
562
+ ),
563
+ formatter_class=argparse.RawDescriptionHelpFormatter,
564
+ )
565
+ parser_package.add_argument(
566
+ "build_dir",
567
+ help="Path to the built PSADT package directory",
568
+ )
569
+ parser_package.add_argument(
570
+ "--output-dir",
571
+ default=None,
572
+ help="Directory for .intunewin output (default: packages/{app_id}/)",
573
+ )
574
+ parser_package.add_argument(
575
+ "--clean-source",
576
+ action="store_true",
577
+ help="Remove the build directory after packaging",
578
+ )
579
+ parser_package.add_argument(
580
+ "-v",
581
+ "--verbose",
582
+ action="store_true",
583
+ help="Show progress and high-level status updates",
584
+ )
585
+ parser_package.add_argument(
586
+ "-d",
587
+ "--debug",
588
+ action="store_true",
589
+ help="Show detailed debugging output (implies --verbose)",
590
+ )
591
+ parser_package.set_defaults(func=cmd_package)
592
+
593
+ # Parse and dispatch
594
+ args = parser.parse_args()
595
+
596
+ # Call the appropriate command handler
597
+ exit_code = args.func(args)
598
+ sys.exit(exit_code)
599
+
600
+
601
+ if __name__ == "__main__":
602
+ main()
@@ -0,0 +1,42 @@
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
+ """Configuration loading and management for NAPT.
16
+
17
+ This module provides tools for loading, merging, and validating YAML-based
18
+ configuration files with a layered approach:
19
+
20
+ - Organization-wide defaults (defaults/org.yaml)
21
+ - Vendor-specific defaults (defaults/vendors/<Vendor>.yaml)
22
+ - Recipe-specific configuration (recipes/<Vendor>/<app>.yaml)
23
+
24
+ The loader performs deep merging where dicts are merged recursively and
25
+ lists/scalars are replaced (last wins). Relative paths are resolved against
26
+ the recipe file location for relocatability.
27
+
28
+ Example:
29
+ Basic usage:
30
+ ```python
31
+ from pathlib import Path
32
+ from napt.config import load_effective_config
33
+
34
+ config = load_effective_config(Path("recipes/Google/chrome.yaml"))
35
+ app = config.get("app")
36
+ print(app["name"]) # "Google Chrome"
37
+ ```
38
+ """
39
+
40
+ from .loader import load_effective_config
41
+
42
+ __all__ = ["load_effective_config"]