diff --git a/src/uwtools/api/fv3.py b/src/uwtools/api/fv3.py index d271ba75e..96fbd2006 100644 --- a/src/uwtools/api/fv3.py +++ b/src/uwtools/api/fv3.py @@ -5,8 +5,7 @@ from pathlib import Path from typing import Dict, Optional -import iotaa as _iotaa - +from uwtools.drivers import support from uwtools.drivers.fv3 import FV3 @@ -30,7 +29,7 @@ def execute( :param batch: Submit run to the batch system :param dry_run: Do not run forecast, just report what would have been done :param graph_file: Write Graphviz DOT output here - :return: True if task completes without raising an exception + :return: ``True`` if task completes without raising an exception """ obj = FV3(config_file=config_file, cycle=cycle, batch=batch, dry_run=dry_run) getattr(obj, task)() @@ -44,13 +43,11 @@ def graph() -> str: """ Returns Graphviz DOT code for the most recently executed task. """ - return _iotaa.graph() + return support.graph() def tasks() -> Dict[str, str]: """ Returns a mapping from task names to their one-line descriptions. """ - return { - task: getattr(FV3, task).__doc__.strip().split("\n")[0] for task in _iotaa.tasknames(FV3) - } + return support.tasks(FV3) diff --git a/src/uwtools/api/sfc_climo_gen.py b/src/uwtools/api/sfc_climo_gen.py index 60e4c7422..8ae524d56 100644 --- a/src/uwtools/api/sfc_climo_gen.py +++ b/src/uwtools/api/sfc_climo_gen.py @@ -4,8 +4,7 @@ from pathlib import Path from typing import Dict, Optional -import iotaa as _iotaa - +from uwtools.drivers import support from uwtools.drivers.sfc_climo_gen import SfcClimoGen @@ -27,7 +26,7 @@ def execute( :param batch: Submit run to the batch system :param dry_run: Do not run forecast, just report what would have been done :param graph_file: Write Graphviz DOT output here - :return: True if task completes without raising an exception + :return: ``True`` if task completes without raising an exception """ obj = SfcClimoGen(config_file=config_file, batch=batch, dry_run=dry_run) getattr(obj, task)() @@ -41,14 +40,11 @@ def graph() -> str: """ Returns Graphviz DOT code for the most recently executed task. """ - return _iotaa.graph() + return support.graph() def tasks() -> Dict[str, str]: """ Returns a mapping from task names to their one-line descriptions. """ - return { - task: getattr(SfcClimoGen, task).__doc__.strip().split("\n")[0] - for task in _iotaa.tasknames(SfcClimoGen) - } + return support.tasks(SfcClimoGen) diff --git a/src/uwtools/cli.py b/src/uwtools/cli.py index 9acacb1fe..3fbaf3867 100644 --- a/src/uwtools/cli.py +++ b/src/uwtools/cli.py @@ -43,24 +43,19 @@ def main() -> None: # are known, then dispatch to the [sub]mode handler. setup_logging(quiet=True) - try: - args, checks = _parse_args(sys.argv[1:]) - for check in checks[args[STR.mode]][args[STR.action]]: - check(args) - setup_logging(quiet=args[STR.quiet], verbose=args[STR.verbose]) - log.debug("Command: %s %s", Path(sys.argv[0]).name, " ".join(sys.argv[1:])) - modes = { - STR.config: _dispatch_config, - STR.fv3: _dispatch_fv3, - STR.rocoto: _dispatch_rocoto, - STR.sfcclimogen: _dispatch_sfc_climo_gen, - STR.template: _dispatch_template, - } - sys.exit(0 if modes[args[STR.mode]](args) else 1) - except Exception as e: # pylint: disable=broad-exception-caught - if _switch(STR.debug) in sys.argv: - log.exception(str(e)) - _abort(str(e)) + args, checks = _parse_args(sys.argv[1:]) + for check in checks[args[STR.mode]][args[STR.action]]: + check(args) + setup_logging(quiet=args[STR.quiet], verbose=args[STR.verbose]) + log.debug("Command: %s %s", Path(sys.argv[0]).name, " ".join(sys.argv[1:])) + modes = { + STR.config: _dispatch_config, + STR.fv3: _dispatch_fv3, + STR.rocoto: _dispatch_rocoto, + STR.sfcclimogen: _dispatch_sfc_climo_gen, + STR.template: _dispatch_template, + } + sys.exit(0 if modes[args[STR.mode]](args) else 1) # Mode config @@ -531,16 +526,6 @@ def _add_arg_cycle(group: Group) -> None: ) -def _add_arg_debug(group: Group) -> None: - group.add_argument( - _switch(STR.debug), - action="store_true", - help=""" - Print all log messages, plus any unhandled exception's stack trace (implies --verbose) - """, - ) - - def _add_arg_dry_run(group: Group) -> None: group.add_argument( _switch(STR.dryrun), @@ -731,12 +716,11 @@ def _abort(msg: str) -> None: def _add_args_verbosity(group: Group) -> ActionChecks: """ - Add debug, quiet, and verbose arguments. + Add quiet and verbose arguments. :param group: The group to add the arguments to. :return: Check for mutual exclusivity of quiet/verbose arguments. """ - _add_arg_debug(group) _add_arg_quiet(group) _add_arg_verbose(group) return [_check_verbosity] @@ -802,11 +786,8 @@ def _check_template_render_vals_args(args: Args) -> Args: def _check_verbosity(args: Args) -> Args: - if args.get(STR.quiet) and (args.get(STR.debug) or args.get(STR.verbose)): - _abort( - "%s may not be used with %s or %s" - % (_switch(STR.quiet), _switch(STR.debug), _switch(STR.verbose)) - ) + if args.get(STR.quiet) and args.get(STR.verbose): + _abort("%s may not be used with %s" % (_switch(STR.quiet), _switch(STR.verbose))) return args @@ -872,7 +853,6 @@ class STR: compare: str = "compare" config: str = "config" cycle: str = "cycle" - debug: str = "debug" dryrun: str = "dry_run" file1fmt: str = "file_1_format" file1path: str = "file_1_path" diff --git a/src/uwtools/config/formats/yaml.py b/src/uwtools/config/formats/yaml.py index 4998483b7..3917cd19f 100644 --- a/src/uwtools/config/formats/yaml.py +++ b/src/uwtools/config/formats/yaml.py @@ -5,7 +5,7 @@ import yaml from uwtools.config.formats.base import Config -from uwtools.config.support import INCLUDE_TAG, TaggedString, add_representers, log_and_error +from uwtools.config.support import INCLUDE_TAG, TaggedString, add_yaml_representers, log_and_error from uwtools.utils.file import FORMAT, readable, writable _MSGS = ns( @@ -44,7 +44,7 @@ def __repr__(self) -> str: """ The string representation of a YAMLConfig object. """ - add_representers() + add_yaml_representers() return yaml.dump(self.data, default_flow_style=False).strip() # Private methods @@ -115,7 +115,7 @@ def dump_dict(cfg: dict, path: Optional[Path] = None) -> None: :param cfg: The in-memory config object to dump. :param path: Path to dump config to. """ - add_representers() + add_yaml_representers() with writable(path) as f: yaml.dump(cfg, f, sort_keys=False) diff --git a/src/uwtools/config/support.py b/src/uwtools/config/support.py index daabc5627..7fc41e980 100644 --- a/src/uwtools/config/support.py +++ b/src/uwtools/config/support.py @@ -15,7 +15,9 @@ # Public functions -def add_representers() -> None: + + +def add_yaml_representers() -> None: """ Add representers to the YAML dumper for custom types. """ @@ -66,33 +68,30 @@ def log_and_error(msg: str) -> Exception: # Private functions + + def _represent_namelist(dumper: yaml.Dumper, data: Namelist) -> yaml.nodes.MappingNode: """ - Convert f90nml Namelist to OrderedDict and serialize. + Convert an f90nml Namelist to an OrderedDict, then represent as a YAML mapping. :param dumper: The YAML dumper. :param data: The f90nml Namelist to serialize. """ - # Convert the f90nml Namelist to an OrderedDict. namelist_dict = data.todict() - - # Represent the OrderedDict as a YAML mapping. return dumper.represent_mapping("tag:yaml.org,2002:map", namelist_dict) def _represent_ordereddict(dumper: yaml.Dumper, data: OrderedDict) -> yaml.nodes.MappingNode: """ - Convert OrderedDict to dict and serialize. + Recursrively convert an OrderedDict to a dict, then represent as a YAML mapping. :param dumper: The YAML dumper. :param data: The OrderedDict to serialize. """ - # Convert the OrderedDict to a dict. def from_od(d: Union[OrderedDict, Dict]) -> dict: return {key: from_od(val) if isinstance(val, dict) else val for key, val in d.items()} - # Represent the dict as a YAML mapping. return dumper.represent_mapping("tag:yaml.org,2002:map", from_od(data)) diff --git a/src/uwtools/drivers/support.py b/src/uwtools/drivers/support.py new file mode 100644 index 000000000..0ab069384 --- /dev/null +++ b/src/uwtools/drivers/support.py @@ -0,0 +1,22 @@ +from typing import Dict + +import iotaa as _iotaa + +from uwtools.drivers.driver import Driver + + +def graph() -> str: + """ + Returns Graphviz DOT code for the most recently executed task. + """ + return _iotaa.graph() + + +def tasks(driver_class: type[Driver]) -> Dict[str, str]: + """ + Returns a mapping from task names to their one-line descriptions. + """ + return { + task: getattr(driver_class, task).__doc__.strip().split("\n")[0] + for task in _iotaa.tasknames(driver_class) + } diff --git a/src/uwtools/tests/api/test_fv3.py b/src/uwtools/tests/api/test_fv3.py index eda0092f3..94014eb72 100644 --- a/src/uwtools/tests/api/test_fv3.py +++ b/src/uwtools/tests/api/test_fv3.py @@ -3,8 +3,6 @@ import datetime as dt from unittest.mock import patch -from iotaa import asset, external, task, tasks - from uwtools.api import fv3 @@ -25,30 +23,12 @@ def test_execute(tmp_path): def test_graph(): - @external - def ready(): - yield "ready" - yield asset("ready", lambda: True) - - ready() - assert fv3.graph().startswith("digraph") + with patch.object(fv3.support, "graph") as graph: + fv3.graph() + graph.assert_called_once_with() def test_tasks(): - @external - def t1(): - "@external t1" - - @task - def t2(): - "@task t2" - - @tasks - def t3(): - "@tasks t3" - - with patch.object(fv3, "FV3") as FV3: - FV3.t1 = t1 - FV3.t2 = t2 - FV3.t3 = t3 - assert fv3.tasks() == {"t2": "@task t2", "t3": "@tasks t3", "t1": "@external t1"} + with patch.object(fv3.support, "tasks") as _tasks: + fv3.tasks() + _tasks.assert_called_once_with(fv3.FV3) diff --git a/src/uwtools/tests/api/test_sfc_climo_gen.py b/src/uwtools/tests/api/test_sfc_climo_gen.py index 8f1e223eb..bf35d6ef0 100644 --- a/src/uwtools/tests/api/test_sfc_climo_gen.py +++ b/src/uwtools/tests/api/test_sfc_climo_gen.py @@ -2,8 +2,6 @@ from unittest.mock import patch -from iotaa import asset, external, task, tasks - from uwtools.api import sfc_climo_gen @@ -23,30 +21,12 @@ def test_execute(tmp_path): def test_graph(): - @external - def ready(): - yield "ready" - yield asset("ready", lambda: True) - - ready() - assert sfc_climo_gen.graph().startswith("digraph") + with patch.object(sfc_climo_gen.support, "graph") as graph: + sfc_climo_gen.graph() + graph.assert_called_once_with() def test_tasks(): - @external - def t1(): - "@external t1" - - @task - def t2(): - "@task t2" - - @tasks - def t3(): - "@tasks t3" - - with patch.object(sfc_climo_gen, "SfcClimoGen") as SfcClimoGen: - SfcClimoGen.t1 = t1 - SfcClimoGen.t2 = t2 - SfcClimoGen.t3 = t3 - assert sfc_climo_gen.tasks() == {"t2": "@task t2", "t3": "@tasks t3", "t1": "@external t1"} + with patch.object(sfc_climo_gen.support, "tasks") as _tasks: + sfc_climo_gen.tasks() + _tasks.assert_called_once_with(sfc_climo_gen.SfcClimoGen) diff --git a/src/uwtools/tests/config/test_support.py b/src/uwtools/tests/config/test_support.py index 7cfb00c20..de29cd3c5 100644 --- a/src/uwtools/tests/config/test_support.py +++ b/src/uwtools/tests/config/test_support.py @@ -6,9 +6,10 @@ import logging from collections import OrderedDict +import f90nml # type: ignore import pytest import yaml -from f90nml import Namelist, reads # type: ignore +from f90nml import Namelist from pytest import fixture, raises from uwtools.config import support @@ -23,8 +24,8 @@ from uwtools.utils.file import FORMAT -def test_add_representers(): - support.add_representers() +def test_add_yaml_representers(): + support.add_yaml_representers() representers = yaml.Dumper.yaml_representers assert support.TaggedString in representers assert OrderedDict in representers @@ -67,7 +68,7 @@ def test_log_and_error(caplog): def test_represent_namelist(): - namelist = reads("&namelist\n key = value\n/\n") + namelist = f90nml.reads("&namelist\n key = value\n/\n") assert yaml.dump(namelist, default_flow_style=True).strip() == "{namelist: {key: value}}" diff --git a/src/uwtools/tests/drivers/test_support.py b/src/uwtools/tests/drivers/test_support.py new file mode 100644 index 000000000..fd793c435 --- /dev/null +++ b/src/uwtools/tests/drivers/test_support.py @@ -0,0 +1,46 @@ +# pylint: disable=missing-class-docstring,missing-function-docstring +""" +Tests for uwtools.drivers.support module. +""" +from iotaa import asset, external, task, tasks + +from uwtools.drivers import support +from uwtools.drivers.driver import Driver + + +def test_graph(): + @external + def ready(): + yield "ready" + yield asset("ready", lambda: True) + + ready() + assert support.graph().startswith("digraph") + + +def test_tasks(): + class SomeDriver(Driver): + @external + def t1(self): + "@external t1" + + @task + def t2(self): + "@task t2" + + @tasks + def t3(self): + "@tasks t3" + + @property + def _driver_config(self): + pass + + @property + def _resources(self): + pass + + def _validate(self): + pass + + assert support.tasks(SomeDriver) == {"t2": "@task t2", "t3": "@tasks t3", "t1": "@external t1"} diff --git a/src/uwtools/tests/test_cli.py b/src/uwtools/tests/test_cli.py index 7abb2d21e..90adf4d0e 100644 --- a/src/uwtools/tests/test_cli.py +++ b/src/uwtools/tests/test_cli.py @@ -21,7 +21,7 @@ from uwtools.cli import STR from uwtools.exceptions import UWConfigRealizeError, UWError, UWTemplateRenderError from uwtools.logging import log -from uwtools.tests.support import logged, regex_logged +from uwtools.tests.support import regex_logged from uwtools.utils.file import FORMAT # Test functions @@ -185,18 +185,15 @@ def test__check_template_render_vals_args_noop_explicit_valsfmt(): assert cli._check_template_render_vals_args(args) == args -@pytest.mark.parametrize("flag", (STR.debug, STR.verbose)) -def test__check_verbosity_fail(capsys, flag): +def test__check_verbosity_fail(capsys): log.setLevel(logging.INFO) - args = {STR.quiet: True, flag: True} + args = {STR.quiet: True, STR.verbose: True} with raises(SystemExit): cli._check_verbosity(args) - assert "--quiet may not be used with --debug or --verbose" in capsys.readouterr().err + assert "--quiet may not be used with --verbose" in capsys.readouterr().err -@pytest.mark.parametrize( - "flags", ([STR.debug], [STR.quiet], [STR.verbose], [STR.debug, STR.verbose]) -) +@pytest.mark.parametrize("flags", ([STR.quiet], [STR.verbose])) def test__check_verbosity_ok(flags): args = {flag: True for flag in flags} assert cli._check_verbosity(args) == args @@ -493,24 +490,11 @@ def test__dispatch_template_translate_no_optional(): ) -def test_main_debug_logs_stacktrace(caplog): - log.setLevel(logging.DEBUG) - msg = "Test failed intentionally" - with patch.object(cli, "_parse_args", side_effect=Exception(msg)): - with patch.object(sys, "argv", cli._switch(STR.debug)): - with raises(SystemExit): - cli.main() - assert logged(caplog, "Traceback (most recent call last):") - - -@pytest.mark.parametrize("debug", [False, True]) @pytest.mark.parametrize("quiet", [False, True]) @pytest.mark.parametrize("verbose", [False, True]) -def test_main_fail_checks(capsys, debug, quiet, verbose): +def test_main_fail_checks(capsys, quiet, verbose): # Using mode 'template render' for testing. raw_args = ["testing", STR.template, STR.render] - if debug: - raw_args.append(cli._switch(STR.debug)) if quiet: raw_args.append(cli._switch(STR.quiet)) if verbose: @@ -519,11 +503,9 @@ def test_main_fail_checks(capsys, debug, quiet, verbose): with patch.object(cli, "_dispatch_template", return_value=True): with raises(SystemExit) as e: cli.main() - if quiet and (debug or verbose): + if quiet and verbose: assert e.value.code == 1 - assert ( - "--quiet may not be used with --debug or --verbose" in capsys.readouterr().err - ) + assert "--quiet may not be used with --verbose" in capsys.readouterr().err else: assert e.value.code == 0 @@ -540,14 +522,6 @@ def test_main_fail_dispatch(vals): assert e.value.code == exit_status -def test_main_raises_exception(capsys): - msg = "Test failed intentionally" - with patch.object(cli, "_parse_args", side_effect=Exception(msg)): - with raises(SystemExit): - cli.main() - assert msg in capsys.readouterr().err - - def test__parse_args(): raw_args = ["testing", "--bar", "88"] with patch.object(cli, "Parser") as Parser: