diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e33fa..c122dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ All notable changes to this project will be documented in this file. * Fix `RFC3987_SYNTAX_TERMS` correctly containing "ipath_noscheme" (via [#41]) * Fix `RFC3987_SYNTAX_TERMS` correctly containing "ipath_rootless" (via [#41]) * Fix `RFC3987_SYNTAX_TERMS` correctly containing "ipath_empty" (via [#41]) - * Fix `ucschar` range (via [#45]) + * Fix `ucschar` range (via [#45]) +* Performance + * Public symbols use lazy loading (via [#43]) [#41]: https://github.com/jkowalleck/rfc3987-syntax2/pull/41 +[#43]: https://github.com/jkowalleck/rfc3987-syntax2/pull/43 [#45]: https://github.com/jkowalleck/rfc3987-syntax2/pull/45 ## v1.2.0 - 2026-07-16 diff --git a/src/rfc3987_syntax2/syntax_helpers.py b/src/rfc3987_syntax2/syntax_helpers.py index 418c8b7..ecdb943 100644 --- a/src/rfc3987_syntax2/syntax_helpers.py +++ b/src/rfc3987_syntax2/syntax_helpers.py @@ -2,14 +2,64 @@ # Copyright (c) 2026 Jan Kowalleck - modifications and maintenance # SPDX-License-Identifier: MIT -from typing import Callable +from pathlib import Path +from threading import Lock +from typing import Any, Callable, Optional, TYPE_CHECKING from lark import Lark, ParseTree, exceptions -from pathlib import Path - from .utils import load_grammar +__all__ = [ + "RFC3987_SYNTAX_PARSER_TYPE", + "RFC3987_SYNTAX_GRAMMAR_PATH", + "RFC3987_SYNTAX_TERMS", + "grammar", + "syntax_parser", + "parse", + "is_valid_syntax", + "make_syntax_validator", + "is_valid_syntax_iri", + "is_valid_syntax_iri_reference", + "is_valid_syntax_absolute_iri", + "is_valid_syntax_irelative_ref", + "is_valid_syntax_irelative_part", + "is_valid_syntax_ihier_part", + "is_valid_syntax_iauthority", + "is_valid_syntax_iuserinfo", + "is_valid_syntax_ihost", + "is_valid_syntax_ireg_name", + "is_valid_syntax_ipath", + "is_valid_syntax_ipath_abempty", + "is_valid_syntax_ipath_absolute", + "is_valid_syntax_ipath_noscheme", + "is_valid_syntax_ipath_rootless", + "is_valid_syntax_ipath_empty", + "is_valid_syntax_isegment", + "is_valid_syntax_isegment_nz", + "is_valid_syntax_isegment_nz_nc", + "is_valid_syntax_ipchar", + "is_valid_syntax_iquery", + "is_valid_syntax_ifragment", + "is_valid_syntax_iunreserved", + "is_valid_syntax_ucschar", + "is_valid_syntax_iprivate", + "is_valid_syntax_sub_delims", + "is_valid_syntax_ip_literal", + "is_valid_syntax_ipvfuture", + "is_valid_syntax_ipv6address", + "is_valid_syntax_h16", + "is_valid_syntax_ls32", + "is_valid_syntax_ipv4address", + "is_valid_syntax_dec_octet", + "is_valid_syntax_unreserved", + "is_valid_syntax_alpha", + "is_valid_syntax_digit", + "is_valid_syntax_hexdig", + "is_valid_syntax_port", +] + + RFC3987_SYNTAX_PARSER_TYPE: str = "earley" RFC3987_SYNTAX_GRAMMAR_PATH: Path = Path(__file__).parent / "syntax_rfc3987.lark" RFC3987_SYNTAX_TERMS: list[str] = [ @@ -56,13 +106,8 @@ "pct_encoded", ] -grammar: str = load_grammar(RFC3987_SYNTAX_GRAMMAR_PATH) - -syntax_parser = Lark(grammar, start=["iri", "iri_reference", "absolute_iri"], parser=RFC3987_SYNTAX_PARSER_TYPE) - - -def parse(term: str, value: str) -> ParseTree: - return syntax_parser.parse(value, start=term) +def parse(term: str, value: str) -> 'ParseTree': + return _get_syntax_parser().parse(value, start=term) def is_valid_syntax(term: str, value: str) -> bool: @@ -74,9 +119,16 @@ def is_valid_syntax(term: str, value: str) -> bool: def make_syntax_validator(rule_name: str) -> Callable[[str], bool]: - parser = Lark(grammar, start=rule_name, parser=RFC3987_SYNTAX_PARSER_TYPE) + 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) return True @@ -86,6 +138,35 @@ def syntax_validator(text: str) -> bool: 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=["iri", "iri_reference", "absolute_iri"], + parser=RFC3987_SYNTAX_PARSER_TYPE) + return _syntax_parser + + is_valid_syntax_iri = make_syntax_validator("iri") is_valid_syntax_iri_reference = make_syntax_validator("iri_reference") @@ -161,3 +242,25 @@ def syntax_validator(text: str) -> bool: is_valid_syntax_hexdig = make_syntax_validator("hexdig") is_valid_syntax_port = make_syntax_validator("port") + +# region lazy loaded attrs + +if TYPE_CHECKING: # types for lazy-loaded symbols + grammar: str + syntax_parser: Lark + + +def __getattr__(name: str) -> Any: + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + if name == 'grammar': + return _get_grammar() + if name == 'syntax_parser': + return _get_syntax_parser() + raise AttributeError(f"module {__name__!r} failed to implement attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(__all__)) + +# endregion lazy loaded attrs diff --git a/tests/test_exports_exists.py b/tests/test_exports_exists.py new file mode 100644 index 0000000..5ba00ac --- /dev/null +++ b/tests/test_exports_exists.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025 Will Riley - original author +# Copyright (c) 2026 Jan Kowalleck - modifications and maintenance +# SPDX-License-Identifier: MIT + +import rfc3987_syntax2 as sut + diff --git a/tests/test_lazy_attrs.py b/tests/test_lazy_attrs.py new file mode 100644 index 0000000..2267ca2 --- /dev/null +++ b/tests/test_lazy_attrs.py @@ -0,0 +1,26 @@ +# Copyright (c) 2026 Jan Kowalleck - modifications and maintenance +# SPDX-License-Identifier: MIT + +import rfc3987_syntax2 as sut +from lark import Lark + + +def test_grammar_is_accessible() -> None: + """Regression test: grammar is lazily loaded and accessible as a module attribute.""" + assert isinstance(sut.grammar, str) + assert len(sut.grammar) > 0 + + +def test_syntax_parser_is_accessible() -> None: + """Regression test: syntax_parser is lazily loaded and accessible as a module attribute.""" + assert isinstance(sut.syntax_parser, Lark) + + +def test_grammar_in_dir() -> None: + """Regression test: grammar appears in dir(rfc3987_syntax2).""" + assert "grammar" in dir(sut) + + +def test_syntax_parser_in_dir() -> None: + """Regression test: syntax_parser appears in dir(rfc3987_syntax2).""" + assert "syntax_parser" in dir(sut)