#!/usr/bin/env python"""Example for common commercial and research solver behavior and handling.. warning:: The solver I/O handling and CLI are *NOT* good behaviors for authors and developers of command line utilities. They are intended to be examples of representative types of challenging behaviors to consider when writing SCons builders.Solver I/O behaviors:1. Log files are written to the current working directory. Log files are never overwritten and must be cleaned manually. Log files are automatically incremented from ``solver.log`` to ``solver.log10``. If no free log file number is found and the maximum number of 10 is found, the solver exits with a non-zero exit code.2. The output file name is built in preferred order: CLI ``--output-file`` argument or the ``--input-file`` argument with the replacement extension ``.out``.3. The implicit and explicit routines write output file(s) based on the requested number of threads. If only one thread is requested, the output file name is used. If more than one thread, N, is requested, each thread writes to a separately numbered output file 0 to (N-1) as ``output_file.out{number}``.4. If any output file exists and the overwrite behavior is not requested, the solver exits with a non-zero exit code.Runtime errors are returned as non-zero exit codes. Internal errors are returned as the appropriate exception.Exit codes:1. error loading YAML input file2. mismatched subcommand and input file routine request3. output file exists and no overwrite was requested4. reached max log file integer before finding a free file name"""importsysimporttypingimportpathlibimportargparseimportyaml_version="1.0.0"_project_name=pathlib.Path(__file__).stem_project_name_version=f"{_project_name}{_version}"_output_file_extension=".out"_log_file_extension=".log"_log_file=pathlib.Path(f"solver{_log_file_extension}")_cli_description="Dummy solver with file handling behavior similar to numeric solvers"_default_solve_cpus=1
[docs]defmain():"""Main function implementing the command line interface and program flow"""parser=get_parser()subcommand_list=parser._subparsers._group_actions[0].choices.keys()args=parser.parse_args()ifargs.subcommandnotinsubcommand_list:parser.print_help()else:try:subcommand=globals()[args.subcommand]subcommand(args)exceptRuntimeErroraserr:sys.exit(str(err))
[docs]defname_output_file(input_file:pathlib.Path,output_file:pathlib.Path)->pathlib.Path:"""Create the output file name from the input file if not specified"""ifoutput_fileisNone:output_file=input_file.with_suffix(_output_file_extension)output_file=output_file.with_suffix(_output_file_extension)returnoutput_file
[docs]defname_log_file(log_file:pathlib.Path,max_iterations:int=10)->pathlib.Path:"""Return the first free log file name :param log_file: Log file base name :param max_iterations: Maximum number of allowable log files :raises RuntimeError: if no log file name is free within the max iterations """log_file=_log_filecount=0whilelog_file.exists():count=count+1ifcount>max_iterations:message="Found the maximum number of log files. Please remove old log files and try again."raiseRuntimeError(message)log_file=log_file.with_suffix(f"{_log_file_extension}{count}")returnlog_file
[docs]defread_input(input_file:pathlib.Path)->dict:"""Return the configuration by reading the input file and handling common errors :param input_file: The input YAML file absolute or relative path :raises RuntimeError: if the YAML file can not be read """input_file.resolve()ifnotinput_file.is_file():raiseRuntimeError(f"input file '{input_file}' does not exist")withopen(input_file,"r")asinput_handle:try:configuration=yaml.safe_load(input_handle)except(yaml.parser.ParserError,yaml.scanner.ScannerError)aserr:message=f"Error loading '{input_file}'. Check the YAML syntax.\nyaml.parser.ParserError: {err}"raiseRuntimeError(message)returnconfiguration
[docs]defconfigure(args:argparse.Namespace)->dict:"""Return the configuration with appended executable information :param args: The command line argument namespace :raises RuntimeError: if the subcommand doesn't match the input file routine """configuration=read_input(args.input_file)if"routine"inconfigurationandconfiguration["routine"].lower()!=args.subcommand.lower():message=f"requested routine '{configuration['routine']}' does not match subcommmand '{args.subcommand}'"raiseRuntimeError(message)configuration["routine"]=args.subcommand.lower()configuration["version"]=_project_name_versionconfiguration["log_file"]=str(name_log_file(_log_file))configuration["output_file"]=str(name_output_file(args.input_file,args.output_file))configuration["solve_cpus"]=args.solve_cpusconfiguration["overwrite"]=args.overwritewithopen(configuration["log_file"],"w+")aslog_writer:log_writer.write(f"{configuration['version']}\n{configuration['routine']}\n")log_writer.write(f"{configuration['log_file']}\n{configuration['output_file']}\n")returnconfiguration
[docs]defsolve_output_files(output_file:pathlib.Path,solve_cpus:int)->typing.List[pathlib.Path]:"""Return the solve output file list to match the number of solve cpus :param output_file: base name for the output file :param solve_cpus: integer number of solve cpus """ifsolve_cpus==1:output_files=[output_file]else:output_files=[output_file.with_suffix(f"{_output_file_extension}{solve_cpu}")forsolve_cpuinrange(solve_cpus)]returnoutput_files
[docs]defsolve(configuration:dict)->None:"""Common solve logic because we do not really have separate routines :param configuration: The solver configuration :raises RuntimeError: if any output file already exists and overwrite is not requested. """log_file=pathlib.Path(configuration["log_file"])output_file=pathlib.Path(configuration["output_file"])solve_cpus=configuration["solve_cpus"]overwrite=configuration["overwrite"]output_files=solve_output_files(output_file,solve_cpus)ifany([output.exists()foroutputinoutput_files])andnotoverwrite:message="Output file(s) already exist. Exiting."raiseRuntimeError(message)withopen(log_file,"a+")aslog_writer:foroutputinoutput_files:withopen(output,"w")asoutput_writer:log_writer.write(f"writing: {output}\n")output_writer.write(yaml.safe_dump(configuration))
[docs]defimplicit(args:argparse.Namespace)->None:"""Implicit routine :param args: The command line argument namespace """configuration=configure(args)solve(configuration)
[docs]defexplicit(args:argparse.Namespace)->None:"""Explicit routine :param args: The command line argument namespace """configuration=configure(args)solve(configuration)
[docs]defpositive_nonzero_int(argument):"""Type function for argparse - positive, non-zero integers :param str argument: string argument from argparse :returns: argument :rtype: int :raises ValueError: * The argument can't be cast to int * The argument is less than 1 """MINIMUM_VALUE=1try:argument=int(argument)exceptValueError:raiseargparse.ArgumentTypeError("invalid integer value: '{}'".format(argument))ifnotargument>=MINIMUM_VALUE:raiseargparse.ArgumentTypeError("invalid positive integer: '{}'".format(argument))returnargument
[docs]defget_parser()->argparse.ArgumentParser:"""Return the argparse CLI parser"""main_parser=argparse.ArgumentParser(description=_cli_description)main_parser.add_argument("-V","--version",action="version",version=_project_name_version,)subcommand_parser_parent=argparse.ArgumentParser(add_help=False)required_named=subcommand_parser_parent.add_argument_group("required named arguments")required_named.add_argument("-i","--input-file",type=pathlib.Path,required=True,help=f"The {_project_name} input file, e.g. ``input_file.yaml``",)subcommand_parser_parent.add_argument("-o","--output-file",type=pathlib.Path,default=None,required=False,# fmt: offhelp=f"The {_project_name} results file. Extension is always replaced with ``{_output_file_extension}``. "f"If none is provided, uses the pattern ``input_file{_output_file_extension}``",# fmt: on)subcommand_parser_parent.add_argument("-n","--solve-cpus",type=positive_nonzero_int,default=_default_solve_cpus,required=False,help=f"The number of threads to use (default: %(default)s)",)subcommand_parser_parent.add_argument("--overwrite",action="store_true",help="Overwrite existing output files (default: %(default)s)",)subparsers=main_parser.add_subparsers(# So args.subcommand will contain the name of the subcommand calledtitle="subcommands",metavar="{subcommand}",dest="subcommand",)subparsers.add_parser("implicit",help=f"Execute the {_project_name} implicit routine",parents=[subcommand_parser_parent])subparsers.add_parser("explicit",help=f"Execute the {_project_name} explicit routine",parents=[subcommand_parser_parent])returnmain_parser