Source code for aequilibrae.project.tools.cli

import argparse
import functools
import inspect
import os

# Dests used internally via the .set_defaults trick. The prefix keeps them from colliding with
# real function parameters.
_VAR_KEYWORDS = "_aeq_internal_var_keywords"


def _parse_value(text: str):
    """Parse a command line value as a Python literal when possible (numbers, booleans, lists, ...)
    so values round-trip with the types found in parameters.yml, otherwise keep it as a string.

    Quoting a value forces it to be a string, e.g. '"101"'.
    """
    import ast

    try:
        return ast.literal_eval(text)
    except (ValueError, SyntaxError):
        return text


def _keyword_pair(text: str):
    """
    Parse a 'key=value' string into a (key, value) tuple.
    """
    key, sep, value = text.partition("=")
    if not sep or not key:
        raise argparse.ArgumentTypeError(f"expected 'key=value', got '{text}'")

    return key, _parse_value(value)


def _annotation_help(param):
    """
    Type annotations are not enforced, they are only surfaced in the help text.
    """
    if param.annotation is inspect.Parameter.empty:
        return None
    return f"type: {inspect.formatannotation(param.annotation)}"


def _extract_target_call(args: dict):
    """
    Split a parsed argument dict into the target function and the keyword arguments to call it with.
    """
    target_function = args.pop("_aeq_internal_target_func")
    var_keywords = dict(args.pop(_VAR_KEYWORDS, []))

    return target_function, {**args, **var_keywords}


[docs] def add_subcommand_from_function(subparsers, func, defaults: dict): """ Create a sub-command from a function's signature. """ doc = inspect.getdoc(func) parser = subparsers.add_parser(func.__name__, description=doc, help=doc.split("\n")[0] if doc else None) parser.set_defaults(_aeq_internal_target_func=functools.partial(func, **defaults) if defaults else func) var_keyword = None for param_name, param in inspect.signature(func).parameters.items(): if param.kind is inspect.Parameter.POSITIONAL_ONLY: raise ValueError(f"positional-only parameters ('{param_name}') are not supported by the aeq CLI") elif param.kind is inspect.Parameter.VAR_KEYWORD: var_keyword = param_name continue elif param.kind is inspect.Parameter.VAR_POSITIONAL: # We call the function with keyword arguments only, so *args can never be supplied. continue help_text = _annotation_help(param) if param.default is inspect.Parameter.empty and param_name not in defaults: # Required positional argument. dest must remain the parameter name so the parsed # value can be passed back as a keyword argument. parser.add_argument(param_name, type=_parse_value, help=help_text) elif param.default is inspect.Parameter.empty: # Required in the signature, but parameters.yml provides a default parser.add_argument(param_name, nargs="?", default=argparse.SUPPRESS, type=_parse_value, help=help_text) elif isinstance(default := defaults.get(param_name, param.default), bool): # Boolean defaults become a --flag/--no-flag pair rather than taking a value. parser.add_argument( f"--{param_name}", action=argparse.BooleanOptionalAction, default=default, help=help_text ) else: # Optional argument with default parser.add_argument(f"--{param_name}", default=argparse.SUPPRESS, type=_parse_value, help=help_text) if var_keyword is not None: # Arbitrary keyword arguments are accepted as trailing 'key=value' pairs. parameters.yml # defaults that don't match a named parameter reach **kwargs through the partial, and the # command line can override them since later keyword arguments win. parser.add_argument( _VAR_KEYWORDS, nargs="*", metavar="key=value", type=_keyword_pair, default=[], help=f"additional keyword arguments passed as **{var_keyword}", )
[docs] def list_functions(parser, args, unparsed_args): """ List functions present in the run module. """ from pprint import pprint from aequilibrae.project import Project # We attempt to parse the remaining arguments to provide a good error message in case something was provided. args = parser.parse_args(args=unparsed_args, namespace=args) project = Project() project.open(args.project) pprint(list(project.parameters["run"].keys())) project.close()
[docs] def run(args, unparsed_args): """ Execute a function from the run module with argument parsing inferred from the function signature. """ from pprint import pprint from aequilibrae.project import Project project = Project() project.open(args.project) try: run_module = project.run # We create a new parser because we don't want any of the old arguments to pollute the unparsed_args with # their default values (specifically "no_defaults"). new_parser = argparse.ArgumentParser(prog="aeq run", description="Run module functions", allow_abbrev=False) subparsers = new_parser.add_subparsers(title="functions", metavar="function", required=True) # For each function we'll inspect the signature and create a sort of "best guess" set of arguments to accept. # POSITIONAL_ONLY and VAR_POSITIONAL arguments are not supported because we supply everything as keyword # arguments. VAR_KEYWORD arguments are accepted as trailing 'key=value' pairs. for func in run_module: add_subcommand_from_function(subparsers, func.func, func.keywords if not args.no_defaults else {}) # The parsed arguments contain internal bookkeeping entries from the .set_defaults trick: the run module # function to call and its **kwargs handling. _extract_target_call() separates those from the real keyword # arguments, which contain only what the user typed. parameters.yml defaults are partially applied to the # target function itself, just like project.run does. target_function, kwargs = _extract_target_call(vars(new_parser.parse_args(args=unparsed_args))) res = target_function(**kwargs) if res is not None: pprint(res) finally: project.close()
[docs] def cli(argv=None): """Entry point for the aeq command. argv defaults to sys.argv, it is a parameter for testing.""" # Create a parser that just parses the project dir. This is global and used by every sub-command. # allow_abbrev is disabled here and below because unrecognised arguments are handed over to the per-function # parsers built in run(); prefix matching would let e.g. "--proj" silently bind to "--project". parser = argparse.ArgumentParser(description="AequilibraE project command line tool", allow_abbrev=False) parser.add_argument( "-p", "--project", default=os.getcwd(), type=str, help="Path to the project folder (defaults to the current directory)", ) try: from importlib.metadata import version aeq_version = version("aequilibrae") except Exception: aeq_version = "unknown" parser.add_argument("--version", action="version", version=f"aeq (AequilibraE {aeq_version})") # We'll add our sub-commands via a sub-parser. The function corresponding to the sub-command is set via the # .set_defaults trick given in the docs # https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_subparsers subparsers = parser.add_subparsers(title="commands", help="Available run commands", required=True) # add_help is disabled so that a --help after the function name is left in unparsed_args for the per-function # parser built in run(), e.g. "aeq -p dir run my_func --help" prints the help of my_func. "aeq -p dir run --help" # still works: the parser built in run() handles it and lists the available functions. run_parser = subparsers.add_parser("run", help="Run project commands", add_help=False, allow_abbrev=False) run_parser.add_argument( "--no-defaults", action="store_true", help="do not use default arguments from parameters.yml" ) run_parser.set_defaults(_aeq_internal_func=run) list_functions_parser = subparsers.add_parser("list", help="List project commands") list_functions_parser.set_defaults(_aeq_internal_func=functools.partial(list_functions, list_functions_parser)) args, unparsed_args = parser.parse_known_args(argv) # args now contains the project and the "_aeq_internal_func" to run from .set_defaults. unparsed_args should # contain everything else. args._aeq_internal_func(args, unparsed_args)