diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 5f2da11..f3dec91 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -125,6 +125,14 @@ files: '\.recipe(\.plist|\.yaml|\.json)?$' types: [text] +- id: format-autopkg-yaml-recipes + name: Auto-format AutoPkg recipes [YAML] + description: Auto-format AutoPkg YAML recipes — reorder keys and normalize spacing. + entry: format-autopkg-yaml-recipes + language: python + files: '\.recipe\.yaml$' + types: [text] + - id: format-xml-plist name: Auto-format plist [XML] description: Auto-format a Property List (plist) as XML. diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2c6f3..e7b9f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ All notable changes to this project will be documented in this file. This projec ## [Unreleased] -Nothing yet. +### 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). ## [1.24.1] - 2026-04-12 diff --git a/README.md b/README.md index aea8007..e91fce9 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ After adding a hook to your pre-commit config, it's not a bad idea to run `pre-c This hook prevents AutoPkg recipes with trust info from being added to the repo. +- __format-autopkg-yaml-recipes__ + + This hook auto-formats AutoPkg YAML recipes (`*.recipe.yaml`): reorders top-level keys, moves `NAME` to the top of `Input`, places `Arguments` last in each processor, and inserts a blank line before each top-level section. Comments and quoted strings (including YAML 1.1 boolean literals like `'YES'` and `'NO'`) are preserved. + ### [Jamf](https://www.jamf.com/) - __check-jamf-extension-attributes__ diff --git a/pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py b/pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py new file mode 100644 index 0000000..b93f232 --- /dev/null +++ b/pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py @@ -0,0 +1,154 @@ +#!/usr/bin/python +"""This hook auto-formats AutoPkg YAML recipes.""" + +import argparse +import io +import re +from typing import List, Optional + +import ruamel.yaml +from ruamel.yaml.constructor import DuplicateKeyError + +# YAML 1.1 boolean tokens that AutoPkg uses as strings (e.g. 'YES'/'NO'). +# Force single quotes so a later load doesn't coerce them to booleans. +_YAML_11_BOOL_RE = re.compile( + r"^(y|Y|yes|Yes|YES|n|N|no|No|NO" + r"|true|True|TRUE|false|False|FALSE" + r"|on|On|ON|off|Off|OFF)$" +) + +_DESIRED_TOP_LEVEL_ORDER = ( + "Comment", + "Description", + "Identifier", + "ParentRecipe", + "MinimumVersion", + "Input", + "Process", + "ParentRecipeTrustInfo", +) + +_TOP_LEVEL_TRIGGERS = ( + "Input:", + "Process:", + "ParentRecipeTrustInfo:", + "- Processor:", +) + + +def _represent_str_bool_safe(representer, data): + if _YAML_11_BOOL_RE.match(data): + return representer.represent_scalar("tag:yaml.org,2002:str", data, style="'") + return representer.represent_scalar("tag:yaml.org,2002:str", data) + + +def build_yaml() -> ruamel.yaml.YAML: + """Build a round-trip YAML instance configured for AutoPkg recipes.""" + yaml = ruamel.yaml.YAML(typ="rt") + yaml.width = float("inf") + yaml.default_flow_style = False + yaml.preserve_quotes = True + yaml.indent(mapping=2, sequence=2, offset=0) + yaml.representer.add_representer(str, _represent_str_bool_safe) + return yaml + + +def _reorder_recipe(recipe) -> None: + """Reorder a recipe in place for readability.""" + process = recipe.get("Process") + if process: + for processor in process: + if "Comment" in processor: + processor.move_to_end("Comment") + if "Arguments" in processor: + processor.move_to_end("Arguments") + + input_block = recipe.get("Input") + if input_block is not None and "NAME" in input_block: + input_block.move_to_end("NAME", last=False) + + for key in _DESIRED_TOP_LEVEL_ORDER: + if key in recipe: + recipe.move_to_end(key) + + +def _insert_section_blank_lines(output: str) -> str: + """Ensure a single blank line precedes each top-level recipe section.""" + result: List[str] = [] + for line in output.split("\n"): + if not line.startswith(_TOP_LEVEL_TRIGGERS): + result.append(line) + continue + + while result and result[-1] == "": + result.pop() + + is_first_processor = ( + line.startswith("- Processor:") + and result + and result[-1].rstrip() == "Process:" + ) + if result and not is_first_processor: + result.append("") + result.append(line) + + return "\n".join(result) + + +def tidy_recipe(path: str, yaml: ruamel.yaml.YAML) -> None: + """Tidy a single AutoPkg YAML recipe in place.""" + with open(path) as in_file: + original = in_file.read() + + recipe = yaml.load(original) + if recipe is None: + return + + _reorder_recipe(recipe) + + buf = io.StringIO() + yaml.dump(recipe, buf) + formatted = _insert_section_blank_lines(buf.getvalue()) + + # Skip the write so pre-commit doesn't flag the file as modified on a no-op. + if formatted == original: + return + + with open(path, "w") as out_file: + out_file.write(formatted) + + +def build_argument_parser() -> argparse.ArgumentParser: + """Build and return the argument parser.""" + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("filenames", nargs="*", help="Filenames to format.") + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + """Main process.""" + argparser = build_argument_parser() + args = argparser.parse_args(argv) + + yaml = build_yaml() + retval = 0 + for filename in args.filenames: + try: + tidy_recipe(filename, yaml) + except DuplicateKeyError as err: + print(f"{filename}: yaml duplicate key: {err}") + retval = 1 + except ruamel.yaml.YAMLError as err: + print(f"{filename}: yaml parsing error: {err}") + retval = 1 + except Exception as err: + print(f"{filename}: unexpected error: {err}") + retval = 1 + + return retval + + +if __name__ == "__main__": + exit(main()) diff --git a/setup.py b/setup.py index 38fd3c8..aa0a777 100755 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ "check-preference-manifests = pre_commit_macadmin_hooks.check_preference_manifests:main", "forbid-autopkg-overrides = pre_commit_macadmin_hooks.forbid_autopkg_overrides:main", "forbid-autopkg-trust-info = pre_commit_macadmin_hooks.forbid_autopkg_trust_info:main", + "format-autopkg-yaml-recipes = pre_commit_macadmin_hooks.format_autopkg_yaml_recipes:main", "format-xml-plist = pre_commit_macadmin_hooks.format_xml_plist:main", "munki-makecatalogs = pre_commit_macadmin_hooks.munki_makecatalogs:main", ] diff --git a/tests/test_format_autopkg_yaml_recipes.py b/tests/test_format_autopkg_yaml_recipes.py new file mode 100644 index 0000000..1ac95a0 --- /dev/null +++ b/tests/test_format_autopkg_yaml_recipes.py @@ -0,0 +1,164 @@ +#!/usr/bin/python +"""Tests for the format_autopkg_yaml_recipes hook.""" + +import tempfile +import unittest +from pathlib import Path + +import ruamel.yaml + +from pre_commit_macadmin_hooks import format_autopkg_yaml_recipes + + +class TestFormatAutopkgYamlRecipes(unittest.TestCase): + + def setUp(self): + self._paths = [] + + def tearDown(self): + for path in self._paths: + Path(path).unlink(missing_ok=True) + + def _write(self, content: str) -> str: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".recipe.yaml", delete=False + ) as tmp: + tmp.write(content) + self._paths.append(tmp.name) + return tmp.name + + def _read(self, path: str) -> str: + with open(path) as f: + return f.read() + + def test_idempotent(self): + path = self._write( + "Identifier: com.example.test\n" + "Description: A test recipe.\n" + "Input:\n" + " NAME: TestApp\n" + "Process:\n" + " - Processor: URLDownloader\n" + " Arguments:\n" + " url: https://example.com/file.dmg\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + once = self._read(path) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + self.assertEqual(once, self._read(path)) + + def test_top_level_key_reorder(self): + path = self._write( + "Process:\n" + " - Processor: EndOfCheckPhase\n" + "Input:\n" + " NAME: TestApp\n" + "Identifier: com.example.test\n" + "Description: A test recipe.\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + text = self._read(path) + order = [ + text.index("Description:"), + text.index("Identifier:"), + text.index("Input:"), + text.index("Process:"), + ] + self.assertEqual(order, sorted(order)) + + def test_input_name_moved_first(self): + path = self._write( + "Identifier: com.example.test\n" + "Input:\n" + " DOWNLOAD_URL: https://example.com\n" + " NAME: TestApp\n" + " VERSION: '1.0'\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + text = self._read(path) + input_idx = text.index("Input:") + name_idx = text.index("NAME:", input_idx) + url_idx = text.index("DOWNLOAD_URL:", input_idx) + self.assertLess(name_idx, url_idx) + + def test_processor_arguments_moved_last(self): + path = self._write( + "Identifier: com.example.test\n" + "Process:\n" + " - Arguments:\n" + " url: https://example.com/file.dmg\n" + " Comment: Download the thing.\n" + " Processor: URLDownloader\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + text = self._read(path) + proc_idx = text.index("Processor:") + comment_idx = text.index("Comment:", proc_idx) + args_idx = text.index("Arguments:", proc_idx) + self.assertLess(proc_idx, comment_idx) + self.assertLess(comment_idx, args_idx) + + def test_blank_line_before_process_but_not_before_first_processor(self): + path = self._write( + "Identifier: com.example.test\n" + "Input:\n" + " NAME: TestApp\n" + "Process:\n" + " - Processor: EndOfCheckPhase\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + lines = self._read(path).split("\n") + process_idx = lines.index("Process:") + self.assertEqual(lines[process_idx - 1], "") + self.assertTrue(lines[process_idx + 1].startswith("- Processor:")) + + def test_yes_no_strings_preserved(self): + path = self._write( + "Identifier: com.example.test\n" + "Input:\n" + " NAME: TestApp\n" + " DERIVE_MIN_OS: 'YES'\n" + " SOMETHING_ELSE: 'NO'\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + text = self._read(path) + self.assertIn("'YES'", text) + self.assertIn("'NO'", text) + yaml = ruamel.yaml.YAML(typ="safe") + with open(path) as f: + data = yaml.load(f) + self.assertEqual(data["Input"]["DERIVE_MIN_OS"], "YES") + self.assertIsInstance(data["Input"]["DERIVE_MIN_OS"], str) + + def test_comments_preserved(self): + path = self._write( + "Identifier: com.example.test # the recipe id\n" + "Input:\n" + " NAME: TestApp\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + self.assertIn("# the recipe id", self._read(path)) + + def test_invalid_yaml_returns_one(self): + path = self._write("Identifier: com.example.test\n : : bad\n") + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 1) + + def test_multiple_files(self): + path1 = self._write("Identifier: com.example.one\nInput:\n NAME: One\n") + path2 = self._write("Identifier: com.example.two\nInput:\n NAME: Two\n") + self.assertEqual(format_autopkg_yaml_recipes.main([path1, path2]), 0) + self.assertIn("com.example.one", self._read(path1)) + self.assertIn("com.example.two", self._read(path2)) + + def test_already_formatted_does_not_rewrite(self): + path = self._write( + "Identifier: com.example.test\n" "Input:\n" " NAME: TestApp\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + mtime_after_first = Path(path).stat().st_mtime_ns + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + self.assertEqual(Path(path).stat().st_mtime_ns, mtime_after_first) + + +if __name__ == "__main__": + unittest.main()