The Actual Weather

Ever wonder what the actual weather is?

I don’t mean the vague, hand-wavy forecast you get from “The Weather Channel,” AccuWeather, WeatherBug, or whichever app currently owns your home screen. I mean the real numbers. Because if you’ve ever compared two weather apps side by side, you’ve noticed something unsettling: they disagree. Sometimes by a degree or two. Sometimes by a lot. Sometimes one confidently reports clear skies while another swears it’s overcast.

Living in the Sacramento region makes this especially important. Temperature alone doesn’t tell the whole story here—cloud cover matters a lot. Some services barely report it. Others report it… creatively. (I’m looking at you, Weather Channel app.) Meanwhile, WeatherBug will perfectly nail cloud cover like it’s reading the sky’s diary.

Naturally, I did what any reasonable software engineer would do: I decided to build my own weather app.

Repository: https://github.com/bbornino/weather_backend

Feature & User Guide: https://bornino.net/projects/weather-app/


The Undertaking

The goal was simple in spirit and chaotic in execution: write a backend service that could eventually live inside an AWS Lambda. No frontend required. Just cold, hard data.

When run end-to-end, the app can take anywhere from 2 to 15 seconds to complete. That’s not inefficiency—that’s the cost of knocking on the doors of multiple free, public weather APIs and waiting for them to answer politely (or not).

This wasn’t meant to be a flashy UI project (…yet). It was about aggregation, normalization, and answering a deceptively simple question:

“So… what’s the weather really doing?”

Why a Backend (and Why a CLI)?

To develop and test the system, I wrote a “small” Python command-line interface. Small in quotes because it’s about 540 lines and absolutely earns its keep.

The CLI let me quickly:

  • Run one or many weather providers
  • Inspect raw and normalized data
  • Debug API quirks without touching a browser

More importantly, it cleanly separated concerns. The CLI touches none of the backend logic—it just feeds inputs and prints outputs. That meant the same core code could later become:

  • An AWS Lambda
  • A Django service
  • A Node.js endpoint

The CLI wasn’t a detour. It was scaffolding.


The First Real Question: Where Does the Data Come From?

Weather data doesn’t fall from the sky. (Ironically.) It comes from APIs.

In a previous life at NameHero, I worked on a Laravel app that queried Zabbix (yes, it exists; no, it’s not relevant here), so I had done this kind of work before. Apparently, the general term for this kind of thing is a web scraper, though in this case, it’s more polite API consumption than scraping.

I narrowed the data sources down to six candidates:

  • AccuWeather
  • National Weather Service (NWS)
  • Open-Meteo
  • WeatherAPI (yes, that’s the actual name)
  • WeatherBit
  • OpenWeather (creativity was clearly optional)

What followed was a tour of wildly different design philosophies.


Open-Meteo: Trial by Fire

The first real scraper I wrote wasn’t the CLI. It wasn’t the core. It was Open-Meteo.

And calling this a “gentle start” would be a lie.

Open-Meteo was hard — not because it was conceptually complex, but because it was fiddly in exactly the wrong ways. There’s no API key, which sounds great on paper, until you realize you pay for that convenience with your sanity.

Every data point you want has to be explicitly named and embedded into the query URL. That meant figuring out:

  • Which field names existed
  • Which ones applied to current vs hourly vs daily data
  • Which ones were defaults
  • And which ones were weird one-offs that only applied in exactly one context

And then there was the naming. Oh, the naming.

Open-Meteo has what feels like three different ways to say “temperature”, depending on where you’re looking and what you’re asking for. Some fields overlap. Some look similar but aren’t interchangeable. A handful apply across multiple endpoints; the rest feel like they were added during a long meeting that ran past lunch.

Because this was my first API, it left a strong impression: that I was about to spend an enormous amount of time building a complex, shared field-mapping layer just to survive six different providers.

In retrospect, that fear turned out to be wildly overstated — but at the time, Open-Meteo made the problem space feel much larger than it actually was.

The one genuine upside? No API key. But honestly, I would much rather deal with generating and storing a key than ever again line up Open-Meteo’s field names by hand.

