Skip to content
Merged
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 114 additions & 11 deletions src/rfc3987_syntax2/syntax_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Comment thread
jkowalleck marked this conversation as resolved.
try:
parser.parse(text)
return True
Expand All @@ -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)
Comment thread
jkowalleck marked this conversation as resolved.
return _syntax_parser


is_valid_syntax_iri = make_syntax_validator("iri")

is_valid_syntax_iri_reference = make_syntax_validator("iri_reference")
Expand Down Expand Up @@ -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()
Comment thread
jkowalleck marked this conversation as resolved.
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
6 changes: 6 additions & 0 deletions tests/test_exports_exists.py
Original file line number Diff line number Diff line change
@@ -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

26 changes: 26 additions & 0 deletions tests/test_lazy_attrs.py
Original file line number Diff line number Diff line change
@@ -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)