diff --git a/CHANGELOG.md b/CHANGELOG.md index 665f021..26eb914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. +## v1.3.0 - 2026-07-21 + * Fixed * Fix grammar for `ucschar` character range (via [#45]) * Fix `RFC3987_SYNTAX_TERMS` correctly containing "ipath" (via [#41]) @@ -20,7 +22,6 @@ All notable changes to this project will be documented in this file. * Added * Function `is_valid_syntax` issues a warning if an unexpected error occurred (via [#51]) * New function `is_valid_syntax_scheme` (via [#48]) - * New function `is_valid_syntax_non_zero` (via [#48]) * New function `is_valid_syntax_pct_encoded` (via [#48]) * New dict `RFC3987_SYNTAX_TERM_VALIDATORS` mapping `RFC3987_SYNTAX_TERMS` to validator function (via [#49]) * Performance diff --git a/pyproject.toml b/pyproject.toml index 874230d..59ea1c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "rfc3987-syntax2" -version = "1.2.0" +version = "1.3.0" description = "Helper functions to syntactically validate strings according to RFC 3987." readme = "README.md" requires-python = ">=3.9" diff --git a/src/rfc3987_syntax2/__init__.py b/src/rfc3987_syntax2/__init__.py index cb9cb7e..99ee396 100644 --- a/src/rfc3987_syntax2/__init__.py +++ b/src/rfc3987_syntax2/__init__.py @@ -6,7 +6,7 @@ Re-export of :mod:`.syntax_helpers` """ -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from . import syntax_helpers as _syntax_helpers @@ -18,6 +18,7 @@ from .syntax_helpers import * # cannot simply re-export - need to respect lazy-loading + def __getattr__(name: str) -> Any: if name in _EXPORTS_SH: value = getattr(_syntax_helpers, name) diff --git a/src/rfc3987_syntax2/syntax_helpers.py b/src/rfc3987_syntax2/syntax_helpers.py index 3952bf3..6f64b60 100644 --- a/src/rfc3987_syntax2/syntax_helpers.py +++ b/src/rfc3987_syntax2/syntax_helpers.py @@ -4,14 +4,13 @@ from pathlib import Path from threading import Lock -from typing import Any, Callable, Optional, TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional from warnings import warn from lark import Lark, ParseTree, exceptions from .utils import load_grammar - __all__ = [ "RFC3987_SYNTAX_PARSER_TYPE", "RFC3987_SYNTAX_GRAMMAR_PATH", @@ -59,7 +58,6 @@ "is_valid_syntax_ipv4address", "is_valid_syntax_dec_octet", "is_valid_syntax_digit", - "is_valid_syntax_non_zero", "is_valid_syntax_unreserved", "is_valid_syntax_alpha", "is_valid_syntax_hexdig", @@ -127,15 +125,15 @@ """ _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 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. + :raises lark.exceptions.LarkError: If parser initialization fails or parsing cannot be completed. """ return _get_syntax_parser().parse(value, start=term) @@ -146,10 +144,11 @@ def is_valid_syntax(term: T_SYNTAX_PARSER_TERM, value: str) -> bool: 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. + .. 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 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``. """ @@ -163,11 +162,12 @@ def is_valid_syntax(term: T_SYNTAX_PARSER_TERM, value: str) -> bool: # from Lark internals / initialization (non-UnexpectedInput) warn("Unexpected LarkError (non-UnexpectedInput) " f"for term={term!r}: {type(err).__name__}: {err}", - RuntimeWarning, - stacklevel=2) + RuntimeWarning, + stacklevel=2) return False return True + T_SYNTAX_VALIDATOR = Callable[[str], bool] """Callable validator for one RFC 3987 grammar rule. @@ -212,6 +212,7 @@ def syntax_validator(text: str) -> bool: _grammar: Optional[str] = None _grammar_lock = Lock() + def _get_grammar() -> str: """this is private API""" @@ -225,6 +226,7 @@ def _get_grammar() -> str: _syntax_parser: Optional[Lark] = None _syntax_parser_lock = Lock() + def _get_syntax_parser() -> Lark: """this is private API""" @@ -239,6 +241,7 @@ def _get_syntax_parser() -> Lark: 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.""" @@ -486,19 +489,6 @@ def is_valid_syntax_digit(text: str) -> bool: else: is_valid_syntax_digit = make_syntax_validator("digit") -if TYPE_CHECKING: - def is_valid_syntax_non_zero(text: str) -> bool: - """ - Local helper token used in ``dec_octet``. - - .. warning:: - Deprecated: this is not a stable/public entry point and may be removed - in a future release. - """ - ... -else: - is_valid_syntax_non_zero = make_syntax_validator("non_zero") - if TYPE_CHECKING: def is_valid_syntax_unreserved(text: str) -> bool: """Validate that input text conforms to the RFC 3987 ``unreserved`` rule.""" @@ -571,7 +561,8 @@ def is_valid_syntax_pct_encoded(text: str) -> bool: "ipv4address": is_valid_syntax_ipv4address, "dec_octet": is_valid_syntax_dec_octet, "digit": is_valid_syntax_digit, - "non_zero": is_valid_syntax_non_zero, + # non_zero - a local helper token used in `dec_octet` + "non_zero": make_syntax_validator("non_zero"), "unreserved": is_valid_syntax_unreserved, "alpha": is_valid_syntax_alpha, "hexdig": is_valid_syntax_hexdig, @@ -605,6 +596,7 @@ def __getattr__(name: str) -> Any: 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__)) diff --git a/tests/__init__.py b/tests/__init__.py index 13df859..e54eb3f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,7 +5,8 @@ import json from pathlib import Path from typing import TypedDict -from typing_extensions import Required, NotRequired + +from typing_extensions import NotRequired, Required TESTS_ROOT_DIR = Path(__file__).parent TESTS_DATA_DIR = TESTS_ROOT_DIR / '_data' @@ -15,6 +16,7 @@ 'valid_syntax': TESTS_DATA_DIR / 'valid_syntax.json', } + class SyntaxCase(TypedDict): value: Required[str] reason: Required[str] @@ -23,12 +25,15 @@ class SyntaxCase(TypedDict): conforms_with_rfc3987_semantics: NotRequired[bool] semantics_notes: NotRequired[str] + T_syntax_file = dict[str, list[SyntaxCase]] + def valid_syntax_data() -> T_syntax_file: with open(TESTS_DATA_FILES['valid_syntax'], "r", encoding="utf-8") as f: return json.load(f) # type:ignore[no-any-return] + def invalid_syntax_data() -> T_syntax_file: with open(TESTS_DATA_FILES['invalid_syntax'], "r", encoding="utf-8") as f: - return json.load(f) # type:ignore[no-any-return] \ No newline at end of file + return json.load(f) # type:ignore[no-any-return] diff --git a/tests/test_exports_exists.py b/tests/test_exports_exists.py index 5ba00ac..54d094d 100644 --- a/tests/test_exports_exists.py +++ b/tests/test_exports_exists.py @@ -3,4 +3,3 @@ # SPDX-License-Identifier: MIT import rfc3987_syntax2 as sut - diff --git a/tests/test_is_valid_syntax_warning.py b/tests/test_is_valid_syntax_warning.py index b7c5054..d5e5fb4 100644 --- a/tests/test_is_valid_syntax_warning.py +++ b/tests/test_is_valid_syntax_warning.py @@ -1,9 +1,9 @@ # Copyright (c) 2026 Jan Kowalleck - modifications and maintenance # SPDX-License-Identifier: MIT -import pytest from unittest.mock import patch +import pytest from lark import exceptions import rfc3987_syntax2.syntax_helpers as sut diff --git a/tests/test_lazy_attrs.py b/tests/test_lazy_attrs.py index 29266a4..cccedc0 100644 --- a/tests/test_lazy_attrs.py +++ b/tests/test_lazy_attrs.py @@ -4,14 +4,16 @@ import subprocess import sys -import rfc3987_syntax2 as sut from lark import Lark +import rfc3987_syntax2 as sut + def test_grammar_not_eager() -> None: """Regression test: grammar is not populated in rfc3987_syntax2.__dict__ immediately after import.""" result = subprocess.run( - [sys.executable, '-c', 'import rfc3987_syntax2; assert "grammar" not in vars(rfc3987_syntax2), "grammar was eagerly loaded on import"'], + [sys.executable, '-c', + 'import rfc3987_syntax2; assert "grammar" not in vars(rfc3987_syntax2), "grammar was eagerly loaded on import"'], capture_output=True, text=True, ) @@ -21,7 +23,8 @@ def test_grammar_not_eager() -> None: def test_syntax_parser_not_eager() -> None: """Regression test: syntax_parser is not populated in rfc3987_syntax2.__dict__ immediately after import.""" result = subprocess.run( - [sys.executable, '-c', 'import rfc3987_syntax2; assert "syntax_parser" not in vars(rfc3987_syntax2), "syntax_parser was eagerly loaded on import"'], + [sys.executable, '-c', + 'import rfc3987_syntax2; assert "syntax_parser" not in vars(rfc3987_syntax2), "syntax_parser was eagerly loaded on import"'], capture_output=True, text=True, ) diff --git a/tests/test_syntax.py b/tests/test_syntax.py index 85298e7..a9d552a 100644 --- a/tests/test_syntax.py +++ b/tests/test_syntax.py @@ -8,12 +8,7 @@ import rfc3987_syntax2 as sut -from . import ( - valid_syntax_data, - invalid_syntax_data, - SyntaxCase, - T_syntax_file, -) +from . import SyntaxCase, T_syntax_file, invalid_syntax_data, valid_syntax_data def syntax_data_as_params(src_cb: Callable[[], T_syntax_file]) -> Any: diff --git a/tests/test_term_validators.py b/tests/test_term_validators.py index 3bc0c94..0e2c83d 100644 --- a/tests/test_term_validators.py +++ b/tests/test_term_validators.py @@ -8,6 +8,15 @@ @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]