diff --git a/src/ucumvert/__init__.py b/src/ucumvert/__init__.py index 550f49e..1990c8a 100644 --- a/src/ucumvert/__init__.py +++ b/src/ucumvert/__init__.py @@ -5,7 +5,9 @@ from pathlib import Path from ucumvert.parser import ( + InvalidUcumError, get_ucum_parser, + parse_ucum, update_lark_ucum_grammar_file, ) from ucumvert.ucum_pint import ( @@ -29,10 +31,12 @@ HAS_PYDOT = False __all__ = [ + "InvalidUcumError", "PintUcumRegistry", "UcumToPintStrTransformer", "UcumToPintTransformer", "get_ucum_parser", + "parse_ucum", "ucum_preprocessor", "update_lark_ucum_grammar_file", ] diff --git a/src/ucumvert/parser.py b/src/ucumvert/parser.py index 5a0e57a..56eaf41 100644 --- a/src/ucumvert/parser.py +++ b/src/ucumvert/parser.py @@ -5,6 +5,7 @@ from pathlib import Path from lark import Lark, Transformer +from lark.exceptions import LarkError, UnexpectedInput import ucumvert from ucumvert.xml_util import ( @@ -179,3 +180,31 @@ def get_ucum_parser(grammar_file=None): with grammar_file.open("r", encoding="utf8") as f: ucum_grammar = f.read() return Lark(ucum_grammar, start="main_term", strict=True) + + +class InvalidUcumError(LarkError): + """Raised when a string cannot be parsed as a UCUM unit.""" + + +def parse_ucum(ucum_code, parser=None): + """Parse a UCUM code into a lark tree. + + Wraps lark's low-level parse errors in an InvalidUcumError that names the + offending unit and points at the failure, without exposing grammar-internal + terminal names that are meaningless to a UCUM user. + """ + if parser is None: + parser = get_ucum_parser() + try: + return parser.parse(ucum_code) + except UnexpectedInput as exc: + context = exc.get_context(ucum_code).rstrip("\n") + if exc.pos_in_stream is not None and 0 <= exc.pos_in_stream < len(ucum_code): + detail = ( + f"unexpected character {ucum_code[exc.pos_in_stream]!r} " + f"at column {exc.column}" + ) + else: + detail = "unexpected end of input" + msg = f"{ucum_code!r} is not a valid UCUM unit: {detail}.\n{context}" + raise InvalidUcumError(msg) from exc diff --git a/src/ucumvert/ucum_pint.py b/src/ucumvert/ucum_pint.py index 299190d..63c5f26 100644 --- a/src/ucumvert/ucum_pint.py +++ b/src/ucumvert/ucum_pint.py @@ -15,6 +15,7 @@ from ucumvert.parser import ( get_ucum_parser, + parse_ucum, ) from ucumvert.xml_util import ( get_metric_units, @@ -261,7 +262,7 @@ def ucum_preprocessor(unit_input): """ ucum_parser = get_ucum_parser() transformer = UcumToPintStrTransformer() - parsed_data = ucum_parser.parse(unit_input) + parsed_data = parse_ucum(unit_input, ucum_parser) return str(transformer.transform(parsed_data)) @@ -395,7 +396,7 @@ def from_ucum(self, ucum_code): ucum_code : Ucum code as string. """ - parsed_data = self._ucum_parser.parse(ucum_code) + parsed_data = parse_ucum(ucum_code, self._ucum_parser) return self._from_ucum_transformer(parsed_data) diff --git a/tests/test_ucum_pint.py b/tests/test_ucum_pint.py index 05403d3..35a9914 100644 --- a/tests/test_ucum_pint.py +++ b/tests/test_ucum_pint.py @@ -11,6 +11,7 @@ UcumToPintTransformer, ucum_preprocessor, ) +from ucumvert.parser import InvalidUcumError, parse_ucum from ucumvert.ucum_pint import find_ucum_codes_that_need_mapping from ucumvert.xml_util import get_metric_units, get_non_metric_units @@ -198,3 +199,41 @@ def test_decibel_milliwatt_is_invalid_ucum_issue62(ucum_parser): # (pint's dBm) has no UCUM representation and must not parse. with pytest.raises(LarkError): ucum_parser.parse("dB[mW]") + + +def test_invalid_ucum_unit_has_clear_message_issue62(ucum_parser): + with pytest.raises(InvalidUcumError) as excinfo: + parse_ucum("dB[mW]", ucum_parser) + msg = str(excinfo.value) + assert "dB[mW]" in msg # echoes the offending input + assert "not a valid UCUM unit" in msg # domain framing, not parser jargon + assert "'['" in msg # names the offending character + assert "^" in msg # keeps the caret pointing at the failure + assert "ANNOTATION" not in msg # drops grammar terminal names + + +def test_invalid_ucum_unit_is_larkerror_issue62(ucum_parser): + # Backward compatibility: callers catching LarkError keep working. + assert issubclass(InvalidUcumError, LarkError) + with pytest.raises(LarkError): + parse_ucum("dB[mW]", ucum_parser) + + +def test_invalid_ucum_truncated_input_message_issue62(ucum_parser): + with pytest.raises(InvalidUcumError) as excinfo: + parse_ucum("m/", ucum_parser) + assert "end of input" in str(excinfo.value) + + +def test_from_ucum_raises_clear_message_issue62(): + reg = PintUcumRegistry() + with pytest.raises(InvalidUcumError): + reg.from_ucum("dB[mW]") + + +def test_parse_ucum_builds_parser_when_none_given_issue62(): + # parse_ucum() builds its own parser when none is passed. + tree = parse_ucum("kg") + # The root rule of the UCUM grammar is "main_term", so a tree rooted there + # demonstrates that the self-built parser produced a valid UCUM parse tree. + assert tree.data == "main_term"