aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-14 19:35:45 +0530
committernatsuoto <[email protected]>2026-07-14 19:35:45 +0530
commit8eda172b4d7ad3388bafa912358f6a2c71e82c1c (patch)
treeb4ce2ac620c297057aa74d59a27110359c59b19c
parent082325df2374340b4ccf6bdfd1b4842d2b9aa763 (diff)
downloadedify-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.py77
-rw-r--r--edify/pattern/composition.py31
-rw-r--r--edify/serialize/__init__.py46
-rw-r--r--edify/serialize/dump.py65
-rw-r--r--edify/serialize/kinds.py132
-rw-r--r--edify/serialize/load.py131
-rw-r--r--edify/serialize/version.py16
-rw-r--r--tests/serialize/errors.test.py51
-rw-r--r--tests/serialize/roundtrip.test.py243
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"