デスクトップ通知スケジューラ
指定した時刻に任意のメッセージをデスクトップ通知するスケジューラアプリ。after()による定期チェックとplyer通知APIを学ぶ。
1. アプリ概要
指定した時刻に任意のメッセージをデスクトップ通知するスケジューラアプリ。after()による定期チェックとplyer通知APIを学ぶ。
このアプリはappカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter・plyer、難易度は ★★☆ 普通 です。
このアプリは「実用アプリ」カテゴリです。実用的なアプリの開発は、GUI とビジネスロジックの分離・データ永続化・エラー処理の全てを一度に学べる好機です。tkinter・plyer を活かして実装するこの構造は、他のアプリにも応用が効きます。
コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。
応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。
2. 機能一覧
- 「+ 追加」ボタン(
_add())・「✏ 編集」ボタン(_edit())・「🗑 削除」ボタン(_delete())・「▶ 今すぐ通知」ボタン(_notify_now())・「☑ 有効/無効切替」ボタン(_toggle_enabled())・「保存」ボタンで操作 after(1000, ...)による1000ミリ秒間隔の_update_clock()呼び出し(画面の自動更新)ttk.Treeviewによる表形式の一覧表示- JSON ファイルの保存・読み込み(起動時に自動実行)
messagebox.showinfo()によるダイアログ通知- ウィンドウはタイトル「デスクトップ通知スケジューラ」・サイズ 800x520 で起動
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(仮想マシンで起動・画面表示を確認)
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox
import threading
import time
import os
import sys
import json
from datetime import datetime, timedelta
try:
from plyer import notification
PLYER_AVAILABLE = True
except ImportError:
PLYER_AVAILABLE = False
SAVE_PATH = os.path.join(os.path.dirname(__file__), "notify_schedule.json")
class App085:
"""デスクトップ通知スケジューラ"""
REPEAT_OPTIONS = ["なし", "毎分", "毎時間", "毎日", "毎週"]
def __init__(self, root):
self.root = root
self.root.title("デスクトップ通知スケジューラ")
self.root.geometry("800x520")
self.root.configure(bg="#1e1e1e")
self._tasks = [] # [{"title":, "message":, "time":, "repeat":, "enabled":, "last_notified":}]
self._running = True
self._build_ui()
self._load()
self._tick_thread = threading.Thread(target=self._tick_loop, daemon=True)
self._tick_thread.start()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _build_ui(self):
header = tk.Frame(self.root, bg="#252526", pady=6)
header.pack(fill=tk.X)
tk.Label(header, text="🔔 デスクトップ通知スケジューラ",
font=("Noto Sans JP", 12, "bold"),
bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)
if not PLYER_AVAILABLE:
tk.Label(self.root,
text="⚠ plyer が未インストールです (pip install plyer) ─ 内蔵通知で代替",
bg="#fff3cd", fg="#856404", font=("Arial", 9),
anchor="w", padx=8).pack(fill=tk.X)
# ツールバー
tb = tk.Frame(self.root, bg="#2d2d2d", pady=4)
tb.pack(fill=tk.X)
tk.Button(tb, text="+ 追加", command=self._add,
bg="#2e7d32", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#1b5e20", bd=0).pack(side=tk.LEFT, padx=4)
tk.Button(tb, text="✏ 編集", command=self._edit,
bg="#1565c0", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#0d47a1", bd=0).pack(side=tk.LEFT, padx=2)
tk.Button(tb, text="🗑 削除", command=self._delete,
bg="#c62828", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#b71c1c", bd=0).pack(side=tk.LEFT, padx=2)
ttk.Button(tb, text="▶ 今すぐ通知",
command=self._notify_now).pack(side=tk.LEFT, padx=8)
ttk.Button(tb, text="☑ 有効/無効切替",
command=self._toggle_enabled).pack(side=tk.LEFT, padx=4)
# スケジュールリスト
cols = ("enabled", "title", "time", "repeat", "next")
self.tree = ttk.Treeview(self.root, columns=cols, show="headings",
selectmode="browse")
self.tree.heading("enabled", text="有効")
self.tree.heading("title", text="タイトル")
self.tree.heading("time", text="通知時刻")
self.tree.heading("repeat", text="繰り返し")
self.tree.heading("next", text="次回通知")
self.tree.column("enabled", width=50, anchor="center")
self.tree.column("title", width=200, anchor="w")
self.tree.column("time", width=100, anchor="center")
self.tree.column("repeat", width=80, anchor="center")
self.tree.column("next", width=160, anchor="center")
tsb = ttk.Scrollbar(self.root, command=self.tree.yview)
self.tree.configure(yscrollcommand=tsb.set)
tsb.pack(side=tk.RIGHT, fill=tk.Y)
self.tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
self.tree.tag_configure("disabled", foreground="#555")
self.tree.tag_configure("soon", foreground="#ffa726")
# ログ
log_f = tk.Frame(self.root, bg="#0d1117")
log_f.pack(fill=tk.X, padx=8, pady=2)
self.log_text = tk.Text(log_f, height=4, bg="#0d1117", fg="#8b949e",
font=("Courier New", 8), relief=tk.FLAT,
state=tk.DISABLED)
self.log_text.pack(fill=tk.X)
self.clock_var = tk.StringVar()
tk.Label(self.root, textvariable=self.clock_var,
bg="#252526", fg="#858585", font=("Courier New", 9),
anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)
self._update_clock()
def _update_clock(self):
self.clock_var.set(f"現在時刻: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
self.root.after(1000, self._update_clock)
def _log(self, msg):
self.log_text.configure(state=tk.NORMAL)
self.log_text.insert(tk.END, f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
self.log_text.see(tk.END)
self.log_text.configure(state=tk.DISABLED)
def _refresh(self):
self.tree.delete(*self.tree.get_children())
now = datetime.now()
for t in self._tasks:
next_dt = self._next_notify(t)
next_str = next_dt.strftime("%Y-%m-%d %H:%M") if next_dt else "-"
diff = (next_dt - now).total_seconds() if next_dt else 999999
tag = "disabled" if not t["enabled"] else ("soon" if 0 < diff < 60 else "")
self.tree.insert("", tk.END,
values=("✔" if t["enabled"] else "✘",
t["title"], t["time"],
t["repeat"], next_str),
tags=(tag,))
def _next_notify(self, task):
"""次の通知予定を計算"""
try:
hhmm = task["time"] # HH:MM
now = datetime.now()
h, m = map(int, hhmm.split(":"))
base = now.replace(hour=h, minute=m, second=0, microsecond=0)
repeat = task["repeat"]
if repeat == "なし":
return base if base > now else None
elif repeat == "毎分":
return now + timedelta(minutes=1)
elif repeat == "毎時間":
next_ = now.replace(minute=m, second=0, microsecond=0)
if next_ <= now:
next_ += timedelta(hours=1)
return next_
elif repeat == "毎日":
if base <= now:
base += timedelta(days=1)
return base
elif repeat == "毎週":
if base <= now:
base += timedelta(weeks=1)
return base
except Exception:
return None
def _should_notify(self, task):
if not task["enabled"]:
return False
now = datetime.now()
try:
hhmm = task["time"]
h, m = map(int, hhmm.split(":"))
repeat = task["repeat"]
last = task.get("last_notified", "")
now_str = now.strftime("%Y-%m-%d %H:%M")
if now.hour == h and now.minute == m and now_str != last:
if repeat in ("毎日", "毎週", "なし"):
return True
if repeat == "毎分" and now_str != last:
return True
if repeat == "毎時間" and now.minute == m and now_str != last:
return True
except Exception:
pass
return False
def _tick_loop(self):
while self._running:
time.sleep(5)
for task in self._tasks:
if self._should_notify(task):
task["last_notified"] = datetime.now().strftime("%Y-%m-%d %H:%M")
self.root.after(0, self._fire_notification, task)
self.root.after(0, self._refresh)
def _fire_notification(self, task):
self._log(f"通知: {task['title']}")
if PLYER_AVAILABLE:
try:
notification.notify(
title=task["title"],
message=task.get("message", ""),
app_name="通知スケジューラ",
timeout=5)
return
except Exception:
pass
# フォールバック: tkinter messagebox
messagebox.showinfo(task["title"], task.get("message", ""))
def _add(self):
self._edit_dialog(None)
def _edit(self):
sel = self.tree.selection()
if not sel:
return
self._edit_dialog(self.tree.index(sel[0]))
def _edit_dialog(self, idx):
entry = self._tasks[idx] if idx is not None else {
"title": "リマインダー", "message": "", "time": "09:00",
"repeat": "毎日", "enabled": True}
dlg = tk.Toplevel(self.root)
dlg.title("通知設定")
dlg.configure(bg="#252526")
dlg.grab_set()
fields = [
("タイトル", "title", entry["title"]),
("メッセージ", "message", entry.get("message", "")),
("通知時刻 (HH:MM)", "time", entry["time"]),
]
vars_ = {}
for label, key, default in fields:
row = tk.Frame(dlg, bg="#252526")
row.pack(fill=tk.X, padx=12, pady=4)
tk.Label(row, text=f"{label}:", bg="#252526", fg="#ccc",
width=18, anchor="w").pack(side=tk.LEFT)
v = tk.StringVar(value=default)
vars_[key] = v
ttk.Entry(row, textvariable=v, width=24).pack(side=tk.LEFT, padx=4)
row = tk.Frame(dlg, bg="#252526")
row.pack(fill=tk.X, padx=12, pady=4)
tk.Label(row, text="繰り返し:", bg="#252526", fg="#ccc",
width=18, anchor="w").pack(side=tk.LEFT)
rep_var = tk.StringVar(value=entry["repeat"])
ttk.Combobox(row, textvariable=rep_var,
values=self.REPEAT_OPTIONS,
state="readonly", width=10).pack(side=tk.LEFT)
en_var = tk.BooleanVar(value=entry["enabled"])
tk.Checkbutton(dlg, text="有効", variable=en_var,
bg="#252526", fg="#ccc", selectcolor="#3c3c3c",
activebackground="#252526").pack(padx=12, pady=4, anchor="w")
def _ok():
new = {
"title": vars_["title"].get(),
"message": vars_["message"].get(),
"time": vars_["time"].get(),
"repeat": rep_var.get(),
"enabled": en_var.get(),
"last_notified": "",
}
if idx is None:
self._tasks.append(new)
else:
self._tasks[idx] = new
self._refresh()
self._save()
dlg.destroy()
ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
def _delete(self):
sel = self.tree.selection()
if not sel:
return
del self._tasks[self.tree.index(sel[0])]
self._refresh()
self._save()
def _toggle_enabled(self):
sel = self.tree.selection()
if not sel:
return
t = self._tasks[self.tree.index(sel[0])]
t["enabled"] = not t["enabled"]
self._refresh()
self._save()
def _notify_now(self):
sel = self.tree.selection()
if not sel:
return
self._fire_notification(self._tasks[self.tree.index(sel[0])])
def _save(self):
try:
with open(SAVE_PATH, "w", encoding="utf-8") as f:
json.dump(self._tasks, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _load(self):
if os.path.exists(SAVE_PATH):
try:
with open(SAVE_PATH, encoding="utf-8") as f:
self._tasks = json.load(f)
except Exception:
self._tasks = []
else:
self._tasks = [
{"title": "朝のリマインダー", "message": "おはようございます!",
"time": "09:00", "repeat": "毎日",
"enabled": True, "last_notified": ""},
]
self._refresh()
def _on_close(self):
self._running = False
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App085(root)
root.mainloop()
5. コード解説
デスクトップ通知スケジューラのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App085 クラスにアプリの全機能をまとめています(メソッド18個・全323行)。__init__ ではタイトル「デスクトップ通知スケジューラ」とウィンドウサイズ 800x520 を設定します。あわせて状態を保持する変数(self._running)を初期化し、そのあと _build_ui() → _load() の順に呼び出して初期状態を作ります。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×4(ほかにループでも生成)・tk.Label×4(ほかにループでも生成)・tk.Button×3・ttk.Button×3・ttk.Treeview・ttk.Scrollbar・tk.Text・tk.Toplevel・ttk.Entry(ループで生成)・ttk.Combobox・tk.Checkbutton で構成しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「+ 追加」ボタン(_add())・「✏ 編集」ボタン(_edit())・「🗑 削除」ボタン(_delete())・「▶ 今すぐ通知」ボタン(_notify_now())・「☑ 有効/無効切替」ボタン(_toggle_enabled())・「保存」ボタンを command= で接続しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
after() による定期処理
_update_clock() の末尾で after(1000, ...) を呼び、1000ミリ秒後に自分自身の再実行を予約しています。tkinter では while ループで待つと画面が固まるため、この「予約の連鎖」で定期処理を作るのが定石です。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
JSON への保存と読み込み
データは json.dump() でファイル(notify_schedule.json)へ保存します。起動時は json.load() で読み戻します。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
クラス定数によるデータ定義
クラスの先頭でデータを定数として定義しています:REPEAT_OPTIONS = ['なし', '毎分', '毎時間', '毎日', '毎週']。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app085.py と保存します。外部ライブラリ
plyerを使います。先にpip install plyerを実行してください。 -
2クラスの骨格を作る
App085クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("デスクトップ通知スケジューラ")・geometry("800x520")・configure(bg="#1e1e1e")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×4(ほかにループでも生成)・tk.Label×4(ほかにループでも生成)・tk.Button×3・ttk.Button×3・ttk.Treeview・ttk.Scrollbar・tk.Text・tk.Toplevel・ttk.Entry(ループで生成)・ttk.Combobox・tk.Checkbuttonを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_add()・_edit()・_delete()・_notify_now()・_toggle_enabled()・_ok)につなぎます。 -
6中心になるメソッドを実装する
アプリの本体である
_update_clock()(3行)・_refresh()(13行)・_fire_notification()(14行)など を実装します。定期処理はafter(1000, ...)で自分自身を予約し続ける形にします。 -
7動作確認する
python app085.pyで起動し、各ボタンが反応すること・表示が自動で更新されることを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 更新間隔を変えてみる
after(1000, ...) の 1000 が更新間隔(ミリ秒)です。大きくすると更新頻度が下がって CPU 負荷が軽くなり、小さくすると更新が細かくなります(表示の刻み幅より細かくしても見た目は変わりません)。数値を変えて違いを確かめてみましょう。
💡 設定ダイアログ
フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ 改造したら表示の更新が二重になった・速くなった
原因:_update_clock() は after(1000, ...) で自分自身を予約し続ける構造です。予約を止めずにもう一度開始経路を通ると、予約の連鎖が2本になり、更新が倍速になります。
解決法:開始処理の前に動作中かどうかをフラグで判定してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("800x520") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は全アプリ共通の定型課題です)。
-
課題1:通知時刻の書式をチェックする
通知時刻に「9時」や「25:00」と入れても、そのまま保存できてしまいます。
_next_notify()も_should_notify()も例外を握りつぶすため、エラーは出ないのに通知だけが来ない状態になります。保存前に書式を確かめます。期待結果:通知時刻に「9時」と入れて保存を押すと警告が出て、ダイアログが閉じない。「09:00」なら今までどおり保存できる。
合格条件
- 「25:00」や「9:60」も弾く
- 繰り返しを「毎日」にして正しい時刻を保存すると、一覧の「次回通知」列に日時が入る
- 「+ 追加」と「✏ 編集」のどちらから開いても同じ検証が効く
ヒント①(どこを触るか): 入力を受け取って
self._tasksに入れているのは_edit_dialog()の中の_ok()の1か所です。「+ 追加」も「✏ 編集」も同じ_edit_dialog()を通るので、ここだけで両方に効きます。
ヒント②(使うもの):datetime.strptime(値, "%H:%M")が例外を出すかどうかで判定できます(datetimeは先頭で import 済みです)。警告はmessagebox.showwarning()で出し、returnしてダイアログを閉じないようにします。
つまずきやすい点:notify_schedule.jsonに不正な時刻がすでに残っている場合、検証を足しても一覧の「次回通知」は「-」のままです。その行を開いて保存し直すと直ります。 -
課題2:繰り返し「なし」の通知を一度きりにする
繰り返しを「なし」にした通知も、翌日の同じ時刻にまた鳴ります。
_should_notify()は日付が変われば「なし」でも True を返すためです。1回鳴ったら自動で無効にします。期待結果:繰り返し「なし」の通知が鳴ったあと、一覧の「有効」列が ✘ に変わり、次の日の同じ時刻には鳴らない。
合格条件
- 「毎日」「毎分」の通知は今までどおり繰り返す
- 一度鳴った「なし」の通知は、アプリを閉じて開き直しても無効のまま
- 「▶ 今すぐ通知」で手動で鳴らしたときは無効にならない
ヒント①(どこを触るか): 通知を出す判断をしているのは
_tick_loop()の中で、_should_notify(task)が True のときにtask["last_notified"]を更新して_fire_notificationを呼んでいます。無効化はこの1か所に足してください。_fire_notification()に書くと「▶ 今すぐ通知」でも無効になってしまいます。
ヒント②(使うもの):task["repeat"] == "なし"のときだけtask["enabled"] = Falseにします。保存はself.root.after(0, self._save)の形で呼ぶと、すぐ下のself.root.after(0, self._refresh)と同じ流れに乗せられます。
つまずきやすい点:_tick_loop()はthreading.Threadで動く別スレッドです。ここから画面や保存に直接触ると動きが不安定になるので、self.root.after(0, ...)を通してください。 -
課題3:クラス定数を書き換えて動きを変える
クラス定数(
REPEAT_OPTIONS)の中身を書き換えて、画面や動きがどう変わるかを確かめてみましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点にも注目してください。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。