I’ve always been fascinated by history and weather data.

In this project, I built an app that weaves together these data: hourly weather forecasts from Open-Meteo and “on this day” historical events from History.muffinlabs.

Each hour becomes its story card, pairing the temperature, precipitation, and wind speed I’m experiencing now (or the weather of any date I choose) with fascinating moments from the past.

At the same time, I will showcase modern Python techniques - async HTTP requests, clean data models, and lightweight templates - while delivering a playful, visually engaging experience.

By the end of this tutorial, you’ll see how I used FastAPI and Jinja2 to turn raw JSON into a dynamic timeline of weather and history that feels informative and fun.

You can download the full source code here:


CTA Image

This book offers an in-depth exploration of Python's magic methods, examining the mechanics and applications that make these features essential to Python's design.

Get the eBook

Prerequisites & Setup

Before diving into the code, here’s what you’ll need to have in place on your machine—and how I got my environment ready:

Languages & Frameworks

  • Python 3.10+: I used Python 3.11 for this project, but any 3.10 or newer interpreter works perfectly.
  • FastAPI: powers the async web server and routing.
  • aiohttp: handles all of my non-blocking HTTP requests to the weather and history APIs.
  • Jinja2: renders the HTML templates

Installing Dependencies

Create a virtual environment (recommended):

python -m venv .venv
source .venv/bin/activate

requirements.txt
I keep the key packages in a simple requirements.txt:

fastapi
uvicorn
jinja2
python-multipart
requests
python-dateutil
aiohttp

Install:

pip install -r requirements.txt

Obtaining Free Access

One of my favorite parts of this project is that no API keys or signup flows are required:

  • Open-Meteo: their forecast and archive endpoints are fully open and free - just pass latitude/longitude in your query.
  • History.muffinlabs.com: the “on this day” API is also public and doesn’t require authentication.

If you want to learn more about Open-Meteo, check out some of my other articles:

https://developer-service.blog/how-to-build-a-free-weather-app-with-pyside6-and-open-meteo/

Or some of my YouTube videos:


Project Structure

Here’s how I’ve organized the code for this app - each file serves a clear purpose to keep things modular and maintainable:

/app
  ├─ utils.py
  ├─ data_models.py
  ├─ main.py
  └─ templates/
       ├─ base.html  
       └─ index.html  

utils.py
Contains all of my asynchronous helper functions:

  • search_location() for geocoding via Open-Meteo
  • fetch_current_weather() and fetch_weather_data() to pull today’s and historical forecasts
  • fetch_historical_events() for “on this day” data
  • distribute_events() and create_story_cards() to assemble the per-hour story cards

data_models.py
Defines the data structures I use throughout the app, all as @dataclass:

  • WeatherData, DailyWeatherData for weather
  • HistoricalEvent, HistoricalData for events, births, deaths
  • StoryCard to bundle one hour’s weather + historical snippets

main.py
The FastAPI application entry point:

  • Mounts static files and sets up Jinja2 templates
  • Defines three routes:
    • GET / renders the home form
    • GET /search-location powers the HTMX drop-down for live location suggestions
    • POST / handles form submission, orchestrates data fetching, and renders the story cards

templates/base.html
The HTML skeleton with <head> imports (Bootstrap, HTMX) and a {% block content %} placeholder for child pages.

templates/index.html
Extends base.html to provide:

  • A date picker and location input (with HTMX dropdown)
  • A loop over the story_cards context variable to display each hour’s weather details alongside its historical events

This layout keeps concerns separated - data models, external-API logic, web-server routes, and presentation - so you can work on each part independently.


Mug Trust Me Prompt Engineer Sarcastic Design

A sarcastic "prompt badge" design coffee mug, featuring GPT-style neural network lines and a sunglasses emoji.

Perfect for professionals with a sense of humor, this mug adds a touch of personality to your morning routine.

Ideal for engineers, tech enthusiasts, and anyone who appreciates a good joke.

Great for gifting on birthdays, holidays, and work anniversaries.

I want one!

Defining the Data Models (data_models.py)

In data_models.py, I lean on Python’s @dataclass decorator to give structure and type safety to every piece of data flowing through the app.

Here’s how I break it down, first the Weather data:

@dataclass
class WeatherData:
    time: datetime
    temperature: float
    precipitation: float
    wind_speed: float
    weather_code: int

