As a personal project to build my portfolio, I've created a simple weather app that displays temperature, precipitation, and wind for any given city. This is my first time building a GUI using tkinter, so any suggestions and criticisms are welcome.
No API key is required, as this uses the open-meteo free public API.
"""
Weather Data Visualization
@author Ben Antonellis
@date September 23rd, 2025
Usage:
python3 weather.py
CSV Format and Example Data:
date,tmax,tmin,precip,wind
2021-09-22,22.5,14.1,2.0,11.2
"""
import csv
import threading
from datetime import datetime
from io import StringIO
from tkinter import filedialog, messagebox
import tkinter as tk
from tkinter import ttk
import requests
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
TITLE = "Weather Visualizer"
# ----------------------------- Helpers ----------------------------- #
def c_to_f(c: float) -> float:
return c * 9.0 / 5.0 + 32.0
def to_unit(val, unit: str) -> float | None:
if val is None:
return None
return c_to_f(val) if unit == "F" else val
def parse_csv_text(text: str) -> list[dict[str, datetime | str]]:
""" Parse CSV with columns: date,tmax,tmin,precip,wind """
rdr = csv.DictReader(StringIO(text))
required = { "date", "tmax", "tmin", "precip", "wind" }
if not required.issubset({c.strip().lower() for c in rdr.fieldnames or []}):
raise ValueError("CSV must have columns: date,tmax,tmin,precip,wind")
rows = []
for row in rdr:
try:
date = datetime.fromisoformat(row["date"].strip()).date()
except Exception:
# try common formats
date = datetime.strptime(row["date"].strip(), "%Y-%m-%d").date()
rows.append(
{
"date": date,
"tmax": float(row["tmax"]),
"tmin": float(row["tmin"]),
"precip": float(row["precip"]),
"wind": float(row["wind"]),
}
)
rows.sort(key=lambda r: r["date"])
return rows
# --------------------------- API functions --------------------------- #
def geocode_city(q: str) -> dict:
url = "https://geocoding-api.open-meteo.com/v1/search"
params = { "name": q, "count": 1, "language": "en", "format": "json" }
r = requests.get(url, params=params, timeout=20)
r.raise_for_status()
data = r.json()
hit = (data.get("results") or [None])[0]
if not hit:
raise ValueError("City not found")
return {
"name": hit["name"],
"country": hit.get("country_code", ""),
"lat": hit["latitude"],
"lon": hit["longitude"],
}
def fetch_forecast(lat: float, lon: float) -> list[dict[str, datetime | float]]:
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max",
"timezone": "auto",
"forecast_days": 7,
}
r = requests.get(url, params=params, timeout=20)
r.raise_for_status()
j = r.json()["daily"]
rows = []
for i, t in enumerate(j["time"]):
rows.append(
{
"date": datetime.fromisoformat(t).date(),
"tmax": float(j["temperature_2m_max"][i]),
"tmin": float(j["temperature_2m_min"][i]),
"precip": float(j["precipitation_sum"][i]),
"wind": float(j["windspeed_10m_max"][i]),
}
)
return rows
# ---------------------------- UI Class ---------------------------- #
class WeatherApp(tk.Tk):
def __init__(self):
super().__init__()
self.title(TITLE)
self.geometry("1080x720")
# State
self.unit = tk.StringVar(value="C")
self.place_label = tk.StringVar(value="Demo")
self.city_query = tk.StringVar(value="")
self.status = tk.StringVar(value="Ready")
self.rows = None
self._build_ui()
self._render_all()
# ------------------------ UI Construction ------------------------ #
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
# Top bar
top = ttk.Frame(self, padding=10)
top.grid(row=0, column=0, sticky="ew")
top.columnconfigure(1, weight=1)
ttk.Label(top, text="City:").grid(row=0, column=0, padx=(0, 6))
entry = ttk.Entry(top, textvariable=self.city_query)
entry.grid(row=0, column=1, sticky="ew")
entry.bind("<Return>", lambda _: self.search_city())
ttk.Button(top, text="Search", command=self.search_city).grid(row=0, column=2, padx=6)
ttk.Button(top, text="Load CSV…", command=self.load_csv).grid(row=0, column=3, padx=6)
ttk.Label(top, text="Units:").grid(row=0, column=4, padx=(12, 6))
unit_box = ttk.Combobox(top, textvariable=self.unit, values=("C", "F"), width=4, state="readonly")
unit_box.grid(row=0, column=5)
unit_box.bind("<<ComboboxSelected>>", lambda _: self._render_all())
ttk.Label(top, textvariable=self.place_label, font=("TkDefaultFont", 10, "italic")).grid(row=0, column=6, padx=(12, 0))
# Stats bar
stats = ttk.Frame(self, padding=(10, 0))
stats.grid(row=1, column=0, sticky="ew")
stats.columnconfigure((0, 1, 2, 3), weight=1)
self.stat_tmax = ttk.Label(stats, anchor="center")
self.stat_tmin = ttk.Label(stats, anchor="center")
self.stat_prec = ttk.Label(stats, anchor="center")
self.stat_wind = ttk.Label(stats, anchor="center")
self.stat_tmax.grid(row=0, column=0, sticky="ew", padx=6, pady=6)
self.stat_tmin.grid(row=0, column=1, sticky="ew", padx=6, pady=6)
self.stat_prec.grid(row=0, column=2, sticky="ew", padx=6, pady=6)
self.stat_wind.grid(row=0, column=3, sticky="ew", padx=6, pady=6)
# Charts area
charts = ttk.Panedwindow(self, orient=tk.VERTICAL)
charts.grid(row=2, column=0, sticky="nsew", padx=10, pady=6)
self.fig_temp = Figure(figsize=(6, 2.6), dpi=100)
self.ax_temp = self.fig_temp.add_subplot(111)
self.cv_temp = FigureCanvasTkAgg(self.fig_temp, master=charts)
charts.add(self.cv_temp.get_tk_widget())
self.fig_prec = Figure(figsize=(6, 2.2), dpi=100)
self.ax_prec = self.fig_prec.add_subplot(111)
self.cv_prec = FigureCanvasTkAgg(self.fig_prec, master=charts)
charts.add(self.cv_prec.get_tk_widget())
self.fig_wind = Figure(figsize=(6, 5.2), dpi=100)
self.ax_wind = self.fig_wind.add_subplot(1, 1, 1)
self.cv_wind = FigureCanvasTkAgg(self.fig_wind, master=charts)
charts.add(self.cv_wind.get_tk_widget())
# Status bar
status = ttk.Frame(self, padding=8)
status.grid(row=3, column=0, sticky="ew")
self.status_lbl = ttk.Label(status, textvariable=self.status, anchor="w")
self.status_lbl.pack(fill="x")
# --------------------------- Rendering --------------------------- #
def _render_stats(self) -> None:
if not self.rows:
self.stat_tmax["text"] = "Latest Tmax: —"
self.stat_tmin["text"] = "Latest Tmin: —"
self.stat_prec["text"] = "Latest Precip: —"
self.stat_wind["text"] = "Latest Wind: —"
return
latest = sorted(self.rows, key=lambda r: r["date"])[-1]
unit = self.unit.get()
tmax = to_unit(latest["tmax"], unit)
tmin = to_unit(latest["tmin"], unit)
self.stat_tmax["text"] = f"Latest Tmax: {tmax:.1f} °{unit}"
self.stat_tmin["text"] = f"Latest Tmin: {tmin:.1f} °{unit}"
self.stat_prec["text"] = f"Latest Precip: {latest['precip']:.1f} mm"
self.stat_wind["text"] = f"Latest Wind: {latest['wind']:.1f} km/h"
def _render_charts(self) -> None:
if not self.rows:
for ax in (self.ax_temp, self.ax_prec, self.ax_wind):
ax.clear()
for cv in (self.cv_temp, self.cv_prec, self.cv_wind):
cv.draw()
return
unit = self.unit.get()
# Prepare series
dates = [r["date"] for r in self.rows]
tmax = [to_unit(r["tmax"], unit) for r in self.rows]
tmin = [to_unit(r["tmin"], unit) for r in self.rows]
precip = [r["precip"] for r in self.rows]
wind = [r["wind"] for r in self.rows]
# Temperature line chart
self.ax_temp.clear()
self.ax_temp.plot(dates, tmax, label=f"Tmax (°{unit})")
self.ax_temp.plot(dates, tmin, label=f"Tmin (°{unit})")
self.ax_temp.set_title("Daily Temperature")
self.ax_temp.set_xlabel("Date")
self.ax_temp.set_ylabel(f"°{unit}")
self.ax_temp.legend(loc="best")
self.ax_temp.grid(True, alpha=0.25)
self.fig_temp.tight_layout()
self.cv_temp.draw()
# Precipitation bar chart
self.ax_prec.clear()
self.ax_prec.bar(dates, precip)
self.ax_prec.set_title("Daily Precipitation")
self.ax_prec.set_xlabel("Date")
self.ax_prec.set_ylabel("mm")
self.ax_prec.grid(True, axis="y", alpha=0.25)
self.fig_prec.tight_layout()
self.cv_prec.draw()
# Wind area chart (fill between)
self.ax_wind.clear()
self.ax_wind.fill_between(dates, wind, step="pre", alpha=0.4)
self.ax_wind.plot(dates, wind)
self.ax_wind.set_title("Daily Wind")
self.ax_wind.set_xlabel("Date")
self.ax_wind.set_ylabel("km/h")
self.ax_wind.grid(True, alpha=0.25)
self.cv_wind.draw()
def _render_all(self) -> None:
self._render_stats()
self._render_charts()
# --------------------------- Actions --------------------------- #
def load_csv(self) -> None:
path = filedialog.askopenfilename(filetypes=[("CSV files", "*.csv"), ("All files", "*.*")])
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
self.rows = parse_csv_text(text)
self.place_label.set("Custom CSV")
self.status.set(f"Loaded {len(self.rows)} rows from {path}")
self._render_all()
except Exception as e:
messagebox.showerror("CSV Error", str(e))
def search_city(self) -> None:
if q := self.city_query.get().strip():
self.status.set("Searching...")
thread = threading.Thread(target=self._search_city_thread, args=(q,), daemon=True)
thread.start()
def _search_city_thread(self, query: str) -> None:
try:
g = geocode_city(query)
rows = fetch_forecast(g["lat"], g["lon"])
def apply():
self.rows = rows
self.place_label.set(f"{g['name']}, {g.get('country','')}")
self.status.set("Loaded 7‑day forecast")
self._render_all()
self.after(0, apply)
except Exception as e:
self.after(0, lambda: messagebox.showerror("Search Error", str(e)))
self.after(0, lambda: self.status.set("Ready"))
if __name__ == "__main__":
app = WeatherApp()
app.mainloop()