Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down
154 changes: 154 additions & 0 deletions pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
164 changes: 164 additions & 0 deletions tests/test_format_autopkg_yaml_recipes.py
Original file line number Diff line number Diff line change
@@ -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()
Loading