天気予報アプリ
OpenWeatherMap APIで現在の天気と5日間予報を取得して表示するアプリ。requestsライブラリとJSONデータ処理を学びます。
1. アプリ概要
OpenWeatherMap APIで現在の天気と5日間予報を取得して表示するアプリ。requestsライブラリとJSONデータ処理を学びます。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・requests で、難易度は ★★☆ です。
このアプリは「ネットワーク」カテゴリです。ネットワーク連携は Python の代表的な活用領域で、I/O 待ちと UI 更新の関係や非同期処理の考え方が他の Web API 連携にも直接活きます。tkinter(標準ライブラリ)・requests を活かして実装するこの構造は、他のアプリにも応用が効きます。
動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。
カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。
2. 機能一覧
- ループで生成するボタン群を
lambda c=city: (self.city_var.set(c), self._search())で接続 - 「🔍 検索」ボタン(
_search())で操作 - キー
<Return>から_search()を実行 - ウィンドウはタイトル「天気予報アプリ」・サイズ 600x540 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。
- Python 3.10 以上
- 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. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox
import requests
import threading
from datetime import datetime
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")
# 1日1エントリに集約(正午データ優先)
days = {}
for item in data["list"]:
date = item["dt_txt"][:10]
hour = item["dt_txt"][11:13]
if date not in days or hour == "12":
days[date] = item
entries = list(days.values())[:5]
w = self.forecast_canvas.winfo_width() or 560
col_w = w // max(len(entries), 1)
for i, item in enumerate(entries):
x = i * col_w + col_w // 2
date = item["dt_txt"][:10]
weekday = ["月", "火", "水", "木", "金", "土", "日"][
datetime.strptime(date, "%Y-%m-%d").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"{date[5:]} ({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. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・requests.exceptions.HTTPError・requests.exceptions.RequestException・requests.exceptions.Timeout を捕捉しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app04.py と保存します。外部ライブラリ
requestsを使います。先にpip install requestsを実行してください。 -
2クラスの骨格を作る
App04クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("天気予報アプリ")・geometry("600x540")・configure(bg="#e8f4fd")・minsize(600, 540)をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×4・tk.Label×7(ほかにループでも生成)・ttk.Entry・ttk.Button・tk.Button(ループで生成)・tk.LabelFrame×2・tk.Canvasを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_search())につなぎます。ループ生成のボタンはcommand=lambda c=city: (self.city_var.set(c), self._search())の形で接続します。bind("<Return>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_update_current()(20行)・_update_forecast()(33行)・_show_demo()(32行)・_search()(7行) を実装します。 -
7動作確認する
python app04.pyで起動し、各ボタンが反応すること・キー操作(<Return>)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 クラス定数を書き換えて動きを変える
データはクラス定数(API_KEY・BASE_URL)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。
💡 入力履歴機能
以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の 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:取得中の連打で通信を重ねない
「🔍 検索」は押すたびに新しいスレッドを起動します。取得の途中でもう一度押すと通信が二重に走り、あとから返ってきた方が画面を上書きします。取得中は次の検索を始めないようにしてください。
期待結果:「🔍 検索」を押すと、結果が返るまでボタンが押せない見た目に変わり、取得が終わると元に戻る。
合格条件
- 取得中はプリセットの都市ボタンや 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: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:保存機能の追加
入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。