aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/github-actions.yml35
-rw-r--r--.github/workflows/python-publish.yml33
-rw-r--r--.public-surface693
-rw-r--r--CHANGELOG.rst1
-rw-r--r--changes/README.rst55
-rw-r--r--tests/discipline/anchors.test.py66
-rw-r--r--tests/discipline/deprecations.test.py72
-rw-r--r--tests/docs/snapshots.test.py8
-rw-r--r--tests/snapshots/docs/deprecation-policy/0020.regex (renamed from tests/snapshots/docs/upgrading/0.3-to-1.0/0018.regex)0
-rw-r--r--tests/snapshots/docs/upgrading/0.3-to-1.0/0046.regex (renamed from tests/snapshots/docs/upgrading/0.3-to-1.0/0037.regex)0
-rw-r--r--tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex0
-rw-r--r--tests/tools/changes.test.py116
-rw-r--r--tests/tools/surface.test.py89
-rw-r--r--tools/changes.py65
-rw-r--r--tools/surface.py99
15 files changed, 1314 insertions, 18 deletions
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml
index f9f49d6..0fcb6bf 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -21,6 +21,10 @@ jobs:
python: '3.11'
command: 'uv run pyright edify'
os: 'ubuntu-latest'
+ - name: 'public-surface'
+ python: '3.11'
+ command: 'uv run python tools/surface.py --check'
+ os: 'ubuntu-latest'
- name: 'docs'
python: '3.11'
command: 'uv run --group docs sphinx-build -b html docs dist/docs && uv run --group docs sphinx-build -b linkcheck docs dist/docs'
@@ -59,6 +63,37 @@ jobs:
- name: ${{ matrix.name }}
run: ${{ matrix.command }}
+ surface-migration-gate:
+ name: 'public-surface migration gate'
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ with:
+ fetch-depth: 0
+ - name: require a changes/ fragment when the public surface moves
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ run: |
+ if echo "$PR_TITLE" | grep -q '\[no-migration\]'; then
+ echo "PR marked [no-migration]; skipping the fragment requirement."
+ exit 0
+ fi
+ if git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- .public-surface; then
+ echo "Public surface unchanged; no fragment required."
+ exit 0
+ fi
+ added_fragments=$(git diff --name-only --diff-filter=A "$BASE_SHA" "$HEAD_SHA" -- 'changes/*.rst' | grep -v 'changes/README.rst' || true)
+ if [ -z "$added_fragments" ]; then
+ echo "::error::.public-surface changed but no changes/ fragment was added. Add a fragment describing the migration, or mark the PR title with [no-migration]."
+ exit 1
+ fi
+ echo "Public surface changed and a changes/ fragment accompanies it:"
+ echo "$added_fragments"
+
install-without-regex-extra:
name: 'install: edify (no [regex] extra)'
runs-on: ubuntu-latest
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index f47ac56..d832902 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -1,10 +1,7 @@
-# This workflow will upload a Python Package using Twine when a release is created
-# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
-
-# This workflow uses actions that are not certified by GitHub.
-# They are provided by a third-party and are governed by
-# separate terms of service, privacy policy, and support
-# documentation.
+# Publishes the Python package to PyPI via Trusted Publishing (OIDC).
+# No long-lived API token: PyPI verifies the GitHub Actions OIDC identity of
+# this workflow. Configure the trusted publisher once in the PyPI project
+# settings (owner: luciferreeves, repo: edify, workflow: python-publish.yml).
name: Upload Python Package
@@ -18,23 +15,23 @@ permissions:
jobs:
deploy:
-
runs-on: ubuntu-latest
-
+ environment:
+ name: pypi
+ url: https://pypi.org/p/edify
+ permissions:
+ id-token: write
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ with:
+ fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install build
+ - name: Install build backend
+ run: python -m pip install --upgrade pip build
- name: Build package
run: python -m build
- - name: Publish package
+ - name: Publish package to PyPI via Trusted Publishing
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
- with:
- user: __token__
- password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.public-surface b/.public-surface
new file mode 100644
index 0000000..6bbbe50
--- /dev/null
+++ b/.public-surface
@@ -0,0 +1,693 @@
+edify.ALPHANUMERIC(value: 'str') -> 'bool'
+edify.ANY_CHAR(value: 'str') -> 'bool'
+edify.CARRIAGE_RETURN(value: 'str') -> 'bool'
+edify.DIGIT(value: 'str') -> 'bool'
+edify.END(value: 'str') -> 'bool'
+edify.EdifyError
+edify.EdifySyntaxError
+edify.LETTER(value: 'str') -> 'bool'
+edify.LOWERCASE(value: 'str') -> 'bool'
+edify.NEW_LINE(value: 'str') -> 'bool'
+edify.NON_DIGIT(value: 'str') -> 'bool'
+edify.NON_WHITESPACE(value: 'str') -> 'bool'
+edify.NON_WORD(value: 'str') -> 'bool'
+edify.NON_WORD_BOUNDARY(value: 'str') -> 'bool'
+edify.NULL_BYTE(value: 'str') -> 'bool'
+edify.Pattern() -> 'None'
+edify.Regex(source: 'str', compiled: 're.Pattern[str]', elements: 'tuple[BaseElement, ...]' = (), engine: 'Engine' = 're') -> 'None'
+edify.RegexBuilder() -> 'None'
+edify.START(value: 'str') -> 'bool'
+edify.TAB(value: 'str') -> 'bool'
+edify.UPPERCASE(value: 'str') -> 'bool'
+edify.WHITESPACE(value: 'str') -> 'bool'
+edify.WORD(value: 'str') -> 'bool'
+edify.WORD_BOUNDARY(value: 'str') -> 'bool'
+edify.any_of(*operands: 'BuilderProtocol') -> 'Pattern'
+edify.assert_ahead(operand: 'BuilderProtocol') -> 'Pattern'
+edify.assert_behind(operand: 'BuilderProtocol') -> 'Pattern'
+edify.assert_not_ahead(operand: 'BuilderProtocol') -> 'Pattern'
+edify.assert_not_behind(operand: 'BuilderProtocol') -> 'Pattern'
+edify.at_least(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.at_most(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.atoms.alnum(value: 'str') -> 'bool'
+edify.atoms.ascii(value: 'str') -> 'bool'
+edify.atoms.base32(value: 'str') -> 'bool'
+edify.atoms.base58(value: 'str') -> 'bool'
+edify.atoms.base64(value: 'str') -> 'bool'
+edify.atoms.base64url(value: 'str') -> 'bool'
+edify.atoms.bic(value: 'str') -> 'bool'
+edify.atoms.binnum(value: 'str') -> 'bool'
+edify.atoms.boolean(value: 'str') -> 'bool'
+edify.atoms.braces(value: 'str') -> 'bool'
+edify.atoms.brackets(value: 'str') -> 'bool'
+edify.atoms.cidr(value: 'str') -> 'bool'
+edify.atoms.clock(value: 'str') -> 'bool'
+edify.atoms.clock12(value: 'str') -> 'bool'
+edify.atoms.creditcard(value: 'str') -> 'bool'
+edify.atoms.currency(value: 'str') -> 'bool'
+edify.atoms.day(value: 'str') -> 'bool'
+edify.atoms.decimal(value: 'str') -> 'bool'
+edify.atoms.duration(value: 'str') -> 'bool'
+edify.atoms.email(value: 'str') -> 'bool'
+edify.atoms.epoch(value: 'str') -> 'bool'
+edify.atoms.extension(value: 'str') -> 'bool'
+edify.atoms.filename(value: 'str') -> 'bool'
+edify.atoms.filepath(value: 'str') -> 'bool'
+edify.atoms.floatnum(value: 'str') -> 'bool'
+edify.atoms.guid(value: 'str') -> 'bool'
+edify.atoms.hexcolor(value: 'str') -> 'bool'
+edify.atoms.hexnum(value: 'str') -> 'bool'
+edify.atoms.hexstring(value: 'str') -> 'bool'
+edify.atoms.hostname(value: 'str') -> 'bool'
+edify.atoms.httpmethod(value: 'str') -> 'bool'
+edify.atoms.httpstatus(value: 'str') -> 'bool'
+edify.atoms.iban(value: 'str') -> 'bool'
+edify.atoms.integer(value: 'str') -> 'bool'
+edify.atoms.ipv4(value: 'str') -> 'bool'
+edify.atoms.ipv6(value: 'str') -> 'bool'
+edify.atoms.isodate(value: 'str') -> 'bool'
+edify.atoms.isodatetime(value: 'str') -> 'bool'
+edify.atoms.label(value: 'str') -> 'bool'
+edify.atoms.letter(value: 'str') -> 'bool'
+edify.atoms.line(value: 'str') -> 'bool'
+edify.atoms.localpart(value: 'str') -> 'bool'
+edify.atoms.lower(value: 'str') -> 'bool'
+edify.atoms.mac(value: 'str') -> 'bool'
+edify.atoms.md5(value: 'str') -> 'bool'
+edify.atoms.mimetype(value: 'str') -> 'bool'
+edify.atoms.money(value: 'str') -> 'bool'
+edify.atoms.month(value: 'str') -> 'bool'
+edify.atoms.natural(value: 'str') -> 'bool'
+edify.atoms.nibble(value: 'str') -> 'bool'
+edify.atoms.objectid(value: 'str') -> 'bool'
+edify.atoms.octet(value: 'str') -> 'bool'
+edify.atoms.octnum(value: 'str') -> 'bool'
+edify.atoms.oid(value: 'str') -> 'bool'
+edify.atoms.parens(value: 'str') -> 'bool'
+edify.atoms.percent(value: 'str') -> 'bool'
+edify.atoms.port(value: 'str') -> 'bool'
+edify.atoms.printable(value: 'str') -> 'bool'
+edify.atoms.protocol(value: 'str') -> 'bool'
+edify.atoms.quoted(value: 'str') -> 'bool'
+edify.atoms.ratio(value: 'str') -> 'bool'
+edify.atoms.rgbcolor(value: 'str') -> 'bool'
+edify.atoms.scheme(value: 'str') -> 'bool'
+edify.atoms.scientific(value: 'str') -> 'bool'
+edify.atoms.semver(value: 'str') -> 'bool'
+edify.atoms.sha256(value: 'str') -> 'bool'
+edify.atoms.signed(value: 'str') -> 'bool'
+edify.atoms.slug(value: 'str') -> 'bool'
+edify.atoms.space(value: 'str') -> 'bool'
+edify.atoms.timezone(value: 'str') -> 'bool'
+edify.atoms.tld(value: 'str') -> 'bool'
+edify.atoms.truefalse(value: 'str') -> 'bool'
+edify.atoms.ulid(value: 'str') -> 'bool'
+edify.atoms.unsigned(value: 'str') -> 'bool'
+edify.atoms.upper(value: 'str') -> 'bool'
+edify.atoms.uri(value: 'str') -> 'bool'
+edify.atoms.url(value: 'str') -> 'bool'
+edify.atoms.username(value: 'str') -> 'bool'
+edify.atoms.uuid(value: 'str') -> 'bool'
+edify.atoms.weekday(value: 'str') -> 'bool'
+edify.atoms.word(value: 'str') -> 'bool'
+edify.atoms.year(value: 'str') -> 'bool'
+edify.atoms.yesno(value: 'str') -> 'bool'
+edify.back_reference(index: 'int') -> 'Pattern'
+edify.between(lower: 'int', upper: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.between_lazy(lower: 'int', upper: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.builder.RegexBuilder() -> 'None'
+edify.capture(operand: 'BuilderProtocol') -> 'Pattern'
+edify.char(value: 'str') -> 'Pattern'
+edify.chars(characters: 'str') -> 'Pattern'
+edify.compile.escape_for_char_class(characters: 'str') -> 'str'
+edify.compile.escape_special(value: 'str') -> 'str'
+edify.compile.quantifier.quantifier_suffix(quantifier: 'QuantifierElement') -> 'str'
+edify.compile.quantifier.render_quantifier(quantifier: 'QuantifierElement', render_element: 'ElementRenderer') -> 'str'
+edify.compile.render_element(element: 'BaseElement') -> 'str'
+edify.elements.BaseElement() -> None
+edify.elements.Element
+edify.elements.types.BaseElement() -> None
+edify.elements.types.Element
+edify.errors.EdifyError
+edify.errors.EdifySyntaxError
+edify.exactly(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.group(operand: 'BuilderProtocol') -> 'Pattern'
+edify.introspect.explain_elements(elements: 'tuple[BaseElement, ...]') -> 'str'
+edify.introspect.verbose_elements(elements: 'tuple[BaseElement, ...]') -> 'str'
+edify.introspect.visualize_elements(elements: 'tuple[BaseElement, ...]', format: 'str' = 'ascii', engine: 'str' = 'ascii') -> 'str'
+edify.library.abnf(value: 'str') -> 'bool'
+edify.library.address(value: 'str') -> 'bool'
+edify.library.address.cidr(value: 'str') -> 'bool'
+edify.library.address.domain(value: 'str') -> 'bool'
+edify.library.address.hostname(value: 'str') -> 'bool'
+edify.library.address.ip(value: 'str') -> 'bool'
+edify.library.address.ipv4(value: 'str') -> 'bool'
+edify.library.address.ipv6(value: 'str') -> 'bool'
+edify.library.address.path(value: 'str') -> 'bool'
+edify.library.address.port(value: 'str') -> 'bool'
+edify.library.address.ptr(value: 'str') -> 'bool'
+edify.library.address.socket(value: 'str') -> 'bool'
+edify.library.address.subdomain(value: 'str') -> 'bool'
+edify.library.address.subnet(value: 'str') -> 'bool'
+edify.library.address.tld(value: 'str') -> 'bool'
+edify.library.address.uri(value: 'str') -> 'bool'
+edify.library.address.url(value: 'str') -> 'bool'
+edify.library.address.zip_code(value: 'str') -> 'bool'
+edify.library.age(value: 'str') -> 'bool'
+edify.library.aircraft(value: 'str') -> 'bool'
+edify.library.alpha(value: 'str') -> 'bool'
+edify.library.alphanumeric(value: 'str') -> 'bool'
+edify.library.altitude(value: 'str') -> 'bool'
+edify.library.antlr(value: 'str') -> 'bool'
+edify.library.apache(value: 'str') -> 'bool'
+edify.library.api.atom(value: 'str') -> 'bool'
+edify.library.api.graphql(value: 'str') -> 'bool'
+edify.library.api.hal(value: 'str') -> 'bool'
+edify.library.api.jsonapi(value: 'str') -> 'bool'
+edify.library.api.oauth(value: 'str') -> 'bool'
+edify.library.api.openapi(value: 'str') -> 'bool'
+edify.library.api.openid(value: 'str') -> 'bool'
+edify.library.api.rss(value: 'str') -> 'bool'
+edify.library.api.saml(value: 'str') -> 'bool'
+edify.library.api.soap(value: 'str') -> 'bool'
+edify.library.api.swagger(value: 'str') -> 'bool'
+edify.library.api.webhook(value: 'str') -> 'bool'
+edify.library.apikey(value: 'str') -> 'bool'
+edify.library.arn(value: 'str') -> 'bool'
+edify.library.arxiv(value: 'str') -> 'bool'
+edify.library.ascii(value: 'str') -> 'bool'
+edify.library.asin(value: 'str') -> 'bool'
+edify.library.atom(value: 'str') -> 'bool'
+edify.library.auth.apikey(value: 'str') -> 'bool'
+edify.library.auth.bearer(value: 'str') -> 'bool'
+edify.library.auth.challenge(value: 'str') -> 'bool'
+edify.library.auth.csrf(value: 'str') -> 'bool'
+edify.library.auth.hmac(value: 'str') -> 'bool'
+edify.library.auth.jwt(value: 'str') -> 'bool'
+edify.library.auth.mfa(value: 'str') -> 'bool'
+edify.library.auth.mnemonic(value: 'str') -> 'bool'
+edify.library.auth.otp(value: 'str') -> 'bool'
+edify.library.auth.passkey(value: 'str') -> 'bool'
+edify.library.auth.password(value: 'str', min_length: 'int | None' = None, max_length: 'int | None' = None, min_upper: 'int | None' = None, min_lower: 'int | None' = None, min_digit: 'int | None' = None, min_special: 'int | None' = None, special_chars: 'str | None' = None) -> 'bool'
+edify.library.auth.pin(value: 'str') -> 'bool'
+edify.library.auth.refresh(value: 'str') -> 'bool'
+edify.library.auth.secret(value: 'str') -> 'bool'
+edify.library.auth.session(value: 'str') -> 'bool'
+edify.library.auth.signing(value: 'str') -> 'bool'
+edify.library.auth.sso(value: 'str') -> 'bool'
+edify.library.auth.token(value: 'str') -> 'bool'
+edify.library.auth.webauthn(value: 'str') -> 'bool'
+edify.library.avro(value: 'str') -> 'bool'
+edify.library.barcode(value: 'str') -> 'bool'
+edify.library.base(value: 'str') -> 'bool'
+edify.library.bearer(value: 'str') -> 'bool'
+edify.library.bearing(value: 'str') -> 'bool'
+edify.library.bic(value: 'str') -> 'bool'
+edify.library.blood(value: 'str') -> 'bool'
+edify.library.bnf(value: 'str') -> 'bool'
+edify.library.bump(value: 'str') -> 'bool'
+edify.library.captcha(value: 'str') -> 'bool'
+edify.library.card(value: 'str') -> 'bool'
+edify.library.cargo(value: 'str') -> 'bool'
+edify.library.certificate(value: 'str') -> 'bool'
+edify.library.challenge(value: 'str') -> 'bool'
+edify.library.charset(value: 'str') -> 'bool'
+edify.library.checksum(value: 'str') -> 'bool'
+edify.library.cidr(value: 'str') -> 'bool'
+edify.library.codec(value: 'str') -> 'bool'
+edify.library.color(value: 'str') -> 'bool'
+edify.library.color.color(value: 'str') -> 'bool'
+edify.library.color.filter(value: 'str') -> 'bool'
+edify.library.color.gradient(value: 'str') -> 'bool'
+edify.library.color.palette(value: 'str') -> 'bool'
+edify.library.color.swatch(value: 'str') -> 'bool'
+edify.library.component(value: 'str') -> 'bool'
+edify.library.contact.address(value: 'str') -> 'bool'
+edify.library.contact.email(value: 'str') -> 'bool'
+edify.library.contact.email_rfc_5322(value: 'str') -> 'bool'
+edify.library.contact.fax(value: 'str') -> 'bool'
+edify.library.contact.handle(value: 'str') -> 'bool'
+edify.library.contact.pager(value: 'str') -> 'bool'
+edify.library.contact.phone(value: 'str') -> 'bool'
+edify.library.contact.username(value: 'str') -> 'bool'
+edify.library.cookie(value: 'str') -> 'bool'
+edify.library.coordinate(value: 'str') -> 'bool'
+edify.library.cron(value: 'str') -> 'bool'
+edify.library.crypto(value: 'str') -> 'bool'
+edify.library.csp(value: 'str') -> 'bool'
+edify.library.csr(value: 'str') -> 'bool'
+edify.library.csrf(value: 'str') -> 'bool'
+edify.library.csv(value: 'str') -> 'bool'
+edify.library.currency(value: 'str') -> 'bool'
+edify.library.cusip(value: 'str') -> 'bool'
+edify.library.data.avro(value: 'str') -> 'bool'
+edify.library.data.csv(value: 'str') -> 'bool'
+edify.library.data.hdf5(value: 'str') -> 'bool'
+edify.library.data.html(value: 'str') -> 'bool'
+edify.library.data.ini(value: 'str') -> 'bool'
+edify.library.data.json(value: 'str') -> 'bool'
+edify.library.data.msgpack(value: 'str') -> 'bool'
+edify.library.data.orc(value: 'str') -> 'bool'
+edify.library.data.parquet(value: 'str') -> 'bool'
+edify.library.data.protobuf(value: 'str') -> 'bool'
+edify.library.data.toml(value: 'str') -> 'bool'
+edify.library.data.tsv(value: 'str') -> 'bool'
+edify.library.data.xml(value: 'str') -> 'bool'
+edify.library.data.yaml(value: 'str') -> 'bool'
+edify.library.date(value: 'str') -> 'bool'
+edify.library.datetime(value: 'str') -> 'bool'
+edify.library.der(value: 'str') -> 'bool'
+edify.library.dicom(value: 'str') -> 'bool'
+edify.library.did(value: 'str') -> 'bool'
+edify.library.digest(value: 'str') -> 'bool'
+edify.library.docker(value: 'str') -> 'bool'
+edify.library.document.docx(value: 'str') -> 'bool'
+edify.library.document.epub(value: 'str') -> 'bool'
+edify.library.document.mobi(value: 'str') -> 'bool'
+edify.library.document.odt(value: 'str') -> 'bool'
+edify.library.document.pdf(value: 'str') -> 'bool'
+edify.library.document.pptx(value: 'str') -> 'bool'
+edify.library.document.readme(value: 'str') -> 'bool'
+edify.library.document.rtf(value: 'str') -> 'bool'
+edify.library.document.svg(value: 'str') -> 'bool'
+edify.library.document.tex(value: 'str') -> 'bool'
+edify.library.document.xlsx(value: 'str') -> 'bool'
+edify.library.docx(value: 'str') -> 'bool'
+edify.library.doi(value: 'str') -> 'bool'
+edify.library.domain(value: 'str') -> 'bool'
+edify.library.dosage(value: 'str') -> 'bool'
+edify.library.duration(value: 'str') -> 'bool'
+edify.library.ebnf(value: 'str') -> 'bool'
+edify.library.ein(value: 'str') -> 'bool'
+edify.library.email(value: 'str') -> 'bool'
+edify.library.email_rfc_5322(value: 'str') -> 'bool'
+edify.library.emoji(value: 'str') -> 'bool'
+edify.library.encoding(value: 'str') -> 'bool'
+edify.library.epoch(value: 'str') -> 'bool'
+edify.library.epub(value: 'str') -> 'bool'
+edify.library.extension(value: 'str') -> 'bool'
+edify.library.favicon(value: 'str') -> 'bool'
+edify.library.fax(value: 'str') -> 'bool'
+edify.library.filename(value: 'str') -> 'bool'
+edify.library.filter(value: 'str') -> 'bool'
+edify.library.financial.card(value: 'str') -> 'bool'
+edify.library.financial.crypto(value: 'str') -> 'bool'
+edify.library.financial.currency(value: 'str') -> 'bool'
+edify.library.financial.routing(value: 'str') -> 'bool'
+edify.library.financial.sortcode(value: 'str') -> 'bool'
+edify.library.financial.vat(value: 'str') -> 'bool'
+edify.library.financial.wallet(value: 'str') -> 'bool'
+edify.library.flight(value: 'str') -> 'bool'
+edify.library.fraction(value: 'str') -> 'bool'
+edify.library.geo.altitude(value: 'str') -> 'bool'
+edify.library.geo.bearing(value: 'str') -> 'bool'
+edify.library.geo.coordinate(value: 'str') -> 'bool'
+edify.library.geo.geohash(value: 'str') -> 'bool'
+edify.library.geo.mgrs(value: 'str') -> 'bool'
+edify.library.geo.place(value: 'str') -> 'bool'
+edify.library.geo.plus(value: 'str') -> 'bool'
+edify.library.geo.postal(value: 'str') -> 'bool'
+edify.library.geohash(value: 'str') -> 'bool'
+edify.library.git(value: 'str') -> 'bool'
+edify.library.glob(value: 'str') -> 'bool'
+edify.library.gradient(value: 'str') -> 'bool'
+edify.library.grammar.abnf(value: 'str') -> 'bool'
+edify.library.grammar.antlr(value: 'str') -> 'bool'
+edify.library.grammar.bnf(value: 'str') -> 'bool'
+edify.library.grammar.ebnf(value: 'str') -> 'bool'
+edify.library.grammar.peg(value: 'str') -> 'bool'
+edify.library.grammar.pest(value: 'str') -> 'bool'
+edify.library.graphql(value: 'str') -> 'bool'
+edify.library.gtin(value: 'str') -> 'bool'
+edify.library.guid(value: 'str') -> 'bool'
+edify.library.hal(value: 'str') -> 'bool'
+edify.library.handle(value: 'str') -> 'bool'
+edify.library.hash(value: 'str') -> 'bool'
+edify.library.hdf5(value: 'str') -> 'bool'
+edify.library.hmac(value: 'str') -> 'bool'
+edify.library.hostname(value: 'str') -> 'bool'
+edify.library.htaccess(value: 'str') -> 'bool'
+edify.library.html(value: 'str') -> 'bool'
+edify.library.humans(value: 'str') -> 'bool'
+edify.library.iata(value: 'str') -> 'bool'
+edify.library.iban(value: 'str') -> 'bool'
+edify.library.icao(value: 'str') -> 'bool'
+edify.library.iccid(value: 'str') -> 'bool'
+edify.library.identifier.arn(value: 'str') -> 'bool'
+edify.library.identifier.asin(value: 'str') -> 'bool'
+edify.library.identifier.bic(value: 'str') -> 'bool'
+edify.library.identifier.cusip(value: 'str') -> 'bool'
+edify.library.identifier.did(value: 'str') -> 'bool'
+edify.library.identifier.ein(value: 'str') -> 'bool'
+edify.library.identifier.guid(value: 'str') -> 'bool'
+edify.library.identifier.iata(value: 'str') -> 'bool'
+edify.library.identifier.iban(value: 'str') -> 'bool'
+edify.library.identifier.icao(value: 'str') -> 'bool'
+edify.library.identifier.iccid(value: 'str') -> 'bool'
+edify.library.identifier.imei(value: 'str') -> 'bool'
+edify.library.identifier.imo(value: 'str') -> 'bool'
+edify.library.identifier.isin(value: 'str') -> 'bool'
+edify.library.identifier.itin(value: 'str') -> 'bool'
+edify.library.identifier.lei(value: 'str') -> 'bool'
+edify.library.identifier.mac(value: 'str') -> 'bool'
+edify.library.identifier.meid(value: 'str') -> 'bool'
+edify.library.identifier.mmsi(value: 'str') -> 'bool'
+edify.library.identifier.orcid(value: 'str') -> 'bool'
+edify.library.identifier.sedol(value: 'str') -> 'bool'
+edify.library.identifier.sku(value: 'str') -> 'bool'
+edify.library.identifier.ssn(value: 'str') -> 'bool'
+edify.library.identifier.tin(value: 'str') -> 'bool'
+edify.library.identifier.uuid(value: 'str') -> 'bool'
+edify.library.identifier.vin(value: 'str') -> 'bool'
+edify.library.image(value: 'str') -> 'bool'
+edify.library.imei(value: 'str') -> 'bool'
+edify.library.imo(value: 'str') -> 'bool'
+edify.library.ini(value: 'str') -> 'bool'
+edify.library.integer(value: 'str') -> 'bool'
+edify.library.interval(value: 'str') -> 'bool'
+edify.library.ip(value: 'str') -> 'bool'
+edify.library.ipv4(value: 'str') -> 'bool'
+edify.library.ipv6(value: 'str') -> 'bool'
+edify.library.isbn(value: 'str') -> 'bool'
+edify.library.isin(value: 'str') -> 'bool'
+edify.library.iso_date(value: 'str') -> 'bool'
+edify.library.issn(value: 'str') -> 'bool'
+edify.library.itin(value: 'str') -> 'bool'
+edify.library.json(value: 'str') -> 'bool'
+edify.library.jsonapi(value: 'str') -> 'bool'
+edify.library.jwt(value: 'str') -> 'bool'
+edify.library.keyring(value: 'str') -> 'bool'
+edify.library.lei(value: 'str') -> 'bool'
+edify.library.locale(value: 'str') -> 'bool'
+edify.library.mac(value: 'str') -> 'bool'
+edify.library.makefile(value: 'str') -> 'bool'
+edify.library.manifest(value: 'str') -> 'bool'
+edify.library.media.charset(value: 'str') -> 'bool'
+edify.library.media.codec(value: 'str') -> 'bool'
+edify.library.media.encoding(value: 'str') -> 'bool'
+edify.library.media.extension(value: 'str') -> 'bool'
+edify.library.media.favicon(value: 'str') -> 'bool'
+edify.library.media.filename(value: 'str') -> 'bool'
+edify.library.media.glob(value: 'str') -> 'bool'
+edify.library.media.locale(value: 'str') -> 'bool'
+edify.library.media.mimetype(value: 'str') -> 'bool'
+edify.library.media.regex(value: 'str') -> 'bool'
+edify.library.media.shebang(value: 'str') -> 'bool'
+edify.library.medical(value: 'str') -> 'bool'
+edify.library.medical.blood(value: 'str') -> 'bool'
+edify.library.medical.dicom(value: 'str') -> 'bool'
+edify.library.medical.dosage(value: 'str') -> 'bool'
+edify.library.medical.medical(value: 'str') -> 'bool'
+edify.library.meid(value: 'str') -> 'bool'
+edify.library.mfa(value: 'str') -> 'bool'
+edify.library.mgrs(value: 'str') -> 'bool'
+edify.library.mimetype(value: 'str') -> 'bool'
+edify.library.mmsi(value: 'str') -> 'bool'
+edify.library.mnemonic(value: 'str') -> 'bool'
+edify.library.mobi(value: 'str') -> 'bool'
+edify.library.mpn(value: 'str') -> 'bool'
+edify.library.msgpack(value: 'str') -> 'bool'
+edify.library.natural(value: 'str') -> 'bool'
+edify.library.nginx(value: 'str') -> 'bool'
+edify.library.nonce(value: 'str') -> 'bool'
+edify.library.number(value: 'str') -> 'bool'
+edify.library.numeric(value: 'str') -> 'bool'
+edify.library.numeric.fraction(value: 'str') -> 'bool'
+edify.library.numeric.hash(value: 'str') -> 'bool'
+edify.library.numeric.integer(value: 'str') -> 'bool'
+edify.library.numeric.natural(value: 'str') -> 'bool'
+edify.library.numeric.number(value: 'str') -> 'bool'
+edify.library.numeric.ordinal(value: 'str') -> 'bool'
+edify.library.numeric.percentage(value: 'str') -> 'bool'
+edify.library.numeric.ratio(value: 'str') -> 'bool'
+edify.library.numeric.roman(value: 'str') -> 'bool'
+edify.library.numeric.scientific(value: 'str') -> 'bool'
+edify.library.oauth(value: 'str') -> 'bool'
+edify.library.odt(value: 'str') -> 'bool'
+edify.library.offset(value: 'str') -> 'bool'
+edify.library.openapi(value: 'str') -> 'bool'
+edify.library.openid(value: 'str') -> 'bool'
+edify.library.orc(value: 'str') -> 'bool'
+edify.library.orcid(value: 'str') -> 'bool'
+edify.library.ordinal(value: 'str') -> 'bool'
+edify.library.otp(value: 'str') -> 'bool'
+edify.library.package(value: 'str') -> 'bool'
+edify.library.pager(value: 'str') -> 'bool'
+edify.library.palette(value: 'str') -> 'bool'
+edify.library.parquet(value: 'str') -> 'bool'
+edify.library.passkey(value: 'str') -> 'bool'
+edify.library.password(value: 'str', min_length: 'int | None' = None, max_length: 'int | None' = None, min_upper: 'int | None' = None, min_lower: 'int | None' = None, min_digit: 'int | None' = None, min_special: 'int | None' = None, special_chars: 'str | None' = None) -> 'bool'
+edify.library.path(value: 'str') -> 'bool'
+edify.library.pdf(value: 'str') -> 'bool'
+edify.library.peg(value: 'str') -> 'bool'
+edify.library.pem(value: 'str') -> 'bool'
+edify.library.percentage(value: 'str') -> 'bool'
+edify.library.pest(value: 'str') -> 'bool'
+edify.library.pgp(value: 'str') -> 'bool'
+edify.library.phone(value: 'str') -> 'bool'
+edify.library.pin(value: 'str') -> 'bool'
+edify.library.place(value: 'str') -> 'bool'
+edify.library.plate(value: 'str') -> 'bool'
+edify.library.plus(value: 'str') -> 'bool'
+edify.library.pmc(value: 'str') -> 'bool'
+edify.library.pmid(value: 'str') -> 'bool'
+edify.library.port(value: 'str') -> 'bool'
+edify.library.postal(value: 'str') -> 'bool'
+edify.library.pptx(value: 'str') -> 'bool'
+edify.library.printable(value: 'str') -> 'bool'
+edify.library.product.barcode(value: 'str') -> 'bool'
+edify.library.product.gtin(value: 'str') -> 'bool'
+edify.library.product.mpn(value: 'str') -> 'bool'
+edify.library.protobuf(value: 'str') -> 'bool'
+edify.library.ptr(value: 'str') -> 'bool'
+edify.library.publishing.arxiv(value: 'str') -> 'bool'
+edify.library.publishing.doi(value: 'str') -> 'bool'
+edify.library.publishing.isbn(value: 'str') -> 'bool'
+edify.library.publishing.issn(value: 'str') -> 'bool'
+edify.library.publishing.pmc(value: 'str') -> 'bool'
+edify.library.publishing.pmid(value: 'str') -> 'bool'
+edify.library.ratio(value: 'str') -> 'bool'
+edify.library.readme(value: 'str') -> 'bool'
+edify.library.ref(value: 'str') -> 'bool'
+edify.library.refresh(value: 'str') -> 'bool'
+edify.library.regex(value: 'str') -> 'bool'
+edify.library.robots(value: 'str') -> 'bool'
+edify.library.roman(value: 'str') -> 'bool'
+edify.library.routing(value: 'str') -> 'bool'
+edify.library.rss(value: 'str') -> 'bool'
+edify.library.rtf(value: 'str') -> 'bool'
+edify.library.saml(value: 'str') -> 'bool'
+edify.library.scientific(value: 'str') -> 'bool'
+edify.library.script(value: 'str') -> 'bool'
+edify.library.secret(value: 'str') -> 'bool'
+edify.library.security.age(value: 'str') -> 'bool'
+edify.library.security.certificate(value: 'str') -> 'bool'
+edify.library.security.csr(value: 'str') -> 'bool'
+edify.library.security.der(value: 'str') -> 'bool'
+edify.library.security.keyring(value: 'str') -> 'bool'
+edify.library.security.nonce(value: 'str') -> 'bool'
+edify.library.security.pem(value: 'str') -> 'bool'
+edify.library.security.pgp(value: 'str') -> 'bool'
+edify.library.security.signature(value: 'str') -> 'bool'
+edify.library.security.ssh(value: 'str') -> 'bool'
+edify.library.security.x509(value: 'str') -> 'bool'
+edify.library.sedol(value: 'str') -> 'bool'
+edify.library.semver(value: 'str') -> 'bool'
+edify.library.session(value: 'str') -> 'bool'
+edify.library.shebang(value: 'str') -> 'bool'
+edify.library.signature(value: 'str') -> 'bool'
+edify.library.signing(value: 'str') -> 'bool'
+edify.library.sitemap(value: 'str') -> 'bool'
+edify.library.sku(value: 'str') -> 'bool'
+edify.library.slug(value: 'str') -> 'bool'
+edify.library.soap(value: 'str') -> 'bool'
+edify.library.socket(value: 'str') -> 'bool'
+edify.library.software.bump(value: 'str') -> 'bool'
+edify.library.software.cargo(value: 'str') -> 'bool'
+edify.library.software.checksum(value: 'str') -> 'bool'
+edify.library.software.component(value: 'str') -> 'bool'
+edify.library.software.digest(value: 'str') -> 'bool'
+edify.library.software.docker(value: 'str') -> 'bool'
+edify.library.software.git(value: 'str') -> 'bool'
+edify.library.software.image(value: 'str') -> 'bool'
+edify.library.software.makefile(value: 'str') -> 'bool'
+edify.library.software.package(value: 'str') -> 'bool'
+edify.library.software.ref(value: 'str') -> 'bool'
+edify.library.software.semver(value: 'str') -> 'bool'
+edify.library.software.version(value: 'str') -> 'bool'
+edify.library.sortcode(value: 'str') -> 'bool'
+edify.library.ssh(value: 'str') -> 'bool'
+edify.library.ssn(value: 'str') -> 'bool'
+edify.library.sso(value: 'str') -> 'bool'
+edify.library.subdomain(value: 'str') -> 'bool'
+edify.library.subnet(value: 'str') -> 'bool'
+edify.library.svg(value: 'str') -> 'bool'
+edify.library.swagger(value: 'str') -> 'bool'
+edify.library.swatch(value: 'str') -> 'bool'
+edify.library.temporal.cron(value: 'str') -> 'bool'
+edify.library.temporal.date(value: 'str') -> 'bool'
+edify.library.temporal.datetime(value: 'str') -> 'bool'
+edify.library.temporal.duration(value: 'str') -> 'bool'
+edify.library.temporal.epoch(value: 'str') -> 'bool'
+edify.library.temporal.interval(value: 'str') -> 'bool'
+edify.library.temporal.iso_date(value: 'str') -> 'bool'
+edify.library.temporal.offset(value: 'str') -> 'bool'
+edify.library.temporal.time(value: 'str') -> 'bool'
+edify.library.temporal.timestamp(value: 'str') -> 'bool'
+edify.library.temporal.timezone(value: 'str') -> 'bool'
+edify.library.temporal.year(value: 'str') -> 'bool'
+edify.library.tex(value: 'str') -> 'bool'
+edify.library.text.alpha(value: 'str') -> 'bool'
+edify.library.text.alphanumeric(value: 'str') -> 'bool'
+edify.library.text.ascii(value: 'str') -> 'bool'
+edify.library.text.base(value: 'str') -> 'bool'
+edify.library.text.emoji(value: 'str') -> 'bool'
+edify.library.text.numeric(value: 'str') -> 'bool'
+edify.library.text.printable(value: 'str') -> 'bool'
+edify.library.text.script(value: 'str') -> 'bool'
+edify.library.text.slug(value: 'str') -> 'bool'
+edify.library.text.unicode(value: 'str') -> 'bool'
+edify.library.text.word(value: 'str') -> 'bool'
+edify.library.time(value: 'str') -> 'bool'
+edify.library.timestamp(value: 'str') -> 'bool'
+edify.library.timezone(value: 'str') -> 'bool'
+edify.library.tin(value: 'str') -> 'bool'
+edify.library.tld(value: 'str') -> 'bool'
+edify.library.token(value: 'str') -> 'bool'
+edify.library.toml(value: 'str') -> 'bool'
+edify.library.transport.aircraft(value: 'str') -> 'bool'
+edify.library.transport.flight(value: 'str') -> 'bool'
+edify.library.transport.plate(value: 'str') -> 'bool'
+edify.library.transport.vehicle(value: 'str') -> 'bool'
+edify.library.tsv(value: 'str') -> 'bool'
+edify.library.unicode(value: 'str') -> 'bool'
+edify.library.uri(value: 'str') -> 'bool'
+edify.library.url(value: 'str') -> 'bool'
+edify.library.useragent(value: 'str') -> 'bool'
+edify.library.username(value: 'str') -> 'bool'
+edify.library.uuid(value: 'str') -> 'bool'
+edify.library.vat(value: 'str') -> 'bool'
+edify.library.vehicle(value: 'str') -> 'bool'
+edify.library.version(value: 'str') -> 'bool'
+edify.library.vin(value: 'str') -> 'bool'
+edify.library.wallet(value: 'str') -> 'bool'
+edify.library.web.apache(value: 'str') -> 'bool'
+edify.library.web.captcha(value: 'str') -> 'bool'
+edify.library.web.cookie(value: 'str') -> 'bool'
+edify.library.web.csp(value: 'str') -> 'bool'
+edify.library.web.htaccess(value: 'str') -> 'bool'
+edify.library.web.humans(value: 'str') -> 'bool'
+edify.library.web.manifest(value: 'str') -> 'bool'
+edify.library.web.nginx(value: 'str') -> 'bool'
+edify.library.web.robots(value: 'str') -> 'bool'
+edify.library.web.sitemap(value: 'str') -> 'bool'
+edify.library.web.useragent(value: 'str') -> 'bool'
+edify.library.webauthn(value: 'str') -> 'bool'
+edify.library.webhook(value: 'str') -> 'bool'
+edify.library.word(value: 'str') -> 'bool'
+edify.library.x509(value: 'str') -> 'bool'
+edify.library.xlsx(value: 'str') -> 'bool'
+edify.library.xml(value: 'str') -> 'bool'
+edify.library.yaml(value: 'str') -> 'bool'
+edify.library.year(value: 'str') -> 'bool'
+edify.library.zip_code(value: 'str') -> 'bool'
+edify.named_back_reference(name: 'str') -> 'Pattern'
+edify.named_capture(name: 'str', operand: 'BuilderProtocol') -> 'Pattern'
+edify.nonchars(characters: 'str') -> 'Pattern'
+edify.nonrange(start_character: 'str', end_character: 'str') -> 'Pattern'
+edify.nonstring(value: 'str') -> 'Pattern'
+edify.one_or_more(operand: 'BuilderProtocol') -> 'Pattern'
+edify.one_or_more_lazy(operand: 'BuilderProtocol') -> 'Pattern'
+edify.optional(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.ALPHANUMERIC(value: 'str') -> 'bool'
+edify.pattern.ANY_CHAR(value: 'str') -> 'bool'
+edify.pattern.CARRIAGE_RETURN(value: 'str') -> 'bool'
+edify.pattern.DIGIT(value: 'str') -> 'bool'
+edify.pattern.END(value: 'str') -> 'bool'
+edify.pattern.LETTER(value: 'str') -> 'bool'
+edify.pattern.LOWERCASE(value: 'str') -> 'bool'
+edify.pattern.NEW_LINE(value: 'str') -> 'bool'
+edify.pattern.NON_DIGIT(value: 'str') -> 'bool'
+edify.pattern.NON_WHITESPACE(value: 'str') -> 'bool'
+edify.pattern.NON_WORD(value: 'str') -> 'bool'
+edify.pattern.NON_WORD_BOUNDARY(value: 'str') -> 'bool'
+edify.pattern.NULL_BYTE(value: 'str') -> 'bool'
+edify.pattern.Pattern() -> 'None'
+edify.pattern.START(value: 'str') -> 'bool'
+edify.pattern.TAB(value: 'str') -> 'bool'
+edify.pattern.UPPERCASE(value: 'str') -> 'bool'
+edify.pattern.WHITESPACE(value: 'str') -> 'bool'
+edify.pattern.WORD(value: 'str') -> 'bool'
+edify.pattern.WORD_BOUNDARY(value: 'str') -> 'bool'
+edify.pattern.any_of(*operands: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.assert_ahead(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.assert_behind(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.assert_not_ahead(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.assert_not_behind(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.at_least(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.at_most(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.back_reference(index: 'int') -> 'Pattern'
+edify.pattern.between(lower: 'int', upper: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.between_lazy(lower: 'int', upper: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.capture(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.char(value: 'str') -> 'Pattern'
+edify.pattern.chars(characters: 'str') -> 'Pattern'
+edify.pattern.exactly(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.any_of(*operands: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.assert_ahead(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.assert_behind(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.assert_not_ahead(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.assert_not_behind(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.at_least(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.at_most(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.back_reference(index: 'int') -> 'Pattern'
+edify.pattern.factories.between(lower: 'int', upper: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.between_lazy(lower: 'int', upper: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.capture(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.char(value: 'str') -> 'Pattern'
+edify.pattern.factories.chars(characters: 'str') -> 'Pattern'
+edify.pattern.factories.exactly(count: 'int', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.group(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.named_back_reference(name: 'str') -> 'Pattern'
+edify.pattern.factories.named_capture(name: 'str', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.nonchars(characters: 'str') -> 'Pattern'
+edify.pattern.factories.nonrange(start_character: 'str', end_character: 'str') -> 'Pattern'
+edify.pattern.factories.nonstring(value: 'str') -> 'Pattern'
+edify.pattern.factories.one_or_more(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.one_or_more_lazy(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.optional(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.range_of(start_character: 'str', end_character: 'str') -> 'Pattern'
+edify.pattern.factories.string(value: 'str') -> 'Pattern'
+edify.pattern.factories.zero_or_more(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.factories.zero_or_more_lazy(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.group(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.named_back_reference(name: 'str') -> 'Pattern'
+edify.pattern.named_capture(name: 'str', operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.nonchars(characters: 'str') -> 'Pattern'
+edify.pattern.nonrange(start_character: 'str', end_character: 'str') -> 'Pattern'
+edify.pattern.nonstring(value: 'str') -> 'Pattern'
+edify.pattern.one_or_more(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.one_or_more_lazy(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.optional(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.range_of(start_character: 'str', end_character: 'str') -> 'Pattern'
+edify.pattern.string(value: 'str') -> 'Pattern'
+edify.pattern.zero_or_more(operand: 'BuilderProtocol') -> 'Pattern'
+edify.pattern.zero_or_more_lazy(operand: 'BuilderProtocol') -> 'Pattern'
+edify.range_of(start_character: 'str', end_character: 'str') -> 'Pattern'
+edify.result.Match(wrapped_match: 're.Match[str]') -> 'None'
+edify.result.NamedCaptures(wrapped_match: 're.Match[str]') -> 'None'
+edify.result.Regex(source: 'str', compiled: 're.Pattern[str]', elements: 'tuple[BaseElement, ...]' = (), engine: 'Engine' = 're') -> 'None'
+edify.serialize.JSONPrimitive
+edify.serialize.JSONValue
+edify.serialize.SCHEMA_VERSION
+edify.serialize.dict_to_element(tree: 'dict[str, JSONValue]') -> 'BaseElement'
+edify.serialize.dict_to_state(document: 'dict[str, JSONValue]') -> 'BuilderState'
+edify.serialize.element_to_dict(element: 'BaseElement') -> 'dict[str, JSONValue]'
+edify.serialize.state_to_dict(state: 'BuilderState') -> 'dict[str, JSONValue]'
+edify.string(value: 'str') -> 'Pattern'
+edify.testing.SnapshotMismatchError(snapshot_path: 'Path', actual: 'str', expected: 'str') -> 'None'
+edify.testing.SnapshotMissingError(snapshot_path: 'Path') -> 'None'
+edify.testing.assert_snapshot(actual: 'str', snapshot_path: 'Path') -> 'None'
+edify.validate.is_valid_group_name(name: 'str') -> 'bool'
+edify.zero_or_more(operand: 'BuilderProtocol') -> 'Pattern'
+edify.zero_or_more_lazy(operand: 'BuilderProtocol') -> 'Pattern'
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 9453464..ad6869c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,6 +4,7 @@ Changelog
Unreleased
----------
+* **Changed:** Relicensed from Apache-2.0 to the `MIT License <https://github.com/luciferreeves/edify/blob/main/LICENSE>`_. No API change. See the :ref:`license-mit` note in the 0.3 → 1.0 upgrade guide for the patent-grant and pinned-tags details.
* **Breaking:** Dropped support for Python 3.8. Edify now requires Python 3.9 or newer (:pr:`64`). Python 3.8 reached EOL on 2024-10-07; dependencies are now actively dropping it (e.g. ``virtualenv`` 21.5+ requires ``>=3.9``).
* **Tooling and CI:** Dropped macOS and Windows runners from the CI matrix; Linux-only from here on. Edify is a pure-Python wheel with no platform-specific code, so the multi-OS jobs were buying ~zero signal and produced false negatives. Branch-protection required contexts went from 31 → 13 in lockstep (and now 12 with the Python 3.8 drop) (:pr:`60`).
* **Dependencies:** Bumped the minimum ``virtualenv`` floor for the CI bootstrap to ``>=21.4.2`` (:pr:`56`, :pr:`58`).
diff --git a/changes/README.rst b/changes/README.rst
new file mode 100644
index 0000000..7a19e14
--- /dev/null
+++ b/changes/README.rst
@@ -0,0 +1,55 @@
+Change fragments
+================
+
+Every breaking change to Edify's public surface lands with a **change fragment**
+in this directory. Fragments are the single source of truth for the CHANGELOG and
+the upgrade guide: the release generator (``tools/changes.py``) concatenates them
+into both outputs, then deletes them.
+
+There is no external changelog tooling — the format is a small stdlib-parseable
+INI, and the generator is a single stdlib script.
+
+File format
+-----------
+
+One file per breaking change, named ``<fragment-id>.rst`` where ``<fragment-id>``
+sorts into the order the fragment should appear in the release notes (for
+example ``0010-char-class-escape.rst``). The file is an INI document with a single
+``[change]`` section:
+
+.. code-block:: ini
+
+ [change]
+ anchor = char-class-escape
+ heading = Character-class escaping is the minimal correct form
+ before = any_of_chars('#?!@$%^&*-') -> [\#\?!@$%\^\&\*\-]
+ after = any_of_chars('#?!@$%^&*-') -> [#?!@$%^&*-]
+ context = Only backslash, closing bracket, first-position caret, and interior
+ dash are escaped inside a character class; every other metacharacter is a
+ literal. Match behavior is unchanged.
+
+Fields
+------
+
+``anchor`` (required)
+ The frozen ``.. _<anchor>:`` label the upgrade-guide section carries. The
+ deprecation-warning URL for this change ends in ``#<anchor>``.
+
+``heading`` (required)
+ The one-line human title of the change.
+
+``before`` / ``after`` (optional)
+ A before/after pair. Include both or neither. Rendered as a literal block when
+ present.
+
+``context`` (required)
+ One or two sentences explaining what changed and what the caller should do.
+ May span multiple indented lines.
+
+Release flow
+------------
+
+On release, ``tools/changes.py`` slots each fragment into the matching section of
+``CHANGELOG.rst`` and the active upgrade guide in fragment-id order, then removes
+the fragment files. Adding a fragment is the only step a contributor takes; the
+generator does the rest.
diff --git a/tests/discipline/anchors.test.py b/tests/discipline/anchors.test.py
new file mode 100644
index 0000000..0e5e8d7
--- /dev/null
+++ b/tests/discipline/anchors.test.py
@@ -0,0 +1,66 @@
+"""Deprecation-URL anchor contract.
+
+A deprecation warning points users at ``docs/upgrading/...#anchor``. Shape-checking
+the URL alone passes even when the anchor 404s. This test extracts every anchor
+referenced by a deprecation stub, parses the upgrade guides for their actual
+``.. _label:`` targets, and asserts each referenced anchor exists.
+
+Edify 1.0 ships stub-free, so the deprecation-URL registry is empty today; the
+test still verifies that every anchor the upgrade guides *declare* is well-formed
+and unique, so the contract is enforced the moment the first stub lands.
+"""
+
+import re
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).parent.parent.parent
+_UPGRADING_DIR = _REPO_ROOT / "docs" / "upgrading"
+_ANCHOR_PATTERN = re.compile(r"^\.\.\s+_([a-zA-Z0-9][a-zA-Z0-9\-]*):\s*$")
+
+# Anchors referenced by deprecation-warning URLs. Each entry is (guide-stem, anchor).
+# Empty in 1.0 (stub-free); every future deprecation stub adds its (guide, anchor) row.
+_DEPRECATION_URL_ANCHORS: frozenset[tuple[str, str]] = frozenset()
+
+
+def _declared_anchors(rst_path: Path) -> list[str]:
+ anchors: list[str] = []
+ for line in rst_path.read_text().splitlines():
+ matched = _ANCHOR_PATTERN.match(line)
+ if matched is not None:
+ anchors.append(matched.group(1))
+ return anchors
+
+
+def _anchors_by_guide() -> dict[str, list[str]]:
+ result: dict[str, list[str]] = {}
+ for rst_path in sorted(_UPGRADING_DIR.glob("*.rst")):
+ result[rst_path.stem] = _declared_anchors(rst_path)
+ return result
+
+
+def test_upgrade_guide_anchors_are_unique_within_each_guide():
+ for guide_stem, anchors in _anchors_by_guide().items():
+ assert len(anchors) == len(set(anchors)), (
+ f"duplicate anchor(s) in docs/upgrading/{guide_stem}.rst: "
+ f"{sorted({a for a in anchors if anchors.count(a) > 1})}"
+ )
+
+
+def test_every_deprecation_url_anchor_exists_in_its_guide():
+ anchors_by_guide = _anchors_by_guide()
+ for guide_stem, anchor in _DEPRECATION_URL_ANCHORS:
+ declared = anchors_by_guide.get(guide_stem, [])
+ assert anchor in declared, (
+ f"deprecation URL references #{anchor} in docs/upgrading/{guide_stem}.rst, "
+ f"but no `.. _{anchor}:` target exists there."
+ )
+
+
+def test_the_0_3_to_1_0_guide_declares_the_license_anchor():
+ anchors = _anchors_by_guide()["0.3-to-1.0"]
+ assert "license-mit" in anchors
+
+
+def test_the_0_3_to_1_0_guide_declares_the_python_floor_anchor():
+ anchors = _anchors_by_guide()["0.3-to-1.0"]
+ assert "python-floor" in anchors
diff --git a/tests/discipline/deprecations.test.py b/tests/discipline/deprecations.test.py
new file mode 100644
index 0000000..fe3416e
--- /dev/null
+++ b/tests/discipline/deprecations.test.py
@@ -0,0 +1,72 @@
+"""Deprecation-stub contract harness.
+
+Every deprecated public symbol must fire exactly one :class:`DeprecationWarning`
+whose message matches the policy format::
+
+ <name> is deprecated since <version>; use <replacement>. See <docs-url>
+
+with ``stacklevel=2`` so the warning points at the caller. Edify 1.0 ships
+stub-free, so the registry below is empty; this harness parametrizes over it, so
+the moment a stub lands its row here pins the category, message shape, single-fire
+behavior, and documentation URL.
+"""
+
+import importlib
+import re
+import warnings
+
+import pytest
+
+_MESSAGE_PATTERN = re.compile(
+ r"^(?P<name>\S+) is deprecated since (?P<version>\S+); "
+ r"use (?P<replacement>.+)\. See (?P<url>https://\S+#\S+)$"
+)
+
+# One row per deprecated stub: (import_path, symbol_name, call_args).
+# Empty in 1.0 (stub-free). Each future deprecation adds its row here.
+_DEPRECATED_STUBS: list[tuple[str, str, tuple[object, ...]]] = []
+
+
+ ("import_path", "symbol_name", "call_args"),
+ _DEPRECATED_STUBS,
+ ids=[f"{path}.{name}" for path, name, _ in _DEPRECATED_STUBS],
+)
+def test_deprecated_stub_fires_one_well_formed_warning(import_path, symbol_name, call_args):
+ module = importlib.import_module(import_path)
+ stub = getattr(module, symbol_name)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ stub(*call_args)
+ deprecation_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
+ assert len(deprecation_warnings) == 1
+ message_text = str(deprecation_warnings[0].message)
+ assert _MESSAGE_PATTERN.match(message_text), (
+ f"{import_path}.{symbol_name} warning does not match the policy format: {message_text!r}"
+ )
+
+
+def test_message_pattern_accepts_a_policy_conformant_message():
+ conformant = (
+ "old_name is deprecated since 1.1; use new_name. "
+ "See https://edify.readthedocs.io/en/latest/upgrading/1.0-to-1.1.html#new-name"
+ )
+ assert _MESSAGE_PATTERN.match(conformant)
+
+
+def test_message_pattern_rejects_a_message_missing_the_docs_url():
+ non_conformant = "old_name is deprecated since 1.1; use new_name."
+ assert _MESSAGE_PATTERN.match(non_conformant) is None
+
+
+def test_message_pattern_rejects_a_url_without_an_anchor():
+ non_conformant = (
+ "old_name is deprecated since 1.1; use new_name. "
+ "See https://edify.readthedocs.io/en/latest/upgrading/1.0-to-1.1.html"
+ )
+ assert _MESSAGE_PATTERN.match(non_conformant) is None
+
+
+def test_registry_is_empty_or_every_row_has_three_fields():
+ for row in _DEPRECATED_STUBS:
+ assert len(row) == 3
diff --git a/tests/docs/snapshots.test.py b/tests/docs/snapshots.test.py
index b1c223b..f335373 100644
--- a/tests/docs/snapshots.test.py
+++ b/tests/docs/snapshots.test.py
@@ -103,6 +103,12 @@ _BLOCKS_DEFERRED_TO_DOCS_REWRITE = frozenset(
}
)
+_ILLUSTRATIVE_NON_EXECUTABLE_BLOCKS = frozenset(
+ {
+ ("upgrading/0.3-to-1.0", 140),
+ }
+)
+
_BLOCKS_SKIPPED_ON_PYPY = frozenset(
{
("regex-builder/flags/index", 38),
@@ -123,6 +129,8 @@ def test_doc_code_block_produces_the_snapshotted_regex(
stem_string = str(relative_stem)
if (stem_string, block_start) in _BLOCKS_DEFERRED_TO_DOCS_REWRITE:
pytest.skip("doc block references validators / kwargs slated for the docs rewrite")
+ if (stem_string, block_start) in _ILLUSTRATIVE_NON_EXECUTABLE_BLOCKS:
+ pytest.skip("doc block shows pre/post-migration code that is intentionally not executable")
if _ON_PYPY and (stem_string, block_start) in _BLOCKS_SKIPPED_ON_PYPY:
pytest.skip("doc block hits PyPy's re.DEBUG upstream disassembler bug")
namespace = _prepared_exec_namespace()
diff --git a/tests/snapshots/docs/upgrading/0.3-to-1.0/0018.regex b/tests/snapshots/docs/deprecation-policy/0020.regex
index e69de29..e69de29 100644
--- a/tests/snapshots/docs/upgrading/0.3-to-1.0/0018.regex
+++ b/tests/snapshots/docs/deprecation-policy/0020.regex
diff --git a/tests/snapshots/docs/upgrading/0.3-to-1.0/0037.regex b/tests/snapshots/docs/upgrading/0.3-to-1.0/0046.regex
index e69de29..e69de29 100644
--- a/tests/snapshots/docs/upgrading/0.3-to-1.0/0037.regex
+++ b/tests/snapshots/docs/upgrading/0.3-to-1.0/0046.regex
diff --git a/tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex b/tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex
diff --git a/tests/tools/changes.test.py b/tests/tools/changes.test.py
new file mode 100644
index 0000000..7eeb777
--- /dev/null
+++ b/tests/tools/changes.test.py
@@ -0,0 +1,116 @@
+"""Tests for the ``changes/`` fragment generator in ``tools/changes.py``."""
+
+import importlib.util
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).parent.parent.parent
+_GENERATOR_PATH = _REPO_ROOT / "tools" / "changes.py"
+
+
+def _load_generator():
+ spec = importlib.util.spec_from_file_location("edify_changes_tool", _GENERATOR_PATH)
+ assert spec is not None
+ assert spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+_GENERATOR = _load_generator()
+
+
+def _write_fragment(directory: Path, name: str, body: str) -> Path:
+ fragment_path = directory / name
+ fragment_path.write_text(body, encoding="utf-8")
+ return fragment_path
+
+
+def test_collect_fragments_returns_files_in_fragment_id_order(tmp_path):
+ _write_fragment(tmp_path, "0020-second.rst", "[change]\nanchor=b\nheading=B\ncontext=b")
+ _write_fragment(tmp_path, "0010-first.rst", "[change]\nanchor=a\nheading=A\ncontext=a")
+ collected = _GENERATOR.collect_fragments(tmp_path)
+ assert [path.name for path in collected] == ["0010-first.rst", "0020-second.rst"]
+
+
+def test_collect_fragments_skips_the_readme(tmp_path):
+ _write_fragment(tmp_path, "README.rst", "not a fragment")
+ _write_fragment(tmp_path, "0010-only.rst", "[change]\nanchor=a\nheading=A\ncontext=a")
+ collected = _GENERATOR.collect_fragments(tmp_path)
+ assert [path.name for path in collected] == ["0010-only.rst"]
+
+
+def test_render_fragment_emits_anchor_heading_and_context(tmp_path):
+ fragment_path = _write_fragment(
+ tmp_path,
+ "0010-example.rst",
+ "[change]\nanchor = my-anchor\nheading = My Heading\ncontext = A single sentence.",
+ )
+ rendered = _GENERATOR.render_fragment(fragment_path)
+ assert ".. _my-anchor:" in rendered
+ assert "My Heading" in rendered
+ assert "-" * len("My Heading") in rendered
+ assert "A single sentence." in rendered
+
+
+def test_render_fragment_collapses_multiline_context_into_one_paragraph(tmp_path):
+ fragment_path = _write_fragment(
+ tmp_path,
+ "0010-multiline.rst",
+ "[change]\nanchor = a\nheading = H\ncontext = first line\n second line",
+ )
+ rendered = _GENERATOR.render_fragment(fragment_path)
+ assert "first line second line" in rendered
+
+
+def test_render_fragment_includes_before_after_block_when_both_present(tmp_path):
+ fragment_path = _write_fragment(
+ tmp_path,
+ "0010-beforeafter.rst",
+ "[change]\nanchor = a\nheading = H\nbefore = old\nafter = new\ncontext = changed",
+ )
+ rendered = _GENERATOR.render_fragment(fragment_path)
+ assert ".. code-block:: text" in rendered
+ assert " old" in rendered
+ assert " new" in rendered
+
+
+def test_render_fragment_omits_before_after_block_when_absent(tmp_path):
+ fragment_path = _write_fragment(
+ tmp_path,
+ "0010-nofix.rst",
+ "[change]\nanchor = a\nheading = H\ncontext = no before/after here",
+ )
+ rendered = _GENERATOR.render_fragment(fragment_path)
+ assert ".. code-block:: text" not in rendered
+
+
+def test_render_all_concatenates_every_fragment_in_order(tmp_path):
+ _write_fragment(tmp_path, "0020-b.rst", "[change]\nanchor=b\nheading=Bravo\ncontext=b")
+ _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=Alpha\ncontext=a")
+ rendered = _GENERATOR.render_all(tmp_path)
+ assert rendered.index("Alpha") < rendered.index("Bravo")
+
+
+def test_main_release_deletes_consumed_fragments(tmp_path, monkeypatch):
+ _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=A\ncontext=a")
+ monkeypatch.setattr(_GENERATOR, "_CHANGES_DIR", tmp_path)
+ exit_code = _GENERATOR.main(["--release"])
+ assert exit_code == 0
+ assert _GENERATOR.collect_fragments(tmp_path) == []
+
+
+def test_main_without_release_leaves_fragments_in_place(tmp_path, monkeypatch, capsys):
+ _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=A\ncontext=a")
+ monkeypatch.setattr(_GENERATOR, "_CHANGES_DIR", tmp_path)
+ exit_code = _GENERATOR.main([])
+ captured = capsys.readouterr()
+ assert exit_code == 0
+ assert "A" in captured.out
+ assert len(_GENERATOR.collect_fragments(tmp_path)) == 1
+
+
+def test_committed_changes_directory_fragments_all_parse():
+ changes_dir = _REPO_ROOT / "changes"
+ for fragment_path in _GENERATOR.collect_fragments(changes_dir):
+ rendered = _GENERATOR.render_fragment(fragment_path)
+ assert rendered.strip() != ""
diff --git a/tests/tools/surface.test.py b/tests/tools/surface.test.py
new file mode 100644
index 0000000..0b8daf0
--- /dev/null
+++ b/tests/tools/surface.test.py
@@ -0,0 +1,89 @@
+"""Tests for the public-surface snapshot tool in ``tools/surface.py``."""
+
+import importlib.util
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).parent.parent.parent
+_TOOL_PATH = _REPO_ROOT / "tools" / "surface.py"
+_SURFACE_PATH = _REPO_ROOT / ".public-surface"
+
+
+def _load_tool():
+ spec = importlib.util.spec_from_file_location("edify_surface_tool", _TOOL_PATH)
+ assert spec is not None
+ assert spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+_TOOL = _load_tool()
+
+
+def test_committed_surface_file_exists_and_is_non_empty():
+ assert _SURFACE_PATH.exists()
+ assert _SURFACE_PATH.read_text(encoding="utf-8").strip() != ""
+
+
+def test_computed_surface_matches_the_committed_snapshot():
+ computed = _TOOL.compute_surface()
+ committed = _SURFACE_PATH.read_text(encoding="utf-8")
+ assert computed == committed, (
+ "public surface drift: run `python tools/surface.py --write` and commit "
+ ".public-surface with a changes/ fragment describing the change."
+ )
+
+
+def test_surface_lists_the_core_public_symbols():
+ computed = _TOOL.compute_surface()
+ assert "edify.Pattern" in computed
+ assert "edify.RegexBuilder" in computed
+ assert "edify.Regex" in computed
+ assert "edify.EdifyError" in computed
+
+
+def test_surface_excludes_private_module_paths():
+ computed = _TOOL.compute_surface()
+ for line in computed.splitlines():
+ dotted = line.split("(")[0]
+ parts = dotted.split(".")
+ assert not any(part.startswith("_") for part in parts[:-1]), (
+ f"surface line leaks a private module path: {line!r}"
+ )
+
+
+def test_surface_is_sorted_and_deduplicated():
+ computed = _TOOL.compute_surface()
+ lines = computed.splitlines()
+ assert lines == sorted(lines)
+ assert len(lines) == len(set(lines))
+
+
+def test_check_returns_zero_when_surface_matches(capsys):
+ exit_code = _TOOL.main(["--check"])
+ assert exit_code == 0
+
+
+def test_check_returns_one_when_surface_drifts(tmp_path, monkeypatch, capsys):
+ drifted_snapshot = tmp_path / ".public-surface"
+ drifted_snapshot.write_text("edify.SomethingRemoved\n", encoding="utf-8")
+ monkeypatch.setattr(_TOOL, "_SURFACE_PATH", drifted_snapshot)
+ exit_code = _TOOL.main(["--check"])
+ captured = capsys.readouterr()
+ assert exit_code == 1
+ assert "public surface drift" in captured.err
+
+
+def test_write_overwrites_the_snapshot_file(tmp_path, monkeypatch):
+ target = tmp_path / ".public-surface"
+ monkeypatch.setattr(_TOOL, "_SURFACE_PATH", target)
+ exit_code = _TOOL.main(["--write"])
+ assert exit_code == 0
+ assert target.read_text(encoding="utf-8") == _TOOL.compute_surface()
+
+
+def test_bare_invocation_prints_the_surface_to_stdout(capsys):
+ exit_code = _TOOL.main([])
+ captured = capsys.readouterr()
+ assert exit_code == 0
+ assert "edify.Pattern" in captured.out
diff --git a/tools/changes.py b/tools/changes.py
new file mode 100644
index 0000000..0c3e0bf
--- /dev/null
+++ b/tools/changes.py
@@ -0,0 +1,65 @@
+"""Render committed change fragments into CHANGELOG / upgrade-guide RST.
+
+Stdlib only, no external changelog tooling. Reads every ``changes/*.rst``
+fragment (INI with a single ``[change]`` section), sorts them by filename, and
+renders each into an RST section. On ``--release`` the rendered blocks are
+slotted into the target files and the consumed fragments are deleted.
+"""
+
+from __future__ import annotations
+
+import configparser
+import sys
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_CHANGES_DIR = _REPO_ROOT / "changes"
+
+
+def collect_fragments(changes_dir: Path) -> list[Path]:
+ """Return the fragment files under ``changes_dir`` in fragment-id order."""
+ fragments = [path for path in changes_dir.glob("*.rst") if path.name != "README.rst"]
+ return sorted(fragments, key=lambda path: path.name)
+
+
+def render_fragment(fragment_path: Path) -> str:
+ """Return the RST block for a single fragment file."""
+ parser = configparser.ConfigParser()
+ parser.read(fragment_path, encoding="utf-8")
+ change = parser["change"]
+ anchor = change["anchor"]
+ heading = change["heading"]
+ context = _normalize_paragraph(change["context"])
+ lines = [f".. _{anchor}:", "", heading, "-" * len(heading), "", context, ""]
+ before = change.get("before")
+ after = change.get("after")
+ if before is not None and after is not None:
+ lines.extend(["", ".. code-block:: text", "", f" {before}", f" {after}", ""])
+ return "\n".join(lines)
+
+
+def render_all(changes_dir: Path) -> str:
+ """Return the concatenated RST for every fragment under ``changes_dir``."""
+ fragment_paths = collect_fragments(changes_dir)
+ rendered_blocks = [render_fragment(path) for path in fragment_paths]
+ return "\n".join(rendered_blocks)
+
+
+def _normalize_paragraph(raw: str) -> str:
+ collapsed_lines = [line.strip() for line in raw.splitlines()]
+ non_empty = [line for line in collapsed_lines if line]
+ return " ".join(non_empty)
+
+
+def main(argv: list[str]) -> int:
+ """Print the rendered fragments; with ``--release`` also delete them."""
+ rendered = render_all(_CHANGES_DIR)
+ sys.stdout.write(rendered)
+ if "--release" in argv:
+ for fragment_path in collect_fragments(_CHANGES_DIR):
+ fragment_path.unlink()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/tools/surface.py b/tools/surface.py
new file mode 100644
index 0000000..2677d95
--- /dev/null
+++ b/tools/surface.py
@@ -0,0 +1,99 @@
+"""Compute Edify's public API surface as a stable, diffable text snapshot.
+
+Walks ``edify`` and its documented public submodules, and emits one line per
+public symbol: its dotted path plus, for callables, a normalized signature. The
+output is deterministic (sorted) so a diff against the committed
+``.public-surface`` file shows exactly what a PR added, removed, or changed.
+"""
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import pkgutil
+import sys
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_SURFACE_PATH = _REPO_ROOT / ".public-surface"
+_ROOT_PACKAGE = "edify"
+
+
+def compute_surface() -> str:
+ """Return the full public-surface snapshot text for the ``edify`` package."""
+ module_names = _public_module_names()
+ entries: list[str] = []
+ for module_name in module_names:
+ module = importlib.import_module(module_name)
+ entries.extend(_module_entries(module_name, module))
+ unique_sorted = sorted(set(entries))
+ return "\n".join(unique_sorted) + "\n"
+
+
+def _public_module_names() -> list[str]:
+ root_module = importlib.import_module(_ROOT_PACKAGE)
+ root_path = root_module.__path__
+ names = [_ROOT_PACKAGE]
+ for module_info in pkgutil.walk_packages(root_path, prefix=f"{_ROOT_PACKAGE}."):
+ if _is_private_path(module_info.name):
+ continue
+ names.append(module_info.name)
+ return names
+
+
+def _is_private_path(dotted_name: str) -> bool:
+ parts = dotted_name.split(".")
+ return any(part.startswith("_") for part in parts)
+
+
+def _module_entries(module_name: str, module: object) -> list[str]:
+ exported_names = getattr(module, "__all__", None)
+ if exported_names is None:
+ return []
+ entries: list[str] = []
+ for symbol_name in exported_names:
+ if symbol_name.startswith("_"):
+ continue
+ value = getattr(module, symbol_name, None)
+ entries.append(_render_symbol(module_name, symbol_name, value))
+ return entries
+
+
+def _render_symbol(module_name: str, symbol_name: str, value: object) -> str:
+ dotted = f"{module_name}.{symbol_name}"
+ signature = _signature_or_empty(value)
+ if signature:
+ return f"{dotted}{signature}"
+ return dotted
+
+
+def _signature_or_empty(value: object) -> str:
+ if not callable(value):
+ return ""
+ try:
+ return str(inspect.signature(value))
+ except (ValueError, TypeError):
+ return ""
+
+
+def main(argv: list[str]) -> int:
+ """Print the surface; with ``--write`` overwrite the committed snapshot file."""
+ surface = compute_surface()
+ if "--write" in argv:
+ _SURFACE_PATH.write_text(surface, encoding="utf-8")
+ return 0
+ if "--check" in argv:
+ committed = _SURFACE_PATH.read_text(encoding="utf-8")
+ if committed != surface:
+ sys.stderr.write(
+ "public surface drift: run `python tools/surface.py --write` and commit "
+ ".public-surface, adding a changes/ fragment for the change.\n"
+ )
+ return 1
+ return 0
+ sys.stdout.write(surface)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))