def get_open_meteo_data(location, units):
    loc = parse_location(location)

    latitude = None
    longitude = None
    if loc["city"] is not None and loc["state"] is not None:
        geocode_location = geocode(loc["city"], loc["state"])

        if geocode_location is None:
            # Graceful exit: we can't proceed without coordinates
            print(f"Could not geocode location: {loc['city']}, {loc['state']}")
            return None
        else:
            latitude = geocode_location.get("latitude")
            longitude = geocode_location.get("longitude")
    else:
        latitude = loc["lat"]
        longitude = loc["lon"]

    open_meteo_query = {
        "current_weather": "true",
        "latitude": latitude,
        "longitude": longitude,
    }

    base_url = OPEN_METEO_BASE_URL + "forecast"

    if units == "imperial":
        open_meteo_query.update(
            {
                "temperature_unit": "fahrenheit",
                "windspeed_unit": "mph",
                "precipitation_unit": "inch",
                "timezone": "America/Los_Angeles",
            }
        )
    else:  # metric
        open_meteo_query.update(
            {
                "temperature_unit": "celsius",
                "windspeed_unit": "kmh",
                "precipitation_unit": "mm",
                "timezone": "America/Los_Angeles",
            }
        )

    # Display and Add the hourly and daily fields requested into the query
    if OPEN_METEO_HOURLY_FIELDS:
        open_meteo_query.update({"hourly": ",".join(OPEN_METEO_HOURLY_FIELDS)})
    if OPEN_METEO_DAILY_FIELDS:
        open_meteo_query.update({"daily": ",".join(OPEN_METEO_DAILY_FIELDS)})

    # This block will get JUST the current weather as a tiny amount of data.
    # Main call does not use the current_weather
    # Left commented out for potential use later.
    # open_meteo_current_weather_query = {
    #     "current_weather": "true",
    #     "latitude": latitude,
    #     "longitude": longitude,
    # }
    # current_weather_response = requests.get(
    #     base_url, params=open_meteo_current_weather_query, timeout=10
    # )

    response = requests.get(base_url, params=open_meteo_query, timeout=10)
    data = response.json()
    current_open_meteo_weather = parse_open_meteo_data(data["current_weather"], units)

    open_meteo_report = WeatherReport()
    open_meteo_report.source = "open_meteo"
    if loc.get("city") and loc.get("state"):
        open_meteo_report.location = f"{loc['city']}, {loc['state']}"
    else:
        open_meteo_report.location = f"{data['latitude']},{data['longitude']}"

    open_meteo_report.latitude = data["latitude"]
    open_meteo_report.longitude = data["longitude"]
    open_meteo_report.fetched_at = datetime.now()
    open_meteo_report.current = current_open_meteo_weather
    open_meteo_report.hourly = None
    open_meteo_report.daily = None 

    return open_meteo_report

National Weather Service: Raw, Federal, and Unapologetic

Then came the NWS.

Yes, it’s free. Yes, it’s provided by the U.S. federal government. And yes, it is absolutely not “your weather.”

The NWS gives you raw tower data. Lots of it. Nearby towers, ordered by distance. Everyone else takes this data, runs their secret sauce over it, and produces something human-friendly, often times with “pin point accuracy” (to the nearest house!).

The API flow goes something like this:

  1. Send a request to get nearby towers
  2. Dig through the response to find URLs
  3. Call one URL for current conditions
  4. Another for hourly forecasts
  5. Another for daily forecasts
  6. Yet another for alerts (which may apply to multiple towers)

It’s powerful, verbose, and deeply unconcerned with developer convenience.

That said, this is also where weather alerts ultimately come from. So while it’s not pretty, it’s authoritative.

