中級者向け No.14

ログファイルアナライザー

ログファイルを読み込んでエラー・警告を抽出・フィルタリング・統計表示するツール。正規表現の実践活用を学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

ログファイルを読み込んでエラー・警告を抽出・フィルタリング・統計表示するツール。正規表現の実践活用を学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。

このアプリは「ファイル操作」カテゴリです。ファイル操作は業務スクリプトの心臓部で、I/O のパターンは他のあらゆる Python プログラムで再利用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

完成形を見てから細部の解説に進む流れが効果的です。実行→気になる箇所を解説で確認→自分の手で改造、というサイクルで定着が早まります。

カスタマイズ章では具体的な拡張アイデアを示しています。一つ実装するごとに動作確認することで、変更が予期せぬ副作用を起こさないかも体感できます。

ログファイルアナライザー 実行画面(Windows)
実行画面(Windows)
ログファイルアナライザー 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「📂 ファイルを開く」ボタン(_open_file())・「📊 統計表示」ボタン(_show_stats())で操作
  • キー <KeyRelease> から _apply_filter() を実行
  • イベント <<ComboboxSelected>> から _apply_filter() を実行
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • messagebox.showerror()messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「ログファイルアナライザー」・サイズ 980x647 で起動
  • 「💾 結果を保存」ボタン(_save_filtered())で、絞り込んだ行だけをテキスト(.txt)または CSV(.csv)に書き出せる

3. 事前準備・環境

ℹ️
動作確認環境

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

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

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

4. 完全なソースコード

💡
コードのコピー方法

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

追加インストール不要(標準ライブラリのみ使用)
app14.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import csv
import re
from datetime import datetime
from collections import Counter
import os


