中級者向け No.04

天気予報アプリ

OpenWeatherMap APIで現在の天気と5日間予報を取得して表示するアプリ。requestsライブラリとJSONデータ処理を学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ), requests 🧩 作るもの: 都市名で引く現在の天気と5日間予報の表示アプリ(全244行)

1. アプリ概要

出かける前に天気サイトを開く代わりに、都市名を英語で入れて検索するだけで、今の気温・湿度・風速と5日間の予報が1つのウィンドウに並ぶデスクトップアプリを作ります。OpenWeatherMap API への問い合わせは requests ライブラリで送り、返ってきた JSON データから必要な値を取り出して画面に配置します。

APIキーが未設定・無効のときや通信に失敗したときは、_show_demo が画面をデモデータの表示に切り替えるため、キーを取得する前でも画面の構成を確かめられます。

天気予報アプリ 実行画面(Windows)
実行画面(Windows)
天気予報アプリ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群を lambda c=city: (self.city_var.set(c), self._search()) で接続
  • 「🔍 検索」ボタン(_search())で操作
  • キー <Return> から _search() を実行
  • ウィンドウはタイトル「天気予報アプリ」・サイズ 600x540 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認) / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認)
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

インストールが必要なライブラリ

pip install requests

APIキーと外部通信

このアプリは検索するたびに OpenWeatherMap のサーバー(api.openweathermap.org)へ都市名と APIキーを送信し、天気データを受け取ります。通信するのはこの 2 つのエンドポイント(/data/2.5/weather・/data/2.5/forecast)だけで、ほかへの送信はありません。

利用には OpenWeatherMap のアカウント登録で発行される APIキーが必要です(取得手順は公式の How to start、プランごとの上限は料金ページ)。取得したデータの扱いは OpenWeatherMap の利用規約に従い、個人の学習の範囲で使ってください。無料プランの範囲や呼び出し回数の上限は変更されることがあるため、条件は公式サイトの最新情報を確認してください。

コード先頭の API_KEY = "YOUR_API_KEY_HERE" を自分のキーに置き換えてください。キーを書き込んだファイルは自分だけの秘密情報になります。他人と共有したり GitHub 等に公開したりしないでください(公開してしまったキーは OpenWeatherMap の管理画面で無効化して再発行します)。ファイルごと共有したい場合は、キーを直書きせず環境変数から読む形(API_KEY = os.environ.get("OWM_API_KEY", "YOUR_API_KEY_HERE"))に書き換えると、キーがコードに残りません(この場合はファイル先頭に import os を追加してください)。

キーを置き換えないまま実行すると、サーバーが 401 を返すため「APIキーが無効です」と表示してデモデータに切り替わります。UI の確認だけならキーなしでもできます。

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

📦 必要なライブラリをインストール(初回のみ)
pip install requests
app04.py
import tkinter as tk
from tkinter import ttk, messagebox
import requests
import threading
from datetime import datetime, timedelta, timezone