def get_nws_data(latitude, longitude, units):
    nws_endpoint_url = (
        NATIONAL_WEATHER_SERVICE_BASE_URL + f"points/{latitude},{longitude}"
    )

    # Given a specific latitutde and longitude, NWS will respond with the URLS to use:
    # "properties": {
    #       "forecast": "https://api.weather.gov/gridpoints/STO/47,69/forecast",
    #       "forecastHourly": "https://api.weather.gov/gridpoints/STO/47,69/forecast/hourly",
    #       "forecastGridData": "https://api.weather.gov/gridpoints/STO/47,69",
    #       "observationStations": "https://api.weather.gov/gridpoints/STO/47,69/stations",
    # Each of these urls will be queried to get the weather
    with urllib.request.urlopen(nws_endpoint_url) as nws_response:
        location_data = json.loads(nws_response.read().decode())

    # save the location_data to a file so that we can easily read the output
    location_file = BASE_DIR / "nws_location_data.json"
    with open(location_file, "w", encoding="utf-8") as json_file:
        json.dump(location_data, json_file, indent=4)

    #####################   Collect DAILY Forecast
    forecast_daily_url = location_data["properties"]["forecast"]
    with urllib.request.urlopen(forecast_daily_url) as forecast_response:
        forecast_daily_data = json.loads(forecast_response.read().decode())

    # save the daily forcast data to a file so that we can easily read the output
    daily_forecast_file = BASE_DIR / "nws_forecast_daily.json"
    with open(daily_forecast_file, "w", encoding="utf-8") as daily_json:
        json.dump(forecast_daily_data, daily_json, indent=4)

    #####################   Collect HOURLY Forecast
    forecast_hourly_url = location_data["properties"]["forecastHourly"]

    with urllib.request.urlopen(forecast_hourly_url) as hourly_response:
        forecast_hourly_data = json.loads(hourly_response.read().decode())

    # save the hourly forcast data to a file so that we can easily read the output
    hourly_forecast_file = BASE_DIR / "nws_forecast_hourly.json"
    with open(hourly_forecast_file, "w", encoding="utf-8") as hourly_json:
        json.dump(forecast_hourly_data, hourly_json, indent=4)

    #####################   Collect CURRENT Observation Stations
    current_observation_stations_url = location_data["properties"][
        "observationStations"
    ]

    with urllib.request.urlopen(current_observation_stations_url) as station_response:
        current_observation_stations_json = json.loads(station_response.read().decode())

    # save the observation stations to a file so that we can easily read the output
    observation_stations_file = BASE_DIR / "nws_current_observation_stations_json.json"
    with open(observation_stations_file, "w", encoding="utf-8") as stations_json:
        json.dump(current_observation_stations_json, stations_json, indent=4)

    #####################   Collect CURRENT [Closest] Observation Station Data
    current_observation_station_url = (
        current_observation_stations_json["features"][0]["id"] + "/observations/latest"
    )

    with urllib.request.urlopen(current_observation_station_url) as response:
        current_observation_data_json = json.loads(response.read().decode())

    # too long: use a shorter name!
    current = current_observation_data_json["properties"]
    current_weather = WeatherData()

    current_weather.temperature = set_temp(
        current["temperature"]["value"], current["temperature"]["unitCode"], units
    )

    

# The rest of the implementation follows the same pattern: call, parse, normalize, repeat — until you finally have something usable.



    current_weather.icon = current["icon"]
    current_weather.timestamp = current["timestamp"]
    current_weather.condition_str = current["textDescription"]

    # save the observation stations to a file so that we can easily read the output
    current_weather_file = BASE_DIR / "nws_current_weather.json"
    with open(current_weather_file, "w", encoding="utf-8") as current_weather_json:
        json.dump(current_observation_data_json, current_weather_json, indent=4)

    #####################   Collect Alerts
    alerts_url = (
        NATIONAL_WEATHER_SERVICE_BASE_URL + f"alerts?point={latitude},{longitude}"
    )
    with urllib.request.urlopen(alerts_url) as response:
        alert_data_json = json.loads(response.read().decode())

    alerts = []
    now = datetime.now(timezone.utc)
    for feature in alert_data_json.get("features", []):
        props = feature["properties"]
        expires_str = props.get("expires")

        if expires_str:
            expires = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
            if expires < now:
                continue  # Skip expired alerts
        alerts.append(
            {
                "event": props["event"],
                "headline": props["headline"],
                "description": props.get("description", ""),
                "instruction": props.get("instruction", ""),
                "severity": props.get("severity"),
                "effective": props.get("effective"),
                "expires": props.get("expires"),
                "area": props.get("areaDesc"),
            }
        )

    # save the observation stations to a file so that we can easily read the output
    alert_file = BASE_DIR / "nws_alert.json"
    with open(alert_file, "w", encoding="utf-8") as alert_json:
        json.dump(alert_data_json, alert_json, indent=4)

    nws_report = WeatherReport()

    nws_report.source = "nws"
    nws_report.latitude = latitude
    nws_report.longitude = longitude
    nws_report.fetched_at = datetime.now()
    nws_report.current = current_weather

    return nws_report

WeatherBit: A Tease

After NWS, WeatherBit felt like a vacation.

You send:

  • Location
  • Units

That’s it.

