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"],