10
\$\begingroup\$

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()
\$\endgroup\$

4 Answers 4

11
\$\begingroup\$

Good PEP 8 Conformance

You have conformed to the PEP 8 style guide reasonably well. For example:

  1. import statements are in the correct order.
  2. You have provided type hints for all functions and methods (but I believe the type hint for function parse_csv_text is incorrect since the dictionaries being returned have values that are either of type datetime or float).
  3. You have docstrings for functions and methods that a client could potentially want to directly call (see next section).
  4. Most lines, but not all, are not too long.

Visibility of Functions and Methods

You have named certain functions and methods with a leading underscore to suggest that they are "private" and also to prevent certain functions from automatically being imported when a client codes from some_module import *. This is great, but I wonder why you chose not to use the leading underscore in naming certain functions and methods that I would not expect a client to ever call, such as functions to_unit and parse_csv_text or methodsload_csv and search_city. That you have not included docstrings for these suggests that you do not consider these to be "public".

Multiple Daemon Threads for Searching Cities

Each time a user wants to search a city a new daemon thread is started. Consider starting a single daemon thread at initialization that is initialized with a queue.Queue instance. The thread's processing is a loop that reads and processes queries from this queue. Method search_city is then modified to put the query on the queue.

Suggestions for the User Interface

  1. You allow the user to specify whether they want temperatures to be displayed in centigrade or Fahrenheit. Great! But why not allow wind velocities to be displayed in miles per hour in addition to kilometers per hour?
  2. You currently allow a user to specify a city. But if I enter "Rome", surely you are returned multiple cities with that name in different countries and US states. Shouldn't the user be able to specify the country and, if applicable, state?
  3. When I load your CSV file example specifying data for a single date, I really do not understand why the Daily Temperature and Daily Wind graphs' horizontal axis specify multiple years and months but nothing is plotted. And I really do not understand the Daily Precipitation graph at all given the input. Of course, this might just be obtuseness on my part.
  4. The UI's height is not large enough to display the entire interface and the user is force to make the window larger.
  5. When the program starts up 3 empty graphs with no titles or labels for their horizontal and vertical axis are displayed. Couldn't the display of these graphs be postponed until a user has searched on a city or has loaded a CSV file?

Use of if __name__ == "__main__":

This is great since this makes the module importable without explicitly creating the user interface. But then you have:

if __name__ == "__main__":
    app = WeatherApp()
    app.mainloop()

How would a user know to call app.mainloop() to actually display the UI since that call is tkinter specific? You could add a documented show method:

class WeatherApp(tk.Tk):
   ...

    def show(self) -> None:
        """Display the user interface."""

        self.mainloop()


if __name__ == "__main__":
    app = WeatherApp()
    app.show()
\$\endgroup\$
5
\$\begingroup\$

In addition to the previous answer, here are some other suggestions.

User Interface

The GUI looks great.

However, there is a minor portability issue with some of the Unicode characters in the source code.

In this line:

ttk.Button(top, text="Load CSV…", command=self.load_csv).grid(row=0, column=3, padx=6)

the ellipsis shows up as a left paren for me in the button at the top of the GUI: "Load CSV)". I think the ellipsis can simply be removed:

ttk.Button(top, text="Load CSV", command=self.load_csv).grid(row=0, column=3, padx=6)

Similarly, I see this in the bottom left in the GUI:

Loaded 7)day forecast

The dash character is also converted to a right paren.


In the temperature graph, it is more common to refer to temperatures as "High" and "Low", instead of "Tmax" and "Tmin", respectively.


If I type a number into the "City" field, such as "777", it does find a city and presents data in the graphs. This is unexpected. If you support that, please add some information into the GUI to explain what an entered number is meant to represent. Otherwise, you could check for numbers only and report an error.


If I enter a city which can not be found, I get a pop-up error window, as expected. However, the message ("City not found") is so short that I don't see the full title of the message box because the pop-up window is not wide enough. You could try to set the width of that window. Also, you could change:

raise ValueError("City not found")

to:

raise ValueError(f"City not found: {q}")

This gives the user a more specific error message.

\$\endgroup\$
0
3
\$\begingroup\$

You already have an alias tk to tkinter (good) - so there isn't much value in also from tkinter import filedialog, messagebox. Keep those in their (aliased) namespace.

to_unit's unit should be hinted as a typing.Literal['C', 'F'].

StringIO(text) has a context management interface, so put it in a with.

Convert required to a frozenset that is only constructed once in the global namespace. (Or you could have it as an instance member of a model class, which would also be reasonable). Speaking of which - write a model class. Don't pass around dictionaries; write a simple NamedTuple that captures your row data.