Three endpoints (current, hourly, daily), clean responses, minimal setup. I was thrilled. Briefly.

Then I discovered the API key was time-limited. After the trial, it’s paid-only.

Given that I already had multiple free alternatives, I left the WeatherBit code in place but unfinished. Think of it as a foundation for anyone who wants to continue… with their credit card.


AccuWeather: Cache Early, Cache Often

AccuWeather was next, and it shared some DNA with NWS.

Before you can get weather data, you have to get a location key. Every request counts. Every request can be billed. Suddenly, caching isn’t an optimization—it’s survival.

So I implemented a small file-based cache:

  • Look up the location
  • If it exists, reuse the key
  • If not, fetch it, store it, and move on

Once you have the key, you can hit the current, hourly, and daily endpoints.

And then came the bad news: AccuWeather is never free. After 30 days, you pay. Even for basics.

Once again, the code remains, quietly waiting for someone braver—or richer—than me.

# accuweather_scraper.py
from accuweather.accuweather_client import AccuWeatherClient

def get_accuweather_data(city_state):
    client = AccuWeatherClient()
    location_key = client.get_location_key(city_state)

    current_conditions_data = client.get_current_conditions(location_key)
    # hourly_forecast_data = client.get_hourly_forecast(location_key)
    # daily_forecast_data = client.get_daily_forecast(location_key)

    # with open("accuweather_current_conditions_data.json", "w", encoding="utf-8") as f:
    #     json.dump(current_conditions_data, f, indent=4)

    # with open("accuweather_hourly_forecast_data.json", "w", encoding="utf-8") as f:
    #     json.dump(hourly_forecast_data, f, indent=4)

    # with open("accuweather_daily_forecast_data.json", "w", encoding="utf-8") as f:
    #     json.dump(current_conditions_data, f, indent=4)
    return current_conditions_data
# accuweather_client.py
# ---------------------
# A Python client for interacting with the AccuWeather API.
# Provides methods to:
#   - Fetch and cache location keys by friendly name or lat/lon
#   - Retrieve current conditions
#   - Retrieve hourly (up to 12-hour) forecasts
#   - Retrieve daily (up to 5-day) forecasts
# Cache is persisted in a JSON file to minimize API calls and stay within free-tier limits.

import os
import json
import requests
from dotenv import load_dotenv

# Load environment variables from .env immediately
load_dotenv()


