Source code for aviary.utils.doctape

import inspect
import subprocess
import tempfile
import os
import numpy as np


"""
# DocTAPE #
------------
Documentation Testing and Automated Placement of Expressions

A collection of utility functions (and wrappers for Glue) that are useful
for automating the process of building and testing documentation to ensure that
documentation doesn't get stale.

expected_error is an execption that can be used in try/except blocks to allow desired errors to
pass while still raising unexpected errors.

gramatical_list combines the elements of a list into a string with proper punctuation
check_value is a simple function for comparing two values
check_contains confirms that all the elements of one iterable are contained in the other
check_args gets the signature of a function and compares it to the arguments you are expecting
run_command_no_file_error executes a CLI command but won't fail if a FileNotFoundError is raised
get_attribute_name gets the name of an object's attribute based on it's value
get_all_keys recursively get all of the keys from a dict of dicts
get_value recursively get a value from a dict of dicts
glue_variable Glue a variable for later use in markdown cells of notebooks (can auto format for code)
glue_keys recursively glue all of the keys from a dict of dicts
"""


[docs] class expected_error(Exception): ...
[docs] def gramatical_list(list_of_strings: list, cc='and', add_accents=False) -> str: """ Combines the elements of a list into a string with proper punctuation Parameters ---------- list_of_strings : list A list of strings (or elements with a string representation) cc : str, optional The coordinating conjunction to use with the list (default is `and`) add_accents : bool, optional Whether or not to wrap each element with ` characters (default is False) Returns ------- str A string that combines the elements of the list into a string with proper punctuation """ list_of_strings = ['`'+str(s)+'`' if add_accents else str(s) for s in list_of_strings] if len(list_of_strings) == 1: return list_of_strings[0] elif len(list_of_strings) == 2: return list_of_strings[0]+' '+cc+' '+list_of_strings[1] else: return ', '.join(list_of_strings[:-1]+[cc+' '+list_of_strings[-1]])
[docs] def get_previous_line(n=1) -> str: """ returns the previous n line(s) of code as a string Parameters ---------- n : int The number of lines to return (default is 1) Returns ------- str A string that contains the previous line of code or a list that contains the previous n lines of code """ pframe = inspect.currentframe().f_back # get the previous frame that called this function # get the lines of code as a list of strings lines, first_line = inspect.getsourcelines(pframe) # get the line number of the line that called this function lineno = pframe.f_lineno - first_line if first_line else pframe.f_lineno - 1 # get the previous lines return lines[lineno-n:lineno] if n > 1 else lines[lineno-1].strip()
[docs] def get_variable_name(*variables) -> str: """ returns the name of the variable passed to the function as a string Parameters ---------- variables : any The variable(s) of interest Returns ------- str A string that contains the name of variable passed to this function (or list of strings, if multiple arguments are passed) """ pframe = inspect.currentframe().f_back # get the previous frame that called this function # get the lines of code as a list of strings lines, first_line = inspect.getsourcelines(pframe) # get the line number that called this function lineno = pframe.f_lineno - first_line if first_line else pframe.f_lineno - 1 # extract the argument and remove all whitespace arg: str = ''.join(lines[lineno].split( 'get_variable_name(')[1].split(')')[0].split()) if ',' in arg: return arg.split(',') else: return arg
[docs] def check_value(val1, val2, error_type=ValueError): """ Compares two values and raises a ValueError if they are not equal. This method checks whether the provided values are equal. For primitive data types such as strings, integers, floats, lists, tuples, dictionaries, and sets, it uses the equality operator. For other types, it uses identity comparison. Parameters ---------- val1 : any The first value to be compared. val2 : any The second value to be compared. error_type : Exception, optional The exception to raise (default is ValueError) Raises ------ ValueError If the values are not equal (or not the same object for non-primitive types). """ if isinstance(val1, (str, int, float, list, tuple, dict, set, np.ndarray, type({}.keys()))): if val1 != val2: raise error_type(f"{val1} is not equal to {val2}") else: if val1 is not val2: raise error_type(f"{val1} is not {val2}")
[docs] def check_contains(expected_values, actual_values, error_string="{var} not in {actual_values}", error_type=RuntimeError): """ Checks that all of the expected_values exist in actual_values (It does not check for missing values) Parameters ---------- expected_values : any iterable This can also be a single value, in which case it will be wrapped into a list actual_values : any iterable error_string : str, optional The string to display as the error message, kwarg substitutions will be made using .format() for "var" and "actual_values" error_type : Exception, optional The exception to raise (default is RuntimeError) Raises ------ RuntimeError If a value in expected_values is not present in actual_values """ # if a single expected item is provided, wrap it if not hasattr(expected_values, '__class_getitem__'): expected_values = [expected_values] for var in expected_values: if var not in actual_values: raise error_type(error_string.format(var=var, actual_values=actual_values))
[docs] def check_args(func, expected_args: tuple[list, dict, str], args_to_ignore: tuple[list, tuple] = ['self'], exact=True, error_type=ValueError): """ Checks that the expected arguments are valid for a given function. This method verifies that the provided `expected_args` match the actual arguments of the given function `func`. If `exact` is True, the method checks for an exact match. If `exact` is False, it only checks that the provided `expected_args` are included in the actual arguments (it won't fail if the function has additional arguments). Parameters ---------- func : function The function whose arguments are being checked. expected_args : list, dict, or str The expected arguments. If a dict, the values will be compared to the default values. If a string, it will be treated as a single argument of interest. (exact will be set to False) args_to_ignore : list or tuple, optional Arguments to ignore during the check (default is ['self']). exact : bool, optional Whether to check for an exact match of arguments (default is True). error_type : Exception, optional The exception to raise (default is ValueError) Raises ------ ValueError If the expected arguments do not match the actual arguments of the function. """ if isinstance(expected_args, str): expected_args = [expected_args] exact = False params = inspect.signature(func).parameters available_args = { arg: params[arg].default for arg in params if arg not in args_to_ignore} if exact: if isinstance(expected_args, dict): check_value(available_args, expected_args) else: check_value(sorted(available_args), sorted(expected_args)) else: for arg in expected_args: if arg not in available_args: raise error_type(f'{arg} is not a valid argument for {func.__name__}') elif isinstance(expected_args, dict) and expected_args[arg] != available_args[arg]: raise error_type( f"the default value of {arg} is {available_args[arg]}, not {expected_args[arg]}")
[docs] def run_command_no_file_error(command: str): """ Executes a CLI command and handles FileNotFoundError separately. This method runs a given command in a temporary directory and captures the output. If the command returns a non-zero exit code, it checks the error message. If the error is a FileNotFoundError, it prints the error name. For other errors, it prints the full error message. Parameters ---------- command : str The CLI command to be executed. Raises ------ CalledProcessError If the command returns a non-zero exit code (except for FileNotFoundError). """ with tempfile.TemporaryDirectory() as tempdir: rc = subprocess.run(command.split(), cwd=tempdir, capture_output=True, text=True) if rc.returncode: err = rc.stderr.split('\n')[-2].split(':')[0] if err == 'FileNotFoundError': print(err) else: print(rc.stderr) rc.check_returncode()
[docs] def get_attribute_name(object: object, attribute, error_type=AttributeError) -> str: """ Gets the name of an object's attribute based on it's value This is intended for use with Enums and other objects that have unique values. This method will return the name of the first attribute that has a value that matches the value provided. Parameters ---------- object : any The object whose attributes will be searched attribute : any The value of interest error_type : Exception, optional The exception to raise (default is AttributeError) Returns ------- name : str The name of the attribute Raises ------ AttributeError If the object has no attributes with the provided value. """ for name, val in object.__dict__.items(): if val == attribute: return name raise error_type( f"`{object.__name__}` object has no attribute with a value of `{attribute}`")
[docs] def get_all_keys(dict_of_dicts: dict, track_layers=False, all_keys=None) -> list: """ Recursively get all of the keys from a dict of dicts Note: this will not add duplicates of keys, but will continue deeper even if a key is duplicated Parameters ---------- dict_of_dicts : dict The dictionary who's keys will be gathered track_layers : Bool Whether or not to track where keys inside the dict of dicts came from. This will get every key, by ensuring that all keys have a unique name by tracking the path it took to get there. all_keys : list A list of the keys that have been found so far Returns ------- all_keys : list A list of all the keys in the dict_of_dicts """ if all_keys is None: all_keys = [] for key, val in dict_of_dicts.items(): if track_layers is True: current_layer = '' elif track_layers: current_layer = track_layers if track_layers and current_layer: key = current_layer+'.'+key if key not in all_keys: all_keys.append(key) if isinstance(val, dict): if track_layers: current_layer = key else: current_layer = False all_keys = get_all_keys(val, track_layers=current_layer, all_keys=all_keys) return all_keys
[docs] def get_value(dict_of_dicts: dict, comlpete_key: str): """ Recursively get a value from a dict of dicts Parameters ---------- dict_of_dicts : dict complete_key : str A string that contains the full path through the dict_of_dicts (i.e. dictkey1.dictkey2.keyofinterest) Returns ------- val : any The value found """ for key in comlpete_key.split('.'): dict_of_dicts = dict_of_dicts[key] return dict_of_dicts
[docs] def glue_variable(name: str, val=None, md_code=False, display=True): """ Glue a variable for later use in markdown cells of notebooks Note: glue_variable(get_variable_name(Aircraft.APU.MASS)) can be used to glue the name of the variable (Aircraft.APU.MASS) not the value of the variable ('aircraft:apu:mass') Parameters ---------- name : str The name the value will be glued to val : any The value to be displayed in the markdown cell (default is the value of name) md_code : Bool Whether to wrap the value in markdown code formatting (e.g. `code`) """ # local import so myst isn't required unless glue is being used from myst_nb import glue from IPython.display import Markdown if val is None: val = name if md_code: val = Markdown('`'+val+'`') else: val = Markdown(val) glue(name, val, display)
[docs] def glue_keys(dict_of_dicts: dict, display=True) -> list: """ Recursively glue all of the keys from a dict of dicts Parameters ---------- dict_of_dicts : dict The dictionary who's keys will be glued Returns ------- all_keys : list A list of all the keys that were glued """ all_keys = get_all_keys(dict_of_dicts) for key in all_keys: glue_variable(key, md_code=True, display=display) return all_keys