diff --git a/atef/bin/check.py b/atef/bin/check.py index d49d1693..f917cffa 100644 --- a/atef/bin/check.py +++ b/atef/bin/check.py @@ -3,7 +3,6 @@ """ from __future__ import annotations -import argparse import asyncio import enum import itertools @@ -79,66 +78,6 @@ def set_or_clear(verbosity: cls, name: str, value: bool) -> cls: return verbosity -def build_arg_parser(argparser=None): - if argparser is None: - argparser = argparse.ArgumentParser() - - argparser.description = DESCRIPTION - argparser.formatter_class = argparse.RawTextHelpFormatter - - argparser.add_argument( - "filename", - type=str, - help="Configuration filename", - ) - - for setting in VerbositySetting: - flag_name = setting.name.replace("_", "-") - if setting == VerbositySetting.default: - continue - - help_text = setting.name.replace("_", " ").capitalize() - - argparser.add_argument( - f"--{flag_name}", - dest=setting.name, - help=help_text, - action="store_true", - default=setting in VerbositySetting.default, - ) - - if flag_name.startswith("show-"): - hide_flag_name = flag_name.replace("show-", "hide-") - help_text = help_text.replace("Show ", "Hide ") - argparser.add_argument( - f"--{hide_flag_name}", - dest=setting.name, - help=help_text, - action="store_false", - ) - - # argparser.add_argument( - # "--filter", - # type=str, - # nargs="*", - # dest="name_filter", - # help="Limit checkout to the named device(s) or identifiers", - # ) - - argparser.add_argument( - "-p", "--parallel", - action="store_true", - help="Acquire data for comparisons in parallel", - ) - - argparser.add_argument( - "-r", "--report-path", - help="Path to the report save path, if provided" - ) - - return argparser - - default_severity_to_rich = { Severity.success: "[bold green]:heavy_check_mark:", Severity.warning: "[bold yellow]:heavy_check_mark:", diff --git a/atef/bin/config.py b/atef/bin/config.py index 2699cbcf..699fc25b 100644 --- a/atef/bin/config.py +++ b/atef/bin/config.py @@ -1,13 +1,12 @@ """ `atef config` opens up a graphical config file editor. """ -import argparse import logging import sys from typing import List, Optional from pydm import exception -from qtpy.QtWidgets import QApplication, QStyleFactory +from qtpy.QtWidgets import QApplication from ..type_hints import AnyPath from ..widgets.config.window import Window @@ -15,68 +14,6 @@ logger = logging.getLogger(__name__) -def build_arg_parser(argparser=None): - if argparser is None: - argparser = argparse.ArgumentParser() - - # Arguments that need to be passed through to Qt - qt_args = { - '--qmljsdebugger': 1, - '--reverse': '?', - '--stylesheet': 1, - '--widgetcount': '?', - '--platform': 1, - '--platformpluginpath': 1, - '--platformtheme': 1, - '--plugin': 1, - '--qwindowgeometry': 1, - '--qwindowicon': 1, - '--qwindowtitle': 1, - '--session': 1, - '--display': 1, - '--geometry': 1 - } - - for name in qt_args: - argparser.add_argument( - name, - type=str, - nargs=qt_args[name] - ) - - argparser.add_argument( - '--style', - type=str, - choices=QStyleFactory.keys(), - default='fusion', - help='Qt style to use for the application' - ) - - argparser.description = """ - Runs the atef configuration GUI, optionally with an existing configuration. - Qt arguments are also supported. For a full list, see the Qt docs: - https://doc.qt.io/qt-5/qapplication.html#QApplication - https://doc.qt.io/qt-5/qguiapplication.html#supported-command-line-options - """ - argparser.add_argument( - "--cache-size", - metavar="cache_size", - type=int, - default=5, - help="Page widget cache size", - ) - - argparser.add_argument( - "filenames", - metavar="filename", - type=str, - nargs="*", - help="Configuration filename", - ) - - return argparser - - def main(cache_size: int, filenames: Optional[List[AnyPath]] = None, **kwargs): app = QApplication(sys.argv) main_window = Window(cache_size=cache_size, show_welcome=not filenames) diff --git a/atef/bin/main.py b/atef/bin/main.py index 136e5462..229cdafd 100644 --- a/atef/bin/main.py +++ b/atef/bin/main.py @@ -7,62 +7,34 @@ import argparse import asyncio -import importlib import logging from inspect import iscoroutinefunction import atef +from atef.bin.subparsers import SUBCOMMANDS DESCRIPTION = __doc__ -COMMAND_TO_MODULE = { - "check": "check", - "config": "config", - "scripts": "scripts", -} - - -def _try_import(module_name): - return importlib.import_module(f".{module_name}", 'atef.bin') - - -def _build_commands(): - global DESCRIPTION - result = {} - unavailable = [] - - for command, module_name in sorted(COMMAND_TO_MODULE.items()): - try: - module = _try_import(module_name) - except Exception as ex: - unavailable.append((command, ex)) - else: - result[module_name] = (module.build_arg_parser, module.main) - DESCRIPTION += f'\n $ atef {command} --help' - - if unavailable: - DESCRIPTION += '\n\n' - - for command, ex in unavailable: - DESCRIPTION += ( - f'\nWARNING: "atef {command}" is unavailable due to:' - f'\n\t{ex.__class__.__name__}: {ex}' - ) - - return result - - -COMMANDS = _build_commands() +def main(): + """ + Create the top-level parser for atef. Gathers subparsers from + atef.bin.subparsers, which have been separated to avoid pre-mature imports + Expects SUBCOMMANDS to be a dictionary mapping subcommand name to a tuple of: + - sub-parser builder function: Callable[[], argparse.ArgumentParser] + - function returning the main function for the sub command: + Callable[[], Callable[**subcommand_kwargs]] -def main(): + Have fun "parsing" this ;D + """ top_parser = argparse.ArgumentParser( prog='atef', - description=DESCRIPTION, formatter_class=argparse.RawTextHelpFormatter ) + desc = DESCRIPTION + top_parser.add_argument( '--version', '-V', action='version', @@ -78,11 +50,14 @@ def main(): ) subparsers = top_parser.add_subparsers(help='Possible subcommands') - for command_name, (build_func, main) in COMMANDS.items(): + for command_name, (build_func, main) in SUBCOMMANDS.items(): + desc += f'\n $ atef {command_name} --help' sub = subparsers.add_parser(command_name) build_func(sub) sub.set_defaults(func=main) + top_parser.description = desc + args = top_parser.parse_args() kwargs = vars(args) log_level = kwargs.pop('log_level') @@ -93,11 +68,12 @@ def main(): if hasattr(args, 'func'): func = kwargs.pop('func') - logger.debug('%s(**%r)', func.__name__, kwargs) - if iscoroutinefunction(func): - asyncio.run(func(**kwargs)) + logger.debug('main(**%r)', kwargs) + main_fn = func() + if iscoroutinefunction(main_fn): + asyncio.run(main_fn(**kwargs)) else: - func(**kwargs) + main_fn(**kwargs) else: top_parser.print_help() diff --git a/atef/bin/scripts.py b/atef/bin/scripts.py index a5084259..93593312 100644 --- a/atef/bin/scripts.py +++ b/atef/bin/scripts.py @@ -2,67 +2,9 @@ `atef scripts` runs helper scripts. Scripts may be added over time. """ -import argparse -import importlib -import logging -from pkgutil import iter_modules -from typing import Callable, Dict, Tuple - -logger = logging.getLogger(__name__) - DESCRIPTION = __doc__ -def gather_scripts() -> Dict[str, Tuple[Callable, Callable]]: - """Gather scripts, one main function from each submodule""" - # similar to main's _build_commands - global DESCRIPTION - DESCRIPTION += "\nTry:\n" - results = {} - unavailable = [] - - scripts_module = importlib.import_module("atef.scripts") - for sub_module in iter_modules(scripts_module.__path__): - module_name = sub_module.name - try: - module = importlib.import_module(f".{module_name}", "atef.scripts") - except Exception as ex: - unavailable.append((module_name, ex)) - else: - results[module_name] = (module.build_arg_parser, module.main) - DESCRIPTION += f'\n $ atef scripts {module_name} --help' - - if unavailable: - DESCRIPTION += '\n\n' - - for command, ex in unavailable: - DESCRIPTION += ( - f'\nWARNING: "atef scripts {command}" is unavailable due to:' - f'\n\t{ex.__class__.__name__}: {ex}' - ) - - return results - - -SCRIPTS = gather_scripts() - - -def build_arg_parser(argparser=None): - if argparser is None: - argparser = argparse.ArgumentParser() - - argparser.description = """ - Runs atef related scripts. Pick a subcommand to run its script - """ - - sub_parsers = argparser.add_subparsers(help='available script subcommands') - for script_name, (build_parser_func, script_main) in SCRIPTS.items(): - sub = sub_parsers.add_parser(script_name) - build_parser_func(sub) - sub.set_defaults(func=script_main) - - return argparser - - def main(): + """Here as a formality, this is itself a subcommand""" print(DESCRIPTION) diff --git a/atef/bin/subparsers.py b/atef/bin/subparsers.py new file mode 100644 index 00000000..f5d229a9 --- /dev/null +++ b/atef/bin/subparsers.py @@ -0,0 +1,188 @@ +""" +A collection of subparser helpers. Separate from the sub-command submodules to +isolate imports until absolutely necessary. Avoid importing any atef core +library utilities, wherever possible. +""" + +import argparse +import importlib +from functools import partial +from typing import Callable + +from qtpy.QtWidgets import QStyleFactory + +from atef.scripts.scripts_subparsers import SUBSCRIPTS + +# Sub-sub parsers too difficult to isolate here. Leave as submodule import +# from atef.bin.scripts import build_arg_parser as build_arg_parser_scripts + + +def get_main(submodule_name: str, base_module: str) -> Callable: + """Grab the `main` function from atef.bin.{submodule_name}""" + module = importlib.import_module(f".{submodule_name}", base_module) + + return module.main + + +# `atef check` +_VERBOSITY_SETTINGS = { + "show-severity-emoji": True, + "show-severity-description": True, + "show-config-description": False, + "show-tags": False, + "show-passed-tests": False, +} + + +def build_arg_parser_check(argparser=None): + if argparser is None: + argparser = argparse.ArgumentParser() + + argparser.description = """ + `atef check` runs passive checkouts of devices given a configuration file. + """ + argparser.formatter_class = argparse.RawTextHelpFormatter + + argparser.add_argument( + "filename", + type=str, + help="Configuration filename", + ) + + # Hard code VerbositySetting to extricate from atef.bin.check + for flag_name, default in _VERBOSITY_SETTINGS.items(): + + help_text = flag_name.replace("-", " ").capitalize() + + argparser.add_argument( + f"--{flag_name}", + dest=flag_name.replace("-", "_"), + help=help_text, + action="store_true", + default=default, + ) + + if flag_name.startswith("show-"): + hide_flag_name = flag_name.replace("show-", "hide-") + help_text = help_text.replace("Show ", "Hide ") + argparser.add_argument( + f"--{hide_flag_name}", + dest=flag_name.replace("-", "_"), + help=help_text, + action="store_false", + ) + + # argparser.add_argument( + # "--filter", + # type=str, + # nargs="*", + # dest="name_filter", + # help="Limit checkout to the named device(s) or identifiers", + # ) + + argparser.add_argument( + "-p", "--parallel", + action="store_true", + help="Acquire data for comparisons in parallel", + ) + + argparser.add_argument( + "-r", "--report-path", + help="Path to the report save path, if provided" + ) + + return argparser + + +# `atef config` +def build_arg_parser_config(argparser=None): + if argparser is None: + argparser = argparse.ArgumentParser() + + argparser.formatter_class = argparse.RawTextHelpFormatter + # Arguments that need to be passed through to Qt + qt_args = { + '--qmljsdebugger': 1, + '--reverse': '?', + '--stylesheet': 1, + '--widgetcount': '?', + '--platform': 1, + '--platformpluginpath': 1, + '--platformtheme': 1, + '--plugin': 1, + '--qwindowgeometry': 1, + '--qwindowicon': 1, + '--qwindowtitle': 1, + '--session': 1, + '--display': 1, + '--geometry': 1 + } + + for name in qt_args: + argparser.add_argument( + name, + type=str, + nargs=qt_args[name] + ) + + argparser.add_argument( + '--style', + type=str, + choices=QStyleFactory.keys(), + default='fusion', + help='Qt style to use for the application' + ) + + argparser.description = """ + Runs the atef configuration GUI, optionally with an existing configuration. + Qt arguments are also supported. For a full list, see the Qt docs: + https://doc.qt.io/qt-5/qapplication.html#QApplication + https://doc.qt.io/qt-5/qguiapplication.html#supported-command-line-options + """ + argparser.add_argument( + "--cache-size", + metavar="cache_size", + type=int, + default=5, + help="Page widget cache size", + ) + + argparser.add_argument( + "filenames", + metavar="filename", + type=str, + nargs="*", + help="Configuration filename", + ) + + return argparser + + +# `atef scripts` +def build_arg_parser_scripts(argparser=None): + if argparser is None: + argparser = argparse.ArgumentParser() + + argparser.formatter_class = argparse.RawTextHelpFormatter + description = """ + Runs atef related scripts. Pick a subcommand to run its script. + + Try: + """ + + sub_parsers = argparser.add_subparsers(help='available script subcommands') + for script_name, build_parser_func in SUBSCRIPTS.items(): + description += f"\n $ atef scripts {script_name} --help" + sub = sub_parsers.add_parser(script_name) + build_parser_func(sub) + sub.set_defaults(func=partial(get_main, script_name, "atef.scripts")) + + argparser.description = description + return argparser + + +SUBCOMMANDS = { + "check": (build_arg_parser_check, partial(get_main, "check", "atef.bin")), + "config": (build_arg_parser_config, partial(get_main, "config", "atef.bin")), + "scripts": (build_arg_parser_scripts, partial(get_main, "scripts", "atef.bin")), +} diff --git a/atef/scripts/converter_v0.py b/atef/scripts/converter_v0.py index a1e5859d..cb60304e 100644 --- a/atef/scripts/converter_v0.py +++ b/atef/scripts/converter_v0.py @@ -5,7 +5,6 @@ from __future__ import annotations -import argparse import json import logging import pathlib @@ -19,6 +18,7 @@ import atef.config from atef import serialization, tools from atef.check import Comparison +from atef.scripts.scripts_subparsers import build_converter_arg_parser from atef.type_hints import AnyPath logger = logging.getLogger(__name__) @@ -270,39 +270,6 @@ def convert(fn: AnyPath) -> str: return json.dumps(load(fn).to_json(), indent=2) -def build_arg_parser(argparser=None) -> argparse.ArgumentParser: - """Create the argparser.""" - if argparser is None: - argparser = argparse.ArgumentParser() - - argparser.description = DESCRIPTION - argparser.formatter_class = argparse.RawTextHelpFormatter - - argparser.add_argument( - "--log", - "-l", - dest="log_level", - default="INFO", - type=str, - help="Python logging level (e.g. DEBUG, INFO, WARNING)", - ) - - argparser.add_argument( - "filename", - type=str, - nargs="+", - help="File(s) to convert", - ) - - argparser.add_argument( - "--write", - action="store_true", - help="Convert and overwrite the files in-place", - ) - - return argparser - - def main( filename: str, write: bool @@ -322,7 +289,7 @@ def main( def main_script(args=None) -> None: """Run the conversion tool.""" - parser = build_arg_parser() + parser = build_converter_arg_parser() # Add log_level if running file alone parser.add_argument( diff --git a/atef/scripts/pmgr_check.py b/atef/scripts/pmgr_check.py index a9251ffa..01840368 100644 --- a/atef/scripts/pmgr_check.py +++ b/atef/scripts/pmgr_check.py @@ -6,7 +6,6 @@ An example invocation might be: python scripts/pmgr_check.py cxi test_pmgr_checkout.json --names "KB1 DS SLIT LEF" --prefix CXI:KB1:MMS:13 """ -import argparse import json import logging from typing import Any, Dict, List @@ -16,6 +15,7 @@ from atef.check import Equals from atef.config import ConfigurationFile, ConfigurationGroup, PVConfiguration +from atef.scripts.scripts_subparsers import build_pmgr_arg_parser DESCRIPTION = __doc__ logger = logging.getLogger() @@ -126,57 +126,6 @@ def create_atef_check( return pv_config -def build_arg_parser(argparser=None) -> argparse.ArgumentParser: - """Create the argparser.""" - if argparser is None: - argparser = argparse.ArgumentParser() - - argparser.description = DESCRIPTION - argparser.formatter_class = argparse.RawTextHelpFormatter - - argparser.add_argument( - "--names", - "-n", - dest="pmgr_names", - type=str, - nargs="+", - help="a list of stored pmgr configuration names, case and whitespace sensitive. " - "e.g. 'KB1 DS SLIT LEF'. Length must match --prefixes", - ) - - argparser.add_argument( - "--prefixes", - "-p", - dest="prefixes", - type=str, - nargs="+", - help="a list of EPICS PV prefixes, e.g. 'CXI:KB1:MMS:13'. Length must match --names", - ) - - argparser.add_argument( - "--table", - "-t", - dest="table_name", - default="ims_motor", - type=str, - help="Table type, by default 'ims_motor'", - ) - - argparser.add_argument( - dest="hutch", - type=str, - help="name of hutch, e.g. 'cxi'", - ) - - argparser.add_argument( - "filename", - type=str, - help="Output filepath", - ) - - return argparser - - def main( hutch: str, filename: str, @@ -203,7 +152,7 @@ def main( def main_script(args=None) -> None: """Get pmgr data and contruct checkout.""" - parser = build_arg_parser() + parser = build_pmgr_arg_parser() # Add log_level if running file alone parser.add_argument( "--log", diff --git a/atef/scripts/scripts_subparsers.py b/atef/scripts/scripts_subparsers.py new file mode 100644 index 00000000..1ce2a69b --- /dev/null +++ b/atef/scripts/scripts_subparsers.py @@ -0,0 +1,106 @@ +""" +sub-parsers for scripts files. Separarted to allow inclusion in main cli +entrypoint without importing core functionality +""" +import argparse + + +def build_pmgr_arg_parser(argparser=None) -> argparse.ArgumentParser: + """Create the argparser.""" + if argparser is None: + argparser = argparse.ArgumentParser() + + argparser.description = """ + This script creates an atef check from a pmgr configuration. The configuration will + be converted into a PVConfiguration. Note that default tolerances will be used for + checks. + + An example invocation might be: + python scripts/pmgr_check.py cxi test_pmgr_checkout.json --names "KB1 DS SLIT LEF" --prefix CXI:KB1:MMS:13 + """ + + argparser.formatter_class = argparse.RawTextHelpFormatter + + argparser.add_argument( + "--names", + "-n", + dest="pmgr_names", + type=str, + nargs="+", + help="a list of stored pmgr configuration names, case and whitespace sensitive. " + "e.g. 'KB1 DS SLIT LEF'. Length must match --prefixes", + ) + + argparser.add_argument( + "--prefixes", + "-p", + dest="prefixes", + type=str, + nargs="+", + help="a list of EPICS PV prefixes, e.g. 'CXI:KB1:MMS:13'. Length must match --names", + ) + + argparser.add_argument( + "--table", + "-t", + dest="table_name", + default="ims_motor", + type=str, + help="Table type, by default 'ims_motor'", + ) + + argparser.add_argument( + dest="hutch", + type=str, + help="name of hutch, e.g. 'cxi'", + ) + + argparser.add_argument( + "filename", + type=str, + help="Output filepath", + ) + + return argparser + + +def build_converter_arg_parser(argparser=None) -> argparse.ArgumentParser: + """Create the argparser.""" + if argparser is None: + argparser = argparse.ArgumentParser() + + argparser.description = """ + This script will convert a prototype atef configuration file to the latest + supported (and numbered) version. + """ + argparser.formatter_class = argparse.RawTextHelpFormatter + + argparser.add_argument( + "--log", + "-l", + dest="log_level", + default="INFO", + type=str, + help="Python logging level (e.g. DEBUG, INFO, WARNING)", + ) + + argparser.add_argument( + "filename", + type=str, + nargs="+", + help="File(s) to convert", + ) + + argparser.add_argument( + "--write", + action="store_true", + help="Convert and overwrite the files in-place", + ) + + return argparser + + +SUBSCRIPTS = { + "converter_v0": build_converter_arg_parser, + "pmgr_check": build_pmgr_arg_parser, +} diff --git a/atef/tests/test_commandline.py b/atef/tests/test_commandline.py index 8a16c02b..03ed42f9 100644 --- a/atef/tests/test_commandline.py +++ b/atef/tests/test_commandline.py @@ -5,6 +5,7 @@ import atef.bin.main as atef_main from atef.bin import check as bin_check +from atef.bin.subparsers import SUBCOMMANDS from .. import util from .conftest import CONFIG_PATH @@ -16,7 +17,7 @@ def test_help_main(monkeypatch): atef_main.main() -@pytest.mark.parametrize('subcommand', list(atef_main.COMMANDS)) +@pytest.mark.parametrize('subcommand', list(SUBCOMMANDS)) def test_help_module(monkeypatch, subcommand): monkeypatch.setattr(sys, 'argv', [subcommand, '--help']) with pytest.raises(SystemExit): diff --git a/docs/source/upcoming_release_notes/256-perf_cli.rst b/docs/source/upcoming_release_notes/256-perf_cli.rst new file mode 100644 index 00000000..83a1709b --- /dev/null +++ b/docs/source/upcoming_release_notes/256-perf_cli.rst @@ -0,0 +1,22 @@ +256 perf_cli +############ + +API Breaks +---------- +- N/A + +Features +-------- +- N/A + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- improves the performance of the CLI entrypoint, defering functional imports as longas possible + +Contributors +------------ +- tangkong