class AccuWeatherClient:
    def __init__(
        self,
        cache_file="accuweather_location_cache.json",
        api_key=None,
        timeout=(5, 10),
    ):
        """
        Initialize AccuWeatherClient
        :param cache_file: Path to JSON file storing cached location keys
        :param api_key: AccuWeather API key
        :param timeout: Tuple of (connect_timeout, read_timeout)
        """
        self.API_KEY = api_key or os.getenv("ACCUWEATHER_API_KEY")
        self.cache_file = cache_file
        self.timeout = timeout

        # Load existing cache if exists, else start empty
        if os.path.exists(self.cache_file):
            with open(self.cache_file, "r", encoding="utf-8") as f:
                try:
                    self.cached_keys = json.load(f)
                except json.JSONDecodeError:
                    self.cached_keys = []
        else:
            self.cached_keys = []

    def get_location_key(self, friendly_name=None, lat=None, lon=None):
        """
        Return the AccuWeather location key for the given friendly name or lat/lon
        """
        # Try to find in cache
        cached_key = self.find_cached_key(friendly_name, lat, lon)
        if cached_key:
            return cached_key

        # Not found in cache → call API
        if lat is not None and lon is not None:
            # Geoposition search
            url = "https://dataservice.accuweather.com/locations/v1/cities/geoposition/search"
            params = {"apikey": self.API_KEY, "q": f"{lat},{lon}"}
            data = self._get_json_or_raise(url, params)
            location_key = data["Key"]
            # Store the lat/lon from response (use the returned coordinates, more accurate)
            lat_resp = data["GeoPosition"]["Latitude"]
            lon_resp = data["GeoPosition"]["Longitude"]

        elif friendly_name:
            # City name search
            url = "https://dataservice.accuweather.com/locations/v1/cities/search"
            params = {"apikey": self.API_KEY, "q": friendly_name}
            data_list = self._get_json_or_raise(url, params)
            if not data_list:
                raise RuntimeError(f"No results returned for city '{friendly_name}'")
            data = data_list[0]

            location_key = data["Key"]
            lat_resp = data["GeoPosition"]["Latitude"]
            lon_resp = data["GeoPosition"]["Longitude"]

        else:
            raise ValueError("Must provide either friendly_name or lat/lon")

        # Save to cache
        self.cached_keys.append(
            {
                "friendly_name": friendly_name,
                "key": location_key,
                "lat": lat_resp,
                "lon": lon_resp,
            }
        )
        self._save_cache()

        return location_key

    def _get_json_or_raise(self, url, params):
        """
        Wrapper for requests.get that raises RuntimeError with API message
        if the key is expired or any other error occurs.
        """
        try:
            resp = requests.get(url, params=params, timeout=self.timeout)
            # First, try to parse JSON regardless of status code
            try:
                data = resp.json()
            except Exception:
                data = {}

            # Handle API-specific 403 with expired key
            if resp.status_code == 403:
                detail = data.get("detail") or data.get("title") or "Forbidden"
                raise RuntimeError(f"AccuWeather API Key problem: {detail}")

            # Raise for any other HTTP errors
            resp.raise_for_status()
            return data

        except requests.RequestException as e:
            raise RuntimeError(f"Request failed: {e}")

    def find_cached_key(self, friendly_name=None, lat=None, lon=None):
        """
        Search the cache for a matching friendly_name or lat/lon.
        Uses small tolerance for float comparison.
        """
        for entry in self.cached_keys:
            # Match by friendly_name first
            if friendly_name and entry.get("friendly_name") == friendly_name:
                return entry["key"]
            # Match by lat/lon if provided
            if lat is not None and lon is not None:
                if (
                    abs(entry["lat"] - lat) < 0.0001
                    and abs(entry["lon"] - lon) < 0.0001
                ):
                    return entry["key"]
        return None

    def get_api_key(self):
        return self.API_KEY

    def _save_cache(self):
        """Private method to persist cache to disk"""
        print("AccuWeatherClint wrote to file")
        with open(self.cache_file, "w", encoding="utf-8") as f:
            json.dump(self.cached_keys, f, indent=2)

    def get_current_conditions(self, location_key):
        """
        Fetch current conditions for a given location key.
        Returns the JSON response from AccuWeather.
        """
        url = f"https://dataservice.accuweather.com/currentconditions/v1/{location_key}"
        params = {"apikey": self.API_KEY}
        try:
            resp = requests.get(url, params=params, timeout=self.timeout)
            resp.raise_for_status()
            return resp.json()
        except requests.RequestException as e:
            raise RuntimeError(f"Failed to fetch current conditions: {e}")

    def get_hourly_forecast(self, location_key, hours=12):
        """
        Fetch hourly forecast for the next 'hours' hours (free tier: 12 hours max)
        Returns a list of hourly forecast dicts.
        """
        if hours > 12:
            hours = 12  # free tier limit
        url = f"https://dataservice.accuweather.com/forecasts/v1/hourly/{hours}hour/{location_key}"
        params = {"apikey": self.API_KEY}

        try:
            resp = requests.get(url, params=params, timeout=self.timeout)
            resp.raise_for_status()
            return resp.json()  # list of hourly forecast dicts
        except requests.RequestException as e:
            raise RuntimeError(f"Failed to fetch hourly forecast: {e}")

    def get_daily_forecast(self, location_key, days=5):
        """
        Fetch daily forecast for the next 'days' days (free tier: 5 days max)
        Returns the DailyForecasts array from the API response.
        """
        if days > 5:
            days = 5  # free tier limit
        url = f"https://dataservice.accuweather.com/forecasts/v1/daily/{days}day/{location_key}"
        params = {"apikey": self.API_KEY}

        try:
            resp = requests.get(url, params=params, timeout=self.timeout)
            resp.raise_for_status()
            data = resp.json()
            return data.get("DailyForecasts", [])  # list of daily forecast dicts
        except requests.RequestException as e:
            raise RuntimeError(f"Failed to fetch daily forecast: {e}")

WeatherAPI & OpenWeather: Change a Few Strings, Ship It

The final two APIs — WeatherAPI and OpenWeather — were refreshingly predictable, and I mean that as the highest compliment.

By the time I got to them, the hard lessons had already been learned. These two worked almost identically to WeatherBit, and more importantly, identically to each other.

Same code structure. Same incoming data pattern. Same mental model.

