aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-06-30 16:34:20 +0530
committerGitHub <[email protected]>2026-06-30 16:34:20 +0530
commitbd3b12ff2f30c251c5fc52b43f81ec2f282d53ab (patch)
treece6e3f57da4c7d9af29c39df4dea78e54523cf3d
parent21ec7908c94839fff63b5275871cf84e92787a08 (diff)
parent6774eab5f8a1bbda930efd38d41d793557057aed (diff)
downloadedify-bd3b12ff2f30c251c5fc52b43f81ec2f282d53ab.tar.xz
edify-bd3b12ff2f30c251c5fc52b43f81ec2f282d53ab.zip
chore!: package reorg + uv/ruff/pytest/hatchling toolchain + Python 3.11 floor (#255)
## Summary Closes #68 — package reorganization to a single-purpose, dir-per-concern layout (no `src/`). Closes #69 — toolchain migration to `uv` + `ruff` + `pytest` + `hatchling` + `hatch-vcs`, driven entirely from `pyproject.toml`. Closes #253. Closes #259 — drop `.editorconfig`; LF / trim / final-newline / Python indent now enforced by `mixed-line-ending` (added in fixup) + existing pre-commit hooks + `ruff format`.— Python floor bump to 3.11; drops `py39` / `py310` / `pypy310` from the CI matrix. The three issues are paired by construction (dropping `src/` requires the new build backend's package discovery, and the 3.11 floor lets the new layout use `typing.Self`, frozen dataclasses, `match`/`case`, and Python-3.11 idioms throughout). ## Highlights **Layout** — `edify/builder/` (RegexBuilder + 11 mixin files + `types/` for state/flags/frame/protocol), `edify/elements/types/` (sealed dataclass union: 16 leaves, 7 char-shaped, 4 captures, 7 groups, 9 quantifiers, RootElement), `edify/compile/` (per-family render functions + dispatch, plus `quantifier/` subpackage for suffix + apply), `edify/validate/`, `edify/errors/` (typed exception hierarchy), `edify/library/` (per-validator files in family folders: `email/`, `ip/`, `date/`, plus flat single-validator files), `pattern/`, `serialize/`, `result/` skeletons. **Toolchain** — replaces `tox` / `virtualenv` / `flake8` / `isort` / `black` / `setup.py` / `setup.cfg` / `pytest.ini` / `.coveragerc` / `.bumpversion.cfg` / `MANIFEST.in` / `ci/` / `tests.local.sh` with `pyproject.toml`-driven `uv` + `ruff` + `pytest` + `hatchling` + `hatch-vcs`. CI workflow rewritten to `uv sync --frozen` + `uv run pytest`; required-status-check names preserved (`check`, `docs`, `py311 (ubuntu)`, `py312 (ubuntu)`, `py313 (ubuntu)`, `py314 (ubuntu)`, `pypy311 (ubuntu)`). Pre-commit replaced with `ruff` hooks, all hook repos pinned by commit SHA. **Tests** — reorganised under `tests/builder/` and `tests/library/<family>/` using the `*.test.py` naming shape. 173 tests, 100% line coverage, ruff lint + format clean, `uv build` produces sdist + wheel cleanly. ## Behaviour changes versus 0.3 These will get their own upgrade-guide entries when the migration tooling lands (#80): - `to_regex_string()` now returns the bare pattern (no `/.../` wrap). - `to_regex()` on a named back-reference compiles successfully via `(?P=name)` instead of raising. - Builder errors raise typed `EdifyError` subclasses (`MustBeAStringError`, `StartInputAlreadyDefinedError`, etc.) 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.
-rw-r--r--.bumpversion.cfg25
-rw-r--r--.coveragerc16
-rw-r--r--.editorconfig20
-rw-r--r--.github/workflows/coverage.yml34
-rw-r--r--.github/workflows/github-actions.yml77
-rw-r--r--.gitignore4
-rw-r--r--.pre-commit-config.yaml30
-rw-r--r--MANIFEST.in23
-rw-r--r--ci/requirements.txt5
-rw-r--r--docs/conf.py57
-rw-r--r--edify/__init__.py23
-rw-r--r--edify/builder/__init__.py3
-rw-r--r--edify/builder/builder.py56
-rw-r--r--edify/builder/merge.py251
-rw-r--r--edify/builder/mixins/__init__.py0
-rw-r--r--edify/builder/mixins/anchors.py40
-rw-r--r--edify/builder/mixins/assertions.py47
-rw-r--r--edify/builder/mixins/captures.py89
-rw-r--r--edify/builder/mixins/chain.py70
-rw-r--r--edify/builder/mixins/chars.py130
-rw-r--r--edify/builder/mixins/classes.py96
-rw-r--r--edify/builder/mixins/flags.py53
-rw-r--r--edify/builder/mixins/groups.py30
-rw-r--r--edify/builder/mixins/quantifiers.py155
-rw-r--r--edify/builder/mixins/subexpression.py98
-rw-r--r--edify/builder/mixins/terminals.py75
-rw-r--r--edify/builder/types/__init__.py0
-rw-r--r--edify/builder/types/flags.py67
-rw-r--r--edify/builder/types/frame.py45
-rw-r--r--edify/builder/types/protocol.py24
-rw-r--r--edify/builder/types/state.py108
-rw-r--r--edify/compile/__init__.py4
-rw-r--r--edify/compile/captures.py43
-rw-r--r--edify/compile/chars.py47
-rw-r--r--edify/compile/dispatch.py55
-rw-r--r--edify/compile/escape.py22
-rw-r--r--edify/compile/fuse.py67
-rw-r--r--edify/compile/groups.py88
-rw-r--r--edify/compile/leaves.py72
-rw-r--r--edify/compile/quantifier/__init__.py4
-rw-r--r--edify/compile/quantifier/apply.py43
-rw-r--r--edify/compile/quantifier/suffix.py53
-rw-r--r--edify/compile/root.py25
-rw-r--r--edify/compile/types.py9
-rw-r--r--edify/elements/__init__.py3
-rw-r--r--edify/elements/types/__init__.py4
-rw-r--r--edify/elements/types/base.py22
-rw-r--r--edify/elements/types/captures.py68
-rw-r--r--edify/elements/types/chars.py108
-rw-r--r--edify/elements/types/groups.py101
-rw-r--r--edify/elements/types/leaves.py98
-rw-r--r--edify/elements/types/quantifiers.py142
-rw-r--r--edify/elements/types/root.py25
-rw-r--r--edify/elements/types/union.py159
-rw-r--r--edify/errors/__init__.py4
-rw-r--r--edify/errors/anchors.py45
-rw-r--r--edify/errors/base.py12
-rw-r--r--edify/errors/captures.py18
-rw-r--r--edify/errors/input.py80
-rw-r--r--edify/errors/internal.py42
-rw-r--r--edify/errors/naming.py37
-rw-r--r--edify/errors/structure.py28
-rw-r--r--edify/errors/syntax.py20
-rw-r--r--edify/library/__init__.py31
-rw-r--r--edify/library/_support/__init__.py0
-rw-r--r--edify/library/_support/zip.py (renamed from src/edify/library/support/zip.py)29
-rw-r--r--edify/library/date/__init__.py4
-rw-r--r--edify/library/date/basic.py23
-rw-r--r--edify/library/date/iso.py26
-rw-r--r--edify/library/email/__init__.py4
-rw-r--r--edify/library/email/basic.py26
-rw-r--r--edify/library/email/strict.py33
-rw-r--r--edify/library/guid.py27
-rw-r--r--edify/library/ip/__init__.py4
-rw-r--r--edify/library/ip/v4.py28
-rw-r--r--edify/library/ip/v6.py44
-rw-r--r--edify/library/mac.py26
-rw-r--r--edify/library/password.py69
-rw-r--r--edify/library/phone.py31
-rw-r--r--edify/library/ssn.py27
-rw-r--r--edify/library/url.py79
-rw-r--r--edify/library/uuid.py26
-rw-r--r--edify/library/zip.py61
-rw-r--r--edify/pattern/__init__.py0
-rw-r--r--edify/result/__init__.py0
-rw-r--r--edify/serialize/__init__.py0
-rw-r--r--edify/validate/__init__.py3
-rw-r--r--edify/validate/names.py25
-rw-r--r--pyproject.toml117
-rw-r--r--pytest.ini28
-rw-r--r--setup.cfg11
-rwxr-xr-xsetup.py80
-rw-r--r--src/edify/__init__.py4
-rw-r--r--src/edify/builder/builder.py532
-rw-r--r--src/edify/builder/errors.py73
-rw-r--r--src/edify/builder/helpers/core.py79
-rw-r--r--src/edify/builder/helpers/quantifiers.py11
-rw-r--r--src/edify/builder/helpers/regex_vars.py1
-rw-r--r--src/edify/builder/helpers/t.py49
-rw-r--r--src/edify/library/__init__.py17
-rw-r--r--src/edify/library/date.py34
-rw-r--r--src/edify/library/guid.py15
-rw-r--r--src/edify/library/ip.py34
-rw-r--r--src/edify/library/mac.py17
-rw-r--r--src/edify/library/mail.py34
-rw-r--r--src/edify/library/password.py40
-rw-r--r--src/edify/library/phone.py19
-rw-r--r--src/edify/library/ssn.py17
-rw-r--r--src/edify/library/url.py39
-rw-r--r--src/edify/library/uuid.py14
-rw-r--r--src/edify/library/zip.py29
-rwxr-xr-xtests.local.sh26
-rw-r--r--tests/builder/alternation.test.py13
-rw-r--r--tests/builder/builder.test.py554
-rw-r--r--tests/builder/conflict.test.py20
-rw-r--r--tests/builder/frame.test.py17
-rw-r--r--tests/builder/merge.test.py123
-rw-r--r--tests/builder/passthrough.test.py40
-rw-r--r--tests/builder/validation.test.py157
-rw-r--r--tests/compile/invariants.test.py30
-rw-r--r--tests/errors/errors.test.py153
-rw-r--r--tests/library/date/basic.test.py29
-rw-r--r--tests/library/date/iso.test.py28
-rw-r--r--tests/library/email/basic.test.py32
-rw-r--r--tests/library/email/strict.test.py32
-rw-r--r--tests/library/guid.test.py (renamed from tests/test_guid.py)4
-rw-r--r--tests/library/ip/v4.test.py16
-rw-r--r--tests/library/ip/v6.test.py17
-rw-r--r--tests/library/mac.test.py (renamed from tests/test_mac.py)0
-rw-r--r--tests/library/password.test.py (renamed from tests/test_password.py)7
-rw-r--r--tests/library/phone.test.py (renamed from tests/test_phone.py)3
-rw-r--r--tests/library/ssn.test.py (renamed from tests/ssn_test.py)0
-rw-r--r--tests/library/url.test.py55
-rw-r--r--tests/library/uuid.test.py (renamed from tests/test_uuid.py)0
-rw-r--r--tests/library/zip.test.py43
-rw-r--r--tests/package.test.py13
-rw-r--r--tests/test_builder.py518
-rw-r--r--tests/test_date.py57
-rw-r--r--tests/test_email.py89
-rw-r--r--tests/test_ip.py35
-rw-r--r--tests/test_url.py63
-rw-r--r--tests/test_zip.py34
-rw-r--r--tox.ini77
-rw-r--r--uv.lock694
144 files changed, 5890 insertions, 2308 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/.editorconfig b/.editorconfig
deleted file mode 100644
index 586c736..0000000
--- a/.editorconfig
+++ /dev/null
@@ -1,20 +0,0 @@
-# see https://editorconfig.org/
-root = true
-
-[*]
-# Use Unix-style newlines for most files (except Windows files, see below).
-end_of_line = lf
-trim_trailing_whitespace = true
-indent_style = space
-insert_final_newline = true
-indent_size = 4
-charset = utf-8
-
-[*.{bat,cmd,ps1}]
-end_of_line = crlf
-
-[*.{yml,yaml}]
-indent_size = 2
-
-[*.tsv]
-indent_style = tab
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 79c7a5f..80cf274 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -5,21 +5,19 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- - uses: actions/checkout@v5
- - uses: actions/setup-python@v5
- with:
- python-version: 3.11
- - name: install dependencies
- run: |
- python -m pip install --upgrade pip
- python -mpip install --progress-bar=off -r ci/requirements.txt
- virtualenv --version
- pip --version
- tox --version
- pip list --format=freeze
- - name: upload coverage reports
- run: |
- tox -e clean -v
- tox -e py311 -v
- tox -e report -v
- tox -e codecov -v
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ with:
+ fetch-depth: 0
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
+ with:
+ python-version: '3.11'
+ - name: install dependencies
+ run: uv sync --frozen
+ - name: run tests with coverage
+ run: uv run pytest --cov=edify --cov-report=xml
+ - name: upload coverage to codecov
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: ./coverage.xml
+ fail_ci_if_error: true \ No newline at end of file
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
diff --git a/.gitignore b/.gitignore
index cacae9c..c9d3784 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ __pycache__
*.egg-info
dist
build
+edify/version.py
eggs
.eggs
parts
@@ -20,7 +21,7 @@ develop-eggs
.installed.cfg
lib
lib64
-venv*/
+*venv*/
pyvenv*/
pip-wheel-metadata/
@@ -32,6 +33,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..04fc647 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,24 +1,16 @@
-# 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: mixed-line-ending
+ args: [--fix=lf]
+ - 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..94eb775
--- /dev/null
+++ b/edify/__init__.py
@@ -0,0 +1,23 @@
+import importlib.metadata
+
+from edify.builder.builder import RegexBuilder
+from edify.errors.base import EdifyError
+from edify.errors.syntax import EdifySyntaxError
+
+
+def _resolve_installed_version() -> str:
+ """Return the installed package version or ``"0.0.0"`` when metadata is missing."""
+ try:
+ return importlib.metadata.version("edify")
+ except importlib.metadata.PackageNotFoundError:
+ return "0.0.0"
+
+
+__version__ = _resolve_installed_version()
+
+__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/pyproject.toml b/pyproject.toml
index 6baa4ee..6fb6c03 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,10 +1,113 @@
[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.targets.sdist]
+include = [
+ "/edify",
+ "/tests",
+ "/docs",
+ "/README.rst",
+ "/CHANGELOG.rst",
+ "/CONTRIBUTING.rst",
+ "/AUTHORS.rst",
+ "/LICENSE",
]
-[tool.black]
-line-length = 140
-target-version = ['py39']
-skip-string-normalization = true
+[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.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
+
+[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..384f9ce
--- /dev/null
+++ b/tests/package.test.py
@@ -0,0 +1,13 @@
+"""Tests for the top-level :mod:`edify` package surface."""
+
+import importlib.metadata
+
+from edify import _resolve_installed_version
+
+
+def test_version_falls_back_when_package_metadata_missing(monkeypatch):
+ def raise_not_found(distribution_name: str) -> str:
+ raise importlib.metadata.PackageNotFoundError(distribution_name)
+
+ monkeypatch.setattr(importlib.metadata, "version", raise_not_found)
+ assert _resolve_installed_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 = [
- "plainaddress",
- "#@%^%#$@#$@#.com",
- "@example.com",
- "Joe Smith <[email protected]>",
- "email.example.com",
- "あいうえお@example.com",
-]
-
-
-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
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..43c30fd
--- /dev/null
+++ b/uv.lock
@@ -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" },
+]