Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Path: blob/master/Tools/scripts/annotate_params.py
Views: 1798
#!/usr/bin/env python312"""3This script fetches online ArduPilot parameter documentation (if not cached) and adds it to the specified file4or to all *.param and *.parm files in the specified directory.561. Checks if a local cache of the XML file exists in the target directory or on the directory of the target file:7- If it does, the script loads the file content.8- If it doesn't, the script sends a GET request to the URL to fetch the XML data for the requested vehicle type.92. Parses the XML data and creates a dictionary of parameter documentation.103. DELETES all comments that start at the beginning of a line114. Adds the parameter documentation to the target file or to all *.param,*.parm files in the target directory.1213Supports AP_Periph, AntennaTracker, ArduCopter, ArduPlane, ArduSub, Blimp, Heli, Rover and SITL vehicle types14Supports both Mission Planner and MAVProxy file formats15Supports sorting the parameters16Has unit tests with 88% coverage1718AP_FLAKE8_CLEAN1920Author: Amilcar do Carmo Lucas, IAV GmbH21"""2223import os24import glob25import re26from typing import Any, Dict, List, Tuple27import xml.etree.ElementTree as ET28import argparse29import logging3031# URL of the XML file32BASE_URL = "https://autotest.ardupilot.org/Parameters/"3334PARAM_DEFINITION_XML_FILE = "apm.pdef.xml"3536# ArduPilot parameter names start with a capital letter and can have capital letters, numbers and _37PARAM_NAME_REGEX = r'^[A-Z][A-Z_0-9]*'38PARAM_NAME_MAX_LEN = 1639VERSION = '1.0'404142def arg_parser():43parser = argparse.ArgumentParser(description='Fetches on-line ArduPilot parameter documentation and adds it to the '44'specified file or to all *.param and *.parm files in the specified directory.')45parser.add_argument('target',46help='The target file or directory.',47)48parser.add_argument('-s', '--sort',49choices=['none', 'missionplanner', 'mavproxy'],50default='none',51help='Sort the parameters in the file. Defaults to not sorting.',52)53parser.add_argument('-t', '--vehicle-type',54choices=['AP_Periph', 'AntennaTracker', 'ArduCopter', 'ArduPlane',55'ArduSub', 'Blimp', 'Heli', 'Rover', 'SITL'],56default='ArduCopter',57help='The type of the vehicle. Defaults to ArduCopter',58)59parser.add_argument('--verbose', action='store_true',60help='Increase output verbosity, print ReadOnly parameter list. Defaults to false',61)62parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {VERSION}',63help='Display version information and exit.',64)65args = parser.parse_args()6667if args.verbose:68logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')69else:70logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s')7172return args737475def get_xml_data(base_url: str, directory: str, filename: str) -> ET.Element:76"""77Fetches XML data from a local file or a URL.7879Args:80base_url (str): The base URL for fetching the XML file.81directory (str): The directory where the XML file is expected.82filename (str): The name of the XML file.8384Returns:85ET.Element: The root element of the parsed XML data.86"""87file_path = os.path.join(directory, filename)88# Check if the locally cached file exists89if os.path.isfile(file_path):90# Load the file content relative to the script location91with open(file_path, "r", encoding="utf-8") as file:92xml_data = file.read()93else:94# No locally cached file exists, get it from the internet95try:96import requests # pylint: disable=C041597except ImportError:98logging.error("The requests package was not found")99logging.error("Please install it by running 'pip install requests' in your terminal.")100raise SystemExit("requests package is not installed") # pylint: disable=W0707101try:102# Send a GET request to the URL103response = requests.get(base_url + filename, timeout=5)104except requests.exceptions.RequestException as e:105logging.error("Unable to fetch XML data: %s", e)106raise SystemExit("unable to fetch online XML documentation") # pylint: disable=W0707107# Get the text content of the response108xml_data = response.text109# Write the content to a file110with open(os.path.join(directory, filename), "w", encoding="utf-8") as file:111file.write(xml_data)112113# Parse the XML data114root = ET.fromstring(xml_data)115return root116117118def remove_prefix(text: str, prefix: str) -> str:119"""120Removes a prefix from a string.121122Args:123text (str): The original string.124prefix (str): The prefix to remove.125126Returns:127str: The string without the prefix.128"""129if text.startswith(prefix):130return text[len(prefix):]131return text132133134def split_into_lines(string_to_split: str, maximum_line_length: int) -> List[str]:135"""136Splits a string into lines of a maximum length.137138Args:139string_to_split (str): The string to split.140maximum_line_length (int): The maximum length of a line.141142Returns:143List[str]: The list of lines.144"""145doc_lines = re.findall(146r".{1," + str(maximum_line_length) + r"}(?:\s|$)", string_to_split147)148# Remove trailing whitespace from each line149return [line.rstrip() for line in doc_lines]150151152def create_doc_dict(root: ET.Element, vehicle_type: str) -> Dict[str, Any]:153"""154Create a dictionary of parameter documentation from the root element of the parsed XML data.155156Args:157root (ET.Element): The root element of the parsed XML data.158159Returns:160Dict[str, Any]: A dictionary of parameter documentation.161"""162# Dictionary to store the parameter documentation163doc = {}164165# Use the findall method with an XPath expression to find all "param" elements166for param in root.findall(".//param"):167name = param.get("name")168# Remove the <vehicle_type>: prefix from the name if it exists169name = remove_prefix(name, vehicle_type + ":")170171human_name = param.get("humanName")172documentation = split_into_lines(param.get("documentation"), 100)173# the keys are the "name" attribute of the "field" sub-elements174# the values are the text content of the "field" sub-elements175fields = {field.get("name"): field.text for field in param.findall("field")}176# if Units and UnitText exist, combine them into a single element177delete_unit_text = False178for key, value in fields.items():179if key == "Units" and "UnitText" in fields:180fields[key] = f"{value} ({fields['UnitText']})"181delete_unit_text = True182if delete_unit_text:183del fields['UnitText']184# the keys are the "code" attribute of the "values/value" sub-elements185# the values are the text content of the "values/value" sub-elements186values = {value.get("code"): value.text for value in param.findall("values/value")}187188# Dictionary with "Parameter names" as keys and the values is a189# dictionary with "humanName", "documentation" attributes and190# "fields", "values" sub-elements.191doc[name] = {192"humanName": human_name,193"documentation": documentation,194"fields": fields,195"values": values,196}197198return doc199200201def format_columns(values: Dict[str, Any], max_width: int = 105, max_columns: int = 4) -> List[str]:202"""203Formats a dictionary of values into column-major horizontally aligned columns.204It uses at most max_columns columns205206Args:207values (Dict[str, Any]): The dictionary of values to format.208max_width (int, optional): The maximum number of characters on all columns. Defaults to 105.209210Returns:211List[str]: The list of formatted strings.212"""213# Convert the dictionary into a list of strings214strings = [f"{k}: {v}" for k, v in values.items()]215216if (not strings) or (len(strings) == 0):217return []218219# Calculate the maximum length of the strings220max_len = max(len(s) for s in strings)221222# Determine the number of columns223# Column distribution will only happen if it results in more than 5 rows224# The strings will be distributed evenly across up-to max_columns columns.225for num_cols in range(max_columns, 0, -1):226if len(strings) // num_cols > 5 and (max_len + 2) * num_cols < max_width:227break228229# Calculate the column width230col_width = max_width // num_cols231232num_rows = (len(strings) + num_cols - 1) // num_cols233234formatted_strings = []235for j in range(num_rows):236row = []237for i in range(num_cols):238if i*num_rows + j < len(strings):239if i < num_cols - 1 and ((i+1)*num_rows + j < len(strings)):240row.append(strings[i*num_rows + j].ljust(col_width))241else:242row.append(strings[i*num_rows + j])243formatted_strings.append(" ".join(row))244245return formatted_strings246247248def extract_parameter_name(item: str) -> str:249"""250Extract the parameter name from a line. Very simple to be used in sorting251"""252item = item.strip()253match = re.match(PARAM_NAME_REGEX, item)254return match.group(0) if match else item255256257def missionplanner_sort(item: str) -> Tuple[str, ...]:258"""259MissionPlanner parameter sorting function260"""261# Split the parameter name by underscore262parts = extract_parameter_name(item).split("_")263# Compare the parts separately264return tuple(parts)265266267def extract_parameter_name_and_validate(line: str, filename: str, line_nr: int) -> str:268"""269Extracts the parameter name from a line and validates it.270Args:271line (str): The line to extract the parameter name from.272Returns:273str: The extracted parameter name.274Raises:275SystemExit: If the line is invalid or the parameter name is too long or invalid.276"""277# Extract the parameter name278match = re.match(PARAM_NAME_REGEX, line)279if match:280param_name = match.group(0)281else:282logging.error("Invalid line %d in file %s: %s", line_nr, filename, line)283raise SystemExit("Invalid line in input file")284param_len = len(param_name)285param_sep = line[param_len] # the character following the parameter name must be a separator286if param_sep not in {',', ' ', '\t'}:287logging.error("Invalid parameter name %s on line %d in file %s", param_name, line_nr,288filename)289raise SystemExit("Invalid parameter name")290if param_len > PARAM_NAME_MAX_LEN:291logging.error("Too long parameter name on line %d in file %s", line_nr, filename)292raise SystemExit("Too long parameter name")293return param_name294295296def update_parameter_documentation(doc: Dict[str, Any], target: str = '.', sort_type: str = 'none') -> None:297"""298Updates the parameter documentation in the target file or in all *.param,*.parm files of the target directory.299300This function iterates over all the ArduPilot parameter files in the target directory or file.301For each file, it DELETES all comments that start at the beginning of a line, optionally sorts the302parameter names and checks if the parameter name is in the dictionary of parameter documentation.303If it is, it prefixes the line with comment derived from the dictionary element.304If it's not, it copies the parameter line 1-to-1.305After processing all the parameters in a file, it writes the new lines back to the file.306307Args:308doc (Dict[str, Any]): A dictionary of parameter documentation.309target (str, optional): The target directory or file. Defaults to '.'.310sort_type (str, optional): The type of sorting to apply to the parameters.311Can be 'none', 'missionplanner', or 'mavproxy'. Defaults to 'none'.312"""313# Check if the target is a file or a directory314if os.path.isfile(target):315# If it's a file, process only that file316param_files = [target]317elif os.path.isdir(target):318# If it's a directory, process all .param and .parm files in that directory319param_files = glob.glob(os.path.join(target, "*.param")) \320+ glob.glob(os.path.join(target, "*.parm"))321else:322raise ValueError(f"Target '{target}' is neither a file nor a directory.")323324# Iterate over all the target ArduPilot parameter files325for param_file in param_files:326327# Read the entire file contents328with open(param_file, "r", encoding="utf-8") as file:329lines = file.readlines()330331new_lines = []332total_params = 0333documented_params = 0334undocumented_params = []335is_first_param_in_file = True # pylint: disable=C0103336if sort_type == "missionplanner":337lines.sort(key=missionplanner_sort)338if sort_type == "mavproxy":339lines.sort(key=extract_parameter_name)340for n, line in enumerate(lines, start=1):341line = line.strip()342if not line.startswith("#") and line:343param_name = extract_parameter_name_and_validate(line, param_file, n)344345if param_name in doc:346# If the parameter name is in the dictionary,347# prefix the line with comment derived from the dictionary element348data = doc[param_name]349prefix_parts = [350f"{data['humanName']}",351]352prefix_parts += data["documentation"]353for key, value in data["fields"].items():354prefix_parts.append(f"{key}: {value}")355prefix_parts += format_columns(data["values"])356doc_text = "\n# ".join(prefix_parts) # pylint: disable=C0103357if not is_first_param_in_file:358new_lines.append("\n")359new_lines.append(f"# {doc_text}\n{line}\n")360documented_params += 1361else:362# If the parameter name is in not the dictionary, copy the parameter line 1-to-1363new_lines.append(f"{line}\n")364undocumented_params.append(param_name)365total_params += 1366is_first_param_in_file = False367368if total_params == documented_params:369logging.info("Read file %s with %d parameters, all got documented",370param_file, total_params)371else:372logging.warning("Read file %s with %d parameters, but only %s of which got documented",373param_file, total_params, documented_params)374logging.warning("No documentation found for: %s", ", ".join(undocumented_params))375376# Write the new file contents to the file377with open(param_file, "w", encoding="utf-8") as file:378file.writelines(new_lines)379380381def print_read_only_params(doc):382"""383Print the names of read-only parameters.384385Args:386doc (dict): A dictionary of parameter documentation.387"""388logging.info("ReadOnly parameters:")389for param_name, param_value in doc.items():390if 'ReadOnly' in param_value['fields'] and param_value['fields']['ReadOnly']:391logging.info(param_name)392393394def main():395args = arg_parser()396try:397xml_dir = args.target if os.path.isdir(args.target) else os.path.dirname(os.path.realpath(args.target))398xml_root = get_xml_data(BASE_URL + args.vehicle_type + "/", xml_dir, PARAM_DEFINITION_XML_FILE)399doc_dict = create_doc_dict(xml_root, args.vehicle_type)400update_parameter_documentation(doc_dict, args.target, args.sort)401if args.verbose:402print_read_only_params(doc_dict)403except Exception as exp: # pylint: disable=W0718404logging.fatal(exp)405exit(1) # pylint: disable=R1722406407408if __name__ == "__main__":409main()410411412