Skip to content
Open
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ After adding a hook to your pre-commit config, it's not a bad idea to run `pre-c

- __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.
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, and comment-only lines are reindented to match the content they precede.

Pass `--no-blank-line-before-processor` (via the hook's `args`) to skip inserting a blank line before each `- Processor:` entry — useful when another hook manages spacing between processors, and it keeps a comment tight against the processor it documents.

### [Jamf](https://www.jamf.com/)

Expand Down
88 changes: 82 additions & 6 deletions pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,23 @@ def _reorder_recipe(recipe) -> None:
recipe.move_to_end(key)


def _insert_section_blank_lines(output: str) -> str:
"""Ensure a single blank line precedes each top-level recipe section."""
def _insert_section_blank_lines(
output: str, blank_line_before_processor: bool = True
) -> str:
"""Ensure a single blank line precedes each top-level recipe section.

When blank_line_before_processor is False, "- Processor:" lines are left
untouched (no blank line inserted before them), which keeps a comment tight
against the processor it documents. Use this when a separate hook manages
blank lines between processors.
"""
triggers = _TOP_LEVEL_TRIGGERS
if not blank_line_before_processor:
triggers = tuple(t for t in triggers if t != "- Processor:")

result: list[str] = []
for line in output.split("\n"):
if not line.startswith(_TOP_LEVEL_TRIGGERS):
if not line.startswith(triggers):
result.append(line)
continue

Expand All @@ -94,7 +106,55 @@ def _insert_section_blank_lines(output: str) -> str:
return "\n".join(result)


def tidy_recipe(path: str, yaml: ruamel.yaml.YAML) -> None:
def _realign_comments(output: str) -> str:
"""Reindent comment-only lines to match the surrounding content.

ruamel.yaml round-trips comments at their original absolute column, so a
comment does not move when the structural indentation around it changes
(most visibly on Process list items, which are re-emitted with the dash at
column 0). Reindent each comment-only line to the indentation of the next
content line, falling back to the previous content line for trailing
comments at the end of a block. Inline (trailing) comments are untouched
because they are not comment-only lines.
"""

def _indent_of(text: str) -> int:
return len(text) - len(text.lstrip(" "))

def _is_comment(text: str) -> bool:
return text.lstrip(" ").startswith("#")

lines = output.split("\n")
result = list(lines)
for idx, line in enumerate(lines):
if not _is_comment(line):
continue
target = next(
(
_indent_of(nxt)
for nxt in lines[idx + 1 :]
if nxt.strip() and not _is_comment(nxt)
),
None,
)
if target is None:
target = next(
(
_indent_of(prev)
for prev in reversed(result[:idx])
if prev.strip() and not _is_comment(prev)
),
None,
)
if target is not None:
result[idx] = " " * target + line.lstrip(" ")

return "\n".join(result)


def tidy_recipe(
path: str, yaml: ruamel.yaml.YAML, blank_line_before_processor: bool = True
) -> None:
"""Tidy a single AutoPkg YAML recipe in place."""
with open(path) as in_file:
original = in_file.read()
Expand All @@ -107,7 +167,10 @@ def tidy_recipe(path: str, yaml: ruamel.yaml.YAML) -> None:

buf = io.StringIO()
yaml.dump(recipe, buf)
formatted = _insert_section_blank_lines(buf.getvalue())
formatted = _insert_section_blank_lines(
buf.getvalue(), blank_line_before_processor=blank_line_before_processor
)
formatted = _realign_comments(formatted)

# Skip the write so pre-commit doesn't flag the file as modified on a no-op.
if formatted == original:
Expand All @@ -123,6 +186,15 @@ def build_argument_parser() -> argparse.ArgumentParser:
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("filenames", nargs="*", help="Filenames to format.")
parser.add_argument(
"--no-blank-line-before-processor",
action="store_true",
help=(
"Do not insert a blank line before each '- Processor:' entry. "
"Useful when a separate hook manages spacing between processors; "
"also keeps comments tight against the processor they document."
),
)
return parser


Expand All @@ -135,7 +207,11 @@ def main(argv: list[str] | None = None) -> int:
retval = 0
for filename in args.filenames:
try:
tidy_recipe(filename, yaml)
tidy_recipe(
filename,
yaml,
blank_line_before_processor=not args.no_blank_line_before_processor,
)
except DuplicateKeyError as err:
print(f"{filename}: yaml duplicate key: {err}")
retval = 1
Expand Down
64 changes: 64 additions & 0 deletions tests/test_format_autopkg_yaml_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,70 @@ def test_comments_preserved(self):
self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0)
self.assertIn("# the recipe id", self._read(path))

def test_comments_realigned_to_reindented_process(self):
# Process list indented 4 spaces; the formatter re-emits the dash at
# column 0, and the comment-only lines must follow the new indentation
# rather than keep their original absolute columns.
path = self._write(
"Identifier: com.example.test\n"
"Process:\n"
" # comment before first processor\n"
" - Processor: URLDownloader\n"
" Arguments:\n"
" # comment about url\n"
" url: https://example.com/file.dmg\n"
" # comment before second processor\n"
" - Processor: EndOfCheckPhase\n"
)
self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0)
lines = self._read(path).split("\n")
# Comments before a processor align with the dash (column 0).
self.assertIn("# comment before first processor", lines)
self.assertIn("# comment before second processor", lines)
# The comment inside Arguments aligns with its sibling key (column 4).
self.assertIn(" # comment about url", lines)
self.assertIn(" url: https://example.com/file.dmg", lines)

def test_no_blank_line_before_processor_keeps_comment_tight(self):
# With the flag, no blank line is inserted before "- Processor:", so a
# comment stays directly above the processor it documents.
path = self._write(
"Identifier: com.example.test\n"
"Process:\n"
" # download the app\n"
" - Processor: URLDownloader\n"
" Arguments:\n"
" url: https://example.com/file.dmg\n"
" # end the check phase\n"
" - Processor: EndOfCheckPhase\n"
)
self.assertEqual(
format_autopkg_yaml_recipes.main(
["--no-blank-line-before-processor", path]
),
0,
)
lines = self._read(path).split("\n")
# No blank line between the comment and its processor.
first = lines.index("# download the app")
self.assertEqual(lines[first + 1], "- Processor: URLDownloader")
second = lines.index("# end the check phase")
self.assertEqual(lines[second + 1], "- Processor: EndOfCheckPhase")

def test_blank_line_before_processor_is_default(self):
# Without the flag, the default behavior still inserts a blank line
# before non-first "- Processor:" entries.
path = self._write(
"Identifier: com.example.test\n"
"Process:\n"
" - Processor: URLDownloader\n"
" - Processor: EndOfCheckPhase\n"
)
self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0)
lines = self._read(path).split("\n")
second = lines.index("- Processor: EndOfCheckPhase")
self.assertEqual(lines[second - 1], "")

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)
Expand Down