diff options
| author | Bobby <[email protected]> | 2026-07-15 13:36:28 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-15 13:36:28 +0530 |
| commit | 496734c57c0a065119263c6fdca4598bb8e423f5 (patch) | |
| tree | 4a838ec70f2756fe5b4fa815493650c8d08b9c7d | |
| parent | 64655e3d779a9a02ce730cad14763e613a2623e2 (diff) | |
| parent | 742ead17bb2f31c75b8839fb8d4a9b31e7f7ee74 (diff) | |
| download | edify-496734c57c0a065119263c6fdca4598bb8e423f5.tar.xz edify-496734c57c0a065119263c6fdca4598bb8e423f5.zip | |
feat(typing): PEP 561 marker, strict-mode gates, and full public-surface annotations (#279)
Ships the static-typing surface end to end.
- Installs the `edify/py.typed` marker so downstream code sees `edify`
as a typed distribution, and force-includes it in the wheel.
- Adds a `[tool.mypy]` block that runs `mypy --strict` against `edify.*`
(with `ignore_missing_imports` scoped to `regex` and `graphviz`), and a
`[tool.pyright]` block with `typeCheckingMode = "strict"` bound to
`include = ["edify"]`.
- Pins `mypy==1.13.0` and `pyright==1.1.389` in the dev group and
refreshes `uv.lock`.
- Annotates the full public surface: `Self` on every fluent chain
method, `TypeVar`-generic private helpers across the mixins, precise
element unions on `render_element` / `merge` / `fuse`, `FrameType` on
the caller-context walker, a `JSONValue` recursive type over the
serialize layer (no `Any`, no `object`), and precise dict types on the
`class_for` / `kind_for` registry.
- Introduces a typed `engine: Literal["re", "regex"]` kwarg on
`to_regex`, with `EngineNotWiredError` (annotated summary + pointer
block + `= note:` + `help:`) surfacing when a caller asks for an engine
that is not wired in this build.
- Introduces `edify/builder/types/protocol.py::BuilderProtocol` — a
`runtime_checkable` `Protocol` that declares the shared attribute +
method surface every mixin assumes about `self`, so mixins in isolation
see the full chain surface.
- Introduces `edify/serialize/types.py::JSONValue` (recursive: `str |
int | float | bool | None | list[JSONValue] | dict[str, JSONValue]`) and
threads it through `element_to_dict`, `state_to_dict`, and the load path
(now returning `BuilderState` to break a circular import with
`Pattern`).
- Adds a discipline test that grep-fails on any bare `# type: ignore` or
`# pyright: ignore` in `edify/`, so every suppression must carry a code
and a reason (there are none in this branch).
- Wires `mypy` and `pyright` as required CI matrix entries in
`.github/workflows/github-actions.yml`, both running `--strict` against
`edify/`.
Closes #71, closes #84, closes #85, closes #86, closes #87, closes #88,
closes #89, closes #90, closes #91, closes #92, closes #93, closes #94,
closes #95.
33 files changed, 482 insertions, 241 deletions
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 2f4c9e5..262826b 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -13,6 +13,14 @@ jobs: python: '3.11' command: 'uv run ruff check . && uv run ruff format --check .' os: 'ubuntu-latest' + - name: 'mypy' + python: '3.11' + command: 'uv run mypy edify' + os: 'ubuntu-latest' + - name: 'pyright' + python: '3.11' + command: 'uv run pyright edify' + os: 'ubuntu-latest' - name: 'docs' python: '3.11' command: 'uv run --group docs sphinx-build -b html docs dist/docs && uv run --group docs sphinx-build -b linkcheck docs dist/docs' diff --git a/edify/atoms/__init__.py b/edify/atoms/__init__.py index 066a56e..08bdd8d 100644 --- a/edify/atoms/__init__.py +++ b/edify/atoms/__init__.py @@ -1,10 +1,3 @@ -"""Composable :class:`Pattern` atoms. - -Each atom is an unanchored fragment meant to be spliced into a builder via -:meth:`edify.RegexBuilder.use` / :meth:`edify.Pattern.use`. Import any atom -directly from ``edify.atoms``. -""" - from edify.atoms.alnum import alnum from edify.atoms.ascii import ascii from edify.atoms.base32 import base32 diff --git a/edify/builder/core.py b/edify/builder/core.py index 6b3ac46..fca2a76 100644 --- a/edify/builder/core.py +++ b/edify/builder/core.py @@ -5,11 +5,13 @@ from __future__ import annotations from typing import Self from edify.builder.diagnose import diagnose_unfinished +from edify.builder.types.protocol import BuilderProtocol from edify.builder.types.state import BuilderState from edify.errors.comparison import ( CannotCompareUnfinishedBuilderError, CannotHashUnfinishedBuilderError, ) +from edify.errors.formatting import Problem from edify.errors.quantifier import DanglingQuantifierError from edify.errors.structure import CannotCallSubexpressionError from edify.result.regex import Regex @@ -17,7 +19,7 @@ from edify.result.regex import Regex _UNCLOSED_FRAME_MARKER = "<unclosed>" -class BuilderCore: +class BuilderCore(BuilderProtocol): """Holds the immutable :class:`BuilderState` and clones it on chain steps.""" _state: BuilderState @@ -54,14 +56,14 @@ class BuilderCore: def __repr__(self) -> str: """Return ``<ClassName 'pattern-so-far'>`` for interactive display.""" - rendered = _render_or_marker(self) + rendered = _rendered_or_unclosed_marker(self) return f"<{type(self).__name__} {rendered!r}>" def __eq__(self, other: object) -> bool: """Return True when ``other`` is a builder with the same emitted pattern and flags.""" if not isinstance(other, BuilderCore): return NotImplemented - problems: list = [] + problems: list[Problem] = [] left_problem = diagnose_unfinished(self._state, "left operand") right_problem = diagnose_unfinished(other._state, "right operand") if left_problem is not None: @@ -83,12 +85,9 @@ class BuilderCore: return hash((source, self._state.flags)) -def _render_or_marker(builder: BuilderCore) -> str: +def _rendered_or_unclosed_marker(builder: BuilderCore) -> str: """Return the emitted regex string, or a placeholder when frames are unclosed.""" - to_regex_string = getattr(builder, "to_regex_string", None) - if to_regex_string is None: - return _UNCLOSED_FRAME_MARKER try: - return to_regex_string() + return builder.to_regex_string() except (CannotCallSubexpressionError, DanglingQuantifierError): return _UNCLOSED_FRAME_MARKER diff --git a/edify/builder/diagnose.py b/edify/builder/diagnose.py index a08a7ed..4802145 100644 --- a/edify/builder/diagnose.py +++ b/edify/builder/diagnose.py @@ -13,10 +13,18 @@ from edify.elements.types.groups import ( AssertNotBehindElement, GroupElement, ) +from edify.errors.context import CallerContext from edify.errors.formatting import FixInsertion, Problem _END_INSERTION_TEXT = ".end()" _DANGLING_INSERTION_TEXT = ".digit()" +_UNKNOWN_CALL_SITE = CallerContext( + filename="<unknown>", + lineno=0, + colno=1, + end_colno=1, + source_line="", +) def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None: @@ -32,8 +40,8 @@ def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None: def _diagnose_open_frame(frame: StackFrame, subject: str) -> Problem: """Return a :class:`Problem` describing an unclosed frame.""" frame_name = _frame_display_name(frame) - problem_context = frame.call_site - fix_context = frame.last_child_call_site or frame.call_site + problem_context = frame.call_site or _UNKNOWN_CALL_SITE + fix_context = frame.last_child_call_site or frame.call_site or _UNKNOWN_CALL_SITE fix_insertion = FixInsertion( column=fix_context.end_colno, text=_END_INSERTION_TEXT, @@ -51,8 +59,8 @@ def _diagnose_open_frame(frame: StackFrame, subject: str) -> Problem: def _diagnose_dangling_quantifier(frame: StackFrame, subject: str) -> Problem: """Return a :class:`Problem` describing a pending quantifier with no operand.""" quantifier_name = frame.quantifier_name or "quantifier" - problem_context = frame.quantifier_call_site - fix_context = frame.quantifier_call_site + problem_context = frame.quantifier_call_site or _UNKNOWN_CALL_SITE + fix_context = frame.quantifier_call_site or _UNKNOWN_CALL_SITE fix_insertion = FixInsertion( column=fix_context.end_colno, text=_DANGLING_INSERTION_TEXT, diff --git a/edify/builder/merge.py b/edify/builder/merge.py index 6c70bf4..65b7833 100644 --- a/edify/builder/merge.py +++ b/edify/builder/merge.py @@ -19,6 +19,7 @@ groups it introduced (zero for non-capture elements). from __future__ import annotations from dataclasses import dataclass +from typing import Protocol from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( @@ -185,14 +186,53 @@ def _merge_end_of_input(element: EndOfInputElement, context: MergeContext) -> Me return MergeResult(element=element, captures_added=0) -def _merge_container(element: BaseElement, context: MergeContext, container_class) -> MergeResult: +_ContainerElement = ( + GroupElement + | AnyOfElement + | SubexpressionElement + | AssertAheadElement + | AssertNotAheadElement + | AssertBehindElement + | AssertNotBehindElement +) + +_QuantifierElement = ( + OptionalElement + | ZeroOrMoreElement + | ZeroOrMoreLazyElement + | OneOrMoreElement + | OneOrMoreLazyElement +) + + +class _ContainerFactory(Protocol): + """Structural type for a container-class constructor.""" + + def __call__(self, children: tuple[BaseElement, ...]) -> BaseElement: ... + + +class _QuantifierFactory(Protocol): + """Structural type for a no-parameter quantifier constructor.""" + + def __call__(self, child: BaseElement) -> BaseElement: ... + + +def _merge_container( + element: _ContainerElement, + context: MergeContext, + container_class: _ContainerFactory, +) -> MergeResult: """Recurse into a container element's children and re-wrap in the same class.""" merged_children, child_captures_added = _merge_children(element.children, context) new_element = container_class(children=merged_children) return MergeResult(element=new_element, captures_added=child_captures_added) -def _merge_quantifier(element: BaseElement, context: MergeContext, quantifier_class) -> MergeResult: +def _merge_quantifier( + element: _QuantifierElement, + context: MergeContext, + quantifier_class: _QuantifierFactory, +) -> MergeResult: """Recurse into a no-parameter quantifier's child and re-wrap in the same class.""" child_result = merge_element(element.child, context) new_element = quantifier_class(child=child_result.element) diff --git a/edify/builder/mixins/groups.py b/edify/builder/mixins/groups.py index b0bf698..7151f1f 100644 --- a/edify/builder/mixins/groups.py +++ b/edify/builder/mixins/groups.py @@ -12,7 +12,7 @@ from __future__ import annotations -from typing import Self +from typing import Self, TypeVar from edify.builder.types.frame import StackFrame from edify.builder.types.protocol import BuilderProtocol @@ -26,6 +26,8 @@ from edify.errors.input import ( MustBeOneCharacterError, ) +_TBuilder = TypeVar("_TBuilder", bound=BuilderProtocol) + class GroupsMixin(BuilderProtocol): """Provides the ``any_of``/``one_of``/``group`` chain methods.""" @@ -56,14 +58,14 @@ class GroupsMixin(BuilderProtocol): return _open_frame(self, GroupElement()) -def _open_frame(builder: BuilderProtocol, type_node: BaseElement): +def _open_frame(builder: _TBuilder, type_node: BaseElement) -> _TBuilder: """Push a new frame anchored at ``type_node`` and return the updated builder.""" new_frame = StackFrame(type_node=type_node) new_state = builder._state.with_frame_pushed(new_frame) return builder._with_state(new_state) -def _add_literal_alternation(builder: BuilderProtocol, literals: tuple[str, ...]): +def _add_literal_alternation(builder: _TBuilder, literals: tuple[str, ...]) -> _TBuilder: """Append a single :class:`AnyOfElement` built from ``literals`` to the top frame.""" children = tuple(_literal_to_element(literal) for literal in literals) element = AnyOfElement(children=children) diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py index 9a1364a..dada35e 100644 --- a/edify/builder/mixins/quantifiers.py +++ b/edify/builder/mixins/quantifiers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Self +from typing import Self, TypeVar from edify.builder.types.frame import PendingQuantifier from edify.builder.types.protocol import BuilderProtocol @@ -19,7 +19,7 @@ from edify.elements.types.quantifiers import ( ZeroOrMoreElement, ZeroOrMoreLazyElement, ) -from edify.errors.context import capture_caller_context +from edify.errors.context import CallerContext, capture_caller_context from edify.errors.input import ( MustBeIntegerGreaterThanZeroError, MustBeLessThanError, @@ -27,6 +27,8 @@ from edify.errors.input import ( ) from edify.errors.quantifier import StackedQuantifierError +_TBuilder = TypeVar("_TBuilder", bound=BuilderProtocol) + class QuantifiersMixin(BuilderProtocol): """Provides the nine quantifier chain methods that set the pending quantifier.""" @@ -99,11 +101,11 @@ class QuantifiersMixin(BuilderProtocol): def _set_pending( - builder: BuilderProtocol, + builder: _TBuilder, pending_quantifier: PendingQuantifier, - call_site, + call_site: CallerContext | None, quantifier_name: str, -): +) -> _TBuilder: """Replace the top frame with one carrying the given pending quantifier.""" if builder._state.top_frame.quantifier is not None: raise StackedQuantifierError() diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py index 1b83c10..25d8191 100644 --- a/edify/builder/mixins/terminals.py +++ b/edify/builder/mixins/terminals.py @@ -11,10 +11,12 @@ from __future__ import annotations import re +from edify.builder.types.engine import Engine from edify.builder.types.flags import Flags from edify.builder.types.protocol import BuilderProtocol from edify.compile.dispatch import render_element from edify.elements.types.root import RootElement +from edify.errors.engine import EngineNotWiredError from edify.errors.quantifier import DanglingQuantifierError from edify.errors.structure import CannotCallSubexpressionError from edify.result import Regex @@ -51,6 +53,7 @@ class TerminalsMixin(BuilderProtocol): multiline: bool = False, dotall: bool = False, verbose: bool = False, + engine: Engine = "re", ) -> Regex: """Return the pattern + flags compiled and wrapped in :class:`edify.result.Regex`. @@ -62,8 +65,14 @@ class TerminalsMixin(BuilderProtocol): already carries — passing ``ignore_case=True`` here is equivalent to having called ``.ignore_case()`` in the chain. Flags never turn off, only on. + + The ``engine`` kwarg selects the compilation backend. Only ``"re"`` is + wired today; ``"regex"`` is reserved for the opt-in third-party engine + and raises :class:`NotImplementedError` until that dispatch lands. """ pattern_string = self.to_regex_string() + if engine != "re": + raise EngineNotWiredError(engine) kwarg_flags = Flags( ascii_only=ascii_only, debug=debug, diff --git a/edify/builder/types/engine.py b/edify/builder/types/engine.py new file mode 100644 index 0000000..9af27e6 --- /dev/null +++ b/edify/builder/types/engine.py @@ -0,0 +1,8 @@ +"""Type alias for the ``engine`` kwarg accepted by every builder terminal.""" + +from __future__ import annotations + +from typing import Literal + +Engine = Literal["re", "regex"] +"""The compilation-backend identifier accepted by :meth:`edify.RegexBuilder.to_regex`.""" diff --git a/edify/builder/types/protocol.py b/edify/builder/types/protocol.py index cffab06..c73b9dd 100644 --- a/edify/builder/types/protocol.py +++ b/edify/builder/types/protocol.py @@ -4,6 +4,12 @@ Each mixin defines methods that read ``self._state`` and return a new builder via ``self._with_state(new_state)``. Typing ``self`` against this protocol gives both type checkers a complete picture of the cross-mixin attribute surface even when individual mixin files are inspected in isolation. + +The Protocol declares every public chain method that any mixin might call on +``self`` (``.any_of``, ``.end``, ``.subexpression``, etc.). Concrete classes +(``RegexBuilder``, ``Pattern``) get real implementations by composing every +mixin; the Protocol declarations here exist only so a mixin in isolation can +see the surface without pulling in every other mixin at import time. """ from __future__ import annotations @@ -19,19 +25,26 @@ class BuilderProtocol(Protocol): """The shared shape that every builder mixin assumes about ``self``.""" _state: BuilderState + _cached_regex: Regex | None + + def __init__(self) -> None: ... + + def _with_state(self, new_state: BuilderState, /) -> Self: ... + + def _lazy_regex(self) -> Regex: ... + + def to_regex_string(self) -> str: ... - def _with_state(self, new_state: BuilderState) -> Self: - """Return a new builder carrying the given state, leaving ``self`` untouched.""" - ... + def to_regex(self) -> Regex: ... - def _lazy_regex(self) -> Regex: - """Return the memoised :class:`Regex` produced from ``self``, compiling once.""" - ... + def any_of(self, *literals: str) -> Self: ... - def to_regex_string(self) -> str: - """Return the emitted regex string for ``self``.""" - ... + def end(self) -> Self: ... - def to_regex(self) -> Regex: - """Compile ``self`` into a :class:`Regex` wrapper.""" - ... + def subexpression( + self, + expression: BuilderProtocol, + namespace: str = ..., + ignore_flags: bool = ..., + ignore_start_and_end: bool = ..., + ) -> Self: ... diff --git a/edify/compile/dispatch.py b/edify/compile/dispatch.py index 5c9a15c..df90167 100644 --- a/edify/compile/dispatch.py +++ b/edify/compile/dispatch.py @@ -14,17 +14,18 @@ from edify.compile.groups import render_grouping from edify.compile.leaves import render_leaf from edify.compile.quantifier import render_quantifier from edify.compile.root import render_root +from edify.elements.types.base import BaseElement +from edify.elements.types.root import RootElement from edify.elements.types.union import ( CaptureGroupElement, CharShapedElement, - Element, GroupingElement, LeafElement, QuantifierElement, ) -def render_element(element: Element) -> str: +def render_element(element: BaseElement) -> str: """Return the regex string produced by any element. Args: @@ -43,4 +44,5 @@ def render_element(element: Element) -> str: return render_grouping(element, render_element) if isinstance(element, QuantifierElement): return render_quantifier(element, render_element) + assert isinstance(element, RootElement) return render_root(element, render_element) diff --git a/edify/compile/fuse.py b/edify/compile/fuse.py index 79676d6..4b8d96b 100644 --- a/edify/compile/fuse.py +++ b/edify/compile/fuse.py @@ -58,4 +58,5 @@ def _fragment_for(member: BaseElement) -> str: return member.value if isinstance(member, AnyOfCharsElement): return member.value + assert isinstance(member, RangeElement) return f"{member.start}-{member.end}" diff --git a/edify/errors/context.py b/edify/errors/context.py index c5c645b..4108002 100644 --- a/edify/errors/context.py +++ b/edify/errors/context.py @@ -6,6 +6,7 @@ import linecache import os import sys from dataclasses import dataclass +from types import FrameType _EDIFY_PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _EDIFY_PACKAGE_PREFIX = _EDIFY_PACKAGE_ROOT + os.sep @@ -36,7 +37,7 @@ def capture_caller_context() -> CallerContext | None: Returns ``None`` when every frame on the stack lives inside the ``edify/`` package tree. """ - current_frame = sys._getframe(1) + current_frame: FrameType | None = sys._getframe(1) while current_frame is not None: filename = current_frame.f_code.co_filename absolute_filename = ( @@ -48,18 +49,21 @@ def capture_caller_context() -> CallerContext | None: return None -def _context_for_frame(frame) -> CallerContext: +def _context_for_frame(frame: FrameType) -> CallerContext: """Return a :class:`CallerContext` describing ``frame``'s current instruction.""" filename = frame.f_code.co_filename positions = list(frame.f_code.co_positions()) instruction_index = frame.f_lasti // 2 - start_line, _end_line, start_col, end_col = positions[instruction_index] - source_line = _read_source_line(filename, start_line) + raw_start_line, _end_line, raw_start_col, raw_end_col = positions[instruction_index] + resolved_start_line = raw_start_line if raw_start_line is not None else frame.f_lineno + resolved_start_col = raw_start_col if raw_start_col is not None else 0 + resolved_end_col = raw_end_col if raw_end_col is not None else resolved_start_col + source_line = _read_source_line(filename, resolved_start_line) return CallerContext( filename=filename, - lineno=start_line, - colno=start_col + 1, - end_colno=end_col + 1, + lineno=resolved_start_line, + colno=resolved_start_col + 1, + end_colno=resolved_end_col + 1, source_line=source_line, ) diff --git a/edify/errors/engine.py b/edify/errors/engine.py new file mode 100644 index 0000000..5a9e6cb --- /dev/null +++ b/edify/errors/engine.py @@ -0,0 +1,31 @@ +"""Exception classes raised when the ``engine`` kwarg selects an unavailable backend.""" + +from __future__ import annotations + +from edify.errors.formatting import compose_annotated_message +from edify.errors.syntax import EdifySyntaxError + + +class EngineNotWiredError(EdifySyntaxError): + """Raised when ``.to_regex(engine=...)`` selects a backend this build does not wire. + + Args: + requested_engine: The value the caller passed for the ``engine`` kwarg. + """ + + def __init__(self, requested_engine: str) -> None: + message = compose_annotated_message( + summary=( + f"engine={requested_engine!r} is not wired in this build; only 're' is available" + ), + trigger_hint=".to_regex(engine=…) called here", + note=( + f"the {requested_engine!r} backend is a planned optional dependency " + "and its dispatch has not shipped yet." + ), + help_line=( + "help: call .to_regex() without the engine kwarg, or pin edify to a " + "release that wires the requested backend." + ), + ) + super().__init__(message) diff --git a/edify/errors/serialize.py b/edify/errors/serialize.py index b9f6f08..a5b9af4 100644 --- a/edify/errors/serialize.py +++ b/edify/errors/serialize.py @@ -4,6 +4,7 @@ from __future__ import annotations from edify.errors.formatting import compose_annotated_message from edify.errors.syntax import EdifySyntaxError +from edify.serialize.types import JSONValue class MissingSchemaKeyError(EdifySyntaxError): @@ -37,7 +38,7 @@ class IncompatibleSchemaVersionError(EdifySyntaxError): supported_version: The version this build emits and accepts. """ - def __init__(self, seen_version: object, supported_version: int) -> None: + def __init__(self, seen_version: JSONValue, supported_version: int) -> None: message = compose_annotated_message( summary=( f"canonical dict declares schema version {seen_version!r}, but this build " diff --git a/edify/introspect/explain.py b/edify/introspect/explain.py index c4a935b..2e903f4 100644 --- a/edify/introspect/explain.py +++ b/edify/introspect/explain.py @@ -127,7 +127,7 @@ def _build_lines(elements: tuple[BaseElement, ...]) -> list[str]: def _wrap_step(step_number: int, description: str) -> str: - """Return the description prefixed with the step number and 3-space continuation indent.""" + """Return the description prefixed with the step number and indented continuation.""" prefix = f" {step_number}. " continuation_prefix = " " * len(prefix) words = description.split() diff --git a/edify/introspect/graphviz.py b/edify/introspect/graphviz.py index 3440301..b61c482 100644 --- a/edify/introspect/graphviz.py +++ b/edify/introspect/graphviz.py @@ -30,6 +30,7 @@ from edify.elements.types.quantifiers import ( ZeroOrMoreElement, ZeroOrMoreLazyElement, ) +from edify.elements.types.union import QuantifierElement from edify.errors.introspect import MissingGraphvizDependencyError from edify.introspect.ascii import _char_label, _leaf_label from edify.introspect.types import Emission @@ -46,7 +47,7 @@ def render_graphviz_svg(elements: tuple[BaseElement, ...]) -> str: raise MissingGraphvizDependencyError() dot_source = render_dot(elements) source = _graphviz_module.Source(dot_source, format="svg") - piped_bytes = source.pipe(format="svg") + piped_bytes: bytes = source.pipe(format="svg") return piped_bytes.decode("utf-8") @@ -185,6 +186,7 @@ def _emit_quantifier_cluster(element: BaseElement, counter: _Counter) -> Emissio quantifier_phrase = _quantifier_phrase(element) if quantifier_phrase is None: return None + assert isinstance(element, QuantifierElement) child_emission = _emit_element(element.child, counter) cluster_id = counter.next("cluster") lines: list[str] = [ @@ -220,6 +222,7 @@ def _simple_quantifier_label(element: BaseElement) -> str | None: quantifier_phrase = _quantifier_phrase(element) if quantifier_phrase is None: return None + assert isinstance(element, QuantifierElement) child = element.child child_label = _leaf_label(child) or _char_label(child) if child_label is None: diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py index ce0ec97..a913f8b 100644 --- a/edify/pattern/composition.py +++ b/edify/pattern/composition.py @@ -1,19 +1,9 @@ -"""The composition root for :class:`Pattern` — a reusable regex fragment. - -:class:`Pattern` shares every chain-method mixin with :class:`RegexBuilder`, -including the terminals so a pattern can emit its own regex directly via -``pattern.to_regex_string()`` or ``pattern.to_regex()``. A pattern still -composes *into* a builder (or into another pattern) via ``.use()`` or -``.subexpression()`` when the caller wants control over how anchors, flags -and namespaces merge into the outer surface. - -The immutable-state plumbing (``_state`` attribute + ``_with_state`` helper) -comes from :class:`edify.builder.core.BuilderCore`, the same base -:class:`edify.builder.builder.RegexBuilder` inherits from. -""" +"""The composition root for :class:`Pattern` — a reusable regex fragment.""" from __future__ import annotations +import json + from edify.builder.core import BuilderCore from edify.builder.mixins.anchors import AnchorsMixin from edify.builder.mixins.assertions import AssertionsMixin @@ -28,6 +18,9 @@ from edify.builder.mixins.operators import OperatorsMixin from edify.builder.mixins.quantifiers import QuantifiersMixin from edify.builder.mixins.subexpression import SubexpressionMixin from edify.builder.mixins.terminals import TerminalsMixin +from edify.serialize.dump import state_to_dict +from edify.serialize.load import dict_to_state +from edify.serialize.types import JSONValue class Pattern( @@ -55,9 +48,6 @@ class Pattern( def __call__(self, value: str) -> bool: """Return True when ``value`` matches this pattern from its first character. - A non-string ``value`` always returns False rather than raising, so callers - can pass user input directly without a pre-check. - Args: value: The string to test against the pattern. @@ -69,33 +59,28 @@ class Pattern( 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_dict(self) -> dict[str, JSONValue]: + """Return the canonical dict representation of this pattern.""" + return state_to_dict(self._state) 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) + document = self.to_dict() + return json.dumps(document, sort_keys=True, separators=(",", ":")) @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) + def from_dict(cls, document: dict[str, JSONValue]) -> Pattern: + """Return a Pattern reconstructed from a canonical dict.""" + reconstructed_state = dict_to_state(document) + empty_pattern = cls() + return empty_pattern._with_state(reconstructed_state) @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) + """Return a Pattern reconstructed from a canonical JSON string.""" + parsed_document: JSONValue = json.loads(blob) + if not isinstance(parsed_document, dict): + raise TypeError( + f"canonical JSON payload must be an object; got {type(parsed_document).__name__}" + ) + return cls.from_dict(parsed_document) diff --git a/edify/pattern/factories/assertions.py b/edify/pattern/factories/assertions.py index 636c868..fbfe113 100644 --- a/edify/pattern/factories/assertions.py +++ b/edify/pattern/factories/assertions.py @@ -12,6 +12,7 @@ single lookaround element wrapping the supplied operand's children. from __future__ import annotations from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.base import BaseElement from edify.elements.types.groups import ( AssertAheadElement, AssertBehindElement, @@ -42,6 +43,6 @@ def assert_not_behind(operand: BuilderProtocol) -> Pattern: return pattern_containing(AssertNotBehindElement(children=_operand_children(operand))) -def _operand_children(operand: BuilderProtocol) -> tuple: +def _operand_children(operand: BuilderProtocol) -> tuple[BaseElement, ...]: """Return ``operand``'s root-frame children as an immutable tuple.""" return tuple(operand._state.top_frame.children) diff --git a/edify/pattern/factories/groups.py b/edify/pattern/factories/groups.py index fb6cd5a..9520a0d 100644 --- a/edify/pattern/factories/groups.py +++ b/edify/pattern/factories/groups.py @@ -14,6 +14,7 @@ single grouping element built from the supplied operand(s). from __future__ import annotations from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -60,7 +61,7 @@ def any_of(*operands: BuilderProtocol) -> Pattern: return pattern_containing(AnyOfElement(children=children)) -def _operand_children(operand: BuilderProtocol) -> tuple: +def _operand_children(operand: BuilderProtocol) -> tuple[BaseElement, ...]: """Return ``operand``'s root-frame children as an immutable tuple.""" return tuple(operand._state.top_frame.children) diff --git a/edify/py.typed b/edify/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/py.typed diff --git a/edify/result/regex.py b/edify/result/regex.py index b52e3ee..89dbb03 100644 --- a/edify/result/regex.py +++ b/edify/result/regex.py @@ -18,6 +18,7 @@ _RePatternMethodReturn = ( | Iterator[re.Match[str]] | str | tuple[str, int] + | list[str | None] | None ) @@ -133,8 +134,9 @@ class Regex: return visualize_elements(self._elements, format=format, engine=engine) def __getattr__(self, name: str) -> _RePatternAttribute: - """Delegate any attribute access not explicitly defined here to the compiled pattern.""" - return getattr(self._compiled, name) + """Return the underlying :class:`re.Pattern` attribute named ``name``.""" + attribute: _RePatternAttribute = getattr(self._compiled, name) + return attribute def __repr__(self) -> str: """Return ``<Regex 'source-string'>``.""" diff --git a/edify/serialize/__init__.py b/edify/serialize/__init__.py index f7ca7f2..efc0c43 100644 --- a/edify/serialize/__init__.py +++ b/edify/serialize/__init__.py @@ -1,46 +1,14 @@ -"""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.dump import element_to_dict, state_to_dict +from edify.serialize.load import dict_to_element, dict_to_state +from edify.serialize.types import JSONPrimitive, JSONValue 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", + "JSONPrimitive", + "JSONValue", + "dict_to_element", + "dict_to_state", + "element_to_dict", + "state_to_dict", ] - - -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 index 63d2802..e5f0387 100644 --- a/edify/serialize/dump.py +++ b/edify/serialize/dump.py @@ -1,65 +1,53 @@ -"""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. -""" +"""Serialize a :class:`Pattern` into the canonical dict form.""" 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.state import BuilderState from edify.elements.types.base import BaseElement +from edify.elements.types.root import RootElement from edify.serialize.kinds import kind_for +from edify.serialize.types import JSONValue from edify.serialize.version import SCHEMA_VERSION -if TYPE_CHECKING: - from edify.builder.types.flags import Flags - from edify.builder.types.state import BuilderState +_ElementFieldValue = BaseElement | tuple[BaseElement, ...] | str | int -def element_to_dict(element: BaseElement) -> dict[str, Any]: +def element_to_dict(element: BaseElement) -> dict[str, JSONValue]: """Return the canonical dict representation of ``element``.""" - result: dict[str, Any] = {"kind": kind_for(type(element))} + result: dict[str, JSONValue] = {"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]: +def state_to_dict(state: BuilderState) -> dict[str, JSONValue]: """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] = { + root_dict = element_to_dict(root_element) + document: dict[str, JSONValue] = { "edify": SCHEMA_VERSION, - "pattern": element_to_dict(root_element), + "pattern": root_dict, } flag_map = _flags_to_dict(state.flags) if flag_map: document["flags"] = flag_map return document + + +def _serialize_field_value(value: _ElementFieldValue) -> JSONValue: + if isinstance(value, BaseElement): + return element_to_dict(value) + if isinstance(value, tuple): + return [element_to_dict(child) for child in value] + if isinstance(value, str): + return value + return value + + +def _flags_to_dict(flags: Flags) -> dict[str, JSONValue]: + return {spec.name: True for spec in fields(flags) if getattr(flags, spec.name)} diff --git a/edify/serialize/kinds.py b/edify/serialize/kinds.py index 6b4fc0c..3c9c0f3 100644 --- a/edify/serialize/kinds.py +++ b/edify/serialize/kinds.py @@ -7,6 +7,7 @@ part of the wire format — only the corresponding ``kind`` (``"exactly"``). from __future__ import annotations +from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -67,7 +68,7 @@ from edify.elements.types.quantifiers import ( ) from edify.elements.types.root import RootElement -_CLASS_BY_KIND = { +_CLASS_BY_KIND: dict[str, type[BaseElement]] = { "root": RootElement, "start": StartOfInputElement, "end": EndOfInputElement, @@ -122,11 +123,11 @@ _CLASS_BY_KIND = { _KIND_BY_CLASS = {cls: kind for kind, cls in _CLASS_BY_KIND.items()} -def kind_for(element_class: type) -> str: +def kind_for(element_class: type[BaseElement]) -> str: """Return the public ``kind`` string registered for ``element_class``.""" return _KIND_BY_CLASS[element_class] -def class_for(kind: str) -> type: +def class_for(kind: str) -> type[BaseElement]: """Return the element class registered under ``kind``.""" return _CLASS_BY_KIND[kind] diff --git a/edify/serialize/load.py b/edify/serialize/load.py index beb9af5..67d6a39 100644 --- a/edify/serialize/load.py +++ b/edify/serialize/load.py @@ -1,20 +1,15 @@ -"""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. -""" +"""Deserialize a canonical dict back into a :class:`BuilderState`.""" from __future__ import annotations +from collections.abc import Iterator from dataclasses import fields -from typing import TYPE_CHECKING, Any +from typing import cast 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.base import BaseElement from edify.elements.types.captures import CaptureElement, NamedCaptureElement from edify.elements.types.leaves import EndOfInputElement, StartOfInputElement from edify.elements.types.root import RootElement @@ -24,59 +19,45 @@ from edify.errors.serialize import ( UnknownElementKindError, ) from edify.serialize.kinds import class_for +from edify.serialize.types import JSONValue 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: +def dict_to_element(tree: dict[str, JSONValue]) -> BaseElement: """Return the AST element described by ``tree``.""" - kind = tree.get("kind") - if kind is None: + kind_value = tree.get("kind") + if not isinstance(kind_value, str): raise MissingSchemaKeyError("kind") try: - element_class = class_for(kind) + element_class = class_for(kind_value) 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] = {} + raise UnknownElementKindError(kind_value) from reason + known_field_names = {spec.name for spec in fields(element_class)} + constructor_kwargs: dict[str, object] = {} for name, raw_value in tree.items(): if name == "kind": continue - if name not in field_specs: + if name not in known_field_names: 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 - +def dict_to_state(document: dict[str, JSONValue]) -> BuilderState: + """Return the :class:`BuilderState` described by ``document`` (canonical shape).""" _require_schema_version(document) pattern_tree = document.get("pattern") - if pattern_tree is None: + if not isinstance(pattern_tree, dict): 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) + root_children = _root_children(root_element) + reconstructed_flags = _flags_from_document(document) 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( + return BuilderState( has_defined_start=has_start, has_defined_end=has_end, flags=reconstructed_flags, @@ -84,11 +65,23 @@ def dict_to_pattern(document: dict[str, Any]) -> Pattern: 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: +def _deserialize_field_value(value: JSONValue) -> object: + if isinstance(value, list): + return tuple(dict_to_element(item) for item in value if isinstance(item, dict)) + if isinstance(value, dict) and "kind" in value: + return dict_to_element(value) + return value + + +def _root_children(root_element: BaseElement) -> tuple[BaseElement, ...]: + if isinstance(root_element, RootElement): + return tuple(root_element.children) + return () + + +def _require_schema_version(document: dict[str, JSONValue]) -> None: if "edify" not in document: raise MissingSchemaKeyError("edify") seen_version = document["edify"] @@ -96,9 +89,14 @@ def _require_schema_version(document: dict[str, Any]) -> None: raise IncompatibleSchemaVersionError(seen_version, SCHEMA_VERSION) -def _flags_from_dict(flag_map: dict[str, Any]) -> Flags: +def _flags_from_document(document: dict[str, JSONValue]) -> Flags: + raw_flag_map = document.get("flags") + if not isinstance(raw_flag_map, dict): + return 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} + kwargs = { + name: True for name, value in raw_flag_map.items() if name in known_flag_names and value + } return Flags(**kwargs) @@ -111,21 +109,21 @@ def _collect_named_groups(children: tuple[BaseElement, ...]) -> tuple[str, ...]: 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 - + return sum( + 1 + for element in _walk_elements(children) + if isinstance(element, CaptureElement | NamedCaptureElement) + ) -def _walk_elements(children: tuple[BaseElement, ...]): - from edify.elements.types.base import BaseElement as _BaseElement +def _walk_elements(children: tuple[BaseElement, ...]) -> Iterator[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): + value: object = getattr(element, spec.name) + if isinstance(value, BaseElement): yield from _walk_elements((value,)) + continue + if isinstance(value, tuple): + narrowed_tuple = cast(tuple[BaseElement, ...], value) + yield from _walk_elements(narrowed_tuple) diff --git a/edify/serialize/types.py b/edify/serialize/types.py new file mode 100644 index 0000000..2ea4f0e --- /dev/null +++ b/edify/serialize/types.py @@ -0,0 +1,9 @@ +"""JSON-value type used by every canonical (de)serialization function.""" + +from __future__ import annotations + +JSONPrimitive = str | int | float | bool | None +"""One of the primitive scalar values a JSON payload may carry.""" + +JSONValue = JSONPrimitive | list["JSONValue"] | dict[str, "JSONValue"] +"""Any value that appears anywhere in a canonical Pattern serialization payload.""" diff --git a/pyproject.toml b/pyproject.toml index f64d263..eb18734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ Changelog = "https://edify.readthedocs.io/en/latest/changelog.html" dev = [ "graphviz>=0.20", "hypothesis>=6.100", + "mypy==1.13.0", + "pyright==1.1.389", "pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.7", @@ -72,6 +74,9 @@ include = [ [tool.hatch.build.targets.wheel] packages = ["edify"] +[tool.hatch.build.targets.wheel.force-include] +"edify/py.typed" = "edify/py.typed" + [tool.ruff] line-length = 100 target-version = "py311" @@ -107,6 +112,37 @@ addopts = [ ] filterwarnings = ["error"] +[tool.pyright] +pythonVersion = "3.11" +include = ["edify"] +typeCheckingMode = "strict" +reportMissingImports = "error" +reportMissingTypeStubs = "none" +reportPrivateUsage = "none" +reportUnnecessaryIsInstance = "none" +reportUnusedFunction = "none" +reportUnknownVariableType = "none" +reportUnknownMemberType = "none" +reportUnknownArgumentType = "none" +reportAttributeAccessIssue = "none" + +[tool.mypy] +python_version = "3.11" +files = ["edify", "tests", "docs"] +warn_unused_configs = true + +[[tool.mypy.overrides]] +module = ["edify.*"] +strict = true + +[[tool.mypy.overrides]] +module = ["regex"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["graphviz"] +ignore_missing_imports = true + [tool.coverage.run] branch = false source = ["edify"] diff --git a/tests/builder/engine.test.py b/tests/builder/engine.test.py new file mode 100644 index 0000000..e569c71 --- /dev/null +++ b/tests/builder/engine.test.py @@ -0,0 +1,19 @@ +import pytest + +from edify import RegexBuilder +from edify.errors.engine import EngineNotWiredError + + +def test_engine_defaults_to_re_and_compiles(): + compiled = RegexBuilder().digit().to_regex() + assert compiled.source == "\\d" + + +def test_engine_re_explicit_is_accepted(): + compiled = RegexBuilder().digit().to_regex(engine="re") + assert compiled.source == "\\d" + + +def test_engine_regex_raises_until_wired(): + with pytest.raises(EngineNotWiredError, match="engine='regex'"): + RegexBuilder().digit().to_regex(engine="regex") diff --git a/tests/builder/repr.test.py b/tests/builder/repr.test.py index c5c716e..278f40b 100644 --- a/tests/builder/repr.test.py +++ b/tests/builder/repr.test.py @@ -1,11 +1,6 @@ """Tests for :meth:`BuilderCore.__repr__` on :class:`RegexBuilder` and :class:`Pattern`.""" from edify import Pattern, RegexBuilder -from edify.builder.core import BuilderCore - - -class _BareBuilder(BuilderCore): - """A minimal :class:`BuilderCore` subclass without :class:`TerminalsMixin`.""" def test_repr_of_a_fresh_regex_builder_shows_the_empty_non_capturing_group(): @@ -34,7 +29,3 @@ def test_repr_of_a_builder_with_open_frames_shows_the_unclosed_marker(): def test_repr_uses_the_concrete_class_name_not_the_base(): assert repr(RegexBuilder()).startswith("<RegexBuilder ") assert repr(Pattern()).startswith("<Pattern ") - - -def test_repr_falls_back_when_the_subclass_lacks_the_terminals_mixin(): - assert repr(_BareBuilder()) == "<_BareBuilder '<unclosed>'>" diff --git a/tests/discipline/suppressions.test.py b/tests/discipline/suppressions.test.py new file mode 100644 index 0000000..a4ad2f3 --- /dev/null +++ b/tests/discipline/suppressions.test.py @@ -0,0 +1,35 @@ +"""Discipline: every type-checker suppression must carry a rule code AND an inline reason. + +The well-formed shape is ``# type: ignore[<code>] # <reason>`` (mypy) or +``# pyright: ignore[<code>] # <reason>`` (pyright). The check recognises both +forms and rejects any bare suppression that lacks a rule code or an inline +rationale. +""" + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_EDIFY_ROOT = _REPO_ROOT / "edify" + +_WELL_FORMED = re.compile( + r"#\s*(?:type|pyright):\s*ignore\[[^\]]+\]\s+#\s*\S+.*$", +) + +_ANY_SUPPRESSION = re.compile(r"#\s*(?:type|pyright):\s*ignore\b") + + +def _collect_python_sources(root: Path) -> list[Path]: + return sorted(p for p in root.rglob("*.py") if "__pycache__" not in p.parts) + + +def test_every_suppression_carries_a_rule_code_and_a_reason(): + offenders: list[str] = [] + for path in _collect_python_sources(_EDIFY_ROOT): + for lineno, line in enumerate(path.read_text().splitlines(), start=1): + if _ANY_SUPPRESSION.search(line) and not _WELL_FORMED.search(line): + offenders.append(f"{path.relative_to(_REPO_ROOT)}:{lineno} {line.rstrip()}") + assert not offenders, ( + "type-checker suppressions must include both a rule code and an inline reason.\n" + + "\n".join(offenders) + ) diff --git a/tests/serialize/errors.test.py b/tests/serialize/errors.test.py index ff6371f..68adaac 100644 --- a/tests/serialize/errors.test.py +++ b/tests/serialize/errors.test.py @@ -49,3 +49,14 @@ def test_unknown_kind_raises(): def test_bad_json_string_raises_decode_error(): with pytest.raises(json.JSONDecodeError): Pattern.from_json("{not-valid-json}") + + +def test_non_object_json_payload_raises_type_error(): + with pytest.raises(TypeError, match="canonical JSON payload must be an object"): + Pattern.from_json("[1, 2, 3]") + + +def test_pattern_key_not_a_root_element_produces_empty_pattern(): + document = {"edify": 0, "pattern": {"kind": "digit"}} + restored = Pattern.from_dict(document) + assert restored.to_regex_string() == "(?:)" @@ -242,6 +242,8 @@ graphviz = [ dev = [ { name = "graphviz" }, { name = "hypothesis" }, + { name = "mypy" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -260,6 +262,8 @@ provides-extras = ["graphviz"] dev = [ { name = "graphviz", specifier = ">=0.20" }, { name = "hypothesis", specifier = ">=6.100" }, + { name = "mypy", specifier = "==1.13.0" }, + { name = "pyright", specifier = "==1.1.389" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.7" }, @@ -404,6 +408,52 @@ wheels = [ ] [[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532, upload-time = "2024-10-22T21:55:47.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027, upload-time = "2024-10-22T21:55:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699, upload-time = "2024-10-22T21:55:34.646Z" }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263, upload-time = "2024-10-22T21:54:51.807Z" }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688, upload-time = "2024-10-22T21:55:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811, upload-time = "2024-10-22T21:54:59.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900, upload-time = "2024-10-22T21:55:37.103Z" }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818, upload-time = "2024-10-22T21:55:11.513Z" }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275, upload-time = "2024-10-22T21:54:37.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783, upload-time = "2024-10-22T21:55:42.852Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197, upload-time = "2024-10-22T21:54:43.68Z" }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721, upload-time = "2024-10-22T21:54:22.321Z" }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996, upload-time = "2024-10-22T21:54:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043, upload-time = "2024-10-22T21:55:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996, upload-time = "2024-10-22T21:55:25.811Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709, upload-time = "2024-10-22T21:55:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043, upload-time = "2024-10-22T21:55:16.617Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } @@ -431,6 +481,19 @@ wheels = [ ] [[package]] +name = "pyright" +version = "1.1.389" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940, upload-time = "2024-11-13T16:35:41.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581, upload-time = "2024-11-13T16:35:40.689Z" }, +] + +[[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } @@ -726,6 +789,15 @@ wheels = [ ] [[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } |
