Building Weather & History Story Cards with Python and FastAPI

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: Download Source Code SPONSORED By Python's Magic Methods - Beyond init and str 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. 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 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. 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 repr

May 1, 2025 - 08:41
 0
Building Weather & History Story Cards with Python and FastAPI

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:

Download Source Code

SPONSORED By Python's Magic Methods - Beyond init and str

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.

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

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.

Location Search

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(...):

async def fetch_weather_data(latitude: float, longitude: float, date: datetime) -> Tuple[List[WeatherData], DailyWeatherData]:
    """Fetch weather data from Open-Meteo API, using current API for today's data."""
    today = datetime.now().date()
    selected_date = date.date()

    if selected_date == today:
        return await fetch_current_weather(latitude, longitude)

    # For historical data, use the archive API
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "start_date": date.strftime("%Y-%m-%d"),
        "end_date": date.strftime("%Y-%m-%d"),
        "hourly": "temperature_2m,precipitation,wind_speed_10m,weather_code",
        "daily": "sunrise,sunset",
        "timezone": "auto"
    }

    async with aiohttp.ClientSession() as session:
        async with session.get(OPEN_METEO_BASE_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:

  • If the requested date is today, it calls the previouly defined fetch_current_weather.
  • Otherwise, It calls Open-Meteo’s archive API to retrieve historical data for that single date.

Fetching Historical Events

async def fetch_historical_events(date: datetime) -> HistoricalData:
    """Fetch historical events from History.muffinlabs.com API."""
    url = f"{HISTORY_API_BASE_URL}/{date.month}/{date.day}"

    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            response.raise_for_status()
            data = await response.json()

    def parse_events(event_list: List[Dict[str, Any]]) -> List[HistoricalEvent]:
        return [
            HistoricalEvent(
                year=event["year"],
                text=event["text"],
                wikipedia_url=event.get("links", [{}])[0].get("link")
            )
            for event in event_list
        ]

    return HistoricalData(
        events=parse_events(data["data"]["Events"]),
        births=parse_events(data["data"]["Births"]),
        deaths=parse_events(data["data"]["Deaths"])
    )

Code description:

  • Calls the History.muffinlabs endpoint for that month/day.
  • Converts each event, birth, and death into a HistoricalEvent(year, text, wikipedia_url) and bundles them into a HistoricalData object.

Story-Card Logic

Function distribute_events(...):

def distribute_events(events: List[HistoricalEvent], num_cards: int) -> List[List[HistoricalEvent]]:
    """Distribute events evenly across cards, with max 3 random events per card.
    Avoids repeating events unless there aren't enough unique events."""
    if not events:
        return [[] for _ in range(num_cards)]

    # Sort events by year to maintain some historical context
    sorted_events = sorted(events, key=lambda x: x.year)

    # Calculate how many events we need in total (max 3 per card)
    total_events_needed = min(3 * num_cards, len(sorted_events))

    # If we have enough unique events, distribute them without repetition
    if len(sorted_events) >= total_events_needed:
        # Create a list for each card
        distributed_events = []
        event_index = 0

        for _ in range(num_cards):
            # Get up to 3 events for this card
            card_events = []
            for _ in range(3):
                if event_index < len(sorted_events):
                    card_events.append(sorted_events[event_index])
                    event_index += 1
                else:
                    break

            distributed_events.append(card_events)

        return distributed_events

    # If we don't have enough unique events, we'll need to repeat some
    else:
        # Create list for each card
        distributed_events = []
        for _ in range(num_cards):
            # Select 3 random events, allowing repetition
            card_events = random.choices(sorted_events, k=min(3, len(sorted_events)))
            distributed_events.append(card_events)

        return distributed_events

Code description:

  • Evenly assigns up to three unique events per hour card.
  • If there aren’t enough events to fill every card uniquely, it allows random repeats.

Function create_story_cards(...):

def create_story_cards(weather_data: List[WeatherData], daily_data: DailyWeatherData, historical_data: HistoricalData) -> List[StoryCard]:
    """Create story cards combining weather and historical data for each hour."""
    num_cards = len(weather_data)

    # Distribute events, births, and deaths across cards
    distributed_events = distribute_events(historical_data.events, num_cards)
    distributed_births = distribute_events(historical_data.births, num_cards)
    distributed_deaths = distribute_events(historical_data.deaths, num_cards)

    # Create cards with distributed historical data
    story_cards = []
    for i, weather in enumerate(weather_data):
        story_cards.append(
            StoryCard(
                weather=weather,
                daily=daily_data,
                events=distributed_events[i],
                births=distributed_births[i],
                deaths=distributed_deaths[i]
            )
        )

    return story_cards 

Code description:

  • It generates three parallel lists - one for events, births, and deaths - each matched to the number of hourly data points.
  • Then they are zipped together with each hour’s WeatherData (and the single DailyWeatherData) into a list of StoryCard instances, ready for rendering.

FastAPI Back-End (main.py)

In this section, I’ll walk through how I wire up the FastAPI server, define routes, and handle errors in main.py.

App Initialization

Right at the top of main.py, I set up the FastAPI app, configure Jinja2 and basic logging:

app = FastAPI()
templates = Jinja2Templates(directory="templates")

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Code description:

  • Jinja2Templates points to the templates/ folder, for using TemplateResponse later.
  • Basic logs are setup with INFO level

Routes

Route GET / → render home form:

@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    return templates.TemplateResponse(
        "index.html",
        {
            "request": request,
            "error": None,
            "today": datetime.now().strftime("%Y-%m-%d"),
            "selected_date": datetime.now().strftime("%Y-%m-%d"),
            "location_data": None,
            "locations": []
        }
    )

Code description:

  • When users first open the page, it renders index.html with an empty form.
  • The current date is passed as selected_date to the template, so the date-picker can default to today’s date.

Route GET /search-location → HTMX endpoint for live location suggestions

@app.get("/search-location", response_class=HTMLResponse)
async def search_location_endpoint(request: Request, location: str):
    if len(location) < 2:
        return ""

    locations = await search_location(location)
    if not locations:
        return ""

    # Create HTML for location results
    html = ""
    for location in locations:
        location_text = f"{location['name']} ({location['latitude']}, {location['longitude']}) {location['country']}"
        html += f"""
        "dropdown-item" href="#" 
           hx-on:click="document.getElementById('locationInput').value = '{location_text}'; return false;"
           hx-on:click="document.getElementById('locationResults').style.display = 'none';">
            
"fw-bold">{location['name']}, {location['country']}
"text-muted small">({location['latitude']:.4f}, {location['longitude']:.4f})
""" return html

Code description:

  • As the user types in the location field, HTMX fires requests to this endpoint.
  • It return only the dropdown items’ HTML, which HTMX injects under the input.

Route POST / → parse form, validate date/location, call utils, render story cards:

@app.post("/", response_class=HTMLResponse)
async def get_weather_history(
    request: Request,
    location: str = Form(...),
    date: str = Form(...)
):
    try:
        # Parse location data from the form
        try:
            location_data = {
                "name": location.split(" (")[0],
                "latitude": float(location.split("(")[1].split(",")[0]),
                "longitude": float(location.split(",")[1].split(")")[0]),
                "country": location.split(")")[1].strip(),
                "timezone": "auto"  # We'll use auto timezone
            }
        except (IndexError, ValueError):
            raise HTTPException(status_code=400, detail="Invalid location format")

        # Parse date
        try:
            selected_date = datetime.strptime(date, "%Y-%m-%d")
        except ValueError:
            raise HTTPException(status_code=400, detail="Invalid date format")

        # Check if the date is in the future
        today = datetime.now().date()
        if selected_date.date() > today:
            raise HTTPException(status_code=400, detail="Cannot fetch weather data for future dates")

        # Fetch weather and historical data
        weather_data, daily_data = await fetch_weather_data(
            location_data["latitude"],
            location_data["longitude"],
            selected_date
        )
        historical_data = await fetch_historical_events(selected_date)

        # Create story cards
        story_cards = create_story_cards(weather_data, daily_data, historical_data)

        # Prepare template context
        context = {
            "request": request,
            "story_cards": story_cards,
            "selected_date": selected_date.strftime("%Y-%m-%d"),
            "location_data": location_data,
            "weather_data": weather_data,
            "today": datetime.now().strftime("%Y-%m-%d"),
            "error": None,
            "locations": [],
            "is_current_day": selected_date.date() == today
        }

        return templates.TemplateResponse("index.html", context)

    except HTTPException as e:
        return templates.TemplateResponse(
            "index.html",
            {
                "request": request,
                "error": e.detail,
                "today": datetime.now().strftime("%Y-%m-%d"),
                "selected_date": date,
                "location_data": None,
                "locations": []
            }
        )
    except Exception as e:
        logger.error(f"Error processing request: {str(e)}")
        return templates.TemplateResponse(
            "index.html",
            {
                "request": request,
                "error": "An unexpected error occurred",
                "today": datetime.now().strftime("%Y-%m-%d"),
                "selected_date": date,
                "location_data": None,
                "locations": []
            }
        )

Code description:

  • It parse the form inputs (location, date), validate them, then orchestrate the calls to utils.py.
  • Finally, It re-render index.html, this time supplying the story_cards list for display.

Error Handling

Throughout the POST handler, it raises exception against user errors and unexpected failures:

Form validation: if the location string can’t be parsed into latitude/longitude:

raise HTTPException(status_code=400, detail="Invalid location format")

Future‐date guard: It compares the chosen date against datetime.now().date():

raise HTTPException(status_code=400, detail="Cannot fetch weather data for future dates")

Generic exceptions: It wraps the entire logic in a try/except Exception as e, logging the errors for debugging, and return the template with an error message so users see a friendly notice rather than a 500.

Front-End with Jinja2 Templates

I built the UI using simple Jinja2 templates, with Bootstrap for styling and HTMX for dynamic behaviors.

Template base.html

This is my “skeleton” layout that the index.html extends. In the , it pulls in Bootstrap’s CSS/JS and HTMX:


 lang="en">

     charset="UTF-8">
     name="viewport" content="width=device-width, initial-scale=1.0">
    </span>{% block title %}Historical Weather Storybook{% endblock %}<span class="nt">
    
     href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    
     href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
    
    
    
    

 class="bg-light">
     class="navbar navbar-expand-lg navbar-dark bg-primary">
         class="container">
             class="navbar-brand" href="/">
                 class="bi bi-clock-history">
                Historical Weather Storybook
            
        
class="container mt-4"> {% block content %}{% endblock %}
{% block scripts %}{% endblock %}