390 lines
13 KiB
Python
390 lines
13 KiB
Python
|
#!/usr/bin/env python3
|
||
|
import argparse
|
||
|
import os
|
||
|
import os.path
|
||
|
import ansible
|
||
|
from packaging import version
|
||
|
import ansible.modules
|
||
|
from ansible.utils.plugin_docs import get_docstring
|
||
|
from ansible.plugins.loader import fragment_loader
|
||
|
from typing import Any, List
|
||
|
|
||
|
OUTPUT_FILENAME = "ansible.snippets"
|
||
|
OUTPUT_STYLE = ["multiline", "dictionary"]
|
||
|
HEADER = [
|
||
|
"# NOTE: This file is auto-generated. Modifications may be overwritten.",
|
||
|
"priority -50",
|
||
|
]
|
||
|
MAX_DESCRIPTION_LENGTH = 512
|
||
|
ANSIBLE_VERSION = ansible.release.__version__
|
||
|
|
||
|
|
||
|
def get_files_builtin() -> List[str]:
|
||
|
"""Return the sorted list of all module files that ansible provides with the ansible package
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
List[str]
|
||
|
A list of strings representing the Python module files provided by
|
||
|
Ansible
|
||
|
"""
|
||
|
|
||
|
file_names: List[str] = []
|
||
|
for root, dirs, files in os.walk(os.path.dirname(ansible.modules.__file__)):
|
||
|
files_without_symlinks = []
|
||
|
for f in files:
|
||
|
if not os.path.islink(os.path.join(root, f)):
|
||
|
files_without_symlinks.append(f)
|
||
|
file_names += [
|
||
|
f"{root}/{file_name}"
|
||
|
for file_name in files_without_symlinks
|
||
|
if file_name.endswith(".py") and not file_name.startswith("__init__")
|
||
|
]
|
||
|
|
||
|
return sorted(file_names)
|
||
|
|
||
|
|
||
|
def get_files_collections(user: bool = False) -> List[str]:
|
||
|
"""Return the sorted list of all module files provided by collections installed in either
|
||
|
the system folder /usr/share/ansible/collections/ or user folder ~/.ansible/collections/
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
user: bool (default: False)
|
||
|
A boolean indicating whether to get collections installed in the user folder
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
List[str]
|
||
|
A list of strings representing the Python module files provided by collections
|
||
|
"""
|
||
|
|
||
|
if user:
|
||
|
collection_path = '~/.ansible/collections/ansible_collections/'
|
||
|
else:
|
||
|
collection_path = '/usr/share/ansible/collections/ansible_collections/'
|
||
|
|
||
|
file_names: List[str] = []
|
||
|
for root, dirs, files in os.walk(os.path.expanduser(collection_path)):
|
||
|
files_without_symlinks = []
|
||
|
for f in files:
|
||
|
if not os.path.islink(os.path.join(root, f)):
|
||
|
files_without_symlinks.append(f)
|
||
|
file_names += [
|
||
|
f"{root}/{file_name}"
|
||
|
for file_name in files_without_symlinks
|
||
|
if file_name.endswith(".py") and not file_name.startswith("__init__") and "plugins/modules" in root
|
||
|
]
|
||
|
|
||
|
return sorted(file_names)
|
||
|
|
||
|
|
||
|
def get_module_docstring(file_path: str) -> Any:
|
||
|
"""Extract and return docstring information from a module file
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
file_names: file_path[str]
|
||
|
string representing module file
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
Any
|
||
|
An AnsibleMapping object, representing docstring information
|
||
|
(in dict form), excluding those that are marked as deprecated.
|
||
|
|
||
|
"""
|
||
|
|
||
|
docstring = get_docstring(file_path, fragment_loader)[0]
|
||
|
|
||
|
if docstring and not docstring.get("deprecated"):
|
||
|
return docstring
|
||
|
|
||
|
|
||
|
def escape_strings(escapist: str) -> str:
|
||
|
"""Escapes strings as required for ultisnips snippets
|
||
|
|
||
|
Escapes instances of \\, `, {, }, $
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
escapist: str
|
||
|
A string to apply string replacement on
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
str
|
||
|
The input string with all defined replacements applied
|
||
|
"""
|
||
|
|
||
|
return (
|
||
|
escapist.replace("\\", "\\\\")
|
||
|
.replace("`", r"\`")
|
||
|
.replace("{", r"\{")
|
||
|
.replace("}", r"\}")
|
||
|
.replace("$", r"\$")
|
||
|
.replace("\"", "'")
|
||
|
)
|
||
|
|
||
|
|
||
|
def option_data_to_snippet_completion(option_data: Any) -> str:
|
||
|
"""Convert Ansible option info into a string used for ultisnip completion
|
||
|
|
||
|
Converts data about an Ansible module option (retrieved from an
|
||
|
AnsibleMapping object) into a formatted string that can be used within an
|
||
|
UltiSnip macro.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
option_data: Any
|
||
|
The option parameters
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
str
|
||
|
A string representing one formatted option parameter
|
||
|
"""
|
||
|
|
||
|
# join descriptions that are provided as lists and crop them
|
||
|
description = escape_strings(
|
||
|
"".join(option_data.get("description"))[0:MAX_DESCRIPTION_LENGTH]
|
||
|
)
|
||
|
default = option_data.get("default")
|
||
|
choices = option_data.get("choices")
|
||
|
option_type = option_data.get("type")
|
||
|
|
||
|
# if the option is of type "bool" return "yes" or "no"
|
||
|
if option_type and "bool" in option_type:
|
||
|
if default in [True, "True", "true", "yes"]:
|
||
|
return "true"
|
||
|
if default in [False, "False", "false", "no"]:
|
||
|
return "false"
|
||
|
|
||
|
# if there is no default and no choices, return the description
|
||
|
if not choices and default is None and not args.no_description:
|
||
|
return f"# {description}"
|
||
|
|
||
|
# if there is a default but no choices return the default as string
|
||
|
if default is not None and not choices:
|
||
|
if len(str(default)) == 0:
|
||
|
return '""'
|
||
|
else:
|
||
|
if isinstance(default, str) and "\\" in default:
|
||
|
return f'"{escape_strings(str(default))}"'
|
||
|
elif isinstance(default, str):
|
||
|
return escape_strings(str(default))
|
||
|
else:
|
||
|
return default
|
||
|
|
||
|
# if there is a default and there are choices return the list of choices
|
||
|
# with the default prefixed with #
|
||
|
if default is not None and choices:
|
||
|
if isinstance(default, list):
|
||
|
# prefix default choice(s)
|
||
|
prefixed_choices = [
|
||
|
f"#{choice}" if choice in default else f"{choice}" for choice in choices
|
||
|
]
|
||
|
return str(prefixed_choices)
|
||
|
else:
|
||
|
# prefix default choice
|
||
|
prefixed_choices = [
|
||
|
f"#{choice}" if str(choice) == str(default) else f"{choice}"
|
||
|
for choice in choices
|
||
|
]
|
||
|
return "|".join(prefixed_choices)
|
||
|
|
||
|
# if there are choices but no default, return the choices as pipe separated
|
||
|
# list
|
||
|
if choices and default is None:
|
||
|
return "|".join([str(choice) for choice in choices])
|
||
|
|
||
|
# as fallback return empty string
|
||
|
return ""
|
||
|
|
||
|
|
||
|
def module_options_to_snippet_options(module_options: Any) -> List[str]:
|
||
|
"""Convert module options to UltiSnips snippet options
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
module_options: Any
|
||
|
The "options" attribute of an AnsibleMapping object
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
List[str]
|
||
|
A list of strings representing converted options
|
||
|
"""
|
||
|
|
||
|
options: List[str] = []
|
||
|
delimiter = ": " if args.style == "dictionary" else "="
|
||
|
|
||
|
if not module_options:
|
||
|
return options
|
||
|
|
||
|
# order by option name
|
||
|
module_options = sorted(module_options.items(), key=lambda x: x[0])
|
||
|
# order by "required" attribute
|
||
|
module_options = sorted(
|
||
|
module_options, key=lambda x: x[1].get("required", False), reverse=True
|
||
|
)
|
||
|
|
||
|
# insert an empty option above the list of non-required options
|
||
|
for index, (_, option) in enumerate(module_options):
|
||
|
if not option.get("required") and not args.comment_non_required:
|
||
|
if index != 0:
|
||
|
module_options.insert(index, (None, None))
|
||
|
break
|
||
|
|
||
|
for index, (name, option_data) in enumerate(module_options, start=1):
|
||
|
# insert a line to separate required/non-required options
|
||
|
if not name and not option_data:
|
||
|
options += [""]
|
||
|
else:
|
||
|
# set comment character for non-required options
|
||
|
if not option_data.get("required") and args.comment_non_required:
|
||
|
comment = "#"
|
||
|
else:
|
||
|
comment = ""
|
||
|
# the free_form option in some modules are special
|
||
|
if name == "free_form":
|
||
|
options += [
|
||
|
f"\t{comment}${{{index}:{name}{delimiter}{option_data_to_snippet_completion(option_data)}}}"
|
||
|
]
|
||
|
else:
|
||
|
options += [
|
||
|
f"\t{comment}{name}{delimiter}${{{index}:{option_data_to_snippet_completion(option_data)}}}"
|
||
|
]
|
||
|
|
||
|
return options
|
||
|
|
||
|
|
||
|
def convert_docstring_to_snippet(convert_docstring: Any, collection_name) -> List[str]:
|
||
|
"""Converts data about an Ansible module into an UltiSnips snippet string
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
convert_docstring: Any
|
||
|
An AnsibleMapping object representing the docstring for an Ansible
|
||
|
module
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
str
|
||
|
A string representing an ultisnips compatible snippet of an Ansible
|
||
|
module
|
||
|
"""
|
||
|
|
||
|
snippet: List[str] = []
|
||
|
snippet_options = "b"
|
||
|
if "module" in convert_docstring.keys():
|
||
|
module_name = convert_docstring["module"]
|
||
|
module_short_description = convert_docstring["short_description"]
|
||
|
|
||
|
# use only the module name if ansible version < 2.10
|
||
|
if version.parse(ANSIBLE_VERSION) < version.parse("2.10"):
|
||
|
snippet_module_name = f"{module_name}:"
|
||
|
# use FQCN if ansible version is 2.10 or higher
|
||
|
else:
|
||
|
snippet_module_name = f"{collection_name}.{module_name}:"
|
||
|
|
||
|
snippet += [f'snippet {module_name} "{escape_strings(module_short_description)}" {snippet_options}']
|
||
|
if args.style == "dictionary":
|
||
|
snippet += [f"{snippet_module_name}"]
|
||
|
else:
|
||
|
snippet += [f"{snippet_module_name}:{' >' if convert_docstring.get('options') else ''}"]
|
||
|
module_options = module_options_to_snippet_options(convert_docstring.get("options"))
|
||
|
snippet += module_options
|
||
|
snippet += ["endsnippet"]
|
||
|
|
||
|
return snippet
|
||
|
|
||
|
def get_collection_name(filepath:str) -> str:
|
||
|
""" Returns the collection name for a full file path """
|
||
|
|
||
|
path_splitted = filepath.split('/')
|
||
|
|
||
|
collection_top_folder_index = path_splitted.index('ansible_collections')
|
||
|
collection_namespace = path_splitted[collection_top_folder_index + 1]
|
||
|
collection_name = path_splitted[collection_top_folder_index + 2]
|
||
|
|
||
|
# print(f"{collection_namespace}.{collection_name}")
|
||
|
return f"{collection_namespace}.{collection_name}"
|
||
|
|
||
|
|
||
|
if __name__ == "__main__":
|
||
|
|
||
|
parser = argparse.ArgumentParser()
|
||
|
parser.add_argument(
|
||
|
"--output",
|
||
|
help=f"Output filename (default: {OUTPUT_FILENAME})",
|
||
|
default=OUTPUT_FILENAME,
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"--style",
|
||
|
help=f"YAML format used for snippets (default: {OUTPUT_STYLE[0]})",
|
||
|
choices=OUTPUT_STYLE,
|
||
|
default=OUTPUT_STYLE[0],
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
'--user',
|
||
|
help="Include user modules",
|
||
|
action="store_true",
|
||
|
default=False
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
'--no-description',
|
||
|
help="Remove options description",
|
||
|
action="store_true",
|
||
|
default=False
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
'--comment-non-required',
|
||
|
help="Comment non-required options",
|
||
|
action="store_true",
|
||
|
default=False
|
||
|
)
|
||
|
args = parser.parse_args()
|
||
|
|
||
|
|
||
|
if version.parse(ANSIBLE_VERSION) < version.parse("2.10"):
|
||
|
print(f"ansible version {ANSIBLE_VERSION} doesn't support FQCN")
|
||
|
print("generated snippets will only use the module name e.g. 'yum' instead of 'ansible.builtin.yum'")
|
||
|
else:
|
||
|
print(f"ansible version {ANSIBLE_VERSION} supports using FQCN")
|
||
|
print("Generated snippets will use FQCN e.g. 'ansible.builtin.yum' instead of 'yum'")
|
||
|
print("Still, you only need to type 'yum' to trigger the snippet")
|
||
|
|
||
|
modules_docstrings = []
|
||
|
|
||
|
builtin_modules_paths = get_files_builtin()
|
||
|
for f in builtin_modules_paths:
|
||
|
docstring_builtin = get_module_docstring(f)
|
||
|
if docstring_builtin and docstring_builtin not in modules_docstrings:
|
||
|
docstring_builtin['collection_name'] = "ansible.builtin"
|
||
|
modules_docstrings.append(docstring_builtin)
|
||
|
|
||
|
system_modules_paths = get_files_collections()
|
||
|
for f in system_modules_paths:
|
||
|
docstring_system = get_module_docstring(f)
|
||
|
if docstring_system and docstring_system not in modules_docstrings:
|
||
|
collection_name = get_collection_name(f)
|
||
|
docstring_system['collection_name'] = collection_name
|
||
|
modules_docstrings.append(docstring_system)
|
||
|
|
||
|
if args.user:
|
||
|
user_modules_paths = get_files_collections(user=True)
|
||
|
for f in user_modules_paths:
|
||
|
docstring_user = get_module_docstring(f)
|
||
|
if docstring_user and docstring_user not in modules_docstrings:
|
||
|
collection_name = get_collection_name(f)
|
||
|
docstring_user['collection_name'] = collection_name
|
||
|
modules_docstrings.append(docstring_user)
|
||
|
|
||
|
with open(args.output, "w") as f:
|
||
|
f.writelines(f"{header}\n" for header in HEADER)
|
||
|
for docstring in modules_docstrings:
|
||
|
f.writelines(
|
||
|
f"{line}\n" for line in convert_docstring_to_snippet(docstring, docstring.get("collection_name"))
|
||
|
)
|