Skip to content

Instantly share code, notes, and snippets.

@tigattack
Last active November 11, 2025 21:22
Show Gist options
  • Select an option

  • Save tigattack/3eb895c5416d9ccc24a8236ec6eb4184 to your computer and use it in GitHub Desktop.

Select an option

Save tigattack/3eb895c5416d9ccc24a8236ec6eb4184 to your computer and use it in GitHub Desktop.
Generate Markdown docs from Ansible argument_specs.yml

This script will generate Markdown sections from the contents of an Ansible role's meta/argument_specs.yml.

Each variable found in the argument specs will result in a block in the following style:

option_name

Type Default Required
string default value here ❌/✅

Raw, this looks like:

### `option_name`

| Type   | Default              | Required |
| ------ | -------------------- | -------- |
| string | `default value here` | ❌/✅    |

Features

  • The generated markdown will be inserted into the README.md file between the markers below if they are found. If they are not found, the markdown will be dumped to stdout.
    • Begin marker: <!-- BEGIN ANSIBLE ROLE VARIABLES -->
    • End marker: <!-- END ANSIBLE ROLE VARIABLES -->
  • Any single quotes (') that seem to be wrapping a value (as opposed to being used as grammatical syntax, e.g. as an apostrophe) will be replaced with backticks. For example:
    • Before: Set this variable's value to 'null' to omit
    • After: Set this variable's value to null to omit
  • Bare URLs will be wrapped in angle brackets (<>).
  • Any lines in the description keys starting with one of the following (case-insensitive) will be converted to a matching GitHub markdown callout (as shown here):
    • NOTE:
    • TIP:
    • IMPORTANT:
    • WARNING:
    • CAUTION:

Examples

For example, given the following meta/argument_specs.yml:

argument_specs:
  main:
    ...
    options:
      myapp_int:
        type: "int"
        required: false
        default: 42
        description:
          - "The integer value, defaulting to '42'."
          - "This is a second paragraph."

      myapp_str:
        type: "str"
        required: true
        description: "The string's value description."

      myapp_list:
        type: "list"
        elements: "str"
        required: true
        description:
          - "A list of string values."
          - "Note: This is a notice callout"
        version_added: 1.3.0

The following Markdown will be generated:

myapp_int

Type Default Required
int 42

The integer value, defaulting to 42.

This is a second paragraph.

myapp_str

Type Default Required
string None

The string's value description.

myapp_list

Type Default Required
list[str] None

A list of string values.

Note

This is a notice callout


Raw, this looks like:

### `myapp_int`

| Type | Default | Required |
| ---- | ------- | -------- |
| int  | `42`    ||

The integer value, defaulting to `42`.

This is a second paragraph.

### `myapp_str`

| Type   | Default | Required |
| ------ | ------- | -------- |
| string | None    ||

The string's value description.

### `myapp_list`

| Type      | Default | Required |
| --------- | ------- | -------- |
| list[str] | None    ||

A list of string values.

> [!NOTE]
> This is a notice callout
import re
import yaml
BEGIN_MARKER = "<!-- BEGIN ANSIBLE ROLE VARIABLES -->"
END_MARKER = "<!-- END ANSIBLE ROLE VARIABLES -->"
def convert_prefixes_to_callouts(text):
"""Convert NOTE:/WARNING:/etc. prefixes to GitHub callout syntax."""
callout_map = {
"NOTE:": "NOTE",
"TIP:": "TIP",
"IMPORTANT:": "IMPORTANT",
"WARNING:": "WARNING",
"CAUTION:": "CAUTION",
}
for prefix, callout_type in callout_map.items():
# Match lines starting with the prefix (case insensitive)
pattern = re.compile(
rf"^{re.escape(prefix)}\s*(.+)$", re.IGNORECASE | re.MULTILINE
)
text = pattern.sub(rf"> [!{callout_type}]\n> \1", text)
return text
def format_description(text):
"""Format description text for markdown output.
- Replace single quotes wrapping words with backticks
- Wrap bare URLs in angle brackets
- Convert NOTE:/WARNING:/etc. prefixes to GitHub callouts
"""
# Pattern handles: space/paren + quote + content + quote + space/paren/punctuation/end
pattern = r"(\s|\()'([^']+)'(\s|\)|[.,;:!?]|$)"
text = re.sub(pattern, r"\1`\2`\3", text)
# Wrap bare URLs in <>
# Match URLs not already in markdown links or angle brackets
url_pattern = r"(?<!\()(?<!\<)(https?://[^\s<>]+)(?!\))(?!\>)"
text = re.sub(url_pattern, r"<\1>", text)
# Convert prefixes to callouts
text = convert_prefixes_to_callouts(text)
return text
with open("meta/argument_specs.yml", "r") as f:
argspec = yaml.load(f, Loader=yaml.Loader)
argspec_options = argspec["argument_specs"]["main"]["options"]
# Initialise markdown content list with a leading newline. Only an empty string is needed to achieve this.
md: list[str] = [""]
for k, v in argspec_options.items():
type_str = "string" if v["type"] == "str" else v["type"]
if v.get("elements"):
type_str = f"{type_str}[{v['elements']}]"
default_str = v.get("default")
required = v.get("required", False)
required_str = "✅" if required else "❌"
description_extra = ""
# Length of type value
type_len = len(type_str)
# Lengths of header values
type_header_text = "Type" + " " * (type_len - 4) if type_len > 4 else "Type"
type_header_row = (
"-" * len(type_header_text) if len(type_header_text) > 4 else "----"
)
# Body value spacing
type_body_spacing = " " * (len(type_header_text) - type_len)
# Required column (fixed width)
required_header_text = "Required"
required_header_row = "--------"
required_body_spacing = " "
if default_str is not None:
# Format default value and wrap in backticks
match default_str:
case bool():
default_str = str(f"`{default_str}`").lower()
case str():
if len(default_str) > 0:
default_str = f"`{default_str}`"
else:
default_str = ""
case int():
default_str = str(f"`{default_str}`")
case dict():
if len(default_str.items()) > 0:
description_extra = "**Default:**"
for k, v in default_str.items():
description_extra += f"\n- `{k}`: `{v}`"
else:
default_str = "`{}`"
case list():
if len(default_str) > 0:
for i, v in enumerate(default_str):
default_str[i] = f"`{v}`"
default_str = ", ".join(default_str)
else:
default_str = "`{}`"
# Length of default value
default_len = len(str(default_str))
# Lengths of header values
default_header_text = (
"Default" + " " * (default_len - 7) if default_len > 7 else "Default"
)
default_header_row = (
"-" * len(default_header_text)
if len(default_header_text) > 7
else "-------"
)
# Body values spacing
default_body_spacing = " " * (len(default_header_text) - default_len)
else:
default_str = str(default_str)
default_header_text = "Default"
default_header_row = (
"-" * len(default_header_text)
if len(default_header_text) > 7
else "-------"
)
default_body_spacing = " " * (len(default_header_text) - len(default_str))
description = v.get("description")
description_fmt = (
description if isinstance(description, str) else "\n\n".join(description)
)
# Format description text
description_fmt = format_description(description_fmt)
if description_extra:
description_fmt += f"\n\n{description_extra}"
choices = v.get("choices")
if choices:
choices = [str(f"`{c}`") for c in choices]
description_fmt += f"\n\nMust be one of: {", ".join(choices)}."
md.append(f"""### `{k}`
| {type_header_text} | {default_header_text} | {required_header_text} |
| {type_header_row} | {default_header_row} | {required_header_row} |
| {type_str}{type_body_spacing} | {default_str}{default_body_spacing} | {required_str}{required_body_spacing} |
{description_fmt}
""")
# Read README and find markers
try:
with open("README.md", "r") as f:
readme_content = f.read()
# Find the markers
begin_index = readme_content.find(BEGIN_MARKER)
end_index = readme_content.find(END_MARKER)
if begin_index == -1 or end_index == -1:
raise ValueError("Markers not found")
# Replace content between markers
new_content = (
readme_content[: begin_index + len(BEGIN_MARKER)]
+ "\n"
+ "\n".join(md)
+ "\n"
+ readme_content[end_index:]
)
# Write updated README
with open("README.md", "w") as f:
f.write(new_content)
print("README.md updated")
except (FileNotFoundError, ValueError):
# Fall back to stdout if README.md not found or markers not found
print("\n".join(md))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment