diff --git a/doc/developer-guide/release-process/index.en.rst b/doc/developer-guide/release-process/index.en.rst
index 47437dd3163..b17b20a8591 100644
--- a/doc/developer-guide/release-process/index.en.rst
+++ b/doc/developer-guide/release-process/index.en.rst
@@ -65,10 +65,22 @@ Build
#. Check the version in ``CMakeLists.txt``. There is a ``project`` line near the
top with the version number. Make sure that is correct for the release.
-#. Generate or update the CHANGELOG for the next release. ::
-
- ./tools/git/changelog.pl -o apache -r trafficserver -m X.Y.Z >
- CHANGELOG-X.Y.Z
+#. Generate or update the CHANGELOG for the next release. This needs
+ `uv `__, and ``--use-gh`` additionally needs the
+ `GitHub CLI `__ already authenticated with
+ ``gh auth login``. ::
+
+ uv run --project tools/changelog python tools/changelog/changelog.py \
+ -o apache -r trafficserver -m X.Y.Z --use-gh > CHANGELOG-X.Y.Z
+
+ Without the GitHub CLI, omit ``--use-gh`` and supply a token through the
+ ``GH_TOKEN`` environment variable instead. A token is not optional here: an
+ unauthenticated run exceeds the GitHub API rate limit partway through a
+ release-sized milestone and exits without writing a changelog. ::
+
+ GH_TOKEN= uv run --project tools/changelog \
+ python tools/changelog/changelog.py \
+ -o apache -r trafficserver -m X.Y.Z > CHANGELOG-X.Y.Z
#. Commit this file to the repository and push it to the release branch.
diff --git a/tools/changelog/changelog.py b/tools/changelog/changelog.py
new file mode 100644
index 00000000000..f0794db3cbf
--- /dev/null
+++ b/tools/changelog/changelog.py
@@ -0,0 +1,514 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Generate a changelog from merged PRs in a GitHub milestone.
+
+Usage:
+ uv run --project tools/changelog python tools/changelog/changelog.py \
+ -o apache -r trafficserver -m 10.2.0
+
+Output is written to stdout in the format used by CHANGELOG-* files:
+
+ Changes with Apache Traffic Server 10.2.0
+ #11945 - Make directory operations methods on `Directory`
+ #12026 - Static link opentelemetry-cpp libraries to otel_tracer plugin
+ ...
+
+To generate a changelog file for a release:
+
+ uv run --project tools/changelog python tools/changelog/changelog.py \
+ -o apache -r trafficserver -m 10.2.0 > CHANGELOG-10.2.0
+
+Use --doc to include extra metadata (merge commit SHA, PR body, labels) for each
+PR, useful for generating release documentation. With --from-git the body is the
+commit message body rather than a PR body:
+
+ uv run --project tools/changelog python tools/changelog/changelog.py \
+ -o apache -r trafficserver -m 10.2.0 --doc --format yaml > changelog.yaml
+
+For security releases whose fixes land directly on a release branch without
+public pull requests (and therefore never appear in a milestone), use
+--from-git to source the changelog from a git commit range. This is merged
+with the milestone PRs (deduplicated by PR number) so the result includes both
+the direct-to-branch commits and any milestone PRs. The -m value is still used
+as the version label in the output:
+
+ uv run --project tools/changelog python tools/changelog/changelog.py \
+ -o apache -r trafficserver -m 10.1.4 \
+ --from-git 10.1.3..upstream/10.1.x
+
+Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+import httpx
+import yaml
+
+API_URL = "https://api.github.com"
+
+
+def gh_cli_available() -> bool:
+ try:
+ subprocess.run(["gh", "--version"], capture_output=True, check=True)
+ return True
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ return False
+
+
+def changelog_via_gh(owner: str, repo: str, milestone: str, verbose: bool, doc: bool) -> list[dict]:
+ """Use the gh CLI to fetch milestone PRs (avoids API rate limits)."""
+ milestone_id = None
+ result = subprocess.run(
+ ["gh", "api", f"/repos/{owner}/{repo}/milestones?state=all", "--paginate"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f"gh api error: {result.stderr}", file=sys.stderr)
+ sys.exit(1)
+
+ milestones = json.loads(result.stdout)
+ for ms in milestones:
+ if ms["title"] == milestone:
+ milestone_id = ms["number"]
+ break
+
+ if milestone_id is None:
+ print(f"Milestone not found: {milestone}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Looking for issues from Milestone {milestone}", file=sys.stderr)
+
+ changelog = []
+ page = 1
+ while True:
+ print(f"Page {page}", file=sys.stderr)
+ result = subprocess.run(
+ [
+ "gh",
+ "api",
+ f"/repos/{owner}/{repo}/issues?milestone={milestone_id}&state=closed&page={page}&per_page=100",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f"gh api error: {result.stderr}", file=sys.stderr)
+ sys.exit(1)
+
+ issues = json.loads(result.stdout)
+ if not issues:
+ break
+
+ for issue in issues:
+ number = issue["number"]
+ title = issue["title"]
+ if verbose:
+ print(f"Issue #{number} - {title} ", end="", file=sys.stderr)
+
+ if "pull_request" not in issue:
+ if verbose:
+ print("not a PR.", file=sys.stderr)
+ continue
+
+ if not _gh_is_merged(owner, repo, number):
+ if verbose:
+ print("not merged.", file=sys.stderr)
+ continue
+
+ if verbose:
+ print("added.", file=sys.stderr)
+
+ entry: dict = {"number": number, "title": title}
+ if doc:
+ labels = [label["name"] for label in issue.get("labels", [])]
+ entry["labels"] = labels
+ pr_detail = subprocess.run(
+ ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"],
+ capture_output=True,
+ text=True,
+ )
+ if pr_detail.returncode != 0:
+ print(f"gh api error fetching PR #{number}: {pr_detail.stderr.strip()}", file=sys.stderr)
+ sys.exit(1)
+ pr_data = json.loads(pr_detail.stdout)
+ entry["sha"] = pr_data.get("merge_commit_sha", "")
+ entry["body"] = pr_data.get("body", "") or ""
+ changelog.append(entry)
+
+ page += 1
+
+ return changelog
+
+
+def changelog_via_api(
+ owner: str,
+ repo: str,
+ milestone: str,
+ token: str | None,
+ verbose: bool,
+ doc: bool,
+) -> list[dict]:
+ """Use httpx to call the GitHub REST API directly."""
+ headers = {
+ "Accept": "application/vnd.github.v3+json",
+ "User-Agent": "ATS-Changelog-Tool",
+ }
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ with httpx.Client(base_url=API_URL, headers=headers, timeout=30) as client:
+ milestone_id = _lookup_milestone(client, owner, repo, milestone)
+ if milestone_id is None:
+ print(f"Milestone not found: {milestone}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Looking for issues from Milestone {milestone}", file=sys.stderr)
+
+ changelog = []
+ page = 1
+ while True:
+ print(f"Page {page}", file=sys.stderr)
+ resp = client.get(
+ f"/repos/{owner}/{repo}/issues",
+ params={
+ "milestone": milestone_id,
+ "state": "closed",
+ "page": page,
+ "per_page": 100,
+ },
+ )
+ _check_rate_limit(resp)
+ resp.raise_for_status()
+ issues = resp.json()
+
+ if not issues:
+ break
+
+ for issue in issues:
+ number = issue["number"]
+ title = issue["title"]
+ if verbose:
+ print(f"Issue #{number} - {title} ", end="", file=sys.stderr)
+
+ if "pull_request" not in issue:
+ if verbose:
+ print("not a PR.", file=sys.stderr)
+ continue
+
+ if not _is_merged(client, owner, repo, number):
+ if verbose:
+ print("not merged.", file=sys.stderr)
+ continue
+
+ if verbose:
+ print("added.", file=sys.stderr)
+
+ entry: dict = {"number": number, "title": title}
+ if doc:
+ labels = [label["name"] for label in issue.get("labels", [])]
+ entry["labels"] = labels
+ pr_resp = client.get(f"/repos/{owner}/{repo}/pulls/{number}")
+ _check_rate_limit(pr_resp)
+ pr_resp.raise_for_status()
+ pr_data = pr_resp.json()
+ entry["sha"] = pr_data.get("merge_commit_sha", "")
+ entry["body"] = pr_data.get("body", "") or ""
+ changelog.append(entry)
+
+ page += 1
+
+ return changelog
+
+
+def changelog_via_git(git_range: str, verbose: bool, doc: bool) -> list[dict]:
+ """Build the changelog from a git commit range.
+
+ Used for security releases whose fixes are committed directly to a release
+ branch without public PRs, so they never appear in a GitHub milestone. Each
+ commit becomes an entry; a trailing "(#N)" in the subject is captured as the
+ PR number when present, but is not required.
+ """
+ field_sep = "\x1f"
+ record_sep = "\x1e"
+ fmt = f"%H{field_sep}%s{field_sep}%b{record_sep}"
+ result = subprocess.run(
+ ["git", "log", "--no-merges", "--reverse", f"--pretty=format:{fmt}", git_range],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f"git log error: {result.stderr.strip()}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Reading commits from git range {git_range}", file=sys.stderr)
+
+ changelog = []
+ for record in result.stdout.split(record_sep):
+ record = record.strip("\n")
+ if not record:
+ continue
+ sha, subject, body = record.split(field_sep, 2)
+ # Only a trailing "(#N)" is a PR number. A bare "#N" anywhere in the subject
+ # is usually an issue reference, and treating it as a PR number would merge
+ # unrelated entries in merge_changelogs().
+ match = re.search(r"\(#(\d+)\)$", subject)
+ number = int(match.group(1)) if match else None
+ if verbose:
+ label = f"#{number}" if number else sha[:12]
+ print(f"{label} - {subject} added.", file=sys.stderr)
+
+ entry: dict = {"number": number, "title": subject}
+ if doc:
+ entry["sha"] = sha
+ entry["body"] = body.strip()
+ changelog.append(entry)
+
+ return changelog
+
+
+def merge_changelogs(milestone_entries: list[dict], git_entries: list[dict]) -> list[dict]:
+ """Union the milestone-sourced and git-sourced entries, deduplicated by PR number.
+
+ Git commits keep their chronological order and carry commit-derived metadata.
+ Where a git commit's PR number matches a milestone PR, the milestone labels are
+ grafted onto the git entry. Milestone PRs with no corresponding commit in the
+ git range (e.g. folded into a combined backport) are appended, sorted by number.
+ """
+ ms_by_number = {e["number"]: e for e in milestone_entries}
+
+ # A PR can appear more than once in a git range -- a revert and reapply, or the
+ # same commit cherry-picked twice -- and both commits carry the same trailing
+ # "(#N)". Keep the first, so the surviving entry is the chronological one.
+ deduped: list[dict] = []
+ seen: set[int] = set()
+ for ge in git_entries:
+ num = ge.get("number")
+ if num is not None:
+ if num in seen:
+ continue
+ seen.add(num)
+ deduped.append(ge)
+
+ for ge in deduped:
+ num = ge.get("number")
+ if num in ms_by_number and ms_by_number[num].get("labels") and "labels" not in ge:
+ ge["labels"] = ms_by_number[num]["labels"]
+
+ extras = [e for e in milestone_entries if e["number"] not in seen]
+ extras.sort(key=lambda x: x["number"])
+
+ return deduped + extras
+
+
+def _lookup_milestone(client: httpx.Client, owner: str, repo: str, title: str) -> int | None:
+ page = 1
+ while True:
+ resp = client.get(f"/repos/{owner}/{repo}/milestones", params={"state": "all", "per_page": 100, "page": page})
+ _check_rate_limit(resp)
+ resp.raise_for_status()
+ data = resp.json()
+ if not data:
+ break
+ for ms in data:
+ if ms["title"] == title:
+ return ms["number"]
+ page += 1
+ return None
+
+
+def _http_status(response: str) -> int | None:
+ """Status code from the status line that `gh api --include` prints first."""
+ match = re.match(r"HTTP/[\d.]+\s+(\d{3})", response.split("\n", 1)[0])
+ return int(match.group(1)) if match else None
+
+
+def _gh_is_merged(owner: str, repo: str, pr_number: int) -> bool:
+ """Whether pr_number is merged, via the gh CLI.
+
+ gh exits non-zero for any HTTP error, so without the status line --include
+ supplies, a real 404 is indistinguishable from 403 rate limiting or a 5xx.
+ Anything but 204/404 is fatal; skipping would drop merged PRs and still exit 0.
+ """
+ result = subprocess.run(
+ ["gh", "api", "--include", f"/repos/{owner}/{repo}/pulls/{pr_number}/merge"],
+ capture_output=True,
+ text=True,
+ )
+ status = _http_status(result.stdout)
+ if status == 204:
+ return True
+ if status == 404:
+ return False
+
+ print(
+ f"gh api error checking whether PR #{pr_number} is merged "
+ f"(HTTP {status if status else 'unknown'}): {result.stderr.strip()}",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+def _is_merged(client: httpx.Client, owner: str, repo: str, pr_number: int) -> bool:
+ resp = client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}/merge")
+ if resp.status_code == 204:
+ return True
+ if resp.status_code == 404:
+ return False
+ _check_rate_limit(resp)
+ resp.raise_for_status()
+ return False
+
+
+def _check_rate_limit(resp: httpx.Response) -> None:
+ """Exit if @a resp is a rate-limit response, leaving other errors to the caller.
+
+ A 403 alone does not identify a rate limit -- GitHub also uses it for a missing
+ token or insufficient scopes, where "try using an auth token" is wrong and hides
+ the real cause. A primary limit zeroes x-ratelimit-remaining, a secondary limit
+ sends retry-after, and a 429 is always a limit. Everything else falls through to
+ the raise_for_status() that follows each call site.
+ """
+ if resp.status_code not in (403, 429):
+ return
+ if (resp.status_code == 429 or resp.headers.get("x-ratelimit-remaining") == "0" or "retry-after" in resp.headers):
+ print(
+ "You have exceeded your rate limit. Try using an auth token.",
+ file=sys.stderr,
+ )
+ sys.exit(2)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Generate changelog from merged PRs in a GitHub milestone.")
+ parser.add_argument("-o", "--owner", required=True, help="Repository owner")
+ parser.add_argument("-r", "--repo", required=True, help="Repository name")
+ parser.add_argument("-m", "--milestone", required=True, help="Milestone title")
+ parser.add_argument(
+ "-a",
+ "--auth",
+ default=None,
+ help="GitHub auth token. Discouraged: it is visible in ps output and shell history. Prefer GH_TOKEN.",
+ )
+ parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
+ parser.add_argument(
+ "--doc",
+ action="store_true",
+ help="Include extra metadata (merge SHA, PR body, labels) for documentation",
+ )
+ parser.add_argument(
+ "--format",
+ choices=["text", "yaml"],
+ default="text",
+ help="Output format (default: text)",
+ )
+ parser.add_argument(
+ "--use-gh",
+ action="store_true",
+ help="Use gh CLI instead of direct API calls (avoids rate limits)",
+ )
+ parser.add_argument(
+ "--from-git",
+ metavar="RANGE",
+ default=None,
+ help="Also source commits from a git range (e.g. 10.1.3..upstream/10.1.x) and merge them "
+ "with the milestone PRs, deduplicated by PR number. Use for security releases whose fixes "
+ "land directly on a branch without public PRs. -m is still used as the version label.",
+ )
+ args = parser.parse_args()
+
+ token = args.auth or os.environ.get("GH_TOKEN")
+
+ if not token and not args.use_gh:
+ print(
+ "WARNING: No GitHub token provided. Unauthenticated requests are limited\n"
+ "to 60 per hour, which is usually not enough to generate a full changelog.\n"
+ "\n"
+ "Provide a token via -a or the GH_TOKEN environment variable.\n"
+ "\n"
+ "To create a token:\n"
+ " 1. Go to https://github.com/settings/tokens\n"
+ " 2. Click 'Generate new token' -> 'Generate new token (classic)'\n"
+ " 3. Select the 'public_repo' scope (sufficient for public repositories)\n"
+ " 4. For fine-grained tokens, grant read-only access to Issues and\n"
+ " Pull Requests on the target repository\n"
+ "\n"
+ "Alternatively, use --use-gh to use the gh CLI with its existing auth.\n",
+ file=sys.stderr,
+ )
+
+ if args.use_gh:
+ if not gh_cli_available():
+ print("gh CLI not found. Install it or omit --use-gh.", file=sys.stderr)
+ sys.exit(1)
+ changelog = changelog_via_gh(args.owner, args.repo, args.milestone, args.verbose, args.doc)
+ else:
+ changelog = changelog_via_api(
+ args.owner,
+ args.repo,
+ args.milestone,
+ token,
+ args.verbose,
+ args.doc,
+ )
+
+ if args.from_git:
+ git_changelog = changelog_via_git(args.from_git, args.verbose, args.doc)
+ changelog = merge_changelogs(changelog, git_changelog)
+
+ if changelog:
+ # Milestone-only entries all carry a PR number and sort by it. A merged
+ # (--from-git) result preserves git's chronological order with milestone-only
+ # PRs appended, so it must not be re-sorted.
+ if not args.from_git:
+ changelog.sort(key=lambda x: x["number"])
+
+ if args.format == "yaml":
+ output = {
+ "milestone": args.milestone,
+ "owner": args.owner,
+ "repo": args.repo,
+ "entries": changelog,
+ }
+ yaml.safe_dump(output, sys.stdout, default_flow_style=False, sort_keys=False, allow_unicode=True)
+ else:
+ print(f"Changes with Apache Traffic Server {args.milestone}")
+ for entry in changelog:
+ if entry.get("number"):
+ print(f" #{entry['number']} - {entry['title']}")
+ else:
+ print(f" {entry['title']}")
+ if args.doc:
+ if entry.get("sha"):
+ print(f" SHA: {entry['sha']}")
+ if entry.get("labels"):
+ print(f" Labels: {', '.join(entry['labels'])}")
+ if entry.get("body"):
+ body_lines = entry["body"].strip().split("\n")
+ print(f" Body:")
+ for line in body_lines:
+ print(f" {line}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/changelog/pyproject.toml b/tools/changelog/pyproject.toml
new file mode 100644
index 00000000000..87dc6a4daec
--- /dev/null
+++ b/tools/changelog/pyproject.toml
@@ -0,0 +1,30 @@
+# 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.
+
+[project]
+name = "changelog"
+version = "0.1.0"
+description = "Generate changelog from GitHub milestones for Apache Traffic Server"
+requires-python = ">=3.11"
+license = "Apache-2.0"
+dependencies = [
+ "httpx>=0.27",
+ "pyyaml>=6.0",
+]
+
+# No [build-system] and no [project.scripts]: this is a uv virtual project, used
+# only to pin the dependencies for the in-tree script. Run it as
+# `uv run --project tools/changelog python tools/changelog/changelog.py`.
diff --git a/tools/changelog/uv.lock b/tools/changelog/uv.lock
new file mode 100644
index 00000000000..a08778737c9
--- /dev/null
+++ b/tools/changelog/uv.lock
@@ -0,0 +1,150 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "changelog"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "httpx" },
+ { name = "pyyaml" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "httpx", specifier = ">=0.27" },
+ { name = "pyyaml", specifier = ">=6.0" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[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" },
+]
diff --git a/tools/git/changelog.pl b/tools/git/changelog.pl
deleted file mode 100755
index a76632ae6bd..00000000000
--- a/tools/git/changelog.pl
+++ /dev/null
@@ -1,174 +0,0 @@
-#!/usr/bin/env perl
-#
-# 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.
-
-use strict;
-use warnings;
-
-use WWW::Curl::Easy;
-use JSON;
-use Getopt::Std;
-
-my $url = "https://api.github.com";
-
-sub usage() {
- print "USAGE: changelog.pl -o owner -r repo -m milestone [-a auth] [-v]\n";
- print "\t-v verbose\n\n";
- print "Example:\n";
- print "\tchangelog.pl -o apache -r trafficserver -m 8.0.6 -a auth_token\n";
- exit(1);
-}
-
-sub rate_fail
-{
- print STDERR "You have exceeded your rate limit. Try using an auth token.\n";
- exit 2;
-}
-
-sub milestone_lookup($$$$$)
-{
- my($curl, $url, $owner, $repo, $milestone_title) = @_;
-
- my $endpoint = "/repos/$owner/$repo/milestones";
- my $resp_body;
-
- $curl->setopt(CURLOPT_WRITEDATA, \$resp_body);
- $curl->setopt(CURLOPT_URL, $url . $endpoint);
-
- my $retcode = $curl->perform();
- if ($retcode == 0 && $curl->getinfo(CURLINFO_HTTP_CODE) == 200) {
- my $milestones = from_json($resp_body);
- foreach my $milestone (@{$milestones}) {
- if ($milestone->{title} eq $milestone_title) {
- return $milestone->{number};
- }
- }
- } elsif ($retcode == 0 && $curl->getinfo(CURLINFO_HTTP_CODE) == 403) {
- rate_fail();
- }
-
- undef;
-}
-
-sub is_merged($$$$$)
-{
- my($curl, $url, $owner, $repo, $issue_id) = @_;
-
- my $endpoint = "/repos/$owner/$repo/pulls/$issue_id/merge";
- my $resp_body;
-
- $curl->setopt(CURLOPT_WRITEDATA, \$resp_body);
- $curl->setopt(CURLOPT_URL, $url . $endpoint);
-
- my $retcode = $curl->perform();
- if ($retcode == 0 && $curl->getinfo(CURLINFO_HTTP_CODE) == 204) {
- return 1;
- } elsif ($retcode == 0 && $curl->getinfo(CURLINFO_HTTP_CODE) == 403) {
- rate_fail();
- }
-
- undef;
-}
-
-sub issue_search($$$$$$)
-{
- my($curl, $url, $owner, $repo, $milestone_id, $page) = @_;
-
- my $endpoint = "/repos/$owner/$repo/issues";
-
- my $params = "milestone=$milestone_id&state=closed&page=$page";
-
- my $resp_body;
-
- $curl->setopt(CURLOPT_WRITEDATA, \$resp_body);
- $curl->setopt(CURLOPT_URL, $url . $endpoint . '?' . $params);
-
- my $retcode = $curl->perform();
- if ($retcode == 0 && $curl->getinfo(CURLINFO_HTTP_CODE) == 200) {
- return from_json($resp_body);
- } elsif ($retcode == 0 && $curl->getinfo(CURLINFO_HTTP_CODE) == 403) {
- rate_fail();
- }
-
- undef;
-}
-
-{
- # get the command line arguments
- my %opts;
- getopts("o:r:m:a:v", \%opts);
- usage() if (! defined $opts{o} || ! defined $opts{r} || ! defined $opts{m});
- my $owner = $opts{o};
- my $repo = $opts{r};
- my $milestone = $opts{m};
- my $auth = $opts{a};
- my $verbose = 0;
- $verbose = 1 if (defined $opts{v});
-
- my $curl = WWW::Curl::Easy->new;
- $curl->setopt(CURLOPT_VERBOSE, 1) if $verbose;
- $curl->setopt(CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json', 'User-Agent: Awesome-Octocat-App']);
-
- if (defined($auth)) {
- $curl->setopt(CURLOPT_USERPWD, $auth);
- }
-
- my $milestone_id = milestone_lookup($curl, $url, $owner, $repo, $milestone);
-
- if (!defined($milestone_id)) {
- print STDERR "Milestone not found: $milestone\n";
- exit 1;
- }
-
- my $issues;
- my $changelog;
- my $page = 1;
-
- print STDERR "Looking for issues from Milestone $milestone\n";
-
- do {
- print STDERR "Page $page\n";
- $issues = issue_search($curl, $url, $owner, $repo, $milestone_id, $page);
- foreach my $issue (@{$issues}) {
- if (defined($issue)) {
- print STDERR "Issue #" . $issue->{number} . " - " . $issue->{title} . " ";
-
- if (!exists($issue->{pull_request})) {
- print STDERR "not a PR.\n";
- next;
- }
-
- if (!is_merged($curl, $url, $owner, $repo, $issue->{number})) {
- print STDERR "not merged.\n";
- next;
- }
-
- print STDERR "added.\n";
- push @{$changelog}, {number => $issue->{number}, title => $issue->{title}};
- }
- }
- $page++;
- } while (scalar @{$issues});
-
- if (defined($changelog)) {
- print "Changes with Apache Traffic Server $milestone\n";
-
- foreach my $issue (sort {$a->{number} <=> $b->{number}} @{$changelog}) {
- print " #$issue->{number} - $issue->{title}\n";
- }
- }
-}