Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 42 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,17 +118,21 @@ 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.

---

## Two things that make this different

### 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`
Expand Down Expand Up @@ -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
Expand All @@ -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. |
Expand All @@ -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`.

---

Expand All @@ -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
Expand All @@ -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
Expand Down
28 changes: 18 additions & 10 deletions docs/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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** |
Expand Down
11 changes: 8 additions & 3 deletions docs/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -1289,8 +1289,11 @@ export async function getWeather(location: string): Promise<Weather> {
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) {
Expand Down Expand Up @@ -1363,14 +1366,16 @@ 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.

**`await` waits for the network.** The request takes maybe 200ms. `await` means "pause here until the answer arrives." That's why the function is marked `async` — and why calling it needs `await` too. The next section explains what's really going on.

**`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.


Expand Down
28 changes: 17 additions & 11 deletions pyweather/weather.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 9 additions & 7 deletions src/weather.ts
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -49,11 +51,11 @@ export async function getWeather(location: string): Promise<Weather> {
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) {
Expand Down
Loading