From 4c6c7b92eb38394609608b0516e6f029b2ebcc95 Mon Sep 17 00:00:00 2001
From: Abderrahim Adrabi
<184391033+abderrahim-lectures@users.noreply.github.com>
Date: Sun, 2 Aug 2026 01:19:40 +0100
Subject: [PATCH] content: add Changelog from Git project
New Real-World Project (backlog idea #19). Full lesson plus a runnable
tool that fetches commit history with git log and synthesizes a clean,
categorized changelog with a free-tier LLM, citing a commit hash for
every entry so nothing can be invented.
Closes #276
Co-authored-by: deepseek-v4-flash-free
---
.../changelog-from-git/_category_.json | 4 +
docs/projects/changelog-from-git/index.md | 352 ++++++++++++++++++
docs/projects/index.mdx | 6 +
examples/changelog-from-git/.env.example | 1 +
examples/changelog-from-git/.gitignore | 5 +
examples/changelog-from-git/.python-version | 1 +
examples/changelog-from-git/README.md | 76 ++++
examples/changelog-from-git/fetch_commits.py | 60 +++
examples/changelog-from-git/generate.py | 55 +++
.../changelog-from-git/make_sample_repo.py | 66 ++++
examples/changelog-from-git/notebook.ipynb | 227 +++++++++++
examples/changelog-from-git/pyproject.toml | 10 +
examples/changelog-from-git/uv.lock | 341 +++++++++++++++++
src/data/projects.ts | 6 +
src/pages/index.tsx | 18 +
15 files changed, 1228 insertions(+)
create mode 100644 docs/projects/changelog-from-git/_category_.json
create mode 100644 docs/projects/changelog-from-git/index.md
create mode 100644 examples/changelog-from-git/.env.example
create mode 100644 examples/changelog-from-git/.gitignore
create mode 100644 examples/changelog-from-git/.python-version
create mode 100644 examples/changelog-from-git/README.md
create mode 100644 examples/changelog-from-git/fetch_commits.py
create mode 100644 examples/changelog-from-git/generate.py
create mode 100644 examples/changelog-from-git/make_sample_repo.py
create mode 100644 examples/changelog-from-git/notebook.ipynb
create mode 100644 examples/changelog-from-git/pyproject.toml
create mode 100644 examples/changelog-from-git/uv.lock
diff --git a/docs/projects/changelog-from-git/_category_.json b/docs/projects/changelog-from-git/_category_.json
new file mode 100644
index 0000000..09ffb05
--- /dev/null
+++ b/docs/projects/changelog-from-git/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Changelog from Git",
+ "position": 22
+}
diff --git a/docs/projects/changelog-from-git/index.md b/docs/projects/changelog-from-git/index.md
new file mode 100644
index 0000000..6607924
--- /dev/null
+++ b/docs/projects/changelog-from-git/index.md
@@ -0,0 +1,352 @@
+---
+id: changelog-from-git
+title: "Build a Changelog Generator from Git History"
+sidebar_label: "Changelog from Git"
+slug: /projects/changelog-from-git
+description: "Turn a noisy stream of commit messages into a clean, categorized changelog: fetch the history with git, group it, and let a free-tier LLM synthesize it — with every entry traceable back to the commits it came from."
+---
+
+import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox';
+import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate';
+import ProjectGreeting from '@site/src/components/ProjectGreeting';
+import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist';
+
+# 📜 Build a Changelog Generator from Git History
+
+
+
+
+
+Every open-source project's "What's new" notes start life as a pile of commit messages — and those messages are a mess: "fix bug", "wip", "Merge branch 'main'", "tweak", half of them written at 2am. Yet the information you need for a changelog is *all there*: what changed, in what file, by whom, when. The problem isn't extraction, it's **judgment under noisy input** — grouping, de-duplicating, and phrasing without inventing anything.
+
+This project builds a small tool that fetches a repo's commit history with `git log`, hands the messages to a free-tier LLM, and produces a clean, categorized changelog (Added / Changed / Fixed) where every entry cites the commit hash it came from — so you can verify nothing was invented. It's a different shape from the [RAG projects](/docs/projects/rag-notes): the "documents" are commit messages, generated by `git` rather than read from files, and each one is already atomic — no chunking needed. This assumes Python 101 and the free-tier LLM pattern from the [AI Agent project](/docs/projects/ai-agent); nothing from Data Analysis is required.
+
+This is optional and ungraded. See [Real-World Projects](/docs/projects) for the full, growing list.
+
+## 🎯 What you'll do
+
+1. Fetch a repo's recent commit history with `git log`, capturing hash, author, date, and subject — using the `subprocess` pattern from the [agentic code reviewer](/docs/projects/agentic-code-reviewer) and the RAG-over-a-GitHub-repo project.
+2. See why commit messages are a low-signal source: wildly varying quality, noise commits (merges, formatting, "wip"), and how to handle them without losing real changes.
+3. Send the grouped messages to a free-tier LLM with a prompt that demands categorization (Added / Changed / Fixed), scope grouping, and a **commit hash citation per entry**.
+4. Verify the changelog against the real `git log` — checking both for *invented* entries and *omitted* real changes.
+
+## Where to run this
+
+**Locally with `uv`** is the path this lesson's steps follow, and the recommended one — it's real Python against a real repo on your own machine, and you can run it on any project you have cloned.
+
+**GitHub Codespaces** is a zero-setup alternative: open [the whole course repo in a free Codespace](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python, and `uv` are already installed, per the repo's `.devcontainer/devcontainer.json`) and run the exact same `uv` commands from a terminal in your browser tab — the course repo itself is the target.
+
+**Google Colab, Kaggle Notebooks, or Binder** also work, since this project needs no GPU. A ready-to-run notebook that shallow-clones the course repo and generates a changelog from its recent commits is included in the repo:
+
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/changelog-from-git/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/changelog-from-git/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fchangelog-from-git%2Fnotebook.ipynb)
+
+Be honest with yourself about the tradeoff: the notebook generates a changelog for the course repo, which is fine for learning — but the real payoff is pointing the tool at a repo *you* work on, where the noise is yours.
+
+## Setup
+
+### Install `uv`
+
+`uv` is a single tool that replaces the usual "install Python, then install pip, then install a virtual environment tool, then install packages" chain — it can install and manage Python versions itself, alongside your project's dependencies.
+
+**macOS / Linux** (terminal):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+**Windows** (PowerShell):
+
+```powershell
+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+Close and reopen your terminal, then confirm it installed:
+
+```bash
+uv --version
+```
+
+Then set up a project:
+
+```bash
+uv init changelog-from-git
+cd changelog-from-git
+uv add python-dotenv openai
+```
+
+Notice what's *not* here: no `sentence-transformers`, no embeddings. This project doesn't need a retrieval index — `git` does the "retrieval" for you, returning exactly the commits that exist. The `openai` client talks to GitHub Models (and, with a different `base_url`, the other providers in the table below). `python-dotenv` keeps your API key in a local `.env`.
+
+### Clone a test repository
+
+You need a repo with enough history to make a changelog meaningful. The course repo works (shallow is fine):
+
+```bash
+git clone --depth 1 https://github.com/abderrahim-lectures/python-data-analysis-course
+```
+
+`--depth 1` grabs only the latest snapshot — for the changelog tool you'll want more history, so for a fuller demo clone without `--depth`, or just point the tool at any repo you already have locally. The tool reads the repo in your *current directory*, so `cd` into it first:
+
+```bash
+cd python-data-analysis-course
+```
+
+### Get a free LLM API key
+
+The whole project is one LLM call (plus a verification step that needs nothing). **Pick whichever provider you like** — none of them require a credit card at the time of writing, and this course doesn't favor one over another.
+
+| Provider | Where to get a key | Why you might pick it |
+|---|---|---|
+| **GitHub Models** *(suggested default)* | [github.com/settings/tokens](https://github.com/settings/tokens) — a personal access token with the `models: read` scope | No separate signup — you already have a GitHub account. More generous free-tier limits than Gemini's. |
+| Gemini | [Google AI Studio](https://aistudio.google.com/) | The most commonly referenced option. |
+| Groq | [console.groq.com/keys](https://console.groq.com/keys) | Fast inference, generous free tier, no card. |
+| Mistral | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) | One of the more generous permanent free quotas. |
+| Cerebras | [cloud.cerebras.ai](https://cloud.cerebras.ai/) | High daily token volume, no card. |
+| OpenRouter | [openrouter.ai/keys](https://openrouter.ai/keys) | One API, many free models — good for comparing providers. |
+
+Whichever you pick, the process is the same:
+
+1. Sign in and generate an API key on that provider's site.
+2. **Never paste this key directly into code or commit it to a repository.** Put it in a `.env` file instead (already gitignored):
+
+```bash
+# .env
+GITHUB_TOKEN=your-key-here
+```
+
+## Step 1: Fetch the commit stream
+
+`git log` already knows how to print exactly the fields you want, one per line, in a machine-friendly format. Use the `--format` flag to avoid parsing human-facing output:
+
+```python
+# fetch_commits.py
+"""Fetches a repo's recent commit history as structured records.
+
+Run with: uv run python fetch_commits.py [max_commits]
+This prints a summary -- generate.py (Step 2) imports load_commits().
+Run from inside a git repo (or a folder that is one).
+"""
+
+import subprocess
+import sys
+
+
+def _run_git(args: list[str]) -> str:
+ """Runs `git ` in the current directory and returns its stdout."""
+ result = subprocess.run(
+ ["git", *args],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr}")
+ return result.stdout
+
+
+def load_commits(max_commits: int = 50) -> list[dict]:
+ """Returns the last `max_commits` commits, newest first."""
+ raw = _run_git(
+ [
+ "log",
+ f"-{max_commits}",
+ # One record per commit, fields separated by a NUL byte so that
+ # neither subject text nor hashes can accidentally blur together.
+ "--format=%H%x00%an%x00%ad%x00%s%x00%x00",
+ "--date=short",
+ ]
+ )
+ commits = []
+ for block in raw.split("\x00\x00"):
+ block = block.strip("\n")
+ if not block:
+ continue
+ parts = block.split("\x00")
+ if len(parts) < 4:
+ continue
+ commit = {
+ "hash": parts[0],
+ "author": parts[1],
+ "date": parts[2],
+ "subject": parts[3],
+ }
+ commits.append(commit)
+ return commits
+
+
+if __name__ == "__main__":
+ limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50
+ commits = load_commits(limit)
+ print(f"Fetched {len(commits)} commits")
+ for c in commits[:5]:
+ print(f" {c['hash'][:8]} {c['date']} {c['author']}: {c['subject']}")
+```
+
+```bash
+uv run python fetch_commits.py 50
+```
+
+Two choices matter here:
+
+- **The NUL byte (`\x00`) as a field separator.** Subjects can contain commas, quotes, and newlines; a NUL byte is the one thing no commit message will ever contain. Splitting on it makes the parser robust against the messy reality of real commit messages — the same instinct as the RAG-over-a-GitHub-repo project tracking line ranges so citations stay verifiable.
+- **`--format` instead of parsing `git log`'s human output.** If you ever find yourself regex-ing a tool's display formatting, the tool almost certainly offers a structured format — `git log --format` is the canonical example.
+
+**✅ Checklist**
+
+
+`uv run python fetch_commits.py 50` prints 50 (or fewer) commits with hash, date, author, and subject.
+Subjects with unusual punctuation or even embedded newlines parse into single clean records.
+Every commit has a non-empty hash and subject.
+
+
+**🤔 Socratic Question(s)**
+
+- Run this on a real repo and skim the subjects. Which ones are obviously useful for a changelog, which are noise, and which are *ambiguous* — a message like "refactor utils" that could mean anything?
+- Why is it better to let `git` return "exactly the commits that exist" than to fetch more and filter them yourself? Where would you draw the line between "too many to summarize well" and "enough to be useful"?
+
+## Step 2: Let the LLM synthesize, with citations required
+
+The generation step is one prompt, but the prompt is where all the judgment lives. It has to do several things at once: ignore noise, group related changes, categorize into Added / Changed / Fixed, and — critically — **cite the commit hash for every entry** so nothing can be silently invented:
+
+```python
+# generate.py
+"""Turns fetched commits into a categorized, cited changelog.
+
+Run with: uv run python generate.py 50
+(Set your API key in .env first.)
+"""
+
+import os
+import sys
+
+from dotenv import load_dotenv
+from openai import OpenAI
+
+from fetch_commits import load_commits
+
+load_dotenv()
+
+PROMPT_TEMPLATE = """You are writing a release changelog from commit messages.
+Below is a list of recent commits, each tagged with a short hash. Write a
+clean changelog with these rules:
+
+- Group entries into sections: **Added**, **Changed**, **Fixed**.
+- Merge commits that clearly belong to the same change; drop pure noise
+ (merge commits, "wip", formatting-only messages) unless they hint at a real
+ change, in which case include the change.
+- Every entry must end with the commit hash(es) it came from, like "(abc1234)".
+- Do NOT invent commits, features, or fixes. If a message is too vague to
+ classify, put it in a final "Other / unclear" section rather than guessing.
+
+Commits (hash: subject):
+{commits}
+
+Changelog:
+"""
+
+
+def build_prompt(commits: list[dict]) -> str:
+ lines = [f"{c['hash'][:8]}: {c['subject']}" for c in commits]
+ return PROMPT_TEMPLATE.format(commits="\n".join(lines))
+
+
+def generate_changelog(commits: list[dict]) -> str:
+ client = OpenAI(
+ api_key=os.environ["GITHUB_TOKEN"],
+ base_url="https://models.github.ai/inference",
+ )
+ response = client.chat.completions.create(
+ model="gpt-4o-mini", # confirm this still has a free tier before running
+ messages=[{"role": "user", "content": build_prompt(commits)}],
+ )
+ return response.choices[0].message.content
+
+
+if __name__ == "__main__":
+ limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50
+ print(generate_changelog(load_commits(limit)))
+```
+
+```bash
+uv run python generate.py 50
+```
+
+The three rules in the prompt map directly to the three failure modes of this kind of tool:
+
+- **Grouping rules** fight the *omission* failure — real changes hidden under merge commits or "wip".
+- **"Every entry must cite a hash"** fights the *invention* failure — the model's temptation to write what a release "should" contain based on its training data instead of what actually happened.
+- **The "Other / unclear" section** is the honesty valve: instead of forcing an ambiguous message into a bucket where it pretends to be something it isn't, it gets an honest home.
+
+:::tip[Using a different provider?]
+Swap the `OpenAI(...)` block for your provider's own client, following the same pattern as the [AI Agent project](/docs/projects/ai-agent#step-1-write-your-first-agent) — e.g. Google's `google-genai` package for Gemini, or `groq`'s own client for Groq. Cerebras and OpenRouter are also OpenAI-compatible, so the `openai` package works for them too, just with a different `base_url`.
+:::
+
+**✅ Checklist**
+
+
+`uv run python generate.py 50` prints a changelog with Added / Changed / Fixed sections.
+Every entry ends with at least one short commit hash in parentheses.
+There's no entry you can't connect to a real commit — nothing feels invented.
+
+
+**🤔 Socratic Question(s)**
+
+- Try the same prompt with the citation rule removed. What does the model do differently — does it invent plausible-sounding features? What does that tell you about why the rule exists?
+- A repo with conventional commits ("feat:", "fix:") is easy; one with "tweak" and "fix bug" everywhere is hard. Where in the prompt does the model learn to cope with the second case — and what happens if the messages are *so* bad that even the "unclear" section is mostly guesses?
+
+## Step 3: Verify against the real `git log`
+
+The changelog is a *claim about the repo*, and claims need checking. Verification is just running the same `git log` you trusted in Step 1 and comparing:
+
+```bash
+uv run python fetch_commits.py 50
+```
+
+Now work through the changelog entry by entry:
+
+- **Every cited hash exists** — run `git show ` for a spot-check or two and confirm the subject matches the changelog line.
+- **Nothing big is missing** — is there a major commit in the log with no changelog entry? That's an omission worth a second prompt ("you missed this one").
+- **Nothing is invented** — if an entry cites `abc1234` but `git show abc1234` shows a formatting-only change, the model over-reached, and the citation is doing its job: catching it.
+
+This verification is the whole trust model. The citation requirement doesn't make the model truthful by itself — it makes the model's truth *checkable*, which is almost as good.
+
+**✅ Checklist**
+
+
+You ran `git show` on at least two cited hashes and confirmed the changelog entries match the actual commits.
+You identified at least one commit in `git log` that the changelog either omitted or described loosely — and decided whether that's acceptable.
+No changelog entry cites a hash that doesn't exist or that contradicts the commit's subject.
+
+
+**🤔 Socratic Question(s)**
+
+- The tool's citation lets you verify the *attribution* of each line, but not the *completeness* of the whole changelog. Which failure is easier to spot — invention or omission — and why?
+- Would you trust this tool more on a repo with conventional commits or on a repo with free-form messages? Does the tool change that answer, or only make the difference visible?
+
+## ⚠️ Common pitfalls
+
+- **Generating from too few commits.** A `--depth 1` clone has one commit — a changelog of one line. Use `--depth` with a larger number (or no depth) for a meaningful demo, and remember the tool only sees what you fetch.
+- **Trusting the changelog without checking.** The citation rule is a check-ability guarantee, not a truth guarantee. A model that wants to invent will invent *with a citation*. Always spot-check `git show` on a sample of entries.
+- **Forgetting which directory you're in.** The tool runs `git log` in your current working directory. Run it from inside the target repo, or `git log` fails with a confusing error.
+- **Context window overflow on big histories.** 500 commits of subjects fit fine in a free-tier context, but huge histories or long subjects can hit limits. If the call fails, reduce the commit count — the summary quality per-commit usually improves anyway.
+- **Rate limits on the free LLM tier.** A 429 means the provider wants you to slow down, not a bug — see the [AI Agent project](/docs/projects/ai-agent#handling-rate-limits) for a retry pattern you can copy.
+
+## What you just built
+
+A tool that turns the messy, human reality of commit history into a clean, categorized, *verifiable* changelog — using `git` for exact retrieval and a free-tier LLM for the judgment, with a citation requirement that makes the model's output checkable. It's a genuinely useful maintenance tool: run it before a release, and you get the first draft of the release notes for free. Nothing here is a toy — the same architecture (fetch exact records, synthesize with grounded LLM, verify against source) scales directly to release-note tooling in real projects.
+
+## Where to go from here
+
+- **Use conventional-commit parsing for better grouping.** If a repo follows `feat:`, `fix:`, `docs:` prefixes, parse them yourself to pre-bucket commits before the LLM even sees them — the LLM then only has to phrase, not classify.
+- **Add a file-level signal.** Fetch `git log --stat` (files touched per commit) and include it in the prompt — "docs:" is ambiguous, but "docs: update README in `/examples/`" is not.
+- **Two-pass generation.** First prompt: "drop noise, group the rest into 5-10 clusters." Second prompt: "turn these clusters into a changelog." Often better than one big prompt.
+- **Gate it behind your own git hook** or run it in CI on a tag push, emitting `CHANGELOG.md` automatically — the tool is deterministic given a prompt, so the output is stable.
+
+## Share your project with the class
+
+Built something you're proud of? [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) is a gallery of projects other students have submitted — and its README has a full, beginner-friendly walkthrough for adding yours via a **pull request**, even if you've never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.
+
+You just automated the least-fun part of open-source. 🎓
+
+
diff --git a/docs/projects/index.mdx b/docs/projects/index.mdx
index e22e291..7ce4412 100644
--- a/docs/projects/index.mdx
+++ b/docs/projects/index.mdx
@@ -17,6 +17,12 @@ They're optional and ungraded. Browse them any time — each project's intro say
` inside `sample_repo/`.
+
+### Using a real repo
+
+`cd` into any git repo you have locally (or shallow-clone the course repo) and run the same two commands from inside it:
+
+```bash
+git clone --depth 20 https://github.com/abderrahim-lectures/python-data-analysis-course
+cd python-data-analysis-course
+# copy the example scripts here (or run them with the --project flag)
+uv run --project ../examples/changelog-from-git python ../examples/changelog-from-git/generate.py 20
+```
+
+`--depth 20` gives you enough history to make a changelog meaningful (a `--depth 1` clone has exactly one commit).
+
+`uv` reads `pyproject.toml`/`uv.lock` and creates an isolated environment for this project automatically on first run.
+
+## Running it in GitHub Codespaces
+
+Click the badge above, or go to the [repo's Codespaces page](https://github.com/abderrahim-lectures/python-data-analysis-course), for a ready-to-go cloud dev environment (Node + Python + `uv` preinstalled via [`.devcontainer/devcontainer.json`](../../.devcontainer/devcontainer.json)). Once it's open:
+
+```bash
+cd examples/changelog-from-git
+uv run python make_sample_repo.py
+cd sample_repo
+uv run python ../generate.py 50
+```
+
+(add your API key as a [Codespaces secret](https://docs.github.com/en/codespaces/managing-your-codespaces/managing-encrypted-secrets-for-your-repository-and-organization#adding-secrets-for-a-repository) or `export` it for a one-off session).
+
+## A note on staying current
+
+Model names and APIs in this space change fast. The GitHub Models endpoint and `gpt-4o-mini` free tier used here were both verified working while writing this example, but may have drifted by the time you read it — see the callout in the [lesson](../../docs/projects/changelog-from-git/index.md#step-2-let-the-llm-synthesize-with-citations-required) for what to check before relying on this code.
+
+## Built your own changelog tool?
+
+See [`examples/student-projects/`](../student-projects/) for how to share it with the class via a pull request — no git experience required, it walks through every step.
diff --git a/examples/changelog-from-git/fetch_commits.py b/examples/changelog-from-git/fetch_commits.py
new file mode 100644
index 0000000..fd3782f
--- /dev/null
+++ b/examples/changelog-from-git/fetch_commits.py
@@ -0,0 +1,60 @@
+"""Fetches a repo's recent commit history as structured records.
+
+Run with: uv run python fetch_commits.py [max_commits]
+This prints a summary -- generate.py (Step 2) imports load_commits().
+Run from inside a git repo (or a folder that is one).
+"""
+
+import subprocess
+import sys
+
+
+def _run_git(args: list[str]) -> str:
+ """Runs `git ` in the current directory and returns its stdout."""
+ result = subprocess.run(
+ ["git", *args],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr}")
+ return result.stdout
+
+
+def load_commits(max_commits: int = 50) -> list[dict]:
+ """Returns the last `max_commits` commits, newest first."""
+ raw = _run_git(
+ [
+ "log",
+ f"-{max_commits}",
+ # One record per commit, fields separated by a NUL byte so that
+ # neither subject text nor hashes can accidentally blur together.
+ "--format=%H%x00%an%x00%ad%x00%s%x00%x00",
+ "--date=short",
+ ]
+ )
+ commits = []
+ for block in raw.split("\x00\x00"):
+ block = block.strip("\n")
+ if not block:
+ continue
+ parts = block.split("\x00")
+ if len(parts) < 4:
+ continue
+ commit = {
+ "hash": parts[0],
+ "author": parts[1],
+ "date": parts[2],
+ "subject": parts[3],
+ }
+ commits.append(commit)
+ return commits
+
+
+if __name__ == "__main__":
+ limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50
+ commits = load_commits(limit)
+ print(f"Fetched {len(commits)} commits")
+ for c in commits[:5]:
+ print(f" {c['hash'][:8]} {c['date']} {c['author']}: {c['subject']}")
diff --git a/examples/changelog-from-git/generate.py b/examples/changelog-from-git/generate.py
new file mode 100644
index 0000000..a39dbb3
--- /dev/null
+++ b/examples/changelog-from-git/generate.py
@@ -0,0 +1,55 @@
+"""Turns fetched commits into a categorized, cited changelog.
+
+Run with: uv run python generate.py 50
+(Set your API key in .env first.)
+"""
+
+import os
+import sys
+
+from dotenv import load_dotenv
+from openai import OpenAI
+
+from fetch_commits import load_commits
+
+load_dotenv()
+
+PROMPT_TEMPLATE = """You are writing a release changelog from commit messages.
+Below is a list of recent commits, each tagged with a short hash. Write a
+clean changelog with these rules:
+
+- Group entries into sections: **Added**, **Changed**, **Fixed**.
+- Merge commits that clearly belong to the same change; drop pure noise
+ (merge commits, "wip", formatting-only messages) unless they hint at a real
+ change, in which case include the change.
+- Every entry must end with the commit hash(es) it came from, like "(abc1234)".
+- Do NOT invent commits, features, or fixes. If a message is too vague to
+ classify, put it in a final "Other / unclear" section rather than guessing.
+
+Commits (hash: subject):
+{commits}
+
+Changelog:
+"""
+
+
+def build_prompt(commits: list[dict]) -> str:
+ lines = [f"{c['hash'][:8]}: {c['subject']}" for c in commits]
+ return PROMPT_TEMPLATE.format(commits="\n".join(lines))
+
+
+def generate_changelog(commits: list[dict]) -> str:
+ client = OpenAI(
+ api_key=os.environ["GITHUB_TOKEN"],
+ base_url="https://models.github.ai/inference",
+ )
+ response = client.chat.completions.create(
+ model="gpt-4o-mini", # confirm this still has a free tier before running
+ messages=[{"role": "user", "content": build_prompt(commits)}],
+ )
+ return response.choices[0].message.content
+
+
+if __name__ == "__main__":
+ limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50
+ print(generate_changelog(load_commits(limit)))
diff --git a/examples/changelog-from-git/make_sample_repo.py b/examples/changelog-from-git/make_sample_repo.py
new file mode 100644
index 0000000..321f184
--- /dev/null
+++ b/examples/changelog-from-git/make_sample_repo.py
@@ -0,0 +1,66 @@
+"""Creates a small sample git repo with realistically messy commit history,
+so the changelog tool has something to work with out of the box.
+
+Run with: uv run python make_sample_repo.py
+Creates ./sample_repo with ~15 commits (mix of features, fixes, docs, and
+noise like merge commits and "wip"), then prints the fetched commit list.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+REPO = Path("sample_repo")
+
+STEPS = [
+ ("feat: add user login with email verification", "app.py", "def login():\n pass\n"),
+ ("wip", "app.py", "def login():\n return email\n"),
+ ("fix: handle empty email in login", "app.py", "def login(email):\n return email.strip() or None\n"),
+ ("add README", "README.md", "# Sample App\n"),
+ ("feat: password reset flow", "auth.py", "def reset_password():\n pass\n"),
+ ("Merge branch 'feature/reset'", "auth.py", "def reset_password():\n return True\n"),
+ ("docs: clarify setup steps in README", "README.md", "# Sample App\n\n## Setup\n1. Install\n"),
+ ("fix: reset token expiry", "auth.py", "from datetime import timedelta\nTOKEN_TTL = timedelta(hours=1)\n"),
+ ("refactor utils", "utils.py", "def normalize(s):\n return s\n"),
+ ("feat: dark mode toggle", "theme.py", "def toggle_theme():\n pass\n"),
+ ("tweak", "theme.py", "def toggle_theme():\n return 'dark'\n"),
+ ("fix: flash on toggle", "theme.py", "def toggle_theme():\n from time import sleep\n sleep(0)\n return 'dark'\n"),
+ ("feat: export CSV report", "reports.py", "def export_csv():\n return []\n"),
+ ("bump version to 1.2.0", "pyproject.toml", "version = \"1.2.0\"\n"),
+]
+
+
+def make_repo() -> None:
+ if REPO.exists():
+ import shutil
+
+ shutil.rmtree(REPO)
+ REPO.mkdir()
+ os.chdir(REPO)
+
+ def git(*args: str) -> None:
+ subprocess.run(["git", *args], check=True, capture_output=True)
+
+ git("init", "-q")
+ git("config", "user.email", "student@example.com")
+ git("config", "user.name", "Student")
+
+ for subject, filename, content in STEPS:
+ Path(filename).write_text(content)
+ git("add", "-A")
+ git("commit", "-q", "-m", subject)
+ os.chdir("..")
+ print(f"Created {REPO}/ with {len(STEPS)} commits")
+
+
+if __name__ == "__main__":
+ make_repo()
+ sys.path.insert(0, ".")
+ from fetch_commits import load_commits
+
+ os.chdir(REPO)
+ for c in load_commits(50):
+ print(f" {c['hash'][:8]} {c['date']} {c['author']}: {c['subject']}")
diff --git a/examples/changelog-from-git/notebook.ipynb b/examples/changelog-from-git/notebook.ipynb
new file mode 100644
index 0000000..97830e8
--- /dev/null
+++ b/examples/changelog-from-git/notebook.ipynb
@@ -0,0 +1,227 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Build a Changelog Generator from Git History \u2014 Colab / Kaggle / Binder companion\n",
+ "\n",
+ "This notebook mirrors the local `uv` project from the course's\n",
+ "[Build a Changelog Generator from Git History](https://abderrahim-lectures.github.io/python-data-analysis-course/docs/projects/changelog-from-git)\n",
+ "lesson, adapted to run in a hosted notebook: a small sample repo is created in place with\n",
+ "messy, realistic commit history, and a free-tier LLM turns it into a clean, categorized,\n",
+ "hash-cited changelog.\n",
+ "\n",
+ "See the [lesson](https://abderrahim-lectures.github.io/python-data-analysis-course/docs/projects/changelog-from-git) for the full walkthrough and the\n",
+ "[local example project](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/changelog-from-git) for the real, file-based version of this same code."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 0: Install dependencies"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!pip install openai"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 1: Create a sample git repo\n",
+ "\n",
+ "A hosted notebook needs a real git repo to read. Instead of cloning, build a small one in\n",
+ "place with a handful of intentionally messy commits \u2014 features, fixes, docs, and noise\n",
+ "(\"wip\", \"tweak\", a merge commit) \u2014 so the changelog tool has something realistic to chew on."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import subprocess\n",
+ "from pathlib import Path\n",
+ "\n",
+ "os.makedirs(\"sample_repo\", exist_ok=True)\n",
+ "os.chdir(\"sample_repo\")\n",
+ "\n",
+ "def git(*args):\n",
+ " subprocess.run([\"git\", *args], check=True, capture_output=True)\n",
+ "\n",
+ "git(\"init\", \"-q\")\n",
+ "git(\"config\", \"user.email\", \"student@example.com\")\n",
+ "git(\"config\", \"user.name\", \"Student\")\n",
+ "\n",
+ "STEPS = [\n",
+ " (\"feat: add user login with email verification\", \"app.py\", \"def login():\\n pass\\n\"),\n",
+ " (\"wip\", \"app.py\", \"def login():\\n return email\\n\"),\n",
+ " (\"fix: handle empty email in login\", \"app.py\", \"def login(email):\\n return email.strip() or None\\n\"),\n",
+ " (\"add README\", \"README.md\", \"# Sample App\\n\"),\n",
+ " (\"feat: password reset flow\", \"auth.py\", \"def reset_password():\\n pass\\n\"),\n",
+ " (\"Merge branch 'feature/reset'\", \"auth.py\", \"def reset_password():\\n return True\\n\"),\n",
+ " (\"fix: reset token expiry\", \"auth.py\", \"from datetime import timedelta\\nTOKEN_TTL = timedelta(hours=1)\\n\"),\n",
+ " (\"refactor utils\", \"utils.py\", \"def normalize(s):\\n return s\\n\"),\n",
+ " (\"feat: dark mode toggle\", \"theme.py\", \"def toggle_theme():\\n pass\\n\"),\n",
+ " (\"tweak\", \"theme.py\", \"def toggle_theme():\\n return 'dark'\\n\"),\n",
+ " (\"feat: export CSV report\", \"reports.py\", \"def export_csv():\\n return []\\n\"),\n",
+ " (\"bump version to 1.2.0\", \"pyproject.toml\", 'version = \"1.2.0\"\\n'),\n",
+ "]\n",
+ "\n",
+ "for subject, filename, content in STEPS:\n",
+ " Path(filename).write_text(content)\n",
+ " git(\"add\", \"-A\")\n",
+ " git(\"commit\", \"-q\", \"-m\", subject)\n",
+ "print(f\"Created sample repo with {len(STEPS)} commits\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 2: Fetch the commit stream\n",
+ "\n",
+ "Same as [`fetch_commits.py`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/changelog-from-git/fetch_commits.py):\n",
+ "`git log` with a NUL-separated `--format` so messy subjects can't break parsing."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def _run_git(args: list[str]) -> str:\n",
+ " result = subprocess.run([\"git\", *args], capture_output=True, text=True, check=False)\n",
+ " if result.returncode != 0:\n",
+ " raise RuntimeError(result.stderr)\n",
+ " return result.stdout\n",
+ "\n",
+ "\n",
+ "def load_commits(max_commits: int = 50) -> list[dict]:\n",
+ " raw = _run_git([\n",
+ " \"log\", f\"-{max_commits}\",\n",
+ " \"--format=%H%x00%an%x00%ad%x00%s%x00%x00\",\n",
+ " \"--date=short\",\n",
+ " ])\n",
+ " commits = []\n",
+ " for block in raw.split(\"\\x00\\x00\"):\n",
+ " block = block.strip(\"\\n\")\n",
+ " if not block:\n",
+ " continue\n",
+ " parts = block.split(\"\\x00\")\n",
+ " if len(parts) < 4:\n",
+ " continue\n",
+ " commits.append({\"hash\": parts[0], \"author\": parts[1], \"date\": parts[2], \"subject\": parts[3]})\n",
+ " return commits\n",
+ "\n",
+ "\n",
+ "for c in load_commits(50):\n",
+ " print(f\" {c['hash'][:8]} {c['date']} {c['author']}: {c['subject']}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 3: Get a free-tier LLM API key\n",
+ "\n",
+ "Pick any provider from the table in the [lesson's Setup](https://abderrahim-lectures.github.io/python-data-analysis-course/docs/projects/changelog-from-git#get-a-free-llm-api-key) \u2014\n",
+ "GitHub Models is the suggested default. Paste the key below; `getpass` keeps it out of the\n",
+ "notebook's saved output and cell history."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import getpass\n",
+ "\n",
+ "os.environ[\"GITHUB_TOKEN\"] = getpass.getpass(\"Paste your GitHub Models API key: \")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 4: Generate the changelog\n",
+ "\n",
+ "Same prompt as [`generate.py`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/changelog-from-git/generate.py):\n",
+ "categorized sections, noise-filtering, and a **commit-hash citation per entry** so nothing\n",
+ "can be silently invented."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from openai import OpenAI\n",
+ "\n",
+ "PROMPT_TEMPLATE = \"\"\"You are writing a release changelog from commit messages.\n",
+ "Below is a list of recent commits, each tagged with a short hash. Write a\n",
+ "clean changelog with these rules:\n",
+ "\n",
+ "- Group entries into sections: **Added**, **Changed**, **Fixed**.\n",
+ "- Merge commits that clearly belong to the same change; drop pure noise\n",
+ " (merge commits, \"wip\", formatting-only messages) unless they hint at a real\n",
+ " change, in which case include the change.\n",
+ "- Every entry must end with the commit hash(es) it came from, like \"(abc1234)\".\n",
+ "- Do NOT invent commits, features, or fixes. If a message is too vague to\n",
+ " classify, put it in a final \"Other / unclear\" section rather than guessing.\n",
+ "\n",
+ "Commits (hash: subject):\n",
+ "{commits}\n",
+ "\n",
+ "Changelog:\n",
+ "\"\"\"\n",
+ "\n",
+ "\n",
+ "def build_prompt(commits: list[dict]) -> str:\n",
+ " lines = [f\"{c['hash'][:8]}: {c['subject']}\" for c in commits]\n",
+ " return PROMPT_TEMPLATE.format(commits=\"\\n\".join(lines))\n",
+ "\n",
+ "\n",
+ "client = OpenAI(\n",
+ " api_key=os.environ[\"GITHUB_TOKEN\"],\n",
+ " base_url=\"https://models.github.ai/inference\",\n",
+ ")\n",
+ "response = client.chat.completions.create(\n",
+ " model=\"gpt-4o-mini\", # confirm this still has a free tier before running\n",
+ " messages=[{\"role\": \"user\", \"content\": build_prompt(load_commits(50))}],\n",
+ ")\n",
+ "print(response.choices[0].message.content)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.12"
+ },
+ "colab": {
+ "name": "changelog-from-git-notebook.ipynb",
+ "provenance": []
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
\ No newline at end of file
diff --git a/examples/changelog-from-git/pyproject.toml b/examples/changelog-from-git/pyproject.toml
new file mode 100644
index 0000000..2c65722
--- /dev/null
+++ b/examples/changelog-from-git/pyproject.toml
@@ -0,0 +1,10 @@
+[project]
+name = "changelog-from-git"
+version = "0.1.0"
+description = "Local companion to the course's 'Build a Changelog Generator from Git History' project: fetch commit history with git, synthesize a categorized changelog with a free-tier LLM, and verify every entry against the real commits."
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "python-dotenv>=1.2.2",
+ "openai>=2.8.0",
+]
diff --git a/examples/changelog-from-git/uv.lock b/examples/changelog-from-git/uv.lock
new file mode 100644
index 0000000..03b5068
--- /dev/null
+++ b/examples/changelog-from-git/uv.lock
@@ -0,0 +1,341 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "annotated-types"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.14.2"
+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/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
+name = "changelog-from-git"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "openai" },
+ { name = "python-dotenv" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "openai", specifier = ">=2.8.0" },
+ { name = "python-dotenv", specifier = ">=1.2.2" },
+]
+
+[[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 = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[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.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "jiter"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" },
+ { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" },
+ { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" },
+ { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" },
+ { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" },
+ { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" },
+ { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" },
+ { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" },
+ { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" },
+ { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" },
+ { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" },
+ { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" },
+ { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" },
+ { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" },
+ { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" },
+ { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" },
+ { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" },
+ { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" },
+ { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
+]
+
+[[package]]
+name = "openai"
+version = "2.52.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/5a/c45fa035cd72c70ebe67c6e079e3adf871492382634f69e3dff62c43597d/openai-2.52.0.tar.gz", hash = "sha256:7c736d592f81471ce1f734838390983c4d8c8aecff23dcd36e600a58e5032d9c", size = 1098876, upload-time = "2026-07-31T15:13:03.228Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/ac/ceb40c995df49533ad4dcff6c37f0d85cf14446a212363fc9d2f927e60b4/openai-2.52.0-py3-none-any.whl", hash = "sha256:f97e231d9a8fa69ab55897df1080f02d99913fb0a30e3ee56ea16a1eb6c2d434", size = 1659569, upload-time = "2026-07-31T15:13:01.145Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.70.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
diff --git a/src/data/projects.ts b/src/data/projects.ts
index 1cad01e..a4ac69d 100644
--- a/src/data/projects.ts
+++ b/src/data/projects.ts
@@ -19,6 +19,12 @@ export interface ProjectMeta {
* and src/pages/index.tsx for where those get merged in.
*/
export const PROJECTS: ProjectMeta[] = [
+ {
+ id: 'changelog-from-git',
+ date: '2027-08',
+ url: '/docs/projects/changelog-from-git',
+ tags: ['AI Agents', 'Automation', 'Developer Tools'],
+ },
{
id: '2027-dependency-freshness-checker',
date: '2027-08',
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index c1ed17a..af2282c 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -190,6 +190,24 @@ function RealWorldProjects() {
+
+ Build a Changelog Generator from Git History
+
+ }
+ summary={
+
+ Turn a noisy stream of commit messages into a clean, categorized changelog —
+ with every entry traceable back to the commit hash it came from.
+
+ }
+ />