diff --git a/docs/projects/blog-post-generator/_category_.json b/docs/projects/blog-post-generator/_category_.json
new file mode 100644
index 0000000..95fda03
--- /dev/null
+++ b/docs/projects/blog-post-generator/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Outline→Blog Post Generator",
+ "position": 18
+}
diff --git a/docs/projects/blog-post-generator/index.md b/docs/projects/blog-post-generator/index.md
new file mode 100644
index 0000000..b001ba5
--- /dev/null
+++ b/docs/projects/blog-post-generator/index.md
@@ -0,0 +1,357 @@
+---
+id: blog-post-generator
+title: "Build an Outline→Blog Post Generator"
+sidebar_label: "Build an Outline→Blog Post Generator"
+slug: /projects/blog-post-generator
+description: "Graduate from the in-browser playground to real Python: build a CLI tool that turns a rough bullet-point outline into a polished blog post draft with a free-tier LLM — you own the structure and ideas, the model does the writing."
+---
+
+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 an Outline→Blog Post Generator
+
+
+
+
+
+The hardest part of writing anything long isn't usually the typing — it's deciding what goes where. Once you have a solid outline, the actual prose is a mechanical (if tedious) job of turning each bullet into real sentences. This project builds a CLI tool that does exactly that: it reads a rough Markdown outline from a file, hands it to a free-tier language model with a carefully designed "expand this outline into prose" prompt, and prints back a polished blog post draft. You stay in charge of the structure, the ideas, and the voice; the model handles the grunt work of writing it out.
+
+This assumes Python 101 and nothing from Data Analysis. It's optional and ungraded; see [Real-World Projects](/docs/projects) for the full, growing list.
+
+## 🎯 What you'll do
+
+1. Install `uv`, get a free-tier LLM API key, and set up a small project — all in one place, before any building starts.
+2. Load a Markdown outline from a real file with `pathlib`, so the tool works on outlines you already have.
+3. Design a system prompt that turns a general-purpose LLM into a disciplined outline-expander: faithful to your structure, no invented sections.
+4. Send the outline to the model and print back a complete, well-structured blog post draft.
+5. Run the whole tool against a sample outline, then edit the result — and make a small change to your outline to see how the output tracks it.
+
+## Where to run this
+
+**Locally with `uv`** is the primary, recommended path here — the tool's whole job is reading *your own* outlines from files on your machine, so it works best where those files actually live.
+
+**GitHub Codespaces** works fine too: 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), and run the same `uv` commands from a terminal in your browser tab. There's even a sample outline bundled in the example folder to try it on immediately.
+
+**Google Colab, Kaggle Notebooks, and Binder** work for trying the idea out — nothing here needs a GPU. The notebook version of this project asks for your API key interactively with `getpass` and uses a bundled sample outline fetched from the repo. Use it to see the tool work end to end with zero setup; switch to local `uv` once you want it pointed at your real outlines:
+
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fblog-post-generator%2Fnotebook.ipynb)
+
+**opencode** *(optional)* — a free, open-source AI coding agent that runs in your terminal. If you'd rather have an agent write and run this project for you than type the code yourself, install it with `curl -fsSL https://opencode.ai/install | bash` (or `npm install -g opencode-ai`) and point it at this repo with the same API key from Setup below. It's optional — this project's whole point is building it yourself, so treat it as a bonus, not a shortcut.
+
+## Setup
+
+Everything you need before you write a line of the generator itself: a real Python, a free API key, and a small project to hold both.
+
+### 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
+```
+
+### Set up the project
+
+```bash
+uv init blog-post-generator
+cd blog-post-generator
+uv add openai python-dotenv
+```
+
+`openai`'s client library works here for every provider in the table below, not just OpenAI itself — GitHub Models, Gemini, Groq, Mistral, Cerebras, and OpenRouter all expose an OpenAI-compatible chat endpoint, so one client, pointed at a different `base_url`, is all this project needs. `python-dotenv` lets you keep your API key in a local `.env` file instead of `export`-ing it every session.
+
+### Get a free LLM API key
+
+**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. The fuller example in the course repo ([`examples/blog-post-generator/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/blog-post-generator)) supports all six out of the box, selected with one setting.
+
+| 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; also exposes an OpenAI-compatible endpoint, used below. |
+| 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.** Create a `.env` file in your project folder instead (never commit this):
+
+```bash
+# .env
+LLM_PROVIDER=github
+GITHUB_TOKEN=your-key-here
+```
+
+An API key is a secret, exactly like a password — anyone with it can use your account's quota. Treating it as an environment variable rather than a hardcoded string is the standard practice for exactly this reason.
+
+:::tip[A .env file is often more convenient than export]
+Instead of `export`-ing a key in every new terminal session, `python-dotenv` reads a `.env` file in your project folder into `os.environ` automatically, the first time your script runs — see `load_dotenv()` in Step 3 below.
+:::
+
+**✅ Checklist**
+
+
+`uv --version` prints a version number.
+`blog-post-generator/` exists with a `pyproject.toml`, and `openai` and `python-dotenv` are installed.
+You have a real API key from one provider, saved in a `.env` file in your project folder — not pasted into any script.
+
+
+## Step 1: Read the outline from a file
+
+`pathlib.Path` is Python's modern way of handling file paths, and `.read_text()` turns a whole file into one string. The tool's input is a Markdown outline — a nested list of headings and bullets that captures your structure without any prose. The simplest, most honest design takes one file on the command line:
+
+```python
+# generate.py
+import argparse
+from pathlib import Path
+
+
+def load_outline(path: str | Path) -> str:
+ """Reads a Markdown outline file and returns its contents as a single string."""
+ return Path(path).read_text(encoding="utf-8")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Turn a rough Markdown outline into a polished blog post draft."
+ )
+ parser.add_argument("outline", help="Path to your outline as a .md or .txt file.")
+ parser.add_argument("--provider", help="Override LLM_PROVIDER for this run, e.g. 'groq'.")
+ return parser.parse_args()
+
+
+if __name__ == "__main__":
+ args = parse_args()
+ outline = load_outline(args.outline)
+ print(f"Loaded outline: {len(outline)} characters")
+```
+
+`encoding="utf-8"` matters more than it might seem — without it, Python falls back to a platform-dependent default encoding, and the same script can silently misread accented characters on Windows versus macOS/Linux. Being explicit about the encoding is the reliable choice for text that may contain them (which outlines of any real topic usually do).
+
+:::tip[An outline is a spec for your prose]
+The quality of the generated draft is bounded by the quality of the outline. A terse outline ("Intro", "Main point", "Conclusion") gives the model nothing to work with and produces a shallow, generic post; a specific one ("Why Python's list comprehensions beat for-loops for this task — with the two examples from last week") gives it real material. This project rewards the same skill the course has been building all along: being specific about what you mean.
+:::
+
+**✅ Checklist**
+
+
+You can run `uv run python generate.py my_outline.md` and it prints the outline's character count.
+You have a real Markdown outline you'd actually write about, saved as a file — not just the bundled sample.
+Running the script with a typo'd file path raises a clear `FileNotFoundError`, not a confusing error from somewhere deeper.
+
+
+**🤔 Socratic Question(s)**
+
+- Why read the outline from a file rather than hardcoding it as a string in the script? What does that choice buy you beyond "the script is shorter"?
+- The script reads a file but does nothing with its contents yet. What's the value of building and testing this input-handling step before the LLM part exists, instead of writing the whole thing at once?
+
+## Step 2: Design the outline-expansion system prompt
+
+A language model told only "write a blog post about this outline" will cheerfully invent new sections, pad with filler, and drift far from what you actually wanted. The system prompt below is what turns a general-purpose chat model into a disciplined expander — faithful to your structure, and honest about not inventing material:
+
+```python
+SYSTEM_PROMPT = """\
+You are an experienced, clear-writing blog post editor who expands outlines
+into prose.
+
+You will be given a Markdown outline. Expand it into a complete, well-
+structured blog post draft. Follow these rules:
+
+- Faithfulness: Cover every section and bullet in the outline, in the order
+ given. NEVER invent sections, claims, or examples that are not in the
+ outline. If a bullet is a question or a placeholder ("TODO", "need an
+ example here"), write it as an honest rough passage and mark it with a
+ bracketed note like [expand: find a concrete example], rather than
+ inventing something to fill it.
+- Structure: Preserve the outline's headings (##, ###) as your section
+ headings. Add an engaging intro paragraph after the title, and a short
+ conclusion, IF the outline calls for them -- but do not add sections the
+ outline doesn't imply.
+- Prose: Write in clear, conversational but professional prose. Expand each
+ bullet into one or more paragraphs. Do not pad with fluff, repetition, or
+ generic filler sentences.
+- Voice: Write in the first person, in a confident but plain voice, as if
+ the outline's author were writing it.
+
+Output ONLY the draft. No preamble, no "here is your draft", no commentary.
+"""
+```
+
+Three deliberate design choices worth noticing:
+
+- **The faithfulness rule is stated first and absolutely** ("NEVER invent sections, claims, or examples that are not in the outline"), because a draft that adds made-up material isn't a bad draft — it's actively misleading about what you believe.
+- **The placeholder handling** ("[expand: find a concrete example]") gives the model an honest escape hatch instead of forcing it to fabricate: when your outline has a hole, you get a clearly-marked hole in the draft, not a made-up fact to catch later.
+- **"Output ONLY the draft"** keeps the result clean — no "here is your draft" wrapper, no editorializing, just the prose you asked for, ready to edit.
+
+:::tip[The prompt is a spec you will iterate on]
+Treat this system prompt as a first draft, not a finished spec. Run it against an outline you care about, then read the output critically: if it padded, tighten the "no filler" instruction; if it invented an example, strengthen the faithfulness rule. Prompt engineering for a focused task like this is closer to writing a very precise spec than "asking nicely."
+:::
+
+**✅ Checklist**
+
+
+You can explain, in your own words, why the prompt tells the model to write a bracketed placeholder instead of inventing content for a "TODO" bullet.
+The prompt specifies the output's structure (headings preserved, no wrapper text), not just "write a blog post".
+
+
+**🤔 Socratic Question(s)**
+
+- If you removed the "NEVER invent sections" instruction, what kind of mistake would you expect the model to start making on an outline that's missing an obvious section a typical blog post would have?
+- The prompt asks for first-person, plain prose "as if the outline's author were writing it." How does that single instruction change what the draft feels like compared to a generic third-person "this article will explain..." style?
+
+## Step 3: Call the LLM and print the draft
+
+Wire the file-reading from Step 1 and the system prompt from Step 2 together into one working tool:
+
+```python
+# generate.py (continued -- add these imports and functions)
+import os
+
+from dotenv import load_dotenv
+from openai import OpenAI
+
+load_dotenv() # reads .env into the environment, if present
+
+MAX_OUTLINE_CHARS = 12_000 # see the "overlong outlines" pitfall below
+
+
+def truncate(outline: str, max_chars: int = MAX_OUTLINE_CHARS) -> str:
+ """Cuts an oversized outline down to a size that fits a free-tier context window."""
+ if len(outline) <= max_chars:
+ return outline
+ return outline[:max_chars] + f"\n\n... [outline truncated -- {len(outline) - max_chars} more characters not shown] ..."
+
+
+def generate(outline: str, provider: str | None = None) -> str:
+ """Sends an outline to a free-tier LLM and returns the expanded blog post draft."""
+ 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": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": f"Here is my outline:\n\n```markdown\n{truncate(outline)}\n```"},
+ ],
+ )
+ return response.choices[0].message.content
+
+
+if __name__ == "__main__":
+ args = parse_args()
+ outline = load_outline(args.outline)
+ print(f"Loaded outline: {len(outline)} characters\n")
+ print("Generating your draft...\n")
+ print(generate(outline))
+```
+
+`truncate` matters more here than it might first appear — see the pitfalls section below for why a very long outline isn't just slow, it can silently fail or get a shallow result. Wrapping the outline in a fenced ` ```markdown ` code block in the user message, rather than pasting it in raw, is a small but real signal to the model about what kind of text it's looking at.
+
+Run it against the bundled sample outline (or your own):
+
+```bash
+uv run python generate.py sample_outline.md
+```
+
+:::tip[Using a different provider?]
+Swap the `OpenAI(...)` block for a different `base_url` and key — e.g. `base_url="https://api.groq.com/openai/v1"` with `api_key=os.environ["GROQ_API_KEY"]` for Groq, or `base_url="https://generativelanguage.googleapis.com/v1beta/openai/"` with `api_key=os.environ["GOOGLE_API_KEY"]` for Gemini's OpenAI-compatible endpoint. Everything else in this file stays the same. See [`examples/blog-post-generator/generate.py`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/blog-post-generator/generate.py) in the course repo for all six wired up side by side, selectable with one environment variable.
+:::
+
+**✅ Checklist**
+
+
+`uv run python generate.py sample_outline.md` prints a complete draft with your outline's headings preserved.
+Every section and bullet from the outline appears in the draft — and no new sections were invented.
+The draft has no wrapper text (no "here is your draft" preamble).
+
+
+**🤔 Socratic Question(s)**
+
+- The user message fenced the outline in a `markdown` code block. What would likely go wrong if the outline were pasted in as raw text with no code fence?
+- If you ran this tool twice on the exact same outline, would you expect identical drafts? What does that tell you about treating the output as a final post versus a starting point you edit in your own voice?
+
+## Step 4: Iterate — edit the draft, then change the outline and regenerate
+
+The tool becomes genuinely useful the moment you treat it as a *draft engine in a loop you control*, not a one-shot text factory. Two ways to work with it, both worth trying:
+
+**1. Generate, then edit by hand.** Produce a draft and edit it in your editor — tighten sentences, fix anything the model got slightly wrong about your topic, add your own examples. The generated prose is the scaffolding; your edit is the actual writing. Save the result as a new file:
+
+```bash
+uv run python generate.py my_outline.md > draft.md
+# open draft.md in your editor, edit it, save
+```
+
+**2. Change the outline, regenerate, and watch the draft track it.** This is the iteration that shows you the division of labor. Move one bullet to a different section, delete a section, or make one bullet more specific, then rerun:
+
+```bash
+# edit my_outline.md, then:
+uv run python generate.py my_outline.md
+```
+
+The new draft should reflect your changes — the section moves, the deleted one disappears, the sharpened bullet produces sharper prose. When it *doesn't* track a change you made, that's a signal your prompt's faithfulness rule needs strengthening (Step 2), not a reason to shrug.
+
+**✅ Checklist**
+
+
+You edited a generated draft by hand and saved the result — the model's draft is now visibly *your* writing, not its output verbatim.
+You made at least one structural change to an outline and confirmed the regenerated draft tracked it (section moved, removed, or sharpened).
+You can name a place where the tool genuinely helped you and a place where it clearly needed your judgment — the honest assessment this project is really teaching.
+
+
+**🤔 Socratic Question(s)**
+
+- If the regenerated draft *doesn't* reflect a change you made to the outline, is that more likely a prompt problem, a model limitation, or a sign you're using the tool wrong? How would you diagnose it?
+- The lesson claims the quality of the output is bounded by the quality of the outline. Where in this project did you see that claim play out in practice?
+
+## ⚠️ Common pitfalls
+
+- **The model inventing sections or examples.** The faithfulness rule reduces this but doesn't eliminate it — a language model with a partial outline will sometimes "helpfully" fill in an obvious section you deliberately omitted. Always read the draft against your outline and strike anything you didn't write. The bracketed-placeholder instruction exists precisely because inventing is the failure mode that most damages trust in this tool.
+- **Overlong outlines blowing past the context window or free-tier token quota.** A very detailed outline (or one where you pasted in full notes under each bullet) can exceed what the model can attend to, or simply exceed your free tier's per-request token limit. `truncate` in Step 3 caps this, but truncation means a partial expansion — for genuinely long material, split it into two generation runs and stitch the drafts together.
+- **The draft being in "AI voice" — overpolished, hedged, or filler-heavy.** This is the most common reason generated drafts are recognizable as generated. The "no padding" and "first person, plain voice" instructions help; your own editing helps more. If every draft comes out the same bland tone regardless of what you wrote, tighten the voice instruction in the prompt.
+- **Treating the output as publish-ready.** A model expanding your outline has no idea about facts, references, or your real opinions — it writes fluently from the outline alone. Publishing a generated draft unedited means publishing a draft that *you* didn't really write and may not even agree with. Edit it until it's yours, then it's a post.
+- **Blurry outlines producing generic posts.** Feed a model "Intro / Body / Conclusion" and you get a generic blog post. Feed it a specific outline and you get a draft worth editing. If the output feels hollow, the fix is almost always more specificity in the outline, not a bigger model.
+
+## What you just built
+
+A real, working outline-expansion CLI: it reads a Markdown outline from a file, hands it to a free-tier LLM guided by a system prompt engineered specifically for disciplined expansion — faithful to your structure, honest about holes, no invented sections — and prints back a complete draft. You control the ideas and the structure; the model does the mechanical work of turning bullets into prose. It's a tool with a deliberately narrow, honest job, and that's exactly what makes it useful.
+
+:::tip[Run a fuller version without any local setup]
+[`examples/blog-post-generator/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/blog-post-generator) in the course repo is a fuller version of the code above, with all six providers from the table wired up side by side (selected with one `LLM_PROVIDER` setting), a bundled sample outline, and the `--provider` option already included. Clone it, or open the whole repo in a [GitHub Codespace](https://codespaces.new/abderrahim-lectures/python-data-analysis-course), and run it from there.
+:::
+
+## Where to go from here
+
+- Add a `--output` flag that writes the draft to a file, so you can open it in your editor immediately instead of copying from the terminal (or use shell redirection as in Step 4).
+- Accept an outline from **stdin** with a `--stdin` flag, so you can pipe an outline from another tool: `cat outline.md | uv run python generate.py --stdin`.
+- Add a **second pass** that takes your first draft *and* your specific critique of it, and rewrites just the passages you flagged — an iterative refinement loop that models how you'd actually edit a draft by hand.
+- Generate a draft **section by section** instead of in one pass, so each section gets its own focused generation (and you can regenerate one section without rewriting the whole post).
+
+## 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.
+
+Welcome to writing Python outside the browser. 🎓
+
+
diff --git a/docs/projects/index.mdx b/docs/projects/index.mdx
index e22e291..97cbce3 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
draft.md`), then when you change the outline, regenerate and watch the draft track your structural changes. If a change to the outline *doesn't* show up in the next draft, that's a signal your prompt's faithfulness rule needs strengthening -- not a reason to ignore it.
+
+## Running it in GitHub Codespaces
+
+Click into a [Codespace for the whole repo](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python, and `uv` are preinstalled) -- the bundled sample outline is already there, so you can run every command above immediately, no setup beyond copying `.env.example` to `.env`.
+
+## Try it with zero setup: `notebook.ipynb`
+
+[`notebook.ipynb`](./notebook.ipynb) in this folder is a runnable notebook version of this same tool, for Colab, Kaggle, or Binder:
+
+
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fblog-post-generator%2Fnotebook.ipynb)
+
+A notebook environment has no local files, which is this tool's whole premise -- so rather than pretending that gap doesn't exist, the notebook fetches the bundled `sample_outline.md` straight from the course repo and asks for your API key interactively with `getpass`. Every other part of the tool -- the `pathlib` file reading, the system prompt, the LLM call, the structured output -- runs unmodified. It's a fast way to see the whole thing work end to end before setting it up locally; once you want to run it against your real outlines, come back to `uv run python generate.py` above or a Codespace.
+
+## A note on staying current
+
+Model names and provider free-tier terms change fast -- the model IDs and endpoints in `generate.py`'s `PROVIDERS` dict were verified against a live run while writing this example, but check each provider's own docs before relying on them, since they may have drifted by the time you read this.
+
+## Built your own version?
+
+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/blog-post-generator/generate.py b/examples/blog-post-generator/generate.py
new file mode 100644
index 0000000..6854d6e
--- /dev/null
+++ b/examples/blog-post-generator/generate.py
@@ -0,0 +1,158 @@
+"""Outline→Blog Post Generator -- a CLI that turns a rough Markdown outline
+into a polished blog post draft using a free-tier LLM.
+
+See docs/projects/blog-post-generator/index.md for the walkthrough this
+file accompanies.
+
+You're free to use whichever free-tier provider you like -- this isn't
+locked to any one of them. Set LLM_PROVIDER in a .env file (copy
+.env.example) or a real environment variable to pick one; see PROVIDERS
+below for the full list and which API key each one needs. Defaults to
+"github" (GitHub Models) since it's free with no separate signup, tied to
+a GitHub account every student here already has.
+
+Never hardcode a real API key here or commit one to the repo.
+
+Usage:
+ uv run python generate.py sample_outline.md
+ uv run python generate.py my_outline.md --provider groq
+"""
+
+import argparse
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+from openai import OpenAI
+
+load_dotenv() # reads a local .env file, if present; real env vars always win
+
+# Outlines longer than this get truncated before being sent to the model --
+# see the "overlong outlines" pitfall in the lesson for why this matters:
+# free-tier context windows and per-request token quotas are both limited.
+MAX_OUTLINE_CHARS = 12_000
+
+SYSTEM_PROMPT = """\
+You are an experienced, clear-writing blog post editor who expands outlines
+into prose.
+
+You will be given a Markdown outline. Expand it into a complete, well-
+structured blog post draft. Follow these rules:
+
+- Faithfulness: Cover every section and bullet in the outline, in the order
+ given. NEVER invent sections, claims, or examples that are not in the
+ outline. If a bullet is a question or a placeholder ("TODO", "need an
+ example here"), write it as an honest rough passage and mark it with a
+ bracketed note like [expand: find a concrete example], rather than
+ inventing something to fill it.
+- Structure: Preserve the outline's headings (##, ###) as your section
+ headings. Add an engaging intro paragraph after the title, and a short
+ conclusion, IF the outline calls for them -- but do not add sections the
+ outline doesn't imply.
+- Prose: Write in clear, conversational but professional prose. Expand each
+ bullet into one or more paragraphs. Do not pad with fluff, repetition, or
+ generic filler sentences.
+- Voice: Write in the first person, in a confident but plain voice, as if
+ the outline's author were writing it.
+
+Output ONLY the draft. No preamble, no "here is your draft", no commentary.
+"""
+
+
+def load_outline(path: str | Path) -> str:
+ """Reads a Markdown outline file and returns its contents as a single string."""
+ return Path(path).read_text(encoding="utf-8")
+
+
+def truncate(outline: str, max_chars: int = MAX_OUTLINE_CHARS) -> str:
+ """Cuts an oversized outline down to a size that fits a free-tier context window.
+
+ Keeps the front of the outline (usually the most structure-dense part)
+ and appends a clear marker so the model -- and you -- know the draft was
+ based on a partial view, rather than silently trimming.
+ """
+ if len(outline) <= max_chars:
+ return outline
+ return outline[:max_chars] + f"\n\n... [outline truncated -- {len(outline) - max_chars} more characters not shown] ..."
+
+
+def _build_github_client() -> OpenAI:
+ return OpenAI(api_key=os.environ["GITHUB_TOKEN"], base_url="https://models.github.ai/inference")
+
+
+def _build_gemini_client() -> OpenAI:
+ # Gemini exposes an OpenAI-compatible endpoint, so the same openai client
+ # works here too, just with a different base_url and key.
+ return OpenAI(
+ api_key=os.environ["GOOGLE_API_KEY"],
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
+ )
+
+
+def _build_groq_client() -> OpenAI:
+ return OpenAI(api_key=os.environ["GROQ_API_KEY"], base_url="https://api.groq.com/openai/v1")
+
+
+def _build_mistral_client() -> OpenAI:
+ return OpenAI(api_key=os.environ["MISTRAL_API_KEY"], base_url="https://api.mistral.ai/v1")
+
+
+def _build_cerebras_client() -> OpenAI:
+ return OpenAI(api_key=os.environ["CEREBRAS_API_KEY"], base_url="https://api.cerebras.ai/v1")
+
+
+def _build_openrouter_client() -> OpenAI:
+ return OpenAI(api_key=os.environ["OPENROUTER_API_KEY"], base_url="https://openrouter.ai/api/v1")
+
+
+# Every provider here is free-tier at the time of writing, with no credit
+# card required -- but check the provider's own pricing page before relying
+# on that, since free tiers change. Each tuple is (client builder, model ID).
+PROVIDERS = {
+ "github": (_build_github_client, "gpt-4o-mini"),
+ "gemini": (_build_gemini_client, "gemini-3.5-flash"),
+ "groq": (_build_groq_client, "llama-3.3-70b-versatile"),
+ "mistral": (_build_mistral_client, "mistral-small-latest"),
+ "cerebras": (_build_cerebras_client, "llama-3.3-70b"),
+ "openrouter": (_build_openrouter_client, "meta-llama/llama-3.3-70b-instruct:free"),
+}
+
+
+def generate(outline: str, provider: str | None = None) -> str:
+ """Sends an outline to a free-tier LLM and returns the expanded blog post draft."""
+ provider = provider or os.environ.get("LLM_PROVIDER", "github")
+ if provider not in PROVIDERS:
+ raise ValueError(f"Unknown LLM_PROVIDER '{provider}'. Choose one of: {', '.join(PROVIDERS)}")
+ build_client, model = PROVIDERS[provider]
+ client = build_client()
+
+ response = client.chat.completions.create(
+ model=model,
+ messages=[
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": f"Here is my outline:\n\n```markdown\n{truncate(outline)}\n```"},
+ ],
+ )
+ return response.choices[0].message.content
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Turn a rough Markdown outline into a polished blog post draft."
+ )
+ parser.add_argument("outline", help="Path to your outline as a .md or .txt file.")
+ parser.add_argument("--provider", help="Override LLM_PROVIDER for this run, e.g. 'groq'.")
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ outline = load_outline(args.outline)
+
+ print(f"Loaded outline: {len(outline)} characters\n")
+ print("Generating your draft...\n")
+ print(generate(outline, provider=args.provider))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/blog-post-generator/notebook.ipynb b/examples/blog-post-generator/notebook.ipynb
new file mode 100644
index 0000000..0fd9059
--- /dev/null
+++ b/examples/blog-post-generator/notebook.ipynb
@@ -0,0 +1,238 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Outline→Blog Post Generator -- notebook demo\n",
+ "\n",
+ "This notebook is a runnable demo of the **Outline→Blog Post Generator** project from the course: [`docs/projects/blog-post-generator/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/blog-post-generator), companion to the fuller local CLI at [`examples/blog-post-generator/generate.py`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/blog-post-generator/generate.py).\n",
+ "\n",
+ "It reads a rough Markdown outline, hands it to a free-tier LLM with a faithfulness-focused expansion prompt, and prints back a polished blog post draft."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## A note on running this in a notebook\n",
+ "\n",
+ "The real version of this tool (`examples/blog-post-generator/generate.py`) reads **your own outlines** from files on disk -- that's the whole point of the tool. Colab, Kaggle, and Binder don't have your files.\n",
+ "\n",
+ "So **this demo adapts the tool**: it fetches the bundled sample outline (`sample_outline.md`) straight from the course repo. That runs every piece of the tool (the file reading, the system prompt, the LLM call, the structured output) honestly -- it's just not pointed at your real outline. **Locally, or in a GitHub Codespace, you'd point it at your own files instead** -- see the [project walkthrough](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/blog-post-generator) for that path."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "metadata": {},
+ "source": [
+ "!pip install -q openai"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Get the sample outline\n",
+ "\n",
+ "Fetch the bundled `sample_outline.md` straight from the course repo -- a substantive outline about building a habit-tracking heatmap, with a real thesis, a story arc, and a deliberate `TODO` bullet to see how the model handles honest placeholders."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "metadata": {},
+ "source": [
+ "import urllib.request\n",
+ "\n",
+ "url = \"https://raw.githubusercontent.com/abderrahim-lectures/python-data-analysis-course/main/examples/blog-post-generator/sample_outline.md\"\n",
+ "outline = urllib.request.urlopen(url).read().decode(\"utf-8\")\n",
+ "print(f\"Loaded sample outline: {len(outline)} characters\")\n",
+ "print(outline)"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## The expansion system prompt\n",
+ "\n",
+ "This is the exact `SYSTEM_PROMPT` from `generate.py` -- it's what turns a general-purpose chat model into a disciplined outline-expander: cover every section in order, never invent sections or examples, and mark outline holes with honest `[expand: ...]` notes instead of fabricating."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "metadata": {},
+ "source": [
+ "SYSTEM_PROMPT = \"\"\"\\\n",
+ "You are an experienced, clear-writing blog post editor who expands outlines\n",
+ "into prose.\n",
+ "\n",
+ "You will be given a Markdown outline. Expand it into a complete, well-\n",
+ "structured blog post draft. Follow these rules:\n",
+ "\n",
+ "- Faithfulness: Cover every section and bullet in the outline, in the order\n",
+ " given. NEVER invent sections, claims, or examples that are not in the\n",
+ " outline. If a bullet is a question or a placeholder (\"TODO\", \"need an\n",
+ " example here\"), write it as an honest rough passage and mark it with a\n",
+ " bracketed note like [expand: find a concrete example], rather than\n",
+ " inventing something to fill it.\n",
+ "- Structure: Preserve the outline's headings (##, ###) as your section\n",
+ " headings. Add an engaging intro paragraph after the title, and a short\n",
+ " conclusion, IF the outline calls for them -- but do not add sections the\n",
+ " outline doesn't imply.\n",
+ "- Prose: Write in clear, conversational but professional prose. Expand each\n",
+ " bullet into one or more paragraphs. Do not pad with fluff, repetition, or\n",
+ " generic filler sentences.\n",
+ "- Voice: Write in the first person, in a confident but plain voice, as if\n",
+ " the outline's author were writing it.\n",
+ "\n",
+ "Output ONLY the draft. No preamble, no \"here is your draft\", no commentary.\n",
+ "\"\"\""
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Get a free-tier API key\n",
+ "\n",
+ "This demo defaults to **GitHub Models** -- free, no separate signup, just a personal access token with the `models: read` scope from [github.com/settings/tokens](https://github.com/settings/tokens). Any of the other five providers wired up in `generate.py` (Gemini, Groq, Mistral, Cerebras, OpenRouter) work too -- see that file's `PROVIDERS` dict for their base URLs and env var names, and adjust `LLM_PROVIDER` below.\n",
+ "\n",
+ "The key is entered with `getpass` so it never gets typed into a visible cell or saved into this notebook's output -- never hardcode a real API key here."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "metadata": {},
+ "source": [
+ "import os\n",
+ "from getpass import getpass\n",
+ "\n",
+ "LLM_PROVIDER = \"github\" # change to gemini / groq / mistral / cerebras / openrouter if you prefer\n",
+ "os.environ[\"GITHUB_TOKEN\"] = getpass(\"Enter your free-tier GitHub Models token (GITHUB_TOKEN): \")"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## The generation logic itself\n",
+ "\n",
+ "This mirrors `truncate`, `PROVIDERS`, and `generate` from `generate.py` directly -- the same truncation cap, the same free-tier providers (all exposed through the `openai` client, just pointed at each provider's own OpenAI-compatible endpoint), and the same call shape."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "metadata": {},
+ "source": [
+ "from openai import OpenAI\n",
+ "\n",
+ "MAX_OUTLINE_CHARS = 12_000\n",
+ "\n",
+ "\n",
+ "def truncate(outline: str, max_chars: int = MAX_OUTLINE_CHARS) -> str:\n",
+ " \"\"\"Cuts an oversized outline down to a size that fits a free-tier context window.\"\"\"\n",
+ " if len(outline) <= max_chars:\n",
+ " return outline\n",
+ " return outline[:max_chars] + f\"\\n\\n... [outline truncated -- {len(outline) - max_chars} more characters not shown] ...\"\n",
+ "\n",
+ "\n",
+ "def _build_github_client() -> OpenAI:\n",
+ " return OpenAI(api_key=os.environ[\"GITHUB_TOKEN\"], base_url=\"https://models.github.ai/inference\")\n",
+ "\n",
+ "\n",
+ "def _build_gemini_client() -> OpenAI:\n",
+ " return OpenAI(\n",
+ " api_key=os.environ[\"GOOGLE_API_KEY\"],\n",
+ " base_url=\"https://generativelanguage.googleapis.com/v1beta/openai/\",\n",
+ " )\n",
+ "\n",
+ "\n",
+ "def _build_groq_client() -> OpenAI:\n",
+ " return OpenAI(api_key=os.environ[\"GROQ_API_KEY\"], base_url=\"https://api.groq.com/openai/v1\")\n",
+ "\n",
+ "\n",
+ "def _build_mistral_client() -> OpenAI:\n",
+ " return OpenAI(api_key=os.environ[\"MISTRAL_API_KEY\"], base_url=\"https://api.mistral.ai/v1\")\n",
+ "\n",
+ "\n",
+ "def _build_cerebras_client() -> OpenAI:\n",
+ " return OpenAI(api_key=os.environ[\"CEREBRAS_API_KEY\"], base_url=\"https://api.cerebras.ai/v1\")\n",
+ "\n",
+ "\n",
+ "def _build_openrouter_client() -> OpenAI:\n",
+ " return OpenAI(api_key=os.environ[\"OPENROUTER_API_KEY\"], base_url=\"https://openrouter.ai/api/v1\")\n",
+ "\n",
+ "\n",
+ "PROVIDERS = {\n",
+ " \"github\": (_build_github_client, \"gpt-4o-mini\"),\n",
+ " \"gemini\": (_build_gemini_client, \"gemini-3.5-flash\"),\n",
+ " \"groq\": (_build_groq_client, \"llama-3.3-70b-versatile\"),\n",
+ " \"mistral\": (_build_mistral_client, \"mistral-small-latest\"),\n",
+ " \"cerebras\": (_build_cerebras_client, \"llama-3.3-70b\"),\n",
+ " \"openrouter\": (_build_openrouter_client, \"meta-llama/llama-3.3-70b-instruct:free\"),\n",
+ "}\n",
+ "\n",
+ "\n",
+ "def generate(outline: str, provider: str = LLM_PROVIDER) -> str:\n",
+ " \"\"\"Sends an outline to a free-tier LLM and returns the expanded blog post draft.\"\"\"\n",
+ " if provider not in PROVIDERS:\n",
+ " raise ValueError(f\"Unknown provider '{provider}'. Choose one of: {', '.join(PROVIDERS)}\")\n",
+ " build_client, model = PROVIDERS[provider]\n",
+ " client = build_client()\n",
+ "\n",
+ " response = client.chat.completions.create(\n",
+ " model=model,\n",
+ " messages=[\n",
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
+ " {\"role\": \"user\", \"content\": f\"Here is my outline:\\n\\n```markdown\\n{truncate(outline)}\\n```\"},\n",
+ " ],\n",
+ " )\n",
+ " return response.choices[0].message.content"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Run the generation\n",
+ "\n",
+ "Expanding the bundled sample outline above into a full draft. Read the result critically: does it cover every section? Did it invent anything? Did it handle the `TODO` bullet honestly? Then edit it until it's *yours* -- that editing step is the actual writing this tool is a starting point for."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "metadata": {},
+ "source": [
+ "print(f\"Expanding a {len(outline)}-char outline into a blog post draft...\\n\")\n",
+ "draft = generate(outline)\n",
+ "print(draft)"
+ ],
+ "execution_count": null,
+ "outputs": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
\ No newline at end of file
diff --git a/examples/blog-post-generator/pyproject.toml b/examples/blog-post-generator/pyproject.toml
new file mode 100644
index 0000000..0170cd8
--- /dev/null
+++ b/examples/blog-post-generator/pyproject.toml
@@ -0,0 +1,10 @@
+[project]
+name = "blog-post-generator"
+version = "0.1.0"
+description = "A CLI that turns a rough Markdown outline into a polished blog post draft with a free-tier LLM, the fuller companion to the course's Outline→Blog Post Generator project."
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "python-dotenv>=1.2.2",
+ "openai>=2.8.0",
+]
diff --git a/examples/blog-post-generator/sample_outline.md b/examples/blog-post-generator/sample_outline.md
new file mode 100644
index 0000000..38a5242
--- /dev/null
+++ b/examples/blog-post-generator/sample_outline.md
@@ -0,0 +1,31 @@
+# Why I Started Tracking My Habits as a Calendar Heatmap
+
+## Intro
+- Hook: I kept quitting habits within two weeks and never really knew why.
+- Thesis: seeing streaks on a heatmap changed how I stick with things.
+
+## What I tried before
+- Habit apps with reminders -- they nag, I ignore them.
+- Paper checklists -- fine until I lost the notebook.
+- Conclusion: the missing piece was a visible, long-term record.
+
+## The heatmap idea
+- GitHub's contribution graph as a model.
+- One square per day, green = did the thing.
+- Streak becomes a pattern you don't want to break.
+
+## How I built it (the fun part)
+- A tiny script logs a check-in per day to a JSON/CSV file.
+- matplotlib renders a year of squares.
+- TODO: add the exact pandas code snippet here.
+- Lesson: the build was simpler than I expected.
+
+## What changed
+- Two months of visible squares is its own motivation.
+- The graph showed me *when* I usually slip (weekends).
+- Honest note: some squares are fake -- I logged days I didn't really do it.
+
+## Where to go next
+- Auto-detect missed days instead of trusting my self-reports.
+- Add a second habit and compare the two graphs.
+- Open question: does public accountability (sharing it) help or hurt?
diff --git a/examples/blog-post-generator/uv.lock b/examples/blog-post-generator/uv.lock
new file mode 100644
index 0000000..74d7896
--- /dev/null
+++ b/examples/blog-post-generator/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 = "blog-post-generator"
+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 = "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 = "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..4c8cee4 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: 'blog-post-generator',
+ date: '2027-08',
+ url: '/docs/projects/blog-post-generator',
+ tags: ['AI Agents', 'Writing', 'Automation'],
+ },
{
id: '2027-dependency-freshness-checker',
date: '2027-08',
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index c1ed17a..f9976c0 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -190,6 +190,24 @@ function RealWorldProjects() {
+
+ Build an Outline→Blog Post Generator
+
+ }
+ summary={
+
+ Turn a rough bullet-point outline into a polished blog post draft with a free-tier
+ LLM — you own the structure and ideas, the model does the writing.
+
+ }
+ />