diff options
| author | natsuoto <[email protected]> | 2026-07-14 19:35:45 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-14 19:35:45 +0530 |
| commit | 8eda172b4d7ad3388bafa912358f6a2c71e82c1c (patch) | |
| tree | b4ce2ac620c297057aa74d59a27110359c59b19c | |
| parent | 082325df2374340b4ccf6bdfd1b4842d2b9aa763 (diff) | |
| download | edify-8eda172b4d7ad3388bafa912358f6a2c71e82c1c.tar.xz edify-8eda172b4d7ad3388bafa912358f6a2c71e82c1c.zip | |
feat(pattern): canonical dict/JSON serialization on Pattern (to_dict, from_dict, to_json, from_json, schema v0)
| -rw-r--r-- | edify/errors/serialize.py | 77 | ||||
| -rw-r--r-- | edify/pattern/composition.py | 31 | ||||
| -rw-r--r-- | edify/serialize/__init__.py | 46 | ||||
| -rw-r--r-- | edify/serialize/dump.py | 65 | ||||
| -rw-r--r-- | edify/serialize/kinds.py | 132 | ||||
| -rw-r--r-- | edify/serialize/load.py | 131 | ||||
| -rw-r--r-- | edify/serialize/version.py | 16 | ||||
| -rw-r--r-- | tests/serialize/errors.test.py | 51 | ||||
| -rw-r--r-- | tests/serialize/roundtrip.test.py | 243 |
9 files changed, 792 insertions, 0 deletions
diff --git a/edify/errors/serialize.py b/edify/errors/serialize.py new file mode 100644 index 0000000..b9f6f08 --- /dev/null +++ b/edify/errors/serialize.py @@ -0,0 +1,77 @@ +"""Exception classes raised by the canonical :class:`Pattern` (de)serializer.""" + +from __future__ import annotations + +from edify.errors.formatting import compose_annotated_message +from edify.errors.syntax import EdifySyntaxError + + +class MissingSchemaKeyError(EdifySyntaxError): + """Raised when a canonical dict is missing a required top-level key. + + Args: + missing_key: The name of the key that was not present. + """ + + def __init__(self, missing_key: str) -> None: + message = compose_annotated_message( + summary=f"canonical dict is missing the required {missing_key!r} key", + trigger_hint="Pattern.from_dict / Pattern.from_json called here", + note=( + f"every canonical serialization must carry a top-level {missing_key!r} " + "key; a document without it cannot be loaded." + ), + help_line=( + f"help: add {missing_key!r} to the document, or regenerate the payload " + "via ``pattern.to_dict()`` / ``pattern.to_json()``." + ), + ) + super().__init__(message) + + +class IncompatibleSchemaVersionError(EdifySyntaxError): + """Raised when a canonical dict declares a schema version this build does not understand. + + Args: + seen_version: The value found under the ``"edify"`` key. + supported_version: The version this build emits and accepts. + """ + + def __init__(self, seen_version: object, supported_version: int) -> None: + message = compose_annotated_message( + summary=( + f"canonical dict declares schema version {seen_version!r}, but this build " + f"only understands {supported_version}" + ), + trigger_hint="Pattern.from_dict / Pattern.from_json called here", + note=( + "schema version 0 is experimental; regenerate the payload from the " + "producing edify version, or upgrade this side to the version that emitted " + "the payload." + ), + help_line=("help: check the ``edify`` key in the input matches the emitting build."), + ) + super().__init__(message) + + +class UnknownElementKindError(EdifySyntaxError): + """Raised when a nested dict declares a ``kind`` string not in the registry. + + Args: + seen_kind: The ``"kind"`` value that could not be resolved. + """ + + def __init__(self, seen_kind: str) -> None: + message = compose_annotated_message( + summary=f"unknown element kind {seen_kind!r} in canonical dict", + trigger_hint="Pattern.from_dict / Pattern.from_json called here", + note=( + f"the kind {seen_kind!r} is not registered; the payload likely " + "originated from a newer edify build than the one loading it." + ), + help_line=( + "help: upgrade edify on the loading side, or regenerate the payload from " + "a build the loader recognises." + ), + ) + super().__init__(message) diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py index d8d052f..ce0ec97 100644 --- a/edify/pattern/composition.py +++ b/edify/pattern/composition.py @@ -68,3 +68,34 @@ class Pattern( return False compiled = self.to_regex() return compiled.match(value) is not None + + def to_dict(self) -> dict[str, object]: + """Return the canonical dict representation of this pattern. + + The dict carries a ``"edify"`` schema-version header, a ``"pattern"`` + AST tree keyed by public element ``kind`` strings, and an optional + ``"flags"`` map. See :mod:`edify.serialize`. + """ + from edify.serialize import pattern_to_dict + + return pattern_to_dict(self) + + def to_json(self) -> str: + """Return the canonical JSON string for this pattern.""" + from edify.serialize import pattern_to_json + + return pattern_to_json(self) + + @classmethod + def from_dict(cls, document: dict[str, object]) -> Pattern: + """Reconstruct a :class:`Pattern` from a canonical dict.""" + from edify.serialize import pattern_from_dict + + return pattern_from_dict(document) + + @classmethod + def from_json(cls, blob: str) -> Pattern: + """Reconstruct a :class:`Pattern` from a canonical JSON string.""" + from edify.serialize import pattern_from_json + + return pattern_from_json(blob) diff --git a/edify/serialize/__init__.py b/edify/serialize/__init__.py index e69de29..f7ca7f2 100644 --- a/edify/serialize/__init__.py +++ b/edify/serialize/__init__.py @@ -0,0 +1,46 @@ +"""Canonical dict/JSON serialization for :class:`edify.Pattern`. + +The wire format is versioned under the ``"edify"`` key and uses public, +stable ``kind`` strings for every AST element. See :data:`SCHEMA_VERSION` +for the current experimental version. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from edify.serialize.dump import state_to_dict +from edify.serialize.load import dict_to_pattern +from edify.serialize.version import SCHEMA_VERSION + +if TYPE_CHECKING: + from edify.pattern.composition import Pattern + +__all__ = [ + "SCHEMA_VERSION", + "pattern_from_dict", + "pattern_from_json", + "pattern_to_dict", + "pattern_to_json", +] + + +def pattern_to_dict(pattern: Pattern) -> dict[str, Any]: + """Return the canonical dict for ``pattern`` (with schema version header).""" + return state_to_dict(pattern._state) + + +def pattern_from_dict(document: dict[str, Any]) -> Pattern: + """Reconstruct a :class:`Pattern` from a canonical dict.""" + return dict_to_pattern(document) + + +def pattern_to_json(pattern: Pattern) -> str: + """Return the canonical JSON string for ``pattern`` (compact, sorted keys).""" + return json.dumps(pattern_to_dict(pattern), sort_keys=True, separators=(",", ":")) + + +def pattern_from_json(blob: str) -> Pattern: + """Reconstruct a :class:`Pattern` from a canonical JSON string.""" + return pattern_from_dict(json.loads(blob)) diff --git a/edify/serialize/dump.py b/edify/serialize/dump.py new file mode 100644 index 0000000..63d2802 --- /dev/null +++ b/edify/serialize/dump.py @@ -0,0 +1,65 @@ +"""Serialize a :class:`Pattern` into the canonical dict form. + +The canonical shape is:: + + { + "edify": <schema-version>, + "pattern": <root-element-dict>, + "flags": <flag-name-to-True map>, # only when any flag is set + } + +Every element dict carries a ``"kind"`` string (see :mod:`edify.serialize.kinds`) +and the fields that element declares — ``value`` for character literals, +``child`` for quantifiers, ``children`` for containers, ``times``/``lower``/ +``upper`` for numeric quantifiers, ``name``/``index`` for captures. +""" + +from __future__ import annotations + +from dataclasses import fields +from typing import TYPE_CHECKING, Any + +from edify.elements.types.base import BaseElement +from edify.serialize.kinds import kind_for +from edify.serialize.version import SCHEMA_VERSION + +if TYPE_CHECKING: + from edify.builder.types.flags import Flags + from edify.builder.types.state import BuilderState + + +def element_to_dict(element: BaseElement) -> dict[str, Any]: + """Return the canonical dict representation of ``element``.""" + result: dict[str, Any] = {"kind": kind_for(type(element))} + for spec in fields(element): + raw_value = getattr(element, spec.name) + result[spec.name] = _serialize_field_value(raw_value) + return result + + +def _serialize_field_value(value: Any) -> Any: + if isinstance(value, BaseElement): + return element_to_dict(value) + if isinstance(value, tuple): + return [element_to_dict(child) for child in value] + return value + + +def _flags_to_dict(flags: Flags) -> dict[str, bool]: + return {spec.name: True for spec in fields(flags) if getattr(flags, spec.name)} + + +def state_to_dict(state: BuilderState) -> dict[str, Any]: + """Return the canonical dict for a builder state (root element + flags).""" + root_children = tuple(state.top_frame.children) + from edify.elements.types.root import RootElement + + root_element = RootElement(children=root_children) + document: dict[str, Any] = { + "edify": SCHEMA_VERSION, + "pattern": element_to_dict(root_element), + } + flag_map = _flags_to_dict(state.flags) + if flag_map: + document["flags"] = flag_map + return document diff --git a/edify/serialize/kinds.py b/edify/serialize/kinds.py new file mode 100644 index 0000000..6b4fc0c --- /dev/null +++ b/edify/serialize/kinds.py @@ -0,0 +1,132 @@ +"""Registry mapping AST element classes to their public serialization ``kind`` strings. + +The ``kind`` value is the sole public identifier of an element in the +canonical serialization. Internal class names (``ExactlyElement``) are never +part of the wire format — only the corresponding ``kind`` (``"exactly"``). +""" + +from __future__ import annotations + +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.elements.types.root import RootElement + +_CLASS_BY_KIND = { + "root": RootElement, + "start": StartOfInputElement, + "end": EndOfInputElement, + "any": AnyCharElement, + "whitespace": WhitespaceCharElement, + "non-whitespace": NonWhitespaceCharElement, + "digit": DigitElement, + "non-digit": NonDigitElement, + "word": WordElement, + "non-word": NonWordElement, + "word-boundary": WordBoundaryElement, + "non-word-boundary": NonWordBoundaryElement, + "newline": NewLineElement, + "carriage-return": CarriageReturnElement, + "tab": TabElement, + "null-byte": NullByteElement, + "letter": LetterElement, + "upper": UppercaseElement, + "lower": LowercaseElement, + "alnum": AlphanumericElement, + "noop": NoopElement, + "char": CharElement, + "string": StringElement, + "range": RangeElement, + "chars": AnyOfCharsElement, + "non-chars": AnythingButCharsElement, + "non-range": AnythingButRangeElement, + "non-string": AnythingButStringElement, + "capture": CaptureElement, + "named-capture": NamedCaptureElement, + "back-reference": BackReferenceElement, + "named-back-reference": NamedBackReferenceElement, + "group": GroupElement, + "any-of": AnyOfElement, + "subexpression": SubexpressionElement, + "assert-ahead": AssertAheadElement, + "assert-not-ahead": AssertNotAheadElement, + "assert-behind": AssertBehindElement, + "assert-not-behind": AssertNotBehindElement, + "optional": OptionalElement, + "zero-or-more": ZeroOrMoreElement, + "zero-or-more-lazy": ZeroOrMoreLazyElement, + "one-or-more": OneOrMoreElement, + "one-or-more-lazy": OneOrMoreLazyElement, + "exactly": ExactlyElement, + "at-least": AtLeastElement, + "at-most": AtMostElement, + "between": BetweenElement, + "between-lazy": BetweenLazyElement, +} + +_KIND_BY_CLASS = {cls: kind for kind, cls in _CLASS_BY_KIND.items()} + + +def kind_for(element_class: type) -> str: + """Return the public ``kind`` string registered for ``element_class``.""" + return _KIND_BY_CLASS[element_class] + + +def class_for(kind: str) -> type: + """Return the element class registered under ``kind``.""" + return _CLASS_BY_KIND[kind] diff --git a/edify/serialize/load.py b/edify/serialize/load.py new file mode 100644 index 0000000..beb9af5 --- /dev/null +++ b/edify/serialize/load.py @@ -0,0 +1,131 @@ +"""Deserialize a canonical dict back into a :class:`Pattern`. + +The load path validates the schema version, walks the ``pattern`` tree +rebuilding the element AST, and derives the auxiliary state fields +(``has_defined_start``, ``named_groups``, ``total_capture_groups``) from +the tree so the resulting :class:`Pattern` behaves exactly like one built +through the fluent chain. +""" + +from __future__ import annotations + +from dataclasses import fields +from typing import TYPE_CHECKING, Any + +from edify.builder.types.flags import Flags +from edify.builder.types.frame import StackFrame +from edify.builder.types.state import BuilderState +from edify.elements.types.captures import CaptureElement, NamedCaptureElement +from edify.elements.types.leaves import EndOfInputElement, StartOfInputElement +from edify.elements.types.root import RootElement +from edify.errors.serialize import ( + IncompatibleSchemaVersionError, + MissingSchemaKeyError, + UnknownElementKindError, +) +from edify.serialize.kinds import class_for +from edify.serialize.version import SCHEMA_VERSION + +if TYPE_CHECKING: + from edify.elements.types.base import BaseElement + from edify.pattern.composition import Pattern + + +def dict_to_element(tree: dict[str, Any]) -> BaseElement: + """Return the AST element described by ``tree``.""" + kind = tree.get("kind") + if kind is None: + raise MissingSchemaKeyError("kind") + try: + element_class = class_for(kind) + except KeyError as reason: + raise UnknownElementKindError(kind) from reason + field_specs = {spec.name: spec for spec in fields(element_class)} + constructor_kwargs: dict[str, Any] = {} + for name, raw_value in tree.items(): + if name == "kind": + continue + if name not in field_specs: + continue + constructor_kwargs[name] = _deserialize_field_value(raw_value) + return element_class(**constructor_kwargs) + + +def _deserialize_field_value(value: Any) -> Any: + if isinstance(value, list): + return tuple(dict_to_element(item) for item in value) + if isinstance(value, dict) and "kind" in value: + return dict_to_element(value) + return value + + +def dict_to_pattern(document: dict[str, Any]) -> Pattern: + """Return the :class:`Pattern` described by ``document`` (canonical shape).""" + from edify.pattern.composition import Pattern + + _require_schema_version(document) + pattern_tree = document.get("pattern") + if pattern_tree is None: + raise MissingSchemaKeyError("pattern") + root_element = dict_to_element(pattern_tree) + root_children = tuple(root_element.children) if isinstance(root_element, RootElement) else () + flag_map = document.get("flags") or {} + reconstructed_flags = _flags_from_dict(flag_map) + named_groups = _collect_named_groups(root_children) + total_captures = _count_capture_groups(root_children) + has_start = any(isinstance(child, StartOfInputElement) for child in root_children) + has_end = any(isinstance(child, EndOfInputElement) for child in root_children) + root_frame = StackFrame(type_node=RootElement(), children=root_children) + state = BuilderState( + has_defined_start=has_start, + has_defined_end=has_end, + flags=reconstructed_flags, + stack=(root_frame,), + named_groups=named_groups, + total_capture_groups=total_captures, + ) + empty_pattern = Pattern() + return empty_pattern._with_state(state) + + +def _require_schema_version(document: dict[str, Any]) -> None: + if "edify" not in document: + raise MissingSchemaKeyError("edify") + seen_version = document["edify"] + if seen_version != SCHEMA_VERSION: + raise IncompatibleSchemaVersionError(seen_version, SCHEMA_VERSION) + + +def _flags_from_dict(flag_map: dict[str, Any]) -> Flags: + known_flag_names = {spec.name for spec in fields(Flags)} + kwargs = {name: bool(value) for name, value in flag_map.items() if name in known_flag_names} + return Flags(**kwargs) + + +def _collect_named_groups(children: tuple[BaseElement, ...]) -> tuple[str, ...]: + return tuple( + element.name + for element in _walk_elements(children) + if isinstance(element, NamedCaptureElement) + ) + + +def _count_capture_groups(children: tuple[BaseElement, ...]) -> int: + count = 0 + for element in _walk_elements(children): + if isinstance(element, CaptureElement | NamedCaptureElement): + count += 1 + return count + + +def _walk_elements(children: tuple[BaseElement, ...]): + from edify.elements.types.base import BaseElement as _BaseElement + + for element in children: + yield element + for spec in fields(element): + value = getattr(element, spec.name) + if isinstance(value, tuple): + yield from _walk_elements(value) + elif isinstance(value, _BaseElement): + yield from _walk_elements((value,)) diff --git a/edify/serialize/version.py b/edify/serialize/version.py new file mode 100644 index 0000000..b5fc8d4 --- /dev/null +++ b/edify/serialize/version.py @@ -0,0 +1,16 @@ +"""Schema-version marker for the canonical :class:`Pattern` serialization. + +The version is embedded under the ``"edify"`` key in every ``to_dict`` / +``to_json`` output and validated on load. The 1.0 release ships version ``0`` +as experimental — the concrete AST shape may change without a deprecation +cycle until it is promoted to ``1`` in a later release. +""" + +from __future__ import annotations + +SCHEMA_VERSION = 0 +"""The canonical-dict schema version emitted and accepted by this build. + +Version ``0`` is experimental. Consumers should not rely on the concrete +AST shape across releases until the version is promoted. +""" diff --git a/tests/serialize/errors.test.py b/tests/serialize/errors.test.py new file mode 100644 index 0000000..ff6371f --- /dev/null +++ b/tests/serialize/errors.test.py @@ -0,0 +1,51 @@ +"""Errors raised by the canonical (de)serializer through the public Pattern API.""" + +import json + +import pytest + +from edify import Pattern +from edify.errors.serialize import ( + IncompatibleSchemaVersionError, + MissingSchemaKeyError, + UnknownElementKindError, +) + + +def test_missing_edify_key_raises(): + with pytest.raises(MissingSchemaKeyError): + Pattern.from_dict({"pattern": {"kind": "root", "children": []}}) + + +def test_missing_pattern_key_raises(): + with pytest.raises(MissingSchemaKeyError): + Pattern.from_dict({"edify": 0}) + + +def test_missing_kind_on_nested_node_raises(): + document = { + "edify": 0, + "pattern": {"kind": "root", "children": [{"value": "x"}]}, + } + with pytest.raises(MissingSchemaKeyError): + Pattern.from_dict(document) + + +def test_incompatible_schema_version_raises(): + document = {"edify": 999, "pattern": {"kind": "root", "children": []}} + with pytest.raises(IncompatibleSchemaVersionError): + Pattern.from_dict(document) + + +def test_unknown_kind_raises(): + document = { + "edify": 0, + "pattern": {"kind": "root", "children": [{"kind": "mystery"}]}, + } + with pytest.raises(UnknownElementKindError): + Pattern.from_dict(document) + + +def test_bad_json_string_raises_decode_error(): + with pytest.raises(json.JSONDecodeError): + Pattern.from_json("{not-valid-json}") diff --git a/tests/serialize/roundtrip.test.py b/tests/serialize/roundtrip.test.py new file mode 100644 index 0000000..a3d7ea0 --- /dev/null +++ b/tests/serialize/roundtrip.test.py @@ -0,0 +1,243 @@ +"""Round-trip tests for Pattern.to_dict/from_dict and to_json/from_json.""" + +from edify import END, START, Pattern + + +def _roundtrip_dict(pattern: Pattern) -> Pattern: + return Pattern.from_dict(pattern.to_dict()) + + +def _roundtrip_json(pattern: Pattern) -> Pattern: + return Pattern.from_json(pattern.to_json()) + + +def test_empty_pattern_roundtrips(): + original = Pattern() + assert _roundtrip_dict(original) == original + assert _roundtrip_json(original) == original + + +def test_simple_digit_pattern_roundtrips(): + original = Pattern().digit() + restored = _roundtrip_dict(original) + assert restored == original + assert restored.to_regex_string() == original.to_regex_string() + + +def test_anchored_pattern_roundtrips(): + original = Pattern().start_of_input().one_or_more().digit().end_of_input() + restored = _roundtrip_json(original) + assert restored == original + assert restored._state.has_defined_start + assert restored._state.has_defined_end + + +def test_char_class_pattern_roundtrips(): + original = Pattern().any_of().range("a", "z").range("0", "9").char("_").end() + assert _roundtrip_dict(original) == original + + +def test_negated_class_roundtrips(): + original = Pattern().anything_but_chars("abc") + assert _roundtrip_dict(original) == original + + +def test_negated_range_roundtrips(): + original = Pattern().anything_but_range("a", "z") + assert _roundtrip_dict(original) == original + + +def test_anything_but_string_roundtrips(): + original = Pattern().anything_but_string("bad") + assert _roundtrip_dict(original) == original + + +def test_capture_group_roundtrips(): + original = Pattern().capture().digit().end() + assert _roundtrip_dict(original) == original + assert _roundtrip_dict(original)._state.total_capture_groups == 1 + + +def test_named_capture_roundtrips_with_names_and_count(): + original = Pattern().named_capture("year").exactly(4).digit().end() + restored = _roundtrip_dict(original) + assert restored == original + assert restored._state.named_groups == ("year",) + assert restored._state.total_capture_groups == 1 + + +def test_backreference_roundtrips(): + original = Pattern().capture().digit().end().back_reference(1) + assert _roundtrip_dict(original) == original + + +def test_named_backreference_roundtrips(): + original = Pattern().named_capture("d").digit().end().named_back_reference("d") + assert _roundtrip_dict(original) == original + + +def test_group_roundtrips(): + original = Pattern().group().letter().digit().end() + assert _roundtrip_dict(original) == original + + +def test_any_of_alternation_roundtrips(): + original = Pattern().any_of().string("cat").string("dog").end() + assert _roundtrip_dict(original) == original + + +def test_positive_lookahead_roundtrips(): + original = Pattern().assert_ahead().digit().end() + assert _roundtrip_dict(original) == original + + +def test_negative_lookahead_roundtrips(): + original = Pattern().assert_not_ahead().digit().end() + assert _roundtrip_dict(original) == original + + +def test_positive_lookbehind_roundtrips(): + original = Pattern().assert_behind().digit().end() + assert _roundtrip_dict(original) == original + + +def test_negative_lookbehind_roundtrips(): + original = Pattern().assert_not_behind().digit().end() + assert _roundtrip_dict(original) == original + + +def test_optional_quantifier_roundtrips(): + original = Pattern().optional().digit() + assert _roundtrip_dict(original) == original + + +def test_zero_or_more_roundtrips(): + original = Pattern().zero_or_more().digit() + assert _roundtrip_dict(original) == original + + +def test_zero_or_more_lazy_roundtrips(): + original = Pattern().zero_or_more_lazy().digit() + assert _roundtrip_dict(original) == original + + +def test_one_or_more_roundtrips(): + original = Pattern().one_or_more().digit() + assert _roundtrip_dict(original) == original + + +def test_one_or_more_lazy_roundtrips(): + original = Pattern().one_or_more_lazy().digit() + assert _roundtrip_dict(original) == original + + +def test_exactly_roundtrips_and_preserves_count(): + original = Pattern().exactly(5).digit() + restored = _roundtrip_dict(original) + assert restored == original + + +def test_at_least_roundtrips(): + original = Pattern().at_least(3).digit() + assert _roundtrip_dict(original) == original + + +def test_at_most_roundtrips(): + original = Pattern().at_most(5).digit() + assert _roundtrip_dict(original) == original + + +def test_between_roundtrips_and_preserves_bounds(): + original = Pattern().between(2, 5).digit() + assert _roundtrip_dict(original) == original + + +def test_between_lazy_roundtrips(): + original = Pattern().between_lazy(2, 5).digit() + assert _roundtrip_dict(original) == original + + +def test_all_leaves_roundtrip(): + for construction in ( + Pattern().any_char(), + Pattern().whitespace_char(), + Pattern().non_whitespace_char(), + Pattern().digit(), + Pattern().non_digit(), + Pattern().word(), + Pattern().non_word(), + Pattern().word_boundary(), + Pattern().non_word_boundary(), + Pattern().new_line(), + Pattern().carriage_return(), + Pattern().tab(), + Pattern().null_byte(), + Pattern().letter(), + Pattern().uppercase(), + Pattern().lowercase(), + Pattern().alphanumeric(), + ): + assert _roundtrip_dict(construction) == construction + + +def test_module_constants_roundtrip(): + assert _roundtrip_dict(START) == START + assert _roundtrip_dict(END) == END + + +def test_flags_survive_roundtrip(): + original = Pattern().ignore_case().multi_line().digit() + restored = _roundtrip_dict(original) + assert restored == original + assert restored._state.flags.ignore_case + assert restored._state.flags.multiline + + +def test_string_element_roundtrips(): + original = Pattern().string("hello") + assert _roundtrip_dict(original) == original + + +def test_char_element_roundtrips(): + original = Pattern().char("!") + assert _roundtrip_dict(original) == original + + +def test_range_element_roundtrips(): + original = Pattern().range("a", "z") + assert _roundtrip_dict(original) == original + + +def test_any_of_chars_roundtrips(): + original = Pattern().any_of_chars("abc") + assert _roundtrip_dict(original) == original + + +def test_subexpression_survives_roundtrip(): + inner = Pattern().digit() + original = Pattern().string("v=").subexpression(inner) + assert _roundtrip_dict(original) == original + + +def test_pattern_output_dict_carries_schema_version_zero(): + document = Pattern().digit().to_dict() + assert document["edify"] == 0 + + +def test_flags_key_omitted_when_no_flags_set(): + document = Pattern().digit().to_dict() + assert "flags" not in document + + +def test_unknown_element_field_is_ignored_for_forward_compatibility(): + document = { + "edify": 0, + "pattern": { + "kind": "root", + "children": [ + {"kind": "digit", "future_field_from_a_newer_edify": "ignored"}, + ], + }, + } + restored = Pattern.from_dict(document) + assert restored.to_regex_string() == r"\d" |
