Add public function documentation script (#5253)

* Remove obsolete event rename script

* Add initial function documenting python script

- Currently only finds all public/invalid function files
- Method "document_public" is in place to handle the data taken from a
  function header, but currently unimplemented

* Add author/description/arguments processing

* Improve console logging and add return processing

* Use class based approach for better data handling

* Add argument processing with support for notes

* Implement rudimentary doc output

* Add return and example processing

* Fix example variable

* Fix documenting no arguments/return

* Fix malformed return handling

* Improve control flow
This commit is contained in:
SilentSpike 2019-03-23 22:21:33 +00:00 committed by Thomas Kooi
parent bc2d84289b
commit 0cd904bdc3
2 changed files with 306 additions and 115 deletions

View File

@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
Author: SilentSpike
Crawl function headers to produce appropriate documentation of public functions.
Supported header sections:
- Author(s) (with description below)
- Arguments
- Return Value
- Example(s)
- Public (by default function will only be documented if set to "Yes")
EXAMPLES
document_functions common --debug
Crawl only functions in addons/common and only reports debug messages.
"""
import os
import sys
import re
import argparse
class FunctionFile:
def __init__(self, directory="."):
self.directory = directory
# False unless specified in processing
self.debug = False
# Empty until imported from file
self.path = ""
self.header = ""
# Defaults until header is processed
self.public = False
self.authors = []
self.description = ""
self.arguments = []
self.return_value = []
self.example = ""
# Filepath should only be logged once
self.logged = False
def import_header(self, file_path):
self.path = file_path
with open(file_path) as file:
code = file.read()
header_match = re.match(r"\s*/\*.+?\*/", code, re.S)
if header_match:
self.header = header_match.group(0)
else:
self.feedback("Missing header", 3)
def has_header(self):
return bool(self.header)
def process_header(self, debug=False):
# Detailed debugging occurs here so value is set
self.debug = debug
# Preemptively cut away the comment characters (and leading/trailing whitespace)
self.header_text = "\n".join([x[3:].strip() for x in self.header.splitlines()])
# Split the header into expected sections
self.sections = re.split(r"^(Author|Argument|Return Value|Example|Public)s?:\s?", self.header_text, 0, re.M)
# If public section is missing we can't continue
public_raw = self.get_section("Public")
if not public_raw:
self.feedback("Public value undefined", 3)
return
# Determine whether the header is public
self.public = self.process_public(public_raw)
# Don't bother to process the rest if private
# Unless in debug mode
if not self.public and not self.debug:
return
# Retrieve the raw sections text for processing
author_raw = self.get_section("Author")
arguments_raw = self.get_section("Argument")
return_value_raw = self.get_section("Return Value")
example_raw = self.get_section("Example")
# Author and description are stored in first section
if author_raw:
self.authors = self.process_author(author_raw)
self.description = self.process_description(author_raw)
if arguments_raw:
self.arguments = self.process_arguments(arguments_raw)
# Process return
if return_value_raw:
self.return_value = self.process_return_value(return_value_raw)
# Process example
if example_raw:
self.example = example_raw.strip()
def get_section(self, section_name):
try:
section_text = self.sections[self.sections.index(section_name) + 1]
return section_text
except ValueError:
self.feedback("Missing \"{}\" header section".format(section_name), 2)
return ""
def process_public(self, raw):
# Raw just includes an EOL character
public_text = raw[:-1]
if not re.match(r"(Yes|No)", public_text, re.I):
self.feedback("Invalid public value \"{}\"".format(public_text), 2)
return public_text.capitalize() == "Yes"
def is_public(self):
return self.public
def process_author(self, raw):
# Authors are listed on the first line
authors_text = raw.splitlines()[0]
# Seperate authors are divided by commas
return authors_text.split(", ")
def process_description(self, raw):
# Just use all the lines after the authors line
description_text = "".join(raw.splitlines(1)[1:])
return description_text
def process_arguments(self, raw):
lines = raw.splitlines()
if lines[0] == "None":
return []
if lines.count("") == len(lines):
self.feedback("No arguments provided (use \"None\" where appropriate)", 2)
return []
if lines[-1] == "":
lines.pop()
else:
self.feedback("No blank line after arguments list", 1)
arguments = []
for argument in lines:
valid = re.match(r"^(\d+):\s(.+?)\<([\s\w]+?)\>(\s\(default: (.+)\))?$", argument)
if valid:
arg_index = valid.group(1)
arg_name = valid.group(2)
arg_types = valid.group(3)
arg_default = valid.group(5)
arg_notes = []
if arg_index != str(len(arguments)):
self.feedback("Argument index {} does not match listed order".format(arg_index), 1)
if arg_default == None:
arg_default = ""
arguments.append([arg_index, arg_name, arg_types, arg_default, arg_notes])
else:
# Notes about the above argument won't start with an index
# Only applies if there exists an above argument
if re.match(r"^(\d+):", argument) or not arguments:
self.feedback("Malformed argument \"{}\"".format(argument), 2)
arguments.append(["?", "Malformed", "?", "?", []])
else:
arguments[-1][-1].append(argument)
return arguments
def process_return_value(self, raw):
return_value = raw.strip()
if return_value == "None":
return []
valid = re.match(r"^(.+?)\<([\s\w]+?)\>", return_value)
if valid:
return_name = valid.group(1)
return_types = valid.group(2)
else:
self.feedback("Malformed return value \"{}\"".format(return_value), 2)
return ["Malformed",""]
return [return_name, return_types]
def document(self, component):
str_list = []
# Title
str_list.append("\n## ace_{}_fnc_{}\n".format(component,os.path.basename(self.path)[4:-4]))
# Description
str_list.append("__Description__\n\n" + self.description)
# Arguments
if self.arguments:
str_list.append("__Parameters__\n\nIndex | Description | Datatype(s) | Default Value\n--- | --- | --- | ---\n")
for argument in self.arguments:
str_list.append("{} | {} | {} | {}\n".format(*argument))
str_list.append("\n")
else:
str_list.append("__Parameters__\n\nNone\n\n")
# Return Value
if self.return_value:
str_list.append("__Return Value__\n\nDescription | Datatype(s)\n--- | ---\n{} | {}\n\n".format(*self.return_value))
else:
str_list.append("__Return Value__\n\nNone\n\n")
# Example
str_list.append("__Example__\n\n```sqf\n{}\n```\n\n".format(self.example))
# Authors
str_list.append("\n__Authors__\n\n")
for author in self.authors:
str_list.append("- {}\n".format(author))
# Horizontal rule
str_list.append("\n---\n")
return ''.join(str_list)
def log_file(self, error=False):
# When in debug mode we only want to see the files with errors
if not self.debug or error:
if not self.logged:
rel_path = os.path.relpath(self.path, self.directory)
self.write("Processing... {}".format(rel_path), 1)
self.logged = True
def feedback(self, message, level=0):
priority_str = ["Info","Warning","Error","Aborted"][level]
self.log_file(level > 0)
self.write("{0}: {1}".format(priority_str, message))
def write(self, message, indent=2):
to_print = [" "]*indent
to_print.append(message)
print("".join(to_print))
def document_functions(components):
os.makedirs('../wiki/functions/', exist_ok=True)
for component in components:
output = os.path.join('../wiki/functions/',component) + ".md"
with open(output, "w") as file:
for function in components[component]:
file.write(function.document(component))
def crawl_dir(directory, debug=False):
components = {}
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith(".sqf") and file.startswith("fnc_"):
file_path = os.path.join(root, file)
# Attempt to import the header from file
function = FunctionFile(directory)
function.import_header(file_path)
# Undergo data extraction and detailed debug
if function.has_header():
function.process_header(debug)
if function.is_public() and not debug:
# Add functions to component key (initalise key if necessary)
component = os.path.basename(os.path.dirname(root))
components.setdefault(component,[]).append(function)
function.feedback("Publicly documented")
document_functions(components)
def main():
print("""
#########################
# Documenting Functions #
#########################
""")
parser = argparse.ArgumentParser()
parser.add_argument('directory', nargs="?", type=str, default=".", help='only crawl specified module addon folder')
parser.add_argument('--debug', action="store_true", help='only check for header debug messages')
args = parser.parse_args()
# abspath is just used for the terminal output
prospective_dir = os.path.abspath(os.path.join('../../addons/',args.directory))
if os.path.isdir(prospective_dir):
print("Directory: {}".format(prospective_dir))
crawl_dir(prospective_dir, args.debug)
else:
print("Invalid directory: {}".format(prospective_dir))
if __name__ == "__main__":
main()

View File

@ -1,115 +0,0 @@
import os
import sys
import re
un_capitalize = lambda s: s[:1].lower() + s[1:] if s else ''
def add_to_config(old, new):
with open(add_to_config.file, "r+") as file:
contents = file.read()
events_class = re.search(r"class\s+ACE_newEvents\s+{\n",contents,re.I)
if events_class:
newline_index = events_class.end()
insert = " {0} = \"{1}\";\n".format(old,new)
else:
newline_index = len(contents)
insert = "\nclass ACE_newEvents {{\n {0} = \"{1}\";\n}};".format(old,new)
contents = contents[:newline_index] + insert + contents[newline_index:]
file.seek(0)
file.write(contents)
file.truncate()
def event_replace(match):
event = un_capitalize(match.group(1))
add_to_config(match.group(1), "ace_" + event)
return "[\"ace_{0}\", {1}] call CBA_fnc_{2}".format(event,match.group(2),match.group(3))
def process_directory(dir, config=""):
if not config:
config = os.path.join(dir,"config.cpp")
if os.path.isfile(config):
add_to_config.file = config
for p in os.listdir(dir):
path = os.path.join(dir, p)
if os.path.isdir(path):
process_directory(path, config)
continue
ext = os.path.splitext(path)[1]
if ext not in [".sqf",".hpp",".cpp"]:
continue
with open(path, "r+") as file:
contents = file.read()
# Simple single-line substitutions
find = r"\[\s*\"(?!ace_)(\w+)\"\s*,\s*(.+?)\s*\]\s+call\s+CBA_fnc_((add|remove|local|global|target|server)Event(Handler)?)"
contents, subbed = re.subn(find,event_replace,contents,0,re.I)
# Handle multi-line code blocks
for match in re.finditer(r"\[\s*\"(?!ace_)(\w+)\"\s*,\s*({.+?})\s*\]\s+call\s*CBA_fnc_(add|remove)EventHandler",contents,re.I|re.S):
pair = 0
for i, c in enumerate(contents[match.start(2):]):
if c == "{":
pair += 1
elif c == "}":
pair -= 1
if pair == 0:
pair = i
break
if re.match(r"\s*\]\s+call\s+CBA_fnc_(add|remove)EventHandler",contents[pair+match.start(2)+1:],re.I):
event = un_capitalize(match.group(1))
add_to_config(match.group(1), "ace_" + event)
contents = contents[:match.start(1)] + "ace_" + event + contents[match.end(1):]
subbed += 1
# Handle multi-line argument arrays
for match in re.finditer(r"\[\s*\"(?!ace_)(\w+)\"\s*,\s*(\[.+?\])\s*\]\s+call\s*CBA_fnc_(local|global|server)Event",contents,re.I|re.S):
pair = 0
for i, c in enumerate(contents[match.start(2):]):
if c == "[":
pair += 1
elif c == "]":
pair -= 1
if pair == 0:
pair = i
break
if re.match(r"\s*\]\s+call\s+CBA_fnc_(local|global|server)Event",contents[pair+match.start(2)+1:],re.I):
event = un_capitalize(match.group(1))
add_to_config(match.group(1), "ace_" + event)
contents = contents[:match.start(1)] + "ace_" + event + contents[match.end(1):]
subbed += 1
if subbed > 0:
print("Making {0} substitutions: {1}".format(subbed,path))
file.seek(0)
file.write(contents)
file.truncate()
def main():
scriptpath = os.path.realpath(sys.argv[0])
projectpath = os.path.dirname(os.path.dirname(scriptpath))
addonspath = os.path.join(projectpath, "addons")
os.chdir(addonspath)
for p in os.listdir(addonspath):
path = os.path.join(addonspath, p)
if not os.path.isdir(path):
continue
if p[0] == ".":
continue
process_directory(path)
if __name__ == "__main__":
sys.exit(main())