class App04:
    """天気予報アプリ"""

    # OpenWeatherMap API(無料枠)
    API_KEY = "YOUR_API_KEY_HERE"
    BASE_URL = "https://api.openweathermap.org/data/2.5"

    WEATHER_ICONS = {
        "Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️",
        "Drizzle": "🌦️", "Thunderstorm": "⛈️", "Snow": "❄️",
        "Mist": "🌫️", "Fog": "🌫️", "Haze": "🌫️",
    }

    def __init__(self, root):
        self.root = root
        self.root.title("天気予報アプリ")
        self.root.geometry("600x540")
        self.root.minsize(600, 540)
        self.root.configure(bg="#e8f4fd")
        self._build_ui()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#1976d2", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🌤️ 天気予報アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#1976d2", fg="white").pack(side=tk.LEFT, padx=12)

        # 検索バー
        search_frame = tk.Frame(self.root, bg="#e8f4fd", pady=10)
        search_frame.pack(fill=tk.X, padx=16)
        tk.Label(search_frame, text="都市名 (英語):",
                 bg="#e8f4fd", font=("Noto Sans JP", 11)).pack(side=tk.LEFT)
        self.city_var = tk.StringVar(value="Tokyo")
        city_entry = ttk.Entry(search_frame, textvariable=self.city_var,
                               font=("Arial", 12), width=20)
        city_entry.pack(side=tk.LEFT, padx=8)
        city_entry.bind("<Return>", lambda e: self._search())
        ttk.Button(search_frame, text="🔍 検索",
                   command=self._search).pack(side=tk.LEFT)

        # プリセット都市ボタン
        preset_frame = tk.Frame(self.root, bg="#e8f4fd")
        preset_frame.pack(fill=tk.X, padx=16)
        for city in ["Tokyo", "Osaka", "Sapporo", "Fukuoka", "London", "New York"]:
            tk.Button(preset_frame, text=city, bg="#90caf9",
                      relief=tk.FLAT, font=("Arial", 9), padx=6,
                      command=lambda c=city: (self.city_var.set(c), self._search())
                      ).pack(side=tk.LEFT, padx=2, pady=4)

        # 現在の天気パネル
        panel_bg = "#ffffff"
        self.current_frame = tk.LabelFrame(self.root, text="現在の天気",
                                           bg=panel_bg, padx=12, pady=12,
                                           font=("Noto Sans JP", 10))
        self.current_frame.pack(fill=tk.X, padx=16, pady=6)
        self.weather_icon_label = tk.Label(self.current_frame, text="",
                                           font=("Segoe UI Emoji", 40),
                                           bg=panel_bg)
        self.weather_icon_label.grid(row=0, column=0, rowspan=3, padx=12)
        self.city_label = tk.Label(self.current_frame, text="---",
                                   font=("Noto Sans JP", 18, "bold"),
                                   bg=panel_bg)
        self.city_label.grid(row=0, column=1, sticky="w")
        self.temp_label = tk.Label(self.current_frame, text="--°C",
                                   font=("Arial", 28, "bold"), fg="#1976d2",
                                   bg=panel_bg)
        self.temp_label.grid(row=1, column=1, sticky="w")
        self.desc_label = tk.Label(self.current_frame, text="",
                                   font=("Noto Sans JP", 11),
                                   bg=panel_bg)
        self.desc_label.grid(row=2, column=1, sticky="w")

        # 詳細情報
        detail_frame = tk.Frame(self.current_frame, bg=panel_bg)
        detail_frame.grid(row=0, column=2, rowspan=3, padx=20, sticky="n")
        self.detail_labels = {}
        for i, key in enumerate(["湿度", "風速", "体感温度", "気圧"]):
            tk.Label(detail_frame, text=f"{key}:", font=("Arial", 10),
                     bg=panel_bg).grid(row=i, column=0, sticky="w", pady=2)
            lbl = tk.Label(detail_frame, text="--", font=("Arial", 10, "bold"),
                           bg=panel_bg, fg="#1976d2")
            lbl.grid(row=i, column=1, sticky="w", padx=8)
            self.detail_labels[key] = lbl

        # 5日間予報
        forecast_frame = tk.LabelFrame(self.root, text="5日間予報",
                                       bg="#e8f4fd", padx=8, pady=8,
                                       font=("Noto Sans JP", 10))
        forecast_frame.pack(fill=tk.BOTH, expand=True, padx=16, pady=(0, 8))
        self.forecast_canvas = tk.Canvas(forecast_frame, bg="#f5f9ff",
                                         highlightthickness=0, height=160)
        self.forecast_canvas.pack(fill=tk.BOTH, expand=True)

        self.status_var = tk.StringVar(value="都市名を入力して検索してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _search(self):
        city = self.city_var.get().strip()
        if not city:
            return
        self.status_var.set(f"取得中: {city} ...")
        threading.Thread(target=self._fetch_weather, args=(city,),
                         daemon=True).start()

    def _fetch_weather(self, city):
        # requests はクエリ文字列を params= の辞書から組み立てる
        # (都市名の空白や記号のURLエンコードも自動で処理される)
        params = {"q": city, "appid": self.API_KEY,
                  "units": "metric", "lang": "ja"}
        try:
            # 現在天気
            resp = requests.get(f"{self.BASE_URL}/weather",
                                params=params, timeout=10)
            resp.raise_for_status()
            data = resp.json()          # JSON を dict に変換
            self.root.after(0, self._update_current, data)

            # 5日間予報(3時間ごと最大40件)
            resp = requests.get(f"{self.BASE_URL}/forecast",
                                params={**params, "cnt": 40}, timeout=10)
            resp.raise_for_status()
            forecast = resp.json()
            self.root.after(0, self._update_forecast, forecast)
        except requests.exceptions.HTTPError as e:
            code = e.response.status_code
            if code == 401:
                msg = "APIキーが無効です。YOUR_API_KEY_HEREを実際のキーに置き換えてください"
            elif code == 404:
                msg = f"都市が見つかりません: {city}"
            else:
                msg = f"HTTPエラー: {code}"
            self.root.after(0, self._show_demo, city, msg)
        except requests.exceptions.Timeout:
            self.root.after(0, self._show_demo, city, "通信がタイムアウトしました")
        except requests.exceptions.RequestException as e:
            # 例外メッセージにはAPIキー付きURLが含まれるので型名だけ表示する
            self.root.after(0, self._show_demo, city,
                            f"通信エラー: {type(e).__name__}")
        except Exception as e:
            self.root.after(0, self._show_demo, city, str(e))

    def _update_current(self, data):
        name = data["name"]
        temp = data["main"]["temp"]
        feels = data["main"]["feels_like"]
        hum = data["main"]["humidity"]
        wind = data["wind"]["speed"]
        pres = data["main"]["pressure"]
        desc = data["weather"][0]["description"]
        main = data["weather"][0]["main"]
        icon = self.WEATHER_ICONS.get(main, "🌡️")

        self.weather_icon_label.config(text=icon)
        self.city_label.config(text=name)
        self.temp_label.config(text=f"{temp:.1f}°C")
        self.desc_label.config(text=desc)
        self.detail_labels["湿度"].config(text=f"{hum}%")
        self.detail_labels["風速"].config(text=f"{wind} m/s")
        self.detail_labels["体感温度"].config(text=f"{feels:.1f}°C")
        self.detail_labels["気圧"].config(text=f"{pres} hPa")
        self.status_var.set(f"取得完了: {name}  ({datetime.now().strftime('%H:%M')})")

    def _update_forecast(self, data):
        self.forecast_canvas.delete("all")
        # dt・dt_txt はどちらも UTC なので、city.timezone(UTC からのずれ・秒)で現地時刻に直す
        tz = timezone(timedelta(seconds=data["city"]["timezone"]))
        # 1日1エントリに集約(現地時刻の正午にいちばん近いデータを優先)
        days = {}
        for item in data["list"]:
            local = datetime.fromtimestamp(item["dt"], tz)  # dt は UNIX 時刻(UTC)
            gap = abs(local.hour + local.minute / 60 - 12)
            if local.date() not in days or gap < days[local.date()][0]:
                days[local.date()] = (gap, item)
        entries = [(day, item) for day, (gap, item) in days.items()][:5]
        w = self.forecast_canvas.winfo_width() or 560
        col_w = w // max(len(entries), 1)
        for i, (day, item) in enumerate(entries):
            x = i * col_w + col_w // 2
            weekday = ["月", "火", "水", "木", "金", "土", "日"][day.weekday()]
            temp = item["main"]["temp"]
            main = item["weather"][0]["main"]
            icon = self.WEATHER_ICONS.get(main, "🌡️")
            desc = item["weather"][0]["description"]

            self.forecast_canvas.create_text(x, 20, text=f"{day:%m-%d} ({weekday})",
                                             font=("Arial", 9))
            self.forecast_canvas.create_text(x, 50, text=icon,
                                             font=("Segoe UI Emoji", 22))
            self.forecast_canvas.create_text(x, 90, text=f"{temp:.0f}°C",
                                             font=("Arial", 13, "bold"), fill="#1976d2")
            self.forecast_canvas.create_text(x, 115, text=desc,
                                             font=("Arial", 8), fill="#555")
            if i < len(entries) - 1:
                self.forecast_canvas.create_line(
                    (i+1)*col_w, 10, (i+1)*col_w, 140, fill="#ccc")

    def _show_demo(self, city, error_msg):
        """APIキー未設定時はデモデータを表示"""
        self.weather_icon_label.config(text="☀️")
        self.city_label.config(text=f"{city} (デモ)")
        self.temp_label.config(text="22.5°C")
        self.desc_label.config(text="晴れ (デモデータ)")
        self.detail_labels["湿度"].config(text="55%")
        self.detail_labels["風速"].config(text="3.2 m/s")
        self.detail_labels["体感温度"].config(text="21.8°C")
        self.detail_labels["気圧"].config(text="1013 hPa")
        self.status_var.set(f"⚠ {error_msg}  ※デモ表示中")

        # デモ予報
        self.forecast_canvas.delete("all")
        demo = [("☀️", "22°C", "晴れ"), ("⛅", "19°C", "曇り"),
                ("🌧️", "16°C", "雨"), ("☁️", "18°C", "曇り"),
                ("☀️", "24°C", "晴れ")]
        w = self.forecast_canvas.winfo_width() or 560
        col_w = w // 5
        from datetime import timedelta, date
        for i, (icon, temp, desc) in enumerate(demo):
            d = date.today() + timedelta(days=i)
            weekday = ["月", "火", "水", "木", "金", "土", "日"][d.weekday()]
            x = i * col_w + col_w // 2
            self.forecast_canvas.create_text(
                x, 20, text=f"{d.strftime('%m/%d')} ({weekday})", font=("Arial", 9))
            self.forecast_canvas.create_text(x, 50, text=icon,
                                             font=("Segoe UI Emoji", 22))
            self.forecast_canvas.create_text(x, 90, text=temp,
                                             font=("Arial", 13, "bold"), fill="#1976d2")
            self.forecast_canvas.create_text(x, 115, text=desc,
                                             font=("Arial", 8), fill="#555")


if __name__ == "__main__":
    root = tk.Tk()
    app = App04(root)
    root.mainloop()

5. コード解説

天気予報アプリのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App04 クラスにアプリの全機能をまとめています(メソッド7個・全244行)。__init__ ではタイトル「天気予報アプリ」とウィンドウサイズ 600x540 を設定します。最後に _build_ui() で画面を組み立てます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×4・tk.Label×7(ほかにループでも生成)・ttk.Entry・ttk.Button・tk.Button(ループで生成)・tk.LabelFrame×2・tk.Canvas で構成しています。見出し付きの枠(LabelFrame)は「現在の天気」「5日間予報」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「🔍 検索」ボタン(_search())を command= で接続しています。ボタンはループ内で command=lambda c=city: (self.city_var.set(c), self._search()) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。また bind("<Return>", ...) から _search() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:API_KEY = 'YOUR_API_KEY_HERE'、BASE_URL = 'https://api.openweathermap.org/data/2.5'。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

5日間予報を1日1件に集約する(_update_forecast)

予報 API は3時間ごとのデータを返し、その時刻(UNIX 時刻の dt、文字列の dt_txt)はどちらも UTC です。そのため _update_forecast() では city.timezone(UTC からのずれ・秒)で現地時刻に直してから、日付ごとに現地の正午にいちばん近いデータを1件選んでいます(曜日も現地の日付から求めます)。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で Exception・requests.exceptions.HTTPError・requests.exceptions.RequestException・requests.exceptions.Timeout を捕捉しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app04.py と保存します。外部ライブラリ requests を使います。先に pip install requests を実行してください。

  2. 2
    クラスの骨格を作る

    App04 クラスを定義し、__init__ と、末尾の root = tk.Tk() ~ mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("天気予報アプリ")・geometry("600x540")・configure(bg="#e8f4fd")・minsize(600, 540) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×4・tk.Label×7(ほかにループでも生成)・ttk.Entry・ttk.Button・tk.Button(ループで生成)・tk.LabelFrame×2・tk.Canvas を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_search())につなぎます。ループ生成のボタンは command=lambda c=city: (self.city_var.set(c), self._search()) の形で接続します。bind("<Return>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _update_current()(20行)・_update_forecast()(33行)・_show_demo()(32行)・_search()(7行) を実装します。

  7. 7
    動作確認する

    python app04.py で起動し、各ボタンが反応すること・キー操作(<Return>)が効くことを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 クラス定数を書き換えて動きを変える

データはクラス定数(API_KEY・BASE_URL)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:代わりのフォントで変わるのは、都市名と現在の天気の説明(Noto Sans JP)、気温や予報欄の文字(Arial)、天気アイコンの絵文字(Segoe UI Emoji)などの形と幅です。OpenWeatherMap から受け取った気温や予報の値は、どの書体でも同じ数字で表示されます。アイコンを見本に近づけたい場合は font=("Segoe UI Emoji", 40) などのフォント名を自分の OS にあるものへ書き換えるか、font 引数を省略してください。

❌ 写経したら全部のボタンが同じ動きになる

原因:ループでボタンを作るとき、このコードは command=lambda c=city: (self.city_var.set(c), self._search()) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。

解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。

❌ <Return> キーを押しても反応しない

原因:bind("<Return>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。

解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("600x540") の固定サイズで起動するためです。

解決法:geometry() と minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(600, 540) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は全アプリ共通の定型課題です)。APIキーを入れていない状態でも、デモ表示のまま両方の課題を確かめられます。キーを書き込んだファイルは平文のまま残るので、共有や公開はしないでください。

  1. 課題1:取得中の連打で通信を重ねない

    「🔍 検索」は押すたびに新しいスレッドを起動します。取得の途中でもう一度押すと通信が二重に走り、あとから返ってきた方が画面を上書きします。取得中は次の検索を始めないようにしてください。

    期待結果:「🔍 検索」を押すと、結果が返るまでボタンが押せない見た目に変わり、取得が終わると元に戻る。

    合格条件

    • 取得中はプリセットの都市ボタンや Enter キーでも検索が始まらない
    • デモ表示に切り替わったときも、ボタンは押せる状態に戻る
    • 都市名を空にして押したときは、これまでどおり何も起きない

    ヒント①(どこを触るか): 入口は _search() の1か所です。プリセットの都市ボタンも入力欄の <Return> も、最後はここを呼びます。終わりの合図は2か所——成功時の _update_forecast() と、失敗時に呼ばれる _show_demo() に入れてください。
    ヒント②(使うもの): __init__() に self._loading = False を足し、_search() の先頭で取得中なら return します。ボタンの見た目は state="disabled" と state="normal" の切り替えで変わります。
    つまずきやすい点: 検索ボタンは ttk.Button(...).pack(...) の形で1行にまとめられ、変数に残っていません。生成と pack() を分けないと self.search_btn は None になります。_update_current() は現在天気だけの終わりなので、そこで戻すと予報の取得中に次の検索を始められます。

  2. 課題2:5日間予報をウィンドウの幅に追従させる

    5日間予報は取得したときの幅で1回だけ描かれます。ウィンドウを横に広げても列の位置は変わらず、右側に余白が残ります。幅が変わったら描き直すようにしてください。

    期待結果:予報が出ている状態でウィンドウを横に広げると、5日分の列が新しい幅に合わせて均等に並び直す。

    合格条件

    • まだ一度も検索していない状態で大きさを変えてもエラーにならない
    • デモ表示中に広げても、5列が重ならずに並ぶ
    • 検索し直したときは、これまでどおり最新のデータが描かれる

    ヒント①(どこを触るか): 触るのは _build_ui()・_update_forecast()・_show_demo() の3か所と、__init__() の初期化1行です。canvas を作っているのは _build_ui() の self.forecast_canvas の行になります。
    ヒント②(使うもの): self.forecast_canvas.bind("<Configure>", ...) で幅の変化を受け取れます。直前に描いた内容を self.last_forecast などに控えておき、そこから _update_forecast() か _show_demo() を呼び直す形が近道です。列幅は winfo_width() から毎回計算し直しているので、呼び直すだけで幅は合います。
    つまずきやすい点: <Configure> はウィンドウの大きさが決まる最初のタイミングでも発生する点に気をつけてください。控えるための属性を __init__() で None にしておかないと、起動直後に AttributeError が出ます。_show_demo() を呼び直すときは第2引数のメッセージも必要で、渡した文字列がステータス欄に上書きされる点にも注意してください。

  3. 課題3:保存機能の追加

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
この記事の書き方を本で確かめるなら

このアプリで中心になるのは requests による API 取得と、datetime による時差の補正。下の表は『退屈なこと』と『Effective』について、出版社や著者が公開している目次から引いた該当章です(各章の解説内容は当サイトでは未確認。「—」は目次の見出しにその語が無いもので、扱っていないとは限りません)。

この記事で使う書き方退屈なことはPythonにやらせよう 第3版Effective Python 第3版
Web からの取得(requests)13章(Webスクレイピング・13.2 requestsモジュール)—
datetime(日付と時刻)19章(19.2 datetimeモジュール)12章(項目105 datetime)
try-except(例外処理)4章(4.8 例外処理)10章(項目80 try/except/else/finally)
文字列と f-string8章(8.2 f文字列)2章(項目11 フォーマット済み文字列)
スレッド(threading)—9章(並行性と並列性・項目68 スレッド)

本が無くても進めます。tkinter の部品はtkinter ウィジェット一覧、文法は基本文法一覧(どちらも当サイト)。2冊の違いと向く人は書籍紹介で比較しています。