A weather CLI built as an architecture exercise: fetch data from an API, cache it locally, and render it nicely in the terminal — but built so each piece (data source, cache, presentation, AI) can be swapped without touching the others. Defaults to the Open-Meteo API, which is free and needs no API key, with OpenWeatherMap available as a drop-in alternative.
- 🏗️ Architecture
- ✨ Features
- 🚀 Quick Start
- 💾 Cache
- 🌦️ Weather provider
- 🤖 AI integration (optional)
- 🧪 Testing
- 🤝 Contributing
- 📄 License
This is a ports & adapters (hexagonal) design: three Protocols in
ports.py — WeatherProvider, WeatherCache, AIProvider — describe every
external dependency the app has, and everything outside adapters/ depends
on those Protocols, never on a concrete class. main.py doesn't know if
weather data came from the cache or the network, or which vendor served it.
WeatherRepository doesn't know which HTTP client fetched it. ask doesn't
know whether AI is even configured. Each of those is one decision, made in
exactly one place — a factory function in each adapter package's
__init__.py — so swapping any of them out later is a local change instead
of a rewrite.
weather_cli/
├── main.py # CLI (Typer) — orchestrates, no business logic
├── config.py # the only place that reads environment variables
├── ports.py # WeatherProvider, WeatherCache, AIProvider protocols
├── exceptions.py # domain-specific exception hierarchy
├── domain/
│ └── models.py # WeatherData, Location, WeatherCondition (pydantic)
├── adapters/
│ ├── weather/
│ │ ├── open_meteo.py # Open-Meteo HTTP client (geocoding + forecast)
│ │ ├── open_weather_map.py # OpenWeatherMap HTTP client
│ │ └── __init__.py # build_weather_provider(settings) factory
│ ├── cache/
│ │ ├── sqlite_cache.py # local SQLite cache with TTL
│ │ └── __init__.py # build_cache(settings) factory
│ └── ai/
│ ├── litellm_adapter.py # AIProvider backed by LiteLLM (multi-vendor)
│ ├── null_adapter.py # Null Object used when AI isn't configured
│ └── __init__.py # build_ai_provider(settings) factory
├── services/
│ └── weather_repository.py # orchestrates provider + cache behind one interface
└── presentation/
├── ascii_art.py # ASCII art per weather condition
└── renderer.py # rich-based terminal output
Data flow: main.py → WeatherRepository → (cache or the configured
WeatherProvider) → renderer. WeatherRepository is the one piece that
decides whether to serve a cached value or fetch a new one — everything
upstream of it just calls .get_current() and doesn't care which. Cache
entries are namespaced by provider (f"{provider_name}:{city}"), so
switching WEATHER_PROVIDER can never serve a stale reading cached under a
different vendor.
- Current weather —
weather now <city>, with an ASCII icon per condition. - Local caching — SQLite-backed, TTL-based, so repeated lookups don't hit the API.
- Force refresh —
--refreshbypasses the cache for a single call. - Cache clearing —
weather clear-cachewipes the local cache. - Two weather providers — Open-Meteo (default, no key) or OpenWeatherMap, picked via
WEATHER_PROVIDER. - AI Q&A —
weather ask <city> "<question>", backed by LiteLLM so any supported vendor (Anthropic, OpenAI, OpenRouter, …) works via a single model string. Falls back to a Null Object with a friendly message whenAI_PROVIDER_MODELisn't configured, instead of crashing.
git clone <your-repo-url>
cd weather-cli-2
pip install -e ".[dev]"
weather now "Blumenau"
weather now "São Paulo" --refresh
weather clear-cacheNo .env is required to run this — config.py falls back to sane defaults
for the cache TTL, path, and weather provider. Copy .env.example to .env
if you want to override those, switch providers, or enable AI.
Weather responses are cached locally in SQLite (.cache/weather.db by
default) with a configurable TTL (10 minutes by default). Every response
tells you whether it came from the cache or live from the API. Override
either setting via environment variables (in .env or your shell):
CACHE_TTL_SECONDS=600
CACHE_DB_PATH=.cache/weather.db# "open-meteo" (default, no key needed) or "openweathermap"
WEATHER_PROVIDER=open-meteo
# Required only if WEATHER_PROVIDER=openweathermap
OPENWEATHERMAP_API_KEY=Choosing a provider that requires a key without setting it raises a
ConfigurationError, rendered as a friendly message instead of a crash.
weather ask "Blumenau" "Do I need an umbrella tomorrow?"AI is powered by LiteLLM, which
normalizes calls to Anthropic, OpenAI, OpenRouter, and other providers
behind a single completion() call — so adding a vendor is a config change,
not a new adapter class. Set AI_PROVIDER_MODEL in your .env to a
LiteLLM model string plus the matching API key:
AI_PROVIDER_MODEL=anthropic/claude-sonnet-4-6
ANTHROPIC_API_KEY=Leave AI_PROVIDER_MODEL empty and weather ask returns a clear "AI is not
configured" message instead of crashing — that's NullAIAdapter doing its
job.
pip install -e ".[dev]"
pytest50 tests cover the domain models, both weather adapters (including WMO weather-code mapping and malformed-response handling), the SQLite cache, the repository (cache hit/miss/force-refresh, provider-namespaced keys), the AI adapters (LiteLLM and Null Object), config loading, and end-to-end CLI behavior via Typer's test runner.
Bug reports, questions, and pull requests are welcome — see CONTRIBUTING.md for how to get set up and what to expect.
MIT — see LICENSE.