diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd9ee34..a11a3a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,11 +71,11 @@ jobs: with: name: coverage-data - - name: Combine coverage and fail if less than 100% + - name: Combine coverage and fail if less than threshold. run: | python -m coverage combine - python -m coverage html --skip-empty --skip-covered - # python -m coverage report --fail-under=100 # enable later + python -m coverage html --skip-empty # --skip-covered + python -m coverage report --fail-under=94 - name: Upload HTML report if: ${{ failure() }} diff --git a/pyproject.toml b/pyproject.toml index b7c2b27..998aa07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,10 +55,10 @@ lint = [ "black", ] dev = [ - # Recursively including the project's own optional dependencies requires pip>=21.2 "ucumvert[tests,lint]", "ruff", - "openpyxl" + "openpyxl", + "pydot", ] [project.scripts] diff --git a/src/ucumvert/__init__.py b/src/ucumvert/__init__.py index 5ac6ace..71d70cd 100644 --- a/src/ucumvert/__init__.py +++ b/src/ucumvert/__init__.py @@ -13,19 +13,24 @@ PintUcumRegistry, UcumToPintStrTransformer, UcumToPintTransformer, - get_pint_registry, ucum_preprocessor, ) try: from ucumvert._version import __version__, __version_tuple__ -except ImportError: +except ImportError: # pragma: no cover __version__ = "0.0.0" __version_tuple__ = (0, 0, 0) +try: + import pydot # noqa: F401 + + HAS_PYDOT = True +except ImportError: # pragma: no cover + HAS_PYDOT = False + __all__ = [ "get_ucum_parser", - "get_pint_registry", "make_parse_tree_png", "ucum_preprocessor", "update_lark_ucum_grammar_file", @@ -54,7 +59,7 @@ def setup_logging(loglevel: int = logging.INFO, logfile: Path | None = None) -> # Setup handler for logging to console logging.basicConfig(level=loglevel, format="%(levelname)-8s|%(message)s") - if logfile is not None: + if logfile is not None: # pragma: no cover # Setup handler for logging to file fh = logging.handlers.RotatingFileHandler( logfile, maxBytes=100000, backupCount=5 diff --git a/src/ucumvert/cli.py b/src/ucumvert/cli.py index e3dc7f1..aee304b 100644 --- a/src/ucumvert/cli.py +++ b/src/ucumvert/cli.py @@ -6,7 +6,7 @@ from lark.exceptions import LarkError, UnexpectedInput, VisitError -from ucumvert import __version__, setup_logging +import ucumvert from ucumvert.parser import ( get_ucum_parser, make_parse_tree_png, @@ -14,31 +14,26 @@ ) from ucumvert.ucum_pint import UcumToPintTransformer, find_matching_pint_definitions -try: - import pydot # noqa: F401 - - has_pydot = True -except ImportError: - has_pydot = False - logger = logging.getLogger(__name__) def interactive(): print("Enter UCUM unit code to parse, or 'q' to quit.") - if not has_pydot: + if not ucumvert.HAS_PYDOT: print("Package pydot not installed, skipping parse-tree image generation.") ucum_parser = get_ucum_parser() + transformer = UcumToPintTransformer().transform while True: ucum_code = input("> ") - if ucum_code in "qQ": + print(f"input: {ucum_code!r}") + if ucum_code and (ucum_code in "qQ"): break try: - if has_pydot: + if ucumvert.HAS_PYDOT: parsed_data = make_parse_tree_png( - ucum_code, filename="parse_tree.png", parser=ucum_parser + ucum_parser, ucum_code, filename="parse_tree.png" ) print("Created visualization of parse tree (parse_tree.png).") else: @@ -49,9 +44,9 @@ def interactive(): continue try: # parsed_data = ucum_parser.parse(data) # parse data without visualization - pint_quantity = UcumToPintTransformer().transform(parsed_data) + pint_quantity = transformer(parsed_data) print(f"--> Pint {pint_quantity!r}") - except (VisitError, ValueError) as e: + except (VisitError, ValueError) as e: # pragma: no cover print(e) continue @@ -91,15 +86,14 @@ def _split_lines(self, text, width): def root_cmds(args): - if args.version: # pragma: no cover - print(f"ucumvert {__version__}") + if args.version: + print(f"ucumvert {ucumvert.__version__}") if args.interactive: interactive() if args.mapping_report: find_matching_pint_definitions(report_file=args.mapping_report) if args.grammar_update: - grammar_file = Path(__file__).resolve().parent / "ucum_grammar.lark" - update_lark_ucum_grammar_file(grammar_file=grammar_file) + update_lark_ucum_grammar_file(grammar_file=args.grammar_update) def create_root_parser(): @@ -125,10 +119,13 @@ def create_root_parser(): "-g", "--grammar_update", help=( - "Recreate grammar file 'ucum_grammar.lark' with UCUM atoms " - "extracted from ucum-essence.xml." + "Create grammar file with UCUM atoms extracted from ucum-essence.xml. " + "Default is to write to 'ucum_grammar.lark' in the current directory." ), - action="store_true", + type=Path, + metavar=("FILE"), + nargs="?", # make file an optional argument + const=Path("ucum_grammar.lark"), # default value ) parser.add_argument( "-m", @@ -159,7 +156,7 @@ def main_cli(raw_args=None): # Parse the command-line arguments # parse_args will call sys.exit(2) if invalid commands are given. args = parser.parse_args(raw_args) - setup_logging(loglevel=logging.INFO) + ucumvert.setup_logging(loglevel=logging.INFO) args.func(args) @@ -169,10 +166,10 @@ def run_cli_app(raw_args=None): raw_args = sys.argv[1:] try: main_cli(raw_args) - except LarkError: + except LarkError: # pragma: no cover logger.exception("Terminating with ucumvert error.") sys.exit(1) - except Exception: + except Exception: # pragma: no cover logger.exception("Unexpected error.") sys.exit(3) # value 2 is used by argparse for invalid args. diff --git a/src/ucumvert/parser.py b/src/ucumvert/parser.py index d32b7a4..2c9490c 100644 --- a/src/ucumvert/parser.py +++ b/src/ucumvert/parser.py @@ -177,12 +177,7 @@ def get_ucum_parser(grammar_file=None): return Lark(ucum_grammar, start="main_term", strict=True) -def make_parse_tree_png(data, filename="parse_tree_unit.png", parser=None): - if parser is None: - parser = get_ucum_parser() +def make_parse_tree_png(parser, data, filename="parse_tree_unit.png"): parsed_data = parser.parse(data) - try: - tree.pydot__tree_to_png(parsed_data, filename) - except ImportError: - logger.warning("pydot not installed, skipping png generation") + tree.pydot__tree_to_png(parsed_data, filename) return parsed_data diff --git a/src/ucumvert/ucum_pint.py b/src/ucumvert/ucum_pint.py index 64f9e27..4349751 100644 --- a/src/ucumvert/ucum_pint.py +++ b/src/ucumvert/ucum_pint.py @@ -221,8 +221,8 @@ def ucum_preprocessor(unit_input): Note: This will make most standard pint unit expressions invalid. Usage: - >>> import pint - >>> ureg = pint.get_application_registry() + >>> from ucumvert import PintUcumRegistry, ucum_preprocessor + >>> ureg = PintUcumRegistry() >>> ureg.preprocessors.append(ucum_preprocessor) """ ucum_parser = get_ucum_parser() @@ -337,17 +337,6 @@ def find_matching_pint_definitions(report_file: Path | None = None) -> None: logger.info("Created mapping report: %s", report_file) -def get_pint_registry(ureg=None): - """Return a pint registry with the UCUM definitions loaded.""" - if ureg is None: - ureg = get_application_registry() - if "peripheral_vascular_resistance_unit" not in ureg: # UCUM already loaded? - ureg.load_definitions(Path(__file__).resolve().parent / "pint_ucum_defs.txt") - if ucum_preprocessor not in ureg.preprocessors: - ureg.preprocessors.append(ucum_preprocessor) - return ureg - - class PintUcumRegistry(UnitRegistry): def _after_init(self) -> None: """This is called after all __init__""" @@ -375,7 +364,7 @@ def from_ucum(self, ucum_code): return self._from_ucum_transformer(parsed_data) -def run_examples(): +def run_examples(): # pragma: no cover test_ucum_units = [ # "Cel", # "/s2", diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8ed4062 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,69 @@ +import logging +import os +from unittest import mock + +import pytest +from ucumvert.cli import main_cli, run_cli_app + + +def test_run_cli_app_no_args_entrypoint(monkeypatch, capsys): + monkeypatch.setattr("sys.argv", ["ucumvert"]) + run_cli_app() + captured = capsys.readouterr() + assert "usage: ucumvert" in captured.out + + +def test_run_cli_app_no_args(capsys): + run_cli_app([]) + captured = capsys.readouterr() + assert "usage: ucumvert" in captured.out + + +def test_main_unknown_arg(capsys): + with pytest.raises(SystemExit) as exc_info: + main_cli(["--unknown-arg"]) + assert exc_info.value.code == 2 # noqa: PLR2004 + captured = capsys.readouterr() + assert "ucumvert: error: unrecognized arguments: --unknown-arg" in captured.err + + +def test_main_version(capsys): + main_cli(["--version"]) + captured = capsys.readouterr() + assert captured.out.startswith("ucumvert") + + +def test_run_mapping_report_generation(tmp_path): + dst = tmp_path / "mapping.txt" + main_cli(["--mapping_report", str(dst)]) + expected = dst + assert expected.exists() + + +def test_run_grammar_update(tmp_path): + dst = tmp_path / "ucum_grammar.lark" + main_cli(["--grammar_update", str(dst)]) + expected = dst + assert expected.exists() + + +@pytest.mark.parametrize(("pydot_installed"), [(True), (False)]) +def test_run_interactive(monkeypatch, capsys, pydot_installed): + if not pydot_installed: + monkeypatch.setattr("ucumvert.HAS_PYDOT", False) + inputs = ["kg", "missing", "q"] + input_generator = (i for i in inputs) + monkeypatch.setattr("builtins.input", lambda _: next(input_generator)) + main_cli(["--interactive"]) + captured = capsys.readouterr() + assert "Pint " in captured.out + + +@mock.patch.dict(os.environ, {"LOGLEVEL": "ERROR"}) +def test_valid_config(caplog, tmp_path): + # Don't remove "temp_config". The fixture avoid global config change. + dst = tmp_path / "grammar_log.txt" + main_cli(["-g", str(dst)]) + with caplog.at_level(logging.ERROR): + main_cli(["-g", str(dst)]) + assert not caplog.text diff --git a/tests/test_ucum_pint.py b/tests/test_ucum_pint.py index f9951bb..e4e85ae 100644 --- a/tests/test_ucum_pint.py +++ b/tests/test_ucum_pint.py @@ -1,7 +1,13 @@ import pytest +from lark import LarkError from pint import UnitRegistry from test_parser import ucum_examples_valid -from ucumvert import UcumToPintStrTransformer, UcumToPintTransformer +from ucumvert import ( + PintUcumRegistry, + UcumToPintStrTransformer, + UcumToPintTransformer, + ucum_preprocessor, +) from ucumvert.ucum_pint import find_ucum_codes_that_need_mapping from ucumvert.xml_util import get_metric_units, get_non_metric_units @@ -90,3 +96,17 @@ def test_ucum_all_unit_atoms_pint_vs_str( expected_quantity = transform_ucum_pint(parsed_atom) result_str = transform_ucum_str(parsed_atom) assert ureg_ucumvert(result_str) == expected_quantity + + +def test_ucum_preprocessor(ureg_ucumvert): + expected = ureg_ucumvert("m*kg") + ureg_ucumvert.preprocessors.append(ucum_preprocessor) + assert ureg_ucumvert("m.kg") == expected + with pytest.raises(LarkError): + ureg_ucumvert("degC") + + +def test_ucum_unitregistry(): + ureg = PintUcumRegistry() + assert ureg.from_ucum("m.kg") == ureg("m*kg") + assert ureg.from_ucum("Cel") == ureg("degC")