@dataclass
class DailyWeatherData:
    sunrise: datetime
    sunset: datetime

WeatherData captures each hour’s snapshot: the timestamp plus temperature (°C), precipitation (mm), wind speed (m/s), and the weather code for icon lookups.
DailyWeatherData holds just the sunrise and sunset times for the selected date, so I can show this info in the corresponding hour card.

Then the Historical data:

@dataclass
class HistoricalEvent:
    year: int
    text: str
    wikipedia_url: Optional[str] = None

@dataclass
class HistoricalData:
    events: List[HistoricalEvent]
    births: List[HistoricalEvent]
    deaths: List[HistoricalEvent]

HistoricalEvent represents a single “on this day” entry—year, descriptive text, and an optional Wikipedia link.
HistoricalData bundles together three lists of those events (major occurrences, notable births, and deaths) for easy passing around.

Finally, I merge the two into a story card:

@dataclass
class StoryCard:
    weather: WeatherData
    daily: DailyWeatherData
    events: List[HistoricalEvent]
    births: List[HistoricalEvent]
    deaths: List[HistoricalEvent]

StoryCard ties one hour’s weather (weather) and the day’s sunrise/sunset (daily) to up to three randomly-distributed events, births, and deaths.

By keeping these models pure and decoupled, the utility functions simply fill in instances of these classes - and the FastAPI endpoints and templates can treat each card as a single, cohesive unit.


Utility Module Deep-Dive (utils.py)

In utils.py, I encapsulate all the API calls and data‐shaping logic into a set of clear, reusable functions.

Here’s how each piece works.

class LocationData(TypedDict):
    name: str
    latitude: float
    longitude: float
    country: str
    timezone: str

async def search_location(query: str) -> List[LocationData]:
    """Search for locations using Open-Meteo's Geocoding API."""
    params = {
        "name": query,
        "count": 5,
        "language": "en",
        "format": "json"
    }
    
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(GEOCODING_API_URL, params=params) as response:
                response.raise_for_status()
                data = await response.json()
                
                if data.get("results"):
                    return [
                        LocationData(
                            name=result["name"],
                            latitude=result["latitude"],
                            longitude=result["longitude"],
                            country=result["country"],
                            timezone=result["timezone"]
                        )
                        for result in data["results"]
                    ]
                return []
    except Exception:
        return []

Code description:

  • It calls Open-Meteo’s geocoding endpoint with the user’s query and requests for up to five matches.
  • The function returns a list of LocationData TypedDicts, each containing name, latitude, longitude, country, and timezone.

Fetching Weather

Function fetch_current_weather(...):

async def fetch_current_weather(latitude: float, longitude: float) -> Tuple[List[WeatherData], DailyWeatherData]:
    """Fetch current weather data from Open-Meteo API."""
    today = datetime.now().date()
    
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "hourly": "temperature_2m,precipitation,wind_speed_10m,weather_code",
        "daily": "sunrise,sunset",
        "timezone": "auto",
        "start_date": today.strftime("%Y-%m-%d"),
        "end_date": today.strftime("%Y-%m-%d")
    }
    
    async with aiohttp.ClientSession() as session:
        async with session.get(OPEN_METEO_CURRENT_URL, params=params) as response:
            response.raise_for_status()
            data = await response.json()
    
    # Parse hourly data
    weather_data = []
    for i in range(len(data["hourly"]["time"])):
        weather_data.append(WeatherData(
            time=datetime.fromisoformat(data["hourly"]["time"][i]),
            temperature=data["hourly"]["temperature_2m"][i] or 0.0,
            precipitation=data["hourly"]["precipitation"][i] or 0.0,
            wind_speed=data["hourly"]["wind_speed_10m"][i] or 0.0,
            weather_code=data["hourly"]["weather_code"][i] or 0
        ))
    
    # Parse daily data
    daily_data = DailyWeatherData(
        sunrise=datetime.fromisoformat(data["daily"]["sunrise"][0]),
        sunset=datetime.fromisoformat(data["daily"]["sunset"][0])
    )
    
    return weather_data, daily_data

Code description:

  • Fetches today’s hourly temperature, precipitation, wind speed, and weather code.
  • Parses the single‐day “daily” response into sunrise/sunset times.

Function fetch_weather_data(...):