-
Notifications
You must be signed in to change notification settings - Fork 740
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
2 changed files
with
306 additions
and
115 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
Oops, something went wrong.