diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b9f18..83f2501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,35 @@ All notable changes to this project will be documented in this file. This projec ## [Unreleased] +Nothing yet. + +## [1.25.0] - 2026-06-21 + ### Added - New `format-autopkg-yaml-recipes` hook that tidies AutoPkg YAML recipes by reordering keys and normalizing spacing. Adapted from @grahampugh's [plist-yaml-plist](https://github.com/grahampugh/plist-yaml-plist). +- `check-munki-pkgsinfo` now validates the Munki 7.1 pkginfo keys `blocking_applications_manual_quit_only` (boolean) and `blocking_applications_quit_script` (string). +- `check-munki-pkgsinfo` now validates the type of `description_staged` and `display_name_staged` pkginfo keys. +- `check-munki-pkgsinfo` now validates the shebang of `blocking_applications_quit_script`, consistent with other pkginfo script fields. +- `check-munki-pkgsinfo` now warns when pkginfo keys removed in Munki 7 are present (`additional_startosinstall_options`, `copy_local`). +- `check-autopkg-recipes` includes URLDownloaderPython among the list of downloader processors. +- `check-autopkg-recipes` now validates `MinimumVersion` requirements for core AutoPkg processor arguments using a generated table from AutoPkg release history. +- `check-autopkg-recipes` now also validates deprecated and removed core processors dynamically from the generated table. +- `check-autopkg-recipes` now recognizes `intune`, `fleet`, `ws1`, and `jamfclirunner` as known recipe types in processor convention checks (strict mode), and groups `jss-upload` with `jss`. + +### Changed + +- `check-autopkg-recipes` now errors when encountering removed AutoPkg processors, and warns on deprecated processors. As of AutoPkg 3.0.0, CURLDownloader and CURLTextSearcher are removed. +- `check-munki-pkgsinfo` warning messages for removed `installer_type` and `uninstall_method` values now say "removed in Munki 7" instead of "deprecated". +- Simplified and minimized Python code in many hooks and tests. + +### Fixed + +- Fixed an operator precedence bug in `check-jamf-json-manifests` that caused spurious type-mismatch errors for `default` values in props without an explicit type. + +### Removed + +- Removed the "may be a duplicate import" check from `check-munki-pkgsinfo`, along with its `--warn-on-duplicate-imports` flag. ## [1.24.1] - 2026-04-12 @@ -478,7 +504,8 @@ All notable changes to this project will be documented in this file. This projec - Initial release -[Unreleased]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.24.1...HEAD +[Unreleased]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.25.0...HEAD +[1.25.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.24.1...v1.25.0 [1.24.1]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.24.0...v1.24.1 [1.24.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.23.0...v1.24.0 [1.23.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.22.0...v1.23.0 diff --git a/README.md b/README.md index e91fce9..caf4da7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ For any hook in this repo you wish to use, add the following to your pre-commit ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.24.1 + rev: v1.25.0 hooks: - id: check-plists # - id: ... @@ -122,13 +122,10 @@ After adding a hook to your pre-commit config, it's not a bad idea to run `pre-c (default: ".") - Choose to just warn if icons referenced in pkginfo files are missing (this will allow pre-commit checks to pass if no other issues exist): - `args: ['--warn-on-missing-icons]` + `args: ['--warn-on-missing-icons']` - Choose to just warn if installer/uninstaller items (`installer_item_location` or `uninstaller_item_location`) referenced in pkginfo files are missing (this will allow pre-commit checks to pass if no other issues exist): - `args: ['--warn-on-missing-installer-items]` - - - Choose to just warn if pkg/pkginfo files with __1 (or similar) suffixes are detected (this will allow pre-commit checks to pass if no other issues exist): - `args: ['--warn-on-duplicate-imports]` + `args: ['--warn-on-missing-installer-items']` - Add additional shebangs that are valid for your environment: `args: ['--valid-shebangs', '#!/bin/macadmin/python37', '#!/bin/macadmin/python42', '--']` @@ -151,7 +148,7 @@ When combining arguments that take lists (for example: `--required-keys`, `--cat ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.24.1 + rev: v1.25.0 hooks: - id: check-munki-pkgsinfo args: ['--catalogs', 'testing', 'stable', '--'] @@ -161,7 +158,7 @@ But if you also use the `--categories` argument, you would move the trailing `-- ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.24.1 + rev: v1.25.0 hooks: - id: check-munki-pkgsinfo args: ['--catalogs', 'testing', 'stable', '--categories', 'Design', 'Engineering', 'Web Browsers', '--'] @@ -173,7 +170,7 @@ If it looks better to your eye, feel free to use a multi-line list for long argu ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.24.1 + rev: v1.25.0 hooks: - id: check-munki-pkgsinfo args: [ diff --git a/RELEASING.md b/RELEASING.md index 9e04be2..7d0dabd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,6 +8,13 @@ Releases are largely automated via GitHub Actions. The workflow triggers when `s .venv/bin/python -m coverage run -m unittest discover -vs tests +1. Update the generated AutoPkg processor version table from a local AutoPkg checkout: + + .venv/bin/python scripts/generate_autopkg_processor_versions.py --autopkg-repo ../autopkg + .venv/bin/python -m coverage run -m unittest discover -vs tests + + Use `--full` when the generator logic changes, historical AutoPkg tags are added or corrected, or you need to rebuild from AutoPkg `0.1.0`. The default mode appends stable releases newer than the checked-in `last_walked_version`. + 1. Prepare CHANGELOG.md for release by moving `[Unreleased]` changes to a new version section: ## [Unreleased] diff --git a/pre_commit_macadmin_hooks/autopkg_processor_versions.py b/pre_commit_macadmin_hooks/autopkg_processor_versions.py new file mode 100644 index 0000000..14019be --- /dev/null +++ b/pre_commit_macadmin_hooks/autopkg_processor_versions.py @@ -0,0 +1,230 @@ +# This file is generated by scripts/generate_autopkg_processor_versions.py. +# Do not edit it by hand. + +PROC_VERSIONS = { + "AppDmgVersioner": { + "_introduced_": "0.1.0", + }, + "AppPkgCreator": { + "_introduced_": "1.0", + "bundleid": "1.1", + "force_pkg_build": "1.1", + "pkg_path": "1.1", + "version": "1.1", + "version_key": "2.4.1", + }, + "BrewCaskInfoProvider": { + "_introduced_": "0.2.5", + "_removed_": "2.9.0", + }, + "CURLDownloader": { + "_introduced_": "0.5.1", + "_deprecated_": "0.6.0", + "_removed_": "3.0.0", + }, + "CURLTextSearcher": { + "_introduced_": "0.5.1", + "_deprecated_": "0.6.0", + "_removed_": "3.0.0", + }, + "ChocolateyPackager": { + "_introduced_": "2.3", + }, + "CodeSignatureVerifier": { + "_introduced_": "0.3.1", + "DISABLE_CODE_SIGNATURE_VERIFICATION": "0.4.2", + "codesign_additional_arguments": "1.0.3", + "deep_verification": "1.0.3", + "requirement": "0.4.1", + "strict_verification": "1.0.3", + }, + "Copier": { + "_introduced_": "0.1.0", + "destination_path": "1.1", + "overwrite": "1.1", + }, + "DeprecationWarning": { + "_introduced_": "1.1", + }, + "DmgCreator": { + "_introduced_": "0.1.0", + "dmg_filesystem": "1.0.3", + "dmg_format": "0.3.0", + "dmg_megabytes": "0.3.0", + "dmg_zlib_level": "0.3.0", + }, + "DmgMounter": { + "_introduced_": "0.1.0", + }, + "EndOfCheckPhase": { + "_introduced_": "0.1.0", + }, + "FileCreator": { + "_introduced_": "0.1.0", + "file_mode": "1.1", + }, + "FileFinder": { + "_introduced_": "0.2.3", + }, + "FileMover": { + "_introduced_": "0.2.9", + }, + "FindAndReplace": { + "_introduced_": "2.7.6", + }, + "FlatPkgPacker": { + "_introduced_": "0.2.4", + "destination_pkg": "1.1", + }, + "FlatPkgUnpacker": { + "_introduced_": "0.1.0", + }, + "GitHubReleasesInfoProvider": { + "_introduced_": "0.5.0", + "CURL_PATH": "1.4", + "GITHUB_RELEASES_PER_PAGE": "2.9.0", + "GITHUB_TOKEN_PATH": "2.3", + "GITHUB_URL": "2.3", + "curl_opts": "1.4", + "latest_only": "2.7.1", + }, + "InstallFromDMG": { + "_introduced_": "0.4.0", + }, + "Installer": { + "_introduced_": "0.4.0", + }, + "MunkiCatalogBuilder": { + "_introduced_": "0.1.0", + "_deprecated_": "2.7.5", + }, + "MunkiImporter": { + "_introduced_": "0.1.0", + "MUNKILIB_DIR": "2.2", + "MUNKI_PKGINFO_FILE_EXTENSION": "1.1", + "MUNKI_REPO": "0.2.5", + "MUNKI_REPO_PLUGIN": "2.2", + "additional_makepkginfo_options": "1.1", + "extract_icon": "2.2", + "force_munki_repo_lib": "2.2", + "force_munkiimport": "1.1", + "metadata_additions": "1.1", + "munkiimport_appname": "1.1", + "munkiimport_pkgname": "1.1", + "pkginfo": "1.1", + "repo_subdirectory": "1.1", + "uninstaller_pkg_path": "1.1", + "version_comparison_key": "1.1", + }, + "MunkiInfoCreator": { + "_introduced_": "0.1.0", + }, + "MunkiInstallsItemsCreator": { + "_introduced_": "0.1.0", + "derive_minimum_os_version": "2.6", + "version_comparison_key": "0.3.0", + }, + "MunkiOptionalReceiptEditor": { + "_introduced_": "2.7", + }, + "MunkiPkginfoMerger": { + "_introduced_": "0.1.0", + }, + "MunkiSetDefaultCatalog": { + "_introduced_": "0.4.2", + }, + "PackageRequired": { + "_introduced_": "0.5.1", + }, + "PathDeleter": { + "_introduced_": "0.1.0", + }, + "PkgCopier": { + "_introduced_": "0.1.0", + }, + "PkgCreator": { + "_introduced_": "0.1.0", + "force_pkg_build": "0.3.0", + }, + "PkgExtractor": { + "_introduced_": "0.1.0", + "extract_root": "1.1", + }, + "PkgInfoCreator": { + "_introduced_": "0.1.0", + }, + "PkgPayloadUnpacker": { + "_introduced_": "0.1.0", + }, + "PkgRootCreator": { + "_introduced_": "0.1.0", + }, + "PlistEditor": { + "_introduced_": "0.1.0", + "output_plist_path": "1.1", + "plist_data": "1.1", + }, + "PlistReader": { + "_introduced_": "0.2.5", + "plist_keys": "0.4.0", + }, + "SignToolVerifier": { + "_introduced_": "2.3", + }, + "SparkleUpdateInfoProvider": { + "_introduced_": "0.1.0", + "CURL_PATH": "0.6.0", + "PKG": "0.5.0", + "curl_opts": "1.4", + "update_channel": "2.7.1", + "urlencode_path_component": "1.1", + }, + "StopProcessingIf": { + "_introduced_": "0.1.0", + }, + "Symlinker": { + "_introduced_": "0.1.0", + }, + "URLDownloader": { + "_introduced_": "0.1.0", + "CHECK_FILESIZE_ONLY": "1.1", + "CURL_PATH": "1.1", + "PKG": "1.1", + "curl_opts": "1.1", + "download_dir": "1.1", + "filename": "1.1", + "prefetch_filename": "1.4", + "request_headers": "1.1", + }, + "URLDownloaderPython": { + "_introduced_": "2.4.1", + "request_headers": "2.9.0", + }, + "URLGetter": { + "_introduced_": "1.4", + }, + "URLTextSearcher": { + "_introduced_": "0.2.9", + "CURL_PATH": "0.6.0", + "curl_opts": "1.0.4", + }, + "Unarchiver": { + "_introduced_": "0.1.0", + "USE_PYTHON_NATIVE_EXTRACTOR": "2.3", + }, + "VariableSetter": { + "_introduced_": "2.9.0", + }, + "Versioner": { + "_introduced_": "0.1.0", + "plist_version_key": "1.1", + "skip_single_root_dir": "2.3", + }, +} + +GENERATION_METADATA = { + "generator_version": 2, + "last_walked_ref": "v2.9.0", + "last_walked_version": "2.9.0", + "source": "https://github.com/autopkg/autopkg", +} diff --git a/pre_commit_macadmin_hooks/check_autopkg_recipe_list.py b/pre_commit_macadmin_hooks/check_autopkg_recipe_list.py index 33dac68..e7c1d95 100755 --- a/pre_commit_macadmin_hooks/check_autopkg_recipe_list.py +++ b/pre_commit_macadmin_hooks/check_autopkg_recipe_list.py @@ -10,9 +10,7 @@ import plistlib from xml.parsers.expat import ExpatError -import ruamel.yaml - -yaml = ruamel.yaml.YAML(typ="safe") +from pre_commit_macadmin_hooks.util import yaml def build_argument_parser() -> argparse.ArgumentParser: diff --git a/pre_commit_macadmin_hooks/check_autopkg_recipes.py b/pre_commit_macadmin_hooks/check_autopkg_recipes.py index de2f993..ef50ccf 100755 --- a/pre_commit_macadmin_hooks/check_autopkg_recipes.py +++ b/pre_commit_macadmin_hooks/check_autopkg_recipes.py @@ -3,13 +3,14 @@ requirements.""" import argparse +import contextlib import os import sys -from contextlib import contextmanager from typing import Any from packaging.version import Version +from pre_commit_macadmin_hooks.autopkg_processor_versions import PROC_VERSIONS from pre_commit_macadmin_hooks.util import ( detect_deprecated_keys, detect_typoed_keys, @@ -21,22 +22,12 @@ validate_uninstall_method, ) - # Import AutoPkg libraries, but ignore any warnings generated by the import. -@contextmanager -def suppress_stdout() -> Any: - with open(os.devnull, "w", encoding="utf-8") as devnull: - old_stdout = sys.stdout - sys.stdout = devnull - try: - yield - finally: - sys.stdout = old_stdout - - sys.path.append("/Library/AutoPkg") try: - with suppress_stdout(): + with open(os.devnull, "w", encoding="utf-8") as devnull, contextlib.redirect_stdout( + devnull + ): from autopkglib import ( # type: ignore[import-not-found] get_processor, processor_names, @@ -47,6 +38,14 @@ def suppress_stdout() -> Any: # Silently skip checks that require autopkglib. HAS_AUTOPKGLIB = False +if HAS_AUTOPKGLIB: + _CORE_PROCS = { + proc: getattr(get_processor(proc), "input_variables", {}) + for proc in processor_names() + } +else: + _CORE_PROCS = {} + def build_argument_parser() -> argparse.ArgumentParser: """Build and return the argument parser.""" @@ -93,7 +92,7 @@ def validate_recipe_prefix( """Verify that the recipe identifier starts with the expected prefix.""" passed = True - if not any([recipe["Identifier"].startswith(x) for x in prefix]): + if not any(recipe["Identifier"].startswith(x) for x in prefix): print( "{}: identifier does not start with {}".format( filename, @@ -154,7 +153,7 @@ def validate_endofcheckphase(process, filename): ( idx for (idx, x) in enumerate(process) - if x.get("Processor") in ("URLDownloader", "CURLDownloader") + if x.get("Processor") in ("URLDownloader", "URLDownloaderPython") ), None, ) @@ -186,59 +185,6 @@ def validate_minimumversion(process, min_vers, ignore_min_vers_before, filename) """Ensure MinimumVersion is a string and is set appropriately for the processors used.""" - # Processors for which a minimum version of AutoPkg is required. - # Note: packaging.version.Version considers this True: "1.0" == "1.0.0" - proc_min_versions = { - "AppDmgVersioner": "0.0", - "AppPkgCreator": "1.0", - "BrewCaskInfoProvider": "0.2.5", - # "ChocolateyPackager": "3.0", # hasn't been merged yet - "CodeSignatureVerifier": "0.3.1", - "Copier": "0.0", - "CURLDownloader": "0.5.1", - "CURLTextSearcher": "0.5.1", - "DeprecationWarning": "1.1", - "DmgCreator": "0.0", - "DmgMounter": "0.0", - "EndOfCheckPhase": "0.1.0", - "FileCreator": "0.0", - "FileFinder": "0.2.3", - "FileMover": "0.2.9", - "FindAndReplace": "2.7.6", - "FlatPkgPacker": "0.2.4", - "FlatPkgUnpacker": "0.1.0", - "GitHubReleasesInfoProvider": "0.5.0", - "Installer": "0.4.0", - "InstallFromDMG": "0.4.0", - "MunkiCatalogBuilder": "0.1.0", - "MunkiImporter": "0.1.0", - "MunkiInfoCreator": "0.0", - "MunkiInstallsItemsCreator": "0.1.0", - "MunkiOptionalReceiptEditor": "2.7", - "MunkiPkginfoMerger": "0.1.0", - "MunkiSetDefaultCatalog": "0.4.2", - "PackageRequired": "0.5.1", - "PathDeleter": "0.1.0", - "PkgCopier": "0.1.0", - "PkgCreator": "0.0", - "PkgExtractor": "0.1.0", - "PkgInfoCreator": "0.0", - "PkgPayloadUnpacker": "0.1.0", - "PkgRootCreator": "0.0", - "PlistEditor": "0.1.0", - "PlistReader": "0.2.5", - "SignToolVerifier": "2.3", - "SparkleUpdateInfoProvider": "0.1.0", - "StopProcessingIf": "0.1.0", - "Symlinker": "0.1.0", - "Unarchiver": "0.1.0", - "URLDownloader": "0.0", - "URLDownloaderPython": "2.4.1", - "URLTextSearcher": "0.2.9", - "VariableSetter": "2.9.0", - "Versioner": "0.1.0", - } - passed = True # Validate that the MinimumVersion value is a string @@ -246,33 +192,58 @@ def validate_minimumversion(process, min_vers, ignore_min_vers_before, filename) print(f"{filename}: MinimumVersion should be a string.") passed = False - # Validate that the MinimumVersion value fits the processors used - for proc in [ - x - for x in proc_min_versions - if Version(proc_min_versions[x]) >= Version(ignore_min_vers_before) - ]: - if proc in [x.get("Processor") for x in process]: - if Version(str(min_vers)) < Version(proc_min_versions[proc]): - print( - f"{filename}: {proc} processor requires minimum AutoPkg version {proc_min_versions[proc]}" - ) - passed = False + # Validate that the MinimumVersion value fits the processors and processor + # arguments used. Unknown processors and unknown arguments fail open. + for proc in process: + proc_name = proc.get("Processor") + proc_versions = PROC_VERSIONS.get(proc_name) + if proc_versions is None: + continue + + proc_min_version = proc_versions.get("_introduced_") + if proc_min_version is None: + continue + + required_version = proc_min_version + for arg in proc.get("Arguments", {}): + arg_min_version = proc_versions.get(arg) + if arg_min_version and Version(arg_min_version) > Version(required_version): + required_version = arg_min_version + + if Version(required_version) < Version(ignore_min_vers_before): + continue + + if Version(str(min_vers)) < Version(required_version): + print( + f"{filename}: {proc_name} processor requires minimum AutoPkg " + f"version {required_version}" + ) + passed = False return passed def validate_no_deprecated_procs(process, filename): - """Warn if any deprecated processors are used.""" - - # Processors that have been deprecated. - deprecated_procs = ("CURLDownloader", "BrewCaskInfoProvider") + """Error on removed processors; warn on deprecated ones.""" passed = True for proc in process: - if proc.get("Processor") in deprecated_procs: + name = proc.get("Processor") + proc_versions = PROC_VERSIONS.get(name) + if proc_versions is None: + continue + + removed_version = proc_versions.get("_removed_") + deprecated_version = proc_versions.get("_deprecated_") + if removed_version: print( - f'{filename}: WARNING: Deprecated processor {proc.get("Processor")} is used.' + f"{filename}: Processor {name} was removed in AutoPkg {removed_version}." + ) + passed = False + elif deprecated_version: + print( + f"{filename}: WARNING: Processor {name} was deprecated in AutoPkg " + f"{deprecated_version}." ) return passed @@ -320,7 +291,7 @@ def validate_jamf_processor_order(process, filename): # All JamfUploader processors in recipe, ignoring duplicates, preserving order. actual_order = list( dict.fromkeys( - [x.get("Processor") for x in process if x.get("Processor") in rec_order] + x.get("Processor") for x in process if x.get("Processor") in rec_order ) ) desired_order = [x for x in rec_order if x in actual_order] @@ -336,28 +307,6 @@ def validate_jamf_processor_order(process, filename): return passed -# def validate_unused_input_vars(recipe, recipe_text, filename): -# """Warn if any input variables are not referenced in the recipe.""" - -# # List of variables that are commonly allowed to be unreferenced (lowercase). -# ignored_vars = ( -# "name", -# "pkginfo", -# ) - -# passed = True -# for input_var, _ in recipe.get("Input", {}).items(): -# if input_var.lower() in ignored_vars: -# continue -# subst = "%" + input_var + "%" -# if subst not in recipe_text: -# print( -# f"{filename}: WARNING: Input variable {input_var} not referenced in recipe." -# ) - -# return passed - - def validate_no_var_in_app_path(process, filename): """Ensure %NAME% is not used in app paths that should be hard coded.""" @@ -387,79 +336,90 @@ def validate_no_var_in_app_path(process, filename): return passed -def validate_proc_type_conventions(process, filename): - """Ensure that processors used align with recipe type conventions.""" +# For each processor type, this is the list of processors that +# we only expect to see in that type. Tuple keys are all recipe types that share these conventions. +_PROC_TYPE_CONVENTIONS = { + ("download",): [ + "SparkleUpdateInfoProvider", + "GitHubReleasesInfoProvider", + "URLDownloader", + "URLDownloaderPython", + "EndOfCheckPhase", + ], + ("munki",): [ + "MunkiInfoCreator", + "MunkiInstallsItemsCreator", + "MunkiPkginfoMerger", + "MunkiCatalogBuilder", + "MunkiSetDefaultCatalog", + "MunkiOptionalReceiptEditor", + "MunkiImporter", + ], + ("pkg",): ["AppPkgCreator", "PkgCreator"], + ("install",): ["InstallFromDMG", "Installer"], + # https://github.com/jssimporter/JSSImporter + ("jss", "jss-upload"): ["JSSImporter"], + # https://github.com/grahampugh/jamf-upload + ("jamf", "jamf-upload", "jamfclirunner"): [ + "com.github.grahampugh.jamf-upload.processors/JamfAccountUploader", + "com.github.grahampugh.jamf-upload.processors/JamfCategoryUploader", + "com.github.grahampugh.jamf-upload.processors/JamfClassicAPIObjectUploader", + "com.github.grahampugh.jamf-upload.processors/JamfComputerGroupDeleter", + "com.github.grahampugh.jamf-upload.processors/JamfComputerGroupUploader", + "com.github.grahampugh.jamf-upload.processors/JamfComputerProfileUploader", + "com.github.grahampugh.jamf-upload.processors/JamfDockItemUploader", + "com.github.grahampugh.jamf-upload.processors/JamfExtensionAttributeUploader", + "com.github.grahampugh.jamf-upload.processors/JamfIconUploader", + "com.github.grahampugh.jamf-upload.processors/JamfMacAppUploader", + "com.github.grahampugh.jamf-upload.processors/JamfMobileDeviceGroupUploader", + "com.github.grahampugh.jamf-upload.processors/JamfMobileDeviceProfileUploader", + "com.github.grahampugh.jamf-upload.processors/JamfPackageCleaner", + "com.github.grahampugh.jamf-upload.processors/JamfPackageRecalculator", + "com.github.grahampugh.jamf-upload.processors/JamfPackageUploader", + "com.github.grahampugh.jamf-upload.processors/JamfPatchChecker", + "com.github.grahampugh.jamf-upload.processors/JamfPatchUploader", + "com.github.grahampugh.jamf-upload.processors/JamfPkgMetadataUploader", + "com.github.grahampugh.jamf-upload.processors/JamfPolicyDeleter", + "com.github.grahampugh.jamf-upload.processors/JamfPolicyLogFlusher", + "com.github.grahampugh.jamf-upload.processors/JamfPolicyUploader", + "com.github.grahampugh.jamf-upload.processors/JamfScriptUploader", + "com.github.grahampugh.jamf-upload.processors/JamfSoftwareRestrictionUploader", + "com.github.grahampugh.jamf-upload.processors/JamfUploaderSlacker", + "com.github.grahampugh.jamf-upload.processors/JamfUploaderTeamsNotifier", + # https://github.com/grahampugh/recipes (JamfCLIRunner) + "com.github.grahampugh.recipes.JamfCLIRunner", + ], + # https://github.com/autopkg/filewave + ("filewave",): [ + "com.github.autopkg.filewave.FWTool/FileWaveImporter", + "com.github.johncclayton.filewave.FWTool/FileWaveImporter", + "com.github.autopkg.filewave.FWTool/FWTool", + ], + ("verify",): ["com.github.n8felton.shared/GPGSignatureVerifier"], + # https://github.com/almenscorner/intune-upload + ("intune",): [ + "com.github.almenscorner.intune-upload.processors/IntuneAppUploader", + "com.github.almenscorner.intune-upload.processors/IntuneAppIconGetter", + ], + # https://github.com/fleetdm/fleet + ("fleet",): ["com.github.fleet.FleetImporter/FleetImporter"], + # https://github.com/codeskipper/WorkSpaceOneImporter + ("ws1",): [ + "com.github.codeskipper.VMWARE-WorkSpaceOneImporter/WorkSpaceOneImporter" + ], +} + +_ALL_KNOWN_RECIPE_TYPES = [ + f".{recipe_type}." + for recipe_group in _PROC_TYPE_CONVENTIONS + for recipe_type in recipe_group +] - # For each processor type, this is the list of processors that - # we only expect to see in that type. List order is unimportant. - proc_type_conventions = { - # Tuple contains all recipe types that share these conventions. - ("download",): [ - "SparkleUpdateInfoProvider", - "GitHubReleasesInfoProvider", - "URLDownloader", - "URLDownloaderPython", - "CURLDownloader", - "EndOfCheckPhase", - ], - ("munki",): [ - "MunkiInfoCreator", - "MunkiInstallsItemsCreator", - "MunkiPkginfoMerger", - "MunkiCatalogBuilder", - "MunkiSetDefaultCatalog", - "MunkiOptionalReceiptEditor", - "MunkiImporter", - ], - ("pkg",): ["AppPkgCreator", "PkgCreator"], - ("install",): ["InstallFromDMG", "Installer"], - # https://github.com/jssimporter/JSSImporter - ("jss",): ["JSSImporter"], - # https://github.com/grahampugh/jamf-upload - ("jamf", "jamf-upload"): [ - "com.github.grahampugh.jamf-upload.processors/JamfAccountUploader", - "com.github.grahampugh.jamf-upload.processors/JamfCategoryUploader", - "com.github.grahampugh.jamf-upload.processors/JamfClassicAPIObjectUploader", - "com.github.grahampugh.jamf-upload.processors/JamfComputerGroupDeleter", - "com.github.grahampugh.jamf-upload.processors/JamfComputerGroupUploader", - "com.github.grahampugh.jamf-upload.processors/JamfComputerProfileUploader", - "com.github.grahampugh.jamf-upload.processors/JamfDockItemUploader", - "com.github.grahampugh.jamf-upload.processors/JamfExtensionAttributeUploader", - "com.github.grahampugh.jamf-upload.processors/JamfIconUploader", - "com.github.grahampugh.jamf-upload.processors/JamfMacAppUploader", - "com.github.grahampugh.jamf-upload.processors/JamfMobileDeviceGroupUploader", - "com.github.grahampugh.jamf-upload.processors/JamfMobileDeviceProfileUploader", - "com.github.grahampugh.jamf-upload.processors/JamfPackageCleaner", - "com.github.grahampugh.jamf-upload.processors/JamfPackageRecalculator", - "com.github.grahampugh.jamf-upload.processors/JamfPackageUploader", - "com.github.grahampugh.jamf-upload.processors/JamfPatchChecker", - "com.github.grahampugh.jamf-upload.processors/JamfPatchUploader", - "com.github.grahampugh.jamf-upload.processors/JamfPkgMetadataUploader", - "com.github.grahampugh.jamf-upload.processors/JamfPolicyDeleter", - "com.github.grahampugh.jamf-upload.processors/JamfPolicyLogFlusher", - "com.github.grahampugh.jamf-upload.processors/JamfPolicyUploader", - "com.github.grahampugh.jamf-upload.processors/JamfScriptUploader", - "com.github.grahampugh.jamf-upload.processors/JamfSoftwareRestrictionUploader", - "com.github.grahampugh.jamf-upload.processors/JamfUploaderSlacker", - "com.github.grahampugh.jamf-upload.processors/JamfUploaderTeamsNotifier", - ], - # https://github.com/autopkg/filewave - ("filewave",): [ - "com.github.autopkg.filewave.FWTool/FileWaveImporter", - "com.github.johncclayton.filewave.FWTool/FileWaveImporter", - "com.github.autopkg.filewave.FWTool/FWTool", - ], - ("verify",): ["com.github.n8felton.shared/GPGSignatureVerifier"], - } - # Extract all known recipe types from conventions - all_known_types = [] - for recipe_group in proc_type_conventions: - for recipe_type in recipe_group: - all_known_types.append(f".{recipe_type}.") +def validate_proc_type_conventions(process, filename): + """Ensure that processors used align with recipe type conventions.""" - # Skip validation if filename doesn't contain any known recipe type - if not any(known_type in filename for known_type in all_known_types): + if not any(known_type in filename for known_type in _ALL_KNOWN_RECIPE_TYPES): print( f"{filename}: WARNING: Unknown recipe type. Skipping processor convention checks." ) @@ -467,11 +427,11 @@ def validate_proc_type_conventions(process, filename): passed = True processors = [x.get("Processor") for x in process] - for recipe_group in proc_type_conventions: + for recipe_group in _PROC_TYPE_CONVENTIONS: type_hints = [f".{recipe_type}." for recipe_type in recipe_group] if not any(th in filename for th in type_hints): for processor in processors: - if processor in proc_type_conventions[recipe_group]: + if processor in _PROC_TYPE_CONVENTIONS[recipe_group]: print( f"{filename}: Processor {processor} is not conventional for this " "recipe type." @@ -513,7 +473,7 @@ def validate_required_proc_for_types(process, filename): # their parent is a download recipe that produces a pkg. # TODO: Validate parent is a download recipe. break - if not any([x in processors for x in req_procs]): + if not any(x in processors for x in req_procs): if len(req_procs) == 1: print( f"{filename}: Recipe type {recipe_type} should contain processor " @@ -537,16 +497,8 @@ def validate_proc_args(process, filename): # List of argument names (lowercase) that will not be flagged as invalid. ignored_args = ("note", "notes", "comment", "comments") - # Create dictionary of AutoPkg core processors and their inputs. - core_procs = {} - for proc in processor_names(): - if hasattr(get_processor(proc), "input_variables"): - core_procs[proc] = get_processor(proc).input_variables - else: - core_procs[proc] = {} - for proc in process: - if proc["Processor"] not in core_procs: + if proc["Processor"] not in _CORE_PROCS: # Skip input variable validation for non-core processors. continue for arg in proc.get("Arguments", {}): @@ -555,20 +507,20 @@ def validate_proc_args(process, filename): continue suggestion = ( - "Consider using the VariablePlaceholder processor for adding custom environment variables:\n" - "https://derflounder.wordpress.com/2024/08/16/setting-custom-variables-in-autopkg-using-the-variableplaceholder-processor/" + "Consider using the VariableSetter processor (AutoPkg 2.9.0+) to set " + "custom environment variables." ) - if not core_procs[proc["Processor"]]: + if not _CORE_PROCS[proc["Processor"]]: print( f"{filename}: Unknown argument {arg} for processor {proc['Processor']}, " "which does not accept any arguments." ) print(suggestion) passed = False - elif arg not in core_procs[proc["Processor"]]: + elif arg not in _CORE_PROCS[proc["Processor"]]: print( f"{filename}: Unknown argument {arg} for processor {proc['Processor']}. Allowed arguments are: " - + ", ".join(core_procs[proc["Processor"]]) + + ", ".join(_CORE_PROCS[proc["Processor"]]) ) print(suggestion) passed = False @@ -595,10 +547,6 @@ def main(argv=None): retval = 1 break # No need to continue checking this file - # For future implementation of validate_unused_input_vars() - # with open(filename, "r", encoding='utf-8') as openfile: - # recipe_text = openfile.read() - # Top level keys that all AutoPkg recipes should contain. # TODO: Make required recipe keys configurable. required_keys = ["Identifier"] @@ -616,23 +564,16 @@ def main(argv=None): seen_identifiers.append(recipe["Identifier"]) # Validate identifiers. - if args.override_prefix and "Process" not in recipe: + if "Process" not in recipe: if not validate_recipe_prefix(recipe, filename, args.override_prefix): retval = 1 - if args.recipe_prefix and "Process" in recipe: + if "Process" in recipe: if not validate_recipe_prefix(recipe, filename, args.recipe_prefix): retval = 1 if recipe["Identifier"] == recipe.get("ParentRecipe"): print(f"{filename}: Identifier and ParentRecipe should not be the same.") retval = 1 - # Validate that all input variables are used. - # (Disabled for now because it's a little too opinionated, and doesn't take into account - # whether environmental variables are used in custom processors.) - # if args.strict: - # if not validate_unused_input_vars(recipe, recipe_text, filename): - # retval = 1 - # If the Input key contains a pkginfo dict, make a best effort to validate its contents. input_key = recipe.get("Input", recipe.get("input", recipe.get("INPUT"))) if input_key and "pkginfo" in input_key: diff --git a/pre_commit_macadmin_hooks/check_jamf_json_manifests.py b/pre_commit_macadmin_hooks/check_jamf_json_manifests.py index 4face82..c53d986 100755 --- a/pre_commit_macadmin_hooks/check_jamf_json_manifests.py +++ b/pre_commit_macadmin_hooks/check_jamf_json_manifests.py @@ -136,17 +136,16 @@ def validate_default( """Ensure that default values have the expected type.""" passed = True - for test_key in ("default",): - if test_key in prop: - if isinstance(prop[test_key], datetime): - actual_type = str - else: - actual_type = type(prop[test_key]) - if actual_type != MANIFEST_TYPES.get(type_found) if type_found else None: - print( - f"{filename}: {test_key} value for {name} should be {MANIFEST_TYPES.get(type_found) if type_found else 'Unknown'}, not {type(prop[test_key])}" - ) - passed = False + if "default" in prop: + if isinstance(prop["default"], datetime): + actual_type = str + else: + actual_type = type(prop["default"]) + if type_found and actual_type != MANIFEST_TYPES.get(type_found): + print( + f"{filename}: default value for {name} should be {MANIFEST_TYPES.get(type_found) if type_found else 'Unknown'}, not {type(prop['default'])}" + ) + passed = False return passed diff --git a/pre_commit_macadmin_hooks/check_munki_pkgsinfo.py b/pre_commit_macadmin_hooks/check_munki_pkgsinfo.py index b1989d8..9be13cc 100755 --- a/pre_commit_macadmin_hooks/check_munki_pkgsinfo.py +++ b/pre_commit_macadmin_hooks/check_munki_pkgsinfo.py @@ -7,6 +7,8 @@ from pathlib import Path from xml.parsers.expat import ExpatError +_BLOCKING_ACTIONS = ("RequireRestart", "RequireShutdown", "RequireLogout") + from pre_commit_macadmin_hooks.util import ( detect_deprecated_keys, detect_typoed_keys, @@ -55,12 +57,6 @@ def build_argument_parser() -> argparse.ArgumentParser: action="store_true", default=False, ) - parser.add_argument( - "--warn-on-duplicate-imports", - help="If added, this will only warn if pkginfo/pkg files end with a __1 suffix.", - action="store_true", - default=False, - ) parser.add_argument( "--valid-shebangs", nargs="+", @@ -83,7 +79,7 @@ def _check_case_sensitive_path(path: str) -> bool: if p == p.parent: return True # If string representation of path is not in parent directory, return False - if str(p) not in list(map(str, p.parent.iterdir())): + if str(p) not in map(str, p.parent.iterdir()): return False p = p.parent @@ -91,13 +87,6 @@ def _check_case_sensitive_path(path: str) -> bool: def main(argv: list[str] | None = None) -> int: """Main process.""" - # Typical extensions for installer packages. - pkg_exts = ("pkg", "dmg") - dupe_suffixes = [f"__{i}.{ext}" for ext in pkg_exts for i in range(1, 9)] - - # RestartAction values that obviate the need to check blocking applications. - blocking_actions = ("RequireRestart", "RequireShutdown", "RequireLogout") - # Parse command line arguments. argparser = build_argument_parser() args = argparser.parse_args(argv) @@ -113,10 +102,9 @@ def main(argv: list[str] | None = None) -> int: retval = 1 # Check for presence of required pkginfo keys. - if args.required_keys: - if not validate_required_keys(pkginfo, filename, args.required_keys): - retval = 1 - break # No need to continue checking this file + if not validate_required_keys(pkginfo, filename, args.required_keys): + retval = 1 + break # No need to continue checking this file # Ensure pkginfo keys have expected types. if not validate_pkginfo_key_types(pkginfo, filename): @@ -142,8 +130,17 @@ def main(argv: list[str] | None = None) -> int: if not detect_typoed_keys(pkginfo, filename): retval = 1 - # Check for deprecated installer_type values. - depr_installer_types = ( + # Warn on pkginfo keys removed in Munki 7. + removed_munki7_keys = ( + "additional_startosinstall_options", + "copy_local", + ) + for removed_key in removed_munki7_keys: + if removed_key in pkginfo: + print(f"{filename}: WARNING: {removed_key} key is removed in Munki 7") + + # Warn on installer_type values removed in Munki 7. + removed_munki7_installer_types = ( "AdobeAcrobatUpdater", "AdobeCCPInstaller", "AdobeCS5AAMEEPackage", @@ -155,21 +152,21 @@ def main(argv: list[str] | None = None) -> int: "profile", "startosinstall", ) - if pkginfo.get("installer_type") in depr_installer_types: + if pkginfo.get("installer_type") in removed_munki7_installer_types: print( - f"{filename}: WARNING: installer_type '{pkginfo.get('installer_type')}' is deprecated" + f"{filename}: WARNING: installer_type '{pkginfo.get('installer_type')}' is removed in Munki 7" ) - # Check for deprecated uninstall_method values. - depr_uninstall_methods = ( + # Warn on uninstall_method values removed in Munki 7. + removed_munki7_uninstall_methods = ( "AdobeCCPUninstaller", "AdobeCS5AAMEEPackage", "AdobeSetup", "AdobeUberUninstaller", ) - if pkginfo.get("uninstall_method") in depr_uninstall_methods: + if pkginfo.get("uninstall_method") in removed_munki7_uninstall_methods: print( - f"{filename}: WARNING: uninstall_method '{pkginfo.get('uninstall_method')}' is deprecated" + f"{filename}: WARNING: uninstall_method '{pkginfo.get('uninstall_method')}' is removed in Munki 7" ) # Check for rogue categories. @@ -188,13 +185,12 @@ def main(argv: list[str] | None = None) -> int: # Checking for the absence of blocking_applications for pkg installers. # If a pkg doesn't require blocking_applications, use empty "" in pkginfo. - if args.require_pkg_blocking_apps and all( - ( - "blocking_applications" not in pkginfo, - pkginfo.get("installer_item_location", "").endswith(".pkg"), - pkginfo.get("RestartAction") not in blocking_actions, - not pkginfo["name"].startswith("munkitools"), - ) + if ( + args.require_pkg_blocking_apps + and "blocking_applications" not in pkginfo + and pkginfo.get("installer_item_location", "").endswith(".pkg") + and pkginfo.get("RestartAction") not in _BLOCKING_ACTIONS + and not pkginfo["name"].startswith("munkitools") ): print( f"{filename}: contains a pkg installer but missing a blocking applications array" @@ -221,18 +217,6 @@ def main(argv: list[str] | None = None) -> int: print(f"{filename}: {msg}") retval = 1 - # Check for pkg filenames showing signs of duplicate imports. - if pkginfo.get(f"{i_type}_item_location", "").endswith( - tuple(dupe_suffixes) - ): - item_loc = pkginfo[f"{i_type}_item_location"] - msg = f"{i_type} item '{item_loc}' may be a duplicate import" - if args.warn_on_duplicate_imports: - print(f"{filename}: WARNING: {msg}") - else: - print(f"{filename}: {msg}") - retval = 1 - # Ensure an icon exists for the item. if not any( ( @@ -253,6 +237,7 @@ def main(argv: list[str] | None = None) -> int: # Ensure all pkginfo scripts have a proper shebang. script_types = ( "installcheck_script", + "blocking_applications_quit_script", "uninstallcheck_script", "postinstall_script", "postuninstall_script", diff --git a/pre_commit_macadmin_hooks/check_munkiadmin_scripts.py b/pre_commit_macadmin_hooks/check_munkiadmin_scripts.py index a730dc0..6a338b8 100755 --- a/pre_commit_macadmin_hooks/check_munkiadmin_scripts.py +++ b/pre_commit_macadmin_hooks/check_munkiadmin_scripts.py @@ -6,6 +6,12 @@ from pre_commit_macadmin_hooks.util import validate_shebangs +_MA_SCRIPT_PREFIXES = [ + f"{p}-{a}" + for p in ("manifest", "pkginfo", "repository") + for a in ("custom", "postopen", "postsave", "presave") +] + def build_argument_parser() -> argparse.ArgumentParser: """Build and return the argument parser.""" @@ -35,12 +41,9 @@ def main(argv: list[str] | None = None) -> int: # Ensure scripts are named properly. # https://github.com/hjuutilainen/munkiadmin/blob/4f4e96da1f1c7a4dfe7da59d88f1ef68ee02b8f2/MunkiAdmin/Singletons/MAMunkiRepositoryManager.m#L23 - prefixes = ["manifest", "pkginfo", "repository"] - actions = ["custom", "postopen", "postsave", "presave"] - ma_script_prefixes = [f"{p}-{a}" for p in prefixes for a in actions] if not any( os.path.basename(filename).startswith(prefix) - for prefix in ma_script_prefixes + for prefix in _MA_SCRIPT_PREFIXES ): print(f"{filename}: does not start with a valid MunkiAdmin script prefix") retval = 1 diff --git a/pre_commit_macadmin_hooks/check_preference_manifests.py b/pre_commit_macadmin_hooks/check_preference_manifests.py index aca9768..e2e9712 100755 --- a/pre_commit_macadmin_hooks/check_preference_manifests.py +++ b/pre_commit_macadmin_hooks/check_preference_manifests.py @@ -14,6 +14,9 @@ from pre_commit_macadmin_hooks.util import PLIST_TYPES +_VALID_PLATFORMS = frozenset(("macOS", "iOS", "tvOS")) +_PLATFORM_KEYS = ("pfm_platforms", "pfm_n_platforms") + # List keys and their expected item types PFM_LIST_TYPES = { "pfm_allowed_file_types": str, @@ -39,7 +42,7 @@ def build_argument_parser() -> argparse.ArgumentParser: return parser -def validate_required_keys( +def _validate_pfm_required_keys( input_dict: dict[str, Any], required_keys: tuple[str, ...], dict_name: str, @@ -175,7 +178,7 @@ def validate_required_subkeys(subkey, req_keys, filename): display_name = subkey["pfm_name"] + " subkey" else: display_name = " subkey" - if not validate_required_keys(subsubkey, req_keys, display_name, filename): + if not _validate_pfm_required_keys(subsubkey, req_keys, display_name, filename): passed = False return passed @@ -264,7 +267,7 @@ def validate_pfm_targets(subkey, filename): target_options = ("user", "user-managed", "system", "system-managed") if "pfm_targets" in subkey: - if any([x not in target_options for x in subkey["pfm_targets"]]): + if any(x not in target_options for x in subkey["pfm_targets"]): print( f'{filename}: "pfm_targets" values should be one of: {target_options}' ) @@ -277,25 +280,16 @@ def validate_pfm_default(subkey, filename): """Ensure that default values have the expected type.""" passed = True - if "pfm_type" in subkey: + if "pfm_type" in subkey and "pfm_default" in subkey: # TODO: Should we validate pfm_value_placeholder here too? - for test_key in ("pfm_default",): - if test_key in subkey: - # TODO: Should the default for list types be the type of the first list item, or itself? - # if PLIST_TYPES[subkey["pfm_type"]] == list: - # try: - # desired_type = type(subkey["pfm_subkeys"][0]) - # except IndexError: - # # Unknown desired type - # continue - # else: - desired_type = PLIST_TYPES[subkey["pfm_type"]] - if not isinstance(subkey[test_key], desired_type): - print( - f"{filename}: {test_key} value for {subkey.get('pfm_name')} should be type " - f"{PLIST_TYPES[subkey['pfm_type']]}, not type {type(subkey[test_key])}" - ) - passed = False + # TODO: Should the default for list types be the type of the first list item, or itself? + desired_type = PLIST_TYPES[subkey["pfm_type"]] + if not isinstance(subkey["pfm_default"], desired_type): + print( + f"{filename}: pfm_default value for {subkey.get('pfm_name')} should be type " + f"{PLIST_TYPES[subkey['pfm_type']]}, not type {type(subkey['pfm_default'])}" + ) + passed = False return passed @@ -323,13 +317,10 @@ def validate_platforms(subkey, filename): """Ensure that `pfm_platforms` and `pfm_n_platforms` values are valid.""" passed = True - valid_platforms = ["macOS", "iOS", "tvOS"] - - platform_keys = ["pfm_platforms", "pfm_n_platforms"] - for platform_key in platform_keys: + for platform_key in _PLATFORM_KEYS: if platform_key in subkey: for platform in subkey[platform_key]: - if platform not in valid_platforms: + if platform not in _VALID_PLATFORMS: print( f"{filename}: {platform_key} value doesn't look like a valid platform string: {platform}" ) @@ -346,9 +337,9 @@ def validate_subkeys(subkeys, filename): for subkey in subkeys: # Check for presence of required subkeys - # (Not calling validate_required_keys() directly because the output would not be + # (Not calling _validate_pfm_required_keys() directly because the output would not be # specific enough to indicate *where* in the manifest the problem exists.) - # Example of validate_required_keys() output: + # Example of _validate_pfm_required_keys() output: # menu.nomad.NoMADPro.plist: missing required key pfm_type # Example of validate_required_subkeys() output: # menu.nomad.NoMADPro.plist: ChangePasswordItem subkey missing required key pfm_type @@ -429,7 +420,9 @@ def main(argv: list[str] | None = None) -> int: # Check for presence of required keys. required_keys = ("pfm_title", "pfm_domain", "pfm_description") - if not validate_required_keys(manifest, required_keys, "", filename): + if not _validate_pfm_required_keys( + manifest, required_keys, "", filename + ): retval = 1 continue # No need to continue checking this file diff --git a/pre_commit_macadmin_hooks/forbid_autopkg_overrides.py b/pre_commit_macadmin_hooks/forbid_autopkg_overrides.py index 1ad6bc4..a62c3e3 100755 --- a/pre_commit_macadmin_hooks/forbid_autopkg_overrides.py +++ b/pre_commit_macadmin_hooks/forbid_autopkg_overrides.py @@ -19,9 +19,6 @@ def build_argument_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """Main process.""" - # Overrides should not contain top-level Process arrays. - required_keys = ("Process",) - # Parse command line arguments. argparser = build_argument_parser() args = argparser.parse_args(argv) @@ -32,10 +29,10 @@ def main(argv: list[str] | None = None) -> int: if not recipe: retval = 1 break # No need to continue checking this file. - for req_key in required_keys: - if req_key not in recipe: - print(f"{filename}: possible AutoPkg recipe override") - retval = 1 + # Overrides should not contain a top-level Process array. + if "Process" not in recipe: + print(f"{filename}: possible AutoPkg recipe override") + retval = 1 return retval diff --git a/pre_commit_macadmin_hooks/util.py b/pre_commit_macadmin_hooks/util.py index 026caab..de6328a 100644 --- a/pre_commit_macadmin_hooks/util.py +++ b/pre_commit_macadmin_hooks/util.py @@ -1,5 +1,6 @@ #!/usr/bin/python +import itertools import json import plistlib from datetime import datetime @@ -218,12 +219,16 @@ def validate_pkginfo_key_types(pkginfo: dict[str, Any], filename: str) -> bool: "apple_item": bool, "autoremove": bool, "blocking_applications": list, + "blocking_applications_manual_quit_only": bool, + "blocking_applications_quit_script": str, "catalogs": list, "category": str, "copy_local": bool, "description": str, + "description_staged": str, "developer": str, "display_name": str, + "display_name_staged": str, "force_install_after_date": datetime, "forced_install": bool, "forced_uninstall": bool, @@ -291,10 +296,8 @@ def validate_shebangs( script_content: str, filename: str, addl_shebangs: list[str] | None = None ) -> bool: """Verifies that scripts begin with a valid shebang.""" - if addl_shebangs is None: - addl_shebangs = [] passed = True - shebangs = BUILTIN_SHEBANGS + addl_shebangs + shebangs = itertools.chain(BUILTIN_SHEBANGS, addl_shebangs or []) if not any(script_content.startswith(x + "\n") for x in shebangs): print(f"{filename}: does not start with a valid shebang") passed = False diff --git a/scripts/generate_autopkg_processor_versions.py b/scripts/generate_autopkg_processor_versions.py new file mode 100644 index 0000000..9933520 --- /dev/null +++ b/scripts/generate_autopkg_processor_versions.py @@ -0,0 +1,805 @@ +#!/usr/bin/env python +"""Generate AutoPkg processor MinimumVersion data from release history.""" + +from __future__ import annotations + +import argparse +import ast +import importlib.util +import io +import re +import subprocess +import sys +import tokenize +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from packaging.version import InvalidVersion, Version + +GENERATOR_VERSION = 2 +SOURCE_URL = "https://github.com/autopkg/autopkg" +INTRODUCED_KEY = "_introduced_" +DEPRECATED_KEY = "_deprecated_" +REMOVED_KEY = "_removed_" +METADATA_KEYS = {INTRODUCED_KEY, DEPRECATED_KEY, REMOVED_KEY} +MANUAL_VERSION_OVERRIDES = { + # The local AutoPkg history available to this generator may not include a + # stable v3.0.0 tag, but this repo already treated these compatibility + # wrappers as removed in AutoPkg 3.0.0. Keep the correction in generated + # data instead of a separate runtime table. + "CURLDownloader": {REMOVED_KEY: "3.0.0"}, + "CURLTextSearcher": {REMOVED_KEY: "3.0.0"}, +} +DEFAULT_OUTPUT = ( + Path(__file__).resolve().parents[1] + / "pre_commit_macadmin_hooks" + / "autopkg_processor_versions.py" +) + + +@dataclass(frozen=True) +class Release: + version: str + ref: str + + +@dataclass +class ProcessorInfo: + args: set[str] + bases: set[str] + defines_input_variables: bool = False + lifecycle_introduced: str | None = None + lifecycle_deprecated: str | None = None + + +def git(repo: Path, *args: str, check: bool = True) -> str: + """Run git in repo and return stdout.""" + + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=False, + encoding="utf-8", + errors="replace", + capture_output=True, + ) + if check and result.returncode != 0: + raise RuntimeError( + f"git {' '.join(args)} failed with exit code {result.returncode}:\n" + f"{result.stderr}" + ) + return result.stdout + + +def normalize_tag_version(tag: str) -> tuple[str, Version] | None: + """Return normalized version text and Version for a bare AutoPkg tag.""" + + raw_version = tag + if raw_version.startswith("v."): + raw_version = raw_version[2:] + elif raw_version.startswith("v") and len(raw_version) > 1: + raw_version = raw_version[1:] + + try: + parsed = Version(raw_version) + except InvalidVersion: + return None + return str(parsed), parsed + + +def public_releases( + autopkg_repo: Path, + baseline_ref: str | None = None, + include_prereleases: bool = False, + require_baseline: bool = True, +) -> list[Release]: + """Return sorted AutoPkg release refs to scan.""" + + releases_by_version: dict[Version, Release] = {} + for tag in git(autopkg_repo, "tag", "--list").splitlines(): + normalized = normalize_tag_version(tag) + if normalized is None: + continue + version_text, parsed_version = normalized + if parsed_version.is_prerelease and not include_prereleases: + continue + releases_by_version[parsed_version] = Release(version_text, tag) + + baseline_version = Version("0.1.0") + if baseline_version not in releases_by_version: + if require_baseline and baseline_ref is None: + raise ValueError( + "AutoPkg 0.1.0 tag was not found. Pass --baseline-ref to label " + "a historical snapshot as 0.1.0." + ) + if baseline_ref is not None: + releases_by_version[baseline_version] = Release("0.1.0", baseline_ref) + + return [ + releases_by_version[version] + for version in sorted(releases_by_version) + if version >= baseline_version + ] + + +def literal_string(value: ast.AST | None) -> str | None: + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return value.value + if isinstance(value, ast.Str): + return value.s + return None + + +def ast_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def ast_processor_info(source: str) -> dict[str, ProcessorInfo]: + """Extract processor info from source with ast when Python 3 can parse it.""" + + tree = ast.parse(source) + processors: dict[str, ProcessorInfo] = {} + exported_names: set[str] = set() + aliases: dict[str, str] = {} + + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + if isinstance(node.value, (ast.List, ast.Tuple)): + exported_names.update( + name + for item in node.value.elts + if (name := literal_string(item)) is not None + ) + elif isinstance(target, ast.Name) and (value_name := ast_name(node.value)): + aliases[target.id] = value_name + + for node in tree.body: + if not isinstance(node, ast.ClassDef) or node.name == "Processor": + continue + + input_variables_seen = False + args: set[str] = set() + bases = {base_name for base in node.bases if (base_name := ast_name(base))} + lifecycle_introduced: str | None = None + lifecycle_deprecated: str | None = None + + for item in node.body: + target_name = None + value = None + if isinstance(item, ast.Assign): + value = item.value + for target in item.targets: + if isinstance(target, ast.Name): + target_name = target.id + break + elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + target_name = item.target.id + value = item.value + + if target_name == "input_variables": + input_variables_seen = True + if isinstance(value, ast.Dict): + for key in value.keys: + key_string = literal_string(key) + if key_string is not None: + args.add(key_string) + elif target_name == "lifecycle" and isinstance(value, ast.Dict): + for key, lifecycle_value in zip(value.keys, value.values): + if literal_string(key) == "introduced": + lifecycle_introduced = literal_string(lifecycle_value) + elif literal_string(key) == "deprecated": + lifecycle_deprecated = literal_string(lifecycle_value) + + processors[node.name] = ProcessorInfo( + args, + bases, + input_variables_seen, + lifecycle_introduced, + lifecycle_deprecated, + ) + + for alias_name, target_name in aliases.items(): + if alias_name in processors: + continue + if exported_names and alias_name not in exported_names: + continue + processors[alias_name] = ProcessorInfo(set(), {target_name}) + + return processors + + +def line_offsets(text: str) -> list[int]: + offsets = [0] + total = 0 + for line in text.splitlines(keepends=True): + total += len(line) + offsets.append(total) + return offsets + + +def position_to_offset(offsets: list[int], position: tuple[int, int]) -> int: + row, column = position + return offsets[row - 1] + column + + +def dict_text_after_assignment(source: str, attr_name: str) -> str | None: + """Return the literal dict text assigned to attr_name, if one is present.""" + + marker = f"{attr_name}" + attr_index = source.find(marker) + while attr_index != -1: + line_start = source.rfind("\n", 0, attr_index) + 1 + prefix = source[line_start:attr_index] + if prefix.strip() == "": + equals_index = source.find("=", attr_index + len(marker)) + next_line_index = source.find("\n", attr_index) + if equals_index != -1 and ( + next_line_index == -1 or equals_index < next_line_index + ): + brace_index = source.find("{", equals_index) + if brace_index != -1: + break + attr_index = source.find(marker, attr_index + len(marker)) + else: + return None + + dict_source = source[brace_index:] + offsets = line_offsets(dict_source) + level = 0 + started = False + try: + tokens = tokenize.generate_tokens(io.StringIO(dict_source).readline) + for token in tokens: + if token.type != tokenize.OP: + continue + if token.string == "{": + started = True + level += 1 + elif token.string == "}": + level -= 1 + if started and level == 0: + end = position_to_offset(offsets, token.end) + return dict_source[:end] + except tokenize.TokenError: + return None + return None + + +def string_keys_at_top_dict_level(dict_source: str) -> set[str]: + """Return string keys from the top level of a dict literal.""" + + keys: set[str] = set() + level = 0 + previous_significant = "" + try: + tokens = tokenize.generate_tokens(io.StringIO(dict_source).readline) + for token in tokens: + if token.type == tokenize.OP: + if token.string in "{[(": + level += 1 + elif token.string in "}])": + level -= 1 + if token.string not in ",\n": + previous_significant = token.string + continue + if token.type == tokenize.STRING and level == 1: + try: + value = ast.literal_eval(token.string) + except (SyntaxError, ValueError): + continue + if isinstance(value, str) and previous_significant in {"{", ","}: + keys.add(value) + previous_significant = "STRING" + elif token.type not in { + tokenize.COMMENT, + tokenize.ENCODING, + tokenize.NL, + tokenize.NEWLINE, + tokenize.INDENT, + tokenize.DEDENT, + }: + previous_significant = token.string + except tokenize.TokenError: + return keys + return keys + + +def versions_from_lifecycle(dict_source: str) -> tuple[str | None, str | None]: + """Return lifecycle introduced/deprecated versions from a literal dict.""" + + try: + value = ast.literal_eval(dict_source) + except (SyntaxError, ValueError): + return None, None + if not isinstance(value, dict): + return None, None + + introduced = value.get("introduced") + deprecated = value.get("deprecated") + return ( + introduced if isinstance(introduced, str) else None, + deprecated if isinstance(deprecated, str) else None, + ) + + +def fallback_processor_info(source: str) -> dict[str, ProcessorInfo]: + """Extract processor info from simple class blocks when ast cannot parse.""" + + processors: dict[str, ProcessorInfo] = {} + class_offsets: list[tuple[str, set[str], int]] = [] + for match in re.finditer(r"(?m)^class\s+(\w+)(?:\(([^)]*)\))?:", source): + class_name = match.group(1) + if class_name == "Processor": + continue + bases = { + base.strip().split(".")[-1] + for base in (match.group(2) or "").split(",") + if base.strip() + } + class_offsets.append((class_name, bases, match.start())) + + for index, (class_name, bases, start) in enumerate(class_offsets): + end = ( + class_offsets[index + 1][2] + if index + 1 < len(class_offsets) + else len(source) + ) + block = source[start:end] + + input_variables = dict_text_after_assignment(block, "input_variables") + lifecycle = dict_text_after_assignment(block, "lifecycle") + + args = ( + string_keys_at_top_dict_level(input_variables) if input_variables else set() + ) + lifecycle_introduced, lifecycle_deprecated = ( + versions_from_lifecycle(lifecycle) if lifecycle else (None, None) + ) + processors[class_name] = ProcessorInfo( + args, + bases, + input_variables is not None, + lifecycle_introduced, + lifecycle_deprecated, + ) + + return processors + + +def parse_processor_source(source: str) -> dict[str, ProcessorInfo]: + try: + return ast_processor_info(source) + except SyntaxError: + return fallback_processor_info(source) + + +def is_core_processor_source_path(path: str) -> bool: + """Return True for top-level AutoPkg core processor modules.""" + + parts = Path(path).parts + lower_parts = {part.lower() for part in parts} + if lower_parts & {"test", "tests"}: + return False + if parts[-1] == "__init__.py": + return False + + return any( + part == "autopkglib" and index == len(parts) - 2 + for index, part in enumerate(parts) + ) + + +def processor_files_at_ref(autopkg_repo: Path, ref: str) -> dict[str, str]: + """Return candidate Python source files from a release ref.""" + + sources: dict[str, str] = {} + for path in git(autopkg_repo, "ls-tree", "-r", "--name-only", ref).splitlines(): + if not path.endswith(".py"): + continue + if not is_core_processor_source_path(path): + continue + source = git(autopkg_repo, "show", f"{ref}:{path}", check=False) + sources[path] = source + return sources + + +def scan_release(autopkg_repo: Path, release: Release) -> dict[str, ProcessorInfo]: + parsed_classes: dict[str, ProcessorInfo] = {} + for source in processor_files_at_ref(autopkg_repo, release.ref).values(): + for name, info in parse_processor_source(source).items(): + parsed_classes[name] = info + + processor_names = { + name + for name, info in parsed_classes.items() + if info.defines_input_variables + or info.lifecycle_introduced + or "Processor" in info.bases + } + while True: + inherited_processor_names = { + name + for name, info in parsed_classes.items() + if name not in processor_names and info.bases & processor_names + } + if not inherited_processor_names: + break + processor_names.update(inherited_processor_names) + + processors = { + name: info for name, info in parsed_classes.items() if name in processor_names + } + return processors + + +def compact_argument_versions( + processor_versions: dict[str, dict[str, str]], +) -> dict[str, dict[str, str]]: + compacted: dict[str, dict[str, str]] = {} + for proc_name, versions in processor_versions.items(): + introduced_version = versions.get(INTRODUCED_KEY) + if introduced_version is None: + continue + + compacted_versions = { + key: value for key, value in versions.items() if key in METADATA_KEYS + } + for arg, version in versions.items(): + if arg in METADATA_KEYS: + continue + if Version(version) > Version(introduced_version): + compacted_versions[arg] = version + + compacted[proc_name] = compacted_versions + return compacted + + +def lifecycle_version_is_valid( + proc_name: str, + lifecycle_key: str, + version: str, + release: Release, + warnings: list[str], +) -> bool: + try: + Version(version) + except InvalidVersion: + warnings.append( + f"{proc_name}: lifecycle {lifecycle_key} version {version!r} " + f"from {release.ref} is invalid" + ) + return False + return True + + +def apply_lifecycle_version( + versions: dict[str, str], + proc_name: str, + metadata_key: str, + lifecycle_key: str, + lifecycle_version: str, + release: Release, + warnings: list[str], + warnings_seen: set[tuple[str, str, str, str]], +) -> None: + if not lifecycle_version_is_valid( + proc_name, lifecycle_key, lifecycle_version, release, warnings + ): + return + + existing_version = versions.get(metadata_key) + if existing_version is not None and Version(existing_version) != Version( + lifecycle_version + ): + warning_key = ( + proc_name, + lifecycle_key, + existing_version, + lifecycle_version, + ) + if warning_key not in warnings_seen: + warnings_seen.add(warning_key) + warnings.append( + f"{proc_name}: overriding {metadata_key} {existing_version} " + f"with lifecycle {lifecycle_key} {lifecycle_version}" + ) + + versions[metadata_key] = lifecycle_version + + +def apply_manual_overrides( + processor_versions: dict[str, dict[str, str]], + warnings: list[str], +) -> None: + for proc_name, overrides in MANUAL_VERSION_OVERRIDES.items(): + versions = processor_versions.get(proc_name) + if versions is None: + continue + for key, version in overrides.items(): + existing_version = versions.get(key) + if existing_version is not None and Version(existing_version) != Version( + version + ): + warnings.append( + f"{proc_name}: overriding {key} {existing_version} " + f"with manual override {version}" + ) + versions[key] = version + + +def scan_releases( + autopkg_repo: Path, + releases: list[Release], + processor_versions: dict[str, dict[str, str]] | None = None, +) -> tuple[dict[str, dict[str, str]], list[str]]: + """Scan releases and return processor version metadata and warnings.""" + + processor_versions = { + proc: dict(versions) for proc, versions in (processor_versions or {}).items() + } + warnings: list[str] = [] + lifecycle_warnings_seen: set[tuple[str, str, str, str]] = set() + reappearance_warnings_seen: set[tuple[str, str, str]] = set() + active_processors = { + proc_name + for proc_name, versions in processor_versions.items() + if INTRODUCED_KEY in versions and REMOVED_KEY not in versions + } + + for release in releases: + release_processors = scan_release(autopkg_repo, release) + release_processor_names = set(release_processors) + + for proc_name in sorted(active_processors - release_processor_names): + processor_versions[proc_name][REMOVED_KEY] = release.version + active_processors.remove(proc_name) + + for proc_name, info in sorted(release_processors.items()): + versions = processor_versions.setdefault(proc_name, {}) + if REMOVED_KEY in versions: + warning_key = (proc_name, versions[REMOVED_KEY], release.version) + if warning_key not in reappearance_warnings_seen: + reappearance_warnings_seen.add(warning_key) + warnings.append( + f"{proc_name}: processor reappeared in {release.version} " + f"after being absent since {versions[REMOVED_KEY]}" + ) + versions.pop(REMOVED_KEY, None) + + versions.setdefault(INTRODUCED_KEY, release.version) + if info.lifecycle_introduced: + apply_lifecycle_version( + versions, + proc_name, + INTRODUCED_KEY, + "introduced", + info.lifecycle_introduced, + release, + warnings, + lifecycle_warnings_seen, + ) + if info.lifecycle_deprecated: + apply_lifecycle_version( + versions, + proc_name, + DEPRECATED_KEY, + "deprecated", + info.lifecycle_deprecated, + release, + warnings, + lifecycle_warnings_seen, + ) + + for arg in sorted(info.args): + versions.setdefault(arg, release.version) + active_processors.add(proc_name) + + return compact_argument_versions(processor_versions), warnings + + +def load_existing_data( + output_path: Path, +) -> tuple[dict[str, dict[str, str]], dict[str, Any]]: + if not output_path.exists(): + return {}, {} + + spec = importlib.util.spec_from_file_location( + "_autopkg_processor_versions", output_path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load {output_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + return ( + { + proc: dict(versions) + for proc, versions in getattr(module, "PROC_VERSIONS", {}).items() + }, + dict(getattr(module, "GENERATION_METADATA", {})), + ) + + +def format_processor_versions( + processor_versions: dict[str, dict[str, str]], +) -> list[str]: + lines = ["PROC_VERSIONS = {"] + for proc_name in sorted(processor_versions): + lines.append(f' "{proc_name}": {{') + versions = processor_versions[proc_name] + for key in (INTRODUCED_KEY, DEPRECATED_KEY, REMOVED_KEY): + if key in versions: + lines.append(f' "{key}": "{versions[key]}",') + for key in sorted(key for key in versions if key not in METADATA_KEYS): + lines.append(f' "{key}": "{versions[key]}",') + lines.append(" },") + lines.append("}") + return lines + + +def format_metadata(metadata: dict[str, Any]) -> list[str]: + lines = ["GENERATION_METADATA = {"] + for key in sorted(metadata): + value = metadata[key] + if value is None: + lines.append(f' "{key}": None,') + elif isinstance(value, int): + lines.append(f' "{key}": {value},') + else: + lines.append(f' "{key}": "{value}",') + lines.append("}") + return lines + + +def render_module( + processor_versions: dict[str, dict[str, str]], + metadata: dict[str, Any], +) -> str: + lines = [ + "# This file is generated by scripts/generate_autopkg_processor_versions.py.", + "# Do not edit it by hand.", + "", + *format_processor_versions(processor_versions), + "", + *format_metadata(metadata), + "", + ] + return "\n".join(lines) + + +def generation_mode(args: argparse.Namespace, metadata: dict[str, Any]) -> str: + if args.full: + return "full" + if args.incremental: + return "incremental" + if metadata.get("generator_version") != GENERATOR_VERSION: + return "full" + if metadata.get("last_walked_version"): + return "incremental" + return "full" + + +def generate(args: argparse.Namespace) -> tuple[str, list[str]]: + autopkg_repo = Path(args.autopkg_repo).expanduser().resolve() + output_path = Path(args.output).expanduser().resolve() + existing_proc_versions, existing_metadata = load_existing_data(output_path) + mode = generation_mode(args, existing_metadata) + + releases = public_releases( + autopkg_repo, + baseline_ref=args.baseline_ref, + include_prereleases=args.include_prereleases, + require_baseline=(mode == "full"), + ) + if mode == "incremental": + last_walked = existing_metadata.get("last_walked_version") + if not last_walked: + raise ValueError( + "Incremental generation requires last_walked_version metadata." + ) + releases_to_scan = [ + release + for release in releases + if Version(release.version) > Version(str(last_walked)) + ] + proc_versions = existing_proc_versions + else: + releases_to_scan = releases + proc_versions = {} + + proc_versions, warnings = scan_releases( + autopkg_repo, + releases_to_scan, + proc_versions, + ) + apply_manual_overrides(proc_versions, warnings) + + if releases_to_scan: + last_release = releases_to_scan[-1] + last_walked_version = last_release.version + last_walked_ref = last_release.ref + else: + last_walked_version = existing_metadata.get("last_walked_version") + last_walked_ref = existing_metadata.get("last_walked_ref") + + metadata = { + "generator_version": GENERATOR_VERSION, + "last_walked_ref": last_walked_ref, + "last_walked_version": last_walked_version, + "source": SOURCE_URL, + } + return render_module(proc_versions, metadata), warnings + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--autopkg-repo", + required=True, + help="Path to a local AutoPkg Git checkout.", + ) + parser.add_argument( + "--output", + default=str(DEFAULT_OUTPUT), + help=f"Generated Python module path. Defaults to {DEFAULT_OUTPUT}.", + ) + parser.add_argument( + "--baseline-ref", + help="Ref to label as AutoPkg 0.1.0 when no 0.1.0 tag exists.", + ) + parser.add_argument( + "--include-prereleases", + action="store_true", + help="Include beta and release-candidate tags.", + ) + parser.add_argument( + "--full", + action="store_true", + help="Rebuild from AutoPkg 0.1.0 instead of appending newer releases.", + ) + parser.add_argument( + "--incremental", + action="store_true", + help="Append releases newer than the output file's last_walked_version.", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if generated output differs from the output file.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_argument_parser() + args = parser.parse_args(argv) + if args.full and args.incremental: + parser.error("--full and --incremental are mutually exclusive") + + output_path = Path(args.output).expanduser().resolve() + generated, warnings = generate(args) + for warning in warnings: + print(f"WARNING: {warning}", file=sys.stderr) + + existing = output_path.read_text(encoding="utf-8") if output_path.exists() else None + if args.check: + if existing != generated: + print(f"{output_path} is not current.", file=sys.stderr) + return 1 + return 0 + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(generated, encoding="utf-8") + print(f"Wrote {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py index aa0a777..2520557 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ name="pre-commit-macadmin", description="Pre-commit hooks for Mac admins, client engineers, and IT consultants.", url="https://github.com/homebysix/pre-commit-macadmin", - version="1.24.1", + version="1.25.0", author="Elliot Jordan", author_email="elliot@elliotjordan.com", packages=["pre_commit_macadmin_hooks"], diff --git a/tests/test_check_autopkg_recipes.py b/tests/test_check_autopkg_recipes.py index ed83944..fc5fc36 100644 --- a/tests/test_check_autopkg_recipes.py +++ b/tests/test_check_autopkg_recipes.py @@ -143,13 +143,113 @@ def test_validate_minimumversion_passes(self): result = target.validate_minimumversion(process, "1.0", "1.0", "file.recipe") self.assertTrue(result) - def test_validate_no_deprecated_procs_warns(self): - process = [{"Processor": "CURLDownloader"}] + def test_validate_minimumversion_checks_processor_arguments(self): + process = [ + { + "Processor": "SparkleUpdateInfoProvider", + "Arguments": {"urlencode_path_component": True}, + } + ] + with mock.patch("builtins.print") as mock_print: + result = target.validate_minimumversion( + process, "1.0", "1.0", "file.recipe" + ) + self.assertFalse(result) + mock_print.assert_called_with( + "file.recipe: SparkleUpdateInfoProvider processor requires minimum AutoPkg version 1.1" + ) + + def test_validate_minimumversion_unknown_argument_falls_back_to_processor(self): + process = [ + { + "Processor": "AppPkgCreator", + "Arguments": {"unknown_future_argument": True}, + } + ] with mock.patch("builtins.print") as mock_print: + result = target.validate_minimumversion( + process, "0.9", "1.0", "file.recipe" + ) + self.assertFalse(result) + mock_print.assert_called_with( + "file.recipe: AppPkgCreator processor requires minimum AutoPkg version 1.0" + ) + + def test_validate_minimumversion_unknown_processor_is_skipped(self): + process = [ + { + "Processor": "com.github.example.processors/CustomProcessor", + "Arguments": {"urlencode_path_component": True}, + } + ] + result = target.validate_minimumversion( + process, "0.1.0", "0.1.0", "file.recipe" + ) + self.assertTrue(result) + + def test_validate_minimumversion_ignore_floor_suppresses_argument_requirement(self): + process = [ + { + "Processor": "SparkleUpdateInfoProvider", + "Arguments": {"urlencode_path_component": True}, + } + ] + result = target.validate_minimumversion(process, "1.0", "2.0", "file.recipe") + self.assertTrue(result) + + def test_validate_no_deprecated_procs_fails_removed_processor(self): + process = [{"Processor": "RemovedProcessor"}] + proc_versions = { + "RemovedProcessor": { + "_introduced_": "1.0.0", + "_removed_": "3.0.0", + } + } + with mock.patch.object(target, "PROC_VERSIONS", proc_versions): + with mock.patch("builtins.print") as mock_print: + result = target.validate_no_deprecated_procs(process, "file.recipe") + self.assertFalse(result) + mock_print.assert_called_with( + "file.recipe: Processor RemovedProcessor was removed in AutoPkg 3.0.0." + ) + + def test_validate_no_deprecated_procs_skips_unknown_processor(self): + process = [{"Processor": "com.github.example.processors/CustomProcessor"}] + with mock.patch.object(target, "PROC_VERSIONS", {}): result = target.validate_no_deprecated_procs(process, "file.recipe") self.assertTrue(result) + + def test_validate_no_deprecated_procs_warns_deprecated_processor(self): + process = [{"Processor": "DeprecatedProcessor"}] + proc_versions = { + "DeprecatedProcessor": { + "_introduced_": "1.0.0", + "_deprecated_": "2.0.0", + } + } + with mock.patch.object(target, "PROC_VERSIONS", proc_versions): + with mock.patch("builtins.print") as mock_print: + result = target.validate_no_deprecated_procs(process, "file.recipe") + self.assertTrue(result) mock_print.assert_called_with( - "file.recipe: WARNING: Deprecated processor CURLDownloader is used." + "file.recipe: WARNING: Processor DeprecatedProcessor was deprecated in AutoPkg 2.0.0." + ) + + def test_validate_no_deprecated_procs_removed_wins_over_deprecated(self): + process = [{"Processor": "RemovedProcessor"}] + proc_versions = { + "RemovedProcessor": { + "_introduced_": "1.0.0", + "_deprecated_": "2.0.0", + "_removed_": "3.0.0", + } + } + with mock.patch.object(target, "PROC_VERSIONS", proc_versions): + with mock.patch("builtins.print") as mock_print: + result = target.validate_no_deprecated_procs(process, "file.recipe") + self.assertFalse(result) + mock_print.assert_called_once_with( + "file.recipe: Processor RemovedProcessor was removed in AutoPkg 3.0.0." ) def test_validate_no_superclass_procs_warns(self): @@ -282,18 +382,9 @@ def test_validate_required_proc_for_types_unknown_type_passes(self): self.assertTrue(result) def test_validate_proc_args_valid_arguments_passes(self): - # Valid arguments for a core processor should pass - # Skip if autopkglib is not available - if not target.HAS_AUTOPKGLIB: - self.skipTest("AutoPkg library not available") - - # Mock the AutoPkg library functions - mock_proc = mock.Mock() - mock_proc.input_variables = {"url": {}, "filename": {}} - with mock.patch.object( - target, "processor_names", return_value=["URLDownloader"] - ), mock.patch.object(target, "get_processor", return_value=mock_proc): + target, "_CORE_PROCS", {"URLDownloader": {"url": {}, "filename": {}}} + ): process = [ { "Processor": "URLDownloader", @@ -304,20 +395,9 @@ def test_validate_proc_args_valid_arguments_passes(self): self.assertTrue(result) def test_validate_proc_args_invalid_argument_fails(self): - # Invalid argument for a core processor should fail - if not target.HAS_AUTOPKGLIB: - self.skipTest("AutoPkg library not available") - - mock_proc = mock.Mock() - mock_proc.input_variables = {"url": {}, "filename": {}} - with mock.patch.object( - target, "processor_names", return_value=["URLDownloader"] - ), mock.patch.object( - target, "get_processor", return_value=mock_proc - ), mock.patch( - "builtins.print" - ) as mock_print: + target, "_CORE_PROCS", {"URLDownloader": {"url": {}, "filename": {}}} + ), mock.patch("builtins.print") as mock_print: process = [ { "Processor": "URLDownloader", @@ -326,22 +406,14 @@ def test_validate_proc_args_invalid_argument_fails(self): ] result = target.validate_proc_args(process, "App.download.recipe") self.assertFalse(result) - # Check that the error message contains the key info calls = mock_print.call_args_list self.assertEqual(len(calls), 2) # Error message + suggestion self.assertIn("Unknown argument invalid_arg", str(calls[0])) def test_validate_proc_args_ignored_arguments_passes(self): - # Ignored arguments like "note" should pass - if not target.HAS_AUTOPKGLIB: - self.skipTest("AutoPkg library not available") - - mock_proc = mock.Mock() - mock_proc.input_variables = {"url": {}, "filename": {}} - with mock.patch.object( - target, "processor_names", return_value=["URLDownloader"] - ), mock.patch.object(target, "get_processor", return_value=mock_proc): + target, "_CORE_PROCS", {"URLDownloader": {"url": {}, "filename": {}}} + ): process = [ { "Processor": "URLDownloader", @@ -355,13 +427,7 @@ def test_validate_proc_args_ignored_arguments_passes(self): self.assertTrue(result) def test_validate_proc_args_non_core_processor_passes(self): - # Non-core processors should be skipped - if not target.HAS_AUTOPKGLIB: - self.skipTest("AutoPkg library not available") - - with mock.patch.object( - target, "processor_names", return_value=["URLDownloader"] - ), mock.patch.object(target, "get_processor", return_value=mock.Mock()): + with mock.patch.object(target, "_CORE_PROCS", {"URLDownloader": {}}): process = [ { "Processor": "com.github.custom.CustomProcessor", @@ -372,20 +438,9 @@ def test_validate_proc_args_non_core_processor_passes(self): self.assertTrue(result) def test_validate_proc_args_processor_with_no_args_fails(self): - # Processor that doesn't accept arguments but receives one should fail - if not target.HAS_AUTOPKGLIB: - self.skipTest("AutoPkg library not available") - - mock_proc = mock.Mock() - mock_proc.input_variables = {} # No input variables - with mock.patch.object( - target, "processor_names", return_value=["StopProcessingIf"] - ), mock.patch.object( - target, "get_processor", return_value=mock_proc - ), mock.patch( - "builtins.print" - ) as mock_print: + target, "_CORE_PROCS", {"StopProcessingIf": {}} + ), mock.patch("builtins.print") as mock_print: process = [ { "Processor": "StopProcessingIf", diff --git a/tests/test_check_jamf_json_manifests.py b/tests/test_check_jamf_json_manifests.py index 5c29544..9d549ed 100644 --- a/tests/test_check_jamf_json_manifests.py +++ b/tests/test_check_jamf_json_manifests.py @@ -1,7 +1 @@ -import unittest - -# import pre_commit_macadmin_hooks.check_jamf_json_manifests as target - - -class TestCheckJamfJsonManifests(unittest.TestCase): - pass # Hook implementation still in progress, no tests yet +import pre_commit_macadmin_hooks.check_jamf_json_manifests as target # noqa: F401 diff --git a/tests/test_check_jamf_scripts.py b/tests/test_check_jamf_scripts.py index fd42443..a33316e 100644 --- a/tests/test_check_jamf_scripts.py +++ b/tests/test_check_jamf_scripts.py @@ -71,9 +71,6 @@ def test_multiple_files(self): os.remove(path1) os.remove(path2) - def test_valid_shebangs_argument(self): - pass - if __name__ == "__main__": unittest.main() diff --git a/tests/test_check_munki_pkgsinfo.py b/tests/test_check_munki_pkgsinfo.py index 1dd90df..60256ca 100644 --- a/tests/test_check_munki_pkgsinfo.py +++ b/tests/test_check_munki_pkgsinfo.py @@ -69,33 +69,6 @@ def test_valid_pkginfo_returns_zero(self): finally: os.unlink(filename) - # def test_missing_required_key_returns_one(self): - # # Patch validate_required_keys to return False - # with mock.patch( - # "pre_commit_macadmin_hooks.util.validate_required_keys", return_value=False - # ): - # pkginfo = { - # "name": "foo", - # "version": "1.0", - # "category": "Utilities", - # "catalogs": ["testing"], - # "installer_item_location": "foo.pkg", - # "uninstaller_item_location": "foo_un.pkg", - # } - # filename = self.make_pkginfo_file(pkginfo) - # try: - # argv = [ - # "--categories", - # "Utilities", - # "--catalogs", - # "testing", - # filename, - # ] - # ret = target.main(argv) - # self.assertEqual(ret, 1) - # finally: - # os.unlink(filename) - def test_plist_parse_error_returns_one(self): with tempfile.NamedTemporaryFile("w", delete=False) as tmp: tmp.write("not a plist") @@ -107,7 +80,7 @@ def test_plist_parse_error_returns_one(self): finally: os.unlink(tmp_filename) - def test_deprecated_installer_type_warns(self): + def test_removed_munki7_installer_type_warns(self): pkginfo = { "description": "desc", "name": "foo", @@ -122,14 +95,18 @@ def test_deprecated_installer_type_warns(self): try: argv = [filename] with mock.patch("builtins.print") as mprint: - target.main(argv) + ret = target.main(argv) + self.assertEqual(ret, 0) self.assertTrue( - any("installer_type" in str(c) for c in mprint.call_args_list) + any( + "installer_type 'AdobeSetup' is removed in Munki 7" in str(c) + for c in mprint.call_args_list + ) ) finally: os.unlink(filename) - def test_deprecated_uninstall_method_warns(self): + def test_removed_munki7_uninstall_method_warns(self): pkginfo = { "description": "desc", "name": "foo", @@ -144,48 +121,42 @@ def test_deprecated_uninstall_method_warns(self): try: argv = [filename] with mock.patch("builtins.print") as mprint: - target.main(argv) + ret = target.main(argv) + self.assertEqual(ret, 0) self.assertTrue( - any("uninstall_method" in str(c) for c in mprint.call_args_list) + any( + "uninstall_method 'AdobeSetup' is removed in Munki 7" in str(c) + for c in mprint.call_args_list + ) ) finally: os.unlink(filename) - # def test_rogue_category_returns_one(self): - # pkginfo = { - # "description": "desc", - # "name": "foo", - # "version": "1.0", - # "category": "BadCategory", - # "catalogs": ["testing"], - # "installer_item_location": "foo.pkg", - # "uninstaller_item_location": "foo_un.pkg", - # } - # filename = self.make_pkginfo_file(pkginfo) - # try: - # argv = ["--categories", "Utilities", filename] - # ret = target.main(argv) - # self.assertEqual(ret, 1) - # finally: - # os.unlink(filename) - - # def test_rogue_catalog_returns_one(self): - # pkginfo = { - # "description": "desc", - # "name": "foo", - # "version": "1.0", - # "category": "Utilities", - # "catalogs": ["notapproved"], - # "installer_item_location": "foo.pkg", - # "uninstaller_item_location": "foo_un.pkg", - # } - # filename = self.make_pkginfo_file(pkginfo) - # try: - # argv = ["--catalogs", "testing", filename] - # ret = target.main(argv) - # self.assertEqual(ret, 1) - # finally: - # os.unlink(filename) + def test_removed_munki7_key_warns(self): + pkginfo = { + "description": "desc", + "name": "foo", + "version": "1.0", + "category": "Utilities", + "catalogs": ["testing"], + "installer_item_location": "foo.pkg", + "uninstaller_item_location": "foo_un.pkg", + "copy_local": True, + } + filename = self.make_pkginfo_file(pkginfo) + try: + argv = [filename] + with mock.patch("builtins.print") as mprint: + ret = target.main(argv) + self.assertEqual(ret, 0) + self.assertTrue( + any( + "copy_local key is removed in Munki 7" in str(c) + for c in mprint.call_args_list + ) + ) + finally: + os.unlink(filename) def test_missing_icon_returns_one(self): # Patch os.path.isfile to return False @@ -226,24 +197,6 @@ def test_items_to_copy_trailing_slash_returns_one(self): finally: os.unlink(filename) - def test_duplicate_import_returns_one(self): - pkginfo = { - "description": "desc", - "name": "foo", - "version": "1.0", - "category": "Utilities", - "catalogs": ["testing"], - "installer_item_location": "foo__1.pkg", - "uninstaller_item_location": "foo_un__1.pkg", - } - filename = self.make_pkginfo_file(pkginfo) - try: - argv = [filename] - ret = target.main(argv) - self.assertEqual(ret, 1) - finally: - os.unlink(filename) - def test_require_pkg_blocking_apps_missing_returns_one(self): pkginfo = { "description": "desc", @@ -284,3 +237,22 @@ def test_script_with_invalid_shebang_returns_one(self): self.assertEqual(ret, 1) finally: os.unlink(filename) + + def test_blocking_applications_quit_script_with_invalid_shebang_returns_one(self): + pkginfo = { + "description": "desc", + "name": "foo", + "version": "1.0", + "category": "Utilities", + "catalogs": ["testing"], + "installer_item_location": "foo.pkg", + "uninstaller_item_location": "foo_un.pkg", + "blocking_applications_quit_script": "echo hi", + } + filename = self.make_pkginfo_file(pkginfo) + try: + argv = [filename] + ret = target.main(argv) + self.assertEqual(ret, 1) + finally: + os.unlink(filename) diff --git a/tests/test_check_munkipkg_buildinfo.py b/tests/test_check_munkipkg_buildinfo.py index f4737e8..aebce8c 100644 --- a/tests/test_check_munkipkg_buildinfo.py +++ b/tests/test_check_munkipkg_buildinfo.py @@ -11,10 +11,6 @@ class TestCheckMunkiPkgBuildinfo(unittest.TestCase): - def test_import(self): - # Test that the target module imports without error - self.assertIsNotNone(target) - def test_build_argument_parser(self): parser = target.build_argument_parser() args = parser.parse_args(["foo.plist"]) diff --git a/tests/test_check_preference_manifests.py b/tests/test_check_preference_manifests.py index 2b57686..ab0abb8 100644 --- a/tests/test_check_preference_manifests.py +++ b/tests/test_check_preference_manifests.py @@ -25,13 +25,15 @@ def test_build_argument_parser_parses_no_filenames(self): def test_validate_required_keys_all_present(self): d = {"a": 1, "b": 2} - self.assertTrue(target.validate_required_keys(d, ["a", "b"], "dict", "file")) + self.assertTrue( + target._validate_pfm_required_keys(d, ["a", "b"], "dict", "file") + ) def test_validate_required_keys_missing(self): d = {"a": 1} with mock.patch("builtins.print") as mprint: self.assertFalse( - target.validate_required_keys(d, ["a", "b"], "dict", "file") + target._validate_pfm_required_keys(d, ["a", "b"], "dict", "file") ) mprint.assert_called_with("file: dict missing required key b") diff --git a/tests/test_forbid_autopkg_overrides.py b/tests/test_forbid_autopkg_overrides.py index a18e722..6ac2d7e 100644 --- a/tests/test_forbid_autopkg_overrides.py +++ b/tests/test_forbid_autopkg_overrides.py @@ -67,11 +67,6 @@ def test_main_recipe_load_returns_none(self, mock_load): finally: os.unlink(filename) - def test_build_argument_parser(self): - parser = target.build_argument_parser() - args = parser.parse_args(["file1", "file2"]) - self.assertEqual(args.filenames, ["file1", "file2"]) - @mock.patch( "pre_commit_macadmin_hooks.forbid_autopkg_overrides.load_autopkg_recipe" ) diff --git a/tests/test_generate_autopkg_processor_versions.py b/tests/test_generate_autopkg_processor_versions.py new file mode 100644 index 0000000..98e9e5f --- /dev/null +++ b/tests/test_generate_autopkg_processor_versions.py @@ -0,0 +1,386 @@ +import argparse +import subprocess +import tempfile +import unittest +from pathlib import Path + +import scripts.generate_autopkg_processor_versions as generator +from pre_commit_macadmin_hooks.autopkg_processor_versions import PROC_VERSIONS + + +class TestGenerateAutoPkgProcessorVersions(unittest.TestCase): + + def run_git(self, repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + encoding="utf-8", + capture_output=True, + ) + + def init_repo(self, tmp_path): + repo = tmp_path / "autopkg" + repo.mkdir() + self.run_git(repo, "init") + self.run_git(repo, "config", "user.email", "tests@example.com") + self.run_git(repo, "config", "user.name", "Tests") + return repo + + def write_processor( + self, + repo, + relative_path, + class_name, + args, + introduced=None, + deprecated=None, + ): + path = repo / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + arg_lines = "\n".join( + f' "{arg}": {{"required": False}},' for arg in args + ) + lifecycle_line = [] + lifecycle_items = [] + if introduced: + lifecycle_items.append(f'"introduced": "{introduced}"') + if deprecated: + lifecycle_items.append(f'"deprecated": "{deprecated}"') + if lifecycle_items: + lifecycle_line = [f" lifecycle = {{{', '.join(lifecycle_items)}}}"] + path.write_text( + "\n".join( + [ + "from autopkglib import Processor", + "", + f"class {class_name}(Processor):", + *lifecycle_line, + " input_variables = {", + arg_lines, + " }", + "", + ] + ), + encoding="utf-8", + ) + + def commit(self, repo, message): + self.run_git(repo, "add", "-A") + self.run_git(repo, "commit", "-m", message) + + def generate_text(self, repo, output_path, **overrides): + args = argparse.Namespace( + autopkg_repo=str(repo), + output=str(output_path), + baseline_ref=None, + include_prereleases=False, + full=True, + incremental=False, + check=False, + ) + for key, value in overrides.items(): + setattr(args, key, value) + generated, warnings = generator.generate(args) + self.assertEqual(warnings, []) + return generated + + def test_normalize_tag_version_accepts_bare_tags(self): + self.assertEqual(generator.normalize_tag_version("v1.1")[0], "1.1") + + def test_full_generation_detects_argument_added_after_file_move(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + output_path = tmp_path / "autopkg_processor_versions.py" + + self.write_processor( + repo, + "Code/autopkglib/ExampleProcessor.py", + "ExampleProcessor", + ["url"], + ) + self.commit(repo, "Add baseline processor") + self.run_git(repo, "tag", "v0.1.0") + + old_path = repo / "Code/autopkglib/ExampleProcessor.py" + new_path = repo / "Source/autopkglib/ExampleProcessor.py" + new_path.parent.mkdir(parents=True, exist_ok=True) + old_path.rename(new_path) + self.write_processor( + repo, + "Source/autopkglib/ExampleProcessor.py", + "ExampleProcessor", + ["url", "new_arg"], + ) + self.commit(repo, "Move processor and add argument") + self.run_git(repo, "tag", "v1.1") + + generated = self.generate_text(repo, output_path) + + self.assertIn('"ExampleProcessor": {', generated) + self.assertIn('"_introduced_": "0.1.0"', generated) + self.assertIn('"new_arg": "1.1"', generated) + + def test_lifecycle_introduced_overrides_git_first_seen(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + output_path = tmp_path / "autopkg_processor_versions.py" + + self.write_processor( + repo, + "Code/autopkglib/ExampleProcessor.py", + "ExampleProcessor", + ["url"], + "1.0.0", + ) + self.commit(repo, "Add processor with mismatched lifecycle") + self.run_git(repo, "tag", "v0.1.0") + + args = argparse.Namespace( + autopkg_repo=str(repo), + output=str(output_path), + baseline_ref=None, + include_prereleases=False, + full=True, + incremental=False, + check=False, + ) + generated, warnings = generator.generate(args) + + self.assertIn('"_introduced_": "1.0.0"', generated) + self.assertEqual( + warnings, + [ + "ExampleProcessor: overriding _introduced_ 0.1.0 " + "with lifecycle introduced 1.0.0" + ], + ) + + def test_lifecycle_deprecated_is_extracted(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + output_path = tmp_path / "autopkg_processor_versions.py" + + self.write_processor( + repo, + "Code/autopkglib/ExampleProcessor.py", + "ExampleProcessor", + ["url"], + introduced="0.1.0", + deprecated="1.0.0", + ) + self.commit(repo, "Add deprecated processor") + self.run_git(repo, "tag", "v0.1.0") + + generated = self.generate_text(repo, output_path) + + self.assertIn('"_introduced_": "0.1.0"', generated) + self.assertIn('"_deprecated_": "1.0.0"', generated) + + def test_processor_disappearance_creates_removed_metadata(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + output_path = tmp_path / "autopkg_processor_versions.py" + + processor_path = "Code/autopkglib/ExampleProcessor.py" + self.write_processor( + repo, + processor_path, + "ExampleProcessor", + ["url"], + ) + self.commit(repo, "Add processor") + self.run_git(repo, "tag", "v0.1.0") + + (repo / processor_path).unlink() + self.commit(repo, "Remove processor") + self.run_git(repo, "tag", "v1.0.0") + + generated = self.generate_text(repo, output_path) + + self.assertIn('"ExampleProcessor": {', generated) + self.assertIn('"_removed_": "1.0.0"', generated) + + def test_exported_alias_counts_as_processor_presence(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + output_path = tmp_path / "autopkg_processor_versions.py" + + self.write_processor( + repo, + "Code/autopkglib/TargetProcessor.py", + "TargetProcessor", + ["url"], + ) + self.write_processor( + repo, + "Code/autopkglib/LegacyProcessor.py", + "LegacyProcessor", + ["url"], + ) + self.commit(repo, "Add processors") + self.run_git(repo, "tag", "v0.1.0") + + alias_path = repo / "Code/autopkglib/LegacyProcessor.py" + alias_path.write_text( + "\n".join( + [ + "from autopkglib.TargetProcessor import TargetProcessor", + "", + '__all__ = ["LegacyProcessor"]', + "LegacyProcessor = TargetProcessor", + "", + ] + ), + encoding="utf-8", + ) + self.commit(repo, "Replace processor with compatibility alias") + self.run_git(repo, "tag", "v1.0.0") + + generated = self.generate_text(repo, output_path) + + self.assertIn('"LegacyProcessor": {', generated) + self.assertNotIn('"_removed_"', generated) + + def test_processor_reappearance_warns_and_clears_removed_metadata(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + output_path = tmp_path / "autopkg_processor_versions.py" + + processor_path = "Code/autopkglib/ExampleProcessor.py" + self.write_processor( + repo, + processor_path, + "ExampleProcessor", + ["url"], + ) + self.commit(repo, "Add processor") + self.run_git(repo, "tag", "v0.1.0") + + (repo / processor_path).unlink() + self.commit(repo, "Remove processor") + self.run_git(repo, "tag", "v1.0.0") + + self.write_processor( + repo, + processor_path, + "ExampleProcessor", + ["url"], + ) + self.commit(repo, "Re-add processor") + self.run_git(repo, "tag", "v1.1.0") + + args = argparse.Namespace( + autopkg_repo=str(repo), + output=str(output_path), + baseline_ref=None, + include_prereleases=False, + full=True, + incremental=False, + check=False, + ) + generated, warnings = generator.generate(args) + + self.assertIn('"ExampleProcessor": {', generated) + self.assertNotIn('"_removed_"', generated) + self.assertEqual( + warnings, + [ + "ExampleProcessor: processor reappeared in 1.1.0 " + "after being absent since 1.0.0" + ], + ) + + def test_missing_baseline_tag_requires_baseline_ref(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + self.write_processor( + repo, + "Code/autopkglib/ExampleProcessor.py", + "ExampleProcessor", + ["url"], + ) + self.commit(repo, "Add processor") + self.run_git(repo, "tag", "v1.0.0") + + with self.assertRaises(ValueError): + generator.public_releases(repo) + + releases = generator.public_releases(repo, baseline_ref="HEAD") + + self.assertEqual(releases[0].version, "0.1.0") + self.assertEqual(releases[0].ref, "HEAD") + + def test_incremental_generation_appends_new_releases(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo = self.init_repo(tmp_path) + output_path = tmp_path / "autopkg_processor_versions.py" + + self.write_processor( + repo, + "Code/autopkglib/FirstProcessor.py", + "FirstProcessor", + ["url"], + ) + self.commit(repo, "Add first processor") + self.run_git(repo, "tag", "v0.1.0") + + full_text = self.generate_text(repo, output_path) + output_path.write_text(full_text, encoding="utf-8") + + self.write_processor( + repo, + "Code/autopkglib/SecondProcessor.py", + "SecondProcessor", + ["path"], + ) + self.commit(repo, "Add second processor") + self.run_git(repo, "tag", "v1.0.0") + + incremental_text = self.generate_text( + repo, + output_path, + full=False, + incremental=True, + ) + + self.assertIn('"FirstProcessor": {', incremental_text) + self.assertIn('"SecondProcessor": {', incremental_text) + self.assertIn('"_introduced_": "0.1.0"', incremental_text) + self.assertIn('"_introduced_": "1.0.0"', incremental_text) + self.assertIn('"last_walked_version": "1.0.0"', incremental_text) + + def test_generated_runtime_data_is_sorted(self): + self.assertEqual(list(PROC_VERSIONS), sorted(PROC_VERSIONS)) + for versions in PROC_VERSIONS.values(): + self.assertEqual(next(iter(versions)), "_introduced_") + metadata_keys = [ + key + for key in versions + if key in {"_introduced_", "_deprecated_", "_removed_"} + ] + self.assertEqual( + metadata_keys, + [ + key + for key in ("_introduced_", "_deprecated_", "_removed_") + if key in versions + ], + ) + argument_keys = [ + key + for key in versions + if key not in {"_introduced_", "_deprecated_", "_removed_"} + ] + self.assertEqual(argument_keys, sorted(argument_keys)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_util.py b/tests/test_util.py index fb10719..9c484be 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -141,10 +141,18 @@ def test_validate_pkginfo_key_types(self): d = { "catalogs": ["foo"], "blocking_applications": ["bar"], + "blocking_applications_manual_quit_only": True, + "blocking_applications_quit_script": "#!/bin/sh\nexit 0", + "description_staged": "Ready to install", + "display_name_staged": "Example Installer", "minimum_os_version": "10.15.0", "OnDemand": True, } self.assertTrue(validate_pkginfo_key_types(d, "file")) + d = {"description_staged": ["Ready to install"]} + self.assertFalse(validate_pkginfo_key_types(d, "file")) + d = {"blocking_applications_manual_quit_only": "true"} + self.assertFalse(validate_pkginfo_key_types(d, "file")) d = { "catalogs": "foo", # should be list "blocking_applications": ["bar"],