This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 9c07ea6 feat(github-body-field): tool to rewrite one issue-body field
without loading the body into agent context (#412)
9c07ea6 is described below
commit 9c07ea6de4b722dc8a1742af01f1721b39ff280f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun May 31 13:20:31 2026 +0200
feat(github-body-field): tool to rewrite one issue-body field without
loading the body into agent context (#412)
The security-sync skills currently update a single `### Field`
section of a tracker issue body by reading the full 10-15 KB body
into agent context, regex-editing one field, and writing the whole
body back. That spends ~5 K tokens per single-field flip, in
addition to looping reporter-supplied content (the most sensitive
content on the tracker) through the agent for no reason.
`tools/github-body-field/` is a stdlib-only Python tool that does
the read / parse / replace / push in a subprocess. The agent only
sees the diff summary on stdout. Subcommands:
body-field get <N> --field "<name>" → prints one value
body-field set <N> --field "<name>" \
--value "<v>" → rewrites in place
body-field list <N> → prints all headings
Idempotent: a `set` whose value already matches skips the push
(saves an API call + an audit-log entry). Parser is a small state
machine tracking fenced code blocks so a literal `### foo` inside
a shell snippet never false-matches as a heading.
Migrates one call site in this PR — security-cve-allocate Step 4
item 1 (set *CVE tool link*) — to prove the pattern. The remaining
call sites (Public advisory URL, Remediation developer, Short
public summary for publish, Affected versions, etc.) will follow
in separate PRs.
41 unit tests cover the parser edge cases (fenced headings,
duplicate headings, empty values, last-section padding, idempotent
rewrites) and the CLI orchestration (dry-run, no-op short-circuit,
exit-code contract).
---
.claude/skills/security-cve-allocate/SKILL.md | 19 +-
.pre-commit-config.yaml | 31 +++
docs/labels-and-capabilities.md | 1 +
tools/github-body-field/README.md | 103 +++++++
tools/github-body-field/pyproject.toml | 88 ++++++
.../src/github_body_field/__init__.py | 31 +++
.../github-body-field/src/github_body_field/cli.py | 191 +++++++++++++
.../src/github_body_field/parser.py | 240 ++++++++++++++++
tools/github-body-field/tests/__init__.py | 0
tools/github-body-field/tests/test_cli.py | 268 ++++++++++++++++++
tools/github-body-field/tests/test_parser.py | 249 +++++++++++++++++
tools/github-body-field/uv.lock | 309 +++++++++++++++++++++
12 files changed, 1525 insertions(+), 5 deletions(-)
diff --git a/.claude/skills/security-cve-allocate/SKILL.md
b/.claude/skills/security-cve-allocate/SKILL.md
index d52b029..f0313ee 100644
--- a/.claude/skills/security-cve-allocate/SKILL.md
+++ b/.claude/skills/security-cve-allocate/SKILL.md
@@ -488,11 +488,20 @@ user to confirm. Numbered items:
allocated CVE ID (for the airflow-s adopter, this resolves to
`https://cveprocess.apache.org/cve5/CVE-YYYY-NNNNN`). Patch
only this one field; do not touch the rest of the body. Use
- the `security-issue-sync` skill's body-field-surgery recipe —
- read the full body, replace the *CVE tool link* field's value
- between its `### CVE tool link\n\n` header and the next
- `### ` or end-of-body, write back via
- `gh issue edit --body-file`.
+ the
+ [`github-body-field`](../../../tools/github-body-field/README.md)
+ tool — it reads, parses, and rewrites just the targeted
+ `### CVE tool link` section without bringing the issue body
+ into agent context:
+ ```bash
+ uv run --directory <framework>/tools/github-body-field \
+ body-field --repo <tracker> set <N> \
+ --field "CVE tool link" \
+ --value "https://cveprocess.apache.org/cve5/CVE-YYYY-NNNNN"
+ ```
+ Exit code `0` means written; exit `3` means the heading was
+ absent — fall through to manual edit only if that fires
+ (legacy trackers from before the issue template existed).
2. **Add the `cve allocated` label.** `gh issue edit <N> --repo
<tracker> --add-label "cve allocated"`.
3. **Append a `CVE allocated` entry to the tracker's
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 225c904..db1ffc6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -347,6 +347,37 @@ repos:
files: ^tools/jira/(src|tests|pyproject\.toml|bridge\.groovy)
pass_filenames: false
+ # Project-local checks for the GitHub-body-field tool at
+ # `tools/github-body-field/`. Lets the security-sync skills update
+ # one `### Field` section of an issue body without bringing the
+ # body into agent context.
+ - repo: local
+ hooks:
+ - id: github-body-field-ruff-check
+ name: ruff check (github-body-field)
+ language: system
+ entry: uv run --directory tools/github-body-field ruff check
+ files: ^tools/github-body-field/(src|tests|pyproject\.toml)
+ pass_filenames: false
+ - id: github-body-field-ruff-format
+ name: ruff format (github-body-field)
+ language: system
+ entry: uv run --directory tools/github-body-field ruff format --check
+ files: ^tools/github-body-field/(src|tests|pyproject\.toml)
+ pass_filenames: false
+ - id: github-body-field-mypy
+ name: mypy (github-body-field)
+ language: system
+ entry: uv run --directory tools/github-body-field mypy
+ files: ^tools/github-body-field/(src|tests|pyproject\.toml)
+ pass_filenames: false
+ - id: github-body-field-pytest
+ name: pytest (github-body-field)
+ language: system
+ entry: uv run --directory tools/github-body-field pytest
+ files: ^tools/github-body-field/(src|tests|pyproject\.toml)
+ pass_filenames: false
+
# Validate `.claude/skills/**`, every `tools/<name>/README.md`, and the
# `docs/labels-and-capabilities.md` taxonomy via the
# `skill-and-tool-validate` CLI. Re-fires on validator-source changes so
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 8349885..c932202 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -179,6 +179,7 @@ Tools under [`tools/`](../tools/). Tools with two values
(separated by
| [`tools/dev`](../tools/dev/) | `capability:setup` | Framework dev-loop
helpers |
| [`tools/forwarder-relay`](../tools/forwarder-relay/) | `capability:setup` |
Adapter contract for inbound-relay backends (ASF Security relay, huntr.com,
HackerOne triagers). Pure interface spec; adapters declare detection +
credit-extraction + reporter-addressing rules. |
| [`tools/github`](../tools/github/) | `capability:setup` | GitHub REST /
GraphQL substrate (called by every lifecycle phase — pure substrate, no single
phase) |
+| [`tools/github-body-field`](../tools/github-body-field/) |
`capability:setup` | Read or rewrite one `### Field` section of a GitHub issue
body without bringing the body into agent context — substrate helper for the
security-sync skills |
| [`tools/gmail`](../tools/gmail/) | `capability:setup` | Gmail API substrate |
| [`tools/jira`](../tools/jira/) | `capability:setup` | JIRA REST substrate
(read-only today; write subcommands tracked in
[#301](https://github.com/apache/airflow-steward/issues/301)) |
| [`tools/mail-archive`](../tools/mail-archive/) | `capability:setup` |
Adapter contract for public mail-archive backends (PonyMail, Hyperkitty,
Discourse, Google Groups, GitHub Discussions). Pure interface spec. |
diff --git a/tools/github-body-field/README.md
b/tools/github-body-field/README.md
new file mode 100644
index 0000000..f7e81e9
--- /dev/null
+++ b/tools/github-body-field/README.md
@@ -0,0 +1,103 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [github-body-field](#github-body-field)
+ - [Why](#why)
+ - [Invocation](#invocation)
+ - [`get <issue> --field "<name>"`](#get-issue---field-name)
+ - [`set <issue> --field "<name>" --value "<v>" | --value-file
<path>`](#set-issue---field-name---value-v----value-file-path)
+ - [`list <issue>`](#list-issue)
+ - [Body format assumptions](#body-format-assumptions)
+ - [Failure modes](#failure-modes)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# github-body-field
+
+**Capability:** capability:setup
+
+Read or rewrite a single `### Field` section of a GitHub issue
+body **without bringing the body into agent context**.
+
+## Why
+
+Tracker issues in `<tracker>` carry a structured body — a list of
+`### <FieldName>` sections (e.g. *CVE tool link*, *Reporter
+credited as*, *Public advisory URL*, *Short public summary for
+publish*). The sync workflow PATCHes one of these fields at a time;
+the legacy recipe was *read the full 10–15 KB body into agent
+context, regex-edit one field, write it back*. That spent ~5 K
+tokens per single-field flip, in addition to looping
+reporter-supplied content (often the most sensitive content on
+the tracker) through the agent for no reason.
+
+This tool does the read / parse / replace / push in a subprocess.
+Only the diff summary lands on the agent's stdout. The body never
+crosses the boundary.
+
+## Invocation
+
+```bash
+uv run --directory tools/github-body-field body-field --repo <owner>/<repo>
<subcommand> ...
+```
+
+The `--repo` argument is forwarded verbatim to `gh`; omit it when
+the current working directory is already inside the right clone.
+
+### `get <issue> --field "<name>"`
+
+Print the field's value to stdout (with a trailing newline added if
+the value did not already end with one — convenient for shell
+pipelines). Exit 3 if the field is absent or appears more than once.
+
+### `set <issue> --field "<name>" --value "<v>" | --value-file <path>`
+
+Replace the field's value in place. Either `--value` (single argv
+string) or `--value-file` (any path; `-` reads stdin) must be
+given. The replacement preserves the original heading line
+byte-exact and re-uses the spacer-blank-line convention the original
+section had.
+
+`--dry-run` prints the diff summary to stderr but skips the push.
+
+Exit 0 when written (or when the new value matched the old, in
+which case stderr says `unchanged: ...` and no API call happens);
+exit 3 when the field is absent or duplicated.
+
+### `list <issue>`
+
+Print every field heading present in the body, one per line, in
+document order. With `--json`, emit a JSON array instead — useful
+when an orchestrator wants to programmatically check what fields a
+legacy tracker already has populated.
+
+## Body format assumptions
+
+The parser is a small state machine that:
+
+- treats only top-level `^### <Name>$` lines as field headings;
+- tracks fenced code blocks (`` ``` `` and `~~~`) so a literal
+ `### foo` inside a shell snippet never false-matches as a
+ heading;
+- preserves the original body byte-exact when no change is needed
+ (idempotent rewrite — a `set` of the same value triggers no
+ API write).
+
+If the body does not use the `### <FieldName>` convention at all
+(legacy trackers from before the issue template was standardised),
+`set` will report `field not found` and refuse to mutate. Fix the
+tracker body first or fall through to the original "edit by hand"
+path; this tool intentionally does not invent headings.
+
+## Failure modes
+
+| Exit | Meaning |
+|---|---|
+| 0 | Success (or `set` was a no-op because new value matched current). |
+| 2 | CLI argument error (e.g. both `--value` and `--value-file` given). |
+| 3 | Field heading not found, or matched more than once. The body is
untouched. |
+| other | `gh` returned non-zero; the underlying `gh` stderr is forwarded. |
diff --git a/tools/github-body-field/pyproject.toml
b/tools/github-body-field/pyproject.toml
new file mode 100644
index 0000000..e7ac465
--- /dev/null
+++ b/tools/github-body-field/pyproject.toml
@@ -0,0 +1,88 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "github-body-field"
+version = "0.1.0"
+description = "Read or rewrite a single `### Field` section of a GitHub issue
body without bringing the body into agent context."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+# Runtime is stdlib-only; the script shells out to `gh` for GitHub
+# access. Keeping the runtime closed lets `uv run` resolve in ms.
+dependencies = []
+
+[project.scripts]
+body-field = "github_body_field:main"
+
+[dependency-groups]
+dev = [
+ "mypy>=2.1.0",
+ "pytest>=8.0",
+ "ruff>=0.15.14",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/github_body_field"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "UP", # pyupgrade
+ "SIM", # flake8-simplify
+ "C4", # flake8-comprehensions
+ "RUF", # ruff-specific
+]
+ignore = [
+ "E501", # line-too-long — the 110-char limit above is already generous
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"] # test clarity beats these
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+warn_unused_ignores = true
+warn_redundant_casts = true
+warn_unreachable = true
+check_untyped_defs = true
+no_implicit_optional = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/github-body-field/src/github_body_field/__init__.py
b/tools/github-body-field/src/github_body_field/__init__.py
new file mode 100644
index 0000000..620ef37
--- /dev/null
+++ b/tools/github-body-field/src/github_body_field/__init__.py
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from github_body_field.cli import main
+from github_body_field.parser import (
+ FieldNotFoundError,
+ extract_field,
+ list_fields,
+ replace_field,
+)
+
+__all__ = [
+ "FieldNotFoundError",
+ "extract_field",
+ "list_fields",
+ "main",
+ "replace_field",
+]
diff --git a/tools/github-body-field/src/github_body_field/cli.py
b/tools/github-body-field/src/github_body_field/cli.py
new file mode 100644
index 0000000..b58a958
--- /dev/null
+++ b/tools/github-body-field/src/github_body_field/cli.py
@@ -0,0 +1,191 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""CLI front-end for the body-field parser.
+
+Operates on GitHub issue bodies via the ``gh`` CLI. The whole point
+of the tool is that the body never crosses the agent boundary on a
+``set`` — the read, parse, replace, and write all happen in this
+subprocess, and the agent only sees the diff summary on stdout.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from collections.abc import Sequence
+from pathlib import Path
+
+from github_body_field.parser import (
+ FieldNotFoundError,
+ extract_field,
+ list_fields,
+ replace_field,
+)
+
+
+def _gh_get_body(issue: str, repo: str | None) -> str:
+ cmd = ["gh", "issue", "view", issue, "--json", "body", "--jq", ".body"]
+ if repo:
+ cmd.extend(["--repo", repo])
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+ # `gh --jq .body` returns the body with a trailing newline appended
+ # by gh's JSON-to-text serialiser; strip exactly one to keep the
+ # round-trip byte-exact.
+ body = result.stdout
+ if body.endswith("\n"):
+ body = body[:-1]
+ return body
+
+
+def _gh_set_body(issue: str, repo: str | None, body: str) -> None:
+ # `gh issue edit --body-file -` reads from stdin and avoids the
+ # tmpfile dance plus any shell-quoting hazards.
+ cmd = ["gh", "issue", "edit", issue, "--body-file", "-"]
+ if repo:
+ cmd.extend(["--repo", repo])
+ result = subprocess.run(cmd, input=body, text=True, capture_output=True,
check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+
+
+def _read_value(args: argparse.Namespace) -> str:
+ if args.value is not None and args.value_file is not None:
+ sys.stderr.write("error: pass either --value or --value-file, not
both\n")
+ raise SystemExit(2)
+ if args.value is not None:
+ return args.value
+ if args.value_file is not None:
+ if args.value_file == "-":
+ return sys.stdin.read()
+ return Path(args.value_file).read_text(encoding="utf-8")
+ sys.stderr.write("error: one of --value / --value-file is required\n")
+ raise SystemExit(2)
+
+
+def _cmd_get(args: argparse.Namespace) -> int:
+ body = _gh_get_body(args.issue, args.repo)
+ try:
+ value = extract_field(body, args.field)
+ except FieldNotFoundError as exc:
+ sys.stderr.write(f"{exc}\n")
+ return 3
+ sys.stdout.write(value)
+ if not value.endswith("\n"):
+ sys.stdout.write("\n")
+ return 0
+
+
+def _cmd_list(args: argparse.Namespace) -> int:
+ body = _gh_get_body(args.issue, args.repo)
+ names = list_fields(body)
+ if args.json:
+ sys.stdout.write(json.dumps(names, indent=2))
+ sys.stdout.write("\n")
+ else:
+ for name in names:
+ sys.stdout.write(f"{name}\n")
+ return 0
+
+
+def _cmd_set(args: argparse.Namespace) -> int:
+ new_value = _read_value(args)
+ body = _gh_get_body(args.issue, args.repo)
+ try:
+ new_body = replace_field(body, args.field, new_value)
+ except FieldNotFoundError as exc:
+ sys.stderr.write(f"{exc}\n")
+ return 3
+
+ if new_body == body:
+ sys.stderr.write(f"unchanged: {args.field!r} already matches new
value\n")
+ return 0
+
+ # Compact diff summary on stderr so an orchestrator can log what
+ # changed without us streaming the body itself.
+ try:
+ old_value = extract_field(body, args.field)
+ except FieldNotFoundError:
+ # Should not happen — we just rewrote this exact field — but
+ # don't crash the apply if the post-rewrite re-parse drifts.
+ old_value = "<unknown>"
+ sys.stderr.write(f"field={args.field!r} old_len={len(old_value)}
new_len={len(new_value)}\n")
+
+ if args.dry_run:
+ sys.stderr.write("dry-run: not pushing\n")
+ return 0
+
+ _gh_set_body(args.issue, args.repo, new_body)
+ return 0
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="body-field",
+ description=(
+ "Read or rewrite a single `### Field` section of a GitHub "
+ "issue body without bringing the body into agent context."
+ ),
+ )
+ parser.add_argument(
+ "--repo",
+ help="owner/repo. Defaults to the repository of the current working
directory.",
+ )
+
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ p_get = sub.add_parser("get", help="print one field's value to stdout")
+ p_get.add_argument("issue", help="issue number")
+ p_get.add_argument("--field", required=True, help="exact heading text
(without `### `)")
+ p_get.set_defaults(func=_cmd_get)
+
+ p_set = sub.add_parser("set", help="rewrite one field's value in place")
+ p_set.add_argument("issue", help="issue number")
+ p_set.add_argument("--field", required=True, help="exact heading text
(without `### `)")
+ p_set.add_argument("--value", help="new value (use --value-file for
multi-line / shell-unsafe text)")
+ p_set.add_argument(
+ "--value-file",
+ help="path to a file containing the new value; pass '-' to read stdin",
+ )
+ p_set.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="print the diff summary to stderr but skip the push",
+ )
+ p_set.set_defaults(func=_cmd_set)
+
+ p_list = sub.add_parser("list", help="print every field heading present in
the body")
+ p_list.add_argument("issue", help="issue number")
+ p_list.add_argument("--json", action="store_true", help="emit a JSON array
instead of one name per line")
+ p_list.set_defaults(func=_cmd_list)
+
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = _build_parser()
+ args = parser.parse_args(argv)
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/github-body-field/src/github_body_field/parser.py
b/tools/github-body-field/src/github_body_field/parser.py
new file mode 100644
index 0000000..e0253e7
--- /dev/null
+++ b/tools/github-body-field/src/github_body_field/parser.py
@@ -0,0 +1,240 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Parse and rewrite the ``### Field`` sections of a GitHub issue body.
+
+The tracker issue body is a sequence of `### <FieldName>` headings,
+each followed by the field's value. This module exposes a pure
+parser/replacer so the CLI can run without ever bringing the body
+into the agent's context, and so the (numerous) tricky edge cases
+can be unit-tested in isolation.
+
+The parser is a small state machine that:
+
+- tracks whether the current line is inside a fenced code block
+ (``` ``` ``` or ``` ~~~ ```) so that a literal ``### foo`` line
+ inside a shell snippet never false-matches as a heading;
+- considers only level-3 ATX headings (``^### <name>$``);
+ level-2 (``## ``) and level-4 (``#### ``) headings are passed
+ through as content;
+- preserves the original body exactly when no changes are needed
+ (idempotent rewrite).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+class FieldNotFoundError(LookupError):
+ """Raised when the requested field heading is absent from the body."""
+
+
+# A fence-line opens / closes a fenced code block when it starts with
+# three or more backticks or three or more tildes, optionally followed
+# by an info string. Per CommonMark §4.5 the opening and closing fences
+# must use the same character; the info string may only follow the
+# opener. We don't enforce matching-character-count strictly because
+# GitHub's tracker body editor is lenient and we want to err on the
+# side of "treat anything ``` ``` ```-ish as a fence" so headings
+# embedded in code samples never get mis-parsed as field markers.
+_BACKTICK_FENCE = "```"
+_TILDE_FENCE = "~~~"
+
+# Heading marker for a field. GitHub trims trailing whitespace on
+# rendered headings; we strip it on the parsed name too so a stray
+# space after the field name doesn't break the lookup.
+_HEADING_PREFIX = "### "
+
+
+@dataclass(frozen=True)
+class _Section:
+ """One ``### <Name>`` heading + the lines that follow it."""
+
+ name: str
+ heading_line: str # the original "### Name" line as it appeared
+ body_lines: list[str] # everything between this heading and the next
+
+
+def _is_fence(line: str) -> bool:
+ stripped = line.lstrip()
+ return stripped.startswith(_BACKTICK_FENCE) or
stripped.startswith(_TILDE_FENCE)
+
+
+def _parse(body: str) -> tuple[list[str], list[_Section]]:
+ """Split ``body`` into a preamble (anything before the first
+ ``### `` heading) and a list of ``_Section`` records.
+
+ The preamble is returned as a list of raw lines so the round-trip
+ is byte-exact when no field is replaced.
+ """
+ lines = body.splitlines(keepends=True)
+ preamble: list[str] = []
+ sections: list[_Section] = []
+ current: _Section | None = None
+ in_fence = False
+
+ for line in lines:
+ if _is_fence(line):
+ in_fence = not in_fence
+ (current.body_lines if current is not None else
preamble).append(line)
+ continue
+
+ # Only top-level (outside any fence) `### ` openers count.
+ if not in_fence and line.startswith(_HEADING_PREFIX):
+ # Stash whatever section we were building.
+ if current is not None:
+ sections.append(current)
+ name = line[len(_HEADING_PREFIX) :].rstrip("\r\n").rstrip()
+ current = _Section(name=name, heading_line=line, body_lines=[])
+ continue
+
+ (current.body_lines if current is not None else preamble).append(line)
+
+ if current is not None:
+ sections.append(current)
+ return preamble, sections
+
+
+def list_fields(body: str) -> list[str]:
+ """Return the field names declared by the body, in document order.
+
+ Duplicate field names are returned as many times as they appear so
+ the caller can flag an ambiguous body (the rewrite refuses to
+ touch duplicates — see :func:`replace_field`).
+ """
+ _, sections = _parse(body)
+ return [s.name for s in sections]
+
+
+def _strip_spacer_blanks(body_lines: list[str]) -> tuple[bool, list[str],
bool]:
+ """Strip up to one leading and one trailing blank-line spacer.
+
+ Returns ``(had_leading, stripped_lines, had_trailing)`` so the
+ caller can later re-emit with the same convention.
+ """
+ lines = list(body_lines)
+ had_leading = bool(lines) and lines[0] == "\n"
+ if had_leading:
+ lines = lines[1:]
+ had_trailing = bool(lines) and lines[-1] == "\n" and len(lines) > 1
+ # The `len(lines) > 1` guard is so a field whose *only* line
+ # is a blank one (i.e. an empty value with one spacer) doesn't
+ # get the same line both stripped and counted as "trailing".
+ if had_trailing:
+ lines = lines[:-1]
+ return had_leading, lines, had_trailing
+
+
+def extract_field(body: str, field: str) -> str:
+ """Return the raw text value of ``### <field>``.
+
+ The blank-line spacer that conventionally separates the heading
+ from the value, and the value from the next heading, is stripped
+ so callers get the value as a user would copy-paste it. Internal
+ blank lines are preserved.
+
+ Raises :class:`FieldNotFoundError` if the heading does not exist
+ or appears more than once.
+ """
+ _, sections = _parse(body)
+ matches = [s for s in sections if s.name == field]
+ if not matches:
+ raise FieldNotFoundError(f"field not found: {field!r}")
+ if len(matches) > 1:
+ raise FieldNotFoundError(f"field {field!r} appears {len(matches)}
times; refusing to guess")
+ _, stripped, _ = _strip_spacer_blanks(matches[0].body_lines)
+ return "".join(stripped)
+
+
+def replace_field(body: str, field: str, new_value: str) -> str:
+ """Return a new body with ``### <field>``'s value replaced.
+
+ The replacement preserves:
+
+ - the original heading line (including any trailing whitespace
+ / line ending that was there);
+ - everything outside the targeted section, byte-exact;
+ - the blank-line spacer between this section and the next, if
+ one was originally present;
+ - the body's final line-ending style (LF vs CRLF) — we use the
+ same convention as the file we read.
+
+ The ``new_value`` is appended as-is and is **not** normalised:
+ callers that want a trailing newline should include one in the
+ value they pass.
+
+ Raises :class:`FieldNotFoundError` if the heading does not exist
+ or appears more than once.
+ """
+ preamble, sections = _parse(body)
+ indices = [i for i, s in enumerate(sections) if s.name == field]
+ if not indices:
+ raise FieldNotFoundError(f"field not found: {field!r}")
+ if len(indices) > 1:
+ raise FieldNotFoundError(f"field {field!r} appears {len(indices)}
times; refusing to guess")
+
+ target_idx = indices[0]
+ target = sections[target_idx]
+ had_leading, _, had_trailing = _strip_spacer_blanks(target.body_lines)
+ is_last_section = target_idx == len(sections) - 1
+
+ # Normalise the caller-supplied value:
+ # - strip any trailing blank-line spacer they may have
+ # accidentally included, so we don't double-stack blanks
+ # when we re-insert the spacer below;
+ # - ensure a single trailing newline if there's any content,
+ # so the next heading starts on its own line;
+ # - the empty-value case (caller passed "") produces an
+ # entirely empty content payload so the heading sits
+ # directly against the spacer / next heading.
+ normalised = new_value
+ while normalised.endswith("\n\n"):
+ normalised = normalised[:-1]
+ if normalised and not normalised.endswith("\n"):
+ normalised += "\n"
+
+ rebuilt_body_lines: list[str] = []
+ if normalised:
+ if had_leading:
+ rebuilt_body_lines.append("\n")
+ rebuilt_body_lines.extend(normalised.splitlines(keepends=True))
+ if had_trailing and not is_last_section:
+ rebuilt_body_lines.append("\n")
+ else:
+ # Empty value: collapse to a single blank-line spacer so the
+ # next heading isn't glued to the current one. Don't stack
+ # both leading and trailing spacers (that would render as
+ # two blank lines, which reads worse than one in the tracker
+ # UI). For the last section, emit nothing — the bare heading
+ # line is sufficient.
+ if not is_last_section and (had_leading or had_trailing):
+ rebuilt_body_lines.append("\n")
+
+ rebuilt_sections = [
+ _Section(name=s.name, heading_line=s.heading_line,
body_lines=s.body_lines) for s in sections
+ ]
+ rebuilt_sections[target_idx] = _Section(
+ name=target.name,
+ heading_line=target.heading_line,
+ body_lines=rebuilt_body_lines,
+ )
+
+ out: list[str] = list(preamble)
+ for s in rebuilt_sections:
+ out.append(s.heading_line)
+ out.extend(s.body_lines)
+ return "".join(out)
diff --git a/tools/github-body-field/tests/__init__.py
b/tools/github-body-field/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/github-body-field/tests/test_cli.py
b/tools/github-body-field/tests/test_cli.py
new file mode 100644
index 0000000..a9adb9c
--- /dev/null
+++ b/tools/github-body-field/tests/test_cli.py
@@ -0,0 +1,268 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""CLI tests. Shells out to `gh` are stubbed via a fake subprocess.run."""
+
+from __future__ import annotations
+
+import subprocess
+from collections.abc import Iterator
+from dataclasses import dataclass
+
+import pytest
+
+from github_body_field import cli
+
+CANONICAL_BODY = (
+ "### CVE tool link\n"
+ "\n"
+ "https://cveprocess.apache.org/cve5/CVE-2026-12345\n"
+ "\n"
+ "### Reporter credited as\n"
+ "\n"
+ "anonymous\n"
+)
+
+
+@dataclass
+class FakeResult:
+ returncode: int
+ stdout: str = ""
+ stderr: str = ""
+
+
+class FakeGh:
+ """Records the (cmd, input) of each subprocess.run and replies with
+ canned output. Tests assert on the recorded calls."""
+
+ def __init__(self, body: str = CANONICAL_BODY) -> None:
+ self.body = body
+ self.calls: list[tuple[list[str], str | None]] = []
+ self.next_write_returncode = 0
+
+ def __call__(
+ self,
+ cmd: list[str],
+ *,
+ capture_output: bool = False,
+ text: bool = False,
+ input: str | None = None,
+ check: bool = False,
+ ) -> FakeResult:
+ self.calls.append((cmd, input))
+ # Distinguish read vs write by argv shape.
+ if "view" in cmd:
+ # gh appends a trailing newline to --jq .body output; replicate.
+ return FakeResult(returncode=0, stdout=self.body + "\n")
+ if "edit" in cmd:
+ # Capture the new body so the test can assert on it.
+ assert input is not None, "edit must pipe a body via stdin"
+ self.body = input # mimic the round-trip
+ return FakeResult(returncode=self.next_write_returncode)
+ raise AssertionError(f"unexpected gh invocation: {cmd!r}")
+
+
[email protected]
+def fake_gh(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeGh]:
+ f = FakeGh()
+ monkeypatch.setattr(subprocess, "run", f)
+ yield f
+
+
+# ---------------------------------------------------------------------------
+# get
+# ---------------------------------------------------------------------------
+
+
+def test_get_prints_field_value(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["get", "123", "--field", "Reporter credited as"])
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert out == "anonymous\n"
+
+
+def test_get_field_missing_exits_3(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["get", "123", "--field", "Nope"])
+ assert rc == 3
+ err = capsys.readouterr().err
+ assert "not found" in err
+
+
+def test_get_forwards_repo_to_gh(fake_gh: FakeGh) -> None:
+ cli.main(["--repo", "foo/bar", "get", "123", "--field", "Reporter credited
as"])
+ read_cmd, _ = fake_gh.calls[0]
+ assert "--repo" in read_cmd
+ assert read_cmd[read_cmd.index("--repo") + 1] == "foo/bar"
+
+
+# ---------------------------------------------------------------------------
+# list
+# ---------------------------------------------------------------------------
+
+
+def test_list_prints_headings_one_per_line(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["list", "123"])
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert out == "CVE tool link\nReporter credited as\n"
+
+
+def test_list_json_emits_array(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["list", "123", "--json"])
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert '"CVE tool link"' in out
+ assert '"Reporter credited as"' in out
+
+
+# ---------------------------------------------------------------------------
+# set
+# ---------------------------------------------------------------------------
+
+
+def test_set_pushes_new_body(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(
+ [
+ "set",
+ "123",
+ "--field",
+ "Reporter credited as",
+ "--value",
+ "Jane Doe (@jdoe)",
+ ]
+ )
+ assert rc == 0
+ # First call: view. Second call: edit with the rewritten body via stdin.
+ assert len(fake_gh.calls) == 2
+ edit_cmd, edit_input = fake_gh.calls[1]
+ assert edit_cmd[:4] == ["gh", "issue", "edit", "123"]
+ assert "--body-file" in edit_cmd
+ assert "-" in edit_cmd
+ assert edit_input is not None
+ assert "### Reporter credited as\n\nJane Doe (@jdoe)\n" in edit_input
+ # Diff summary on stderr.
+ err = capsys.readouterr().err
+ assert "field='Reporter credited as'" in err
+
+
+def test_set_no_op_when_value_unchanged(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(
+ [
+ "set",
+ "123",
+ "--field",
+ "Reporter credited as",
+ "--value",
+ "anonymous",
+ ]
+ )
+ assert rc == 0
+ # Only the view should have happened; the write must be skipped.
+ assert len(fake_gh.calls) == 1
+ err = capsys.readouterr().err
+ assert "unchanged" in err
+
+
+def test_set_dry_run_does_not_push(fake_gh: FakeGh) -> None:
+ rc = cli.main(
+ [
+ "set",
+ "123",
+ "--field",
+ "Reporter credited as",
+ "--value",
+ "Jane",
+ "--dry-run",
+ ]
+ )
+ assert rc == 0
+ # Only the read; no edit call.
+ assert len(fake_gh.calls) == 1
+
+
+def test_set_field_missing_exits_3_without_writing(
+ fake_gh: FakeGh, capsys: pytest.CaptureFixture[str]
+) -> None:
+ rc = cli.main(["set", "123", "--field", "Nope", "--value", "x"])
+ assert rc == 3
+ # Read happened, edit did not.
+ assert len(fake_gh.calls) == 1
+ err = capsys.readouterr().err
+ assert "not found" in err
+
+
+def test_set_rejects_both_value_and_value_file(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit) as exc:
+ cli.main(
+ [
+ "set",
+ "123",
+ "--field",
+ "Reporter credited as",
+ "--value",
+ "x",
+ "--value-file",
+ "/tmp/whatever",
+ ]
+ )
+ assert exc.value.code == 2
+ err = capsys.readouterr().err
+ assert "not both" in err
+ # No gh call should have happened.
+ assert len(fake_gh.calls) == 0
+
+
+def test_set_requires_one_of_value_or_value_file(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit) as exc:
+ cli.main(["set", "123", "--field", "Reporter credited as"])
+ assert exc.value.code == 2
+ err = capsys.readouterr().err
+ assert "required" in err
+
+
+def test_set_value_file_is_read(fake_gh: FakeGh, tmp_path, capsys:
pytest.CaptureFixture[str]) -> None:
+ value_file = tmp_path / "v.txt"
+ value_file.write_text("from-a-file\n", encoding="utf-8")
+ rc = cli.main(
+ [
+ "set",
+ "123",
+ "--field",
+ "Reporter credited as",
+ "--value-file",
+ str(value_file),
+ ]
+ )
+ assert rc == 0
+ edit_input = fake_gh.calls[1][1]
+ assert edit_input is not None
+ assert "### Reporter credited as\n\nfrom-a-file\n" in edit_input
+
+
+def test_set_propagates_gh_write_failure(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ fake_gh.next_write_returncode = 1
+ with pytest.raises(SystemExit) as exc:
+ cli.main(
+ [
+ "set",
+ "123",
+ "--field",
+ "Reporter credited as",
+ "--value",
+ "Jane",
+ ]
+ )
+ assert exc.value.code == 1
diff --git a/tools/github-body-field/tests/test_parser.py
b/tools/github-body-field/tests/test_parser.py
new file mode 100644
index 0000000..83fa547
--- /dev/null
+++ b/tools/github-body-field/tests/test_parser.py
@@ -0,0 +1,249 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+from github_body_field.parser import (
+ FieldNotFoundError,
+ extract_field,
+ list_fields,
+ replace_field,
+)
+
+CANONICAL_BODY = (
+ "<!-- a preamble comment from the issue template -->\n"
+ "\n"
+ "### CVE tool link\n"
+ "\n"
+ "https://cveprocess.apache.org/cve5/CVE-2026-12345\n"
+ "\n"
+ "### Reporter credited as\n"
+ "\n"
+ "anonymous\n"
+ "\n"
+ "### Short public summary for publish\n"
+ "\n"
+ "Upgrade to apache-airflow 3.3.0 or later.\n"
+ "\n"
+ "### Affected versions\n"
+ "\n"
+ "`>= 3.0.0, < 3.3.0`\n"
+)
+
+
+# ---------------------------------------------------------------------------
+# list_fields
+# ---------------------------------------------------------------------------
+
+
+def test_list_fields_returns_headings_in_order():
+ assert list_fields(CANONICAL_BODY) == [
+ "CVE tool link",
+ "Reporter credited as",
+ "Short public summary for publish",
+ "Affected versions",
+ ]
+
+
+def test_list_fields_empty_body():
+ assert list_fields("") == []
+
+
+def test_list_fields_no_headings():
+ assert list_fields("just some prose, no headings.\n") == []
+
+
+def test_list_fields_ignores_headings_in_code_fences():
+ body = "### Real field\n\n```bash\n### not a heading\necho hi\n```\n\n###
Another real\n\nvalue\n"
+ assert list_fields(body) == ["Real field", "Another real"]
+
+
+def test_list_fields_ignores_headings_in_tilde_fences():
+ body = "### Real field\n\n~~~\n### still inside a fence\n~~~\n\n### Next
real\n\nvalue\n"
+ assert list_fields(body) == ["Real field", "Next real"]
+
+
+def test_list_fields_does_not_match_h2_or_h4():
+ body = "## Not a field (h2)\n\n### A real field\n\nvalue\n\n####
Subsection (h4)\n\nmore content\n"
+ assert list_fields(body) == ["A real field"]
+
+
+def test_list_fields_trims_trailing_whitespace_in_heading_name():
+ body = "### Field with trailing space \n\nvalue\n"
+ assert list_fields(body) == ["Field with trailing space"]
+
+
+def test_list_fields_reports_duplicates_as_separate_entries():
+ body = "### Same\n\nvalue 1\n\n### Same\n\nvalue 2\n"
+ assert list_fields(body) == ["Same", "Same"]
+
+
+# ---------------------------------------------------------------------------
+# extract_field
+# ---------------------------------------------------------------------------
+
+
+def test_extract_field_middle_section():
+ assert extract_field(CANONICAL_BODY, "Reporter credited as") ==
"anonymous\n"
+
+
+def test_extract_field_first_section():
+ assert (
+ extract_field(CANONICAL_BODY, "CVE tool link")
+ == "https://cveprocess.apache.org/cve5/CVE-2026-12345\n"
+ )
+
+
+def test_extract_field_last_section():
+ assert extract_field(CANONICAL_BODY, "Affected versions") == "`>= 3.0.0, <
3.3.0`\n"
+
+
+def test_extract_field_multiline_value():
+ body = "### Notes\n\nline 1\nline 2\n\nline 4 (internal blank
preserved)\n\n### Next\n\nx\n"
+ assert extract_field(body, "Notes") == ("line 1\nline 2\n\nline 4
(internal blank preserved)\n")
+
+
+def test_extract_field_value_with_inline_code_fence_containing_hash():
+ body = (
+ "### Repro\n"
+ "\n"
+ "```python\n"
+ "### this looks like a heading but is inside a fence\n"
+ "print(1)\n"
+ "```\n"
+ "\n"
+ "### Next\n"
+ "\n"
+ "x\n"
+ )
+ assert extract_field(body, "Repro") == (
+ "```python\n### this looks like a heading but is inside a
fence\nprint(1)\n```\n"
+ )
+
+
+def test_extract_field_missing_raises():
+ with pytest.raises(FieldNotFoundError, match="not found"):
+ extract_field(CANONICAL_BODY, "Nonexistent")
+
+
+def test_extract_field_duplicate_raises():
+ body = "### Same\n\nv1\n\n### Same\n\nv2\n"
+ with pytest.raises(FieldNotFoundError, match="appears 2 times"):
+ extract_field(body, "Same")
+
+
+# ---------------------------------------------------------------------------
+# replace_field
+# ---------------------------------------------------------------------------
+
+
+def test_replace_field_keeps_other_sections_byte_exact():
+ new = replace_field(CANONICAL_BODY, "Reporter credited as", "Jane Doe
(@jdoe)\n")
+ # The unchanged sections should round-trip identically.
+ assert "### CVE tool
link\n\nhttps://cveprocess.apache.org/cve5/CVE-2026-12345\n\n" in new
+ assert "### Short public summary for publish\n\nUpgrade to apache-airflow
3.3.0 or later.\n\n" in new
+ assert "### Affected versions\n\n`>= 3.0.0, < 3.3.0`\n" in new
+ # The targeted section now carries the new value.
+ assert "### Reporter credited as\n\nJane Doe (@jdoe)\n\n" in new
+ # And the old value is gone.
+ assert "anonymous" not in new
+
+
+def test_replace_field_with_value_missing_trailing_newline():
+ """A value passed without a trailing \\n should still produce a
+ well-formed section (no glued-on next heading)."""
+ new = replace_field(CANONICAL_BODY, "Reporter credited as", "Jane")
+ assert "### Reporter credited as\n\nJane\n\n### Short public summary" in
new
+
+
+def test_replace_field_preserves_spacer_blank_line():
+ new = replace_field(CANONICAL_BODY, "Reporter credited as", "x")
+ # The blank line between "x" and the next heading must survive.
+ assert "### Reporter credited as\n\nx\n\n### Short public summary" in new
+
+
+def test_replace_field_idempotent_when_value_unchanged():
+ same = extract_field(CANONICAL_BODY, "Reporter credited as")
+ new = replace_field(CANONICAL_BODY, "Reporter credited as", same)
+ assert new == CANONICAL_BODY
+
+
+def test_replace_field_last_section_does_not_pad_with_extra_blank():
+ new = replace_field(CANONICAL_BODY, "Affected versions", "`< 3.3.0`")
+ # Last section: original body ended exactly one newline after the
+ # value, so the replacement should too — no extra blank.
+ assert new.endswith("### Affected versions\n\n`< 3.3.0`\n")
+
+
+def test_replace_field_preserves_preamble_byte_exact():
+ new = replace_field(CANONICAL_BODY, "CVE tool link",
"https://example.test/x")
+ assert new.startswith("<!-- a preamble comment from the issue template
-->\n\n")
+
+
+def test_replace_field_does_not_treat_fenced_heading_as_section_break():
+ """A `### Foo` line inside a fenced code block must not fool the
+ parser into ending the current section. We verify by replacing a
+ *different* section and showing the fenced block in section A
+ survives byte-exact."""
+ body = "### A\n\n```\n### Bogus heading inside fence\necho hi\n```\n\n###
B\n\nv\n"
+ new = replace_field(body, "B", "new_v\n")
+ assert "```\n### Bogus heading inside fence\necho hi\n```" in new
+ assert new.startswith("### A\n\n```\n### Bogus heading inside fence\necho
hi\n```\n\n")
+ assert new.endswith("### B\n\nnew_v\n")
+
+
+def test_replace_field_value_can_contain_inline_code_with_pound_signs():
+ new = replace_field(CANONICAL_BODY, "Affected versions", "Comment: see
`### Field` in the docs.\n")
+ assert "### Affected versions\n\nComment: see `### Field` in the docs.\n"
in new
+
+
+def test_replace_field_missing_raises_without_mutating():
+ body = CANONICAL_BODY
+ with pytest.raises(FieldNotFoundError):
+ replace_field(body, "Nope", "x")
+
+
+def test_replace_field_duplicate_raises_without_mutating():
+ body = "### Same\n\nv1\n\n### Same\n\nv2\n"
+ with pytest.raises(FieldNotFoundError, match="appears 2 times"):
+ replace_field(body, "Same", "new")
+
+
+def test_replace_field_value_with_multiple_trailing_blanks_normalised():
+ """A caller passing a value padded with extra trailing blanks
+ should not stack blank lines on top of the section spacer."""
+ new = replace_field(CANONICAL_BODY, "Reporter credited as", "jane\n\n\n\n")
+ # Exactly one spacer blank between the new value and the next heading.
+ assert "### Reporter credited as\n\njane\n\n### Short public summary" in
new
+
+
+def test_replace_field_empty_value():
+ new = replace_field(CANONICAL_BODY, "Reporter credited as", "")
+ # Empty value still produces a well-formed section: the heading, a
+ # single newline (to terminate the heading line), and the spacer.
+ assert "### Reporter credited as\n\n### Short public summary" in new
+
+
+def test_replace_field_field_with_no_spacer_before_next_heading():
+ """Some legacy bodies pack headings without a blank-line spacer
+ between sections. Replacement should still not break the next
+ heading."""
+ body = "### A\nv1\n### B\nv2\n"
+ new = replace_field(body, "A", "new1\n")
+ # No spacer originally → don't invent one.
+ assert new == "### A\nnew1\n### B\nv2\n"
diff --git a/tools/github-body-field/uv.lock b/tools/github-body-field/uv.lock
new file mode 100644
index 0000000..cbe13c9
--- /dev/null
+++ b/tools/github-body-field/uv.lock
@@ -0,0 +1,309 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15'",
+]
+
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included
for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
+[[package]]
+name = "ast-serialize"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz",
hash =
"sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size
= 61157, upload-time = "2026-05-17T17:48:29.429Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl",
hash =
"sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size
= 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
+ { url =
"https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl",
hash =
"sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size
= 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
+ { url =
"https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size
= 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
+ { url =
"https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size
= 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
+ { url =
"https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size
= 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
+ { url =
"https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size
= 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
+ { url =
"https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size
= 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
+ { url =
"https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl",
hash =
"sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size
= 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
+ { url =
"https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl",
hash =
"sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size
= 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
+ { url =
"https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl",
hash =
"sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size
= 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
+ { url =
"https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl",
hash =
"sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size
= 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl",
hash =
"sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size
= 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
+ { url =
"https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size
= 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
+ { url =
"https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl",
hash =
"sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size
= 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
+ { url =
"https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl",
hash =
"sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size
= 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
+ { url =
"https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl",
hash =
"sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size
= 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
+ { url =
"https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl",
hash =
"sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size
= 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
+ { url =
"https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl",
hash =
"sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size
= 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size
= 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
+ { url =
"https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size
= 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
+ { url =
"https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size
= 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
+ { url =
"https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size
= 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
+ { url =
"https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size
= 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
+ { url =
"https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl",
hash =
"sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size
= 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
+ { url =
"https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl",
hash =
"sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size
= 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
+ { url =
"https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl",
hash =
"sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size
= 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
+ { url =
"https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl",
hash =
"sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size
= 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
+ { url =
"https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl",
hash =
"sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size
= 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
+ { url =
"https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl",
hash =
"sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size
= 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
+ { url =
"https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl",
hash =
"sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size
= 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
+ { url =
"https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl",
hash =
"sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size
= 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
+ { url =
"https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl",
hash =
"sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size
= 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
+]
+
+[[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 = "github-body-field"
+version = "0.1.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "mypy", specifier = ">=2.1.0" },
+ { name = "pytest", specifier = ">=8.0" },
+ { name = "ruff", specifier = ">=0.15.14" },
+]
+
+[[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 = "librt"
+version = "0.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz",
hash =
"sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size
= 200139, upload-time = "2026-05-10T18:17:25.138Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl",
hash =
"sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size
= 141092, upload-time = "2026-05-10T18:15:34.795Z" },
+ { url =
"https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl",
hash =
"sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size
= 142035, upload-time = "2026-05-10T18:15:36.242Z" },
+ { url =
"https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size
= 475022, upload-time = "2026-05-10T18:15:37.56Z" },
+ { url =
"https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size
= 467273, upload-time = "2026-05-10T18:15:39.182Z" },
+ { url =
"https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size
= 497083, upload-time = "2026-05-10T18:15:40.634Z" },
+ { url =
"https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size
= 489139, upload-time = "2026-05-10T18:15:41.934Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl",
hash =
"sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size
= 508442, upload-time = "2026-05-10T18:15:43.206Z" },
+ { url =
"https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl",
hash =
"sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size
= 514230, upload-time = "2026-05-10T18:15:44.761Z" },
+ { url =
"https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl",
hash =
"sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size
= 494231, upload-time = "2026-05-10T18:15:46.308Z" },
+ { url =
"https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl",
hash =
"sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size
= 537585, upload-time = "2026-05-10T18:15:47.629Z" },
+ { url =
"https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl",
hash =
"sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size
= 100509, upload-time = "2026-05-10T18:15:49.157Z" },
+ { url =
"https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl",
hash =
"sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size
= 118628, upload-time = "2026-05-10T18:15:50.345Z" },
+ { url =
"https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl",
hash =
"sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size
= 103122, upload-time = "2026-05-10T18:15:52.068Z" },
+ { url =
"https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl",
hash =
"sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size
= 144147, upload-time = "2026-05-10T18:15:53.227Z" },
+ { url =
"https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl",
hash =
"sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size
= 143614, upload-time = "2026-05-10T18:15:54.657Z" },
+ { url =
"https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size
= 485538, upload-time = "2026-05-10T18:15:56.117Z" },
+ { url =
"https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size
= 479623, upload-time = "2026-05-10T18:15:57.544Z" },
+ { url =
"https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size
= 513082, upload-time = "2026-05-10T18:15:58.805Z" },
+ { url =
"https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size
= 508105, upload-time = "2026-05-10T18:16:00.2Z" },
+ { url =
"https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl",
hash =
"sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size
= 522268, upload-time = "2026-05-10T18:16:01.708Z" },
+ { url =
"https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl",
hash =
"sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size
= 527348, upload-time = "2026-05-10T18:16:03.496Z" },
+ { url =
"https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl",
hash =
"sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size
= 516294, upload-time = "2026-05-10T18:16:05.173Z" },
+ { url =
"https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl",
hash =
"sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size
= 553608, upload-time = "2026-05-10T18:16:06.839Z" },
+ { url =
"https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl",
hash =
"sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size
= 101879, upload-time = "2026-05-10T18:16:08.103Z" },
+ { url =
"https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl",
hash =
"sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size
= 119831, upload-time = "2026-05-10T18:16:09.174Z" },
+ { url =
"https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl",
hash =
"sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size
= 103470, upload-time = "2026-05-10T18:16:10.369Z" },
+ { url =
"https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl",
hash =
"sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size
= 144119, upload-time = "2026-05-10T18:16:11.771Z" },
+ { url =
"https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl",
hash =
"sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size
= 143565, upload-time = "2026-05-10T18:16:13.334Z" },
+ { url =
"https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size
= 485395, upload-time = "2026-05-10T18:16:14.729Z" },
+ { url =
"https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size
= 479383, upload-time = "2026-05-10T18:16:16.321Z" },
+ { url =
"https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size
= 513010, upload-time = "2026-05-10T18:16:17.647Z" },
+ { url =
"https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size
= 508433, upload-time = "2026-05-10T18:16:19.309Z" },
+ { url =
"https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl",
hash =
"sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size
= 522595, upload-time = "2026-05-10T18:16:20.642Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl",
hash =
"sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size
= 527255, upload-time = "2026-05-10T18:16:22.352Z" },
+ { url =
"https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl",
hash =
"sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size
= 516847, upload-time = "2026-05-10T18:16:23.627Z" },
+ { url =
"https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl",
hash =
"sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size
= 553920, upload-time = "2026-05-10T18:16:25.025Z" },
+ { url =
"https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl",
hash =
"sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size
= 101898, upload-time = "2026-05-10T18:16:26.649Z" },
+ { url =
"https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl",
hash =
"sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size
= 119812, upload-time = "2026-05-10T18:16:27.859Z" },
+ { url =
"https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl",
hash =
"sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size
= 103448, upload-time = "2026-05-10T18:16:29.066Z" },
+ { url =
"https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl",
hash =
"sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size
= 143345, upload-time = "2026-05-10T18:16:30.674Z" },
+ { url =
"https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl",
hash =
"sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size
= 143131, upload-time = "2026-05-10T18:16:32.037Z" },
+ { url =
"https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size
= 477024, upload-time = "2026-05-10T18:16:33.493Z" },
+ { url =
"https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size
= 474221, upload-time = "2026-05-10T18:16:34.864Z" },
+ { url =
"https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size
= 505174, upload-time = "2026-05-10T18:16:36.705Z" },
+ { url =
"https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size
= 497216, upload-time = "2026-05-10T18:16:38.418Z" },
+ { url =
"https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl",
hash =
"sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size
= 513921, upload-time = "2026-05-10T18:16:39.848Z" },
+ { url =
"https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl",
hash =
"sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size
= 520850, upload-time = "2026-05-10T18:16:41.471Z" },
+ { url =
"https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl",
hash =
"sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size
= 504237, upload-time = "2026-05-10T18:16:43.15Z" },
+ { url =
"https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl",
hash =
"sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size
= 546261, upload-time = "2026-05-10T18:16:44.408Z" },
+ { url =
"https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl",
hash =
"sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size
= 96965, upload-time = "2026-05-10T18:16:46.039Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl",
hash =
"sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size
= 115151, upload-time = "2026-05-10T18:16:47.133Z" },
+ { url =
"https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl",
hash =
"sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size
= 98850, upload-time = "2026-05-10T18:16:48.597Z" },
+ { url =
"https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl",
hash =
"sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size
= 151138, upload-time = "2026-05-10T18:16:49.839Z" },
+ { url =
"https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl",
hash =
"sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size
= 151976, upload-time = "2026-05-10T18:16:51.062Z" },
+ { url =
"https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size
= 557927, upload-time = "2026-05-10T18:16:52.632Z" },
+ { url =
"https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size
= 539698, upload-time = "2026-05-10T18:16:53.934Z" },
+ { url =
"https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size
= 577162, upload-time = "2026-05-10T18:16:55.589Z" },
+ { url =
"https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size
= 566494, upload-time = "2026-05-10T18:16:56.975Z" },
+ { url =
"https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl",
hash =
"sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size
= 596858, upload-time = "2026-05-10T18:16:58.374Z" },
+ { url =
"https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl",
hash =
"sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size
= 590318, upload-time = "2026-05-10T18:16:59.676Z" },
+ { url =
"https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl",
hash =
"sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size
= 575115, upload-time = "2026-05-10T18:17:01.007Z" },
+ { url =
"https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size
= 617918, upload-time = "2026-05-10T18:17:02.682Z" },
+ { url =
"https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl",
hash =
"sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size
= 103562, upload-time = "2026-05-10T18:17:03.99Z" },
+ { url =
"https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl",
hash =
"sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size
= 124327, upload-time = "2026-05-10T18:17:05.465Z" },
+ { url =
"https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl",
hash =
"sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size
= 102572, upload-time = "2026-05-10T18:17:06.809Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ast-serialize" },
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz",
hash =
"sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size
= 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl",
hash =
"sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size
= 14691685, upload-time = "2026-05-11T18:33:27.973Z" },
+ { url =
"https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl",
hash =
"sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size
= 13555165, upload-time = "2026-05-11T18:32:16.107Z" },
+ { url =
"https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size
= 13994376, upload-time = "2026-05-11T18:32:39.256Z" },
+ { url =
"https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size
= 14864618, upload-time = "2026-05-11T18:34:49.765Z" },
+ { url =
"https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl",
hash =
"sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size
= 15102063, upload-time = "2026-05-11T18:34:05.855Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl",
hash =
"sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size
= 11060564, upload-time = "2026-05-11T18:35:36.494Z" },
+ { url =
"https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl",
hash =
"sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size
= 9966983, upload-time = "2026-05-11T18:37:14.139Z" },
+ { url =
"https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl",
hash =
"sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size
= 14874381, upload-time = "2026-05-11T18:37:31.784Z" },
+ { url =
"https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl",
hash =
"sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size
= 13665501, upload-time = "2026-05-11T18:34:23.063Z" },
+ { url =
"https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size
= 14045750, upload-time = "2026-05-11T18:31:48.151Z" },
+ { url =
"https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size
= 15061630, upload-time = "2026-05-11T18:37:06.898Z" },
+ { url =
"https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl",
hash =
"sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size
= 15288831, upload-time = "2026-05-11T18:31:18.07Z" },
+ { url =
"https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl",
hash =
"sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size
= 11135228, upload-time = "2026-05-11T18:34:31.23Z" },
+ { url =
"https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl",
hash =
"sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size
= 10040684, upload-time = "2026-05-11T18:36:48.199Z" },
+ { url =
"https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl",
hash =
"sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size
= 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
+ { url =
"https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl",
hash =
"sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size
= 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
+ { url =
"https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size
= 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
+ { url =
"https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size
= 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
+ { url =
"https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl",
hash =
"sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size
= 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl",
hash =
"sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size
= 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
+ { url =
"https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl",
hash =
"sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size
= 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
+ { url =
"https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl",
hash =
"sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size
= 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
+ { url =
"https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl",
hash =
"sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size
= 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
+ { url =
"https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size
= 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
+ { url =
"https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size
= 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
+ { url =
"https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl",
hash =
"sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size
= 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
+ { url =
"https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl",
hash =
"sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size
= 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl",
hash =
"sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size
= 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
+ { url =
"https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl",
hash =
"sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size
= 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
+ { url =
"https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl",
hash =
"sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size
= 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
+ { url =
"https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size
= 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
+ { url =
"https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size
= 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
+ { url =
"https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size
= 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
+ { url =
"https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl",
hash =
"sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size
= 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
+ { url =
"https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl",
hash =
"sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size
= 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
+ { url =
"https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl",
hash =
"sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size
= 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz",
hash =
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size
= 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl",
hash =
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size
= 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "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 = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz",
hash =
"sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size
= 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl",
hash =
"sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size
= 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[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.0.3"
+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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz",
hash =
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size
= 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl",
hash =
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size
= 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz",
hash =
"sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size
= 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl",
hash =
"sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size
= 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
+ { url =
"https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size
= 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
+ { url =
"https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size
= 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
+ { url =
"https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size
= 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
+ { url =
"https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size
= 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
+ { url =
"https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size
= 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
+ { url =
"https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size
= 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
+ { url =
"https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size
= 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
+ { url =
"https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size
= 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
+ { url =
"https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size
= 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
+ { url =
"https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size
= 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
+ { url =
"https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size
= 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
+ { url =
"https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size
= 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
+ { url =
"https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size
= 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
+ { url =
"https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl",
hash =
"sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size
= 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
+ { url =
"https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl",
hash =
"sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size
= 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
+ { url =
"https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl",
hash =
"sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size
= 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz",
hash =
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size
= 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl",
hash =
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size
= 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]