Guidance for AI coding agents (Claude Code, Cursor, Aider, etc.) working in this repo. Humans should read README.md first.
wx is a Python CLI that prints weather forecasts and aviation observations to
the terminal. It routes each location to the best regional API instead of using
a single global model.
- Language: Python 3.11+ (project pins
requires-python = ">=3.11") - Dependencies:
requestsonly (runtime);pytestfor tests - Package manager:
uv(lockfile committed) - Entry point:
wx.cli:main→ console scriptwx - Distribution: single source tree, hatchling build backend
wx/
├── __init__.py # exports __version__
├── cli.py # argparse + dispatch; the only place argv touches
├── model.py # canonical dataclasses (Forecast, CurrentConditions, …)
├── units.py # SI ↔ display unit conversions (single source of truth)
├── settings.py # TOML config loader, default values, write template
├── geocode.py # Open-Meteo name → (lat, lon)
├── router.py # (lat, lon) → "nws" | "dwd" | "metno"
├── render.py # forecast renderers: rich | table | plain | json
├── aviation_render.py # METAR/TAF renderers: raw | decoded | json
└── providers/
├── nws.py # api.weather.gov adapter
├── dwd.py # Bright Sky (DWD) adapter
├── metno.py # api.met.no adapter
└── aviationweather.py # aviationweather.gov METAR/TAF adapter
tests/
└── test_aviationweather.py # pytest unit tests with mocked HTTP
test_integration.py # script-style end-to-end with mocked HTTP
pyproject.toml
uv.lock
README.md
AGENT.md # this file
These rules keep the codebase predictable. Follow them when adding features.
Every forecast adapter in wx/providers/ MUST normalize to the canonical types
in wx/model.py:
- temperature → °C
- wind speed → m/s
- pressure → hPa
- precipitation → mm
- visibility → meters
- direction → degrees (0=N, 90=E)
- percentages → 0–100
Display conversion happens only in wx/render.py via helpers in
wx/units.py. Never convert inside an adapter, and never inline a conversion
constant in a renderer — use units.U.hpa_to(...) etc.
Aviation is the exception: METAR/TAF are domain-specific, so
aviationweather.py keeps native aviation units (knots, statute miles, feet,
hPa for altimeter) and aviation_render.py formats them directly. This is
intentional — pilots expect aviation units.
wx/cli.py owns all argparse definitions and all print(...) calls.
Providers and renderers must be pure: they take inputs, return values, raise
on errors. No sys.argv, no print, no sys.exit outside cli.py.
- Adapters raise
requests.RequestExceptionfor transport issues or a custom*Error(e.g.AviationWeatherError,GeocodeError) for semantic failures. cli.pycatches them, printswx: <message>to stderr, returns a non-zero exit code.- Exit codes:
0success,1runtime/data error,2usage error. --debugre-raises so the traceback prints.
Always pass query parameters via requests.get(url, params={...}). Never
interpolate user input into a URL string — even ICAO codes that look safe.
See wx/providers/aviationweather.py:_get_json for the pattern.
If a regional provider fails, cli._fetch_with_fallback retries with met.no.
Don't add provider-specific retry logic inside adapters; raise and let the
fallback handle it. Aviation mode short-circuits this entirely (no fallback —
aviationweather.gov is the only source for METAR/TAF).
settings.load() returns a dict obtained by deep-merging the user's TOML
over DEFAULTS. Never read settings directly from disk elsewhere. When
adding a new setting:
- Add it to
DEFAULTSinsettings.pywith a sensible value - Add it to the
TEMPLATEstring (commented if optional) - Document it in the README's "Settings file" section
{rich, table, plain, json}. If you add a new style, update:
argparsechoices incli.py- the dispatcher dict in
render.render - the README
Aviation mode also respects --style=json; non-json styles use the raw/decoded
distinction instead.
wx/__init__.py:__version__ is the source of truth. pyproject.toml mirrors
it. The example User-Agent in README.md and the TEMPLATE in settings.py
display the major.minor only. Bump all four together.
- Create
wx/providers/<name>.pywith afetch(lat, lon, label, ua, timeout) -> Forecastfunction. - Normalize all units to SI (see invariant #1).
- Add the provider name to
router.Providerliteral and thepick()logic if it should be auto-selected. - Wire it into
cli._fetch_with_fallbackand the--providerargparse choices. - Add a row to the routing table in
README.md. - Add fixtures + tests to
test_integration.py(script-style) or a newtests/test_<name>.py(pytest-style).
- Imports: standard library, then third-party, then local (
from . import …). No wildcard imports. - Types: prefer modern syntax (
X | None, notOptional[X];list[X], notList[X]). The project is 3.11+, sofrom __future__ import annotationsis at the top of every module andtyping.Optionalshould not be imported. - Dataclasses for structured data; plain dicts only for config and raw API payloads.
- Docstrings: module-level docstring summarizing what the file does. Avoid function docstrings unless behavior is non-obvious. Never write Sphinx-style parameter lists.
- Comments: explain why when the what isn't obvious from naming. Don't narrate code.
- Error messages: lowercase, no trailing punctuation, prefixed with
wx:when emitted from the CLI. - Naming: snake_case for functions/variables, PascalCase for classes, SCREAMING_SNAKE for module-level constants.
Two test layers, both with mocked HTTP — there must be no network access in tests.
Location: tests/. Run with:
uv run pytestStyle: one file per module-under-test, test_<feature> functions, fixtures
inline at the top of the file. Use unittest.mock.patch to stub
requests.get. See tests/test_aviationweather.py for the pattern.
test_integration.py exercises the full CLI for every renderer + every
provider + every unit preset with canned HTTP responses. Run with:
uv run python test_integration.pyBoth must pass before commits. When adding a new provider or major feature, extend both layers.
- Happy path: a realistic API response → expected Forecast/output.
- Edge cases: missing fields,
Nonevalues, multi-record responses, unit variants ("F" vs "C", "10+" vs6for visibility). - Error paths: 204, 404, malformed JSON, network failure.
- Renderer regressions: bugs found in code review should get a test that would have caught them (see commit history for examples).
- Adapter normalizes to SI; renderer handles display units
- URLs use
params=dict, not f-string interpolation - No
printorsys.exitoutsidecli.py -
Nonehandled explicitly withis None, not via falsyorchains (especially for numeric fields where0is valid — wind direction 0°, wind speed 0 kt, etc.) - Modern typing (
X | None,list[X]) - Tests cover the new code path
- README updated if the public CLI surface changed
- Version bumped in both
pyproject.tomlandwx/__init__.pyif shipping
- Don't add async. The CLI is synchronous and one-shot; aiohttp adds complexity without benefit.
- Don't add a TUI.
wxis line-oriented, pipe-friendly, and CI-friendly. - Don't introduce a runtime dep without strong justification. Every dep is a portability tax.
- Don't add caching. Users get a fresh forecast on every invocation by
design. If a user wants caching, they can wrap
wxin shell. - Don't add API keys without making them optional and documenting the fallback. Current providers are all keyless on purpose.
- Don't write to disk outside of
settings.py's template creation. - Don't add new files without a clear reason. Prefer editing an existing
module. New features usually fit in
cli.py+ one provider/render module.
# Run from source without installing
uv run wx Denver
uv run wx -mt KORD --decode
# Install as a global tool (isolated venv)
uv tool install .
# Upgrade after editing
uv tool upgrade wx
# Tests
uv run pytest # unit tests
uv run python test_integration.py # end-to-end script
# Inspect config location
uv run wx --config-path- Bump
versioninpyproject.tomland__version__inwx/__init__.py(keep in sync). - Update the example User-Agent in
README.mdandsettings.py:TEMPLATEif major or minor changed. - Update
CHANGELOG.md: add a new## [X.Y.Z] — YYYY-MM-DDsection above the previous release. Group entries under the Keep a Changelog headings (Added,Changed,Deprecated,Removed,Fixed,Security,Internal). Add a matching link reference at the bottom of the file ([X.Y.Z]: https://github.com/aeroperf/wx/releases/tag/vX.Y.Z). - Run
uv run pytestanduv run python test_integration.py. - Update README if any user-facing flag/behavior changed.
- Commit with a clear message describing the change.
- Push.
- User-visible behavior changes (new flags, removed flags, changed defaults).
- Bug fixes that affect output or correctness.
- Architectural changes worth noting under
Internal(new modules, typing migrations, dropped dependencies).
What doesn't: refactors with no behavior change, test-only changes,
formatting tweaks, doc typos. If you're unsure, err on the side of
including it under Internal rather than leaving it out.