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: + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb) +[![Open in Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](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: + + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb) +[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](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. + + } + />