In practical terms, supporting them meant changing a few strings:

  • Endpoint URLs
  • Field names
  • Authentication wiring

That was it.

No new architectural decisions. No rethinking the core. No late-night existential debates about what “temperature” really means this time. The scraper logic stayed intact — I just swapped out identifiers and moved on.

WeatherAPI did have one genuinely delightful bonus: astronomy data. Sunrise, sunset, moonrise, moonset — the good stuff you didn’t know you wanted until it showed up neatly packaged.

By this point, the pattern was unmistakable. After Open-Meteo and the NWS, everything else felt… civilized.

With four truly free APIs locked in, it was finally time to stop collecting data sources and start pulling the rest of the app together.

"""
weatherapi_scraper.py

Python script to fetch and display WeatherAPI.com data for a given location, including:
- Current weather
- Forecast (hourly + daily)
- Marine/ocean data (tides)
- Sun/moon rise and set times (astronomy)

Uses weatherapi_utils.py for configuration, API key, and human-readable printing.
"""

from datetime import datetime
import json
import requests

from weatherapi.weatherapi_utils import (
    WEATHERAPI_KEY,
    URL_BASE,
    parse_weatherapi_data,
)

from weather_objects import WeatherReport
from weather_shared import parse_location


def get_weatherapi_data(location, units):
    loc = parse_location(location)

    if loc["lat"] is not None:
        location_query = f"{loc['lat']},{loc['lon']}"
    else:
        location_query = f"city={loc['city']},{loc['state']}"

    BASE_QUERY = {"key": WEATHERAPI_KEY, "q": location_query}

    # Optional Parameters
    days = 14  # Number of forcast days (1-14)
    # hour=10     # Return only for a specific hour
    # dt = 2025 - 11 - 12  # For Specific Date (like history-lite)
    lang = "en"  # Localized for english
    aqi = "yes"  #  air quality
    alerts = "yes"  # Include Weather Alerts

    # Current Weather
    # Use /current.json only if you literally only need the “now” snapshot.
    # params = BASE_QUERY.copy()
    # params.update({"lang": lang, "aqi": aqi, "alerts": alerts})

    # url = URL_BASE + "current.json"
    # resp = requests.get(url, params=params, timeout=10)
    # data = resp.json()
    # # print(json.dumps(data, indent=2))
    # print_weather_data(data)

    # Forecast
    # Use /forecast.json if you want hourly + daily + current.
    params = BASE_QUERY.copy()
    params.update({"lang": lang, "aqi": aqi, "alerts": alerts, "days": days})

    url = URL_BASE + "forecast.json"
    resp = requests.get(url, params=params, timeout=10)
    data = resp.json()
    current_weather = parse_weatherapi_data(data["current"], units)


    # Marine/ocean data   marine.json
    # days = 10  # Number of forecast days.  Default: 1 Max: 10
    # # dt="2025-11-12" # Specific date for historial or forecast data.  Default: today
    # # hour=22         # Specific hour of the day (0-23) if you want hourly info
    # tide = "yes"  # Whether to include tide information "yes" or "no"
    # lang = "en"  # Localized for english
    # MARINE_BASE_QUERY = {"key": WEATHERAPI_KEY, "q": "San Francisco, CA"}

    # params = MARINE_BASE_QUERY.copy()
    # params.update({"lang": lang, "days": days, "tide": tide})

    # url = URL_BASE + "marine.json"
    # resp = requests.get(url, params=params, timeout=10)
    # data = resp.json()
    # print("\nCurrent Marine info for San Francisco")
    # print(json.dumps(data, indent=2))

    # # Sun/moon rise/set (optional)  astronomy.json
    params = BASE_QUERY.copy()
    params.update({"dt": "2025-11-12"})  # For specific date.  Default is today

    url = URL_BASE + "astronomy.json"
    resp = requests.get(url, params=params, timeout=10)
    astronomy_data = resp.json()

    weatherapi_report = WeatherReport()
    weatherapi_report.source = "WeatherApi"
    if loc.get("city") and loc.get("state"):
        weatherapi_report.location = f"{loc['city']}, {loc['state']}"
    else:
        weatherapi_report.location = f"{data['latitude']},{data['longitude']}"

    weatherapi_report.latitude = data["location"]["lat"]
    weatherapi_report.longitude = data["location"]["lon"]
    weatherapi_report.fetched_at = datetime.now()
    weatherapi_report.current = current_weather
    weatherapi_report.hourly = None  # TO DO
    weatherapi_report.daily = None  # TO DO
    weatherapi_report.astronomy = astronomy_data

    return weatherapi_report

