6. Building the complete dashboard
Four working components sit on the project root: geocode_client.py, weather_client.py, weather_codes.py, and integrate_weather.py. This page wraps them in a WeatherDashboard class with display helpers, then adds an interactive session loop that handles Ctrl+C cleanly, quits on "exit," and keeps running when the user hits the Enter key by accident. The pattern is: data retrieval in one place, presentation in another, integration tying them together.
The class skeleton
The WeatherDashboard class holds the two client instances, the display methods, and the integration logic in one cohesive unit. It repeats the coordination checks inline so this small CLI app stays self-contained; in a larger application, you would usually import the free function from integrate_weather.py and keep presentation separate. Start with an empty skeleton showing how the pieces slot in:
from datetime import datetime
from geocode_client import GeocodingClient
from weather_client import WeatherClient
from weather_codes import interpret_weather_code
class WeatherDashboard:
"""Interactive weather dashboard over geocoding + forecast APIs."""
def __init__(self):
self.geo = GeocodingClient()
self.wx = WeatherClient()
# --- formatting helpers -------------------------------------------
def get_wind_direction(self, degrees):
"""Convert wind direction degrees to compass letters."""
...
def format_timestamp(self, timestamp):
"""ISO timestamp -> readable 'HH:MM on Month DD, YYYY'."""
...
def format_date(self, date_string):
"""ISO date -> 'Today', 'Tomorrow', or weekday name."""
...
# --- display ------------------------------------------------------
def display_current_weather(self, weather_data, location_name):
"""Print current conditions in a formatted layout."""
...
def display_forecast(self, weather_data):
"""Print the daily forecast as an aligned table."""
...
# --- integration --------------------------------------------------
def get_weather_for_city(self, city_name):
"""Coordinate geocoding + weather and print the results."""
...
# --- interactive loop ---------------------------------------------
def run_interactive_session(self):
"""Loop until the user quits or sends Ctrl+C."""
...
Four concerns, four method groups. Formatting helpers do small, pure transformations on raw fields; display methods print without touching the network; integration calls the layers and stops on failure; the interactive loop knows nothing about API endpoints. That separation is what makes each piece testable on its own, and it means you can replace the terminal UI with a web interface without touching the data layer.
Formatting helpers
Before the display methods, add three small helpers that keep the formatting out of the display code. Wind direction turns 225° into "SW," timestamps become "14:00 on January 15, 2024," and dates resolve to "Today," "Tomorrow," or a weekday name:
class WeatherDashboard:
# ... previous methods ...
def get_wind_direction(self, degrees):
"""
Convert wind direction degrees to compass direction.
Args:
degrees: Wind direction in degrees (0-360)
Returns:
Compass direction string (N, NE, E, etc.)
"""
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
index = round(degrees / 22.5) % 16
return directions[index]
def format_timestamp(self, timestamp):
"""
Format ISO timestamp for display.
Args:
timestamp: ISO format timestamp string
Returns:
Formatted time string
"""
try:
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
return dt.strftime("%H:%M on %B %d, %Y")
except (ValueError, AttributeError):
return timestamp
def format_date(self, date_string):
"""
Format date string with smart labels.
Args:
date_string: ISO format date string (YYYY-MM-DD)
Returns:
Day name or "Today"/"Tomorrow"
"""
try:
dt = datetime.fromisoformat(date_string)
today = datetime.now().date()
date_obj = dt.date()
if date_obj == today:
return "Today"
elif (date_obj - today).days == 1:
return "Tomorrow"
else:
return dt.strftime("%A")
except (ValueError, AttributeError):
return date_string
Each helper has one job and exists as a separate method because that's easier to change without touching display code. If the date format ever wants to respect a locale, the edit is local. If a fourth helper needs to join the list later, it slots in the same way.
Current-weather display
The current-weather display takes the raw response dictionary and prints a formatted summary. Notice the pervasive .get(): if the API drops a field, the corresponding line just doesn't print. The application keeps running; the user gets whatever weather data did arrive.
class WeatherDashboard:
# ... previous methods ...
def display_current_weather(self, weather_data, location_name):
"""
Display current weather conditions in formatted layout.
Args:
weather_data: Complete weather response dictionary
location_name: Formatted location name from geocoding
"""
current = weather_data["current"]
current_units = weather_data["current_units"]
# Header
print(f"\nCurrent Weather for {location_name}")
print("=" * (len(location_name) + 25))
# Temperature
temp = current.get("temperature_2m")
temp_unit = current_units.get("temperature_2m", "°C")
if temp is not None:
print(f"Temperature: {temp}{temp_unit}")
# Show "feels like" if significantly different
apparent_temp = current.get("apparent_temperature")
if apparent_temp is not None and abs(apparent_temp - temp) > 2:
print(f" Feels like: {apparent_temp}{temp_unit}")
# Weather conditions
weather_code = current.get("weather_code")
if weather_code is not None:
conditions = interpret_weather_code(weather_code)
print(f"Conditions: {conditions}")
# Humidity
humidity = current.get("relative_humidity_2m")
if humidity is not None:
print(f"Humidity: {humidity}%")
# Wind information
wind_speed = current.get("wind_speed_10m")
wind_direction = current.get("wind_direction_10m")
wind_unit = current_units.get("wind_speed_10m", "km/h")
if wind_speed is not None:
wind_info = f"Wind: {wind_speed} {wind_unit}"
if wind_direction is not None:
compass = self.get_wind_direction(wind_direction)
wind_info += f" from {compass}"
print(wind_info)
# Update timestamp
timestamp = current.get("time")
if timestamp:
formatted_time = self.format_timestamp(timestamp)
print(f"Updated: {formatted_time}")
Run it from the project root:
Current Weather for Dublin, Leinster, Ireland
====================================================
Temperature: 8.3°C
Feels like: 6.1°C
Conditions: Overcast
Humidity: 87%
Wind: 11.2 km/h from SW
Updated: 14:00 on January 15, 2024
Two details worth pointing out. The "feels like" temperature only prints when it differs from the actual temperature by more than two degrees -- if it's nearly the same, showing it would be noise. And the wind line assembles in pieces: speed always, compass direction only if wind_direction is available. Composing strings like that keeps the code tolerant of partial API responses.
Daily-forecast display
The forecast display processes the parallel arrays into a readable table. It iterates by index, extracts the aligned value from each array, and formats one row per day. Bounds checking on every array handles the case where the API drops a day from some of the arrays (rare, but it does happen).
class WeatherDashboard:
# ... previous methods ...
def display_forecast(self, weather_data):
"""
Display 5-day weather forecast in tabular format.
Args:
weather_data: Complete weather response dictionary
"""
daily = weather_data["daily"]
daily_units = weather_data["daily_units"]
print("\n5-Day Forecast")
print("=" * 50)
# Extract arrays
dates = daily.get("time", [])
max_temps = daily.get("temperature_2m_max", [])
min_temps = daily.get("temperature_2m_min", [])
weather_codes = daily.get("weather_code", [])
precipitation = daily.get("precipitation_sum", [])
temp_unit = daily_units.get("temperature_2m_max", "°C")
precip_unit = daily_units.get("precipitation_sum", "mm")
# Display each day
for i in range(min(len(dates), 5)):
day_name = self.format_date(dates[i])
# Temperature low and high (guard each array against being short)
lo = f"{min_temps[i]}{temp_unit}" if i < len(min_temps) and min_temps[i] is not None else "N/A"
hi = f"{max_temps[i]}{temp_unit}" if i < len(max_temps) and max_temps[i] is not None else "N/A"
# Weather conditions
conditions = "Clear"
if i < len(weather_codes) and weather_codes[i] is not None:
conditions = interpret_weather_code(weather_codes[i])
# Precipitation (only when there's any)
precip_info = ""
if i < len(precipitation) and precipitation[i] is not None and precipitation[i] > 0:
precip_info = f" | Rain: {precipitation[i]}{precip_unit}"
# Print formatted row. The :6 padding on `hi` keeps the
# `|` separators aligned across rows where the high
# temperature is one digit shorter (e.g. 9.8°C vs 10.2°C).
print(f"{day_name:12} | Temperature: {lo} to {hi:6} | {conditions}{precip_info}")
5-Day Forecast
==================================================
Today | Temperature: 5.8°C to 10.2°C | Overcast
Tomorrow | Temperature: 4.2°C to 9.8°C | Slight rain | Rain: 2.4mm
Wednesday | Temperature: 7.3°C to 12.1°C | Partly cloudy
Thursday | Temperature: 6.1°C to 11.5°C | Mainly clear
Friday | Temperature: 5.4°C to 10.8°C | Clear sky
Every index access is guarded. If precipitation_sum is shorter than time, the "Rain:" annotation silently drops off instead of raising IndexError. That kind of defence matters more in this section than the last because the failure mode isn't a crash -- it's an incomplete forecast the user might trust.
Wiring it together
The integration method coordinates the two API calls and invokes the display methods. It returns a boolean for the interactive loop to key off:
class WeatherDashboard:
# ... previous methods ...
def get_weather_for_city(self, city_name):
"""
Complete workflow: lookup location, fetch weather, display results.
Args:
city_name: City name to look up
Returns:
True if successful, False if any step failed
"""
# Step 1: Geocoding
latitude, longitude, location_name = self.geo.find_location(city_name)
if latitude is None:
print("Cannot get weather without valid location coordinates")
return False
# Step 2: Weather data
weather_data = self.wx.get_weather_data(latitude, longitude)
if weather_data is None:
print("Cannot display weather without valid data")
return False
# Step 3: Display results
self.display_current_weather(weather_data, location_name)
self.display_forecast(weather_data)
return True
Boolean return, not the tuple the coordination page used. Different callers need different shapes. The interactive loop below only cares whether the call succeeded; it doesn't need the weather data back because the display methods have already printed it.
The interactive session
The last piece is the loop that keeps the dashboard running. It accepts input until the user types "quit" or "exit," and responds to Ctrl+C by printing a friendly goodbye rather than a traceback.
class WeatherDashboard:
# ... previous methods ...
def run_interactive_session(self):
"""
Run interactive weather dashboard session.
Continues until user quits.
"""
print("Weather Dashboard")
print("=" * 30)
print("Enter city names to get weather forecasts.")
print("Type 'quit' or 'exit' to stop.\n")
while True:
try:
# Get user input
city_input = input("Enter city name: ").strip()
# Check for exit commands
if city_input.lower() in ['quit', 'exit', 'q']:
print("\nThanks for using the Weather Dashboard.")
break
# Validate input
if not city_input:
print("Please enter a city name.\n")
continue
# Get and display weather
print() # Add spacing
success = self.get_weather_for_city(city_input)
if success:
print("\n" + "─" * 60)
else:
print("\nPlease try a different city name.")
print("─" * 60)
print() # Add spacing for next input
except KeyboardInterrupt:
print("\n\nSession ended. Goodbye.")
break
except Exception as e:
print(f"\nUnexpected error: {e}")
print("Please try again.\n")
# Create and run dashboard
def main():
"""Main entry point for weather dashboard application."""
dashboard = WeatherDashboard()
dashboard.run_interactive_session()
if __name__ == "__main__":
main()
$ python weather_dashboard.py
Weather Dashboard
==============================
Enter city names to get weather forecasts.
Type 'quit' or 'exit' to stop.
Enter city name: Dublin
Looking up coordinates for 'Dublin'...
Found: Dublin, Leinster, Ireland
Fetching 5-day weather forecast...
Weather data retrieved successfully
Current Weather for Dublin, Leinster, Ireland
====================================================
Temperature: 8.3°C
Feels like: 6.1°C
Conditions: Overcast
Humidity: 87%
Wind: 11.2 km/h from SW
Updated: 14:00 on January 15, 2024
5-Day Forecast
==================================================
Today | Temperature: 5.8°C to 10.2°C | Overcast
Tomorrow | Temperature: 4.2°C to 9.8°C | Slight rain | Rain: 2.4mm
Wednesday | Temperature: 7.3°C to 12.1°C | Partly cloudy
Thursday | Temperature: 6.1°C to 11.5°C | Mainly clear
Friday | Temperature: 5.4°C to 10.8°C | Clear sky
────────────────────────────────────────────────────────────
Enter city name: quit
Thanks for using the Weather Dashboard.
Three distinct behaviors sit in the loop. The lowercase in ["quit", "exit", "q"] check handles the expected exit words. The empty-input branch prompts again rather than sending a no-op to the API. And the separate except KeyboardInterrupt turns Ctrl+C from a traceback into a clean "Goodbye." The catch-all Exception at the bottom exists so that one bad request doesn't end the session, just that iteration.
The complete file
The methods built up across this page now sit inside a single WeatherDashboard class. The full assembled file is collapsed below; expand it if you've been copying along in sections (to check nothing drifted) or if you skipped straight here and want the whole thing to save as weather_dashboard.py.
Show the complete weather_dashboard.py
from datetime import datetime
from geocode_client import GeocodingClient
from weather_client import WeatherClient
from weather_codes import interpret_weather_code
class WeatherDashboard:
"""Interactive weather dashboard over geocoding + forecast APIs."""
def __init__(self):
self.geo = GeocodingClient()
self.wx = WeatherClient()
# -- formatting helpers --------------------------------------------
def get_wind_direction(self, degrees):
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
return directions[round(degrees / 22.5) % 16]
def format_timestamp(self, timestamp):
try:
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
return dt.strftime("%H:%M on %B %d, %Y")
except (ValueError, AttributeError):
return timestamp
def format_date(self, date_string):
try:
dt = datetime.fromisoformat(date_string)
today = datetime.now().date()
delta = (dt.date() - today).days
if delta == 0:
return "Today"
if delta == 1:
return "Tomorrow"
return dt.strftime("%A")
except (ValueError, AttributeError):
return date_string
# -- display -------------------------------------------------------
def display_current_weather(self, weather_data, location_name):
current = weather_data["current"]
current_units = weather_data["current_units"]
print(f"\nCurrent Weather for {location_name}")
print("=" * (len(location_name) + 25))
temp = current.get("temperature_2m")
temp_unit = current_units.get("temperature_2m", "°C")
if temp is not None:
print(f"Temperature: {temp}{temp_unit}")
apparent_temp = current.get("apparent_temperature")
if apparent_temp is not None and abs(apparent_temp - temp) > 2:
print(f" Feels like: {apparent_temp}{temp_unit}")
weather_code = current.get("weather_code")
if weather_code is not None:
print(f"Conditions: {interpret_weather_code(weather_code)}")
humidity = current.get("relative_humidity_2m")
if humidity is not None:
print(f"Humidity: {humidity}%")
wind_speed = current.get("wind_speed_10m")
wind_direction = current.get("wind_direction_10m")
wind_unit = current_units.get("wind_speed_10m", "km/h")
if wind_speed is not None:
wind_info = f"Wind: {wind_speed} {wind_unit}"
if wind_direction is not None:
wind_info += f" from {self.get_wind_direction(wind_direction)}"
print(wind_info)
timestamp = current.get("time")
if timestamp:
print(f"Updated: {self.format_timestamp(timestamp)}")
def display_forecast(self, weather_data):
daily = weather_data["daily"]
daily_units = weather_data["daily_units"]
print("\n5-Day Forecast")
print("=" * 50)
dates = daily.get("time", [])
max_temps = daily.get("temperature_2m_max", [])
min_temps = daily.get("temperature_2m_min", [])
weather_codes = daily.get("weather_code", [])
precipitation = daily.get("precipitation_sum", [])
temp_unit = daily_units.get("temperature_2m_max", "°C")
precip_unit = daily_units.get("precipitation_sum", "mm")
for i in range(min(len(dates), 5)):
day_name = self.format_date(dates[i])
lo = f"{min_temps[i]}{temp_unit}" if i < len(min_temps) and min_temps[i] is not None else "N/A"
hi = f"{max_temps[i]}{temp_unit}" if i < len(max_temps) and max_temps[i] is not None else "N/A"
conditions = "Clear"
if i < len(weather_codes) and weather_codes[i] is not None:
conditions = interpret_weather_code(weather_codes[i])
precip_info = ""
if i < len(precipitation) and precipitation[i] is not None and precipitation[i] > 0:
precip_info = f" | Rain: {precipitation[i]}{precip_unit}"
print(f"{day_name:12} | Temperature: {lo} to {hi:6} | {conditions}{precip_info}")
# -- integration ---------------------------------------------------
def get_weather_for_city(self, city_name):
lat, lon, location_name = self.geo.find_location(city_name)
if lat is None:
print("Cannot get weather without valid location coordinates")
return False
weather_data = self.wx.get_weather_data(lat, lon)
if weather_data is None:
print("Cannot display weather without valid data")
return False
self.display_current_weather(weather_data, location_name)
self.display_forecast(weather_data)
return True
# -- interactive loop ----------------------------------------------
def run_interactive_session(self):
print("Weather Dashboard")
print("=" * 30)
print("Enter city names to get weather forecasts.")
print("Type 'quit' or 'exit' to stop.\n")
while True:
try:
city_input = input("Enter city name: ").strip()
if city_input.lower() in ["quit", "exit", "q"]:
print("\nThanks for using the Weather Dashboard.")
break
if not city_input:
print("Please enter a city name.\n")
continue
print()
success = self.get_weather_for_city(city_input)
if success:
print("\n" + "─" * 60)
else:
print("\nPlease try a different city name.")
print("─" * 60)
print()
except KeyboardInterrupt:
print("\n\nSession ended. Goodbye.")
break
except Exception as e:
print(f"\nUnexpected error: {e}")
print("Please try again.\n")
def main():
WeatherDashboard().run_interactive_session()
if __name__ == "__main__":
main()
Five modules sit on disk now: geocode_client.py, weather_client.py, weather_codes.py, weather_dashboard.py, plus the Chapter 7 auth_module.py that carries the retry and error-handling spine under all of them. Run the dashboard from the project root with python weather_dashboard.py, type a few cities, and exit with "quit" or Ctrl+C; the next page distils what this chapter taught about layered design, validation gates, and clean interfaces, and points forward to Chapter 9's deeper pass on error handling.