class App14:
    """ログファイルアナライザー"""

    # よくあるログ形式の正規表現パターン
    PATTERNS = {
        "Apache/Nginx": r'(?P<ip>\S+) - - \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+) \S+" (?P<status>\d+) (?P<size>\S+)',
        "Python/標準": r'(?P<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[,.]?\d*) (?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL) (?P<logger>\S+):? (?P<message>.*)',
        "Syslog": r'(?P<time>\w{3}\s+\d+\s+\d+:\d+:\d+) (?P<host>\S+) (?P<process>\S+): (?P<message>.*)',
        "Windows Event": r'(?P<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}) (?P<level>\w+) (?P<source>[^\s]+) (?P<id>\d+) (?P<message>.*)',
    }

    LEVEL_COLORS = {
        "ERROR": "#ff7b72", "CRITICAL": "#ff0000",
        "WARNING": "#ffa657", "INFO": "#3fb950",
        "DEBUG": "#8b949e",
    }

    def __init__(self, root):
        self.root = root
        self.root.title("ログファイルアナライザー")
        self.root.geometry("980x647")
        self.root.minsize(980, 647)
        self.root.configure(bg="#0d1117")
        self.lines = []
        self.filtered_lines = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 ログファイルアナライザー",
                 font=("Noto Sans JP", 14, "bold"),
                 bg="#161b22", fg="#58a6ff").pack(side=tk.LEFT, padx=12)
        ttk.Button(header, text="📂 ファイルを開く",
                   command=self._open_file).pack(side=tk.LEFT, padx=8)
        ttk.Button(header, text="📊 統計表示",
                   command=self._show_stats).pack(side=tk.LEFT, padx=4)
        ttk.Button(header, text="💾 結果を保存",
                   command=self._save_filtered).pack(side=tk.LEFT, padx=4)

        # フィルターバー
        filter_frame = tk.Frame(self.root, bg="#161b22", pady=6)
        filter_frame.pack(fill=tk.X)

        tk.Label(filter_frame, text="🔍 検索:",
                 bg="#161b22", fg="#ccc").pack(side=tk.LEFT, padx=8)
        self.search_var = tk.StringVar()
        search_entry = ttk.Entry(filter_frame, textvariable=self.search_var, width=24)
        search_entry.pack(side=tk.LEFT, padx=4)
        search_entry.bind("<KeyRelease>", lambda e: self._apply_filter())

        tk.Label(filter_frame, text="レベル:",
                 bg="#161b22", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
        self.level_var = tk.StringVar(value="すべて")
        level_cb = ttk.Combobox(filter_frame, textvariable=self.level_var,
                                 values=["すべて", "ERROR", "CRITICAL",
                                         "WARNING", "INFO", "DEBUG"],
                                 state="readonly", width=10)
        level_cb.pack(side=tk.LEFT)
        level_cb.bind("<<ComboboxSelected>>", lambda e: self._apply_filter())

        tk.Label(filter_frame, text="パターン:",
                 bg="#161b22", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
        self.pattern_var = tk.StringVar(value="Python/標準")
        pattern_cb = ttk.Combobox(filter_frame, textvariable=self.pattern_var,
                                   values=["自動検出"] + list(self.PATTERNS.keys()),
                                   state="readonly", width=14)
        pattern_cb.pack(side=tk.LEFT)

        tk.Label(filter_frame, text="行数:",
                 bg="#161b22", fg="#8b949e").pack(side=tk.LEFT, padx=(16, 4))
        self.count_label = tk.Label(filter_frame, text="0 / 0",
                                    bg="#161b22", fg="#58a6ff",
                                    font=("Arial", 10, "bold"))
        self.count_label.pack(side=tk.LEFT)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.VERTICAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # ログビューアー
        log_frame = tk.Frame(paned, bg="#0d1117")
        self.log_text = tk.Text(log_frame, bg="#0d1117", fg="#c9d1d9",
                                 font=("Courier New", 10),
                                 wrap=tk.NONE, state=tk.DISABLED,
                                 relief=tk.FLAT)
        h_sb = ttk.Scrollbar(log_frame, orient=tk.HORIZONTAL,
                             command=self.log_text.xview)
        v_sb = ttk.Scrollbar(log_frame, orient=tk.VERTICAL,
                             command=self.log_text.yview)
        self.log_text.configure(xscrollcommand=h_sb.set,
                                yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.log_text.pack(fill=tk.BOTH, expand=True)
        paned.add(log_frame, weight=3)

        # タグ設定
        for level, color in self.LEVEL_COLORS.items():
            self.log_text.tag_configure(level, foreground=color)
        self.log_text.tag_configure("highlight", background="#ffd700",
                                    foreground="#000")
        self.log_text.tag_configure("lineno", foreground="#8b949e")

        # 統計パネル
        stats_frame = tk.Frame(paned, bg="#161b22")
        tk.Label(stats_frame, text="エラー統計",
                 bg="#161b22", fg="#8b949e",
                 font=("Arial", 9)).pack(anchor="w", padx=8, pady=2)
        self.stats_canvas = tk.Canvas(stats_frame, bg="#161b22",
                                       height=100, highlightthickness=0)
        self.stats_canvas.pack(fill=tk.X, padx=8)
        paned.add(stats_frame, weight=1)

        self.status_var = tk.StringVar(value="ログファイルを開いてください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#161b22", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _open_file(self):
        path = filedialog.askopenfilename(
            filetypes=[("ログファイル", "*.log *.txt *.out"),
                       ("すべて", "*.*")])
        if not path:
            return
        try:
            for enc in ["utf-8", "cp932", "latin-1"]:
                try:
                    with open(path, encoding=enc, errors="replace") as f:
                        self.lines = f.readlines()
                    break
                except UnicodeDecodeError:
                    continue
            self.root.title(f"ログアナライザー — {os.path.basename(path)}")
            self._apply_filter()
            self._update_stats()
            self.status_var.set(
                f"{os.path.basename(path)}  |  {len(self.lines)} 行")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _apply_filter(self):
        q = self.search_var.get().strip().lower()
        level_filter = self.level_var.get()
        result = []
        for i, line in enumerate(self.lines):
            line_l = line.lower()
            # レベルフィルター
            if level_filter != "すべて":
                if level_filter.lower() not in line_l:
                    continue
            # テキスト検索
            if q and q not in line_l:
                continue
            result.append((i + 1, line))

        self.filtered_lines = result
        self.count_label.config(
            text=f"{len(result)} / {len(self.lines)}")
        self._render_log()

    def _save_filtered(self):
        if not self.filtered_lines:
            messagebox.showinfo("情報", "保存できる行がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".txt",
            filetypes=[("テキスト", "*.txt"), ("CSV", "*.csv"),
                       ("すべて", "*.*")])
        if not path:
            return
        try:
            if path.lower().endswith(".csv"):
                with open(path, "w", encoding="utf-8-sig", newline="") as f:
                    writer = csv.writer(f)
                    writer.writerow(["行番号", "内容"])
                    for lineno, line in self.filtered_lines:
                        writer.writerow([lineno, line.rstrip("\n")])
            else:
                with open(path, "w", encoding="utf-8-sig") as f:
                    for lineno, line in self.filtered_lines:
                        f.write(f"{lineno:6d} | {line.rstrip()}\n")
        except OSError as e:
            messagebox.showerror("エラー", f"保存できませんでした: {e}")
            return
        self.status_var.set(
            f"{len(self.filtered_lines)} 行を保存: {os.path.basename(path)}")
        messagebox.showinfo("完了", f"{len(self.filtered_lines)} 行を保存しました")

    def _render_log(self):
        self.log_text.config(state=tk.NORMAL)
        self.log_text.delete("1.0", tk.END)
        q = self.search_var.get().strip().lower()
        for lineno, line in self.filtered_lines:
            start = self.log_text.index(tk.END)
            self.log_text.insert(tk.END, f"{lineno:6d} │ {line}")
            end = self.log_text.index(tk.END)
            # レベルカラー
            line_upper = line.upper()
            for level in self.LEVEL_COLORS:
                if level in line_upper:
                    self.log_text.tag_add(level, start, end)
                    break
            # 検索ハイライト
            if q:
                content = line.lower()
                idx = 0
                while True:
                    pos = content.find(q, idx)
                    if pos < 0:
                        break
                    tag_start = f"{start}+{pos+9}c"  # 行番号分オフセット
                    tag_end = f"{start}+{pos+9+len(q)}c"
                    self.log_text.tag_add("highlight", tag_start, tag_end)
                    idx = pos + 1
        self.log_text.config(state=tk.DISABLED)

    def _update_stats(self):
        level_counts = Counter()
        for line in self.lines:
            for level in self.LEVEL_COLORS:
                if level in line.upper():
                    level_counts[level] += 1
                    break

        self.stats_canvas.delete("all")
        total = sum(level_counts.values()) or 1
        x = 10
        for level, count in sorted(level_counts.items(),
                                    key=lambda x: x[1], reverse=True):
            color = self.LEVEL_COLORS.get(level, "#ccc")
            bar_w = int(count / total * 200)
            self.stats_canvas.create_rectangle(
                x, 20, x + bar_w, 60, fill=color, outline="")
            self.stats_canvas.create_text(
                x + bar_w // 2, 40, text=f"{level}\n{count}",
                fill="#fff", font=("Arial", 8))
            x += bar_w + 10

    def _show_stats(self):
        if not self.lines:
            messagebox.showinfo("情報", "ファイルを開いてください")
            return
        level_counts = Counter()
        error_messages = Counter()
        for line in self.lines:
            for level in self.LEVEL_COLORS:
                if level in line.upper():
                    level_counts[level] += 1
                    if level in ("ERROR", "CRITICAL"):
                        msg = line.strip()[-80:]
                        error_messages[msg] += 1
                    break

        win = tk.Toplevel(self.root)
        win.title("ログ統計")
        win.geometry("600x400")
        win.configure(bg="#0d1117")
        txt = tk.Text(win, bg="#0d1117", fg="#c9d1d9",
                      font=("Courier New", 11))
        txt.pack(fill=tk.BOTH, expand=True)
        txt.insert(tk.END, f"【ログ統計】  全 {len(self.lines)} 行\n\n")
        txt.insert(tk.END, "=== レベル別件数 ===\n")
        for level, count in sorted(level_counts.items(),
                                    key=lambda x: x[1], reverse=True):
            bar = "█" * min(40, count // max(1, len(self.lines)//40))
            txt.insert(tk.END, f"  {level:<10} {count:>6} 件  {bar}\n")
        txt.insert(tk.END, "\n=== エラー Top10 ===\n")
        for msg, count in error_messages.most_common(10):
            txt.insert(tk.END, f"  [{count:>4}回] {msg[:70]}\n")
        txt.config(state=tk.DISABLED)


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

5. コード解説

ログファイルアナライザーのコードを、実際に書かれている実装に沿って解説します。

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

App14 クラスにアプリの全機能をまとめています(メソッド8個・全286行)。__init__ ではタイトル「ログファイルアナライザー」とウィンドウサイズ 980x647 を設定します。最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

画面は tk.Frame×4・tk.Label×8・ttk.Button×3・ttk.Entryttk.Combobox×2・ttk.PanedWindowtk.Text×2・ttk.Scrollbar×2・tk.Canvastk.Toplevel で構成しています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

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

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

「📂 ファイルを開く」ボタン(_open_file())・「📊 統計表示」ボタン(_show_stats())・「💾 結果を保存」ボタン(_save_filtered())を command= で接続しています。また bind("<KeyRelease>", ...) から _apply_filter()bind("<<ComboboxSelected>>", ...) から _apply_filter() を呼べるようにイベントも登録しています。

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

例外処理

try-except で ExceptionUnicodeDecodeError を捕捉しています。あわせて messagebox.showerror()messagebox.showinfo() のダイアログで、ユーザーへの通知・確認を行います。

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

フィルター結果の書き出し(_save_filtered)

保存するのは self.filtered_lines、つまり画面に出ている絞り込み後の行だけです。filedialog.asksaveasfilename() で決めた拡張子が .csv なら csv.writer で「行番号・内容」の2列に、それ以外はテキストとして書き出します。

文字コードは utf-8-sig を指定しています。BOM 付きで書くと、日本語を含むログを Excel でダブルクリックしても文字化けしません。CSV を書くときの newline="" は、Windows で空行が1行おきに入るのを防ぐための決まり文句です。

手元の7行のサンプルログをレベル「ERROR」で絞り込むと2件になり、その2件だけが保存されました。csv.writer はカンマや引用符を含む行を自動でエスケープするため、"quoted, comma" を含む行 のような行も1セルのまま崩れません(自作の文字列連結で書くと、ここで列がずれます)。

書き込み先を選ばずにダイアログを閉じたときは何もせず、存在しないフォルダなど書き込めない場所を指定したときは OSError を捕まえて messagebox.showerror() で理由を出します。絞り込み結果が0件のときは保存せず、その旨を知らせます。

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

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

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

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

    新しいファイルを作成して app14.py と保存します。使うのは collectionscsvdatetimeosretkinter だけなので、追加インストールは不要です。

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

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

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

    title("ログファイルアナライザー")geometry("980x647")configure(bg="#0d1117")minsize(980, 647) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×4・tk.Label×8・ttk.Button×3・ttk.Entryttk.Combobox×2・ttk.PanedWindowtk.Text×2・ttk.Scrollbar×2・tk.Canvastk.Toplevel を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_open_file()_show_stats()_save_filtered())につなぎます。bind("<KeyRelease>", ...)bind("<<ComboboxSelected>>", ...) の登録も忘れずに。

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

    アプリの本体である _show_stats()(32行)・_save_filtered()(27行)・_open_file()(21行)・_apply_filter()(19行) を実装します。

  7. 7
    動作確認する

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

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

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

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

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

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

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

💡 保存した結果を次に開いたときに読み戻す

絞り込み結果の書き出しは「💾 結果を保存」(_save_filtered())として実装済みで、テキストまたは CSV で保存できます。ただし読み込みは元のログファイル専用なので、保存した CSV を開き直しても列は復元されません。csv.reader で「行番号・内容」を読み戻し、内容の列だけを self.lines に入れて表示する機能を足してみましょう。

💡 Treeview で時刻・レベル・本文を列に分けて表示する

いまは tk.Text に1行まるごと流し込み、レベルごとに色を付けています。PATTERNS の正規表現には timelevelmessage という名前付きグループがあるので、re.search()groupdict() をそのまま ttk.Treeview の列に割り当てれば表形式にできます。列見出しのクリックで並べ替えられるのが表形式の利点です。_render_log() を書き換える大きめの改造になるので、まずは別ウィンドウに Treeview を出して試すと安全です。

💡 解析を別スレッドにして巨大ログでも固まらないようにする

_open_file()readlines() で全行を一度に読み、その場で _apply_filter()_update_stats() を呼びます。行数が増えるほど、この間ウィンドウは操作を受け付けません。読み込みと集計を threading.Thread(target=..., daemon=True) に移し、結果の画面反映だけを root.after(0, ...) でメインスレッドへ戻す形にすると、待っている間もウィンドウが動きます。tkinter のウィジェットを別スレッドから直接触らないことが条件です。

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

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

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

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

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

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

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

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

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

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

❌ 大きいログを開くと画面が固まる

原因:_open_file()readlines() でファイル全体を一度にメモリへ読み込み、続けて _apply_filter() が全行を走査し、_render_log() が該当行をすべて tk.Text へ挿入するためです。この処理はメインスレッドで動くので、終わるまでウィンドウは再描画もクリックも受け付けません。数十MBのログでは「応答なし」に見えます。

解決法:読み込みと集計を threading.Thread(target=..., daemon=True) に移し、画面の更新だけを root.after(0, ...) でメインスレッドに戻します。tkinter のウィジェットは作成したスレッド以外から触ってはいけないので、別スレッドで作るのは「データ」まで、と決めておくと事故りません。表示件数に上限を設けて _render_log() の挿入を先頭 5000 行までに絞る手もあります(保存は絞り込み結果の全件が対象なので、表示を制限しても書き出しには影響しません)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください。

  1. 課題1:統計グラフを絞り込みに追従させる

    下の棒グラフを描く _update_stats()_open_file() からしか呼ばれず、いつも全行を数えています。検索やレベルで絞り込んだ結果を、そのままグラフに反映させます。

    期待結果:検索欄に「error」と入れると、下の棒グラフがファイルを開き直さずに絞り込み後の集計へ変わる。

    合格条件

    • ファイルを開いた直後は、今までどおり全行の集計が出る
    • レベルを「すべて」に戻して検索欄を空にすると、開いた直後と同じグラフに戻る
    • 1行も残らない検索語を入れてもエラーにならない

    ヒント①(どこを触るか): 触るのは _apply_filter()_update_stats() の2か所です。_apply_filter() の末尾(self._render_log() の隣)で self._update_stats() を呼びます。
    ヒント②(使うもの): _update_stats() の走査対象を self.lines から self.filtered_lines に替えます。_open_file() 側の self._update_stats() は残したままで構いません。
    つまずきやすい点: self.filtered_lines(行番号, 本文) のタプルのリストです。for line in ... のままだと line.upper()AttributeError になるので、for _, line in ... のように本文だけを取り出してください。なお1行に複数のレベル名がある行は、LEVEL_COLORS の並び順で先に見つかった方だけが数えられます。

  2. 課題2:「パターン」の選択を実際に使う

    画面上部の「パターン」は選べるのに、どこからも読まれていません。レベルの絞り込みは行全体の部分一致なので、本文に INFO と書かれただけの行まで拾ってしまいます。選んだパターンの level グループでレベルを判定します。

    期待結果:「パターン」を「Python/標準」にしてレベルで「INFO」を選ぶと、本文に INFO という文字が混ざっただけの DEBUG 行が結果から外れる。

    合格条件

    • 「パターン」を選び直しただけで一覧が更新される(いまは選び直しても何も起きない)
    • レベルが「すべて」のときは、パターンに合わない行(空行など)も今までどおり表示される
    • 「自動検出」を選んでもエラーにならない

    ヒント①(どこを触るか): 触るのは _apply_filter()_build_ui()pattern_cb の2か所です。すぐ上の level_cb には <<ComboboxSelected>> の bind がありますが、pattern_cb には付いていません。
    ヒント②(使うもの): self.PATTERNS.get(self.pattern_var.get()) で正規表現が取れます(「自動検出」は PATTERNS のキーではないので [...] で引くと KeyError になります。None が返ったらパターン判定を飛ばして今までどおりの動きにしてください)。re.match() の結果から m.group("level") でレベルだけを取り出せます(re は先頭で import 済みです)。
    つまずきやすい点: level グループを持つのは「Python/標準」と「Windows Event」の2つだけです。「Apache/Nginx」や「Syslog」で m.group("level") を呼ぶと IndexError になるので、m.groupdict()level があるかを先に確かめてください。

  3. 課題3:保存した CSV を読み戻して表示する

    「💾 結果を保存」で書き出した CSV ファイルを、もう一度アプリで開いて画面に表示できるようにしましょう。

    期待結果:保存した CSV を選ぶと、保存時と同じ行数・同じ内容がログ表示欄に並ぶ(行番号は開き直した時点で振り直されてよい)。

    合格条件

    • CSV を選ぶダイアログでキャンセルすると何も起きない
    • 読み込んだ内容にレベルフィルターやテキスト検索がそのまま効く
    • CSV の1行目(見出し「行番号,内容」)が本文の1行として表示されない

    ヒント①(どこを触るか): 新しいボタンとメソッドを1つ追加し、csv.reader で開いたファイルを1行ずつ読みます。1行目(ヘッダー)は next(reader) でスキップします。
    ヒント②(使うもの): 各行の2列目(内容)だけを取り出して self.lines に追加し、最後に self._apply_filter() を呼べば、既存のフィルター・検索・表示の仕組みがそのまま使えます。
    つまずきやすい点: CSV に保存した内容は line.rstrip("\n") 済みで改行が付いていません。self.lines に戻す際に末尾へ "\n" を足しておくと、表示や再保存時の見た目が元のログと揃います。

🚀
次に挑戦するアプリ

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

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

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

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。