diff --git a/README.md b/README.md index 9591386..7c05cbc 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ together. That is the argument made physical rather than asserted. ## Quick start You need **Node.js 20.6 or newer** — 20.6 is when `--env-file` arrived, and -every script here uses it. Check with `node --version`. +every script that needs a key uses it to read your `.env`. Check with +`node --version`. ```bash git clone https://github.com/rdtiv/tirocine.git @@ -117,8 +118,12 @@ uv run weather # needs WEATHER_API_KEY, but makes no Claude call uv run dev # the same first Claude call, in Python ``` -Run those from the **repo root**, not from inside `pyweather/` — that is where -`usage.csv` lives, and where both languages expect to find it. +`usage.csv` lives at the **repo root**, next to `package.json`, and both builds +write to that one file. npm gets there for free, because it runs its scripts +from the project root; the Python side does not lean on that — `pyweather/` +resolves both the ledger and `.env` from its own location on disk, so `uv run` +finds them from whichever directory inside the repo you happened to be standing +in. --- @@ -126,8 +131,8 @@ Run those from the **repo root**, not from inside `pyweather/` — that is where ### You can see what you spend -An API key gets you no dashboard. So every call in every script appends a row to -`usage.csv` — fifteen columns you can open in Excel and add up: +An API key gets you no dashboard. So every call that spends tokens appends a row +to `usage.csv` — fifteen columns you can open in Excel and add up: - **when and what** — `timestamp`, `run_id`, `script`, `model`, `message_id` - **what went in** — `input_tokens`, `cache_read`, `cache_write` @@ -161,11 +166,13 @@ file it teaches ever disagree, CI fails. It checks six things. Every Markdown file in the repo is structurally sound — fences balanced, links resolving. The two languages' command lists stay in sync — every npm script and every Python entry point in `pyproject.toml` names -the other, with the one documented exception below. Then, once per document -that has companion code: the document's code typechecks (including the -earlier version of any file built in stages), no edit instruction tells you to -make a change already present, every finished listing matches the real file -exactly, and nothing in the source tree is left unexplained. +the other, apart from the exceptions documented below, and every entry point +resolves to a module that really defines the function it names. Then, once +per document that has companion code: the document's code typechecks +(including the earlier version of any file built in stages), no edit +instruction tells you to make a change already present, every finished listing +matches the real file exactly, and nothing in the source tree is left +unexplained. Document 2 is held to that standard by `tsc`, document 3 by `pyright`. Adding a second language meant adding a row to a table in `scripts/check-docs.ts`, not a @@ -187,7 +194,7 @@ listing pointing at the wrong endpoint. | `npm run truncate` | `src/truncate.ts` | 5 | `max_tokens: 30` cuts the answer off mid-sentence. `stop_reason` is how you find out. | | `npm run bench` | `src/bench.ts` | 6 | Haiku vs Sonnet vs Opus on three tasks of rising difficulty. Time, cost, and quality side by side. | | `npm run usage` | `src/usage-report.ts` | 6 | Reads `usage.csv` and totals it. No API key needed. | -| `npm run weather` | `src/weather-test.ts` | 7 | `fetch`, `await`, and `response.ok`. No AI in this one at all. | +| `npm run weather` | `src/weather-test.ts` | 7 | `fetch`, `await`, `response.ok`, a time limit you set yourself, and two types where you'd expect one. No AI in this one at all. | | `npm run parse` | `src/parse-request.ts` | 8 | Structured output. Stop parsing prose out of model replies. | | `npm run agent` | `src/agent.ts` | 9 | Tools. The model requests; **your code executes**. The loop, hand-written. | | `npm run assistant` | `src/assistant.ts` | 9 | The finished project — a chat loop with a tool loop inside it. | @@ -208,9 +215,15 @@ Document 3 gives every lesson script above a Python counterpart under `pyweather/`, run the same way with `uv run` instead of `npm run` — `uv run agent`, `uv run parse`, and so on. The names match on purpose, with one exception: `assistant:streaming` becomes `assistant-streaming`, because a -colon isn't legal in a Python entry-point name. `typecheck:py` is document -3's own correctness gate, not a per-script counterpart, and `verify:docs` -isn't mirrored at all — it already checks both trees. +colon isn't legal in a Python entry-point name. Three names have no `uv run` +counterpart at all — `typecheck`, `typecheck:py` and `verify:docs` — because +they are gates rather than lessons. You reach the Python typecheck through +`npm run typecheck:py`, and `verify:docs` already checks both trees at once. + +The four helpers have counterparts too — `pyweather/text.py`, `config.py`, +`usage.py`, `weather.py` — plus one with no TypeScript equivalent: +`pyweather/__init__.py`, which loads `.env` once for the whole package where +the scripts that need a key pass `--env-file`. --- @@ -221,12 +234,20 @@ costs in TypeScript, because it is the same call. | Script | Approximate | |---|---| -| `weather`, `typecheck`, `typecheck:py`, `usage` | Free — no Claude call | -| `dev`, `truncate`, `stream`, `parse`, `models` | A fraction of a cent each | +| `typecheck`, `typecheck:py`, `verify:docs`, `usage` | Free, and no key needed at all | +| `weather` | Free — needs `WEATHER_API_KEY`, but makes no Claude call | +| `models` | Free — needs `ANTHROPIC_API_KEY`, but only lists models; it spends no tokens | +| `dev`, `truncate`, `stream`, `parse` | A fraction of a cent each | +| `agent`, `injection` | A fraction of a cent — a short tool loop, a few calls | | `bench` | About 2¢ — nine calls across three models, most of it Opus | | `chat`, `assistant`, `assistant:streaming` | Pennies per session | -Every call is logged to `usage.csv`, so you never have to guess. +`agent`, `injection`, `assistant`, and `assistant:streaming` look up live +weather, so they need **both** keys. Everything else that calls Claude needs +only `ANTHROPIC_API_KEY`. + +Every call that spends tokens is logged to `usage.csv`, so you never have to +guess. **Set a spend limit on your Anthropic account anyway.** A loop with a mistake in it can call the API thousands of times a minute, and you will write one, because @@ -242,7 +263,10 @@ secrets. If you change a file in `src/`, change the matching code block in `docs/typescript.md` too; likewise `pyweather/` and `docs/python.md`. -`verify:docs` will tell you if you forget, and it names the exact line. +`verify:docs` will tell you if you forget, and it names the exact line — with +one blind spot worth knowing: it strips comments from both sides before it +compares, so a teaching comment can drift out of step with its listing while +every gate still passes. Those you have to keep in sync yourself. Changing one language is usually a reason to look at the other. The two builds are meant to stay the same program, and `pyweather/usage.py` in particular diff --git a/docs/python.md b/docs/python.md index d49f822..9885624 100644 --- a/docs/python.md +++ b/docs/python.md @@ -148,8 +148,11 @@ with one difference worth knowing: plain `uv sync` may *update* the lockfile if `pyproject.toml` has changed, whereas `npm ci` refuses. The strict equivalent is `uv sync --locked`, which is what CI runs. -> **Run every command from the repository root**, not from inside `pyweather/`. -> That is where `usage.csv` lives and where `npm run` puts you automatically. +> **These work from any directory inside the repo.** `npm run` always puts you +> at the project root; `uv run` runs wherever you are standing. So `pyweather/` +> resolves both `usage.csv` and `.env` from its own location on disk instead — +> you'll write that line yourself in Part 6, and it is why the directory +> doesn't matter here. ### The file TypeScript doesn't have @@ -1206,14 +1209,17 @@ def get_weather(location: str) -> Weather: # params={...} handles the percent-encoding for you, the way # URLSearchParams does in the TypeScript version. # - # httpx ships two defaults that fetch() in src/weather.ts does not: a - # 5-second timeout, and no automatic following of redirects. Both are - # arguably SAFER defaults than fetch's "wait forever, follow anything" — - # but this tutorial's whole point is that the two languages run the same - # program, so this is one of the few places that claim needed help. - # follow_redirects=True matches fetch's behavior; the explicit (longer) - # timeout replaces httpx's silent 5-second one so a slow response fails - # the same way for both readers instead of surprising only this one. + # httpx and fetch() in src/weather.ts disagree about two defaults: how long + # to wait, and whether to follow redirects. httpx gives up after 5 seconds + # and follows nothing; fetch gives up after 300 (undici's default) and + # follows redirects. The timeouts differ in kind as well as size — + # timeout=10.0 gives each operation 10 seconds (connect, read, write, + # pool), where AbortSignal.timeout(10_000) is one deadline for the whole + # call. This tutorial's whole point is that the two languages run the same + # program, so both say 10s out loud: follow_redirects=True matches fetch, + # and the explicit timeout replaces httpx's silent 5-second one so a slow + # response fails the same way for both readers instead of surprising only + # this one. try: response = httpx.get( "https://api.weatherapi.com/v1/current.json", @@ -1274,6 +1280,8 @@ Now the comparison this Part exists for. Read it against `src/weather.ts`: | Make an HTTP request | `fetch(url)` | `httpx.get(url)` | | Safe URL encoding | `URLSearchParams` | `params={...}` | | Check before trusting | `if (!response.ok)` | `if not response.is_success` | +| Time limit on the request | `AbortSignal.timeout(10_000)` — one deadline for the whole call; undici defaults to 300s | `timeout=10.0` — 10s *per operation*; httpx defaults to 5s | +| Follow redirects | automatic in `fetch` | `follow_redirects=True` — httpx defaults to off | | Raise a failure | `throw new Error(...)` | `raise RuntimeError(...)` | | Read JSON | `await response.json()` | `response.json()` | | Trust the JSON's shape | `data as WeatherApiResponse` | `.model_validate(data)` — **a real check** | diff --git a/docs/typescript.md b/docs/typescript.md index 13312b9..cfe7ed0 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -1289,8 +1289,11 @@ export async function getWeather(location: string): Promise { const params = new URLSearchParams({ key: apiKey, q: location }); const url = `https://api.weatherapi.com/v1/current.json?${params}`; - // fetch has NO timeout by default — a server that accepts the connection - // and then goes quiet hangs this call forever. Say the limit out loud. + // fetch's default timeout is not one you'd want to inherit: undici, the HTTP + // engine behind it, gives up after 300 seconds. httpx (the Python build) + // gives up after 5. Both builds say 10s out loud, so the number comes from + // the program rather than from whichever engine is underneath — and a server + // that goes quiet doesn't take Part 9's tool loop down with it. const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); if (!response.ok) { @@ -1363,7 +1366,7 @@ Add that line to the end of `src/weather-test.ts` and run `npm run weather` agai ### Read what you just wrote -Four ideas in that file, and all four transfer to every API you'll ever call: +Five ideas in that file, and all five transfer to every API you'll ever call: **`fetch` makes the HTTP request.** Same thing `curl.exe` did, from inside your program. @@ -1371,6 +1374,8 @@ Four ideas in that file, and all four transfer to every API you'll ever call: **`response.ok` is a check you cannot skip.** If the API returns a 401 or a 404, `fetch` does *not* throw. It hands you a response object with a bad status and moves on. Skipping this check is how you end up with `undefined` errors three functions away from the actual problem. +**`fetch`'s timeout is not one you'd want to inherit.** There is one: undici, the HTTP engine behind Node's `fetch`, gives up after 300 seconds. But five minutes is not a limit, it's an outage — and the number appears nowhere in the `fetch` documentation you'd think to read. Python's `httpx` gives up after 5 seconds, sixty times sooner. Neither default is wrong exactly; they just disagree, and a server that accepts your connection and then goes quiet is the case that finds out. `AbortSignal.timeout(10_000)` is you saying the limit out loud, so the number comes from your program instead of from whichever engine happens to be underneath. From Part 9 onward, a request that hangs hangs your tool loop with it. + **The two interfaces are doing different jobs.** `WeatherApiResponse` describes what the *service* sends — their shape, their naming, their `feelslike_f`. `Weather` is what *your* program uses. Keeping them separate means the day you switch weather providers, you change one file and nothing else breaks. That's not beginner over-engineering; it's the reason the next section is easy. diff --git a/pyweather/weather.py b/pyweather/weather.py index d1e2ba3..bf13234 100644 --- a/pyweather/weather.py +++ b/pyweather/weather.py @@ -1,17 +1,20 @@ """Part 7 — The same call in Python. No AI in this file at all. -Four ideas in here, and all four transfer to every API you'll ever call: +Five ideas in here, and all five transfer to every API you'll ever call: 1. `httpx.get` makes the HTTP request — same thing curl.exe did, from code. 2. There is no `await`. This is the one real difference from TypeScript. 3. The status check is one you cannot skip. httpx does NOT raise on a 401 or 404; it hands you a response with a bad status and moves on. (Same as fetch. Same as almost every HTTP client.) - 4. The two shapes do different jobs. WeatherApiResponse describes what the + 4. The time limit is yours to set. httpx does have a default — 5 seconds, + applied to each operation separately — but the two languages disagree + about the number, so say it out loud rather than inherit either one. + 5. The two shapes do different jobs. WeatherApiResponse describes what the SERVICE sends. Weather is what YOUR program uses. Keeping them separate means switching providers changes one file. -On the fourth point, compare src/weather.ts: it uses two `interface` +On the fifth point, compare src/weather.ts: it uses two `interface` declarations, which vanish at compile time. Here they are pydantic models, which exist at runtime and actually validate the JSON. That is a real difference in kind, not just syntax — TypeScript's `as WeatherApiResponse` is @@ -77,14 +80,17 @@ def get_weather(location: str) -> Weather: # params={...} handles the percent-encoding for you, the way # URLSearchParams does in the TypeScript version. # - # httpx ships two defaults that fetch() in src/weather.ts does not: a - # 5-second timeout, and no automatic following of redirects. Both are - # arguably SAFER defaults than fetch's "wait forever, follow anything" — - # but this tutorial's whole point is that the two languages run the same - # program, so this is one of the few places that claim needed help. - # follow_redirects=True matches fetch's behavior; the explicit (longer) - # timeout replaces httpx's silent 5-second one so a slow response fails - # the same way for both readers instead of surprising only this one. + # httpx and fetch() in src/weather.ts disagree about two defaults: how long + # to wait, and whether to follow redirects. httpx gives up after 5 seconds + # and follows nothing; fetch gives up after 300 (undici's default) and + # follows redirects. The timeouts differ in kind as well as size — + # timeout=10.0 gives each operation 10 seconds (connect, read, write, + # pool), where AbortSignal.timeout(10_000) is one deadline for the whole + # call. This tutorial's whole point is that the two languages run the same + # program, so both say 10s out loud: follow_redirects=True matches fetch, + # and the explicit timeout replaces httpx's silent 5-second one so a slow + # response fails the same way for both readers instead of surprising only + # this one. try: response = httpx.get( "https://api.weatherapi.com/v1/current.json", diff --git a/src/weather.ts b/src/weather.ts index 528e486..98225e1 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -1,12 +1,14 @@ // Part 7 — The same call in TypeScript. No AI in this file at all. // -// Four ideas in here, and all four transfer to every API you'll ever call: +// Five ideas in here, and all five transfer to every API you'll ever call: // // 1. `fetch` makes the HTTP request — same thing curl.exe did, from code. // 2. `await` waits for the network. // 3. `response.ok` is a check you cannot skip. fetch does NOT throw on a // 401 or 404; it hands you a response with a bad status and moves on. -// 4. The two interfaces do different jobs. WeatherApiResponse describes what +// 4. The time limit is yours to set. fetch does have a default, but it is +// undici's 300 seconds — say the limit out loud instead of inheriting it. +// 5. The two interfaces do different jobs. WeatherApiResponse describes what // the SERVICE sends. Weather is what YOUR program uses. Keeping them // separate means switching providers changes one file. @@ -49,11 +51,11 @@ export async function getWeather(location: string): Promise { const params = new URLSearchParams({ key: apiKey, q: location }); const url = `https://api.weatherapi.com/v1/current.json?${params}`; - // fetch has NO timeout by default: a server that accepts your connection and - // then says nothing hangs this call forever, and Part 9's tool loop with it. - // httpx (the Python build) ships a 5s default for exactly this reason; both - // builds now say 10s out loud, so neither depends on a default you'd have to - // go and read. + // fetch's default timeout is not one you'd want to inherit: undici, the HTTP + // engine behind it, gives up after 300 seconds. httpx (the Python build) + // gives up after 5. Both builds say 10s out loud, so the number comes from + // the program rather than from whichever engine is underneath — and a server + // that goes quiet doesn't take Part 9's tool loop down with it. const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); if (!response.ok) {