diff --git a/CHANGELOG.md b/CHANGELOG.md index 26eb914..356bacb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. +BREAKING: +- removed `.syntax_helpers` +- removed `.utils` + ## v1.3.0 - 2026-07-21 * Fixed diff --git a/src/rfc3987_syntax2/__init__.py b/src/rfc3987_syntax2/__init__.py index 99ee396..fcbfbff 100644 --- a/src/rfc3987_syntax2/__init__.py +++ b/src/rfc3987_syntax2/__init__.py @@ -2,30 +2,228 @@ # Copyright (c) 2026 Jan Kowalleck - modifications and maintenance # SPDX-License-Identifier: MIT +from pathlib import Path +from threading import Lock +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional +from warnings import warn + +from lark import Lark, ParseTree, exceptions + +__all__ = [ + "RFC3987_SYNTAX_PARSER_TYPE", + "RFC3987_SYNTAX_GRAMMAR_PATH", + "RFC3987_SYNTAX_TERMS", + "grammar", + "T_SYNTAX_PARSER_TERM", + "parse", + "syntax_parser", + "T_SYNTAX_VALIDATOR", + "is_valid_syntax", + "make_syntax_validator", +] + +RFC3987_SYNTAX_PARSER_TYPE: str = "earley" +RFC3987_SYNTAX_GRAMMAR_PATH: Path = Path(__file__).parent / "syntax_rfc3987.lark" +RFC3987_SYNTAX_TERMS: list[str] = [ + "iri", + "iri_reference", + "absolute_iri", + "scheme", + "ihier_part", + "irelative_ref", + "irelative_part", + "iauthority", + "iuserinfo", + "ihost", + "ireg_name", + "ipath", + "ipath_abempty", + "ipath_absolute", + "ipath_noscheme", + "ipath_rootless", + "ipath_empty", + "isegment", + "isegment_nz", + "isegment_nz_nc", + "ipchar", + "iquery", + "ifragment", + "iunreserved", + "ucschar", + "iprivate", + "sub_delims", + "ip_literal", + "ipvfuture", + "ipv6address", + "h16", + "ls32", + "ipv4address", + "dec_octet", + "digit", + # non_zero - a local helper token used in `dec_octet` + "non_zero", + "unreserved", + "alpha", + "hexdig", + "port", + "pct_encoded", +] +"""All supported RFC 3987 grammar rule names exposed by this module. + +These term names can be used to select validators from +:data:`RFC3987_SYNTAX_TERM_VALIDATORS` and correspond to rules +defined in the RFC 3987 Lark grammar. """ -Re-export of :mod:`.syntax_helpers` + +T_SYNTAX_PARSER_TERM = Literal["iri", "iri_reference", "absolute_iri"] +"""Top-level RFC 3987 parser start terms accepted by :func:`parse` and :func:`check`. + +Allowed values are ``"iri"``, ``"iri_reference"``, and ``"absolute_iri"``. """ +_SYNTAX_PARSER_STARTS: list[T_SYNTAX_PARSER_TERM] = ["iri", "iri_reference", "absolute_iri"] + + +def parse(term: T_SYNTAX_PARSER_TERM, value: str) -> ParseTree: + """Parse text as one of the top-level RFC 3987 syntax terms. + + :param term: Start rule used for parsing. + Must be one of ``"iri"``, ``"iri_reference"``, or ``"absolute_iri"``. + :param value: Input text to parse. + :return: The Lark parse tree for ``value`` under the selected start rule. + :raises lark.exceptions.LarkError: If parser initialization fails or parsing cannot be completed. + """ + + return _get_syntax_parser().parse(value, start=term) -from typing import TYPE_CHECKING, Any -from . import syntax_helpers as _syntax_helpers +def is_valid_syntax(term: T_SYNTAX_PARSER_TERM, value: str) -> bool: + """Check whether text is valid for a top-level RFC 3987 syntax term. + + This is a boolean convenience wrapper around :func:`parse`. + + .. warning:: Emits a ``RuntimeWarning`` (via :func:`warnings.warn`) when a non-``UnexpectedInput`` + ``lark.exceptions.LarkError`` occurs. + + :param term: Start rule used for validation. + Must be one of ``"iri"``, ``"iri_reference"``, or ``"absolute_iri"``. + :param value: Input text to validate. + :return: ``True`` if parsing succeeds; otherwise ``False``. + """ + + try: + parse(term=term, value=value) + except exceptions.UnexpectedInput: + # from Lark.parse() + return False + except exceptions.LarkError as err: + # from Lark internals / initialization (non-UnexpectedInput) + # TODO: make this thor transparent - dont catch it + warn("Unexpected LarkError (non-UnexpectedInput) " + f"for term={term!r}: {type(err).__name__}: {err}", + RuntimeWarning, + stacklevel=2) + return False + return True + + +T_SYNTAX_VALIDATOR = Callable[[str], bool] +"""Callable validator for one RFC 3987 grammar rule. + +:param text: Input text to validate. +:return: ``True`` if ``text`` matches the target rule, otherwise ``False``. +""" + -__all__ = _syntax_helpers.__all__ +def make_syntax_validator(rule_name: str) -> T_SYNTAX_VALIDATOR: + """Create a validator function for a specific RFC 3987 grammar rule. -_EXPORTS_SH = frozenset(_syntax_helpers.__all__) + The returned callable lazily initializes and caches a dedicated + :class:`lark.Lark` parser configured with ``start=rule_name`` and + ``parser=RFC3987_SYNTAX_PARSER_TYPE``. -if TYPE_CHECKING: - from .syntax_helpers import * -# cannot simply re-export - need to respect lazy-loading + :param rule_name: Grammar rule name to validate against. Must be a value from + :data:`RFC3987_SYNTAX_TERMS`. + :return: A callable ``(text: str) -> bool`` that returns ``True`` when parsing + succeeds and ``False`` if a ``lark.exceptions.LarkError`` is raised. + """ + + parser: Optional[Lark] = None + parser_lock = Lock() + + def syntax_validator(text: str) -> bool: + nonlocal parser + with parser_lock: + if parser is None: + parser = Lark(_get_grammar(), + start=rule_name, + parser=RFC3987_SYNTAX_PARSER_TYPE) + try: + parser.parse(text) + except exceptions.LarkError: + return False + else: + return True + + return syntax_validator + + +_grammar: Optional[str] = None +_grammar_lock = Lock() + + +def _get_grammar() -> str: + """this is private API""" + + global _grammar + with _grammar_lock: + if _grammar is None: + _grammar = RFC3987_SYNTAX_GRAMMAR_PATH.read_text(encoding="utf-8") + return _grammar + + +_syntax_parser: Optional[Lark] = None +_syntax_parser_lock = Lock() + + +def _get_syntax_parser() -> Lark: + """this is private API""" + + global _syntax_parser + if _syntax_parser is not None: + return _syntax_parser + grammar = _get_grammar() + with _syntax_parser_lock: + if _syntax_parser is None: + _syntax_parser = Lark(grammar, + start=_SYNTAX_PARSER_STARTS, + parser=RFC3987_SYNTAX_PARSER_TYPE) + return _syntax_parser + + +# region lazy loaded attrs + +if TYPE_CHECKING: # types for lazy-loaded symbols + grammar: str + """Lark grammar text for RFC 3987.""" + +if TYPE_CHECKING: # types for lazy-loaded symbols + syntax_parser: Lark + """Lazily initialized parser for RFC 3987 syntax.""" def __getattr__(name: str) -> Any: - if name in _EXPORTS_SH: - value = getattr(_syntax_helpers, name) - globals()[name] = value - return value + if name == "grammar": + global grammar + grammar = _get_grammar() + return grammar + if name == "syntax_parser": + global syntax_parser + syntax_parser = _get_syntax_parser() + return syntax_parser raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def __dir__() -> list[str]: return sorted(set(globals().keys()) | set(__all__)) + +# endregion lazy loaded attrs diff --git a/src/rfc3987_syntax2/syntax_helpers.py b/src/rfc3987_syntax2/is_valid_syntax_term.py similarity index 66% rename from src/rfc3987_syntax2/syntax_helpers.py rename to src/rfc3987_syntax2/is_valid_syntax_term.py index 6f64b60..1222d16 100644 --- a/src/rfc3987_syntax2/syntax_helpers.py +++ b/src/rfc3987_syntax2/is_valid_syntax_term.py @@ -2,26 +2,13 @@ # Copyright (c) 2026 Jan Kowalleck - modifications and maintenance # SPDX-License-Identifier: MIT -from pathlib import Path -from threading import Lock -from typing import TYPE_CHECKING, Any, Callable, Literal, Optional -from warnings import warn +from typing import TYPE_CHECKING -from lark import Lark, ParseTree, exceptions +from . import T_SYNTAX_VALIDATOR, make_syntax_validator -from .utils import load_grammar +"""prepared term validators""" __all__ = [ - "RFC3987_SYNTAX_PARSER_TYPE", - "RFC3987_SYNTAX_GRAMMAR_PATH", - "RFC3987_SYNTAX_TERMS", - "grammar", - "T_SYNTAX_PARSER_TERM", - "parse", - "syntax_parser", - "T_SYNTAX_VALIDATOR", - "is_valid_syntax", - "make_syntax_validator", "RFC3987_SYNTAX_TERM_VALIDATORS", "is_valid_syntax_iri", "is_valid_syntax_iri_reference", @@ -65,183 +52,6 @@ "is_valid_syntax_pct_encoded", ] -RFC3987_SYNTAX_PARSER_TYPE: str = "earley" -RFC3987_SYNTAX_GRAMMAR_PATH: Path = Path(__file__).parent / "syntax_rfc3987.lark" -RFC3987_SYNTAX_TERMS: list[str] = [ - "iri", - "iri_reference", - "absolute_iri", - "scheme", - "ihier_part", - "irelative_ref", - "irelative_part", - "iauthority", - "iuserinfo", - "ihost", - "ireg_name", - "ipath", - "ipath_abempty", - "ipath_absolute", - "ipath_noscheme", - "ipath_rootless", - "ipath_empty", - "isegment", - "isegment_nz", - "isegment_nz_nc", - "ipchar", - "iquery", - "ifragment", - "iunreserved", - "ucschar", - "iprivate", - "sub_delims", - "ip_literal", - "ipvfuture", - "ipv6address", - "h16", - "ls32", - "ipv4address", - "dec_octet", - "digit", - # non_zero - a local helper token used in `dec_octet` - "non_zero", - "unreserved", - "alpha", - "hexdig", - "port", - "pct_encoded", -] -"""All supported RFC 3987 grammar rule names exposed by this module. - -These term names can be used to select validators from -:data:`RFC3987_SYNTAX_TERM_VALIDATORS` and correspond to rules -defined in the RFC 3987 Lark grammar. -""" - -T_SYNTAX_PARSER_TERM = Literal["iri", "iri_reference", "absolute_iri"] -"""Top-level RFC 3987 parser start terms accepted by :func:`parse` and :func:`check`. - -Allowed values are ``"iri"``, ``"iri_reference"``, and ``"absolute_iri"``. -""" -_SYNTAX_PARSER_STARTS: list[T_SYNTAX_PARSER_TERM] = ["iri", "iri_reference", "absolute_iri"] - - -def parse(term: T_SYNTAX_PARSER_TERM, value: str) -> ParseTree: - """Parse text as one of the top-level RFC 3987 syntax terms. - - :param term: Start rule used for parsing. - Must be one of ``"iri"``, ``"iri_reference"``, or ``"absolute_iri"``. - :param value: Input text to parse. - :return: The Lark parse tree for ``value`` under the selected start rule. - :raises lark.exceptions.LarkError: If parser initialization fails or parsing cannot be completed. - """ - - return _get_syntax_parser().parse(value, start=term) - - -def is_valid_syntax(term: T_SYNTAX_PARSER_TERM, value: str) -> bool: - """Check whether text is valid for a top-level RFC 3987 syntax term. - - This is a boolean convenience wrapper around :func:`parse`. - - .. warning:: Emits a ``RuntimeWarning`` (via :func:`warnings.warn`) when a non-``UnexpectedInput`` - ``lark.exceptions.LarkError`` occurs. - - :param term: Start rule used for validation. - Must be one of ``"iri"``, ``"iri_reference"``, or ``"absolute_iri"``. - :param value: Input text to validate. - :return: ``True`` if parsing succeeds; otherwise ``False``. - """ - - try: - parse(term=term, value=value) - except exceptions.UnexpectedInput: - # from Lark.parse() - return False - except exceptions.LarkError as err: - # from Lark internals / initialization (non-UnexpectedInput) - warn("Unexpected LarkError (non-UnexpectedInput) " - f"for term={term!r}: {type(err).__name__}: {err}", - RuntimeWarning, - stacklevel=2) - return False - return True - - -T_SYNTAX_VALIDATOR = Callable[[str], bool] -"""Callable validator for one RFC 3987 grammar rule. - -:param text: Input text to validate. -:return: ``True`` if ``text`` matches the target rule, otherwise ``False``. -""" - - -def make_syntax_validator(rule_name: str) -> T_SYNTAX_VALIDATOR: - """Create a validator function for a specific RFC 3987 grammar rule. - - The returned callable lazily initializes and caches a dedicated - :class:`lark.Lark` parser configured with ``start=rule_name`` and - ``parser=RFC3987_SYNTAX_PARSER_TYPE``. - - :param rule_name: Grammar rule name to validate against. Must be a value from - :data:`RFC3987_SYNTAX_TERMS`. - :return: A callable ``(text: str) -> bool`` that returns ``True`` when parsing - succeeds and ``False`` if a ``lark.exceptions.LarkError`` is raised. - """ - - parser: Optional[Lark] = None - parser_lock = Lock() - - def syntax_validator(text: str) -> bool: - nonlocal parser - with parser_lock: - if parser is None: - parser = Lark(_get_grammar(), - start=rule_name, - parser=RFC3987_SYNTAX_PARSER_TYPE) - try: - parser.parse(text) - except exceptions.LarkError: - return False - else: - return True - - return syntax_validator - - -_grammar: Optional[str] = None -_grammar_lock = Lock() - - -def _get_grammar() -> str: - """this is private API""" - - global _grammar - with _grammar_lock: - if _grammar is None: - _grammar = load_grammar(RFC3987_SYNTAX_GRAMMAR_PATH) - return _grammar - - -_syntax_parser: Optional[Lark] = None -_syntax_parser_lock = Lock() - - -def _get_syntax_parser() -> Lark: - """this is private API""" - - global _syntax_parser - if _syntax_parser is not None: - return _syntax_parser - grammar = _get_grammar() - with _syntax_parser_lock: - if _syntax_parser is None: - _syntax_parser = Lark(grammar, - start=_SYNTAX_PARSER_STARTS, - parser=RFC3987_SYNTAX_PARSER_TYPE) - return _syntax_parser - - if TYPE_CHECKING: def is_valid_syntax_iri(text: str) -> bool: """Validate that input text conforms to the RFC 3987 ``IRI`` rule.""" @@ -249,7 +59,6 @@ def is_valid_syntax_iri(text: str) -> bool: else: is_valid_syntax_iri = make_syntax_validator("iri") - if TYPE_CHECKING: def is_valid_syntax_iri_reference(text: str) -> bool: """Validate that input text conforms to the RFC 3987 ``IRI-reference`` rule.""" @@ -362,7 +171,6 @@ def is_valid_syntax_ipath_empty(text: str) -> bool: else: is_valid_syntax_ipath_empty = make_syntax_validator("ipath_empty") - if TYPE_CHECKING: def is_valid_syntax_isegment(text: str) -> bool: """Validate that input text conforms to the RFC 3987 ``isegment`` rule.""" @@ -524,8 +332,7 @@ def is_valid_syntax_pct_encoded(text: str) -> bool: else: is_valid_syntax_pct_encoded = make_syntax_validator("pct_encoded") - -RFC3987_SYNTAX_TERM_VALIDATORS: dict[str, T_SYNTAX_VALIDATOR] = { # frozendict +RFC3987_SYNTAX_TERM_VALIDATORS: dict[str, 'T_SYNTAX_VALIDATOR'] = { # frozendict "iri": is_valid_syntax_iri, "iri_reference": is_valid_syntax_iri_reference, "absolute_iri": is_valid_syntax_absolute_iri, @@ -573,31 +380,3 @@ def is_valid_syntax_pct_encoded(text: str) -> bool: Allowed keys are the RFC3987 term literals (see :data:`RFC3987_SYNTAX_TERMS`). """ - -# region lazy loaded attrs - -if TYPE_CHECKING: # types for lazy-loaded symbols - grammar: str - """Lark grammar text for RFC 3987.""" - -if TYPE_CHECKING: # types for lazy-loaded symbols - syntax_parser: Lark - """Lazily initialized parser for RFC 3987 syntax.""" - - -def __getattr__(name: str) -> Any: - if name == "grammar": - global grammar - grammar = _get_grammar() - return grammar - if name == "syntax_parser": - global syntax_parser - syntax_parser = _get_syntax_parser() - return syntax_parser - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -def __dir__() -> list[str]: - return sorted(set(globals().keys()) | set(__all__)) - -# endregion lazy loaded attrs diff --git a/src/rfc3987_syntax2/utils.py b/src/rfc3987_syntax2/utils.py deleted file mode 100644 index 32f05dc..0000000 --- a/src/rfc3987_syntax2/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2025 Will Riley - original author -# Copyright (c) 2026 Jan Kowalleck - modifications and maintenance -# SPDX-License-Identifier: MIT - -from pathlib import Path - - -def load_grammar(path: Path) -> str: - with open(path, "r", encoding="utf-8") as file: - return file.read() diff --git a/tests/test_syntax.py b/tests/is_valid_syntax_term.py similarity index 67% rename from tests/test_syntax.py rename to tests/is_valid_syntax_term.py index a9d552a..0c0795c 100644 --- a/tests/test_syntax.py +++ b/tests/is_valid_syntax_term.py @@ -6,7 +6,8 @@ import pytest -import rfc3987_syntax2 as sut +import rfc3987_syntax2.is_valid_syntax_term as sut +from rfc3987_syntax2 import RFC3987_SYNTAX_TERMS from . import SyntaxCase, T_syntax_file, invalid_syntax_data, valid_syntax_data @@ -39,3 +40,19 @@ def test_not_is_valid_syntax(term: str, invalid_example: SyntaxCase) -> None: assert ( actual is False ), f"Failed term: {term} for '{invalid_example['value']}' : {invalid_example['reason']}" + + +@pytest.mark.parametrize("term", RFC3987_SYNTAX_TERMS) +def test_term_validator_exists(term: str) -> None: + actual = sut.RFC3987_SYNTAX_TERM_VALIDATORS.get(term) + assert (actual is not None), f"missing func for {term}" + + +@pytest.mark.parametrize("term", (t for t in RFC3987_SYNTAX_TERMS if t not in [ + # non_zero - a local helper token used in `dec_octet` + 'non_zero', +])) +def test_term_validator_as_expected(term: str) -> None: + expected = getattr(sut, f'is_valid_syntax_{term}') + actual = sut.RFC3987_SYNTAX_TERM_VALIDATORS[term] + assert (actual is expected), f"unexpected func for {term}" diff --git a/tests/test_exports_exists.py b/tests/test_exports_exists.py index 54d094d..d5a5e7a 100644 --- a/tests/test_exports_exists.py +++ b/tests/test_exports_exists.py @@ -3,3 +3,4 @@ # SPDX-License-Identifier: MIT import rfc3987_syntax2 as sut +import rfc3987_syntax2.is_valid_syntax_term as sut_is_valid_syntax_term diff --git a/tests/test_is_valid_syntax_warning.py b/tests/test_is_valid_syntax_warning.py index d5e5fb4..9b276a0 100644 --- a/tests/test_is_valid_syntax_warning.py +++ b/tests/test_is_valid_syntax_warning.py @@ -6,7 +6,7 @@ import pytest from lark import exceptions -import rfc3987_syntax2.syntax_helpers as sut +import rfc3987_syntax2 as sut def _raise_lark_error(*args: object, **kwargs: object) -> None: diff --git a/tests/test_term_validators.py b/tests/test_term_validators.py deleted file mode 100644 index 0e2c83d..0000000 --- a/tests/test_term_validators.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright (c) 2025 Will Riley - original author -# Copyright (c) 2026 Jan Kowalleck - modifications and maintenance -# SPDX-License-Identifier: MIT - -import pytest - -import rfc3987_syntax2 as sut - - -@pytest.mark.parametrize("term", sut.RFC3987_SYNTAX_TERMS) -def test_term_validator_exists(term: str) -> None: - actual = sut.RFC3987_SYNTAX_TERM_VALIDATORS.get(term) - assert (actual is not None), f"missing func for {term}" - - -@pytest.mark.parametrize("term", (t for t in sut.RFC3987_SYNTAX_TERMS if t not in [ - # non_zero - a local helper token used in `dec_octet` - 'non_zero', -])) -def test_term_validator_as_expected(term: str) -> None: - expected = getattr(sut, f'is_valid_syntax_{term}') - actual = sut.RFC3987_SYNTAX_TERM_VALIDATORS[term] - assert (actual is expected), f"unexpected func for {term}"