fromisoformat is good, but... isn't "%Y-%m-%d" also ISO format? How is that different?

It's important that geocode_city and fetch_forecast use a session, and close out the response once you're done with it. This could look like:

def geocode_city(session: requests.Session, q: str) -> dict[str, typing.Any]:
    with session.get(
        url="https://geocoding-api.open-meteo.com/v1/search",
        params={"name": q, "count": 1, "language": "en", "format": "json"},
        timeout=20,
    ) as response:
        response.raise_for_status()
        data = response.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(session: requests.Session, lat: float, lon: float) -> list[dict[str, datetime | float]]:
    with session.get(
        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,
        },
        timeout=20,
    ) as response:
        response.raise_for_status()
        j = response.json()["daily"]

    rows = [
        {
            "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]),
        }
        for i, t in enumerate(j["time"])
    ]
    return rows

WeatherApp inheriting from tk.Tk is a common mis-design floating around on the internet. It isn't that WeatherApp is a tk window; it has a tk window that should be kept private. Make a Tk that's an instance variable.

Your StringVar should have both parents and names:

        self.unit = tk.StringVar(window, name='unit', value="C")
        self.place_name = tk.StringVar(window, name='place_name', value="Demo")
        self.city_query = tk.StringVar(window, name='city_query', value="")
        self.status = tk.StringVar(window, name='status', value="Ready")

When you construct a Thread, I don't think it's a very good idea to immediately drop its reference. You should hold onto it, and ensure that only one thread is being run at a time.

Both apply and the exception trap can be moved to instance methods rather than closures:

    def _apply(self, g, rows) -> None:
        self.rows = rows
        self.place_name.set(f"{g['name']}, {g.get('country', '')}")
        self.status.set("Loaded 7‑day forecast")
        self._render_all()

    def _report_error(self, e: Exception) -> None:
        self.status.set("Ready")
        tk.messagebox.showerror("Search Error", str(e))

    def _search_city_thread(self, query: str) -> None:
        try:
            g = geocode_city(self.session, query)
            rows = fetch_forecast(self.session, g["lat"], g["lon"])
            self.window.after(0, self._apply, g, rows)
        except Exception as e:
            self.window.after(0, self._report_error, e)

I prefer this style for a few reasons, including that the functions are more unit-testable, and you have more control over what state they get exposed to (rather than a closure that gets exposed to all state in the outer scope).

This:

if __name__ == "__main__":
    app = WeatherApp()
    app.mainloop()

does not sufficiently guard app from becoming a global variable. The easiest fix in this context is

if __name__ == "__main__":
    WeatherApp().mainloop()

More broadly: you should endeavour to pry apart your view (tk) code from your business logic, and write the latter in a separate class.

Comments like these:

# ---------------------------- UI Class ---------------------------- #

must either be deleted if the code is short, or you have a file that's too long and you need to move code into separate module files. Namespaces are better than comments.

\$\endgroup\$
2
\$\begingroup\$

Some minor points from me.

Intuitive function names

Function names should be descriptive, so c_to_f should be named celsius_to_fahrenheit. It's not that long and there is no real benefit to using short names. It obfuscates intent when the function purpose is less obvious.

Likewise, in function parse_csv_text, rdr should simply be named reader.

Performance

Function load_csv could be more efficient. What this function does is load the whole file, then send a big string to another function for parsing as CSV:

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)

Your application could suffer performance issues with large files. Instead, you could read the file line by line. You don't need to consume the whole file at once, so don't do it since this is potentially wasteful. And if the file is malformed, an exception will occur early, so that you may end up wasting less CPU.

Example from the Python docs (CSV module)

with open('names.csv', newline='') as csvfile:
    reader = csv.DictReader(csvfile)
    for row in reader:
        print(row['first_name'], row['last_name'])

Personally, I might use a SQLite DB to import and store results. Yes, you are loading rows in memory effectively, so lookups should be fast, but again how will your application cope with very large datasets?

Separation of responsibility

In programming, there is a saying that a function should do just one thing and do it well.

Your function _search_city_thread does two different things:

  • retrieve some data by calling fetch_forecast
  • then update the UI

I find the name somewhat ambiguous. And it coexists with another function named search_city. It should be more like get_forecasts_for_city or something like that. Without looking at the code, I can now guess what it actually does, and what kind of data it returns.

What the function should do: just fetch and return the data, and let the calling routine refresh the UI.

On a side note: I have used other programming languages where you just don't update the UI from a separate thread.

\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.