The CLI: Where Things Came Together

With the APIs understood, it was finally time to build the CLI in earnest.

Using Python’s argparse, I added support for:

  • City/state or latitude/longitude input
  • Listing available APIs and their status
  • Selecting which providers to run
  • Managing a small list of saved locations

One of the unexpectedly satisfying parts of this project was the Python CLI. With argparse, a well-designed interface doesn’t just parse arguments — it documents itself. The same definitions that create the flags also enforce valid combinations and generate a readable --help menu for free.

def parse_args():
    """Parse command-line arguments and return them as a dictionary."""
    parser = argparse.ArgumentParser(description="Weather comparison CLI")
    parser.add_argument(
        "--config", default=DEFAULT_SETTINGS_FILE, help="Path to settings JSON file"
    )
    parser.add_argument(
        "-i", "--interactive", action="store_true", help="Run in interactive mode"
    )
    parser.add_argument(
        "--no-interactive", action="store_true", help="Disable interactive prompts"
    )

    parser.add_argument("location", nargs="?", help="Named location alias")
    parser.add_argument("--apis", help="Comma-separated API list")
    parser.add_argument("--only", help="Use only specified APIs")
    parser.add_argument("--units", choices=["imperial", "metric"])
    parser.add_argument("--show", help="Comma-separated fields")
    parser.add_argument("--forecast-days", type=int)
    parser.add_argument("--forecast-hours", type=int)
    parser.add_argument("--no-config", action="store_true")

    return vars(parser.parse_args())

At this point, I thought the inputs were solved.

Crucially, the CLI doesn’t know—or care—about the backend’s internal structure. That separation made everything else possible.

There were a few tricks to storing the location data within the config

def manage_locations(state):
    """Interactively set the locations in the state."""
    locations = state.get("locations", {})
    default_loc = state.get("default_location")
    active_loc = state.get("active_location", default_loc)

    while True:
        print("\nLocations:")
        if locations:
            for alias, loc in locations.items():
                marks = []
                if alias == default_loc:
                    marks.append("default")
                if alias == active_loc:
                    marks.append("active")
                mark_str = f" ({', '.join(marks)})" if marks else ""
                print(f" - {alias}: {loc}{mark_str}")
        else:
            print(" No locations defined.")

        print("\nOptions:")
        print("a) Add location")
        print("r) Remove location")
        print("s) Set default location")
        print("c) Set active location for this session")
        print("d) Done")

        choice = input("Choose an option: ").strip().lower()

        if choice == "a":
            alias = input("Enter alias (e.g., home, work): ").strip()
            val = input("Enter location (City, State OR Zip OR lat,lon): ").strip()
            locations[alias] = val
            if not default_loc:
                state["default_location"] = alias
            print(f"Added location '{alias}'")
        elif choice == "r":
            alias = input("Enter alias to remove: ").strip()
            if alias in locations:
                del locations[alias]
                if state.get("default_location") == alias:
                    state["default_location"] = None
                print(f"Removed location '{alias}'")
            else:
                print("Alias not found.")
        elif choice == "s":
            alias = input("Enter alias to set as default: ").strip()
            if alias in locations:
                state["default_location"] = alias
                print(f"Default location set to '{alias}'")
            else:
                print("Alias not found.")
        elif choice == "c":
            alias = input("Enter alias to set as active: ").strip()
            if alias in locations:
                state["active_location"] = alias
                active_loc = alias
                state["location"] = locations[alias]
                print(f"Active location set to '{alias}': '{state["location"]}'")
            else:
                print("Alias not found.")
        elif choice == "d":
            state["locations"] = locations
            save_settings(state)
            return  # done
        else:
            print("Invalid choice, try again.")

and there were more tricks for handling the API list and selection

