diff options
| author | natsuoto <[email protected]> | 2026-06-26 19:23:09 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-06-26 19:23:09 +0530 |
| commit | 39f16caea1a92c9fb3e7d9c55cdcc20a058174ee (patch) | |
| tree | f51cbe04af8a387d7d6e2e96eb2a746cca318669 | |
| parent | 21ec7908c94839fff63b5275871cf84e92787a08 (diff) | |
| download | edify-39f16caea1a92c9fb3e7d9c55cdcc20a058174ee.tar.xz edify-39f16caea1a92c9fb3e7d9c55cdcc20a058174ee.zip | |
chore!: package reorg, toolchain migration to uv+ruff+pytest+hatchling, Python 3.11 floor
Drops the `src/` layout. The package lives at `edify/` at repo root, split
into single-purpose subpackages: `builder/` (mixins composing RegexBuilder),
`elements/` (sealed dataclass AST), `compile/` (per-family render functions
plus dispatch), `validate/`, `errors/` (typed exception hierarchy), and
`library/` (per-validator files in family folders). Pattern, serialize,
and result subpackages ship as skeletons.
Replaces tox / virtualenv / flake8+isort+black / setup.py+setup.cfg /
pytest.ini / .coveragerc / .bumpversion.cfg / MANIFEST.in / ci/ /
tests.local.sh with a unified uv + ruff + pytest + hatchling + hatch-vcs
stack driven entirely from pyproject.toml. CI workflow rewritten to
`uv sync --frozen` + `uv run pytest` per matrix entry; required-status-
check names preserved for `check`, `docs`, `py3X (ubuntu)`, and
`pypy311 (ubuntu)`. `py39` / `py310` / `pypy310` dropped (3.11 floor).
Pre-commit replaced with ruff hooks (lint + format), all hook repos
SHA-pinned to commit SHAs.
Tests reorganised under `tests/builder/` and `tests/library/<family>/`
with the `*.test.py` naming shape. 173 tests pass, 100% line coverage.
Behaviour changes versus 0.3 (each will get a documented upgrade-guide
entry once #80 lands):
- `to_regex_string()` returns the bare pattern (no `/.../` wrap)
- `to_regex()` on `named_back_reference` compiles successfully via
`(?P=name)` instead of raising
- builder errors raise typed `EdifyError` subclasses instead of bare
`Exception` (catch via `except EdifyError`)
- internal helpers (`apply_quantifier`, `frame_creating_element`,
`evaluate`, `state`, etc.) removed outright per the 0.3 → 1.0
stub-free policy
Closes #68
Closes #69
Closes #253
Co-Authored-By: Claude Opus 4.7 <[email protected]>
143 files changed, 5896 insertions, 2270 deletions
diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 11cbb6e..0000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,25 +0,0 @@ -[bumpversion] -current_version = 0.3.0 -commit = True -tag = True - -[bumpversion:file:setup.py] -search = version='{current_version}' -replace = version='{new_version}' - -[bumpversion:file (badge):README.rst] -search = /v{current_version}.svg -replace = /v{new_version}.svg - -[bumpversion:file (link):README.rst] -search = /v{current_version}...main -replace = /v{new_version}...main - -[bumpversion:file:docs/conf.py] -search = version = release = '{current_version}' -replace = version = release = '{new_version}' - -[bumpversion:file:src/edify/__init__.py] -search = __version__ = '{current_version}' -replace = __version__ = '{new_version}' - diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index b136d66..0000000 --- a/.coveragerc +++ /dev/null @@ -1,16 +0,0 @@ -[paths] -source = - src - */site-packages - -[run] -branch = true -source = - edify - tests -parallel = true - -[report] -show_missing = true -precision = 2 -omit = *migrations* diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 61665e0..58f0b80 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -10,80 +10,41 @@ jobs: matrix: include: - name: 'check' - python: '3.9' - toxpython: 'python3.9' - tox_env: 'check' + python: '3.11' + command: 'uv run ruff check . && uv run ruff format --check .' os: 'ubuntu-latest' - name: 'docs' - python: '3.9' - toxpython: 'python3.9' - tox_env: 'docs' - os: 'ubuntu-latest' - - name: 'py39 (ubuntu)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'x64' - tox_env: 'py39' - os: 'ubuntu-latest' - - name: 'py310 (ubuntu)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'x64' - tox_env: 'py310' + 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' os: 'ubuntu-latest' - name: 'py311 (ubuntu)' python: '3.11' - toxpython: 'python3.11' - python_arch: 'x64' - tox_env: 'py311' - os: 'ubuntu-latest' - - name: 'pypy310 (ubuntu)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' - python_arch: 'x64' - tox_env: 'pypy310' + command: 'uv run pytest' os: 'ubuntu-latest' - name: 'pypy311 (ubuntu)' python: 'pypy-3.11' - toxpython: 'pypy3.11' - python_arch: 'x64' - tox_env: 'pypy311' + command: 'uv run pytest' os: 'ubuntu-latest' - name: 'py312 (ubuntu)' python: '3.12' - toxpython: 'python3.12' - python_arch: 'x64' - tox_env: 'py312' + command: 'uv run pytest' os: 'ubuntu-latest' - name: 'py313 (ubuntu)' python: '3.13' - toxpython: 'python3.13' - python_arch: 'x64' - tox_env: 'py313' + command: 'uv run pytest' os: 'ubuntu-latest' - name: 'py314 (ubuntu)' python: '3.14' - toxpython: 'python3.14' - python_arch: 'x64' - tox_env: 'py314' + command: 'uv run pytest' os: 'ubuntu-latest' steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - architecture: ${{ matrix.python_arch }} - - name: install dependencies - run: | - python -mpip install --progress-bar=off -r ci/requirements.txt - virtualenv --version - pip --version - tox --version - pip list --format=freeze - - name: test - env: - TOXPYTHON: '${{ matrix.toxpython }}' - run: > - tox -e ${{ matrix.tox_env }} -v
\ No newline at end of file + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 + with: + python-version: ${{ matrix.python }} + - name: install dependencies + run: uv sync --frozen --all-groups + - name: ${{ matrix.name }} + run: ${{ matrix.command }}
\ No newline at end of file @@ -20,7 +20,7 @@ develop-eggs .installed.cfg lib lib64 -venv*/ +*venv*/ pyvenv*/ pip-wheel-metadata/ @@ -32,6 +32,7 @@ pip-log.txt .tox .coverage.* .pytest_cache/ +.ruff_cache/ nosetests.xml coverage.xml htmlcov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d3b54e8..54fbb8c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,14 @@ -# To install the git pre-commit hook run: -# pre-commit install -# To update the pre-commit hooks run: -# pre-commit install-hooks -exclude: '^(\.tox|\.bumpversion\.cfg)(/|$)' repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: c59bba8fb259db0fec2bbb77ad8ba51ea7341b56 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c hooks: - id: trailing-whitespace - id: end-of-file-fixer - - id: debug-statements - - repo: https://github.com/PyCQA/isort - rev: 8.0.1 - hooks: - - id: isort - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.3.1 - hooks: - - id: black - - repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 - hooks: - - id: flake8 + - id: check-yaml + - id: check-toml
\ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index bd328f6..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,23 +0,0 @@ -graft docs -graft src -graft ci -graft tests - -include .bumpversion.cfg -include .coveragerc -include .editorconfig -include .github/workflows/github-actions.yml -include .pre-commit-config.yaml -include .readthedocs.yml -include pytest.ini -include tox.ini - -include AUTHORS.rst -include CHANGELOG.rst -include CONTRIBUTING.rst -include LICENSE -include README.rst -include *.sh -recursive-include images *.png - -global-exclude *.py[cod] __pycache__/* *.so *.dylib diff --git a/ci/requirements.txt b/ci/requirements.txt deleted file mode 100644 index 89f308f..0000000 --- a/ci/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -virtualenv>=21.5.1 -pip>=25.0 -setuptools>=75.0 -six>=1.17.0 -tox diff --git a/docs/conf.py b/docs/conf.py index 74c45f5..28bcbfb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,54 +1,51 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import os extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.coverage', - 'sphinx.ext.doctest', - 'sphinx.ext.extlinks', - 'sphinx.ext.ifconfig', - 'sphinx.ext.napoleon', - 'sphinx.ext.todo', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.coverage", + "sphinx.ext.doctest", + "sphinx.ext.extlinks", + "sphinx.ext.ifconfig", + "sphinx.ext.napoleon", + "sphinx.ext.todo", + "sphinx.ext.viewcode", ] -source_suffix = '.rst' -master_doc = 'index' -project = 'Edify' -year = '2022-2026' -author = 'Bobby' -copyright = '{0}, {1}'.format(year, author) -version = release = '0.3.0' +source_suffix = ".rst" +master_doc = "index" +project = "Edify" +year = "2022-2026" +author = "Bobby" +copyright = f"{year}, {author}" +version = release = "0.3.0" -pygments_style = 'trac' -templates_path = ['.'] +pygments_style = "trac" +templates_path = ["."] extlinks = { - 'issue': ('https://github.com/luciferreeves/edify/issues/%s', '#%s'), - 'pr': ('https://github.com/luciferreeves/edify/pull/%s', 'PR #%s'), + "issue": ("https://github.com/luciferreeves/edify/issues/%s", "#%s"), + "pr": ("https://github.com/luciferreeves/edify/pull/%s", "PR #%s"), } # The "commits since latest release" shield in README.rst targets a # `compare/vX.Y.Z...main` URL, which 404s during the window between # bumping the version and tagging the release. Skip it in linkcheck # rather than letting docs CI fail every time we bump. linkcheck_ignore = [ - r'https://github\.com/luciferreeves/edify/compare/v\d+\.\d+\.\d+\.\.\.main', - r'https://github\.com/luciferreeves/edify/(pull|issues)/\d+', + r"https://github\.com/luciferreeves/edify/compare/v\d+\.\d+\.\d+\.\.\.main", + r"https://github\.com/luciferreeves/edify/(pull|issues)/\d+", ] # on_rtd is whether we are on readthedocs.org -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" if not on_rtd: # only set the theme if we're building docs locally - html_theme = 'sphinx_rtd_theme' + html_theme = "sphinx_rtd_theme" html_use_smartypants = True -html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = "%b %d, %Y" html_split_index = False html_sidebars = { - '**': ['searchbox.html', 'globaltoc.html', 'sourcelink.html'], + "**": ["searchbox.html", "globaltoc.html", "sourcelink.html"], } -html_short_title = '%s-%s' % (project, version) +html_short_title = f"{project}-{version}" napoleon_use_ivar = True napoleon_use_rtype = False diff --git a/edify/__init__.py b/edify/__init__.py new file mode 100644 index 0000000..76ec577 --- /dev/null +++ b/edify/__init__.py @@ -0,0 +1,17 @@ +from importlib.metadata import PackageNotFoundError, version + +from edify.builder.builder import RegexBuilder +from edify.errors.base import EdifyError +from edify.errors.syntax import EdifySyntaxError + +try: + __version__ = version("edify") +except PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "EdifyError", + "EdifySyntaxError", + "RegexBuilder", + "__version__", +] diff --git a/edify/builder/__init__.py b/edify/builder/__init__.py new file mode 100644 index 0000000..ed3e1ce --- /dev/null +++ b/edify/builder/__init__.py @@ -0,0 +1,3 @@ +from edify.builder.builder import RegexBuilder + +__all__ = ["RegexBuilder"] diff --git a/edify/builder/builder.py b/edify/builder/builder.py new file mode 100644 index 0000000..b4854cf --- /dev/null +++ b/edify/builder/builder.py @@ -0,0 +1,56 @@ +"""The composition root for :class:`RegexBuilder` — the fluent regex-builder class. + +The class itself defines no chain methods; every method comes from one of +the mixins under :mod:`edify.builder.mixins`. The composition order does +not matter for behavior because the mixins do not override each other's +methods, but they are listed alphabetically by mixin name for predictability. + +The two attributes required by :class:`BuilderProtocol` — +``_state: BuilderState`` and ``_with_state`` — live here. Every chain +method reads ``self._state`` and returns ``self._with_state(new_state)``. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.mixins.anchors import AnchorsMixin +from edify.builder.mixins.assertions import AssertionsMixin +from edify.builder.mixins.captures import CapturesMixin +from edify.builder.mixins.chain import ChainMixin +from edify.builder.mixins.chars import CharsMixin +from edify.builder.mixins.classes import ClassesMixin +from edify.builder.mixins.flags import FlagsMixin +from edify.builder.mixins.groups import GroupsMixin +from edify.builder.mixins.quantifiers import QuantifiersMixin +from edify.builder.mixins.subexpression import SubexpressionMixin +from edify.builder.mixins.terminals import TerminalsMixin +from edify.builder.types.state import BuilderState + + +class RegexBuilder( + AnchorsMixin, + AssertionsMixin, + CapturesMixin, + ChainMixin, + CharsMixin, + ClassesMixin, + FlagsMixin, + GroupsMixin, + QuantifiersMixin, + SubexpressionMixin, + TerminalsMixin, +): + """A fluent, immutable, strongly-typed regex builder.""" + + _state: BuilderState + + def __init__(self) -> None: + self._state = BuilderState() + + def _with_state(self, new_state: BuilderState) -> Self: + """Return a fresh builder of the same concrete type carrying ``new_state``.""" + builder_class = type(self) + new_instance = builder_class.__new__(builder_class) + new_instance._state = new_state + return new_instance diff --git a/edify/builder/merge.py b/edify/builder/merge.py new file mode 100644 index 0000000..6c70bf4 --- /dev/null +++ b/edify/builder/merge.py @@ -0,0 +1,251 @@ +"""Recursively transform a subexpression's elements when merging into a parent. + +When ``.subexpression(other_builder)`` is invoked, every element from +``other_builder`` is rewritten before it lands in the parent: + +* Numbered back-references shift by the parent's existing capture count. +* Numbered captures count toward an "added" counter so the parent can + increment its own total. +* Named captures and named back-references get prefixed by the merge + namespace (when one is supplied). +* ``StartOfInputElement`` / ``EndOfInputElement`` collapse to + ``NoopElement`` when ``ignore_start_and_end`` is True; otherwise they + raise if the parent already declared the corresponding anchor. + +The transform returns the rewritten element plus the count of capture +groups it introduced (zero for non-capture elements). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from edify.elements.types.base import BaseElement +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import EndOfInputElement, NoopElement, StartOfInputElement +from edify.elements.types.quantifiers import ( + AtLeastElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.errors.anchors import EndInputAlreadyDefinedError, StartInputAlreadyDefinedError + + +@dataclass(frozen=True) +class MergeContext: + """Configuration for a subexpression merge. + + Attributes: + capture_index_offset: Added to every numbered back-reference in the merged elements. + namespace: Prefix prepended to every named-capture and named-back-reference name. + ignore_start_and_end: When True, anchor elements collapse to no-ops. + parent_has_start: True when the parent builder already declared ``start_of_input``. + parent_has_end: True when the parent builder already declared ``end_of_input``. + """ + + capture_index_offset: int + namespace: str + ignore_start_and_end: bool + parent_has_start: bool + parent_has_end: bool + + +@dataclass(frozen=True) +class MergeResult: + """The transformed element plus the count of capture groups it introduced.""" + + element: BaseElement + captures_added: int + + +def merge_element(element: BaseElement, context: MergeContext) -> MergeResult: + """Return the transformed element and the number of capture groups it added. + + Args: + element: The element from the merged-in builder. + context: The merge configuration controlling renames, offsets, and anchor handling. + + Returns: + A :class:`MergeResult` containing the rewritten element and the count + of numbered + named capture groups discovered inside it. + """ + if isinstance(element, BackReferenceElement): + return _merge_back_reference(element, context) + if isinstance(element, NamedBackReferenceElement): + return _merge_named_back_reference(element, context) + if isinstance(element, NamedCaptureElement): + return _merge_named_capture(element, context) + if isinstance(element, CaptureElement): + return _merge_capture(element, context) + if isinstance(element, StartOfInputElement): + return _merge_start_of_input(element, context) + if isinstance(element, EndOfInputElement): + return _merge_end_of_input(element, context) + if isinstance(element, GroupElement): + return _merge_container(element, context, GroupElement) + if isinstance(element, AnyOfElement): + return _merge_container(element, context, AnyOfElement) + if isinstance(element, SubexpressionElement): + return _merge_container(element, context, SubexpressionElement) + if isinstance(element, AssertAheadElement): + return _merge_container(element, context, AssertAheadElement) + if isinstance(element, AssertNotAheadElement): + return _merge_container(element, context, AssertNotAheadElement) + if isinstance(element, AssertBehindElement): + return _merge_container(element, context, AssertBehindElement) + if isinstance(element, AssertNotBehindElement): + return _merge_container(element, context, AssertNotBehindElement) + if isinstance(element, OptionalElement): + return _merge_quantifier(element, context, OptionalElement) + if isinstance(element, ZeroOrMoreElement): + return _merge_quantifier(element, context, ZeroOrMoreElement) + if isinstance(element, ZeroOrMoreLazyElement): + return _merge_quantifier(element, context, ZeroOrMoreLazyElement) + if isinstance(element, OneOrMoreElement): + return _merge_quantifier(element, context, OneOrMoreElement) + if isinstance(element, OneOrMoreLazyElement): + return _merge_quantifier(element, context, OneOrMoreLazyElement) + if isinstance(element, ExactlyElement): + return _merge_exactly(element, context) + if isinstance(element, AtLeastElement): + return _merge_at_least(element, context) + if isinstance(element, BetweenElement): + return _merge_between(element, context) + if isinstance(element, BetweenLazyElement): + return _merge_between_lazy(element, context) + return MergeResult(element=element, captures_added=0) + + +def _merge_back_reference(element: BackReferenceElement, context: MergeContext) -> MergeResult: + """Shift the back-reference index by the parent's existing capture count.""" + shifted_index = element.index + context.capture_index_offset + new_element = BackReferenceElement(index=shifted_index) + return MergeResult(element=new_element, captures_added=0) + + +def _merge_named_back_reference( + element: NamedBackReferenceElement, context: MergeContext +) -> MergeResult: + """Prefix the named back-reference name with the merge namespace.""" + new_name = _apply_namespace(element.name, context.namespace) + new_element = NamedBackReferenceElement(name=new_name) + return MergeResult(element=new_element, captures_added=0) + + +def _merge_named_capture(element: NamedCaptureElement, context: MergeContext) -> MergeResult: + """Prefix the named capture name and recurse into its children.""" + new_name = _apply_namespace(element.name, context.namespace) + merged_children, child_captures_added = _merge_children(element.children, context) + new_element = NamedCaptureElement(name=new_name, children=merged_children) + return MergeResult(element=new_element, captures_added=child_captures_added + 1) + + +def _merge_capture(element: CaptureElement, context: MergeContext) -> MergeResult: + """Recurse into capture children and count this capture toward the added total.""" + merged_children, child_captures_added = _merge_children(element.children, context) + new_element = CaptureElement(children=merged_children) + return MergeResult(element=new_element, captures_added=child_captures_added + 1) + + +def _merge_start_of_input(element: StartOfInputElement, context: MergeContext) -> MergeResult: + """Collapse to no-op when ignoring, else raise if the parent already has a start.""" + if context.ignore_start_and_end: + return MergeResult(element=NoopElement(), captures_added=0) + if context.parent_has_start: + raise StartInputAlreadyDefinedError(in_subexpression=True) + return MergeResult(element=element, captures_added=0) + + +def _merge_end_of_input(element: EndOfInputElement, context: MergeContext) -> MergeResult: + """Collapse to no-op when ignoring, else raise if the parent already has an end.""" + if context.ignore_start_and_end: + return MergeResult(element=NoopElement(), captures_added=0) + if context.parent_has_end: + raise EndInputAlreadyDefinedError(in_subexpression=True) + return MergeResult(element=element, captures_added=0) + + +def _merge_container(element: BaseElement, context: MergeContext, container_class) -> 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: + """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) + return MergeResult(element=new_element, captures_added=child_result.captures_added) + + +def _merge_exactly(element: ExactlyElement, context: MergeContext) -> MergeResult: + """Recurse into an :class:`ExactlyElement`'s child.""" + child_result = merge_element(element.child, context) + new_element = ExactlyElement(times=element.times, child=child_result.element) + return MergeResult(element=new_element, captures_added=child_result.captures_added) + + +def _merge_at_least(element: AtLeastElement, context: MergeContext) -> MergeResult: + """Recurse into an :class:`AtLeastElement`'s child.""" + child_result = merge_element(element.child, context) + new_element = AtLeastElement(times=element.times, child=child_result.element) + return MergeResult(element=new_element, captures_added=child_result.captures_added) + + +def _merge_between(element: BetweenElement, context: MergeContext) -> MergeResult: + """Recurse into a :class:`BetweenElement`'s child.""" + child_result = merge_element(element.child, context) + new_element = BetweenElement( + lower=element.lower, upper=element.upper, child=child_result.element + ) + return MergeResult(element=new_element, captures_added=child_result.captures_added) + + +def _merge_between_lazy(element: BetweenLazyElement, context: MergeContext) -> MergeResult: + """Recurse into a :class:`BetweenLazyElement`'s child.""" + child_result = merge_element(element.child, context) + new_element = BetweenLazyElement( + lower=element.lower, upper=element.upper, child=child_result.element + ) + return MergeResult(element=new_element, captures_added=child_result.captures_added) + + +def _merge_children( + children: tuple[BaseElement, ...], context: MergeContext +) -> tuple[tuple[BaseElement, ...], int]: + """Merge each child and return ``(merged_tuple, total_captures_added)``.""" + merged_list: list[BaseElement] = [] + total_captures_added = 0 + for child in children: + result = merge_element(child, context) + merged_list.append(result.element) + total_captures_added += result.captures_added + return tuple(merged_list), total_captures_added + + +def _apply_namespace(name: str, namespace: str) -> str: + """Prefix ``name`` with ``namespace`` (returns ``name`` unchanged when namespace is empty).""" + if namespace == "": + return name + return f"{namespace}{name}" diff --git a/edify/builder/mixins/__init__.py b/edify/builder/mixins/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/builder/mixins/__init__.py diff --git a/edify/builder/mixins/anchors.py b/edify/builder/mixins/anchors.py new file mode 100644 index 0000000..58b0237 --- /dev/null +++ b/edify/builder/mixins/anchors.py @@ -0,0 +1,40 @@ +"""The :class:`AnchorsMixin` — chain methods for the ``^`` and ``$`` anchors. + +Each anchor may appear at most once per pattern; ``start_of_input`` may +not be added after ``end_of_input``. Violations raise the corresponding +:mod:`edify.errors.anchors` exception. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.leaves import EndOfInputElement, StartOfInputElement +from edify.errors.anchors import ( + CannotDefineStartAfterEndError, + EndInputAlreadyDefinedError, + StartInputAlreadyDefinedError, +) + + +class AnchorsMixin(BuilderProtocol): + """Provides the ``start_of_input`` and ``end_of_input`` chain methods.""" + + def start_of_input(self) -> Self: + """Return a new builder with a leading ``^`` anchor appended.""" + if self._state.has_defined_start: + raise StartInputAlreadyDefinedError() + if self._state.has_defined_end: + raise CannotDefineStartAfterEndError() + state_with_flag = self._state.with_start_defined() + new_state = state_with_flag.with_element_added_to_top(StartOfInputElement()) + return self._with_state(new_state) + + def end_of_input(self) -> Self: + """Return a new builder with a trailing ``$`` anchor appended.""" + if self._state.has_defined_end: + raise EndInputAlreadyDefinedError() + state_with_flag = self._state.with_end_defined() + new_state = state_with_flag.with_element_added_to_top(EndOfInputElement()) + return self._with_state(new_state) diff --git a/edify/builder/mixins/assertions.py b/edify/builder/mixins/assertions.py new file mode 100644 index 0000000..62863e8 --- /dev/null +++ b/edify/builder/mixins/assertions.py @@ -0,0 +1,47 @@ +"""The :class:`AssertionsMixin` — chain methods for lookaround assertions. + +Each method pushes a new frame onto the builder's stack; the frame closes +when the user calls ``.end()`` later. The accumulated children are wrapped +in the appropriate ``Assert*Element`` class at close time. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.frame import StackFrame +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.groups import ( + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, +) + + +class AssertionsMixin(BuilderProtocol): + """Provides the four lookaround-assertion frame-opening chain methods.""" + + def assert_ahead(self) -> Self: + """Return a new builder with a positive-lookahead ``(?=...)`` frame opened.""" + new_frame = StackFrame(type_node=AssertAheadElement()) + new_state = self._state.with_frame_pushed(new_frame) + return self._with_state(new_state) + + def assert_not_ahead(self) -> Self: + """Return a new builder with a negative-lookahead ``(?!...)`` frame opened.""" + new_frame = StackFrame(type_node=AssertNotAheadElement()) + new_state = self._state.with_frame_pushed(new_frame) + return self._with_state(new_state) + + def assert_behind(self) -> Self: + """Return a new builder with a positive-lookbehind ``(?<=...)`` frame opened.""" + new_frame = StackFrame(type_node=AssertBehindElement()) + new_state = self._state.with_frame_pushed(new_frame) + return self._with_state(new_state) + + def assert_not_behind(self) -> Self: + """Return a new builder with a negative-lookbehind ``(?<!...)`` frame opened.""" + new_frame = StackFrame(type_node=AssertNotBehindElement()) + new_state = self._state.with_frame_pushed(new_frame) + return self._with_state(new_state) diff --git a/edify/builder/mixins/captures.py b/edify/builder/mixins/captures.py new file mode 100644 index 0000000..4ba2fec --- /dev/null +++ b/edify/builder/mixins/captures.py @@ -0,0 +1,89 @@ +"""The :class:`CapturesMixin` — chain methods for capture groups and back-references. + +``capture`` and ``named_capture`` push a new frame onto the builder's stack; +the frame closes when the user calls ``.end()`` later. Back-reference +methods validate against the names / indices declared so far and append +their element to the top frame. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.frame import StackFrame +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.errors.captures import InvalidTotalCaptureGroupsIndexError +from edify.errors.input import MustBeAStringError, MustBeOneCharacterError +from edify.errors.naming import ( + CannotCreateDuplicateNamedGroupError, + NamedGroupDoesNotExistError, + NameNotValidError, +) +from edify.validate.names import is_valid_group_name + + +class CapturesMixin(BuilderProtocol): + """Provides capture/named-capture/back-reference chain methods.""" + + def capture(self) -> Self: + """Return a new builder with a numbered-capture frame opened.""" + new_frame = StackFrame(type_node=CaptureElement()) + state_with_frame = self._state.with_frame_pushed(new_frame) + state_with_count = state_with_frame.with_capture_group_count_incremented() + return self._with_state(state_with_count) + + def named_capture(self, name: str) -> Self: + """Return a new builder with a named-capture frame opened under ``name``.""" + _validate_new_named_group(name, self._state.named_groups) + new_frame = StackFrame(type_node=NamedCaptureElement(name=name)) + state_with_frame = self._state.with_frame_pushed(new_frame) + state_with_count = state_with_frame.with_capture_group_count_incremented() + state_with_name = state_with_count.with_named_group_added(name) + return self._with_state(state_with_name) + + def back_reference(self, index: int) -> Self: + """Return a new builder with a numbered back-reference to capture ``index`` appended.""" + _ensure_capture_index_in_range(index, self._state.total_capture_groups) + element = BackReferenceElement(index=index) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + def named_back_reference(self, name: str) -> Self: + """Return a new builder with a named back-reference to ``name`` appended.""" + _ensure_named_group_exists(name, self._state.named_groups) + element = NamedBackReferenceElement(name=name) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + +def _validate_new_named_group(name: object, existing_names: tuple[str, ...]) -> None: + """Raise the appropriate naming error if ``name`` cannot be declared as a new named group.""" + if not isinstance(name, str): + actual_type_name = type(name).__name__ + raise MustBeAStringError("Name", actual_type_name) + if len(name) == 0: + raise MustBeOneCharacterError("Name") + if name in existing_names: + raise CannotCreateDuplicateNamedGroupError(name) + if not is_valid_group_name(name): + raise NameNotValidError(name) + + +def _ensure_capture_index_in_range(index: int, total_capture_groups: int) -> None: + """Raise :class:`InvalidTotalCaptureGroupsIndexError` if ``index`` is out of declared range.""" + if 1 <= index <= total_capture_groups: + return + raise InvalidTotalCaptureGroupsIndexError(index, total_capture_groups) + + +def _ensure_named_group_exists(name: str, declared_names: tuple[str, ...]) -> None: + """Raise :class:`NamedGroupDoesNotExistError` when ``name`` was never declared.""" + if name in declared_names: + return + raise NamedGroupDoesNotExistError(name) diff --git a/edify/builder/mixins/chain.py b/edify/builder/mixins/chain.py new file mode 100644 index 0000000..dd0b5d3 --- /dev/null +++ b/edify/builder/mixins/chain.py @@ -0,0 +1,70 @@ +"""The :class:`ChainMixin` — the ``.end()`` method that closes the top frame. + +Pops the top frame off the stack, constructs the corresponding container +element from the frame's accumulated children, and appends that element to +the new top frame (applying any pending quantifier on the way). +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.frame import StackFrame +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.base import BaseElement +from edify.elements.types.captures import CaptureElement, NamedCaptureElement +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, +) +from edify.errors.internal import UnexpectedFrameTypeError +from edify.errors.structure import CannotEndWhileBuildingRootExpressionError + + +class ChainMixin(BuilderProtocol): + """Provides the ``.end()`` method that closes the current frame.""" + + def end(self) -> Self: + """Return a new builder with the top frame closed and merged into its parent.""" + _ensure_non_root_frame_open(self._state.stack) + state_with_popped, popped_frame = self._state.with_top_frame_popped() + closed_element = _close_frame(popped_frame) + new_state = state_with_popped.with_element_added_to_top(closed_element) + return self._with_state(new_state) + + +def _ensure_non_root_frame_open(stack: tuple[StackFrame, ...]) -> None: + """Raise :class:`CannotEndWhileBuildingRootExpressionError` when the root is the only frame.""" + if len(stack) > 1: + return + raise CannotEndWhileBuildingRootExpressionError() + + +def _close_frame(frame: StackFrame) -> BaseElement: + """Construct the container element that wraps the frame's accumulated children.""" + type_node = frame.type_node + children = frame.children + match type_node: + case CaptureElement(): + return CaptureElement(children=children) + case NamedCaptureElement(name=capture_name): + return NamedCaptureElement(name=capture_name, children=children) + case GroupElement(): + return GroupElement(children=children) + case AnyOfElement(): + return AnyOfElement(children=children) + case AssertAheadElement(): + return AssertAheadElement(children=children) + case AssertNotAheadElement(): + return AssertNotAheadElement(children=children) + case AssertBehindElement(): + return AssertBehindElement(children=children) + case AssertNotBehindElement(): + return AssertNotBehindElement(children=children) + case _: + element_type_name = type(type_node).__name__ + raise UnexpectedFrameTypeError(element_type_name) diff --git a/edify/builder/mixins/chars.py b/edify/builder/mixins/chars.py new file mode 100644 index 0000000..56c1592 --- /dev/null +++ b/edify/builder/mixins/chars.py @@ -0,0 +1,130 @@ +"""The :class:`CharsMixin` — chain methods for character literals and ranges. + +Validates user input, escapes regex metacharacters via +:func:`edify.compile.escape.escape_special`, and appends the resulting +character-shaped element to the top frame's children. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.protocol import BuilderProtocol +from edify.compile.escape import escape_special +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.errors.input import ( + MustBeAStringError, + MustBeOneCharacterError, + MustBeSingleCharacterError, + MustHaveASmallerValueError, +) + + +class CharsMixin(BuilderProtocol): + """Provides chain methods for char/string literals, ranges, and char classes.""" + + def string(self, value: str) -> Self: + """Return a new builder with the literal ``value`` appended.""" + _ensure_is_string("Value", value) + _ensure_non_empty("Value", value) + escaped_value = escape_special(value) + element = _string_or_char_element(escaped_value, source_length=len(value)) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + def char(self, value: str) -> Self: + """Return a new builder with the single-character literal ``value`` appended.""" + _ensure_is_string("Value", value) + _ensure_single_character("Value", value) + escaped_value = escape_special(value) + element = CharElement(value=escaped_value) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + def range(self, start_character: str, end_character: str) -> Self: + """Return a new builder with a ``[start-end]`` range appended.""" + _ensure_single_character("a", start_character) + _ensure_single_character("b", end_character) + _ensure_ascending_codepoints(start_character, end_character) + element = RangeElement(start=start_character, end=end_character) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + def any_of_chars(self, characters: str) -> Self: + """Return a new builder with an inline ``[characters]`` class appended.""" + escaped_characters = escape_special(characters) + element = AnyOfCharsElement(value=escaped_characters) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + def anything_but_string(self, value: str) -> Self: + """Return a new builder with a per-character negation of ``value`` appended.""" + _ensure_is_string("Value", value) + _ensure_non_empty("Value", value) + escaped_value = escape_special(value) + element = AnythingButStringElement(value=escaped_value) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + def anything_but_chars(self, characters: str) -> Self: + """Return a new builder with an inline ``[^characters]`` negation appended.""" + _ensure_is_string("Value", characters) + _ensure_non_empty("Value", characters) + escaped_characters = escape_special(characters) + element = AnythingButCharsElement(value=escaped_characters) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + def anything_but_range(self, start_character: str, end_character: str) -> Self: + """Return a new builder with a ``[^start-end]`` negated range appended.""" + _ensure_single_character("a", start_character) + _ensure_single_character("b", end_character) + _ensure_ascending_codepoints(start_character, end_character) + element = AnythingButRangeElement(start=start_character, end=end_character) + new_state = self._state.with_element_added_to_top(element) + return self._with_state(new_state) + + +def _ensure_is_string(label: str, value: object) -> None: + """Raise :class:`MustBeAStringError` when ``value`` is not a string.""" + if isinstance(value, str): + return + actual_type_name = type(value).__name__ + raise MustBeAStringError(label, actual_type_name) + + +def _ensure_non_empty(label: str, value: str) -> None: + """Raise :class:`MustBeOneCharacterError` when ``value`` has length zero.""" + if len(value) > 0: + return + raise MustBeOneCharacterError(label) + + +def _ensure_single_character(label: str, value: object) -> None: + """Raise :class:`MustBeSingleCharacterError` when ``value`` is not a length-1 string.""" + if isinstance(value, str) and len(value) == 1: + return + actual_type_name = type(value).__name__ + raise MustBeSingleCharacterError(label, actual_type_name) + + +def _ensure_ascending_codepoints(first_character: str, second_character: str) -> None: + """Raise :class:`MustHaveASmallerValueError` when ``first`` does not order before ``second``.""" + if ord(first_character) < ord(second_character): + return + raise MustHaveASmallerValueError(first_character, second_character) + + +def _string_or_char_element(escaped_value: str, source_length: int) -> CharElement | StringElement: + """Pick :class:`CharElement` for single-character inputs, :class:`StringElement` otherwise.""" + if source_length == 1: + return CharElement(value=escaped_value) + return StringElement(value=escaped_value) diff --git a/edify/builder/mixins/classes.py b/edify/builder/mixins/classes.py new file mode 100644 index 0000000..e754bb9 --- /dev/null +++ b/edify/builder/mixins/classes.py @@ -0,0 +1,96 @@ +"""The :class:`ClassesMixin` — chain methods for the built-in character classes. + +Each method appends a single leaf element to the top frame's children. The +state helper takes care of applying any pending quantifier before the +element lands. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.leaves import ( + AnyCharElement, + CarriageReturnElement, + DigitElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NullByteElement, + TabElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) + + +class ClassesMixin(BuilderProtocol): + """Provides chain methods that append a single built-in character class.""" + + def any_char(self) -> Self: + """Return a new builder with a ``.`` (any character) appended.""" + new_state = self._state.with_element_added_to_top(AnyCharElement()) + return self._with_state(new_state) + + def whitespace_char(self) -> Self: + """Return a new builder with a ``\\s`` (whitespace) appended.""" + new_state = self._state.with_element_added_to_top(WhitespaceCharElement()) + return self._with_state(new_state) + + def non_whitespace_char(self) -> Self: + """Return a new builder with a ``\\S`` (non-whitespace) appended.""" + new_state = self._state.with_element_added_to_top(NonWhitespaceCharElement()) + return self._with_state(new_state) + + def digit(self) -> Self: + """Return a new builder with a ``\\d`` (digit) appended.""" + new_state = self._state.with_element_added_to_top(DigitElement()) + return self._with_state(new_state) + + def non_digit(self) -> Self: + """Return a new builder with a ``\\D`` (non-digit) appended.""" + new_state = self._state.with_element_added_to_top(NonDigitElement()) + return self._with_state(new_state) + + def word(self) -> Self: + """Return a new builder with a ``\\w`` (word character) appended.""" + new_state = self._state.with_element_added_to_top(WordElement()) + return self._with_state(new_state) + + def non_word(self) -> Self: + """Return a new builder with a ``\\W`` (non-word character) appended.""" + new_state = self._state.with_element_added_to_top(NonWordElement()) + return self._with_state(new_state) + + def word_boundary(self) -> Self: + """Return a new builder with a ``\\b`` (word boundary) appended.""" + new_state = self._state.with_element_added_to_top(WordBoundaryElement()) + return self._with_state(new_state) + + def non_word_boundary(self) -> Self: + """Return a new builder with a ``\\B`` (non-word-boundary) appended.""" + new_state = self._state.with_element_added_to_top(NonWordBoundaryElement()) + return self._with_state(new_state) + + def new_line(self) -> Self: + """Return a new builder with a ``\\n`` (line feed) appended.""" + new_state = self._state.with_element_added_to_top(NewLineElement()) + return self._with_state(new_state) + + def carriage_return(self) -> Self: + """Return a new builder with a ``\\r`` (carriage return) appended.""" + new_state = self._state.with_element_added_to_top(CarriageReturnElement()) + return self._with_state(new_state) + + def tab(self) -> Self: + """Return a new builder with a ``\\t`` (tab) appended.""" + new_state = self._state.with_element_added_to_top(TabElement()) + return self._with_state(new_state) + + def null_byte(self) -> Self: + """Return a new builder with a ``\\0`` (null byte) appended.""" + new_state = self._state.with_element_added_to_top(NullByteElement()) + return self._with_state(new_state) diff --git a/edify/builder/mixins/flags.py b/edify/builder/mixins/flags.py new file mode 100644 index 0000000..a4443ee --- /dev/null +++ b/edify/builder/mixins/flags.py @@ -0,0 +1,53 @@ +"""The :class:`FlagsMixin` — chain methods that toggle pattern-global regex flags. + +Each method returns a new builder whose flags snapshot has the corresponding +field enabled. Flags are pattern-global — position in the chain does not +matter — so the methods read identically whether placed at the start, the +end, or anywhere in between. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.protocol import BuilderProtocol + + +class FlagsMixin(BuilderProtocol): + """Provides the six flag-toggle chain methods on the builder.""" + + def ascii_only(self) -> Self: + """Return a new builder with the ``re.A`` flag enabled.""" + new_flags = self._state.flags.with_ascii_only() + new_state = self._state.with_flags(new_flags) + return self._with_state(new_state) + + def debug(self) -> Self: + """Return a new builder with the ``re.DEBUG`` flag enabled.""" + new_flags = self._state.flags.with_debug() + new_state = self._state.with_flags(new_flags) + return self._with_state(new_state) + + def ignore_case(self) -> Self: + """Return a new builder with the ``re.I`` flag enabled.""" + new_flags = self._state.flags.with_ignore_case() + new_state = self._state.with_flags(new_flags) + return self._with_state(new_state) + + def multi_line(self) -> Self: + """Return a new builder with the ``re.M`` flag enabled.""" + new_flags = self._state.flags.with_multiline() + new_state = self._state.with_flags(new_flags) + return self._with_state(new_state) + + def dot_all(self) -> Self: + """Return a new builder with the ``re.S`` flag enabled.""" + new_flags = self._state.flags.with_dotall() + new_state = self._state.with_flags(new_flags) + return self._with_state(new_state) + + def verbose(self) -> Self: + """Return a new builder with the ``re.X`` flag enabled.""" + new_flags = self._state.flags.with_verbose() + new_state = self._state.with_flags(new_flags) + return self._with_state(new_state) diff --git a/edify/builder/mixins/groups.py b/edify/builder/mixins/groups.py new file mode 100644 index 0000000..2ee0f14 --- /dev/null +++ b/edify/builder/mixins/groups.py @@ -0,0 +1,30 @@ +"""The :class:`GroupsMixin` — chain methods that open non-capturing groups. + +Both methods push a new frame onto the builder's stack; the frame closes +when the user calls ``.end()`` later. The accumulated children are wrapped +in the appropriate element class at close time. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.frame import StackFrame +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.groups import AnyOfElement, GroupElement + + +class GroupsMixin(BuilderProtocol): + """Provides the ``any_of`` and ``group`` frame-opening chain methods.""" + + def any_of(self) -> Self: + """Return a new builder with an alternation frame opened.""" + new_frame = StackFrame(type_node=AnyOfElement()) + new_state = self._state.with_frame_pushed(new_frame) + return self._with_state(new_state) + + def group(self) -> Self: + """Return a new builder with a non-capturing-group frame opened.""" + new_frame = StackFrame(type_node=GroupElement()) + new_state = self._state.with_frame_pushed(new_frame) + return self._with_state(new_state) diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py new file mode 100644 index 0000000..b485c87 --- /dev/null +++ b/edify/builder/mixins/quantifiers.py @@ -0,0 +1,155 @@ +"""The :class:`QuantifiersMixin` — chain methods that set the pending quantifier. + +A quantifier method does NOT consume the previous element — it stores a +deferred factory on the top frame. The next call that appends an element +sees the pending quantifier and wraps the element with it before storing +the result. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.frame import PendingQuantifier +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.base import BaseElement +from edify.elements.types.quantifiers import ( + AtLeastElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.errors.input import ( + MustBeIntegerGreaterThanZeroError, + MustBeLessThanError, + MustBePositiveIntegerError, +) + + +class QuantifiersMixin(BuilderProtocol): + """Provides the nine quantifier chain methods that set the pending quantifier.""" + + def optional(self) -> Self: + """Return a new builder with ``?`` queued as the pending quantifier.""" + return _set_pending(self, _optional_factory) + + def zero_or_more(self) -> Self: + """Return a new builder with ``*`` queued as the pending quantifier.""" + return _set_pending(self, _zero_or_more_factory) + + def zero_or_more_lazy(self) -> Self: + """Return a new builder with ``*?`` queued as the pending quantifier.""" + return _set_pending(self, _zero_or_more_lazy_factory) + + def one_or_more(self) -> Self: + """Return a new builder with ``+`` queued as the pending quantifier.""" + return _set_pending(self, _one_or_more_factory) + + def one_or_more_lazy(self) -> Self: + """Return a new builder with ``+?`` queued as the pending quantifier.""" + return _set_pending(self, _one_or_more_lazy_factory) + + def exactly(self, count: int) -> Self: + """Return a new builder with ``{count}`` queued as the pending quantifier.""" + _ensure_positive_integer("count", count) + return _set_pending(self, _exactly_factory(count)) + + def at_least(self, count: int) -> Self: + """Return a new builder with ``{count,}`` queued as the pending quantifier.""" + _ensure_positive_integer("count", count) + return _set_pending(self, _at_least_factory(count)) + + def between(self, lower: int, upper: int) -> Self: + """Return a new builder with ``{lower,upper}`` queued as the pending quantifier.""" + _ensure_non_negative_integer("x", lower) + _ensure_positive_integer("y", upper) + _ensure_strictly_ascending("X", "Y", lower, upper) + return _set_pending(self, _between_factory(lower, upper)) + + def between_lazy(self, lower: int, upper: int) -> Self: + """Return a new builder with ``{lower,upper}?`` queued as the pending quantifier.""" + _ensure_non_negative_integer("x", lower) + _ensure_positive_integer("y", upper) + _ensure_strictly_ascending("X", "Y", lower, upper) + return _set_pending(self, _between_lazy_factory(lower, upper)) + + +def _set_pending(builder: BuilderProtocol, pending_quantifier: PendingQuantifier): + """Replace the top frame with one carrying the given pending quantifier.""" + new_top_frame = builder._state.top_frame.with_quantifier(pending_quantifier) + new_state = builder._state.with_top_frame_replaced(new_top_frame) + return builder._with_state(new_state) + + +def _optional_factory(child: BaseElement) -> OptionalElement: + return OptionalElement(child=child) + + +def _zero_or_more_factory(child: BaseElement) -> ZeroOrMoreElement: + return ZeroOrMoreElement(child=child) + + +def _zero_or_more_lazy_factory(child: BaseElement) -> ZeroOrMoreLazyElement: + return ZeroOrMoreLazyElement(child=child) + + +def _one_or_more_factory(child: BaseElement) -> OneOrMoreElement: + return OneOrMoreElement(child=child) + + +def _one_or_more_lazy_factory(child: BaseElement) -> OneOrMoreLazyElement: + return OneOrMoreLazyElement(child=child) + + +def _exactly_factory(count: int) -> PendingQuantifier: + def factory(child: BaseElement) -> ExactlyElement: + return ExactlyElement(times=count, child=child) + + return factory + + +def _at_least_factory(count: int) -> PendingQuantifier: + def factory(child: BaseElement) -> AtLeastElement: + return AtLeastElement(times=count, child=child) + + return factory + + +def _between_factory(lower: int, upper: int) -> PendingQuantifier: + def factory(child: BaseElement) -> BetweenElement: + return BetweenElement(lower=lower, upper=upper, child=child) + + return factory + + +def _between_lazy_factory(lower: int, upper: int) -> PendingQuantifier: + def factory(child: BaseElement) -> BetweenLazyElement: + return BetweenLazyElement(lower=lower, upper=upper, child=child) + + return factory + + +def _ensure_positive_integer(label: str, value: int) -> None: + """Raise :class:`MustBePositiveIntegerError` when ``value`` is not a strictly positive int.""" + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return + raise MustBePositiveIntegerError(label) + + +def _ensure_non_negative_integer(label: str, value: int) -> None: + """Raise :class:`MustBeIntegerGreaterThanZeroError` when ``value`` is not a non-negative int.""" + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return + raise MustBeIntegerGreaterThanZeroError(label) + + +def _ensure_strictly_ascending(lower_label: str, upper_label: str, lower: int, upper: int) -> None: + """Raise :class:`MustBeLessThanError` when ``lower`` is not strictly less than ``upper``.""" + if lower < upper: + return + raise MustBeLessThanError(lower_label, upper_label) diff --git a/edify/builder/mixins/subexpression.py b/edify/builder/mixins/subexpression.py new file mode 100644 index 0000000..2fc0540 --- /dev/null +++ b/edify/builder/mixins/subexpression.py @@ -0,0 +1,98 @@ +"""The :class:`SubexpressionMixin` — merge another builder as a quantifiable atom. + +Validates the incoming expression, transforms its elements through the +namespace / capture-offset / anchor logic in :mod:`edify.builder.merge`, +wraps the merged children in a :class:`SubexpressionElement`, and appends +that to the current frame (applying any pending quantifier). +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.merge import MergeContext, merge_element +from edify.builder.types.protocol import BuilderProtocol +from edify.elements.types.base import BaseElement +from edify.elements.types.groups import SubexpressionElement +from edify.errors.input import MustBeInstanceError +from edify.errors.structure import CannotCallSubexpressionError + + +class SubexpressionMixin(BuilderProtocol): + """Provides the ``.subexpression`` chain method.""" + + def subexpression( + self, + expression: BuilderProtocol, + namespace: str = "", + ignore_flags: bool = True, + ignore_start_and_end: bool = True, + ) -> Self: + """Return a new builder with ``expression`` merged in as a quantifiable atom. + + Args: + expression: A fully-specified builder produced elsewhere (only the + root frame open, no in-progress nested constructs). + namespace: Prefix prepended to every named-capture and named-back- + reference name from ``expression``. + ignore_flags: When True (default), the expression's flag snapshot + is dropped. When False, the parent's flags become the logical + OR of both. + ignore_start_and_end: When True (default), ``start_of_input`` and + ``end_of_input`` markers inside ``expression`` collapse to + no-ops; when False, they merge into the parent (and raise if + the parent already declared the same anchor). + """ + _ensure_is_builder(expression) + _ensure_fully_specified(expression) + merged_root_children, captures_added = _merge_expression_children( + expression, self, namespace, ignore_start_and_end + ) + subexpression_element = SubexpressionElement(children=merged_root_children) + state_with_element = self._state.with_element_added_to_top(subexpression_element) + state_with_counts = state_with_element.with_capture_groups_added(captures_added) + if ignore_flags: + return self._with_state(state_with_counts) + merged_flags = self._state.flags.with_merged(expression._state.flags) + state_with_flags = state_with_counts.with_flags(merged_flags) + return self._with_state(state_with_flags) + + +def _ensure_is_builder(expression: object) -> None: + """Raise :class:`MustBeInstanceError` when ``expression`` is not a builder.""" + if isinstance(expression, BuilderProtocol): + return + actual_type_name = type(expression).__name__ + raise MustBeInstanceError("Expression", actual_type_name, "RegexBuilder") + + +def _ensure_fully_specified(expression: BuilderProtocol) -> None: + """Raise when ``expression`` still has nested frames open beyond the root.""" + if len(expression._state.stack) == 1: + return + top_frame_type_name = type(expression._state.top_frame.type_node).__name__ + raise CannotCallSubexpressionError(top_frame_type_name) + + +def _merge_expression_children( + expression: BuilderProtocol, + parent: BuilderProtocol, + namespace: str, + ignore_start_and_end: bool, +) -> tuple[tuple[BaseElement, ...], int]: + """Run every root child of ``expression`` through the merge transform.""" + context = MergeContext( + capture_index_offset=parent._state.total_capture_groups, + namespace=namespace, + ignore_start_and_end=ignore_start_and_end, + parent_has_start=parent._state.has_defined_start, + parent_has_end=parent._state.has_defined_end, + ) + expression_root_children = expression._state.top_frame.children + merged_list: list[BaseElement] = [] + total_added = 0 + for child in expression_root_children: + result = merge_element(child, context) + merged_list.append(result.element) + total_added += result.captures_added + return tuple(merged_list), total_added diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py new file mode 100644 index 0000000..85b061a --- /dev/null +++ b/edify/builder/mixins/terminals.py @@ -0,0 +1,75 @@ +"""The :class:`TerminalsMixin` — the ``to_regex_string`` and ``to_regex`` terminals. + +Both methods require the builder to be fully specified: every opened frame +must have been closed with ``.end()`` so only the root frame remains. The +string terminal returns exactly the pattern you'd hand to ``re.compile``; +the compiled terminal performs that compilation with the current flag set. +""" + +from __future__ import annotations + +import re + +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.internal import FailedToCompileRegexError +from edify.errors.structure import CannotCallSubexpressionError + +_EMPTY_NON_CAPTURING_GROUP = "(?:)" +_ESCAPED_SPACE = "\\ " +_RAW_SPACE = " " + + +class TerminalsMixin(BuilderProtocol): + """Provides the two pattern-emitting terminal methods on the builder.""" + + def to_regex_string(self) -> str: + """Return the bare regex string the builder describes. + + The output is exactly what would be handed to :func:`re.compile` — + no surrounding ``/.../`` delimiters and no embedded flag suffix. + """ + _ensure_fully_specified(self) + root_element = RootElement(children=self._state.top_frame.children) + rendered_pattern = render_element(root_element) + unescaped_pattern = rendered_pattern.replace(_ESCAPED_SPACE, _RAW_SPACE) + if unescaped_pattern == "": + return _EMPTY_NON_CAPTURING_GROUP + return unescaped_pattern + + def to_regex(self) -> re.Pattern[str]: + """Return a :class:`re.Pattern` compiled from the builder's pattern + flags.""" + pattern_string = self.to_regex_string() + flag_bitmask = _build_flag_bitmask(self._state.flags) + try: + return re.compile(pattern_string, flags=flag_bitmask) + except re.error as compile_error: + raise FailedToCompileRegexError(str(compile_error)) from compile_error + + +def _ensure_fully_specified(builder: BuilderProtocol) -> None: + """Raise :class:`CannotCallSubexpressionError` when frames beyond the root remain open.""" + if len(builder._state.stack) == 1: + return + top_frame_type_name = type(builder._state.top_frame.type_node).__name__ + raise CannotCallSubexpressionError(top_frame_type_name) + + +def _build_flag_bitmask(flags: Flags) -> int: + """Combine the True-valued fields of ``flags`` into a single :mod:`re` bitmask.""" + bitmask = 0 + if flags.ascii_only: + bitmask = bitmask | re.A + if flags.debug: + bitmask = bitmask | re.DEBUG + if flags.ignore_case: + bitmask = bitmask | re.I + if flags.multiline: + bitmask = bitmask | re.M + if flags.dotall: + bitmask = bitmask | re.S + if flags.verbose: + bitmask = bitmask | re.X + return bitmask diff --git a/edify/builder/types/__init__.py b/edify/builder/types/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/builder/types/__init__.py diff --git a/edify/builder/types/flags.py b/edify/builder/types/flags.py new file mode 100644 index 0000000..a77542e --- /dev/null +++ b/edify/builder/types/flags.py @@ -0,0 +1,67 @@ +"""The :class:`Flags` dataclass — the six pattern-global regex flags. + +Each flag corresponds to one of Python's ``re`` flag constants. The builder +toggles them one at a time as the user calls ``.ignore_case()`` etc., and +the terminal step (``to_regex``) translates the True-valued fields into the +combined ``re`` flag bitmask at compile time. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + + +@dataclass(frozen=True) +class Flags: + """Snapshot of the six pattern-global regex flags. + + Attributes: + ascii_only: Maps to ``re.A`` — match ASCII-only character classes. + debug: Maps to ``re.DEBUG`` — print debug information at compile time. + ignore_case: Maps to ``re.I`` — case-insensitive matching. + multiline: Maps to ``re.M`` — ``^``/``$`` match at line boundaries. + dotall: Maps to ``re.S`` — ``.`` matches newlines too. + verbose: Maps to ``re.X`` — allow whitespace and comments in patterns. + """ + + ascii_only: bool = False + debug: bool = False + ignore_case: bool = False + multiline: bool = False + dotall: bool = False + verbose: bool = False + + def with_ascii_only(self) -> Flags: + """Return a new :class:`Flags` with ``ascii_only`` enabled.""" + return replace(self, ascii_only=True) + + def with_debug(self) -> Flags: + """Return a new :class:`Flags` with ``debug`` enabled.""" + return replace(self, debug=True) + + def with_ignore_case(self) -> Flags: + """Return a new :class:`Flags` with ``ignore_case`` enabled.""" + return replace(self, ignore_case=True) + + def with_multiline(self) -> Flags: + """Return a new :class:`Flags` with ``multiline`` enabled.""" + return replace(self, multiline=True) + + def with_dotall(self) -> Flags: + """Return a new :class:`Flags` with ``dotall`` enabled.""" + return replace(self, dotall=True) + + def with_verbose(self) -> Flags: + """Return a new :class:`Flags` with ``verbose`` enabled.""" + return replace(self, verbose=True) + + def with_merged(self, other: Flags) -> Flags: + """Return a new :class:`Flags` whose every field is the logical-or of self and ``other``.""" + return Flags( + ascii_only=self.ascii_only or other.ascii_only, + debug=self.debug or other.debug, + ignore_case=self.ignore_case or other.ignore_case, + multiline=self.multiline or other.multiline, + dotall=self.dotall or other.dotall, + verbose=self.verbose or other.verbose, + ) diff --git a/edify/builder/types/frame.py b/edify/builder/types/frame.py new file mode 100644 index 0000000..071c3ec --- /dev/null +++ b/edify/builder/types/frame.py @@ -0,0 +1,45 @@ +"""The :class:`StackFrame` dataclass — one entry on the builder's frame stack. + +A frame represents an in-progress grouping construct. It carries the element +class that will wrap its children once the frame closes (the ``type_node``), +an optional pending quantifier that will be applied to the next element to +land, and the ordered list of children accumulated so far. + +The whole frame is frozen; chain methods produce new frames with +:func:`dataclasses.replace` rather than mutating in place. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field, replace + +from edify.elements.types.base import BaseElement +from edify.elements.types.union import QuantifierElement + +PendingQuantifier = Callable[[BaseElement], QuantifierElement] + + +@dataclass(frozen=True) +class StackFrame: + """One frame of the builder's open-construct stack. + + Attributes: + type_node: The element that anchors this frame (root, capture, group, etc.). + quantifier: A callable that wraps the next child element in a + quantifier, or ``None`` when no quantifier is pending. + children: The ordered children accumulated in this frame so far. + """ + + type_node: BaseElement + quantifier: PendingQuantifier | None = None + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + def with_appended_child(self, child: BaseElement) -> StackFrame: + """Return a new frame whose ``children`` includes ``child`` at the end.""" + appended_children = (*self.children, child) + return replace(self, children=appended_children, quantifier=None) + + def with_quantifier(self, quantifier: PendingQuantifier) -> StackFrame: + """Return a new frame with the given pending quantifier set.""" + return replace(self, quantifier=quantifier) diff --git a/edify/builder/types/protocol.py b/edify/builder/types/protocol.py new file mode 100644 index 0000000..062ab0a --- /dev/null +++ b/edify/builder/types/protocol.py @@ -0,0 +1,24 @@ +"""The :class:`BuilderProtocol` — the contract every mixin assumes about ``self``. + +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. +""" + +from __future__ import annotations + +from typing import Protocol, Self, runtime_checkable + +from edify.builder.types.state import BuilderState + + +@runtime_checkable +class BuilderProtocol(Protocol): + """The shared shape that every builder mixin assumes about ``self``.""" + + _state: BuilderState + + def _with_state(self, new_state: BuilderState) -> Self: + """Return a new builder carrying the given state, leaving ``self`` untouched.""" + ... diff --git a/edify/builder/types/state.py b/edify/builder/types/state.py new file mode 100644 index 0000000..72ead98 --- /dev/null +++ b/edify/builder/types/state.py @@ -0,0 +1,108 @@ +"""The :class:`BuilderState` dataclass — the entire mutable state of a builder. + +State is frozen; chain methods produce new states with +:func:`dataclasses.replace` rather than mutating in place. That immutability +is what makes ``b0.digit()`` and ``b0.word()`` produce independent chains +from the same starting point. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace + +from edify.builder.types.flags import Flags +from edify.builder.types.frame import StackFrame +from edify.elements.types.base import BaseElement +from edify.elements.types.root import RootElement + + +def _initial_stack() -> tuple[StackFrame, ...]: + """Return the one-element stack a fresh builder starts with.""" + root_frame = StackFrame(type_node=RootElement()) + return (root_frame,) + + +@dataclass(frozen=True) +class BuilderState: + """The full immutable state carried by a builder instance. + + Attributes: + has_defined_start: True once ``start_of_input`` has been added. + has_defined_end: True once ``end_of_input`` has been added. + flags: The current pattern-global flag snapshot. + stack: Ordered frames; the root frame is always at index 0. + named_groups: Names declared by ``named_capture`` so far. + total_capture_groups: Total number of capture groups declared so far + (numbered + named). + """ + + has_defined_start: bool = False + has_defined_end: bool = False + flags: Flags = field(default_factory=Flags) + stack: tuple[StackFrame, ...] = field(default_factory=_initial_stack) + named_groups: tuple[str, ...] = field(default_factory=tuple) + total_capture_groups: int = 0 + + @property + def top_frame(self) -> StackFrame: + """Return the frame currently being built into (the last on the stack).""" + return self.stack[-1] + + def with_start_defined(self) -> BuilderState: + """Return a new state with ``has_defined_start`` set to True.""" + return replace(self, has_defined_start=True) + + def with_end_defined(self) -> BuilderState: + """Return a new state with ``has_defined_end`` set to True.""" + return replace(self, has_defined_end=True) + + def with_flags(self, new_flags: Flags) -> BuilderState: + """Return a new state carrying the given flags snapshot.""" + return replace(self, flags=new_flags) + + def with_top_frame_replaced(self, new_top_frame: StackFrame) -> BuilderState: + """Return a new state whose top frame is replaced by ``new_top_frame``.""" + all_but_top = self.stack[:-1] + new_stack = (*all_but_top, new_top_frame) + return replace(self, stack=new_stack) + + def with_frame_pushed(self, frame: StackFrame) -> BuilderState: + """Return a new state with ``frame`` pushed onto the top of the stack.""" + new_stack = (*self.stack, frame) + return replace(self, stack=new_stack) + + def with_top_frame_popped(self) -> tuple[BuilderState, StackFrame]: + """Return ``(new_state, popped_top_frame)`` after popping the top frame.""" + popped_frame = self.stack[-1] + all_but_top = self.stack[:-1] + new_state = replace(self, stack=all_but_top) + return new_state, popped_frame + + def with_named_group_added(self, name: str) -> BuilderState: + """Return a new state with ``name`` appended to the declared named-groups list.""" + appended = (*self.named_groups, name) + return replace(self, named_groups=appended) + + def with_capture_group_count_incremented(self) -> BuilderState: + """Return a new state with ``total_capture_groups`` incremented by one.""" + incremented = self.total_capture_groups + 1 + return replace(self, total_capture_groups=incremented) + + def with_capture_groups_added(self, count: int) -> BuilderState: + """Return a new state with ``count`` added to ``total_capture_groups``.""" + new_total = self.total_capture_groups + count + return replace(self, total_capture_groups=new_total) + + def with_element_added_to_top(self, element: BaseElement) -> BuilderState: + """Return a new state with ``element`` added to the top frame's children. + + If the top frame carries a pending quantifier, it wraps ``element`` + before appending and the quantifier slot is cleared. + """ + top = self.top_frame + if top.quantifier is not None: + wrapped_element = top.quantifier(element) + new_top_frame = top.with_appended_child(wrapped_element) + else: + new_top_frame = top.with_appended_child(element) + return self.with_top_frame_replaced(new_top_frame) diff --git a/edify/compile/__init__.py b/edify/compile/__init__.py new file mode 100644 index 0000000..b92f5b0 --- /dev/null +++ b/edify/compile/__init__.py @@ -0,0 +1,4 @@ +from edify.compile.dispatch import render_element +from edify.compile.escape import escape_special + +__all__ = ["escape_special", "render_element"] diff --git a/edify/compile/captures.py b/edify/compile/captures.py new file mode 100644 index 0000000..f00711d --- /dev/null +++ b/edify/compile/captures.py @@ -0,0 +1,43 @@ +"""Render capture-group and back-reference elements to their regex string. + +Capture and named-capture render their children recursively, so they take +an :data:`edify.compile.types.ElementRenderer` callable as a parameter +(the dispatcher passes itself). Back-references take no children and render +to a fixed-shape string. +""" + +from __future__ import annotations + +from edify.compile.types import ElementRenderer +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.union import CaptureGroupElement + + +def render_capture_group(element: CaptureGroupElement, render_element: ElementRenderer) -> str: + """Return the regex string produced by a capture or back-reference element. + + Args: + element: One of the four capture-group element classes. + render_element: The dispatcher callable used to render child elements. + + Returns: + The rendered regex fragment. + """ + match element: + case CaptureElement(children=child_elements): + rendered_children = [render_element(child) for child in child_elements] + joined_children = "".join(rendered_children) + return f"({joined_children})" + case NamedCaptureElement(name=capture_name, children=child_elements): + rendered_children = [render_element(child) for child in child_elements] + joined_children = "".join(rendered_children) + return f"(?P<{capture_name}>{joined_children})" + case BackReferenceElement(index=capture_index): + return f"\\{capture_index}" + case NamedBackReferenceElement(name=capture_name): + return f"(?P={capture_name})" diff --git a/edify/compile/chars.py b/edify/compile/chars.py new file mode 100644 index 0000000..f49f126 --- /dev/null +++ b/edify/compile/chars.py @@ -0,0 +1,47 @@ +"""Render character-shaped elements to their regex string. + +Covers single characters, multi-character literals, ranges, character +classes, and their negated variants. Values arrive already escaped from +the builder, so this module never calls into :mod:`edify.compile.escape`. +""" + +from __future__ import annotations + +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.union import CharShapedElement + + +def render_char_shaped(element: CharShapedElement) -> str: + """Return the regex string produced by the given character-shaped element. + + Args: + element: One of the seven char-shaped element classes. + + Returns: + The rendered regex fragment. + """ + match element: + case CharElement(value=character): + return character + case StringElement(value=literal): + return literal + case RangeElement(start=lower_bound, end=upper_bound): + return f"[{lower_bound}-{upper_bound}]" + case AnyOfCharsElement(value=characters): + return f"[{characters}]" + case AnythingButCharsElement(value=characters): + return f"[^{characters}]" + case AnythingButRangeElement(start=lower_bound, end=upper_bound): + return f"[^{lower_bound}-{upper_bound}]" + case AnythingButStringElement(value=literal): + per_character_negations = [f"[^{character}]" for character in literal] + joined_negations = "".join(per_character_negations) + return f"(?:{joined_negations})" diff --git a/edify/compile/dispatch.py b/edify/compile/dispatch.py new file mode 100644 index 0000000..ab91773 --- /dev/null +++ b/edify/compile/dispatch.py @@ -0,0 +1,55 @@ +"""Top-level compile entry — render any :data:`Element` to its regex string. + +Routes the element to the per-family renderer based on which union it +belongs to. The renderers that need to recurse into children receive +:func:`render_element` itself as their renderer argument, keeping the +recursive call site explicit instead of relying on circular imports. +""" + +from __future__ import annotations + +from edify.compile.captures import render_capture_group +from edify.compile.chars import render_char_shaped +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.root import RootElement +from edify.elements.types.union import ( + CaptureGroupElement, + CharShapedElement, + Element, + GroupingElement, + LeafElement, + QuantifierElement, +) +from edify.errors.internal import UnknownElementTypeError + + +def render_element(element: Element) -> str: + """Return the regex string produced by any element. + + Args: + element: Any concrete element class in the :data:`Element` union. + + Returns: + The rendered regex fragment for the element. + + Raises: + UnknownElementTypeError: If ``element`` is not one of the recognised + element kinds (which would indicate an AST built outside the builder). + """ + if isinstance(element, LeafElement): + return render_leaf(element) + if isinstance(element, CharShapedElement): + return render_char_shaped(element) + if isinstance(element, CaptureGroupElement): + return render_capture_group(element, render_element) + if isinstance(element, GroupingElement): + return render_grouping(element, render_element) + if isinstance(element, QuantifierElement): + return render_quantifier(element, render_element) + if isinstance(element, RootElement): + return render_root(element, render_element) + element_type_name = type(element).__name__ + raise UnknownElementTypeError(element_type_name) diff --git a/edify/compile/escape.py b/edify/compile/escape.py new file mode 100644 index 0000000..c82a345 --- /dev/null +++ b/edify/compile/escape.py @@ -0,0 +1,22 @@ +"""Escape user-provided string fragments for safe insertion into a regex pattern. + +Single-purpose wrapper around :func:`re.escape` so the builder never embeds +user input directly into a compiled pattern. +""" + +from __future__ import annotations + +import re + + +def escape_special(value: str) -> str: + """Return ``value`` with all regex metacharacters backslash-escaped. + + Args: + value: A literal fragment supplied by the user. + + Returns: + The fragment with every regex metacharacter escaped, safe to embed + directly into a compiled pattern. + """ + return re.escape(value) diff --git a/edify/compile/fuse.py b/edify/compile/fuse.py new file mode 100644 index 0000000..655ff40 --- /dev/null +++ b/edify/compile/fuse.py @@ -0,0 +1,67 @@ +"""Collapse fusable char-class elements inside an ``any_of`` alternation. + +Inside an :class:`AnyOfElement`, child elements that resolve to a single +character or a character range can collapse into one ``[...]`` bracket +expression. Anything that does not collapse remains in the alternation and +joins via ``|``. This module partitions the children and renders the +combined char-class body. +""" + +from __future__ import annotations + +from edify.elements.types.base import BaseElement +from edify.elements.types.chars import AnyOfCharsElement, CharElement, RangeElement +from edify.errors.internal import NonFusableElementError + + +def fuse_char_class_members( + children: tuple[BaseElement, ...], +) -> tuple[str, tuple[BaseElement, ...]]: + """Split ``children`` into the fused char-class body and the non-fusable tail. + + Args: + children: The ordered child elements of an :class:`AnyOfElement`. + + Returns: + A tuple ``(fused_body, remainder)`` where ``fused_body`` is the + joined char-class fragment (empty string when nothing fuses) and + ``remainder`` is the tuple of children that must be rendered as + alternation arms instead. + """ + fusable_members, remainder = _partition_by_fusability(children) + fragments = [_fragment_for(member) for member in fusable_members] + fused_body = "".join(fragments) + return fused_body, remainder + + +def _is_fusable(member: BaseElement) -> bool: + """Return True when ``member`` can collapse into the shared char-class body.""" + return isinstance(member, CharElement | AnyOfCharsElement | RangeElement) + + +def _partition_by_fusability( + children: tuple[BaseElement, ...], +) -> tuple[tuple[BaseElement, ...], tuple[BaseElement, ...]]: + """Split ``children`` into ``(fusable, non_fusable)`` preserving input order.""" + fusable: list[BaseElement] = [] + non_fusable: list[BaseElement] = [] + for member in children: + if _is_fusable(member): + fusable.append(member) + else: + non_fusable.append(member) + return tuple(fusable), tuple(non_fusable) + + +def _fragment_for(member: BaseElement) -> str: + """Return the char-class fragment produced by a single fusable element.""" + match member: + case CharElement(value=character): + return character + case AnyOfCharsElement(value=characters): + return characters + case RangeElement(start=lower_bound, end=upper_bound): + return f"{lower_bound}-{upper_bound}" + case _: + element_type_name = type(member).__name__ + raise NonFusableElementError(element_type_name) diff --git a/edify/compile/groups.py b/edify/compile/groups.py new file mode 100644 index 0000000..1ea6ad2 --- /dev/null +++ b/edify/compile/groups.py @@ -0,0 +1,88 @@ +"""Render grouping elements to their regex string. + +Covers non-capturing groups, alternation (with char-class fusion), +subexpressions, and the four lookaround assertions. Each renderer recurses +into the element's children via the passed :data:`ElementRenderer` callable. +""" + +from __future__ import annotations + +from edify.compile.fuse import fuse_char_class_members +from edify.compile.types import ElementRenderer +from edify.elements.types.base import BaseElement +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.union import GroupingElement + + +def render_grouping(element: GroupingElement, render_element: ElementRenderer) -> str: + """Return the regex string produced by a grouping element. + + Args: + element: One of the seven grouping element classes. + render_element: The dispatcher callable used to render child elements. + + Returns: + The rendered regex fragment. + """ + match element: + case GroupElement(children=child_elements): + return _render_non_capturing_group(child_elements, render_element) + case SubexpressionElement(children=child_elements): + return _render_subexpression(child_elements, render_element) + case AnyOfElement(children=child_elements): + return _render_alternation(child_elements, render_element) + case AssertAheadElement(children=child_elements): + inner = _render_concatenation(child_elements, render_element) + return f"(?={inner})" + case AssertNotAheadElement(children=child_elements): + inner = _render_concatenation(child_elements, render_element) + return f"(?!{inner})" + case AssertBehindElement(children=child_elements): + inner = _render_concatenation(child_elements, render_element) + return f"(?<={inner})" + case AssertNotBehindElement(children=child_elements): + inner = _render_concatenation(child_elements, render_element) + return f"(?<!{inner})" + + +def _render_concatenation( + children: tuple[BaseElement, ...], render_element: ElementRenderer +) -> str: + """Render a sequence of children and concatenate their fragments.""" + rendered = [render_element(child) for child in children] + return "".join(rendered) + + +def _render_non_capturing_group( + children: tuple[BaseElement, ...], render_element: ElementRenderer +) -> str: + """Wrap the concatenated children in a non-capturing group.""" + inner = _render_concatenation(children, render_element) + return f"(?:{inner})" + + +def _render_subexpression( + children: tuple[BaseElement, ...], render_element: ElementRenderer +) -> str: + """Render a subexpression as its raw concatenated children (no wrapping).""" + return _render_concatenation(children, render_element) + + +def _render_alternation(children: tuple[BaseElement, ...], render_element: ElementRenderer) -> str: + """Render an ``any_of`` element with char-class fusion for simple members.""" + fused_body, remaining_members = fuse_char_class_members(children) + if not remaining_members: + return f"[{fused_body}]" + rendered_remainder = [render_element(member) for member in remaining_members] + joined_remainder = "|".join(rendered_remainder) + if not fused_body: + return f"(?:{joined_remainder})" + return f"(?:{joined_remainder}|[{fused_body}])" diff --git a/edify/compile/leaves.py b/edify/compile/leaves.py new file mode 100644 index 0000000..22102c6 --- /dev/null +++ b/edify/compile/leaves.py @@ -0,0 +1,72 @@ +"""Render leaf elements to their regex string. + +Every leaf in :mod:`edify.elements.types.leaves` maps to a fixed string that +never depends on children or quantifier state, so the renderer is a single +``match`` over the leaf union. +""" + +from __future__ import annotations + +from edify.elements.types.leaves import ( + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.union import LeafElement + + +def render_leaf(element: LeafElement) -> str: + """Return the regex string produced by the given leaf element. + + Args: + element: One of the 16 leaf element classes. + + Returns: + The exact regex fragment (e.g. ``"^"``, ``"\\d"``, ``""`` for noop). + """ + match element: + case StartOfInputElement(): + return "^" + case EndOfInputElement(): + return "$" + case AnyCharElement(): + return "." + case WhitespaceCharElement(): + return "\\s" + case NonWhitespaceCharElement(): + return "\\S" + case DigitElement(): + return "\\d" + case NonDigitElement(): + return "\\D" + case WordElement(): + return "\\w" + case NonWordElement(): + return "\\W" + case WordBoundaryElement(): + return "\\b" + case NonWordBoundaryElement(): + return "\\B" + case NewLineElement(): + return "\\n" + case CarriageReturnElement(): + return "\\r" + case TabElement(): + return "\\t" + case NullByteElement(): + return "\\0" + case NoopElement(): + return "" diff --git a/edify/compile/quantifier/__init__.py b/edify/compile/quantifier/__init__.py new file mode 100644 index 0000000..5a749c9 --- /dev/null +++ b/edify/compile/quantifier/__init__.py @@ -0,0 +1,4 @@ +from edify.compile.quantifier.apply import render_quantifier +from edify.compile.quantifier.suffix import quantifier_suffix + +__all__ = ["quantifier_suffix", "render_quantifier"] diff --git a/edify/compile/quantifier/apply.py b/edify/compile/quantifier/apply.py new file mode 100644 index 0000000..062ab40 --- /dev/null +++ b/edify/compile/quantifier/apply.py @@ -0,0 +1,43 @@ +"""Render a quantifier element by combining its child with its suffix. + +The quantifier suffix itself comes from +:func:`edify.compile.quantifier.suffix.quantifier_suffix`; this module +handles the surrounding logic of rendering the child element and wrapping +it in a non-capturing group when the regex grammar requires it (string +literals and subexpressions need ``(?:...)`` so the quantifier binds to +the whole fragment instead of only the last character). +""" + +from __future__ import annotations + +from edify.compile.quantifier.suffix import quantifier_suffix +from edify.compile.types import ElementRenderer +from edify.elements.types.base import BaseElement +from edify.elements.types.chars import StringElement +from edify.elements.types.groups import SubexpressionElement +from edify.elements.types.union import QuantifierElement + + +def render_quantifier(quantifier: QuantifierElement, render_element: ElementRenderer) -> str: + """Return the regex string produced by a quantifier wrapping its child. + + Args: + quantifier: One of the nine quantifier element classes. + render_element: The dispatcher callable used to render the child element. + + Returns: + The rendered child followed by the quantifier suffix, with the child + wrapped in ``(?:...)`` when its grammar requires grouping. + """ + child_element = quantifier.child + rendered_child = render_element(child_element) + grouped_child = _wrap_in_group_if_required(child_element, rendered_child) + suffix = quantifier_suffix(quantifier) + return f"{grouped_child}{suffix}" + + +def _wrap_in_group_if_required(child_element: BaseElement, rendered_child: str) -> str: + """Return ``rendered_child`` wrapped in ``(?:...)`` when the child kind needs grouping.""" + if isinstance(child_element, StringElement | SubexpressionElement): + return f"(?:{rendered_child})" + return rendered_child diff --git a/edify/compile/quantifier/suffix.py b/edify/compile/quantifier/suffix.py new file mode 100644 index 0000000..aa38811 --- /dev/null +++ b/edify/compile/quantifier/suffix.py @@ -0,0 +1,53 @@ +"""Render the suffix of a quantifier element. + +A quantifier element (``*``, ``+``, ``?``, ``{n}``, ``{n,m}``, plus the +lazy variants) compiles to a suffix string that follows the already-rendered +child fragment. This module owns that suffix translation and nothing else; +the surrounding grouping logic (whether to wrap the child in ``(?:...)``) +is the apply step's responsibility. +""" + +from __future__ import annotations + +from edify.elements.types.quantifiers import ( + AtLeastElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.elements.types.union import QuantifierElement + + +def quantifier_suffix(quantifier: QuantifierElement) -> str: + """Return the regex suffix string for the given quantifier element. + + Args: + quantifier: One of the nine quantifier element classes. + + Returns: + The exact suffix string, e.g. ``"?"``, ``"+?"``, ``"{3}"``, ``"{1,4}?"``. + """ + match quantifier: + case OptionalElement(): + return "?" + case ZeroOrMoreElement(): + return "*" + case ZeroOrMoreLazyElement(): + return "*?" + case OneOrMoreElement(): + return "+" + case OneOrMoreLazyElement(): + return "+?" + case ExactlyElement(times=exact_count): + return f"{{{exact_count}}}" + case AtLeastElement(times=minimum_count): + return f"{{{minimum_count},}}" + case BetweenElement(lower=lower_bound, upper=upper_bound): + return f"{{{lower_bound},{upper_bound}}}" + case BetweenLazyElement(lower=lower_bound, upper=upper_bound): + return f"{{{lower_bound},{upper_bound}}}?" diff --git a/edify/compile/root.py b/edify/compile/root.py new file mode 100644 index 0000000..319d5b9 --- /dev/null +++ b/edify/compile/root.py @@ -0,0 +1,25 @@ +"""Render the top-of-tree :class:`RootElement` to its full regex string. + +The root holds the ordered list of top-level children that make up the +whole pattern. Rendering it is the concatenation of every child's +rendered fragment. +""" + +from __future__ import annotations + +from edify.compile.types import ElementRenderer +from edify.elements.types.root import RootElement + + +def render_root(root: RootElement, render_element: ElementRenderer) -> str: + """Return the regex string produced by the root element's children. + + Args: + root: The top-of-tree element holding every child of the pattern. + render_element: The dispatcher callable used to render each child. + + Returns: + The concatenated regex string for the whole pattern. + """ + rendered_children = [render_element(child) for child in root.children] + return "".join(rendered_children) diff --git a/edify/compile/types.py b/edify/compile/types.py new file mode 100644 index 0000000..56431dd --- /dev/null +++ b/edify/compile/types.py @@ -0,0 +1,9 @@ +"""Type aliases used across the edify compile path.""" + +from __future__ import annotations + +from collections.abc import Callable + +from edify.elements.types.base import BaseElement + +ElementRenderer = Callable[[BaseElement], str] diff --git a/edify/elements/__init__.py b/edify/elements/__init__.py new file mode 100644 index 0000000..4373032 --- /dev/null +++ b/edify/elements/__init__.py @@ -0,0 +1,3 @@ +from edify.elements.types import BaseElement, Element + +__all__ = ["BaseElement", "Element"] diff --git a/edify/elements/types/__init__.py b/edify/elements/types/__init__.py new file mode 100644 index 0000000..cff7726 --- /dev/null +++ b/edify/elements/types/__init__.py @@ -0,0 +1,4 @@ +from edify.elements.types.base import BaseElement +from edify.elements.types.union import Element + +__all__ = ["BaseElement", "Element"] diff --git a/edify/elements/types/base.py b/edify/elements/types/base.py new file mode 100644 index 0000000..9896740 --- /dev/null +++ b/edify/elements/types/base.py @@ -0,0 +1,22 @@ +"""Base class shared by every concrete element in the edify AST. + +Provides a common type that recursive children can reference +(``tuple[BaseElement, ...]`` for capture children, ``BaseElement`` for the +quantifier's single child) without forcing each module to import the full +:data:`edify.elements.types.Element` union — which would form an import +cycle since the union lists every concrete subclass. + +Function signatures that consume or return AST nodes should still use the +precise :data:`Element` union from :mod:`edify.elements.types`; this base +exists only so that the dataclass field annotations type-check without a +circular import. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BaseElement: + """Empty marker base class for every concrete element dataclass.""" diff --git a/edify/elements/types/captures.py b/edify/elements/types/captures.py new file mode 100644 index 0000000..1f585fa --- /dev/null +++ b/edify/elements/types/captures.py @@ -0,0 +1,68 @@ +"""Capture-group and back-reference element classes. + +Capture-group elements collect child elements and emit a parenthesised group +that the underlying ``re`` engine numbers. Back-references re-match what a +capture group already matched, addressed by index (``\\1``) or by name +(``\\k<name>`` on emit, ``(?P=name)`` for the equivalent stdlib form). + +* :class:`CaptureElement` — ``(...)`` numbered capture. +* :class:`NamedCaptureElement` — ``(?P<name>...)`` named capture. +* :class:`BackReferenceElement` — ``\\<index>`` numbered back-reference. +* :class:`NamedBackReferenceElement` — ``(?P=name)`` named back-reference. + +Recursive ``children`` references use :class:`BaseElement` so the dataclass +field annotations type-check without importing the full ``Element`` union +(which would form a cycle). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from edify.elements.types.base import BaseElement + + +@dataclass(frozen=True) +class CaptureElement(BaseElement): + """A numbered capture group containing an ordered list of child elements. + + Attributes: + children: The elements rendered inside the parentheses, in order. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class NamedCaptureElement(BaseElement): + """A named capture group rendered as ``(?P<name>...)``. + + Attributes: + name: The capture-group name (alphanumerics and underscores, leading alpha). + children: The elements rendered inside the parentheses, in order. + """ + + name: str + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class BackReferenceElement(BaseElement): + """A back-reference addressed by capture-group index. + + Attributes: + index: The 1-based capture-group index. + """ + + index: int + + +@dataclass(frozen=True) +class NamedBackReferenceElement(BaseElement): + """A back-reference addressed by capture-group name. + + Attributes: + name: The name of the previously-declared :class:`NamedCaptureElement`. + """ + + name: str diff --git a/edify/elements/types/chars.py b/edify/elements/types/chars.py new file mode 100644 index 0000000..6f7c491 --- /dev/null +++ b/edify/elements/types/chars.py @@ -0,0 +1,108 @@ +"""Character-shaped element classes — single chars, strings, ranges, char classes. + +These elements carry a value (a character, a string, or a pair of range +bounds) and emit the corresponding regex fragment at compile time. + +* :class:`CharElement` — a single literal character. +* :class:`StringElement` — a multi-character literal; needs grouping when + followed by a quantifier. +* :class:`RangeElement` — an inclusive ``[a-z]`` character range. +* :class:`AnyOfCharsElement` — an inline ``[abc]`` character class. +* :class:`AnythingButCharsElement` — an inline ``[^abc]`` negated class. +* :class:`AnythingButRangeElement` — a negated ``[^a-z]`` range. +* :class:`AnythingButStringElement` — a sequence of negated single-character classes. + +Every class inherits from :class:`edify.elements.types.base.BaseElement` so +it participates in the ``Element`` union and in :func:`isinstance` dispatch. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from edify.elements.types.base import BaseElement + + +@dataclass(frozen=True) +class CharElement(BaseElement): + """A single literal character. + + Attributes: + value: The character to match; already escaped for the compile path. + """ + + value: str + + +@dataclass(frozen=True) +class StringElement(BaseElement): + """A multi-character literal string. + + A literal string needs to be wrapped in a non-capturing group when a + quantifier is applied to it, hence the dedicated class separate from + :class:`CharElement`. + + Attributes: + value: The string to match; already escaped for the compile path. + """ + + value: str + + +@dataclass(frozen=True) +class RangeElement(BaseElement): + """An inclusive character range, rendered as ``[<start>-<end>]``. + + Attributes: + start: The lower bound, a single character. + end: The upper bound, a single character. + """ + + start: str + end: str + + +@dataclass(frozen=True) +class AnyOfCharsElement(BaseElement): + """An inline character class containing one or more characters. + + Attributes: + value: The set of characters to match; already escaped for the compile path. + """ + + value: str + + +@dataclass(frozen=True) +class AnythingButCharsElement(BaseElement): + """A negated character class, rendered as ``[^<value>]``. + + Attributes: + value: The set of characters to reject; already escaped for the compile path. + """ + + value: str + + +@dataclass(frozen=True) +class AnythingButRangeElement(BaseElement): + """A negated character range, rendered as ``[^<start>-<end>]``. + + Attributes: + start: The lower bound, a single character. + end: The upper bound, a single character. + """ + + start: str + end: str + + +@dataclass(frozen=True) +class AnythingButStringElement(BaseElement): + """A non-capturing group of per-character negations. + + Attributes: + value: The string whose characters are each negated; already escaped for the compile path. + """ + + value: str diff --git a/edify/elements/types/groups.py b/edify/elements/types/groups.py new file mode 100644 index 0000000..3934b7b --- /dev/null +++ b/edify/elements/types/groups.py @@ -0,0 +1,101 @@ +"""Grouping element classes — non-capturing groups, alternation, lookarounds, subexpressions. + +These elements collect ordered child elements and emit a parenthesised +construct that does not introduce a numbered capture: + +* :class:`GroupElement` — ``(?:...)`` non-capturing group. +* :class:`AnyOfElement` — ``(?:a|b|c)`` (or ``[abc]`` when all members fuse) alternation. +* :class:`SubexpressionElement` — a merged-in pattern fragment treated as a single + unit for quantification purposes. +* :class:`AssertAheadElement` — ``(?=...)`` positive lookahead. +* :class:`AssertNotAheadElement` — ``(?!...)`` negative lookahead. +* :class:`AssertBehindElement` — ``(?<=...)`` positive lookbehind. +* :class:`AssertNotBehindElement` — ``(?<!...)`` negative lookbehind. + +Recursive ``children`` references use :class:`BaseElement` so the dataclass +field annotations type-check without importing the full ``Element`` union. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from edify.elements.types.base import BaseElement + + +@dataclass(frozen=True) +class GroupElement(BaseElement): + """A non-capturing ``(?:...)`` group. + + Attributes: + children: The elements rendered inside the parentheses, in order. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class AnyOfElement(BaseElement): + """An alternation rendered as ``(?:a|b|c)`` (or ``[abc]`` when fully fusable). + + Attributes: + children: The alternative elements, in order. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class SubexpressionElement(BaseElement): + """A merged-in pattern fragment treated as a quantifiable atom. + + Attributes: + children: The elements from the merged pattern, after namespace and + anchor adjustments have been applied. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class AssertAheadElement(BaseElement): + """A positive lookahead ``(?=...)``. + + Attributes: + children: The elements that must match immediately after the current position. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class AssertNotAheadElement(BaseElement): + """A negative lookahead ``(?!...)``. + + Attributes: + children: The elements that must NOT match immediately after the current position. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class AssertBehindElement(BaseElement): + """A positive lookbehind ``(?<=...)``. + + Attributes: + children: The elements that must have matched immediately before the current position. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class AssertNotBehindElement(BaseElement): + """A negative lookbehind ``(?<!...)``. + + Attributes: + children: The elements that must NOT have matched immediately before the current position. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) diff --git a/edify/elements/types/leaves.py b/edify/elements/types/leaves.py new file mode 100644 index 0000000..97ad285 --- /dev/null +++ b/edify/elements/types/leaves.py @@ -0,0 +1,98 @@ +"""Leaf element classes — value-less nodes of the edify AST. + +Each leaf represents a regex construct that emits a fixed string and carries +no parameters: ``\\d`` for :class:`DigitElement`, ``\\b`` for +:class:`WordBoundaryElement`, ``^`` for :class:`StartOfInputElement`, etc. + +Every leaf is :func:`dataclasses.dataclass` with ``frozen=True`` so the whole +AST is immutable; the builder produces new branches by constructing new +elements rather than mutating existing ones. All leaves inherit from +:class:`edify.elements.types.base.BaseElement` so they participate in the +``Element`` union and in :func:`isinstance` dispatch. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from edify.elements.types.base import BaseElement + + +@dataclass(frozen=True) +class StartOfInputElement(BaseElement): + """The ``^`` anchor.""" + + +@dataclass(frozen=True) +class EndOfInputElement(BaseElement): + """The ``$`` anchor.""" + + +@dataclass(frozen=True) +class AnyCharElement(BaseElement): + """The ``.`` wildcard, matching any single character.""" + + +@dataclass(frozen=True) +class WhitespaceCharElement(BaseElement): + """The ``\\s`` character class (whitespace).""" + + +@dataclass(frozen=True) +class NonWhitespaceCharElement(BaseElement): + """The ``\\S`` character class (non-whitespace).""" + + +@dataclass(frozen=True) +class DigitElement(BaseElement): + """The ``\\d`` character class (decimal digit).""" + + +@dataclass(frozen=True) +class NonDigitElement(BaseElement): + """The ``\\D`` character class (non-digit).""" + + +@dataclass(frozen=True) +class WordElement(BaseElement): + """The ``\\w`` character class (word character).""" + + +@dataclass(frozen=True) +class NonWordElement(BaseElement): + """The ``\\W`` character class (non-word character).""" + + +@dataclass(frozen=True) +class WordBoundaryElement(BaseElement): + """The ``\\b`` zero-width assertion at a word boundary.""" + + +@dataclass(frozen=True) +class NonWordBoundaryElement(BaseElement): + """The ``\\B`` zero-width assertion off a word boundary.""" + + +@dataclass(frozen=True) +class NewLineElement(BaseElement): + """The ``\\n`` line-feed character.""" + + +@dataclass(frozen=True) +class CarriageReturnElement(BaseElement): + """The ``\\r`` carriage-return character.""" + + +@dataclass(frozen=True) +class TabElement(BaseElement): + """The ``\\t`` horizontal-tab character.""" + + +@dataclass(frozen=True) +class NullByteElement(BaseElement): + """The ``\\0`` null byte.""" + + +@dataclass(frozen=True) +class NoopElement(BaseElement): + """A no-op marker that emits an empty string in the compile path.""" diff --git a/edify/elements/types/quantifiers.py b/edify/elements/types/quantifiers.py new file mode 100644 index 0000000..28bd122 --- /dev/null +++ b/edify/elements/types/quantifiers.py @@ -0,0 +1,142 @@ +"""Quantifier element classes — operators that repeat a single child element. + +Each quantifier wraps exactly one child element and renders the appropriate +suffix at compile time (``?``, ``*``, ``+``, ``{n}``, ``{n,m}``, plus the +lazy variants). + +Greedy quantifiers: + +* :class:`OptionalElement` — ``?`` (zero or one). +* :class:`ZeroOrMoreElement` — ``*``. +* :class:`OneOrMoreElement` — ``+``. +* :class:`ExactlyElement` — ``{n}``. +* :class:`AtLeastElement` — ``{n,}``. +* :class:`BetweenElement` — ``{lower,upper}``. + +Lazy variants: + +* :class:`ZeroOrMoreLazyElement` — ``*?``. +* :class:`OneOrMoreLazyElement` — ``+?``. +* :class:`BetweenLazyElement` — ``{lower,upper}?``. + +The wrapped ``child`` is annotated as :class:`BaseElement` to avoid forming +a cycle with the full ``Element`` union; the compile path dispatches on the +concrete child type. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from edify.elements.types.base import BaseElement + + +@dataclass(frozen=True) +class OptionalElement(BaseElement): + """Greedy ``?`` quantifier — zero or one match. + + Attributes: + child: The element this quantifier applies to. + """ + + child: BaseElement + + +@dataclass(frozen=True) +class ZeroOrMoreElement(BaseElement): + """Greedy ``*`` quantifier — zero or more matches. + + Attributes: + child: The element this quantifier applies to. + """ + + child: BaseElement + + +@dataclass(frozen=True) +class ZeroOrMoreLazyElement(BaseElement): + """Lazy ``*?`` quantifier — zero or more matches, prefer fewer. + + Attributes: + child: The element this quantifier applies to. + """ + + child: BaseElement + + +@dataclass(frozen=True) +class OneOrMoreElement(BaseElement): + """Greedy ``+`` quantifier — one or more matches. + + Attributes: + child: The element this quantifier applies to. + """ + + child: BaseElement + + +@dataclass(frozen=True) +class OneOrMoreLazyElement(BaseElement): + """Lazy ``+?`` quantifier — one or more matches, prefer fewer. + + Attributes: + child: The element this quantifier applies to. + """ + + child: BaseElement + + +@dataclass(frozen=True) +class ExactlyElement(BaseElement): + """``{times}`` quantifier — exactly ``times`` matches. + + Attributes: + times: The exact number of repetitions required. + child: The element this quantifier applies to. + """ + + times: int + child: BaseElement + + +@dataclass(frozen=True) +class AtLeastElement(BaseElement): + """``{times,}`` quantifier — at least ``times`` matches. + + Attributes: + times: The minimum number of repetitions required. + child: The element this quantifier applies to. + """ + + times: int + child: BaseElement + + +@dataclass(frozen=True) +class BetweenElement(BaseElement): + """``{lower,upper}`` greedy quantifier — between ``lower`` and ``upper`` matches. + + Attributes: + lower: The minimum number of repetitions required. + upper: The maximum number of repetitions allowed. + child: The element this quantifier applies to. + """ + + lower: int + upper: int + child: BaseElement + + +@dataclass(frozen=True) +class BetweenLazyElement(BaseElement): + """``{lower,upper}?`` lazy quantifier — between ``lower`` and ``upper`` matches, prefer fewer. + + Attributes: + lower: The minimum number of repetitions required. + upper: The maximum number of repetitions allowed. + child: The element this quantifier applies to. + """ + + lower: int + upper: int + child: BaseElement diff --git a/edify/elements/types/root.py b/edify/elements/types/root.py new file mode 100644 index 0000000..5abbd3c --- /dev/null +++ b/edify/elements/types/root.py @@ -0,0 +1,25 @@ +"""Root element — the top of every compiled edify AST. + +A :class:`RootElement` wraps the entire ordered list of top-level children +that make up a pattern. The builder starts with an empty root and appends +to it as the user chains methods; the compile path receives a single +:class:`RootElement` and concatenates the rendered fragments of each child. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from edify.elements.types.base import BaseElement + + +@dataclass(frozen=True) +class RootElement(BaseElement): + """The wrapper around every top-level child element of a pattern. + + Attributes: + children: The top-level elements rendered in order to produce the + final regex string. + """ + + children: tuple[BaseElement, ...] = field(default_factory=tuple) diff --git a/edify/elements/types/union.py b/edify/elements/types/union.py new file mode 100644 index 0000000..bff76da --- /dev/null +++ b/edify/elements/types/union.py @@ -0,0 +1,159 @@ +"""The sealed :data:`Element` union — every concrete element class spelled out. + +Function signatures that consume or return AST nodes use this union so the +type checker enforces that every reachable case is one of the listed +classes. Internal recursive references inside the dataclass definitions use +:class:`edify.elements.types.base.BaseElement` to avoid forming an import +cycle with this module. +""" + +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 ( + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.elements.types.root import RootElement + +LeafElement = ( + StartOfInputElement + | EndOfInputElement + | AnyCharElement + | WhitespaceCharElement + | NonWhitespaceCharElement + | DigitElement + | NonDigitElement + | WordElement + | NonWordElement + | WordBoundaryElement + | NonWordBoundaryElement + | NewLineElement + | CarriageReturnElement + | TabElement + | NullByteElement + | NoopElement +) + +CharShapedElement = ( + CharElement + | StringElement + | RangeElement + | AnyOfCharsElement + | AnythingButCharsElement + | AnythingButRangeElement + | AnythingButStringElement +) + +CaptureGroupElement = ( + CaptureElement | NamedCaptureElement | BackReferenceElement | NamedBackReferenceElement +) + +GroupingElement = ( + GroupElement + | AnyOfElement + | SubexpressionElement + | AssertAheadElement + | AssertNotAheadElement + | AssertBehindElement + | AssertNotBehindElement +) + +QuantifierElement = ( + OptionalElement + | ZeroOrMoreElement + | ZeroOrMoreLazyElement + | OneOrMoreElement + | OneOrMoreLazyElement + | ExactlyElement + | AtLeastElement + | BetweenElement + | BetweenLazyElement +) + +Element = ( + StartOfInputElement + | EndOfInputElement + | AnyCharElement + | WhitespaceCharElement + | NonWhitespaceCharElement + | DigitElement + | NonDigitElement + | WordElement + | NonWordElement + | WordBoundaryElement + | NonWordBoundaryElement + | NewLineElement + | CarriageReturnElement + | TabElement + | NullByteElement + | NoopElement + | CharElement + | StringElement + | RangeElement + | AnyOfCharsElement + | AnythingButCharsElement + | AnythingButRangeElement + | AnythingButStringElement + | CaptureElement + | NamedCaptureElement + | BackReferenceElement + | NamedBackReferenceElement + | GroupElement + | AnyOfElement + | SubexpressionElement + | AssertAheadElement + | AssertNotAheadElement + | AssertBehindElement + | AssertNotBehindElement + | QuantifierElement + | RootElement +) diff --git a/edify/errors/__init__.py b/edify/errors/__init__.py new file mode 100644 index 0000000..3d77171 --- /dev/null +++ b/edify/errors/__init__.py @@ -0,0 +1,4 @@ +from edify.errors.base import EdifyError +from edify.errors.syntax import EdifySyntaxError + +__all__ = ["EdifyError", "EdifySyntaxError"] diff --git a/edify/errors/anchors.py b/edify/errors/anchors.py new file mode 100644 index 0000000..ce3507f --- /dev/null +++ b/edify/errors/anchors.py @@ -0,0 +1,45 @@ +"""Exception classes raised when start/end-of-input anchors are misused. + +Each anchor (``start_of_input``, ``end_of_input``) may be declared at most +once per pattern, and ``start_of_input`` may not be added after +``end_of_input``. When a subexpression carries its own anchors and merges +into a parent that already has the corresponding anchor, the raised error +also points at the ``ignore_start_and_end`` opt-out. +""" + +from __future__ import annotations + +from edify.errors.syntax import EdifySyntaxError + +_IGNORE_START_AND_END_HINT = ( + " You can ignore a subexpression's start_of_input/end_of_input markers " + "with the ignore_start_and_end option" +) + + +class StartInputAlreadyDefinedError(EdifySyntaxError): + """Raised when ``start_of_input`` is added to a pattern that already has one.""" + + def __init__(self, in_subexpression: bool = False) -> None: + message = "Regex already has a start of input." + if in_subexpression: + message = message + _IGNORE_START_AND_END_HINT + super().__init__(message) + + +class CannotDefineStartAfterEndError(EdifySyntaxError): + """Raised when ``start_of_input`` is added after ``end_of_input``.""" + + def __init__(self) -> None: + message = "Can not define a start of input after defining an end of input." + super().__init__(message) + + +class EndInputAlreadyDefinedError(EdifySyntaxError): + """Raised when ``end_of_input`` is added to a pattern that already has one.""" + + def __init__(self, in_subexpression: bool = False) -> None: + message = "Regex already has an end of input." + if in_subexpression: + message = message + _IGNORE_START_AND_END_HINT + super().__init__(message) diff --git a/edify/errors/base.py b/edify/errors/base.py new file mode 100644 index 0000000..7e7f67b --- /dev/null +++ b/edify/errors/base.py @@ -0,0 +1,12 @@ +"""Base exception class for every error edify raises. + +Subclassing :class:`Exception` keeps any existing ``except Exception`` blocks +working — the typed hierarchy is additive. Catch :class:`EdifyError` to react +to any error the library produces without binding to the narrower subclasses. +""" + +from __future__ import annotations + + +class EdifyError(Exception): + """Base class for every exception edify raises.""" diff --git a/edify/errors/captures.py b/edify/errors/captures.py new file mode 100644 index 0000000..a18674d --- /dev/null +++ b/edify/errors/captures.py @@ -0,0 +1,18 @@ +"""Exception class raised for invalid capture-group references. + +``back_reference(index)`` accepts a 1-based index that must already exist on +the builder. Passing an out-of-range index raises this error rather than +silently emitting a broken regex. +""" + +from __future__ import annotations + +from edify.errors.syntax import EdifySyntaxError + + +class InvalidTotalCaptureGroupsIndexError(EdifySyntaxError): + """Raised when ``back_reference(index)`` is given an index out of range.""" + + def __init__(self, index: int, total_capture_groups: int) -> None: + message = f"Invalid index #{index}. There are only {total_capture_groups} capture groups." + super().__init__(message) diff --git a/edify/errors/input.py b/edify/errors/input.py new file mode 100644 index 0000000..76d9fcb --- /dev/null +++ b/edify/errors/input.py @@ -0,0 +1,80 @@ +"""Exception classes raised when a builder method receives invalid input. + +Each class is a thin :class:`EdifySyntaxError` subclass whose ``__init__`` +formats the message internally. Classes that report what was passed take +the already-extracted type name as a string (``type(value).__name__``) so +the exception surface stays strongly typed end-to-end. +""" + +from __future__ import annotations + +from edify.errors.syntax import EdifySyntaxError + + +class MustBeAStringError(EdifySyntaxError): + """Raised when a builder argument is required to be a string but is not.""" + + def __init__(self, label: str, actual_type_name: str) -> None: + message = f"{label} must be a string. (got {actual_type_name})" + super().__init__(message) + + +class MustBeOneCharacterError(EdifySyntaxError): + """Raised when a builder argument must have a length of at least one character.""" + + def __init__(self, label: str) -> None: + message = f"{label} must be one character long." + super().__init__(message) + + +class MustBeSingleCharacterError(EdifySyntaxError): + """Raised when a builder argument must be exactly one character long.""" + + def __init__(self, label: str, actual_type_name: str) -> None: + message = f"{label} must be a single character. (got {actual_type_name})" + super().__init__(message) + + +class MustBePositiveIntegerError(EdifySyntaxError): + """Raised when a builder argument must be a positive integer (``> 0``).""" + + def __init__(self, label: str) -> None: + message = f"{label} must be a positive integer." + super().__init__(message) + + +class MustBeIntegerGreaterThanZeroError(EdifySyntaxError): + """Raised when a builder argument must be an integer strictly greater than zero.""" + + def __init__(self, label: str) -> None: + message = f"{label} must be an integer greater than zero." + super().__init__(message) + + +class MustBeInstanceError(EdifySyntaxError): + """Raised when a builder argument must be an instance of a specific class.""" + + def __init__(self, label: str, actual_type_name: str, expected_class_name: str) -> None: + message = f"{label} must be an instance of {expected_class_name}. (got {actual_type_name})" + super().__init__(message) + + +class MustHaveASmallerValueError(EdifySyntaxError): + """Raised when the first character argument must order before the second.""" + + def __init__(self, first: str, second: str) -> None: + first_codepoint = ord(first) + second_codepoint = ord(second) + message = ( + f"{first} must have a smaller character value than {second}. " + f"(a = {first_codepoint}, b = {second_codepoint})" + ) + super().__init__(message) + + +class MustBeLessThanError(EdifySyntaxError): + """Raised when a numeric argument must order strictly before another.""" + + def __init__(self, first_label: str, second_label: str) -> None: + message = f"{first_label} must be less than {second_label}." + super().__init__(message) diff --git a/edify/errors/internal.py b/edify/errors/internal.py new file mode 100644 index 0000000..7484e47 --- /dev/null +++ b/edify/errors/internal.py @@ -0,0 +1,42 @@ +"""Exception classes for internal-consistency violations inside edify. + +These errors should never fire under correct builder use; they exist so that +unrecognised AST shapes raise loudly with a specific class instead of slipping +through with a generic message. +""" + +from __future__ import annotations + +from edify.errors.syntax import EdifySyntaxError + + +class UnknownElementTypeError(EdifySyntaxError): + """Raised when the compile dispatcher receives an element class it does not recognise.""" + + def __init__(self, element_type_name: str) -> None: + message = f"Cannot render unknown element type {element_type_name}" + super().__init__(message) + + +class NonFusableElementError(EdifySyntaxError): + """Raised when the char-class fuser is handed an element it cannot fuse.""" + + def __init__(self, element_type_name: str) -> None: + message = f"Cannot fuse element of type {element_type_name} into a char class" + super().__init__(message) + + +class UnexpectedFrameTypeError(EdifySyntaxError): + """Raised when a stack frame's anchor element is not a recognised container kind.""" + + def __init__(self, element_type_name: str) -> None: + message = f"Stack frame anchored at unexpected element type {element_type_name}" + super().__init__(message) + + +class FailedToCompileRegexError(EdifySyntaxError): + """Raised when the underlying ``re`` engine rejects the emitted pattern.""" + + def __init__(self, underlying_error_message: str) -> None: + message = f"Cannot compile regex: {underlying_error_message}" + super().__init__(message) diff --git a/edify/errors/naming.py b/edify/errors/naming.py new file mode 100644 index 0000000..6897552 --- /dev/null +++ b/edify/errors/naming.py @@ -0,0 +1,37 @@ +"""Exception classes raised when named-group operations are misused. + +Named groups (``named_capture``, ``named_back_reference``) require a unique, +valid identifier for each name. These classes surface the three failure +modes: duplicate name, invalid name shape, and reference to a name that +was never declared. +""" + +from __future__ import annotations + +from edify.errors.syntax import EdifySyntaxError + + +class NameNotValidError(EdifySyntaxError): + """Raised when a named-group name fails the identifier shape check.""" + + def __init__(self, name: str) -> None: + message = ( + f"Name {name} is not valid. (only alphanumeric characters and underscores are allowed)" + ) + super().__init__(message) + + +class CannotCreateDuplicateNamedGroupError(EdifySyntaxError): + """Raised when ``named_capture`` is called with a name already registered on the builder.""" + + def __init__(self, name: str) -> None: + message = f'Can not create duplicate named group "{name}".' + super().__init__(message) + + +class NamedGroupDoesNotExistError(EdifySyntaxError): + """Raised when ``named_back_reference`` references a name that was never declared.""" + + def __init__(self, name: str) -> None: + message = f'Named group "{name}" does not exist (create one with .named_capture()).' + super().__init__(message) diff --git a/edify/errors/structure.py b/edify/errors/structure.py new file mode 100644 index 0000000..20689b7 --- /dev/null +++ b/edify/errors/structure.py @@ -0,0 +1,28 @@ +"""Exception classes raised for builder-frame structural errors. + +These cover frame-stack misuse: closing the root frame, or calling +``subexpression`` on a builder whose own chain still has an open frame. +""" + +from __future__ import annotations + +from edify.errors.syntax import EdifySyntaxError + + +class CannotEndWhileBuildingRootExpressionError(EdifySyntaxError): + """Raised when ``.end()`` is called on a builder whose only open frame is the root.""" + + def __init__(self) -> None: + message = "Can not end while building the root expression." + super().__init__(message) + + +class CannotCallSubexpressionError(EdifySyntaxError): + """Raised when ``.subexpression(expression)`` is given an expression with open frames.""" + + def __init__(self, current_frame_type: str) -> None: + message = ( + "Can not call subexpression on a not yet fully specified regex object. " + f"(Try adding a .end() call to match the {current_frame_type} on the subexpression)" + ) + super().__init__(message) diff --git a/edify/errors/syntax.py b/edify/errors/syntax.py new file mode 100644 index 0000000..99cfb65 --- /dev/null +++ b/edify/errors/syntax.py @@ -0,0 +1,20 @@ +"""Syntax error class for malformed builder chains and invalid builder input. + +Raised when: + +* a quantifier is applied with no operand (e.g. ``RegexBuilder().exactly(3).to_regex()``), +* a quantifier is stacked on another quantifier, +* a builder method receives an argument of the wrong type or shape, +* a frame is closed that was never opened. + +Inherits from :class:`edify.errors.base.EdifyError` so any ``except EdifyError`` +clause catches it too. +""" + +from __future__ import annotations + +from edify.errors.base import EdifyError + + +class EdifySyntaxError(EdifyError): + """Raised when a builder chain is malformed or a builder method receives invalid input.""" diff --git a/edify/library/__init__.py b/edify/library/__init__.py new file mode 100644 index 0000000..eb7e9a5 --- /dev/null +++ b/edify/library/__init__.py @@ -0,0 +1,31 @@ +from edify.library.date.basic import date +from edify.library.date.iso import iso_date +from edify.library.email.basic import email +from edify.library.email.strict import email_rfc_5322 +from edify.library.guid import guid +from edify.library.ip.v4 import ipv4 +from edify.library.ip.v6 import ipv6 +from edify.library.mac import mac +from edify.library.password import password +from edify.library.phone import phone_number +from edify.library.ssn import ssn +from edify.library.url import url +from edify.library.uuid import uuid +from edify.library.zip import zip + +__all__ = [ + "date", + "email", + "email_rfc_5322", + "guid", + "ipv4", + "ipv6", + "iso_date", + "mac", + "password", + "phone_number", + "ssn", + "url", + "uuid", + "zip", +] diff --git a/edify/library/_support/__init__.py b/edify/library/_support/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/library/_support/__init__.py diff --git a/src/edify/library/support/zip.py b/edify/library/_support/zip.py index c8c37d6..9695a9d 100644 --- a/src/edify/library/support/zip.py +++ b/edify/library/_support/zip.py @@ -101,11 +101,15 @@ ZIP_LOCALES = [ {"abbrev": "HK", "name": "Hong Kong"}, {"abbrev": "HU", "name": "Hungary", "zip": "[0-9]{4}"}, {"abbrev": "IS", "name": "Iceland", "zip": "[0-9]{3}"}, - {"abbrev": "IN", "name": "India", "zip": "^(?!0{1})\d{6}$"}, + {"abbrev": "IN", "name": "India", "zip": "^(?!0{1})\\d{6}$"}, {"abbrev": "ID", "name": "Indonesia", "zip": "[0-9]{5}"}, {"abbrev": "IR", "name": "Iran", "zip": "[0-9]{5}"}, {"abbrev": "IQ", "name": "Iraq", "zip": "[0-9]{5}"}, - {"abbrev": "IE", "name": "Ireland, Republic of", "zip": "(?:^[AC-FHKNPRTV-Y][0-9]{2}|D6W)[ -]?[0-9AC-FHKNPRTV-Y]{4}$"}, + { + "abbrev": "IE", + "name": "Ireland, Republic of", + "zip": "(?:^[AC-FHKNPRTV-Y][0-9]{2}|D6W)[ -]?[0-9AC-FHKNPRTV-Y]{4}$", + }, {"abbrev": "FK", "name": "Islas Malvinas", "zip": "FIQQ 1ZZ"}, {"abbrev": "IL", "name": "Israel", "zip": "[0-9]{5}|[0-9]{7}"}, {"abbrev": "IT", "name": "Italy", "zip": "[0-9]{5}"}, @@ -143,7 +147,12 @@ ZIP_LOCALES = [ {"abbrev": "MV", "name": "Maldives", "zip": "[0-9]{5}"}, {"abbrev": "ML", "name": "Mali"}, {"abbrev": "MT", "name": "Malta", "zip": "[A-Z]{3} [0-9]{4}"}, - {"abbrev": "MH", "name": "Marshall Islands", "zip": "\\d{5}(?:[-\\s]\\d{4})?", "range": ["96960", "96970"]}, + { + "abbrev": "MH", + "name": "Marshall Islands", + "zip": "\\d{5}(?:[-\\s]\\d{4})?", + "range": ["96960", "96970"], + }, {"abbrev": "MQ", "name": "Martinique", "zip": "972[0-9]{2}", "range": ["97200", "97290"]}, {"abbrev": "MR", "name": "Mauritania"}, {"abbrev": "MU", "name": "Mauritius", "zip": "[0-9]{5}"}, @@ -153,7 +162,12 @@ ZIP_LOCALES = [ {"abbrev": "MC", "name": "Monaco", "zip": "980[0-9]{2}"}, {"abbrev": "MN", "name": "Mongolia", "zip": "[0-9]{5}"}, {"abbrev": "ME", "name": "Montenegro", "zip": "[0-9]{5}"}, - {"abbrev": "MS", "name": "Montserrat", "zip": "MSR [0-9]{4}", "range": ["MSR 1110", "MSR 1350"]}, + { + "abbrev": "MS", + "name": "Montserrat", + "zip": "MSR [0-9]{4}", + "range": ["MSR 1110", "MSR 1350"], + }, {"abbrev": "MA", "name": "Morocco", "zip": "[0-9]{5}"}, {"abbrev": "MZ", "name": "Mozambique", "zip": "[0-9]{4}"}, {"abbrev": "MM", "name": "Myanmar", "zip": "[0-9]{5}"}, @@ -246,7 +260,12 @@ ZIP_LOCALES = [ {"abbrev": "VE", "name": "Venezuela", "zip": "[0-9]{4}(-[A-Z]{1})?"}, {"abbrev": "VN", "name": "Vietnam", "zip": "[0-9]{6}"}, {"abbrev": "VG", "name": "Virgin Islands (British)", "zip": "VG[0-9]{4}"}, - {"abbrev": "VI", "name": "Virgin Islands (US)", "range": ["00801", "00851"], "zip": "\\d{5}(?:[-\\s]\\d{4})?"}, + { + "abbrev": "VI", + "name": "Virgin Islands (US)", + "range": ["00801", "00851"], + "zip": "\\d{5}(?:[-\\s]\\d{4})?", + }, {"abbrev": "YE", "name": "Yemen"}, {"abbrev": "ZM", "name": "Zambia", "zip": "[0-9]{5}"}, {"abbrev": "ZW", "name": "Zimbabwe"}, diff --git a/edify/library/date/__init__.py b/edify/library/date/__init__.py new file mode 100644 index 0000000..ea823e5 --- /dev/null +++ b/edify/library/date/__init__.py @@ -0,0 +1,4 @@ +from edify.library.date.basic import date +from edify.library.date.iso import iso_date + +__all__ = ["date", "iso_date"] diff --git a/edify/library/date/basic.py b/edify/library/date/basic.py new file mode 100644 index 0000000..7365917 --- /dev/null +++ b/edify/library/date/basic.py @@ -0,0 +1,23 @@ +"""Basic date-shape validator. + +Validates the ``M/D/YYYY`` or ``MM/DD/YYYY`` shape. Shape-only — does not +verify calendar correctness (Feb 30 passes). +""" + +from __future__ import annotations + +import re + +_DATE_PATTERN = re.compile(r"^[0-9]{1,2}/[0-9]{1,2}/[0-9]{4}$") + + +def date(value: str) -> bool: + """Return True when ``value`` matches the slash-separated date shape. + + Args: + value: The string to check. + + Returns: + True for valid ``M/D/YYYY`` shapes; False otherwise. + """ + return _DATE_PATTERN.match(value) is not None diff --git a/edify/library/date/iso.py b/edify/library/date/iso.py new file mode 100644 index 0000000..ecddb71 --- /dev/null +++ b/edify/library/date/iso.py @@ -0,0 +1,26 @@ +"""ISO-8601 date-time shape validator. + +Validates the ``YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]`` shape used by ISO +8601 timestamps. Shape-only — does not verify calendar correctness. +""" + +from __future__ import annotations + +import re + +_ISO_DATE_PATTERN = re.compile( + r"^(?:\d{4})-(?:\d{2})-(?:\d{2})T(?:\d{2}):(?:\d{2}):" + r"(?:\d{2}(?:\.\d*)?)(?:(?:-(?:\d{2}):(?:\d{2})|Z)?)$" +) + + +def iso_date(value: str) -> bool: + """Return True when ``value`` matches the ISO 8601 date-time shape. + + Args: + value: The string to check. + + Returns: + True for valid ISO 8601 date-time strings; False otherwise. + """ + return _ISO_DATE_PATTERN.match(value) is not None diff --git a/edify/library/email/__init__.py b/edify/library/email/__init__.py new file mode 100644 index 0000000..d1c665c --- /dev/null +++ b/edify/library/email/__init__.py @@ -0,0 +1,4 @@ +from edify.library.email.basic import email +from edify.library.email.strict import email_rfc_5322 + +__all__ = ["email", "email_rfc_5322"] diff --git a/edify/library/email/basic.py b/edify/library/email/basic.py new file mode 100644 index 0000000..eb0fbd9 --- /dev/null +++ b/edify/library/email/basic.py @@ -0,0 +1,26 @@ +"""Basic email-shape validator. + +Validates the common email shape used in most application UX (local-part, +``@``, domain with at least one dot). Lower-case only. +""" + +from __future__ import annotations + +import re + +_EMAIL_PATTERN = re.compile( + r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*" + r"@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" +) + + +def email(value: str) -> bool: + """Return True when ``value`` matches the common email shape. + + Args: + value: The string to check. + + Returns: + True for valid basic-shape email addresses; False otherwise. + """ + return _EMAIL_PATTERN.match(value) is not None diff --git a/edify/library/email/strict.py b/edify/library/email/strict.py new file mode 100644 index 0000000..a04622e --- /dev/null +++ b/edify/library/email/strict.py @@ -0,0 +1,33 @@ +"""RFC 5322 strict-email-shape validator. + +Validates the full RFC 5322 mailbox shape — quoted-string local parts, +IP-literal domains, and the related corner cases. Use this when the +permissive :func:`edify.library.email.basic.email` is too lax. +""" + +from __future__ import annotations + +import re + +_EMAIL_RFC_5322_PATTERN = re.compile( + r"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|" + r"\"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|" + r"\\[\x01-\x09\x0b\x0c\x0e-\x7f])*\")" + r"@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|" + r"\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}" + r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:" + r"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|" + r"\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])" +) + + +def email_rfc_5322(value: str) -> bool: + """Return True when ``value`` matches the RFC 5322 mailbox shape. + + Args: + value: The string to check. + + Returns: + True for valid RFC 5322 mailboxes; False otherwise. + """ + return _EMAIL_RFC_5322_PATTERN.match(value) is not None diff --git a/edify/library/guid.py b/edify/library/guid.py new file mode 100644 index 0000000..5f68536 --- /dev/null +++ b/edify/library/guid.py @@ -0,0 +1,27 @@ +"""GUID-shape validator. + +Validates the Microsoft-flavoured GUID form: the 8-4-4-4-12 hex shape, +optionally wrapped in braces, with either case. +""" + +from __future__ import annotations + +import re + +_GUID_PATTERN = re.compile( + r"^(?:\{?(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-" + r"(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}?)$" +) + + +def guid(value: str) -> bool: + """Return True when ``value`` matches the Microsoft-flavoured GUID shape. + + Args: + value: The string to check. + + Returns: + True for valid braced or bare 8-4-4-4-12 hex strings (either case); + False otherwise. + """ + return _GUID_PATTERN.match(value) is not None diff --git a/edify/library/ip/__init__.py b/edify/library/ip/__init__.py new file mode 100644 index 0000000..28e17cb --- /dev/null +++ b/edify/library/ip/__init__.py @@ -0,0 +1,4 @@ +from edify.library.ip.v4 import ipv4 +from edify.library.ip.v6 import ipv6 + +__all__ = ["ipv4", "ipv6"] diff --git a/edify/library/ip/v4.py b/edify/library/ip/v4.py new file mode 100644 index 0000000..d52c5d7 --- /dev/null +++ b/edify/library/ip/v4.py @@ -0,0 +1,28 @@ +"""IPv4-address shape validator. + +Validates the standard dotted-quad ``a.b.c.d`` shape with each octet in +the ``0``-``255`` range. +""" + +from __future__ import annotations + +import re + +_IPV4_PATTERN = re.compile( + r"^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\." + r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\." + r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\." + r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" +) + + +def ipv4(value: str) -> bool: + """Return True when ``value`` matches the dotted-quad IPv4 shape with bounded octets. + + Args: + value: The string to check. + + Returns: + True for valid IPv4 addresses (each octet ``0``-``255``); False otherwise. + """ + return _IPV4_PATTERN.match(value) is not None diff --git a/edify/library/ip/v6.py b/edify/library/ip/v6.py new file mode 100644 index 0000000..108c501 --- /dev/null +++ b/edify/library/ip/v6.py @@ -0,0 +1,44 @@ +"""IPv6-address shape validator. + +Validates every IPv6 form: full eight-group, compressed ``::``, mixed +IPv4-mapped, zone identifiers, etc. Pattern is verbatim from the 0.3 +implementation; replacement is gated by item-41B's callable-Pattern work +and the corpus-equivalence test from the validator-hardening pass. +""" + +from __future__ import annotations + +import re + +_IPV6_PATTERN = re.compile( + r"^(" + r"([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|" + r"([0-9a-fA-F]{1,4}:){1,7}:|" + r"([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|" + r"([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|" + r"([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|" + r"([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|" + r"([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|" + r"[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|" + r":((:[0-9a-fA-F]{1,4}){1,7}|:)|" + r"fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|" + r"::(ffff(:0{1,4}){0,1}:){0,1}" + r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}" + r"(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|" + r"([0-9a-fA-F]{1,4}:){1,4}:" + r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}" + r"(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])" + r")$" +) + + +def ipv6(value: str) -> bool: + """Return True when ``value`` matches any of the IPv6 address forms. + + Args: + value: The string to check. + + Returns: + True for valid IPv6 addresses in any of the recognised forms; False otherwise. + """ + return _IPV6_PATTERN.match(value) is not None diff --git a/edify/library/mac.py b/edify/library/mac.py new file mode 100644 index 0000000..60721cf --- /dev/null +++ b/edify/library/mac.py @@ -0,0 +1,26 @@ +"""MAC-address shape validator. + +Validates the standard 6-octet IEEE 802 MAC address form (e.g. +``aa:bb:cc:dd:ee:ff`` or ``aa-bb-cc-dd-ee-ff``). Case-insensitive on the +hex digits. +""" + +from __future__ import annotations + +import re + +_MAC_PATTERN = re.compile(r"^(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2})$") + + +def mac(value: str) -> bool: + """Return True when ``value`` matches the IEEE-802 MAC address shape. + + Args: + value: The string to check. + + Returns: + True for valid colon- or hyphen-separated 6-octet hex strings; False otherwise. + """ + if not isinstance(value, str): + return False + return _MAC_PATTERN.match(value) is not None diff --git a/edify/library/password.py b/edify/library/password.py new file mode 100644 index 0000000..c5295f3 --- /dev/null +++ b/edify/library/password.py @@ -0,0 +1,69 @@ +"""Password-strength validator. + +Validates that a string meets all four configurable thresholds: length +window, minimum uppercase count, minimum lowercase count, minimum digit +count, and minimum special-character count. The special-character set is +itself configurable. Shape and class-count only — does not check entropy, +breach databases, or anything beyond what's spelled out in the signature. +""" + +from __future__ import annotations + +import re + +_UPPERCASE_PATTERN = re.compile("[A-Z]") +_LOWERCASE_PATTERN = re.compile("[a-z]") +_DIGIT_PATTERN = re.compile("[0-9]") +_DEFAULT_SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;':\",./<>?" + + +def password( + password: str, + min_length: int = 8, + max_length: int = 64, + min_upper: int = 1, + min_lower: int = 1, + min_digit: int = 1, + min_special: int = 1, + special_chars: str = _DEFAULT_SPECIAL_CHARS, +) -> bool: + """Return True when ``password`` meets every configured strength threshold. + + Args: + password: The candidate password to check. + min_length: Minimum allowed length (inclusive). + max_length: Maximum allowed length (inclusive). + min_upper: Minimum number of uppercase letters required. + min_lower: Minimum number of lowercase letters required. + min_digit: Minimum number of decimal digits required. + min_special: Minimum number of special characters required. + special_chars: The set of characters counted toward ``min_special``. + + Returns: + True when every threshold is satisfied; False as soon as one fails. + """ + return ( + _length_in_range(password, min_length, max_length) + and _meets_threshold(password, _UPPERCASE_PATTERN, min_upper) + and _meets_threshold(password, _LOWERCASE_PATTERN, min_lower) + and _meets_threshold(password, _DIGIT_PATTERN, min_digit) + and _meets_special_threshold(password, special_chars, min_special) + ) + + +def _length_in_range(value: str, min_length: int, max_length: int) -> bool: + """Return True when ``len(value)`` is within the inclusive length window.""" + value_length = len(value) + return min_length <= value_length <= max_length + + +def _meets_threshold(value: str, character_pattern: re.Pattern[str], required_count: int) -> bool: + """Return True when ``value`` has at least ``required_count`` ``character_pattern`` matches.""" + matches = character_pattern.findall(value) + return len(matches) >= required_count + + +def _meets_special_threshold(value: str, special_chars: str, required_count: int) -> bool: + """Return True when ``value`` contains at least ``required_count`` special characters.""" + special_match_count = sum(1 for character in value if character in special_chars) + return special_match_count >= required_count diff --git a/edify/library/phone.py b/edify/library/phone.py new file mode 100644 index 0000000..f1675b2 --- /dev/null +++ b/edify/library/phone.py @@ -0,0 +1,31 @@ +"""Phone-number shape validator. + +Validates a permissive international phone-number shape (with optional +``+`` prefix, optional country code, parenthesised area code, and various +separators) plus a short-number fallback for 4-digit service codes. +Shape-only — does not verify dialability. +""" + +from __future__ import annotations + +import re + +_PHONE_PATTERN = re.compile( + r"^\+?\d{1,4}?[-.\s]?\(?\d{1,3}?\)?[-.\s]?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9}$" +) +_SHORT_NUMBER_PATTERN = re.compile(r"^\d{2,4}") + + +def phone_number(value: str) -> bool: + """Return True when ``value`` matches the international or short-number shape. + + Args: + value: The string to check. + + Returns: + True for permissive international phone shapes and 4-digit service + numbers; False otherwise. + """ + matched_international = _PHONE_PATTERN.match(value) is not None + matched_short_number = _SHORT_NUMBER_PATTERN.match(value) is not None + return matched_international or matched_short_number diff --git a/edify/library/ssn.py b/edify/library/ssn.py new file mode 100644 index 0000000..bcc8b61 --- /dev/null +++ b/edify/library/ssn.py @@ -0,0 +1,27 @@ +"""US Social Security Number shape validator. + +Validates the ``AAA-GG-SSSS`` shape with the documented blocked ranges: +``000``, ``666`` and ``9xx`` for the area, ``00`` for the group, ``0000`` +for the serial. Shape-only — does not verify issuance. +""" + +from __future__ import annotations + +import re + +_SSN_PATTERN = re.compile(r"^(?!666|000|9\d{2})\d{3}-(?!00)\d{2}-(?!0{4})\d{4}$") + + +def ssn(value: str) -> bool: + """Return True when ``value`` matches the US SSN shape with blocked-range exclusions. + + Args: + value: The string to check. + + Returns: + True for valid ``AAA-GG-SSSS`` strings with the area / group / + serial blocks respected; False otherwise. + """ + if not isinstance(value, str): + return False + return _SSN_PATTERN.match(value) is not None diff --git a/edify/library/url.py b/edify/library/url.py new file mode 100644 index 0000000..d9688f5 --- /dev/null +++ b/edify/library/url.py @@ -0,0 +1,79 @@ +"""URL-shape validator. + +Validates that a string matches a URL shape — either with a protocol prefix +(``https://...``) or without (``example.com/path``). The caller can restrict +which forms are accepted via the ``match`` argument. Shape-only: does not +verify resolvability or RFC-correctness. +""" + +from __future__ import annotations + +import re + +_PROTOCOL_PATTERN = re.compile( + r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\." + r"[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&/=]*)$" +) +_NO_PROTOCOL_PATTERN = re.compile( + r"^[-a-zA-Z0-9@:%._\+~#=]{1,256}\." + r"[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&/=]*)$" +) +_PROTOCOL_MODE = "proto" +_NO_PROTOCOL_MODE = "no_proto" +_DEFAULT_MATCH_MODES = (_PROTOCOL_MODE, _NO_PROTOCOL_MODE) + + +def url(value: str, match: list[str] | tuple[str, ...] | None = None) -> bool: + """Return True when ``value`` matches one of the requested URL shapes. + + Args: + value: The string to check. + match: Sequence of mode strings naming which URL shapes to accept. + Use ``"proto"`` for the ``https?://...`` shape and ``"no_proto"`` + for the bare ``example.com/...`` shape. Defaults to accepting both. + + Returns: + True when ``value`` matches any of the requested mode patterns. + + Raises: + TypeError: When ``match`` is not a list or tuple. + ValueError: When ``match`` is empty or contains an unrecognised mode. + """ + selected_modes = match if match is not None else _DEFAULT_MATCH_MODES + _ensure_match_is_sequence(selected_modes) + _ensure_match_non_empty(selected_modes) + patterns = _patterns_for_modes(selected_modes) + return _matches_any(value, patterns) + + +def _ensure_match_is_sequence(match: object) -> None: + """Raise :class:`TypeError` when ``match`` is not a list or tuple.""" + if isinstance(match, (list, tuple)): + return + actual_type_name = type(match).__name__ + raise TypeError(f"match argument must be a list (got {actual_type_name})") + + +def _ensure_match_non_empty(match: list[str] | tuple[str, ...]) -> None: + """Raise :class:`ValueError` when ``match`` is empty.""" + if len(match) > 0: + return + raise ValueError("match argument must not be empty") + + +def _patterns_for_modes(match_modes: list[str] | tuple[str, ...]) -> list[re.Pattern[str]]: + """Return the compiled patterns named by ``match_modes`` (raises on unknown names).""" + patterns: list[re.Pattern[str]] = [] + for mode in match_modes: + if mode == _PROTOCOL_MODE: + patterns.append(_PROTOCOL_PATTERN) + elif mode == _NO_PROTOCOL_MODE: + patterns.append(_NO_PROTOCOL_PATTERN) + else: + raise ValueError(f"Invalid protocol: {mode}") + return patterns + + +def _matches_any(value: str, patterns: list[re.Pattern[str]]) -> bool: + """Return True when ``value`` matches at least one of ``patterns``.""" + return any(pattern.match(value) is not None for pattern in patterns) diff --git a/edify/library/uuid.py b/edify/library/uuid.py new file mode 100644 index 0000000..0dcbcb8 --- /dev/null +++ b/edify/library/uuid.py @@ -0,0 +1,26 @@ +"""UUID-shape validator. + +Validates the canonical 8-4-4-4-12 hex form with the version digit pinned +to ``1``-``5`` and the variant digit pinned to ``8``, ``9``, ``a``, or +``b``. Lowercase hex only. +""" + +from __future__ import annotations + +import re + +_UUID_PATTERN = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$" +) + + +def uuid(value: str) -> bool: + """Return True when ``value`` matches the canonical UUID 8-4-4-4-12 shape. + + Args: + value: The string to check. + + Returns: + True for valid lowercase-hex UUIDs; False otherwise. + """ + return _UUID_PATTERN.match(value) is not None diff --git a/edify/library/zip.py b/edify/library/zip.py new file mode 100644 index 0000000..0c2cc30 --- /dev/null +++ b/edify/library/zip.py @@ -0,0 +1,61 @@ +"""ZIP / postal-code shape validator. + +Validates the postal-code shape for a given country (default ``US``). The +per-locale patterns live in :mod:`edify.library._support.zip`; this +module wires the user-supplied ``locale`` to its pattern and runs the match. +""" + +from __future__ import annotations + +import re + +from edify.library._support.zip import ZIP_LOCALES + + +def zip(value: str, locale: str = "US") -> bool: + """Return True when ``value`` matches the postal-code shape for ``locale``. + + Args: + value: The string to check. + locale: The two-letter country code whose postal pattern to apply. + + Returns: + True for valid postal codes in the given locale; False otherwise. + + Raises: + TypeError: When ``locale`` is not a string. + ValueError: When ``locale`` is empty or not in the supported set. + """ + _ensure_is_string(locale) + _ensure_non_empty(locale) + pattern = _pattern_for_locale(locale) + return re.match(pattern, value) is not None + + +def _ensure_is_string(locale: object) -> None: + """Raise :class:`TypeError` when ``locale`` is not a string.""" + if isinstance(locale, str): + return + actual_type_name = type(locale).__name__ + raise TypeError(f"locale must be a string (got {actual_type_name})") + + +def _ensure_non_empty(locale: str) -> None: + """Raise :class:`ValueError` when ``locale`` is the empty string.""" + if locale != "": + return + raise ValueError("locale cannot be empty") + + +def _pattern_for_locale(locale: str) -> str: + """Return the postal-code regex string for ``locale``. + + Raises :class:`ValueError` when ``locale`` is not in the supported set. + """ + for locale_entry in ZIP_LOCALES: + if locale_entry.get("abbrev") == locale and "zip" in locale_entry: + return locale_entry["zip"] + supported_abbreviations = [ + locale_entry["abbrev"] for locale_entry in ZIP_LOCALES if "zip" in locale_entry + ] + raise ValueError(f"locale must be one of {supported_abbreviations}") diff --git a/edify/pattern/__init__.py b/edify/pattern/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/pattern/__init__.py diff --git a/edify/result/__init__.py b/edify/result/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/result/__init__.py diff --git a/edify/serialize/__init__.py b/edify/serialize/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/serialize/__init__.py diff --git a/edify/validate/__init__.py b/edify/validate/__init__.py new file mode 100644 index 0000000..bbbcfda --- /dev/null +++ b/edify/validate/__init__.py @@ -0,0 +1,3 @@ +from edify.validate.names import is_valid_group_name + +__all__ = ["is_valid_group_name"] diff --git a/edify/validate/names.py b/edify/validate/names.py new file mode 100644 index 0000000..df5c229 --- /dev/null +++ b/edify/validate/names.py @@ -0,0 +1,25 @@ +"""Validate the shape of a named-capture-group identifier. + +Named groups (``(?P<name>...)``) accept identifier-style names: letters, +digits, and underscores, starting with a letter. This module owns that +single check. +""" + +from __future__ import annotations + +import re + +_NAMED_GROUP_PATTERN = re.compile(r"^[a-z]+\w*$", re.IGNORECASE) + + +def is_valid_group_name(name: str) -> bool: + """Return True when ``name`` is a syntactically valid named-group identifier. + + Args: + name: The candidate name to check. + + Returns: + True if ``name`` matches ``^[a-z]+\\w*$`` case-insensitively, False otherwise. + """ + match_result = _NAMED_GROUP_PATTERN.match(name) + return match_result is not None diff --git a/edify/version.py b/edify/version.py new file mode 100644 index 0000000..3ebd5da --- /dev/null +++ b/edify/version.py @@ -0,0 +1,24 @@ +# file generated by vcs-versioning +# don't change, don't track in version control +from __future__ import annotations + +__all__ = [ + "__commit_id__", + "__version__", + "__version_tuple__", + "commit_id", + "version", + "version_tuple", +] + +version: str +__version__: str +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None + +__version__ = version = "0.3.1.dev13+g21ec7908c.d20260626" +__version_tuple__ = version_tuple = (0, 3, 1, "dev13", "g21ec7908c.d20260626") + +__commit_id__ = commit_id = None diff --git a/pyproject.toml b/pyproject.toml index 6baa4ee..e48757d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,117 @@ [build-system] -requires = [ - "setuptools>=75.0", - "wheel", +requires = ["hatchling>=1.30", "hatch-vcs>=0.4"] +build-backend = "hatchling.build" + +[project] +name = "edify" +description = "Regular Expressions Made Simple" +readme = "README.rst" +requires-python = ">=3.11" +license = "Apache-2.0" +license-files = ["LICENSE"] +authors = [ + { name = "Bobby", email = "[email protected]" }, + { name = "natsuoto", email = "[email protected]" }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: Unix", + "Operating System :: POSIX", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Utilities", +] +dependencies = [] +dynamic = ["version"] + +[project.urls] +Documentation = "https://edify.readthedocs.io/" +Changelog = "https://edify.readthedocs.io/en/latest/changelog.html" +"Issue Tracker" = "https://github.com/luciferreeves/edify/issues" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.7", +] +docs = [ + "sphinx>=7.4.7", + "sphinx-rtd-theme", +] + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "edify/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/edify", + "/tests", + "/docs", + "/README.rst", + "/CHANGELOG.rst", + "/CONTRIBUTING.rst", + "/AUTHORS.rst", + "/LICENSE", +] + +[tool.hatch.build.targets.wheel] +packages = ["edify"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "I", + "N", + "UP", + "B", + "C4", + "SIM", + "PT", + "RUF", + "PERF", + "TID", ] -[tool.black] -line-length = 140 -target-version = ['py39'] -skip-string-normalization = true +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "parents" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["*.test.py"] +addopts = [ + "-ra", + "--strict-markers", + "--tb=short", + "--import-mode=importlib", +] +filterwarnings = ["error"] + +[tool.coverage.run] +branch = false +source = ["edify"] +parallel = true +omit = ["edify/version.py"] + +[tool.coverage.report] +show_missing = true +precision = 2 +fail_under = 100
\ No newline at end of file diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index d604d65..0000000 --- a/pytest.ini +++ /dev/null @@ -1,28 +0,0 @@ -[pytest] -# If a pytest section is found in one of the possible config files -# (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, -# so if you add a pytest config section elsewhere, -# you will need to delete this section from setup.cfg. -norecursedirs = - migrations - -python_files = - test_*.py - *_test.py - tests.py -addopts = - -ra - --strict-markers - --doctest-modules - --doctest-glob=\*.rst - --tb=short -testpaths = - tests - -# Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors -filterwarnings = - error -# You can add exclusions, some examples: -# ignore:'edify' defines default_app_config:PendingDeprecationWarning:: -# ignore:The {{% if::: -# ignore:Coverage disabled via --no-cov switch! diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3e11bf3..0000000 --- a/setup.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[flake8] -max-line-length = 140 -exclude = .tox,.eggs,build,dist - -[tool:isort] -force_single_line = True -line_length = 120 -known_first_party = edify -default_section = THIRDPARTY -forced_separate = test_edify -skip = .tox,.eggs,build,dist diff --git a/setup.py b/setup.py deleted file mode 100755 index 9547444..0000000 --- a/setup.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- - -import io -import re -from glob import glob -from os.path import basename -from os.path import dirname -from os.path import join -from os.path import splitext - -from setuptools import find_namespace_packages -from setuptools import setup - - -def read(*names, **kwargs): - with io.open(join(dirname(__file__), *names), encoding=kwargs.get('encoding', 'utf8')) as fh: - return fh.read() - - -setup( - name='edify', - version='0.3.0', - license='Apache-2.0', - description='Regular Expressions Made Simple', - long_description='{}\n{}'.format( - re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')), - re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')), - ), - author='Bobby', - author_email='[email protected]', - url='https://github.com/luciferreeves/edify', - packages=find_namespace_packages('src'), - package_dir={'': 'src'}, - py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], - include_package_data=True, - zip_safe=False, - classifiers=[ - # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: Unix', - 'Operating System :: POSIX', - 'Operating System :: Microsoft :: Windows', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Programming Language :: Python :: 3.14', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - # uncomment if you test on these interpreters: - # 'Programming Language :: Python :: Implementation :: IronPython', - # 'Programming Language :: Python :: Implementation :: Jython', - # 'Programming Language :: Python :: Implementation :: Stackless', - 'Topic :: Utilities', - ], - project_urls={ - 'Documentation': 'https://edify.readthedocs.io/', - 'Changelog': 'https://edify.readthedocs.io/en/latest/changelog.html', - 'Issue Tracker': 'https://github.com/luciferreeves/edify/issues', - }, - keywords=[ - # eg: 'keyword1', 'keyword2', 'keyword3', - ], - python_requires='>=3.9', - install_requires=[ - # eg: 'aspectlib==1.1.1', 'six>=1.7', - ], - extras_require={ - # eg: - # 'rst': ['docutils>=0.11'], - # ':python_version=="2.6"': ['argparse'], - }, -) diff --git a/src/edify/__init__.py b/src/edify/__init__.py deleted file mode 100644 index 97ff6e4..0000000 --- a/src/edify/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# flake8: noqa - -__version__ = '0.3.0' -from .builder.builder import RegexBuilder diff --git a/src/edify/builder/builder.py b/src/edify/builder/builder.py deleted file mode 100644 index 666468b..0000000 --- a/src/edify/builder/builder.py +++ /dev/null @@ -1,532 +0,0 @@ -import re -from copy import deepcopy as clone - -from .errors import can_not_call_se -from .errors import can_not_end_while_building_root_exp -from .errors import cannot_create_duplicate_named_group -from .errors import cannot_define_start_after_end -from .errors import end_input_already_defined -from .errors import ignore_se -from .errors import invalid_total_capture_groups_index -from .errors import must_be_a_string -from .errors import must_be_instance -from .errors import must_be_integer_greater_than_zero -from .errors import must_be_one_character -from .errors import must_be_positive_integer -from .errors import must_be_single_character -from .errors import must_have_a_smaller_value -from .errors import name_not_valid -from .errors import named_group_does_not_exist -from .errors import start_input_already_defined -# from .helpers.core import deep_copy -# from .errors import unable_to_quantify -from .helpers.core import apply_subexpression_defaults -from .helpers.core import assertion -from .helpers.core import create_stack_frame -from .helpers.core import escape_special -from .helpers.core import fuse_elements -from .helpers.quantifiers import quantifier_table -from .helpers.regex_vars import named_group_regex -from .helpers.t import t - - -class RegexBuilder: - """Regular Expression Builder Class.""" - - state = {} - - def __init__(self): - self.state = { - 'has_defined_start': False, - 'has_defined_end': False, - 'flags': { - 'A': False, - 'D': False, - 'I': False, - 'M': False, - 'S': False, - 'X': False, - }, - 'stack': [create_stack_frame(t['root'])], - 'named_groups': [], - 'total_capture_groups': 0, - } - - def ascii_only(self): - next = clone(self) - next.state['flags']['A'] = True - return next - - def debug(self): - next = clone(self) - next.state['flags']['D'] = True - return next - - def ignore_case(self): - next = clone(self) - next.state['flags']['I'] = True - return next - - def multi_line(self): - next = clone(self) - next.state['flags']['M'] = True - return next - - def dot_all(self): - next = clone(self) - next.state['flags']['S'] = True - return next - - def verbose(self): - next = clone(self) - next.state['flags']['X'] = True - return next - - def get_current_frame(self): - return self.state['stack'][len(self.state['stack']) - 1] - - def get_current_element_array(self): - return self.get_current_frame()['elements'] - - def match_element(self, type_fn): - next = clone(self) - next.get_current_element_array().append(next.apply_quantifier(type_fn)) - return next - - def apply_quantifier(self, element): - current_frame = self.get_current_frame() - if current_frame['quantifier'] is not None: - wrapped = current_frame['quantifier']['value'](element) - current_frame['quantifier'] = None - return wrapped - return element - - def frame_creating_element(self, type_fn): - next = clone(self) - new_frame = create_stack_frame(type_fn) - next.state['stack'].append(new_frame) - return next - - def tracked_named_group(self, name): - assertion(type(name) is str, must_be_a_string("Name", type(name))) - assertion(len(name) > 0, must_be_one_character("Name")) - assertion(name not in self.state['named_groups'], cannot_create_duplicate_named_group(name)) - assertion(re.compile(named_group_regex, re.I).match(name), name_not_valid(name)) - self.state['named_groups'].append(name) - - def capture(self): - next = clone(self) - new_frame = create_stack_frame(t['capture']) - next.state['stack'].append(new_frame) - next.state['total_capture_groups'] += 1 - return next - - def named_capture(self, name): - next = clone(self) - new_frame = create_stack_frame(t['named_capture'](name)) - next.tracked_named_group(name) - next.state['stack'].append(new_frame) - next.state['total_capture_groups'] += 1 - return next - - def quantifier_element(self, type_fn): - next = clone(self) - current_frame = next.get_current_frame() - # if current_frame['quantifier'] is not None: - # raise Exception(unable_to_quantify(type_fn, current_frame['quantifier']['type'])) - current_frame['quantifier'] = t[type_fn] - return next - - def any_char(self): - return self.match_element(t['any_char']) - - def whitespace_char(self): - return self.match_element(t['whitespace_char']) - - def non_whitespace_char(self): - return self.match_element(t['non_whitespace_char']) - - def digit(self): - return self.match_element(t['digit']) - - def non_digit(self): - return self.match_element(t['non_digit']) - - def word(self): - return self.match_element(t['word']) - - def non_word(self): - return self.match_element(t['non_word']) - - def word_boundary(self): - return self.match_element(t['word_boundary']) - - def non_word_boundary(self): - return self.match_element(t['non_word_boundary']) - - def new_line(self): - return self.match_element(t['new_line']) - - def carriage_return(self): - return self.match_element(t['carriage_return']) - - def tab(self): - return self.match_element(t['tab']) - - def null_byte(self): - return self.match_element(t['null_byte']) - - def named_back_reference(self, name): - assertion(name in self.state['named_groups'], named_group_does_not_exist(name)) - return self.match_element(t['named_back_reference'](name)) - - def back_reference(self, index: int): - assertion(type(index) is int, 'Index must be an integer.') - assertion( - index > 0 and index <= self.state['total_capture_groups'], - invalid_total_capture_groups_index(index, self.state['total_capture_groups']), - ) - return self.match_element(t['back_reference'](index)) - - def any_of(self): - return self.frame_creating_element(t['any_of']) - - def group(self): - return self.frame_creating_element(t['group']) - - def assert_ahead(self): - return self.frame_creating_element(t['assert_ahead']) - - def assert_not_ahead(self): - return self.frame_creating_element(t['assert_not_ahead']) - - def assert_behind(self): - return self.frame_creating_element(t['assert_behind']) - - def assert_not_behind(self): - return self.frame_creating_element(t['assert_not_behind']) - - def optional(self): - return self.quantifier_element('optional') - - def zero_or_more(self): - return self.quantifier_element('zero_or_more') - - def zero_or_more_lazy(self): - return self.quantifier_element('zero_or_more_lazy') - - def one_or_more(self): - return self.quantifier_element('one_or_more') - - def one_or_more_lazy(self): - return self.quantifier_element('one_or_more_lazy') - - def exactly(self, count): - assertion(type(count) is int and count > 0, must_be_positive_integer('count')) - current_frame = self.get_current_frame() - # if current_frame['quantifier'] is not None: - # raise Exception(unable_to_quantify("exactly", current_frame['quantifier']['type'])) - current_frame['quantifier'] = t['exactly'](count) - return self - - def at_least(self, count): - assertion(type(count) is int and count > 0, must_be_positive_integer('count')) - next = clone(self) - current_frame = next.get_current_frame() - # if current_frame['quantifier'] is not None: - # raise Exception(unable_to_quantify("at_least", current_frame['quantifier']['type'])) - current_frame['quantifier'] = t['at_least'](count) - return next - - def between(self, x, y): - assertion(type(x) is int and x >= 0, must_be_integer_greater_than_zero('x')) - assertion(type(y) is int and y > 0, must_be_positive_integer('y')) - assertion(x < y, 'X must be less than Y.') - next = clone(self) - current_frame = next.get_current_frame() - # if current_frame['quantifier'] is not None: - # raise Exception(unable_to_quantify("between", current_frame['quantifier']['type'])) - current_frame['quantifier'] = t['between'](x, y) - return next - - def between_lazy(self, x, y): - assertion(type(x) is int and x >= 0, must_be_integer_greater_than_zero('x')) - assertion(type(y) is int and y > 0, must_be_positive_integer('y')) - assertion(x < y, 'X must be less than Y.') - next = clone(self) - current_frame = next.get_current_frame() - # if current_frame['quantifier'] is not None: - # raise Exception(unable_to_quantify("between_lazy", current_frame['quantifier']['type'])) - current_frame['quantifier'] = t['between_lazy'](x, y) - return next - - def start_of_input(self): - assertion(self.state['has_defined_start'] is False, start_input_already_defined()) - assertion(self.state['has_defined_end'] is False, cannot_define_start_after_end()) - next = clone(self) - next.state['has_defined_start'] = True - next.get_current_element_array().append(t['start_of_input']) - return next - - def end_of_input(self): - assertion(self.state['has_defined_end'] is False, end_input_already_defined()) - next = clone(self) - next.state['has_defined_end'] = True - next.get_current_element_array().append(t['end_of_input']) - return next - - def any_of_chars(self, chars): - next = clone(self) - element_value = t['any_of_chars'](escape_special(chars)) - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(element_value)) - return next - - def end(self): - assertion(len(self.state['stack']) > 1, can_not_end_while_building_root_exp()) - next = clone(self) - old_frame = next.state['stack'].pop() - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(old_frame['type']['value'](old_frame['elements']))) - return next - - def anything_but_string(self, string): - assertion(type(string) is str, must_be_a_string('Value', string)) - assertion(len(string) > 0, must_be_one_character('Value')) - next = clone(self) - element_value = t['anything_but_string'](escape_special(string)) - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(element_value)) - return next - - def anything_but_chars(self, chars): - assertion(type(chars) is str, must_be_a_string('Value', chars)) - assertion(len(chars) > 0, must_be_one_character('Value')) - next = clone(self) - element_value = t['anything_but_chars'](escape_special(chars)) - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(element_value)) - return next - - def anything_but_range(self, a, b): - str_a = str(a) - str_b = str(b) - assertion(len(str_a) == 1, must_be_single_character('a', str_a)) - assertion(len(str_b) == 1, must_be_single_character('b', str_b)) - assertion(ord(str_a) < ord(str_b), must_have_a_smaller_value(str_a, str_b)) - next = clone(self) - element_value = t['anything_but_range']([a, b]) - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(element_value)) - return next - - def string(self, s): - assertion(type(s) is str, must_be_a_string('Value', s)) - assertion(len(s) > 0, must_be_one_character('Value')) - next = clone(self) - element_value = t['string'](escape_special(s)) if len(s) > 1 else t['char'](escape_special(s)) - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(element_value)) - return next - - def char(self, c): - assertion(type(c) is str, must_be_a_string('Value', c)) - assertion(len(c) == 1, must_be_single_character('Value', c)) - next = clone(self) - element_value = t['char'](escape_special(c)) - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(element_value)) - return next - - def range(self, a, b): - str_a = str(a) - str_b = str(b) - assertion(len(str_a) == 1, must_be_single_character('a', str_a)) - assertion(len(str_b) == 1, must_be_single_character('b', str_b)) - assertion(ord(str_a) < ord(str_b), must_have_a_smaller_value(str_a, str_b)) - next = clone(self) - element_value = t['range']([a, b]) - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(element_value)) - return next - - def merge_subexpression(self, el, options, parent, increment_capture_groups): - next_el = clone(el) - - if next_el['type'] == 'back_reference': - next_el['index'] += parent.state['total_capture_groups'] - if next_el['type'] == 'capture': - increment_capture_groups() - if next_el['type'] == 'named_capture': - group_name = '{}{}'.format(options['namespace'], next_el['name']) if options['namespace'] else next_el['name'] - # parent.tracked_named_group(group_name) - next_el['name'] = group_name - if next_el['type'] == 'named_back_reference': - next_el['name'] = '{}{}'.format(options['namespace'], next_el['name']) if options['namespace'] else next_el['name'] - if 'contains_child' in next_el: - next_el['value'] = self.merge_subexpression(next_el['value'], options, parent, increment_capture_groups) - elif 'contains_children' in next_el: - next_el['value'] = list(map(lambda e: self.merge_subexpression(e, options, parent, increment_capture_groups), next_el['value'])) - if next_el['type'] == 'start_of_input': - if options['ignore_start_and_end']: - return t['noop'] - assertion(parent.state['has_defined_start'] is False, str(start_input_already_defined()) + " " + str(ignore_se())) - # assertion(parent.state['has_defined_end'] is False, str(end_input_already_defined()) + " " + str(ignore_se())) - # parent.state['has_defined_start'] = True - if next_el['type'] == 'end_of_input': - if options['ignore_start_and_end']: - return t['noop'] - assertion(parent.state['has_defined_end'] is False, str(end_input_already_defined()) + str(ignore_se())) - # parent.state['has_defined_end'] = True - return next_el - - def subexpression(self, expr, opts={}): - assertion(isinstance(expr, RegexBuilder), must_be_instance("Expression", expr, "RegexBuilder")) - assertion(len(expr.state['stack']) == 1, can_not_call_se(expr.get_current_frame()['type']['type'])) - options = apply_subexpression_defaults(opts) - expr_next = clone(expr) - next = clone(self) - additional_capture_groups = 0 - expr_frame = expr_next.get_current_frame() - - def increment_capture_groups(): - nonlocal additional_capture_groups - additional_capture_groups += 1 - - expr_frame['elements'] = list( - map(lambda e: self.merge_subexpression(e, options, expr_next, increment_capture_groups), expr_frame['elements']) - ) - next.state['total_capture_groups'] += additional_capture_groups - if not options['ignore_flags']: - for flag_name, enabled in expr_next.state['flags'].items(): - next.state['flags'][flag_name] = enabled or next.state['flags'][flag_name] - current_frame = next.get_current_frame() - current_frame['elements'].append(next.apply_quantifier(t['subexpression'](expr_frame['elements']))) - return next - - def evaluate(self, el): - if el['type'] == 'noop': - return '' - if el['type'] == 'any_char': - return '.' - if el['type'] == 'whitespace_char': - return '\\s' - if el['type'] == 'non_whitespace_char': - return '\\S' - if el['type'] == 'digit': - return '\\d' - if el['type'] == 'non_digit': - return '\\D' - if el['type'] == 'word': - return '\\w' - if el['type'] == 'non_word': - return '\\W' - if el['type'] == 'word_boundary': - return '\\b' - if el['type'] == 'non_word_boundary': - return '\\B' - if el['type'] == 'start_of_input': - return '^' - if el['type'] == 'end_of_input': - return '$' - if el['type'] == 'new_line': - return '\\n' - if el['type'] == 'carriage_return': - return '\\r' - if el['type'] == 'tab': - return '\\t' - if el['type'] == 'null_byte': - return '\\0' - if el['type'] == 'string': - return el['value'] - if el['type'] == 'char': - return el['value'] - if el['type'] == 'range': - return '[{}-{}]'.format(el['value'][0], el['value'][1]) - if el['type'] == 'anything_but_range': - return '[^{}-{}]'.format(el['value'][0], el['value'][1]) - if el['type'] == 'any_of_chars': - return '[' + ''.join(el['value']) + ']' - if el['type'] == 'anything_but_chars': - return '[^' + ''.join(el['value']) + ']' - if el['type'] == 'named_back_reference': - return '\\k<{}>'.format(el['name']) - if el['type'] == 'back_reference': - return '\\{}'.format(el['index']) - if el['type'] == 'subexpression': - return ''.join(map(lambda e: self.evaluate(e), el['value'])) - cg1 = ['optional', 'zero_or_more', 'zero_or_more_lazy', 'one_or_more', 'one_or_more_lazy'] - if el['type'] in cg1: - inner = self.evaluate(el['value']) - with_group = "(?:{})".format(inner) if 'quantifiers_require_group' in el['value'] else inner - symbol = quantifier_table[el['type']] - return '{}{}'.format(with_group, symbol) - cg2 = ['between', 'between_lazy', 'at_least', 'exactly'] - if el['type'] in cg2: - inner = self.evaluate(el['value']) - with_group = "(?:{})".format(inner) if 'quantifiers_require_group' in el['value'] else inner - return '{}{}'.format(with_group, quantifier_table[el['type']](el['times'])) - if el['type'] == 'anything_but_string': - chars = ''.join(map(lambda c: '[^{}]'.format(c), el['value'])) - return '(?:{})'.format(chars) - if el['type'] == 'assert_ahead': - evaluated = ''.join(map(lambda e: self.evaluate(e), el['value'])) - return '(?={})'.format(evaluated) - if el['type'] == 'assert_behind': - evaluated = ''.join(map(lambda e: self.evaluate(e), el['value'])) - return '(?<={})'.format(evaluated) - if el['type'] == 'assert_not_ahead': - evaluated = ''.join(map(lambda e: self.evaluate(e), el['value'])) - return '(?!{})'.format(evaluated) - if el['type'] == 'assert_not_behind': - evaluated = ''.join(map(lambda e: self.evaluate(e), el['value'])) - return '(?<!{})'.format(evaluated) - if el['type'] == 'any_of': - [fused, rest] = fuse_elements(el['value']) - if len(rest) == 0: - return '[{}]'.format(fused) - evaluated_rest = list(map(lambda e: self.evaluate(e), rest)) - separator = '|' if len(evaluated_rest) > 0 and len(fused) > 0 else '' - return '(?:{}{}{})'.format('|'.join(evaluated_rest), separator, '[{}]'.format(fused) if fused else '') - if el['type'] == 'capture': - evaluated = ''.join(map(lambda e: self.evaluate(e), el['value'])) - return '({})'.format(evaluated) - if el['type'] == 'named_capture': - evaluated = ''.join(map(lambda e: self.evaluate(e), el['value'])) - return '(?P<{}>{})'.format(el['name'], evaluated) - if el['type'] == 'group': - evaluated = ''.join(map(lambda e: self.evaluate(e), el['value'])) - return '(?:{})'.format(evaluated) - - raise Exception('Can not process unsupported element type: {}'.format(el['type'])) # pragma: no cover - - def get_regex_patterns_and_flags(self): - assertion(len(self.state['stack']) == 1, can_not_call_se(self.get_current_frame()['type']['type'])) - pattern = "".join(list(map(lambda e: self.evaluate(e), self.get_current_element_array()))) - flags = "" - for flag_name, enabled in self.state['flags'].items(): - if enabled: - flags += flag_name - pattern = "(?:)" if pattern == "" else pattern - flags = "".join(sorted(flags)) - return pattern, flags - - def to_regex_string(self): - patterns, flags = self.get_regex_patterns_and_flags() - return '/{}/{}'.format(str(patterns.replace('\\ ', ' ')), str(flags)) - - def to_regex(self): - patterns, flags = self.get_regex_patterns_and_flags() - patterns = r"{}".format(patterns.replace("\\ ", ' ')) - flag = 0 - if flags != '': - for flag_name in flags: - if flag_name == 'D': - flag |= getattr(re, 'DEBUG') - else: - flag |= getattr(re, flag_name) - - try: - return re.compile(patterns, flags=flag) - except Exception as e: - raise Exception('Can not compile regex: {}'.format(e)) diff --git a/src/edify/builder/errors.py b/src/edify/builder/errors.py deleted file mode 100644 index 17a2bef..0000000 --- a/src/edify/builder/errors.py +++ /dev/null @@ -1,73 +0,0 @@ -def must_be_a_string(value, variable_name): - return '{} must be a string. (got {})'.format(value, type(variable_name)) - - -def must_be_one_character(variable_name): - return '{} must be one character long.'.format(variable_name) - - -def cannot_create_duplicate_named_group(name): - return 'Can not create duplicate named group "{}".'.format(name) - - -def name_not_valid(name): - return 'Name {} is not valid. (only alphanumeric characters and underscores are allowed)'.format(name) - - -def named_group_does_not_exist(name): - return 'Named group "{}" does not exist (create one with .named_capture()).'.format(name) - - -def invalid_total_capture_groups_index(index, total_capture_groups): - return 'Invalid index #{}. There are only {} capture groups.'.format(index, total_capture_groups) - - -def must_be_positive_integer(variable_name): - return '{} must be a positive integer.'.format(variable_name) - - -def must_be_integer_greater_than_zero(variable_name): - return '{} must be an integer greater than zero.'.format(variable_name) - - -# def unable_to_quantify(quantifier, type): -# return 'Can not quantify regular expression with {}, because it has already been quantified with {}.'.format(quantifier, type) - - -def start_input_already_defined(): - return 'Regex already has a start of input.' - - -def cannot_define_start_after_end(): - return 'Can not define a start of input after defining an end of input.' - - -def end_input_already_defined(): - return 'Regex already has an end of input.' - - -def can_not_end_while_building_root_exp(): - return 'Can not end while building the root expression.' - - -def must_be_single_character(value, variable_name): - return '{} must be a single character. (got {})'.format(value, type(variable_name)) - - -def must_have_a_smaller_value(a, b): - return '{} must have a smaller character value than {}. (a = {}, b = {})'.format(a, b, ord(a), ord(b)) - - -def ignore_se(): - return 'You can ignore a subexpressions start_of_input/end_of_input markers with the ignore_start_and_end option' - - -def must_be_instance(value, variable_name, class_name): - return '{} must be an instance of {}. (got {})'.format(value, class_name, type(variable_name)) - - -def can_not_call_se(cft): - return "Can not call subexpression a not yet fully specified regex object. \ - \n (Try adding a .end() call to match the {} on the subexpression)".format( - cft - ) diff --git a/src/edify/builder/helpers/core.py b/src/edify/builder/helpers/core.py deleted file mode 100644 index 38e8929..0000000 --- a/src/edify/builder/helpers/core.py +++ /dev/null @@ -1,79 +0,0 @@ -import re - - -def as_type(type, opts={}): - def type_fn(value=None): - return { - 'type': type, - 'value': value, - **opts, - } - - return type_fn - - -def deferred_type(type, opts={}): - type_fn = as_type(type, opts) - return type_fn(type_fn) - - -def create_stack_frame(type): - return { - 'type': type, - 'quantifier': None, - 'elements': [], - } - - -def assertion(condition, message): - if not condition: - raise Exception(message) - - -def escape_special(s): - return re.escape(s) - - -# def deep_copy(o): -# if isinstance(o, list): -# return [deep_copy(e) for e in o] -# if isinstance(o, dict): -# return {k: deep_copy(v) for k, v in o.items()} -# return o - - -def apply_subexpression_defaults(expr): - out = {**expr} - out['namespace'] = "" if 'namespace' not in out else out['namespace'] - out['ignore_flags'] = True if 'ignore_flags' not in out else out['ignore_flags'] - out['ignore_start_and_end'] = True if 'ignore_start_and_end' not in out else out['ignore_start_and_end'] - assertion(isinstance(out['namespace'], str), 'namespace must be a string') - assertion(isinstance(out['ignore_flags'], bool), 'ignore_flags must be a boolean') - assertion(isinstance(out['ignore_start_and_end'], bool), 'ignore_start_and_end must be a boolean') - return out - - -def is_fusable(element): - return element['type'] == 'range' or element['type'] == 'char' or element['type'] == 'any_of_chars' - - -def partition(pred, a): - result = [[], []] - for cur in a: - if pred(cur): - result[0].append(cur) - else: - result[1].append(cur) - return result - - -def fuse_elements(elements): - [fusables, rest] = partition(is_fusable, elements) - - def map_el(el): - if el['type'] == 'char' or el['type'] == 'any_of_chars': - return el['value'] - return '{}-{}'.format(el['value'][0], el['value'][1]) - - fused = ''.join(map(map_el, fusables)) - return [fused, rest] diff --git a/src/edify/builder/helpers/quantifiers.py b/src/edify/builder/helpers/quantifiers.py deleted file mode 100644 index 1810658..0000000 --- a/src/edify/builder/helpers/quantifiers.py +++ /dev/null @@ -1,11 +0,0 @@ -quantifier_table = { - 'one_or_more': '+', - 'one_or_more_lazy': '+?', - 'zero_or_more': '*', - 'zero_or_more_lazy': '*?', - 'optional': '?', - 'exactly': lambda times: '{{{}}}'.format(times), - 'at_least': lambda times: '{{{},}}'.format(times), - 'between': lambda times: '{{{},{}}}'.format(times[0], times[1]), - 'between_lazy': lambda times: '{{{},{}}}?'.format(times[0], times[1]), -} diff --git a/src/edify/builder/helpers/regex_vars.py b/src/edify/builder/helpers/regex_vars.py deleted file mode 100644 index ebf8f28..0000000 --- a/src/edify/builder/helpers/regex_vars.py +++ /dev/null @@ -1 +0,0 @@ -named_group_regex = r"^[a-z]+\w*$" diff --git a/src/edify/builder/helpers/t.py b/src/edify/builder/helpers/t.py deleted file mode 100644 index ab3304c..0000000 --- a/src/edify/builder/helpers/t.py +++ /dev/null @@ -1,49 +0,0 @@ -from .core import as_type -from .core import deferred_type - -t = { - 'root': as_type('root')(), - 'noop': as_type('noop')(), - 'start_of_input': as_type('start_of_input')(), - 'end_of_input': as_type('end_of_input')(), - 'any_char': as_type('any_char')(), - 'whitespace_char': as_type('whitespace_char')(), - 'non_whitespace_char': as_type('non_whitespace_char')(), - 'digit': as_type('digit')(), - 'non_digit': as_type('non_digit')(), - 'word': as_type('word')(), - 'non_word': as_type('non_word')(), - 'word_boundary': as_type('word_boundary')(), - 'non_word_boundary': as_type('non_word_boundary')(), - 'new_line': as_type('new_line')(), - 'carriage_return': as_type('carriage_return')(), - 'tab': as_type('tab')(), - 'null_byte': as_type('null_byte')(), - 'any_of_chars': as_type('any_of_chars'), - 'anything_but_string': as_type('anything_but_string'), - 'anything_but_chars': as_type('anything_but_chars'), - 'anything_but_range': as_type('anything_but_range'), - 'char': as_type('char'), - 'range': as_type('range'), - 'string': as_type('string', {'quantifiers_require_group': True}), - 'named_back_reference': lambda name: deferred_type('named_back_reference', {'name': name}), - 'back_reference': lambda index: deferred_type('back_reference', {'index': index}), - 'capture': deferred_type('capture', {'contains_children': True}), - 'subexpression': as_type('subexpression', {'contains_children': True, 'quantifiers_require_group': True}), - 'named_capture': lambda name: deferred_type('named_capture', {'name': name, 'contains_children': True}), - 'group': deferred_type('group', {'contains_children': True}), - 'any_of': deferred_type('any_of', {'contains_children': True}), - 'assert_ahead': deferred_type('assert_ahead', {'contains_children': True}), - 'assert_not_ahead': deferred_type('assert_not_ahead', {'contains_children': True}), - 'assert_behind': deferred_type('assert_behind', {'contains_children': True}), - 'assert_not_behind': deferred_type('assert_not_behind', {'contains_children': True}), - 'exactly': lambda times: deferred_type('exactly', {'times': times, 'contains_child': True}), - 'at_least': lambda times: deferred_type('at_least', {'times': times, 'contains_child': True}), - 'between': lambda x, y: deferred_type('between', {'times': [x, y], 'contains_child': True}), - 'between_lazy': lambda x, y: deferred_type('between_lazy', {'times': [x, y], 'contains_child': True}), - 'zero_or_more': deferred_type('zero_or_more', {'contains_child': True}), - 'zero_or_more_lazy': deferred_type('zero_or_more_lazy', {'contains_child': True}), - 'one_or_more': deferred_type('one_or_more', {'contains_child': True}), - 'one_or_more_lazy': deferred_type('one_or_more_lazy', {'contains_child': True}), - 'optional': deferred_type('optional', {'contains_child': True}), -} diff --git a/src/edify/library/__init__.py b/src/edify/library/__init__.py deleted file mode 100644 index bae5368..0000000 --- a/src/edify/library/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# flake8: noqa - -# Import everything from the library. -from .date import date -from .date import iso_date -from .guid import guid -from .ip import ipv4 -from .ip import ipv6 -from .mac import mac -from .mail import email -from .mail import email_rfc_5322 -from .password import password -from .phone import phone_number -from .ssn import ssn -from .url import url -from .uuid import uuid -from .zip import zip diff --git a/src/edify/library/date.py b/src/edify/library/date.py deleted file mode 100644 index 94b536b..0000000 --- a/src/edify/library/date.py +++ /dev/null @@ -1,34 +0,0 @@ -import re - -date_pattern = "^[0-9]{1,2}\\/[0-9]{1,2}\\/[0-9]{4}$" -iso_date_pattern = "^(?:\\d{4})-(?:\\d{2})-(?:\\d{2})T(?:\\d{2}):(?:\\d{2}):(?:\\d{2}(?:\\.\\d*)?)(?:(?:-(?:\\d{2}):(?:\\d{2})|Z)?)$" # noqa - - -def date(date: str) -> bool: - """Checks if a string is a valid date. - - Args: - date (str): The string to check. - Returns: - bool: True if the string is a valid date, False otherwise. - """ - - if re.match(date_pattern, date): - return True - else: - return False - - -def iso_date(date: str) -> bool: - """Checks if a string is a valid ISO date. - - Args: - date (str): The string to check. - Returns: - bool: True if the string is a valid ISO date, False otherwise. - """ - - if re.match(iso_date_pattern, date): - return True - else: - return False diff --git a/src/edify/library/guid.py b/src/edify/library/guid.py deleted file mode 100644 index be37efd..0000000 --- a/src/edify/library/guid.py +++ /dev/null @@ -1,15 +0,0 @@ -import re - -pattern = "^(?:\\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\\}{0,1})$" - - -def guid(guid: str) -> bool: - """Check if the given string is a valid GUID. - - Args: - guid (str): The string to check. - - Returns: - bool: True if the string is a valid GUID, False otherwise. - """ - return bool(re.match(pattern, guid)) diff --git a/src/edify/library/ip.py b/src/edify/library/ip.py deleted file mode 100644 index d04b69b..0000000 --- a/src/edify/library/ip.py +++ /dev/null @@ -1,34 +0,0 @@ -import re - -ipv4_pattern = "^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" # noqa -ipv6_pattern = "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$" # noqa - - -def ipv4(ip: str) -> bool: - """Checks if a string is a valid IPv4 address. - - Args: - ip (str): The string to check. - Returns: - bool: True if the string is a valid IPv4 address, False otherwise. - """ - - if re.match(ipv4_pattern, ip): - return True - else: - return False - - -def ipv6(ip: str) -> bool: - """Checks if a string is a valid IPv6 address. - - Args: - ip (str): The string to check. - Returns: - bool: True if the string is a valid IPv6 address, False otherwise. - """ - - if re.match(ipv6_pattern, ip): - return True - else: - return False diff --git a/src/edify/library/mac.py b/src/edify/library/mac.py deleted file mode 100644 index 9fba14c..0000000 --- a/src/edify/library/mac.py +++ /dev/null @@ -1,17 +0,0 @@ -import re - -mac_address_validate_pattern = "^(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2})$" - - -def mac(mac: str) -> bool: - """Validate a MAC (IEEE 802) address. - - Args: - mac (str): The MAC address to validate. - - Returns: - bool: True if the MAC address is valid, False otherwise. - """ - if not isinstance(mac, str): - return False - return bool(re.match(mac_address_validate_pattern, mac)) diff --git a/src/edify/library/mail.py b/src/edify/library/mail.py deleted file mode 100644 index 6797bf2..0000000 --- a/src/edify/library/mail.py +++ /dev/null @@ -1,34 +0,0 @@ -import re - -pattern = r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" # noqa -rfc_5322_pattern = "(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])" # noqa - - -def email(email: str) -> bool: - """Checks if a string is a valid email address. - - Args: - email (str): The string to check. - Returns: - bool: True if the string is a valid email address, False otherwise. - """ - - if re.match(pattern, email): - return True - else: - return False - - -def email_rfc_5322(email: str) -> bool: - """Checks if a string is a valid email address. - - Args: - email (str): The string to check. - Returns: - bool: True if the string is a valid email address, False otherwise. - """ - - if re.match(rfc_5322_pattern, email): - return True - else: - return False diff --git a/src/edify/library/password.py b/src/edify/library/password.py deleted file mode 100644 index 09e9d5f..0000000 --- a/src/edify/library/password.py +++ /dev/null @@ -1,40 +0,0 @@ -import re - - -def password( - password: str, - min_length: int = 8, - max_length: int = 64, - min_upper: int = 1, - min_lower: int = 1, - min_digit: int = 1, - min_special: int = 1, - special_chars: str = "!@#$%^&*()_+-=[]{}|;':\",./<>?", -) -> bool: - """Check if the given string is a valid password. - - Args: - password (str): The string to check. - min_length (int): The minimum length of the password. - max_length (int): The maximum length of the password. - min_upper (int): The minimum number of upper case characters. - min_lower (int): The minimum number of lower case characters. - min_digit (int): The minimum number of digits. - min_special (int): The minimum number of special characters. - special_chars (str): The special characters to check for. - - Returns: - bool: True if the string is a valid password, False otherwise. - """ - if len(password) < min_length or len(password) > max_length: - return False - - upper = re.findall("[A-Z]", password or "") - lower = re.findall("[a-z]", password or "") - digit = re.findall("[0-9]", password or "") - special = [c for c in password if c in special_chars] - - if len(upper) < min_upper or len(lower) < min_lower or len(digit) < min_digit or len(special) < min_special: - return False - - return True diff --git a/src/edify/library/phone.py b/src/edify/library/phone.py deleted file mode 100644 index 362fa97..0000000 --- a/src/edify/library/phone.py +++ /dev/null @@ -1,19 +0,0 @@ -import re - -pattern = "^\\+?\\d{1,4}?[-.\\s]?\\(?\\d{1,3}?\\)?[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,9}$" -special_pattern = r"^\d{2,4}" - - -def phone_number(phone: str) -> bool: - """Checks if a string is a valid phone number. - - Args: - phone (str): The string to check. - Returns: - bool: True if the string is a valid phone number, False otherwise. - """ - - if re.match(pattern, phone) or re.match(special_pattern, phone): - return True - else: - return False diff --git a/src/edify/library/ssn.py b/src/edify/library/ssn.py deleted file mode 100644 index 9c9dcf5..0000000 --- a/src/edify/library/ssn.py +++ /dev/null @@ -1,17 +0,0 @@ -import re - -ssn_validate_pattern = "^(?!666|000|9\\d{2})\\d{3}-(?!00)\\d{2}-(?!0{4})\\d{4}$" - - -def ssn(ssn: str) -> bool: - """Validate a Social Security Number (SSN). - - Args: - ssn (str): The SSN to validate. - - Returns: - bool: True if the SSN is valid, False otherwise. - """ - if not isinstance(ssn, str): - return False - return bool(re.match(ssn_validate_pattern, ssn)) diff --git a/src/edify/library/url.py b/src/edify/library/url.py deleted file mode 100644 index 1094155..0000000 --- a/src/edify/library/url.py +++ /dev/null @@ -1,39 +0,0 @@ -import re - -proto = "^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" -no_proto = "^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" - - -def url(url: str, match: list = ["proto", "no_proto"]) -> bool: - """Checks if a string is a valid URL. - - Args: - url (str): The string to check. - match (list): The protocols to match against. Defaults to ["https", "http", "no_proto"]. - Returns: - bool: True if the string is a valid URL, False otherwise. - """ - - # Validate match argument - if not isinstance(match, list): - raise TypeError("match argument must be a list") - - if not match: - raise ValueError("match argument must not be empty") - - # Validate protocols - protocols = [] - for protocol in match: - if protocol == "proto": - protocols.append(proto) - elif protocol == "no_proto": - protocols.append(no_proto) - else: - raise ValueError("Invalid protocol: {}".format(protocol)) - - # Check if URL matches any of the protocols - for protocol in protocols: - if re.match(protocol, url): - return True - - return False diff --git a/src/edify/library/uuid.py b/src/edify/library/uuid.py deleted file mode 100644 index 8c240a3..0000000 --- a/src/edify/library/uuid.py +++ /dev/null @@ -1,14 +0,0 @@ -import re - -pattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$" - - -def uuid(uuid: str) -> bool: - """Checks if a string is a valid UUID. - - Args: - uuid (str): The string to check. - Returns: - bool: True if the string is a valid UUID, False otherwise. - """ - return re.match(pattern, uuid) is not None diff --git a/src/edify/library/zip.py b/src/edify/library/zip.py deleted file mode 100644 index 017f57d..0000000 --- a/src/edify/library/zip.py +++ /dev/null @@ -1,29 +0,0 @@ -import re - -from .support.zip import ZIP_LOCALES - -locales = [locale["abbrev"] for locale in ZIP_LOCALES] - - -def zip(zip: str, locale: str = "US") -> bool: - """Check if a string is a valid zip code. - - Args: - zip (str): The string to check. - locale (str): (optional) The locale to check against. Defaults to "US". - Returns: - bool: True if the string is a valid zip code, False otherwise. - """ - - if not isinstance(locale, str): - raise TypeError("locale must be a string") - - if locale == "": - raise ValueError("locale cannot be empty") - - if locale not in locales: - print(locales) - raise ValueError("locale must be one of {}".format(locales)) - - pattern = ZIP_LOCALES[locales.index(locale)]["zip"] - return re.match(pattern, zip) is not None diff --git a/tests.local.sh b/tests.local.sh deleted file mode 100755 index 773e279..0000000 --- a/tests.local.sh +++ /dev/null @@ -1,26 +0,0 @@ -# Clean tox environment -tox -e clean - -# Sort imports -isort . - -# Run Tests -tox -e check -v - -# Run Docs -tox -e docs -v - -# Run the tox env matching the current Python version -PY_NODOT=$(python3 -c 'import sys; print("{0.major}{0.minor}".format(sys.version_info))') -TOX_ENV="py${PY_NODOT}" - -if tox --listenvs-all | grep -qE "^${TOX_ENV}$"; then - tox -e "${TOX_ENV}" -v -else - PY_DOTTED=$(python3 -c 'import sys; print("{0.major}.{0.minor}".format(sys.version_info))') - echo "Python ${PY_DOTTED} (tox env ${TOX_ENV}) is not in tox envlist" - exit 1 -fi - -# Run Coverage -tox -e report -v diff --git a/tests/builder/alternation.test.py b/tests/builder/alternation.test.py new file mode 100644 index 0000000..8d7fe82 --- /dev/null +++ b/tests/builder/alternation.test.py @@ -0,0 +1,13 @@ +"""Tests for alternation rendering branches not covered by the main builder tests.""" + +from edify import RegexBuilder + + +def test_any_of_with_only_non_fusable_members(): + expr = RegexBuilder().any_of().string("hello").digit().end() + assert expr.to_regex_string() == "(?:hello|\\d)" + + +def test_any_of_with_any_of_chars_member(): + expr = RegexBuilder().any_of().any_of_chars("xyz").end() + assert expr.to_regex_string() == "[xyz]" diff --git a/tests/builder/builder.test.py b/tests/builder/builder.test.py new file mode 100644 index 0000000..062e1ae --- /dev/null +++ b/tests/builder/builder.test.py @@ -0,0 +1,554 @@ +import re + +import pytest + +from edify import RegexBuilder +from edify.errors.anchors import StartInputAlreadyDefinedError +from edify.errors.captures import InvalidTotalCaptureGroupsIndexError +from edify.errors.input import MustBeInstanceError, MustBeSingleCharacterError +from edify.errors.naming import ( + CannotCreateDuplicateNamedGroupError, + NamedGroupDoesNotExistError, + NameNotValidError, +) +from edify.errors.structure import CannotEndWhileBuildingRootExpressionError + +simple_se = RegexBuilder().string("hello").any_char().string("world") +flags_se = RegexBuilder().multi_line().ignore_case().string("hello").any_char().string("world") +start_end_se = ( + RegexBuilder().start_of_input().string("hello").any_char().string("world").end_of_input() +) +nc_se = ( + RegexBuilder() + .named_capture("module") + .exactly(2) + .any_char() + .end() + .named_back_reference("module") +) +indexed_back_reference_se = RegexBuilder().capture().exactly(2).any_char().end().back_reference(1) +nested_se = RegexBuilder().exactly(2).any_char() +first_layer_se = ( + RegexBuilder() + .string("outer begin") + .named_capture("inner_subexpression") + .optional() + .subexpression(nested_se) + .end() + .string("outer end") +) + + +def regex_equality(regex, rb_expression): + regex_str = str(regex) + rb_expression_str = rb_expression.to_regex_string() + assert regex_str == str(rb_expression_str) + + +def regex_compilation(regex, rb_expression, f=0): + rb_expression_c = rb_expression.to_regex() + assert re.compile(regex, flags=f) == rb_expression_c + + +def test_empty_regex(): + expr = RegexBuilder() + regex_equality("(?:)", expr) + regex_compilation("(?:)", expr) + + +def test_flag_a(): + expr = RegexBuilder().ascii_only() + regex_equality("(?:)", expr) + regex_compilation("(?:)", expr, re.A) + + +def test_flag_d(): + expr = RegexBuilder().debug() + regex_equality("(?:)", expr) + regex_compilation("(?:)", expr, re.DEBUG) + + +def test_flag_i(): + expr = RegexBuilder().ignore_case() + regex_equality("(?:)", expr) + regex_compilation("(?:)", expr, re.I) + + +def test_flag_m(): + expr = RegexBuilder().multi_line() + regex_equality("(?:)", expr) + regex_compilation("(?:)", expr, re.M) + + +def test_flag_s(): + expr = RegexBuilder().dot_all() + regex_equality("(?:)", expr) + regex_compilation("(?:)", expr, re.S) + + +def test_flag_x(): + expr = RegexBuilder().verbose() + regex_equality("(?:)", expr) + regex_compilation("(?:)", expr, re.X) + + +def test_any_char(): + expr = RegexBuilder().any_char() + regex_equality(".", expr) + regex_compilation(".", expr) + + +def test_whitespace_char(): + expr = RegexBuilder().whitespace_char() + regex_equality("\\s", expr) + regex_compilation("\\s", expr) + + +def test_non_whitespace_char(): + expr = RegexBuilder().non_whitespace_char() + regex_equality("\\S", expr) + regex_compilation("\\S", expr) + + +def test_digit(): + expr = RegexBuilder().digit() + regex_equality("\\d", expr) + regex_compilation("\\d", expr) + + +def test_non_digit(): + expr = RegexBuilder().non_digit() + regex_equality("\\D", expr) + regex_compilation("\\D", expr) + + +def test_word(): + expr = RegexBuilder().word() + regex_equality("\\w", expr) + regex_compilation("\\w", expr) + + +def test_non_word(): + expr = RegexBuilder().non_word() + regex_equality("\\W", expr) + regex_compilation("\\W", expr) + + +def test_word_boundary(): + expr = RegexBuilder().word_boundary() + regex_equality("\\b", expr) + regex_compilation("\\b", expr) + + +def test_non_word_boundary(): + expr = RegexBuilder().non_word_boundary() + regex_equality("\\B", expr) + regex_compilation("\\B", expr) + + +def test_new_line(): + expr = RegexBuilder().new_line() + regex_equality("\\n", expr) + regex_compilation("\\n", expr) + + +def test_carriage_return(): + expr = RegexBuilder().carriage_return() + regex_equality("\\r", expr) + regex_compilation("\\r", expr) + + +def test_tab(): + expr = RegexBuilder().tab() + regex_equality("\\t", expr) + regex_compilation("\\t", expr) + + +def test_null_byte(): + expr = RegexBuilder().null_byte() + regex_equality("\\0", expr) + regex_compilation("\\0", expr) + + +def test_any_of_basic(): + expr = RegexBuilder().any_of().string("hello").digit().word().char(".").char("#").end() + regex_equality("(?:hello|\\d|\\w|[\\.\\#])", expr) + regex_compilation("(?:hello|\\d|\\w|[\\.\\#])", expr) + + +def test_any_of_range_fusion(): + expr = ( + RegexBuilder() + .any_of() + .range("a", "z") + .range("A", "Z") + .range("0", "9") + .char(".") + .char("#") + .end() + ) + regex_equality("[a-zA-Z0-9\\.\\#]", expr) + regex_compilation("[a-zA-Z0-9\\.\\#]", expr) + + +def test_any_of_range_fusion_with_other_choices(): + expr = ( + RegexBuilder() + .any_of() + .range("a", "z") + .range("A", "Z") + .range("0", "9") + .char(".") + .char("#") + .string("hello") + .end() + ) + regex_equality("(?:hello|[a-zA-Z0-9\\.\\#])", expr) + regex_compilation("(?:hello|[a-zA-Z0-9\\.\\#])", expr) + + +def test_capture(): + expr = RegexBuilder().capture().string("hello ").word().char("!").end() + regex_equality("(hello \\w!)", expr) + regex_compilation("(hello \\w!)", expr) + + +def test_named_capture(): + expr = RegexBuilder().named_capture("this_is_the_name").string("hello ").word().char("!").end() + regex_equality("(?P<this_is_the_name>hello \\w!)", expr) + regex_compilation("(?P<this_is_the_name>hello \\w!)", expr) + + +def test_bad_name_error(): + with pytest.raises(NameNotValidError): + (RegexBuilder().named_capture("hello world").string("hello ").word().char("!").end()) + + +def test_same_name_error(): + with pytest.raises(CannotCreateDuplicateNamedGroupError): + ( + RegexBuilder() + .named_capture("hello") + .string("hello ") + .word() + .char("!") + .end() + .named_capture("hello") + .string("hello ") + .word() + .char("!") + .end() + ) + + +def test_named_back_reference(): + expr = ( + RegexBuilder() + .named_capture("this_is_the_name") + .string("hello ") + .word() + .char("!") + .end() + .named_back_reference("this_is_the_name") + ) + regex_equality("(?P<this_is_the_name>hello \\w!)(?P=this_is_the_name)", expr) + regex_compilation("(?P<this_is_the_name>hello \\w!)(?P=this_is_the_name)", expr) + + +def test_named_back_reference_no_cg_exists(): + with pytest.raises(NamedGroupDoesNotExistError): + RegexBuilder().named_back_reference("not_here") + + +def test_back_reference(): + expr = RegexBuilder().capture().string("hello ").word().char("!").end().back_reference(1) + regex_equality("(hello \\w!)\\1", expr) + regex_compilation("(hello \\w!)\\1", expr) + + +def test_back_reference_no_cg_exists(): + with pytest.raises(InvalidTotalCaptureGroupsIndexError): + RegexBuilder().back_reference(1) + + +def test_group(): + expr = RegexBuilder().group().string("hello ").word().char("!").end() + regex_equality("(?:hello \\w!)", expr) + regex_compilation("(?:hello \\w!)", expr) + + +def test_error_when_called_with_no_stack(): + with pytest.raises(CannotEndWhileBuildingRootExpressionError): + RegexBuilder().end() + + +def test_assert_ahead(): + expr = RegexBuilder().assert_ahead().range("a", "f").end().range("a", "z") + regex_equality("(?=[a-f])[a-z]", expr) + regex_compilation("(?=[a-f])[a-z]", expr) + + +def test_assert_behind(): + expr = RegexBuilder().assert_behind().string("hello ").end().range("a", "z") + regex_equality("(?<=hello )[a-z]", expr) + regex_compilation("(?<=hello )[a-z]", expr) + + +def test_assert_not_ahead(): + expr = RegexBuilder().assert_not_ahead().range("a", "f").end().range("0", "9") + regex_equality("(?![a-f])[0-9]", expr) + regex_compilation("(?![a-f])[0-9]", expr) + + +def test_assert_not_behind(): + expr = RegexBuilder().assert_not_behind().string("hello ").end().range("a", "z") + regex_equality("(?<!hello )[a-z]", expr) + regex_compilation("(?<!hello )[a-z]", expr) + + +def test_optional(): + expr = RegexBuilder().optional().word() + regex_equality("\\w?", expr) + regex_compilation("\\w?", expr) + + +def test_zero_or_more(): + expr = RegexBuilder().zero_or_more().word() + regex_equality("\\w*", expr) + regex_compilation("\\w*", expr) + + +def test_zero_or_more_lazy(): + expr = RegexBuilder().zero_or_more_lazy().word() + regex_equality("\\w*?", expr) + regex_compilation("\\w*?", expr) + + +def test_one_or_more(): + expr = RegexBuilder().one_or_more().word() + regex_equality("\\w+", expr) + regex_compilation("\\w+", expr) + + +def test_one_or_more_lazy(): + expr = RegexBuilder().one_or_more_lazy().word() + regex_equality("\\w+?", expr) + regex_compilation("\\w+?", expr) + + +def test_exactly(): + expr = RegexBuilder().exactly(3).word() + regex_equality("\\w{3}", expr) + regex_compilation("\\w{3}", expr) + + +def test_at_least(): + expr = RegexBuilder().at_least(3).word() + regex_equality("\\w{3,}", expr) + regex_compilation("\\w{3,}", expr) + + +def test_between(): + expr = RegexBuilder().between(3, 5).word() + regex_equality("\\w{3,5}", expr) + regex_compilation("\\w{3,5}", expr) + + +def test_between_lazy(): + expr = RegexBuilder().between_lazy(3, 5).word() + regex_equality("\\w{3,5}?", expr) + regex_compilation("\\w{3,5}?", expr) + + +def test_start_of_input(): + expr = RegexBuilder().start_of_input() + regex_equality("^", expr) + regex_compilation("^", expr) + + +def test_end_of_input(): + expr = RegexBuilder().end_of_input() + regex_equality("$", expr) + regex_compilation("$", expr) + + +def test_any_of_chars(): + expr = RegexBuilder().any_of_chars("aeiou.-") + regex_equality("[aeiou\\.\\-]", expr) + regex_compilation("[aeiou\\.\\-]", expr) + + +def test_anything_but_chars(): + expr = RegexBuilder().anything_but_chars("aeiou.-") + regex_equality("[^aeiou\\.\\-]", expr) + regex_compilation("[^aeiou\\.\\-]", expr) + + +def test_anything_but_string(): + expr = RegexBuilder().anything_but_string("aeiou.") + regex_equality("(?:[^a][^e][^i][^o][^u][^\\][^.])", expr) + regex_compilation("(?:[^a][^e][^i][^o][^u][^\\][^.])", expr) + + +def test_anything_but_range(): + expr = RegexBuilder().anything_but_range("a", "z") + regex_equality("[^a-z]", expr) + regex_compilation("[^a-z]", expr) + expr = RegexBuilder().anything_but_range("0", "9") + regex_equality("[^0-9]", expr) + regex_compilation("[^0-9]", expr) + + +def test_string(): + expr = RegexBuilder().string("hello") + regex_equality("hello", expr) + regex_compilation("hello", expr) + + +def test_string_escapes_special_chars_with_strings_of_len_1(): + expr = RegexBuilder().string("^").string("hello") + regex_equality("\\^hello", expr) + regex_compilation("\\^hello", expr) + + +def test_char(): + expr = RegexBuilder().char("a") + regex_equality("a", expr) + regex_compilation("a", expr) + + +def test_char_more_than_one_error(): + with pytest.raises(MustBeSingleCharacterError): + RegexBuilder().char("hello") + + +def test_range(): + expr = RegexBuilder().range("a", "z") + regex_equality("[a-z]", expr) + regex_compilation("[a-z]", expr) + + +def test_must_be_instance_error(): + with pytest.raises(MustBeInstanceError): + RegexBuilder().subexpression("nope") + + +def test_simple_se(): + expr = ( + RegexBuilder() + .start_of_input() + .at_least(3) + .digit() + .subexpression(simple_se) + .range("0", "9") + .end_of_input() + ) + regex_equality("^\\d{3,}hello.world[0-9]$", expr) + regex_compilation("^\\d{3,}hello.world[0-9]$", expr) + + +def test_simple_quantified_se(): + expr = ( + RegexBuilder() + .start_of_input() + .at_least(3) + .digit() + .one_or_more() + .subexpression(simple_se) + .range("0", "9") + .end_of_input() + ) + regex_equality("^\\d{3,}(?:hello.world)+[0-9]$", expr) + regex_compilation("^\\d{3,}(?:hello.world)+[0-9]$", expr) + + +def test_flags_se(): + expr = ( + RegexBuilder() + .dot_all() + .start_of_input() + .at_least(3) + .digit() + .subexpression(flags_se, ignore_flags=False) + .range("0", "9") + .end_of_input() + ) + regex_equality("^\\d{3,}hello.world[0-9]$", expr) + regex_compilation("^\\d{3,}hello.world[0-9]$", expr, f=re.M | re.I | re.S) + + +def test_flags_se_ignore_flags(): + expr = ( + RegexBuilder() + .dot_all() + .start_of_input() + .at_least(3) + .digit() + .subexpression(flags_se) + .range("0", "9") + .end_of_input() + ) + regex_equality("^\\d{3,}hello.world[0-9]$", expr) + regex_compilation("^\\d{3,}hello.world[0-9]$", expr, f=re.S) + + +def test_ignore_start_and_end(): + expr = RegexBuilder().at_least(3).digit().subexpression(start_end_se).range("0", "9") + regex_equality("\\d{3,}hello.world[0-9]", expr) + regex_compilation("\\d{3,}hello.world[0-9]", expr) + + +def test_start_defined_in_me_and_se(): + with pytest.raises(StartInputAlreadyDefinedError): + ( + RegexBuilder() + .start_of_input() + .at_least(3) + .digit() + .subexpression(start_end_se, ignore_start_and_end=False) + .range("0", "9") + ) + + +def test_no_namespacing(): + expr = RegexBuilder().at_least(3).digit().subexpression(nc_se).range("0", "9") + regex_equality("\\d{3,}(?P<module>.{2})(?P=module)[0-9]", expr) + regex_compilation("\\d{3,}(?P<module>.{2})(?P=module)[0-9]", expr) + + +def test_namespacing(): + expr = RegexBuilder().at_least(3).digit().subexpression(nc_se, namespace="yolo").range("0", "9") + regex_equality("\\d{3,}(?P<yolomodule>.{2})(?P=yolomodule)[0-9]", expr) + regex_compilation("\\d{3,}(?P<yolomodule>.{2})(?P=yolomodule)[0-9]", expr) + + +def test_indexed_back_referencing(): + expr = ( + RegexBuilder() + .capture() + .at_least(3) + .digit() + .end() + .subexpression(indexed_back_reference_se) + .back_reference(1) + .range("0", "9") + ) + regex_equality("(\\d{3,})(.{2})\\2\\1[0-9]", expr) + regex_compilation("(\\d{3,})(.{2})\\2\\1[0-9]", expr) + + +def test_deeply_nested_se(): + expr = ( + RegexBuilder() + .capture() + .at_least(3) + .digit() + .end() + .subexpression(first_layer_se) + .back_reference(1) + .range("0", "9") + ) + regex_equality("(\\d{3,})outer begin(?P<inner_subexpression>(?:.{2})?)outer end\\1[0-9]", expr) + regex_compilation( + "(\\d{3,})outer begin(?P<inner_subexpression>(?:.{2})?)outer end\\1[0-9]", expr + ) diff --git a/tests/builder/conflict.test.py b/tests/builder/conflict.test.py new file mode 100644 index 0000000..b683c06 --- /dev/null +++ b/tests/builder/conflict.test.py @@ -0,0 +1,20 @@ +"""Tests for anchor conflicts when subexpressions merge with ``ignore_start_and_end=False``.""" + +import pytest + +from edify import RegexBuilder +from edify.errors.anchors import EndInputAlreadyDefinedError, StartInputAlreadyDefinedError + + +def test_parent_with_start_merging_sub_with_start_raises(): + sub = RegexBuilder().start_of_input().digit() + parent = RegexBuilder().start_of_input().digit() + with pytest.raises(StartInputAlreadyDefinedError): + parent.subexpression(sub, ignore_start_and_end=False) + + +def test_parent_with_end_merging_sub_with_end_raises(): + sub = RegexBuilder().digit().end_of_input() + parent = RegexBuilder().digit().end_of_input() + with pytest.raises(EndInputAlreadyDefinedError): + parent.subexpression(sub, ignore_start_and_end=False) diff --git a/tests/builder/frame.test.py b/tests/builder/frame.test.py new file mode 100644 index 0000000..ffd2242 --- /dev/null +++ b/tests/builder/frame.test.py @@ -0,0 +1,17 @@ +"""Test for the ``UnexpectedFrameTypeError`` raise when an AST is built incorrectly.""" + +import pytest + +from edify import RegexBuilder +from edify.builder.types.frame import StackFrame +from edify.elements.types.leaves import DigitElement +from edify.errors.internal import UnexpectedFrameTypeError + + +def test_end_on_unrecognised_frame_type_raises(): + builder = RegexBuilder().capture() + stray_frame = StackFrame(type_node=DigitElement()) + new_state = builder._state.with_frame_pushed(stray_frame) + builder_with_stray = builder._with_state(new_state) + with pytest.raises(UnexpectedFrameTypeError, match="DigitElement"): + builder_with_stray.end() diff --git a/tests/builder/merge.test.py b/tests/builder/merge.test.py new file mode 100644 index 0000000..cc4d513 --- /dev/null +++ b/tests/builder/merge.test.py @@ -0,0 +1,123 @@ +"""Tests that exercise every element kind through the subexpression merge path. + +Each subexpression is built with one particular element kind and merged +into a parent; the resulting regex string is asserted byte-for-byte so the +per-type branches in :mod:`edify.builder.merge` all get exercised. +""" + +from edify import RegexBuilder + + +def _merge(sub: RegexBuilder) -> str: + return RegexBuilder().subexpression(sub).to_regex_string() + + +def test_subexpression_with_capture(): + sub = RegexBuilder().capture().digit().end() + assert _merge(sub) == "(\\d)" + + +def test_subexpression_with_named_capture(): + sub = RegexBuilder().named_capture("token").digit().end() + assert _merge(sub) == "(?P<token>\\d)" + + +def test_subexpression_with_back_reference(): + sub = RegexBuilder().capture().digit().end().back_reference(1) + assert _merge(sub) == "(\\d)\\1" + + +def test_subexpression_with_named_back_reference(): + sub = RegexBuilder().named_capture("token").digit().end().named_back_reference("token") + assert _merge(sub) == "(?P<token>\\d)(?P=token)" + + +def test_subexpression_with_group(): + sub = RegexBuilder().group().digit().end() + assert _merge(sub) == "(?:\\d)" + + +def test_subexpression_with_any_of(): + sub = RegexBuilder().any_of().char("a").char("b").end() + assert _merge(sub) == "[ab]" + + +def test_subexpression_with_nested_subexpression(): + inner = RegexBuilder().digit() + middle = RegexBuilder().subexpression(inner) + assert _merge(middle) == "\\d" + + +def test_subexpression_with_assert_ahead(): + sub = RegexBuilder().assert_ahead().digit().end() + assert _merge(sub) == "(?=\\d)" + + +def test_subexpression_with_assert_not_ahead(): + sub = RegexBuilder().assert_not_ahead().digit().end() + assert _merge(sub) == "(?!\\d)" + + +def test_subexpression_with_assert_behind(): + sub = RegexBuilder().assert_behind().digit().end() + assert _merge(sub) == "(?<=\\d)" + + +def test_subexpression_with_assert_not_behind(): + sub = RegexBuilder().assert_not_behind().digit().end() + assert _merge(sub) == "(?<!\\d)" + + +def test_subexpression_with_optional(): + sub = RegexBuilder().optional().digit() + assert _merge(sub) == "\\d?" + + +def test_subexpression_with_zero_or_more(): + sub = RegexBuilder().zero_or_more().digit() + assert _merge(sub) == "\\d*" + + +def test_subexpression_with_zero_or_more_lazy(): + sub = RegexBuilder().zero_or_more_lazy().digit() + assert _merge(sub) == "\\d*?" + + +def test_subexpression_with_one_or_more(): + sub = RegexBuilder().one_or_more().digit() + assert _merge(sub) == "\\d+" + + +def test_subexpression_with_one_or_more_lazy(): + sub = RegexBuilder().one_or_more_lazy().digit() + assert _merge(sub) == "\\d+?" + + +def test_subexpression_with_exactly(): + sub = RegexBuilder().exactly(3).digit() + assert _merge(sub) == "\\d{3}" + + +def test_subexpression_with_at_least(): + sub = RegexBuilder().at_least(2).digit() + assert _merge(sub) == "\\d{2,}" + + +def test_subexpression_with_between(): + sub = RegexBuilder().between(1, 4).digit() + assert _merge(sub) == "\\d{1,4}" + + +def test_subexpression_with_between_lazy(): + sub = RegexBuilder().between_lazy(1, 4).digit() + assert _merge(sub) == "\\d{1,4}?" + + +def test_subexpression_with_start_of_input_collapses_to_noop(): + sub = RegexBuilder().start_of_input().digit() + assert _merge(sub) == "\\d" + + +def test_subexpression_with_end_of_input_collapses_to_noop(): + sub = RegexBuilder().digit().end_of_input() + assert _merge(sub) == "\\d" diff --git a/tests/builder/passthrough.test.py b/tests/builder/passthrough.test.py new file mode 100644 index 0000000..de45146 --- /dev/null +++ b/tests/builder/passthrough.test.py @@ -0,0 +1,40 @@ +"""Tests for the pass-through branches when subexpression anchors merge without conflict.""" + +from edify import RegexBuilder + + +def test_start_of_input_merges_into_parent_without_existing_start(): + sub = RegexBuilder().start_of_input().digit() + parent = RegexBuilder().digit() + pattern = parent.subexpression(sub, ignore_start_and_end=False).to_regex_string() + assert "^" in pattern + + +def test_end_of_input_merges_into_parent_without_existing_end(): + sub = RegexBuilder().digit().end_of_input() + parent = RegexBuilder().digit() + pattern = parent.subexpression(sub, ignore_start_and_end=False).to_regex_string() + assert "$" in pattern + + +def test_subexpression_called_with_unfinished_expression_raises(): + import pytest + + from edify.errors.structure import CannotCallSubexpressionError + + unfinished_sub = RegexBuilder().capture().digit() + parent = RegexBuilder() + with pytest.raises(CannotCallSubexpressionError): + parent.subexpression(unfinished_sub) + + +def test_to_regex_with_invalid_pattern_raises_failed_to_compile(): + import pytest + + from edify.errors.internal import FailedToCompileRegexError + + expr = RegexBuilder().capture().digit().end().back_reference(1) + state_with_extra = expr._state.with_capture_groups_added(98) + bogus = expr._with_state(state_with_extra).back_reference(99) + with pytest.raises(FailedToCompileRegexError): + bogus.to_regex() diff --git a/tests/builder/validation.test.py b/tests/builder/validation.test.py new file mode 100644 index 0000000..e4898ad --- /dev/null +++ b/tests/builder/validation.test.py @@ -0,0 +1,157 @@ +"""Tests for the validation raise paths across every builder mixin. + +Each test triggers exactly one of the per-method input-validation branches +that the happy-path builder tests never hit. +""" + +import pytest + +from edify import RegexBuilder +from edify.errors.anchors import ( + CannotDefineStartAfterEndError, + EndInputAlreadyDefinedError, + StartInputAlreadyDefinedError, +) +from edify.errors.input import ( + MustBeAStringError, + MustBeIntegerGreaterThanZeroError, + MustBeLessThanError, + MustBeOneCharacterError, + MustBePositiveIntegerError, + MustBeSingleCharacterError, + MustHaveASmallerValueError, +) +from edify.errors.naming import NamedGroupDoesNotExistError + + +def test_start_of_input_twice_raises(): + with pytest.raises(StartInputAlreadyDefinedError): + RegexBuilder().start_of_input().start_of_input() + + +def test_start_of_input_after_end_raises(): + with pytest.raises(CannotDefineStartAfterEndError): + RegexBuilder().end_of_input().start_of_input() + + +def test_end_of_input_twice_raises(): + with pytest.raises(EndInputAlreadyDefinedError): + RegexBuilder().end_of_input().end_of_input() + + +def test_named_capture_non_string_raises(): + with pytest.raises(MustBeAStringError): + RegexBuilder().named_capture(42) + + +def test_named_capture_empty_string_raises(): + with pytest.raises(MustBeOneCharacterError): + RegexBuilder().named_capture("") + + +def test_string_non_string_raises(): + with pytest.raises(MustBeAStringError): + RegexBuilder().string(42) + + +def test_string_empty_raises(): + with pytest.raises(MustBeOneCharacterError): + RegexBuilder().string("") + + +def test_char_non_string_raises(): + with pytest.raises(MustBeAStringError): + RegexBuilder().char(42) + + +def test_range_first_codepoint_not_less_than_second_raises(): + with pytest.raises(MustHaveASmallerValueError): + RegexBuilder().range("z", "a") + + +def test_anything_but_string_non_string_raises(): + with pytest.raises(MustBeAStringError): + RegexBuilder().anything_but_string(42) + + +def test_anything_but_string_empty_raises(): + with pytest.raises(MustBeOneCharacterError): + RegexBuilder().anything_but_string("") + + +def test_anything_but_chars_non_string_raises(): + with pytest.raises(MustBeAStringError): + RegexBuilder().anything_but_chars(42) + + +def test_anything_but_chars_empty_raises(): + with pytest.raises(MustBeOneCharacterError): + RegexBuilder().anything_but_chars("") + + +def test_anything_but_range_multi_char_raises(): + with pytest.raises(MustBeSingleCharacterError): + RegexBuilder().anything_but_range("abc", "z") + + +def test_anything_but_range_ascending_raises(): + with pytest.raises(MustHaveASmallerValueError): + RegexBuilder().anything_but_range("z", "a") + + +def test_exactly_non_positive_raises(): + with pytest.raises(MustBePositiveIntegerError): + RegexBuilder().exactly(0).digit() + + +def test_at_least_non_positive_raises(): + with pytest.raises(MustBePositiveIntegerError): + RegexBuilder().at_least(-1).digit() + + +def test_between_negative_lower_raises(): + with pytest.raises(MustBeIntegerGreaterThanZeroError): + RegexBuilder().between(-1, 5).digit() + + +def test_between_lower_not_less_than_upper_raises(): + with pytest.raises(MustBeLessThanError): + RegexBuilder().between(5, 5).digit() + + +def test_between_lazy_negative_lower_raises(): + with pytest.raises(MustBeIntegerGreaterThanZeroError): + RegexBuilder().between_lazy(-1, 5).digit() + + +def test_between_lazy_lower_not_less_than_upper_raises(): + with pytest.raises(MustBeLessThanError): + RegexBuilder().between_lazy(5, 5).digit() + + +def test_named_back_reference_undeclared_raises(): + with pytest.raises(NamedGroupDoesNotExistError): + RegexBuilder().named_back_reference("missing") + + +def test_to_regex_string_with_open_frame_raises(): + from edify.errors.structure import CannotCallSubexpressionError + + unfinished = RegexBuilder().capture().digit() + with pytest.raises(CannotCallSubexpressionError): + unfinished.to_regex_string() + + +def test_to_regex_with_open_frame_raises(): + from edify.errors.structure import CannotCallSubexpressionError + + unfinished = RegexBuilder().capture().digit() + with pytest.raises(CannotCallSubexpressionError): + unfinished.to_regex() + + +def test_subexpression_non_builder_raises(): + from edify.errors.input import MustBeInstanceError + + with pytest.raises(MustBeInstanceError): + RegexBuilder().subexpression("not a builder") diff --git a/tests/compile/invariants.test.py b/tests/compile/invariants.test.py new file mode 100644 index 0000000..2260427 --- /dev/null +++ b/tests/compile/invariants.test.py @@ -0,0 +1,30 @@ +"""Tests for the compile-path invariant raises that public-builder use never triggers. + +These exercise defensive branches that fire only when an AST is constructed +outside the builder (so the type system can't catch it). They guarantee +the typed exception is raised instead of a silent miscompilation. +""" + +import pytest + +from edify.compile.dispatch import render_element +from edify.compile.fuse import _fragment_for +from edify.elements.types.base import BaseElement +from edify.elements.types.leaves import DigitElement +from edify.errors.internal import NonFusableElementError, UnknownElementTypeError + + +class _StrayElement(BaseElement): + """A BaseElement subclass not in the ``Element`` union — used only here.""" + + +def test_render_element_unknown_type_raises(): + stray = _StrayElement() + with pytest.raises(UnknownElementTypeError, match="_StrayElement"): + render_element(stray) + + +def test_fragment_for_non_fusable_raises(): + non_fusable = DigitElement() + with pytest.raises(NonFusableElementError, match="DigitElement"): + _fragment_for(non_fusable) diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py new file mode 100644 index 0000000..12cfcd5 --- /dev/null +++ b/tests/errors/errors.test.py @@ -0,0 +1,153 @@ +from edify.errors.anchors import ( + CannotDefineStartAfterEndError, + EndInputAlreadyDefinedError, + StartInputAlreadyDefinedError, +) +from edify.errors.captures import InvalidTotalCaptureGroupsIndexError +from edify.errors.input import ( + MustBeAStringError, + MustBeInstanceError, + MustBeIntegerGreaterThanZeroError, + MustBeLessThanError, + MustBeOneCharacterError, + MustBePositiveIntegerError, + MustBeSingleCharacterError, + MustHaveASmallerValueError, +) +from edify.errors.internal import ( + FailedToCompileRegexError, + NonFusableElementError, + UnexpectedFrameTypeError, + UnknownElementTypeError, +) +from edify.errors.naming import ( + CannotCreateDuplicateNamedGroupError, + NamedGroupDoesNotExistError, + NameNotValidError, +) +from edify.errors.structure import ( + CannotCallSubexpressionError, + CannotEndWhileBuildingRootExpressionError, +) + + +def test_start_input_already_defined_outside_subexpression(): + error = StartInputAlreadyDefinedError() + assert "already has a start" in str(error) + assert "ignore_start_and_end" not in str(error) + + +def test_start_input_already_defined_in_subexpression(): + error = StartInputAlreadyDefinedError(in_subexpression=True) + assert "ignore_start_and_end" in str(error) + + +def test_end_input_already_defined_outside_subexpression(): + error = EndInputAlreadyDefinedError() + assert "already has an end" in str(error) + assert "ignore_start_and_end" not in str(error) + + +def test_end_input_already_defined_in_subexpression(): + error = EndInputAlreadyDefinedError(in_subexpression=True) + assert "ignore_start_and_end" in str(error) + + +def test_cannot_define_start_after_end(): + error = CannotDefineStartAfterEndError() + assert "start of input after defining an end" in str(error) + + +def test_invalid_total_capture_groups_index(): + error = InvalidTotalCaptureGroupsIndexError(5, 3) + assert "Invalid index #5" in str(error) + assert "only 3 capture groups" in str(error) + + +def test_must_be_a_string(): + error = MustBeAStringError("Name", "int") + assert "Name must be a string" in str(error) + assert "int" in str(error) + + +def test_must_be_one_character(): + error = MustBeOneCharacterError("Value") + assert "Value must be one character long" in str(error) + + +def test_must_be_single_character(): + error = MustBeSingleCharacterError("Value", "str") + assert "Value must be a single character" in str(error) + assert "str" in str(error) + + +def test_must_be_positive_integer(): + error = MustBePositiveIntegerError("count") + assert "count must be a positive integer" in str(error) + + +def test_must_be_integer_greater_than_zero(): + error = MustBeIntegerGreaterThanZeroError("x") + assert "x must be an integer greater than zero" in str(error) + + +def test_must_be_instance(): + error = MustBeInstanceError("Expression", "str", "RegexBuilder") + assert "Expression must be an instance of RegexBuilder" in str(error) + assert "str" in str(error) + + +def test_must_have_a_smaller_value(): + error = MustHaveASmallerValueError("z", "a") + assert "z must have a smaller character value than a" in str(error) + + +def test_must_be_less_than(): + error = MustBeLessThanError("X", "Y") + assert "X must be less than Y" in str(error) + + +def test_name_not_valid(): + error = NameNotValidError("bad name") + assert "Name bad name is not valid" in str(error) + + +def test_cannot_create_duplicate_named_group(): + error = CannotCreateDuplicateNamedGroupError("dup") + assert 'Can not create duplicate named group "dup"' in str(error) + + +def test_named_group_does_not_exist(): + error = NamedGroupDoesNotExistError("missing") + assert 'Named group "missing" does not exist' in str(error) + + +def test_cannot_end_while_building_root_expression(): + error = CannotEndWhileBuildingRootExpressionError() + assert "Can not end while building the root expression" in str(error) + + +def test_cannot_call_subexpression(): + error = CannotCallSubexpressionError("capture") + assert "Can not call subexpression" in str(error) + assert "capture" in str(error) + + +def test_unknown_element_type(): + error = UnknownElementTypeError("WeirdElement") + assert "WeirdElement" in str(error) + + +def test_non_fusable_element(): + error = NonFusableElementError("DigitElement") + assert "Cannot fuse element of type DigitElement" in str(error) + + +def test_unexpected_frame_type(): + error = UnexpectedFrameTypeError("DigitElement") + assert "Stack frame anchored at unexpected element type DigitElement" in str(error) + + +def test_failed_to_compile_regex(): + error = FailedToCompileRegexError("missing )") + assert "Cannot compile regex: missing )" in str(error) diff --git a/tests/library/date/basic.test.py b/tests/library/date/basic.test.py new file mode 100644 index 0000000..9b3f45a --- /dev/null +++ b/tests/library/date/basic.test.py @@ -0,0 +1,29 @@ +from edify.library import date + +_TEST_CASES = { + "1/1/2020": True, + "01/01/2020": True, + "1/01/2020": True, + "01/1/2020": True, + "1/1/20": False, + "01/01/20": False, + "1/1/202": False, + "01/01/202": False, + "12/12/2022": True, + "12/12/2": False, + "2021-11-04T22:32:47.142354-10:00": False, + "2021-11-04T22:32:47.142354Z": False, + "2021-11-04T22:32:47.142354": False, + "2021-11-04T22:32:47": False, + "2021-11-04T22:32": False, + "2021-11-04T22": False, + "2021-11-04": False, + "2021-11": False, + "2021": False, + "1-1-2020": False, +} + + +def test_date(): + for candidate, expectation in _TEST_CASES.items(): + assert date(candidate) is expectation diff --git a/tests/library/date/iso.test.py b/tests/library/date/iso.test.py new file mode 100644 index 0000000..de29a4c --- /dev/null +++ b/tests/library/date/iso.test.py @@ -0,0 +1,28 @@ +from edify.library import iso_date + +_TEST_CASES = { + "1/1/2020": False, + "01/01/2020": False, + "1/01/2020": False, + "01/1/2020": False, + "1/1/20": False, + "01/01/20": False, + "1/1/202": False, + "01/01/202": False, + "12/12/2022": False, + "12/12/2": False, + "2021-11-04T22:32:47.142354-10:00": True, + "2021-11-04T22:32:47.142354Z": True, + "2021-11-04T22:32:47.142354": True, + "2021-11-04T22:32:47": True, + "2021-11-04T22:32": False, + "2021-11-04T22": False, + "2021-11-04": False, + "2021-11": False, + "2021": False, +} + + +def test_iso_date(): + for candidate, expectation in _TEST_CASES.items(): + assert iso_date(candidate) is expectation diff --git a/tests/library/email/basic.test.py b/tests/library/email/basic.test.py new file mode 100644 index 0000000..e77e3b8 --- /dev/null +++ b/tests/library/email/basic.test.py @@ -0,0 +1,32 @@ +from edify.library import email + +_TEST_CASES = [ + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected].", False), + ("plainaddress", False), + ("#@%^%#$@#$@#.com", False), + ("@example.com", False), + ("Joe Smith <[email protected]>", False), + ("email.example.com", False), + ("email@[email protected]", False), + ("[email protected]", False), + ("[email protected]", False), + ("[email protected]", False), + ("あいうえお@example.com", False), + ("[email protected]", False), + ("[email protected]", False), +] + + +def test_email(): + for candidate, expectation in _TEST_CASES: + assert email(candidate) is expectation diff --git a/tests/library/email/strict.test.py b/tests/library/email/strict.test.py new file mode 100644 index 0000000..7cefbce --- /dev/null +++ b/tests/library/email/strict.test.py @@ -0,0 +1,32 @@ +from edify.library import email_rfc_5322 + +_TEST_CASES = [ + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected]", True), + ("[email protected].", True), + ("plainaddress", False), + ("#@%^%#$@#$@#.com", False), + ("@example.com", False), + ("Joe Smith <[email protected]>", False), + ("email.example.com", False), + ("email@[email protected]", False), + ("[email protected]", False), + ("[email protected]", False), + ("[email protected]", False), + ("あいうえお@example.com", False), + ("[email protected]", False), + ("[email protected]", False), +] + + +def test_email_rfc_5322(): + for candidate, expectation in _TEST_CASES: + assert email_rfc_5322(candidate) is expectation diff --git a/tests/test_guid.py b/tests/library/guid.test.py index b4b1a64..67fcad1 100644 --- a/tests/test_guid.py +++ b/tests/library/guid.test.py @@ -4,8 +4,8 @@ from edify.library import guid def test_valid_guids(): guids = { "6ba7b810-9dad-11d1-80b4-00c04fd430c8": True, - '{51d52cf1-83c9-4f02-b117-703ecb728b74}': True, - '{51d52cf1-83c9-4f02-b117-703ecb728-b74}': False, + "{51d52cf1-83c9-4f02-b117-703ecb728b74}": True, + "{51d52cf1-83c9-4f02-b117-703ecb728-b74}": False, } for guid_string, expectation in guids.items(): assert guid(guid_string) == expectation diff --git a/tests/library/ip/v4.test.py b/tests/library/ip/v4.test.py new file mode 100644 index 0000000..32a1975 --- /dev/null +++ b/tests/library/ip/v4.test.py @@ -0,0 +1,16 @@ +from edify.library import ipv4 + +_TEST_CASES = { + "192.168.0.1": True, + "244.232.123.233": True, + "363.232.123.233": False, + "234.234234.234.234": False, + "12.12.12.12.12": False, + "0.0.0.0": True, + "987.987.987.987": False, +} + + +def test_ipv4(): + for candidate, expectation in _TEST_CASES.items(): + assert ipv4(candidate) is expectation diff --git a/tests/library/ip/v6.test.py b/tests/library/ip/v6.test.py new file mode 100644 index 0000000..a68f892 --- /dev/null +++ b/tests/library/ip/v6.test.py @@ -0,0 +1,17 @@ +from edify.library import ipv6 + +_TEST_CASES = { + "2001:0db8:85a3:0000:0000:8a2e:0370:7334": True, + "2001:db8:85a3:0:0:8a2e:370:7334": True, + "2001:db8:85a3::8a2e:370:7334": True, + "2001:db8:85a3:0:0:8A2E:370:7334": True, + "2001:db8:85a3:0:0:8a2e:370:7334:": False, + "2001:db8:85a3:0:0:8a2e:370:7334:7334": False, + "2001:db8:85a3:0:0:8a2e:370:7334:7334:7334": False, + "2001:db8:85a3:0:0:8a2e:370:7334:7334:7334:7334": False, +} + + +def test_ipv6(): + for candidate, expectation in _TEST_CASES.items(): + assert ipv6(candidate) is expectation diff --git a/tests/test_mac.py b/tests/library/mac.test.py index 2f24b44..2f24b44 100644 --- a/tests/test_mac.py +++ b/tests/library/mac.test.py diff --git a/tests/test_password.py b/tests/library/password.test.py index 538874b..ec3b627 100644 --- a/tests/test_password.py +++ b/tests/library/password.test.py @@ -7,4 +7,9 @@ def test_password(): assert password("Password123!", max_length=8) is False assert password("Password123!", min_upper=2) is False assert password("password", min_upper=0, min_digit=0, min_special=0) is True - assert password("pass@#1", min_special=1, special_chars="!", min_digit=0, min_upper=0, min_length=4) is False + assert ( + password( + "pass@#1", min_special=1, special_chars="!", min_digit=0, min_upper=0, min_length=4 + ) + is False + ) diff --git a/tests/test_phone.py b/tests/library/phone.test.py index cbff29c..6fe16b1 100644 --- a/tests/test_phone.py +++ b/tests/library/phone.test.py @@ -7,7 +7,6 @@ def test(): "123 456 7890": True, "123-456-7890": True, "123.456.7890": True, - "123 456 7890": True, "+1 (123) 456-7890": True, "+1 (123) 456 7890": True, "+1-(123)-456-7890": True, @@ -20,7 +19,7 @@ def test(): "+1 (1) 456-7890": True, "9012": True, "911": True, - "+1 (615) 243-": False + "+1 (615) 243-": False, } for phone, expectation in phones.items(): assert phone_number(phone) == expectation diff --git a/tests/ssn_test.py b/tests/library/ssn.test.py index ce2a57b..ce2a57b 100644 --- a/tests/ssn_test.py +++ b/tests/library/ssn.test.py diff --git a/tests/library/url.test.py b/tests/library/url.test.py new file mode 100644 index 0000000..293bd34 --- /dev/null +++ b/tests/library/url.test.py @@ -0,0 +1,55 @@ +import pytest + +from edify.library import url + +_URLS = [ + "example.com", + "www.example.com", + "www.example.com/path/to/file", + "http://www.example.com", + "http://example.com", + "http://www.example.com/path/to/page", + "https://example.com", + "https://www.example.com/", + "https://www.example.com/path/to/page", + "//example.com", +] + + +def test_all_protocols(): + match_list = ["proto", "no_proto"] + expected = [True] * 9 + [False] + for uri, expectation in zip(_URLS, expected, strict=True): + assert url(uri, match=match_list) is expectation + + +def test_proto_only(): + match_list = ["proto"] + expected = [False] * 3 + [True] * 6 + [False] + for uri, expectation in zip(_URLS, expected, strict=True): + assert url(uri, match=match_list) is expectation + + +def test_no_proto_only(): + match_list = ["no_proto"] + expected = [True] * 3 + [False] * 7 + for uri, expectation in zip(_URLS, expected, strict=True): + assert url(uri, match=match_list) is expectation + + +def test_invalid_protocol(): + for uri in _URLS: + with pytest.raises(ValueError, match="Invalid protocol"): + url(uri, match=["invalid"]) + + +def test_invalid_match_type(): + for uri in _URLS: + with pytest.raises(TypeError, match="must be a list"): + url(uri, match="invalid") + + +def test_empty_match_list(): + for uri in _URLS: + with pytest.raises(ValueError, match="must not be empty"): + url(uri, match=[]) diff --git a/tests/test_uuid.py b/tests/library/uuid.test.py index df4e840..df4e840 100644 --- a/tests/test_uuid.py +++ b/tests/library/uuid.test.py diff --git a/tests/library/zip.test.py b/tests/library/zip.test.py new file mode 100644 index 0000000..77a3e0e --- /dev/null +++ b/tests/library/zip.test.py @@ -0,0 +1,43 @@ +import pytest + +from edify.library import zip + +_DEFAULT_US_ZIPS = { + "12345": True, + "12345-1234": True, + "12345-123456": False, + "1234": False, +} + +_INDIA_ZIPS = { + "123456": True, + "000000": False, + "012345": False, + "12345": False, + "1234567": False, +} + + +def test_valid_zips(): + for candidate, expectation in _DEFAULT_US_ZIPS.items(): + assert zip(candidate) is expectation + + +def test_invalid_locale(): + with pytest.raises(ValueError, match="locale must be one of"): + zip("12345", locale="INVALID") + + +def test_invalid_locale_type(): + with pytest.raises(TypeError, match="locale must be a string"): + zip("12345", 5) + + +def test_empty_locale(): + with pytest.raises(ValueError, match="locale cannot be empty"): + zip("12345", "") + + +def test_locale_india(): + for candidate, expectation in _INDIA_ZIPS.items(): + assert zip(candidate, locale="IN") is expectation diff --git a/tests/package.test.py b/tests/package.test.py new file mode 100644 index 0000000..6e05bda --- /dev/null +++ b/tests/package.test.py @@ -0,0 +1,16 @@ +"""Tests for the top-level :mod:`edify` package surface.""" + +import importlib +import importlib.metadata +import sys +from importlib.metadata import PackageNotFoundError + + +def test_version_falls_back_when_package_metadata_missing(monkeypatch): + def raise_not_found(distribution_name: str) -> str: + raise PackageNotFoundError(distribution_name) + + monkeypatch.setattr(importlib.metadata, "version", raise_not_found) + sys.modules.pop("edify", None) + reloaded_module = importlib.import_module("edify") + assert reloaded_module.__version__ == "0.0.0" diff --git a/tests/test_builder.py b/tests/test_builder.py deleted file mode 100644 index 76a04b5..0000000 --- a/tests/test_builder.py +++ /dev/null @@ -1,518 +0,0 @@ -import re - -from edify import RegexBuilder - -simple_se = RegexBuilder().string('hello').any_char().string('world') -flags_se = RegexBuilder().multi_line().ignore_case().string('hello').any_char().string('world') -start_end_se = RegexBuilder().start_of_input().string('hello').any_char().string('world').end_of_input() -nc_se = RegexBuilder().named_capture('module').exactly(2).any_char().end().named_back_reference('module') -indexed_back_reference_se = RegexBuilder().capture().exactly(2).any_char().end().back_reference(1) -nested_se = RegexBuilder().exactly(2).any_char() -first_layer_se = ( - RegexBuilder().string('outer begin').named_capture('inner_subexpression').optional().subexpression(nested_se).end().string('outer end') -) - - -def regex_equality(regex, rb_expression): - regex_str = str(regex) - rb_expression_str = rb_expression.to_regex_string() - assert regex_str == str(rb_expression_str) - - -def regex_compilation(regex, rb_expression, f=0): - rb_expression_c = rb_expression.to_regex() - assert re.compile(regex, flags=f) == rb_expression_c - - -def test_empty_regex(): - expr = RegexBuilder() - regex_equality('/(?:)/', expr) - regex_compilation('(?:)', expr) - - -def test_flag_a(): - expr = RegexBuilder().ascii_only() - regex_equality('/(?:)/A', expr) - regex_compilation('(?:)', expr, re.A) - - -def test_flag_d(): - expr = RegexBuilder().debug() - regex_equality('/(?:)/D', expr) - regex_compilation('(?:)', expr, re.DEBUG) - - -def test_flag_i(): - expr = RegexBuilder().ignore_case() - regex_equality('/(?:)/I', expr) - regex_compilation('(?:)', expr, re.I) - - -def test_flag_m(): - expr = RegexBuilder().multi_line() - regex_equality('/(?:)/M', expr) - regex_compilation('(?:)', expr, re.M) - - -def test_flag_s(): - expr = RegexBuilder().dot_all() - regex_equality('/(?:)/S', expr) - regex_compilation('(?:)', expr, re.S) - - -def test_flag_x(): - expr = RegexBuilder().verbose() - regex_equality('/(?:)/X', expr) - regex_compilation('(?:)', expr, re.X) - - -def test_any_char(): - expr = RegexBuilder().any_char() - regex_equality('/./', expr) - regex_compilation('.', expr) - - -def test_whitespace_char(): - expr = RegexBuilder().whitespace_char() - regex_equality('/\\s/', expr) - regex_compilation('\\s', expr) - - -def test_non_whitespace_char(): - expr = RegexBuilder().non_whitespace_char() - regex_equality('/\\S/', expr) - regex_compilation('\\S', expr) - - -def test_digit(): - expr = RegexBuilder().digit() - regex_equality('/\\d/', expr) - regex_compilation('\\d', expr) - - -def test_non_digit(): - expr = RegexBuilder().non_digit() - regex_equality('/\\D/', expr) - regex_compilation('\\D', expr) - - -def test_word(): - expr = RegexBuilder().word() - regex_equality('/\\w/', expr) - regex_compilation('\\w', expr) - - -def test_non_word(): - expr = RegexBuilder().non_word() - regex_equality('/\\W/', expr) - regex_compilation('\\W', expr) - - -def test_word_boundary(): - expr = RegexBuilder().word_boundary() - regex_equality('/\\b/', expr) - regex_compilation('\\b', expr) - - -def test_non_word_boundary(): - expr = RegexBuilder().non_word_boundary() - regex_equality('/\\B/', expr) - regex_compilation('\\B', expr) - - -def test_new_line(): - expr = RegexBuilder().new_line() - regex_equality('/\\n/', expr) - regex_compilation('\\n', expr) - - -def test_carriage_return(): - expr = RegexBuilder().carriage_return() - regex_equality('/\\r/', expr) - regex_compilation('\\r', expr) - - -def test_tab(): - expr = RegexBuilder().tab() - regex_equality('/\\t/', expr) - regex_compilation('\\t', expr) - - -def test_null_byte(): - expr = RegexBuilder().null_byte() - regex_equality('/\\0/', expr) - regex_compilation('\\0', expr) - - -def test_any_of_basic(): - expr = RegexBuilder().any_of().string('hello').digit().word().char('.').char('#').end() - regex_equality('/(?:hello|\\d|\\w|[\\.\\#])/', expr) - regex_compilation('(?:hello|\\d|\\w|[\\.\\#])', expr) - - -def test_any_of_range_fusion(): - expr = RegexBuilder().any_of().range('a', 'z').range('A', 'Z').range('0', '9').char('.').char('#').end() - regex_equality('/[a-zA-Z0-9\\.\\#]/', expr) - regex_compilation('[a-zA-Z0-9\\.\\#]', expr) - - -def test_any_of_range_fusion_with_other_choices(): - expr = RegexBuilder().any_of().range('a', 'z').range('A', 'Z').range('0', '9').char('.').char('#').string('hello').end() - regex_equality('/(?:hello|[a-zA-Z0-9\\.\\#])/', expr) - regex_compilation('(?:hello|[a-zA-Z0-9\\.\\#])', expr) - - -def test_capture(): - expr = RegexBuilder().capture().string('hello ').word().char('!').end() - regex_equality('/(hello \\w!)/', expr) - regex_compilation('(hello \\w!)', expr) - - -def test_named_capture(): - expr = RegexBuilder().named_capture('this_is_the_name').string('hello ').word().char('!').end() - regex_equality('/(?P<this_is_the_name>hello \\w!)/', expr) - regex_compilation('(?P<this_is_the_name>hello \\w!)', expr) - - -def test_bad_name_error(): - try: - (RegexBuilder().named_capture('hello world').string('hello ').word().char('!').end()) - except Exception as e: - assert isinstance(e, Exception) - - -def test_same_name_error(): - try: - ( - RegexBuilder() - .namedCapture('hello') - .string('hello ') - .word() - .char('!') - .end() - .namedCapture('hello') - .string('hello ') - .word() - .char('!') - .end() - ) - except Exception as e: - assert isinstance(e, Exception) - - -def test_named_back_reference(): - expr = RegexBuilder().named_capture('this_is_the_name').string('hello ').word().char('!').end().named_back_reference('this_is_the_name') - regex_equality('/(?P<this_is_the_name>hello \\w!)\\k<this_is_the_name>/', expr) - # Python does not support named back references, so we raise an error - try: - expr.to_regex() - except Exception as e: - assert isinstance(e, Exception) - - -def test_named_back_reference_no_cg_exists(): - try: - RegexBuilder().named_back_reference('not_here') - except Exception as e: - assert isinstance(e, Exception) - - -def test_back_reference(): - expr = RegexBuilder().capture().string('hello ').word().char('!').end().back_reference(1) - regex_equality('/(hello \\w!)\\1/', expr) - regex_compilation('(hello \\w!)\\1', expr) - - -def test_back_reference_no_cg_exists(): - try: - RegexBuilder().back_reference(1) - except Exception as e: - assert isinstance(e, Exception) - - -def test_group(): - expr = RegexBuilder().group().string('hello ').word().char('!').end() - regex_equality('/(?:hello \\w!)/', expr) - regex_compilation('(?:hello \\w!)', expr) - - -def test_error_when_called_with_no_stack(): - try: - RegexBuilder().end() - except Exception as e: - assert isinstance(e, Exception) - - -def test_assert_ahead(): - expr = RegexBuilder().assert_ahead().range('a', 'f').end().range('a', 'z') - regex_equality('/(?=[a-f])[a-z]/', expr) - regex_compilation('(?=[a-f])[a-z]', expr) - - -def test_assert_behind(): - expr = RegexBuilder().assert_behind().string('hello ').end().range('a', 'z') - regex_equality('/(?<=hello )[a-z]/', expr) - regex_compilation('(?<=hello )[a-z]', expr) - - -def test_assert_not_ahead(): - expr = RegexBuilder().assert_not_ahead().range('a', 'f').end().range('0', '9') - regex_equality('/(?![a-f])[0-9]/', expr) - regex_compilation('(?![a-f])[0-9]', expr) - - -def test_assert_not_behind(): - expr = RegexBuilder().assert_not_behind().string('hello ').end().range('a', 'z') - regex_equality('/(?<!hello )[a-z]/', expr) - regex_compilation('(?<!hello )[a-z]', expr) - - -def test_optional(): - expr = RegexBuilder().optional().word() - regex_equality('/\\w?/', expr) - regex_compilation('\\w?', expr) - - -def test_zero_or_more(): - expr = RegexBuilder().zero_or_more().word() - regex_equality('/\\w*/', expr) - regex_compilation('\\w*', expr) - - -def test_zero_or_more_lazy(): - expr = RegexBuilder().zero_or_more_lazy().word() - regex_equality('/\\w*?/', expr) - regex_compilation('\\w*?', expr) - - -def test_one_or_more(): - expr = RegexBuilder().one_or_more().word() - regex_equality('/\\w+/', expr) - regex_compilation('\\w+', expr) - - -def test_one_or_more_lazy(): - expr = RegexBuilder().one_or_more_lazy().word() - regex_equality('/\\w+?/', expr) - regex_compilation('\\w+?', expr) - - -def test_exactly(): - expr = RegexBuilder().exactly(3).word() - regex_equality('/\\w{3}/', expr) - regex_compilation('\\w{3}', expr) - - -def test_at_least(): - expr = RegexBuilder().at_least(3).word() - regex_equality('/\\w{3,}/', expr) - regex_compilation('\\w{3,}', expr) - - -def test_between(): - expr = RegexBuilder().between(3, 5).word() - regex_equality('/\\w{3,5}/', expr) - regex_compilation('\\w{3,5}', expr) - - -def test_between_lazy(): - expr = RegexBuilder().between_lazy(3, 5).word() - regex_equality('/\\w{3,5}?/', expr) - regex_compilation('\\w{3,5}?', expr) - - -def test_start_of_input(): - expr = RegexBuilder().start_of_input() - regex_equality('/^/', expr) - regex_compilation('^', expr) - - -def test_end_of_input(): - expr = RegexBuilder().end_of_input() - regex_equality('/$/', expr) - regex_compilation('$', expr) - - -def test_any_of_chars(): - expr = RegexBuilder().any_of_chars('aeiou.-') - regex_equality('/[aeiou\\.\\-]/', expr) - regex_compilation('[aeiou\\.\\-]', expr) - - -def test_anything_but_chars(): - expr = RegexBuilder().anything_but_chars('aeiou.-') - regex_equality('/[^aeiou\\.\\-]/', expr) - regex_compilation('[^aeiou\\.\\-]', expr) - - -def test_anything_but_string(): - expr = RegexBuilder().anything_but_string('aeiou.') - regex_equality('/(?:[^a][^e][^i][^o][^u][^\\][^.])/', expr) - regex_compilation('(?:[^a][^e][^i][^o][^u][^\\][^.])', expr) - - -def test_anything_but_range(): - expr = RegexBuilder().anything_but_range('a', 'z') - regex_equality('/[^a-z]/', expr) - regex_compilation('[^a-z]', expr) - expr = RegexBuilder().anything_but_range('0', '9') - regex_equality('/[^0-9]/', expr) - regex_compilation('[^0-9]', expr) - - -def test_string(): - expr = RegexBuilder().string('hello') - regex_equality('/hello/', expr) - regex_compilation('hello', expr) - - -def test_string_escapes_special_chars_with_strings_of_len_1(): - expr = RegexBuilder().string('^').string('hello') - regex_equality('/\\^hello/', expr) - regex_compilation('\\^hello', expr) - - -def test_char(): - expr = RegexBuilder().char('a') - regex_equality('/a/', expr) - regex_compilation('a', expr) - - -def test_char_more_than_one_error(): - try: - RegexBuilder().char('hello') - except Exception as e: - assert isinstance(e, Exception) - - -def test_range(): - expr = RegexBuilder().range('a', 'z') - regex_equality('/[a-z]/', expr) - regex_compilation('[a-z]', expr) - - -def test_must_be_instance_error(): - try: - RegexBuilder().subexpression('nope') - except Exception as e: - assert isinstance(e, Exception) - - -def test_simple_se(): - expr = RegexBuilder().start_of_input().at_least(3).digit().subexpression(simple_se).range('0', '9').end_of_input() - regex_equality('/^\\d{3,}hello.world[0-9]$/', expr) - regex_compilation('^\\d{3,}hello.world[0-9]$', expr) - - -def test_simple_quantified_se(): - expr = RegexBuilder().start_of_input().at_least(3).digit().one_or_more().subexpression(simple_se).range('0', '9').end_of_input() - regex_equality('/^\\d{3,}(?:hello.world)+[0-9]$/', expr) - regex_compilation('^\\d{3,}(?:hello.world)+[0-9]$', expr) - - -def test_flags_se(): - expr = ( - RegexBuilder() - .dot_all() - .start_of_input() - .at_least(3) - .digit() - .subexpression(flags_se, {'ignore_flags': False}) - .range('0', '9') - .end_of_input() - ) - regex_equality('/^\\d{3,}hello.world[0-9]$/IMS', expr) - regex_compilation('^\\d{3,}hello.world[0-9]$', expr, f=re.M | re.I | re.S) - - -def test_flags_se_ignore_flags(): - expr = RegexBuilder().dot_all().start_of_input().at_least(3).digit().subexpression(flags_se).range('0', '9').end_of_input() - regex_equality('/^\\d{3,}hello.world[0-9]$/S', expr) - regex_compilation('^\\d{3,}hello.world[0-9]$', expr, f=re.S) - - -def test_ignore_start_and_end(): - expr = RegexBuilder().at_least(3).digit().subexpression(start_end_se).range('0', '9') - regex_equality('/\\d{3,}hello.world[0-9]/', expr) - regex_compilation('\\d{3,}hello.world[0-9]', expr) - - -def test_dont_ignore_start_and_end(): - try: - (RegexBuilder().at_least(3).digit().subexpression(start_end_se, {'ignore_start_and_end': False}).range('0', '9')) - except Exception as e: - assert isinstance(e, Exception) - - -def test_dont_ignore_start_and_end2(): - try: - se = RegexBuilder().start_of_input().string('hello').any_char().string('world') - (RegexBuilder().at_least(3).digit().subexpression(se, {'ignore_start_and_end': False}).range('0', '9')) - except Exception as e: - assert isinstance(e, Exception) - - -def test_dont_ignore_start_and_end3(): - try: - se = RegexBuilder().string('hello').any_char().string('world').end_of_input() - (RegexBuilder().at_least(3).digit().subexpression(se, {'ignore_start_and_end': False}).range('0', '9')) - except Exception as e: - assert isinstance(e, Exception) - - -def test_start_defined_in_me_and_se(): - try: - (RegexBuilder().start_of_input().at_least(3).digit().subexpression(start_end_se, {'ignore_start_and_end': False}).range('0', '9')) - except Exception as e: - assert isinstance(e, Exception) - - -def test_end_defined_in_me_and_se(): - try: - (RegexBuilder().at_least(3).digit().subexpression(start_end_se, {'ignore_start_and_end': False}).range('0', '9').end_of_input()) - except Exception as e: - assert isinstance(e, Exception) - - -def test_no_namespacing(): - expr = RegexBuilder().at_least(3).digit().subexpression(nc_se).range('0', '9') - regex_equality('/\\d{3,}(?P<module>.{2})\\k<module>[0-9]/', expr) - try: - expr.to_regex() - except Exception as e: - assert isinstance(e, Exception) - - -def test_namespacing(): - expr = RegexBuilder().at_least(3).digit().subexpression(nc_se, {'namespace': 'yolo'}).range('0', '9') - regex_equality('/\\d{3,}(?P<yolomodule>.{2})\\k<yolomodule>[0-9]/', expr) - try: - expr.to_regex() - except Exception as e: - assert isinstance(e, Exception) - - -def test_group_name_collision_error(): - try: - (RegexBuilder().namedCapture('module').at_least(3).digit().end().subexpression(nc_se).range('0', '9')) - except Exception as e: - assert isinstance(e, Exception) - - -def test_group_name_collision_error_after_namespacing(): - try: - (RegexBuilder().namedCapture('module').at_least(3).digit().end().subexpression(nc_se, {'namespace': 'yolo'}).range('0', '9')) - except Exception as e: - assert isinstance(e, Exception) - - -def test_indexed_back_referencing(): - expr = RegexBuilder().capture().at_least(3).digit().end().subexpression(indexed_back_reference_se).back_reference(1).range('0', '9') - regex_equality('/(\\d{3,})(.{2})\\2\\1[0-9]/', expr) - regex_compilation('(\\d{3,})(.{2})\\2\\1[0-9]', expr) - - -def test_deeply_nested_se(): - expr = RegexBuilder().capture().at_least(3).digit().end().subexpression(first_layer_se).back_reference(1).range('0', '9') - regex_equality('/(\\d{3,})outer begin(?P<inner_subexpression>(?:.{2})?)outer end\\1[0-9]/', expr) - regex_compilation('(\\d{3,})outer begin(?P<inner_subexpression>(?:.{2})?)outer end\\1[0-9]', expr) diff --git a/tests/test_date.py b/tests/test_date.py deleted file mode 100644 index 555dfb0..0000000 --- a/tests/test_date.py +++ /dev/null @@ -1,57 +0,0 @@ -from edify.library import date -from edify.library import iso_date - - -def test_date(): - dates = { - "1/1/2020": True, - "01/01/2020": True, - "1/01/2020": True, - "01/1/2020": True, - "1/1/20": False, - "01/01/20": False, - "1/1/202": False, - "01/01/202": False, - "12/12/2022": True, - "12/12/2": False, - "2021-11-04T22:32:47.142354-10:00": False, - "2021-11-04T22:32:47.142354Z": False, - "2021-11-04T22:32:47.142354": False, - "2021-11-04T22:32:47": False, - "2021-11-04T22:32": False, - "2021-11-04T22": False, - "2021-11-04": False, - "2021-11": False, - "2021": False, - "1-1-2020": False - } - - for date_string, expectation in dates.items(): - assert date(date_string) == expectation - - -def test_iso_date(): - dates = { - "1/1/2020": False, - "01/01/2020": False, - "1/01/2020": False, - "01/1/2020": False, - "1/1/20": False, - "01/01/20": False, - "1/1/202": False, - "01/01/202": False, - "12/12/2022": False, - "12/12/2": False, - "2021-11-04T22:32:47.142354-10:00": True, - "2021-11-04T22:32:47.142354Z": True, - "2021-11-04T22:32:47.142354": True, - "2021-11-04T22:32:47": True, - "2021-11-04T22:32": False, - "2021-11-04T22": False, - "2021-11-04": False, - "2021-11": False, - "2021": False, - } - - for date_string, expectation in dates.items(): - assert iso_date(date_string) == expectation diff --git a/tests/test_email.py b/tests/test_email.py deleted file mode 100644 index cc28472..0000000 --- a/tests/test_email.py +++ /dev/null @@ -1,89 +0,0 @@ -from edify.library import email -from edify.library import email_rfc_5322 - -emails = [ - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "[email protected].", - "plainaddress", - "#@%^%#$@#$@#.com", - "@example.com", - "Joe Smith <[email protected]>", - "email.example.com", - "email@[email protected]", - "[email protected]", - "[email protected]", - "[email protected]", - "あいうえお@example.com", - "[email protected]", -] - - -def test_email(): - - expectations = [ - True, - True, - True, - True, - True, - True, - True, - True, - True, - True, - False, - False, - False, - False, - False, - False, - False, - False, - False, - False, - False, - False, - False - ] - for i in range(len(emails)): - assert email(emails[i]) == expectations[i] - - -def test_email_rfc_5322(): - expectations = [ - True, - True, - True, - True, - True, - True, - True, - True, - True, - True, - True, - False, - False, - False, - False, - False, - False, - False, - False, - False, - False, - False, - False - ] - for i in range(len(emails)): - assert email_rfc_5322(emails[i]) == expectations[i] diff --git a/tests/test_ip.py b/tests/test_ip.py deleted file mode 100644 index dc081a5..0000000 --- a/tests/test_ip.py +++ /dev/null @@ -1,35 +0,0 @@ -from edify.library import ipv4 -from edify.library import ipv6 - -# Generate ipv4 dictionary -ipv4_dict = { - "192.168.0.1": True, - "244.232.123.233": True, - "363.232.123.233": False, - "234.234234.234.234": False, - "12.12.12.12.12": False, - "0.0.0.0": True, - "987.987.987.987": False, -} - -# Generate ipv6 dictionary -ipv6_dict = { - "2001:0db8:85a3:0000:0000:8a2e:0370:7334": True, - "2001:db8:85a3:0:0:8a2e:370:7334": True, - "2001:db8:85a3::8a2e:370:7334": True, - "2001:db8:85a3:0:0:8A2E:370:7334": True, - "2001:db8:85a3:0:0:8a2e:370:7334:": False, - "2001:db8:85a3:0:0:8a2e:370:7334:7334": False, - "2001:db8:85a3:0:0:8a2e:370:7334:7334:7334": False, - "2001:db8:85a3:0:0:8a2e:370:7334:7334:7334:7334": False, -} - - -def test_ipv4(): - for ip, expectation in ipv4_dict.items(): - assert ipv4(ip) == expectation - - -def test_ipv6(): - for ip, expectation in ipv6_dict.items(): - assert ipv6(ip) == expectation diff --git a/tests/test_url.py b/tests/test_url.py deleted file mode 100644 index 74f8ed1..0000000 --- a/tests/test_url.py +++ /dev/null @@ -1,63 +0,0 @@ -from edify.library import url - -urls = [ - "example.com", - "www.example.com", - "www.example.com/path/to/file", - "http://www.example.com", - "http://example.com", - "http://www.example.com/path/to/page", - "https://example.com", - "https://www.example.com/", - "https://www.example.com/path/to/page", - "//example.com", -] - - -def test_all_protocols(): - match_list = ["proto", "no_proto"] - expected = [True] * 9 + [False] - for uri, expectation in zip(urls, expected): - assert url(uri, match=match_list) == expectation - - -def test_proto_only(): - match_list = ["proto"] - expected = [False] * 3 + [True] * 6 + [False] - for uri, expectation in zip(urls, expected): - print(uri, expectation) - assert url(uri, match=match_list) == expectation - - -def test_no_proto_only(): - match_list = ["no_proto"] - expected = [True] * 3 + [False] * 7 - for uri, expectation in zip(urls, expected): - assert url(uri, match=match_list) == expectation - - -def test_invalid_protocol(): - match_list = ["invalid"] - for uri in urls: - try: - url(uri, match=match_list) - except ValueError: - assert True - - -def test_invalid_match_type(): - match_list = "invalid" - for uri in urls: - try: - url(uri, match=match_list) - except TypeError: - assert True - - -def test_empty_match_list(): - match_list = [] - for uri in urls: - try: - url(uri, match=match_list) - except ValueError: - assert True diff --git a/tests/test_zip.py b/tests/test_zip.py deleted file mode 100644 index 999d55d..0000000 --- a/tests/test_zip.py +++ /dev/null @@ -1,34 +0,0 @@ -from edify.library import zip - - -def test_valid_zips(): - zips = {"12345": True, "12345-1234": True, "12345-123456": False, "1234": False} - for zip_string, expectation in zips.items(): - assert zip(zip_string) == expectation - - -def test_invalid_locale(): - try: - zip("12345", locale="INVALID") - except ValueError: - assert True - - -def test_invalid_locale_type(): - try: - zip("12345", 5) - except TypeError: - assert True - - -def test_empty_locale(): - try: - zip("12345", "") - except ValueError: - assert True - - -def test_locale_IN(): - zips = {"123456": True, "000000": False, "012345": False, "12345": False, "1234567": False} - for zip_string, expectation in zips.items(): - assert zip(zip_string, locale="IN") == expectation diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 348586c..0000000 --- a/tox.ini +++ /dev/null @@ -1,77 +0,0 @@ -; a generative tox configuration, see: https://tox.readthedocs.io/en/latest/config.html#generative-envlist - -[tox] -envlist = - clean, - check, - docs, - {py39,py310,py311,py312,py313,py314,pypy310,pypy311}, - report -ignore_basepython_conflict = true - -[testenv] -basepython = - pypy310: {env:TOXPYTHON:pypy3.10} - pypy311: {env:TOXPYTHON:pypy3.11} - py39: {env:TOXPYTHON:python3.9} - py310: {env:TOXPYTHON:python3.10} - py311: {env:TOXPYTHON:python3.11} - py312: {env:TOXPYTHON:python3.12} - py313: {env:TOXPYTHON:python3.13} - py314: {env:TOXPYTHON:python3.14} - {clean,check,report,docs,codecov}: {env:TOXPYTHON:python3} -setenv = - PYTHONPATH={toxinidir}/tests - PYTHONUNBUFFERED=yes -passenv = - * -usedevelop = false -deps = - pytest - pytest-cov -commands = - {posargs:pytest --cov --cov-report=term-missing -vv tests} - -[testenv:check] -deps = - docutils - check-manifest - flake8 - readme-renderer - pygments - isort -skip_install = true -commands = - python setup.py check --strict --metadata --restructuredtext - check-manifest {toxinidir} - flake8 - isort --verbose --check-only --diff --filter-files . - -[testenv:docs] -usedevelop = true -deps = - -r{toxinidir}/docs/requirements.txt -commands = - sphinx-build {posargs:-E} -b html docs dist/docs - sphinx-build -b linkcheck docs dist/docs - -[testenv:codecov] -deps = - codecov -skip_install = true -commands = - codecov [] - -[testenv:report] -deps = - coverage -skip_install = true -commands = - coverage report - coverage html - -[testenv:clean] -commands = coverage erase -skip_install = true -deps = - coverage @@ -0,0 +1,694 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "edify" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +docs = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-rtd-theme" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.7" }, +] +docs = [ + { name = "sphinx", specifier = ">=7.4.7" }, + { name = "sphinx-rtd-theme" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.12'" }, + { name = "babel", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.12'" }, + { name = "imagesize", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, + { name = "roman-numerals", marker = "python_full_version < '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] |