AVAILABLE_APIS = {
    "a": {
        "name": "accuweather",
        "description": "Detailed forecasts with alerts; strong US coverage; popular for hyper-local predictions",
        "status": "API KEY ISSUE",
    },
    "n": {
        "name": "national_weather_service",
        "description": "Official US government forecasts and warnings; US-only; reliable for alerts",
        "status": "OK",
    },
    "o": {
        "name": "open_meteo",
        "description": "Free, no-auth global API; simple forecasts and historical data; lightweight",
        "status": "OK",
    },
    "w": {
        "name": "open_weather",
        "description": "Global coverage, current weather and forecasts; widely supported; needs API key",
        "status": "OK",
    },
    "p": {
        "name": "weatherapi",
        "description": "Global forecasts including historical data; supports alerts and astronomy info",
        "status": "OK",
    },
    "b": {
        "name": "weatherbit",
        "description": "Global hourly/daily forecasts; good for developers needing JSON output",
        "status": "API KEY ISSUE",
    },
}
def manage_apis(state):
    """Interactively set the APIs in the state using single-character selection."""
    apis = state.get("apis", {})

    while True:
        print("\nAPIs (toggle by letter):\n")
        if apis:
            for key, info in AVAILABLE_APIS.items():
                name = info["name"]
                enabled = apis.get(name, {}).get("enabled", False)
                status = "ON" if enabled else "OFF"
                api_status = info["status"]
                print(f"[{key}] {name:<28} [{status}] ({api_status})")
            print("\n[?] View API descriptions")
            print("\n[d] Done")
        else:
            print(" No APIs defined.")

        choice = input("Choose an API to toggle or 'd' when done: ").strip().lower()

        if choice in AVAILABLE_APIS:
            api_name = AVAILABLE_APIS[choice]["name"]
            if api_name not in apis:
                apis[api_name] = {"enabled": True}
                print(f"Added API '{api_name}' (enabled)")
            else:
                apis[api_name]["enabled"] = not apis[api_name].get("enabled", False)
                status = "enabled" if apis[api_name]["enabled"] else "disabled"
                print(f"API '{api_name}' is now {status}")
        elif choice == "?":
            print("\nAPI Details:\n")
            for info in AVAILABLE_APIS.values():
                print(f"{info['name']}")
                print(f"  {info['description']}\n")
            input("Press Enter to return to the API list...")
        elif choice == "d":
            state["apis"] = apis
            save_settings(state)
            return
        else:
            print("Invalid choice, try again.")

At this point, I thought the inputs were solved. That turned out to be… optimistic.


The Core: The Actual Point of the App

The final piece was the core engine.

By this stage, I had:

  • Six service-specific API libraries
  • A fully functional CLI

The core’s job was simple:

  1. Normalize inputs
  2. Call each selected API
  3. Convert results into a standard WeatherData object
  4. Consolidate those into a WeatherReport
  5. Merge all reports into a single WeatherView

The result is one clean JSON object returned to the caller—whether that caller is a CLI today or an AWS Lambda tomorrow.

Integration: Where Normalization Gets Real

Once the scrapers and CLI were in place, it was time to integrate everything into the core. This is where I learned an important lesson:

Normalization you haven’t tested in integration is just optimism with types.

Each scraper already returned data in a roughly consistent shape, but “roughly” stopped being good enough the moment I tried to consolidate everything into a single WeatherReport.

That meant going back into every scraper and answering an uncomfortable question:

Did I actually normalize this… or did I just make it look normalized?

The answer was: mostly, but not completely.


The Location Trap

This is also where I learned an expensive trick about location handling.

Some APIs are perfectly happy with just a city and state.
Some refuse to work unless you give them latitude and longitude.
Others accept either and quietly do the conversion for you.

And none of them agree on how this should be expressed.

Early on, I treated “location” as a single concept. Integration taught me that it’s actually two related but very different inputs, and pretending otherwise just pushes the problem downstream.

The fix wasn’t complicated — but it was a refactor:

  • Standardize location handling at the core level
  • Let each scraper declare what it actually needs
  • Convert once, early, instead of guessing later

Once that was in place, the rest of the integration finally settled down.


The Meta-Lesson

If you want one takeaway from this phase, it’s this:

You don’t really know your abstractions until you try to unify them.

Integration didn’t invalidate the earlier design — it validated it — but only after forcing a few uncomfortable corrections.


So… What’s the Weather?

This project wasn’t about predicting the future. It was about understanding the present—and how many layers of interpretation sit between a thermometer and your phone.

Weather apps don’t disagree because they’re bad. They disagree because they’re opinionated.

This app strips those opinions back, lays the data side by side, and lets you decide.

And honestly? That might be the most accurate forecast of all.