make_run_report.py ダウンロード/コピー

make_run_report.py をダウンロード

make_run_report.py
make_run_report.py
   1#!/usr/bin/env python
   2# -*- coding: utf-8 -*-
   3
   4"""
   5概要:
   6    指定した作業ディレクトリでコマンドを実行し、実行結果をMarkdownレポートとして出力するツールです。
   7詳細説明:
   8    コマンド実行前後のファイル差分、標準出力、標準エラー出力、終了コードなどを収集し、
   9    さらに生成された画像やログファイル内の警告・エラーを検出して、
  10    詳細なレポートを生成します。Pandocが利用可能な場合は、HTMLレポートも作成できます。
  11主な機能:
  12    - コマンド実行前後のファイルシステムの変更を追跡します。
  13    - 実行されたコマンドの標準出力と標準エラー出力を記録します。
  14    - ログファイル内から特定のパターン(エラー、警告)を検索します。
  15    - 生成された画像をレポートに埋め込みます。
  16    - Markdown形式またはHTML形式で実行レポートを出力します。
  17"""
  18
  19from __future__ import annotations
  20
  21import argparse
  22import fnmatch
  23import os
  24import platform
  25import re
  26import shlex
  27import subprocess
  28import sys
  29import traceback
  30from dataclasses import dataclass
  31from datetime import datetime
  32from pathlib import Path
  33from typing import Iterable
  34
  35
  36@dataclass
  37class FileInfo:
  38    """
  39    概要:
  40        ファイル情報を格納するデータクラス。
  41    詳細説明:
  42        ファイルの相対パス、サイズ、最終更新時刻(ナノ秒単位)、
  43        およびISO形式の最終更新時刻を保持します。
  44    属性:
  45        :param relpath: 作業ディレクトリからの相対パス。
  46        :type relpath: str
  47        :param size: ファイルのサイズ(バイト単位)。
  48        :type size: int
  49        :param mtime_ns: ファイルの最終更新時刻(ナノ秒単位)。
  50        :type mtime_ns: int
  51        :param mtime_iso: ファイルの最終更新時刻のISO 8601形式文字列。
  52        :type mtime_iso: str
  53    """
  54    relpath: str
  55    size: int
  56    mtime_ns: int
  57    mtime_iso: str
  58
  59
  60@dataclass
  61class RunResult:
  62    """
  63    概要:
  64        コマンド実行結果を格納するデータクラス。
  65    詳細説明:
  66        実行されたコマンド、終了コード、標準出力、標準エラー出力、
  67        開始時刻、終了時刻、およびタイムアウトしたかどうかを保持します。
  68    属性:
  69        :param command: 実行されたコマンド文字列。
  70        :type command: str
  71        :param returncode: コマンドの終了コード。
  72        :type returncode: int
  73        :param stdout: コマンドの標準出力。
  74        :type stdout: str
  75        :param stderr: コマンドの標準エラー出力。
  76        :type stderr: str
  77        :param started_at: コマンド実行開始時刻のISO 8601形式文字列。
  78        :type started_at: str
  79        :param finished_at: コマンド実行終了時刻のISO 8601形式文字列。
  80        :type finished_at: str
  81        :param timed_out: コマンドがタイムアウトしたかどうかを示す真偽値。
  82        :type timed_out: bool
  83    """
  84    command: str
  85    returncode: int
  86    stdout: str
  87    stderr: str
  88    started_at: str
  89    finished_at: str
  90    timed_out: bool = False
  91
  92
  93@dataclass
  94class LogHit:
  95    """
  96    概要:
  97        ログファイル内で検出された警告またはエラーの情報を格納するデータクラス。
  98    詳細説明:
  99        検出された行の相対パス、行番号、種類(ERRORまたはWARNING)、および該当行の内容を保持します。
 100    属性:
 101        :param relpath: ファイルの相対パス、または標準エラー出力を示す __stderr__ 。
 102        :type relpath: str
 103        :param lineno: 検出された行の行番号(1から始まる)。
 104        :type lineno: int
 105        :param kind: 検出されたヒットの種類(ERRORまたはWARNING)。
 106        :type kind: str
 107        :param line: 検出された行の内容。
 108        :type line: str
 109    """
 110    relpath: str
 111    lineno: int
 112    kind: str
 113    line: str
 114
 115
 116IMAGE_EXTS = {
 117    ".png",
 118    ".jpg",
 119    ".jpeg",
 120    ".gif",
 121    ".svg",
 122    ".webp",
 123}
 124
 125LOG_EXTS_DEFAULT = ".log,.out,.txt"
 126
 127DEFAULT_IGNORE = (
 128    ".git,__pycache__,venv,.venv,.mypy_cache,.pytest_cache,"
 129    "*.pyc,*.pyo,*.tmp,*.bak,*.swp"
 130)
 131
 132
 133def parse_csv_list(text: str) -> list[str]:
 134    """
 135    概要:
 136        カンマ区切りの文字列をリストに分割します。
 137    詳細説明:
 138        空の文字列や空白のみの要素は除外されます。
 139    引数:
 140        :param text: カンマ区切りの文字列。
 141        :type text: str
 142    戻り値:
 143        :returns: 分割された文字列のリスト。
 144        :rtype: list[str]
 145    """
 146    if not text:
 147        return []
 148    return [x.strip() for x in text.split(",") if x.strip()]
 149
 150
 151def now_iso() -> str:
 152    """
 153    概要:
 154        現在のローカル時刻をISO 8601形式の文字列で返します。
 155    詳細説明:
 156        タイムゾーン情報を含み、秒単位までの精度です。
 157    戻り値:
 158        :returns: 現在時刻のISO 8601形式文字列。
 159        :rtype: str
 160    """
 161    return datetime.now().astimezone().isoformat(timespec="seconds")
 162
 163
 164def file_mtime_iso(path: Path) -> str:
 165    """
 166    概要:
 167        ファイルの最終更新時刻をISO 8601形式の文字列で返します。
 168    詳細説明:
 169        指定されたファイルの最終更新時刻を取得し、ローカルタイムゾーンに変換してISO 8601形式でフォーマットします。
 170    引数:
 171        :param path: 処理するファイルのパスオブジェクト。
 172        :type path: Path
 173    戻り値:
 174        :returns: ファイル最終更新時刻のISO 8601形式文字列。
 175        :rtype: str
 176    """
 177    return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds")
 178
 179
 180def normalize_relpath(path: Path) -> str:
 181    """
 182    概要:
 183        パスを正規化された相対パス文字列として返します。
 184    詳細説明:
 185        Pathオブジェクトを、スラッシュ区切りのポータブルな文字列形式に変換します。
 186    引数:
 187        :param path: 正規化するパスオブジェクト。
 188        :type path: Path
 189    戻り値:
 190        :returns: スラッシュ区切りの相対パス文字列。
 191        :rtype: str
 192    """
 193    return path.as_posix()
 194
 195
 196def is_ignored(relpath: str, patterns: Iterable[str]) -> bool:
 197    """
 198    概要:
 199        指定された相対パスが無視パターンに一致するかどうかを判定します。
 200    詳細説明:
 201        relpath はスラッシュ区切りの相対パスです。
 202        パターンはファイル名(basename)にも、パス全体にも、パスの各部分にも適用されます。
 203        いずれかのパターンに一致すれば無視されます。
 204    引数:
 205        :param relpath: 検査対象の相対パス。
 206        :type relpath: str
 207        :param patterns: 無視するパターン文字列のイテラブル。fnmatch 形式を使用します。
 208        :type patterns: Iterable[str]
 209    戻り値:
 210        :returns: パスが無視されるべき場合はTrue、そうでない場合はFalse。
 211        :rtype: bool
 212    """
 213    parts = relpath.split("/")
 214    basename = parts[-1]
 215
 216    for pat in patterns:
 217        pat = pat.strip()
 218        if not pat:
 219            continue
 220
 221        if fnmatch.fnmatch(relpath, pat):
 222            return True
 223
 224        if fnmatch.fnmatch(basename, pat):
 225            return True
 226
 227        for part in parts:
 228            if fnmatch.fnmatch(part, pat):
 229                return True
 230
 231    return False
 232
 233
 234def snapshot_files(workdir: Path, ignore_patterns: list[str]) -> dict[str, FileInfo]:
 235    """
 236    概要:
 237        指定された作業ディレクトリ内のファイルのスナップショットを作成します。
 238    詳細説明:
 239        workdir 内のすべてのファイル(無視パターンに一致しないもの)について、
 240        FileInfo オブジェクトを作成し、相対パスをキーとする辞書として返します。
 241        ディレクトリはスキップされます。
 242    引数:
 243        :param workdir: スナップショットを作成する作業ディレクトリのパス。
 244        :type workdir: Path
 245        :param ignore_patterns: 無視するファイルパスのパターンリスト。
 246        :type ignore_patterns: list[str]
 247    戻り値:
 248        :returns: 相対パスをキーとし、FileInfoオブジェクトを値とする辞書。
 249        :rtype: dict[str, FileInfo]
 250    """
 251    files: dict[str, FileInfo] = {}
 252
 253    for path in workdir.rglob("*"):
 254        try:
 255            rel = normalize_relpath(path.relative_to(workdir))
 256        except ValueError:
 257            continue
 258
 259        if is_ignored(rel, ignore_patterns):
 260            continue
 261
 262        if path.is_dir():
 263            continue
 264
 265        if not path.is_file():
 266            continue
 267
 268        try:
 269            st = path.stat()
 270        except OSError:
 271            continue
 272
 273        files[rel] = FileInfo(
 274            relpath=rel,
 275            size=st.st_size,
 276            mtime_ns=st.st_mtime_ns,
 277            mtime_iso=file_mtime_iso(path),
 278        )
 279
 280    return dict(sorted(files.items(), key=lambda kv: kv[0].lower()))
 281
 282
 283def diff_snapshots(
 284    before: dict[str, FileInfo],
 285    after: dict[str, FileInfo],
 286) -> tuple[list[FileInfo], list[FileInfo], list[FileInfo]]:
 287    """
 288    概要:
 289        2つのファイルスナップショット間の差分を計算します。
 290    詳細説明:
 291        実行前と実行後のファイルスナップショットを比較し、
 292        新しく作成されたファイル、変更されたファイル、削除されたファイルを特定します。
 293        ファイルの変更はサイズまたは最終更新時刻の変更によって検出されます。
 294    引数:
 295        :param before: 実行前のファイルスナップショット辞書。
 296        :type before: dict[str, FileInfo]
 297        :param after: 実行後のファイルスナップショット辞書。
 298        :type after: dict[str, FileInfo]
 299    戻り値:
 300        :returns: 作成されたファイル、変更されたファイル、削除されたファイルのリストからなるタプル。
 301        :rtype: tuple[list[FileInfo], list[FileInfo], list[FileInfo]]
 302    """
 303    before_keys = set(before)
 304    after_keys = set(after)
 305
 306    created = [after[k] for k in sorted(after_keys - before_keys)]
 307    deleted = [before[k] for k in sorted(before_keys - after_keys)]
 308
 309    modified: list[FileInfo] = []
 310    for key in sorted(before_keys & after_keys):
 311        b = before[key]
 312        a = after[key]
 313        if b.size != a.size or b.mtime_ns != a.mtime_ns:
 314            modified.append(a)
 315
 316    return created, modified, deleted
 317
 318
 319def human_size(n: int) -> str:
 320    """
 321    概要:
 322        バイト数を人間が読みやすい形式に変換します。
 323    詳細説明:
 324        バイト数をKB, MB, GB, TBなどの適切な単位に変換し、小数点以下2桁で表示します。
 325        単位がBの場合は整数で表示します。
 326    引数:
 327        :param n: バイト数。
 328        :type n: int
 329    戻り値:
 330        :returns: 人間が読みやすい形式のサイズ文字列。
 331        :rtype: str
 332    """
 333    units = ["B", "KB", "MB", "GB", "TB"]
 334    x = float(n)
 335    for unit in units:
 336        if x < 1024.0 or unit == units[-1]:
 337            if unit == "B":
 338                return f"{int(x)} {unit}"
 339            return f"{x:.2f} {unit}"
 340        x /= 1024.0
 341    return f"{n} B"
 342
 343
 344def md_escape(text: str) -> str:
 345    """
 346    概要:
 347        Markdown特殊文字をエスケープします。
 348    詳細説明:
 349        パイプ文字(|)をエスケープし、改行をスペースに置換します。
 350        これにより、表内のテキストが正しく表示されるようにします。
 351    引数:
 352        :param text: エスケープする文字列。
 353        :type text: str
 354    戻り値:
 355        :returns: エスケープされた文字列。
 356        :rtype: str
 357    """
 358    return text.replace("|", r"\|").replace("\n", " ")
 359
 360
 361def md_path_link(relpath: str, label: str | None = None) -> str:
 362    """
 363    概要:
 364        Markdown形式の相対パスリンクを生成します。
 365    詳細説明:
 366        Pandoc対応のMarkdownで相対パスをリンクとして表示するための形式を生成します。
 367        空白を含むファイル名にも安全な <...> 形式を使用します。
 368    引数:
 369        :param relpath: リンク先の相対パス。
 370        :type relpath: str
 371        :param label: リンクの表示ラベル。Noneの場合、relpathがラベルとして使用されます。
 372        :type label: str | None
 373    戻り値:
 374        :returns: Markdown形式のリンク文字列。
 375        :rtype: str
 376    """
 377    label = label or relpath
 378    href = relpath.replace("\\", "/")
 379    return f"[{md_escape(label)}](<{href}>)"
 380
 381
 382def md_image(relpath: str) -> str:
 383    """
 384    概要:
 385        Markdown形式の画像埋め込み文字列を生成します。
 386    詳細説明:
 387        Pandoc対応のMarkdownで画像を埋め込むための形式を生成します。
 388        相対パスを画像ソースとして指定します。
 389    引数:
 390        :param relpath: 画像ファイルの相対パス。
 391        :type relpath: str
 392    戻り値:
 393        :returns: Markdown形式の画像埋め込み文字列。
 394        :rtype: str
 395    """
 396    href = relpath.replace("\\", "/")
 397    return f"![{md_escape(relpath)}](<{href}>)"
 398
 399
 400def make_file_table(files: list[FileInfo], title: str, max_items: int) -> str:
 401    """
 402    概要:
 403        ファイル情報のMarkdownテーブルを生成します。
 404    詳細説明:
 405        指定されたファイル情報のリストから、ファイル名、サイズ、更新時刻を含むMarkdownテーブルを作成します。
 406        表示するアイテム数には上限があり、超過した場合は省略表示されます。
 407    引数:
 408        :param files: FileInfoオブジェクトのリスト。
 409        :type files: list[FileInfo]
 410        :param title: テーブルのセクションタイトル。
 411        :type title: str
 412        :param max_items: テーブルに表示する最大アイテム数。
 413        :type max_items: int
 414    戻り値:
 415        :returns: Markdown形式のファイルテーブル文字列。
 416        :rtype: str
 417    """
 418    lines: list[str] = []
 419    lines.append(f"### {title}")
 420    lines.append("")
 421
 422    if not files:
 423        lines.append("該当なし。")
 424        lines.append("")
 425        return "\n".join(lines)
 426
 427    lines.append("| File | Size | Modified time |")
 428    lines.append("|---|---:|---|")
 429
 430    for info in files[:max_items]:
 431        lines.append(
 432            f"| {md_path_link(info.relpath)} | {human_size(info.size)} | {info.mtime_iso} |"
 433        )
 434
 435    if len(files) > max_items:
 436        lines.append(f"| ... | ... | {len(files) - max_items} files omitted |")
 437
 438    lines.append("")
 439    return "\n".join(lines)
 440
 441
 442def render_tree(files: dict[str, FileInfo], max_items: int) -> str:
 443    """
 444    概要:
 445        ファイルリストから簡易的なディレクトリツリー表示を生成します。
 446    詳細説明:
 447        ファイルパスのリストを基に、Markdownレポート向けの簡易的なツリー構造をテキストで表現します。
 448        厳密な tree コマンドの出力ではなく、視覚的な階層を示すためのものです。
 449        表示するアイテム数には上限があり、超過した場合は省略表示されます。
 450    引数:
 451        :param files: ファイル情報の辞書。キーは相対パス。
 452        :type files: dict[str, FileInfo]
 453        :param max_items: ツリーに表示する最大アイテム数。
 454        :type max_items: int
 455    戻り値:
 456        :returns: 簡易ツリー形式の文字列。
 457        :rtype: str
 458    """
 459    paths = list(files.keys())
 460    omitted = max(0, len(paths) - max_items)
 461    paths = paths[:max_items]
 462
 463    lines: list[str] = ["."]
 464    for rel in paths:
 465        depth = rel.count("/")
 466        indent = "    " * depth
 467        name = rel.split("/")[-1]
 468        lines.append(f"{indent}├── {name}")
 469
 470    if omitted:
 471        lines.append(f"    ... {omitted} files omitted")
 472
 473    return "\n".join(lines)
 474
 475
 476def choose_fence(text: str) -> str:
 477    """
 478    概要:
 479        Markdownのコードブロックのフェンス文字を選択します。
 480    詳細説明:
 481        指定されたテキスト内に三重バッククォートがある場合、四重バッククォートをフェンスとして選択します。
 482        これにより、テキストがコードブロック内で正しく表示されるようにします。
 483    引数:
 484        :param text: 検査するテキスト。
 485        :type text: str
 486    戻り値:
 487        :returns: 適切なMarkdownフェンス文字列(三重または四重バッククォート)。
 488        :rtype: str
 489    """
 490    if "```" not in text:
 491        return "```"
 492    return "````"
 493
 494
 495def fenced_block(text: str, lang: str = "text", max_chars: int = 20000) -> str:
 496    """
 497    概要:
 498        Markdownのフェンス付きコードブロックを生成します。
 499    詳細説明:
 500        テキストを指定された言語と文字数制限でフェンス付きコードブロックとしてフォーマットします。
 501        テキストが空の場合は「(no output)」と表示し、最大文字数を超えた場合は切り捨てて省略メッセージを追加します。
 502        適切なフェンス文字は choose_fence 関数によって選択されます。
 503    引数:
 504        :param text: コードブロックとして表示するテキスト。
 505        :type text: str
 506        :param lang: コードブロックの言語ヒント。デフォルトは text です。
 507        :type lang: str
 508        :param max_chars: 表示する最大文字数。
 509        :type max_chars: int
 510    戻り値:
 511        :returns: Markdown形式のフェンス付きコードブロック文字列。
 512        :rtype: str
 513    """
 514    if text is None:
 515        text = ""
 516
 517    if text == "":
 518        text = "(no output)"
 519
 520    truncated = False
 521    if len(text) > max_chars:
 522        text = text[:max_chars]
 523        truncated = True
 524
 525    fence = choose_fence(text)
 526    if truncated:
 527        text += "\n\n... truncated ..."
 528
 529    return f"{fence}{lang}\n{text.rstrip()}\n{fence}"
 530
 531
 532def run_command(
 533    command: str,
 534    workdir: Path,
 535    shell: bool,
 536    timeout_sec: float,
 537    encoding: str,
 538) -> RunResult:
 539    """
 540    概要:
 541        指定されたコマンドを実行し、その結果を RunResult オブジェクトとして返します。
 542    詳細説明:
 543        subprocess.run を使用してコマンドを実行し、標準出力、標準エラー出力、
 544        終了コード、実行時間、タイムアウト情報などを収集します。
 545        タイムアウトが発生した場合や、その他の実行時エラーが発生した場合も適切に処理します。
 546    引数:
 547        :param command: 実行するコマンド文字列。
 548        :type command: str
 549        :param workdir: コマンドを実行する作業ディレクトリのパス。
 550        :type workdir: Path
 551        :param shell: shell=True でコマンドを実行するかどうかを示す真偽値。
 552        :type shell: bool
 553        :param timeout_sec: コマンドのタイムアウト時間(秒)。0以下の場合は無制限。
 554        :type timeout_sec: float
 555        :param encoding: 標準出力および標準エラー出力をデコードするためのエンコーディング。
 556        :type encoding: str
 557    戻り値:
 558        :returns: コマンドの実行結果を含む RunResult オブジェクト。
 559        :rtype: RunResult
 560    """
 561    started_at = now_iso()
 562    timed_out = False
 563
 564    try:
 565        if shell:
 566            cmd_for_run: str | list[str] = command
 567        else:
 568            cmd_for_run = shlex.split(command, posix=(os.name != "nt"))
 569
 570        completed = subprocess.run(
 571            cmd_for_run,
 572            cwd=str(workdir),
 573            shell=shell,
 574            text=True,
 575            encoding=encoding,
 576            errors="replace",
 577            stdout=subprocess.PIPE,
 578            stderr=subprocess.PIPE,
 579            timeout=None if timeout_sec <= 0 else timeout_sec,
 580        )
 581
 582        return RunResult(
 583            command=command,
 584            returncode=completed.returncode,
 585            stdout=completed.stdout or "",
 586            stderr=completed.stderr or "",
 587            started_at=started_at,
 588            finished_at=now_iso(),
 589            timed_out=False,
 590        )
 591
 592    except subprocess.TimeoutExpired as e:
 593        timed_out = True
 594
 595        stdout = e.stdout or ""
 596        stderr = e.stderr or ""
 597
 598        if isinstance(stdout, bytes):
 599            stdout = stdout.decode(encoding, errors="replace")
 600        if isinstance(stderr, bytes):
 601            stderr = stderr.decode(encoding, errors="replace")
 602
 603        stderr += f"\nTIMEOUT: command exceeded timeout_sec={timeout_sec}\n"
 604
 605        return RunResult(
 606            command=command,
 607            returncode=-999,
 608            stdout=stdout,
 609            stderr=stderr,
 610            started_at=started_at,
 611            finished_at=now_iso(),
 612            timed_out=timed_out,
 613        )
 614
 615    except Exception:
 616        return RunResult(
 617            command=command,
 618            returncode=-998,
 619            stdout="",
 620            stderr=traceback.format_exc(),
 621            started_at=started_at,
 622            finished_at=now_iso(),
 623            timed_out=False,
 624        )
 625
 626
 627def compile_optional_regex(pattern_text: str) -> re.Pattern[str] | None:
 628    """
 629    概要:
 630        オプションの正規表現パターンをコンパイルします。
 631    詳細説明:
 632        指定されたパターン文字列が空白でない場合に、大文字小文字を区別しない正規表現オブジェクトをコンパイルして返します。
 633        パターン文字列が空または空白のみの場合は None を返します。
 634    引数:
 635        :param pattern_text: コンパイルする正規表現パターン文字列。
 636        :type pattern_text: str
 637    戻り値:
 638        :returns: コンパイルされた正規表現オブジェクト、またはNone。
 639        :rtype: re.Pattern[str] | None
 640    """
 641    if not pattern_text.strip():
 642        return None
 643    return re.compile(pattern_text, re.IGNORECASE)
 644
 645
 646def scan_text_for_hits(
 647    relpath: str,
 648    text: str,
 649    error_regex: re.Pattern[str],
 650    warning_regex: re.Pattern[str],
 651    ignore_regex: re.Pattern[str] | None,
 652    max_hits_per_file: int,
 653) -> list[LogHit]:
 654    """
 655    概要:
 656        テキストから指定された正規表現パターンに一致する警告/エラー行を走査します。
 657    詳細説明:
 658        テキストを1行ずつ読み込み、error_regex または warning_regex に一致する行を LogHit オブジェクトとして記録します。
 659        ignore_regex に一致する行はスキップされます。
 660        1ファイルあたりのヒット数には上限があります。
 661    引数:
 662        :param relpath: テキストの相対パス(ファイル名または __stderr__)。
 663        :type relpath: str
 664        :param text: 走査対象のテキスト。
 665        :type text: str
 666        :param error_regex: エラーパターンを検出するための正規表現オブジェクト。
 667        :type error_regex: re.Pattern[str]
 668        :param warning_regex: 警告パターンを検出するための正規表現オブジェクト。
 669        :type warning_regex: re.Pattern[str]
 670        :param ignore_regex: 無視する行パターンを検出するための正規表現オブジェクト、またはNone。
 671        :type ignore_regex: re.Pattern[str] | None
 672        :param max_hits_per_file: 1ファイルあたりで記録する最大ヒット数。
 673        :type max_hits_per_file: int
 674    戻り値:
 675        :returns: 検出された LogHit オブジェクトのリスト。
 676        :rtype: list[LogHit]
 677    """
 678    hits: list[LogHit] = []
 679
 680    for i, line in enumerate(text.splitlines(), start=1):
 681        if ignore_regex and ignore_regex.search(line):
 682            continue
 683
 684        kind: str | None = None
 685        if error_regex.search(line):
 686            kind = "ERROR"
 687        elif warning_regex.search(line):
 688            kind = "WARNING"
 689
 690        if kind is not None:
 691            hits.append(
 692                LogHit(
 693                    relpath=relpath,
 694                    lineno=i,
 695                    kind=kind,
 696                    line=line.rstrip(),
 697                )
 698            )
 699
 700        if len(hits) >= max_hits_per_file:
 701            break
 702
 703    return hits
 704
 705
 706def scan_log_files(
 707    workdir: Path,
 708    files: list[FileInfo],
 709    log_exts: set[str],
 710    encoding: str,
 711    error_regex: re.Pattern[str],
 712    warning_regex: re.Pattern[str],
 713    ignore_regex: re.Pattern[str] | None,
 714    max_read_bytes: int,
 715    max_hits_per_file: int,
 716) -> list[LogHit]:
 717    """
 718    概要:
 719        指定されたログファイルリストを走査し、警告/エラーを検出します。
 720    詳細説明:
 721        FileInfoオブジェクトのリストから、指定されたログファイル拡張子を持つファイルを抽出し、
 722        ファイルの内容から警告やエラーパターンを検索します。
 723        読み込むバイト数や1ファイルあたりのヒット数には上限があります。
 724    引数:
 725        :param workdir: 作業ディレクトリのパス。
 726        :type workdir: Path
 727        :param files: 走査対象の FileInfo オブジェクトのリスト。
 728        :type files: list[FileInfo]
 729        :param log_exts: ログファイルとして扱う拡張子のセット。
 730        :type log_exts: set[str]
 731        :param encoding: ログファイルをデコードするためのエンコーディング。
 732        :type encoding: str
 733        :param error_regex: エラーパターンを検出するための正規表現オブジェクト。
 734        :type error_regex: re.Pattern[str]
 735        :param warning_regex: 警告パターンを検出するための正規表現オブジェクト。
 736        :type warning_regex: re.Pattern[str]
 737        :param ignore_regex: 無視する行パターンを検出するための正規表現オブジェクト、またはNone。
 738        :type ignore_regex: re.Pattern[str] | None
 739        :param max_read_bytes: 各ログファイルから読み込む最大バイト数。
 740        :type max_read_bytes: int
 741        :param max_hits_per_file: 1ファイルあたりで記録する最大ヒット数。
 742        :type max_hits_per_file: int
 743    戻り値:
 744        :returns: 検出された LogHit オブジェクトのリスト。
 745        :rtype: list[LogHit]
 746    """
 747    hits: list[LogHit] = []
 748
 749    for info in files:
 750        path = workdir / info.relpath
 751        ext = path.suffix.lower()
 752
 753        if ext not in log_exts:
 754            continue
 755
 756        try:
 757            with path.open("rb") as f:
 758                data = f.read(max_read_bytes + 1)
 759        except OSError:
 760            continue
 761
 762        if len(data) > max_read_bytes:
 763            data = data[:max_read_bytes]
 764
 765        text = data.decode(encoding, errors="replace")
 766        hits.extend(
 767            scan_text_for_hits(
 768                relpath=info.relpath,
 769                text=text,
 770                error_regex=error_regex,
 771                warning_regex=warning_regex,
 772                ignore_regex=ignore_regex,
 773                max_hits_per_file=max_hits_per_file,
 774            )
 775        )
 776
 777    return hits
 778
 779
 780def make_log_hits_section(hits: list[LogHit], max_items: int) -> str:
 781    """
 782    概要:
 783        ログヒット情報のMarkdownセクションを生成します。
 784    詳細説明:
 785        LogHitオブジェクトのリストから、警告/エラーの情報を表示するMarkdown形式のテーブルを作成します。
 786        ヒットが見つからない場合はその旨を報告します。
 787        表示するアイテム数には上限があり、超過した場合は省略表示されます。
 788    引数:
 789        :param hits: LogHitオブジェクトのリスト。
 790        :type hits: list[LogHit]
 791        :param max_items: テーブルに表示する最大ヒット数。
 792        :type max_items: int
 793    戻り値:
 794        :returns: Markdown形式のログヒットセクション文字列。
 795        :rtype: str
 796    """
 797    lines: list[str] = []
 798    lines.append("## Warning / Error scan")
 799    lines.append("")
 800
 801    if not hits:
 802        lines.append("新規・更新された log/out/txt および stderr から、指定パターンに一致する行は見つかりませんでした。")
 803        lines.append("")
 804        return "\n".join(lines)
 805
 806    lines.append("| Kind | File | Line | Message |")
 807    lines.append("|---|---|---:|---|")
 808
 809    for hit in hits[:max_items]:
 810        file_part = "stderr" if hit.relpath == "__stderr__" else md_path_link(hit.relpath)
 811        lines.append(
 812            f"| {hit.kind} | {file_part} | {hit.lineno} | {md_escape(hit.line)} |"
 813        )
 814
 815    if len(hits) > max_items:
 816        lines.append(f"| ... | ... | ... | {len(hits) - max_items} hits omitted |")
 817
 818    lines.append("")
 819    return "\n".join(lines)
 820
 821
 822def make_images_section(files: list[FileInfo], max_items: int) -> str:
 823    """
 824    概要:
 825        生成または変更された画像ファイルのMarkdownセクションを生成します。
 826    詳細説明:
 827        ファイル情報のリストから画像ファイルを抽出し、それぞれの画像をMarkdown形式で埋め込みます。
 828        画像が見つからない場合はその旨を報告します。
 829        埋め込む画像数には上限があり、超過した場合は省略表示されます。
 830    引数:
 831        :param files: FileInfoオブジェクトのリスト。
 832        :type files: list[FileInfo]
 833        :param max_items: 埋め込む画像の最大数。
 834        :type max_items: int
 835    戻り値:
 836        :returns: Markdown形式の画像セクション文字列。
 837        :rtype: str
 838    """
 839    images = [f for f in files if Path(f.relpath).suffix.lower() in IMAGE_EXTS]
 840
 841    lines: list[str] = []
 842    lines.append("## Generated / Modified images")
 843    lines.append("")
 844
 845    if not images:
 846        lines.append("新規・更新された画像ファイルはありません。")
 847        lines.append("")
 848        return "\n".join(lines)
 849
 850    for info in images[:max_items]:
 851        lines.append(f"### {info.relpath}")
 852        lines.append("")
 853        lines.append(md_image(info.relpath))
 854        lines.append("")
 855
 856    if len(images) > max_items:
 857        lines.append(f"{len(images) - max_items} image files omitted.")
 858        lines.append("")
 859
 860    return "\n".join(lines)
 861
 862
 863def make_report(
 864    workdir: Path,
 865    before: dict[str, FileInfo],
 866    after: dict[str, FileInfo],
 867    created: list[FileInfo],
 868    modified: list[FileInfo],
 869    deleted: list[FileInfo],
 870    run_result: RunResult,
 871    log_hits: list[LogHit],
 872    args: argparse.Namespace,
 873) -> str:
 874    """
 875    概要:
 876        全体の実行レポートをMarkdown形式で生成します。
 877    詳細説明:
 878        コマンド実行のサマリー、実行されたコマンド、実行前後のファイルツリー、
 879        ファイル変更のテーブル、生成・変更された画像、ログの警告・エラー、
 880        標準出力、標準エラー出力、および環境情報を含む包括的なMarkdownレポートを作成します。
 881        各種セクションの表示アイテム数や文字数には制限が適用されます。
 882    引数:
 883        :param workdir: 作業ディレクトリのパス。
 884        :type workdir: Path
 885        :param before: 実行前のファイルスナップショット辞書。
 886        :type before: dict[str, FileInfo]
 887        :param after: 実行後のファイルスナップショット辞書。
 888        :type after: dict[str, FileInfo]
 889        :param created: 作成されたファイルのリスト。
 890        :type created: list[FileInfo]
 891        :param modified: 変更されたファイルのリスト。
 892        :type modified: list[FileInfo]
 893        :param deleted: 削除されたファイルのリスト。
 894        :type deleted: list[FileInfo]
 895        :param run_result: コマンドの実行結果を含む RunResult オブジェクト。
 896        :type run_result: RunResult
 897        :param log_hits: ログファイルと標準エラー出力から検出された LogHit オブジェクトのリスト。
 898        :type log_hits: list[LogHit]
 899        :param args: コマンドライン引数を格納した Namespace オブジェクト。
 900        :type args: argparse.Namespace
 901    戻り値:
 902        :returns: Markdown形式の実行レポート文字列。
 903        :rtype: str
 904    """
 905    changed = created + modified
 906
 907    lines: list[str] = []
 908
 909    lines.append("# Execution Report")
 910    lines.append("")
 911    lines.append("## Summary")
 912    lines.append("")
 913    lines.append("| Item | Value |")
 914    lines.append("|---|---|")
 915    lines.append(f"| Workdir | {workdir} |")
 916    lines.append(f"| Started at | {run_result.started_at} |")
 917    lines.append(f"| Finished at | {run_result.finished_at} |")
 918    lines.append(f"| Return code | {run_result.returncode} |")
 919    lines.append(f"| Timed out | {run_result.timed_out} |")
 920    lines.append(f"| Files before | {len(before)} |")
 921    lines.append(f"| Files after | {len(after)} |")
 922    lines.append(f"| Created files | {len(created)} |")
 923    lines.append(f"| Modified files | {len(modified)} |")
 924    lines.append(f"| Deleted files | {len(deleted)} |")
 925    lines.append("")
 926    lines.append("")
 927
 928    lines.append("## Command")
 929    lines.append("")
 930    lines.append(fenced_block(run_result.command, lang="bash", max_chars=args.max_output_chars))
 931    lines.append("")
 932
 933    lines.append("## Files before execution")
 934    lines.append("")
 935    lines.append(fenced_block(render_tree(before, args.max_tree_items), lang="text"))
 936    lines.append("")
 937
 938    lines.append(make_file_table(list(before.values()), "Files before execution links", args.max_file_items))
 939    
 940    lines.append("## File changes")
 941    lines.append("")
 942    lines.append(make_file_table(created, "Created files", args.max_file_items))
 943    lines.append(make_file_table(modified, "Modified files", args.max_file_items))
 944    lines.append(make_file_table(deleted, "Deleted files", args.max_file_items))
 945
 946    lines.append(make_images_section(changed, args.max_image_items))
 947
 948    lines.append(make_log_hits_section(log_hits, args.max_log_hits))
 949
 950    lines.append("## stdout")
 951    lines.append("")
 952    lines.append(fenced_block(run_result.stdout, lang="text", max_chars=args.max_output_chars))
 953    lines.append("")
 954
 955    lines.append("## stderr")
 956    lines.append("")
 957    lines.append(fenced_block(run_result.stderr, lang="text", max_chars=args.max_output_chars))
 958    lines.append("")
 959
 960    lines.append("## Environment")
 961    lines.append("")
 962    lines.append("| Item | Value |")
 963    lines.append("|---|---|")
 964    lines.append(f"| Python | {sys.version.split()[0]} |")
 965    lines.append(f"| Platform | {platform.platform()} |")
 966    lines.append(f"| Script | {Path(__file__).name} |")
 967    lines.append("")
 968
 969    return "\n".join(lines)
 970
 971
 972def run_pandoc(
 973    report_md: Path,
 974    report_html: Path,
 975    pandoc_cmd: str,
 976    toc: bool,
 977    standalone: bool,
 978    encoding: str,
 979) -> tuple[int, str, str]:
 980    """
 981    概要:
 982        Pandocを使用してMarkdownレポートをHTMLに変換します。
 983    詳細説明:
 984        指定されたMarkdownファイルを入力として、Pandocコマンドを実行し、
 985        HTMLファイルを生成します。目次 (toc) やスタンドアロンモード (standalone)
 986        のオプションを適用できます。Pandocの標準出力と標準エラー出力を捕捉して返します。
 987    引数:
 988        :param report_md: 入力となるMarkdownレポートファイルのパス。
 989        :type report_md: Path
 990        :param report_html: 出力されるHTMLファイルのパス。
 991        :type report_html: Path
 992        :param pandoc_cmd: Pandocコマンドの実行ファイル名またはパス。
 993        :type pandoc_cmd: str
 994        :param toc: HTMLレポートに目次を含めるかどうかを示す真偽値。
 995        :type toc: bool
 996        :param standalone: スタンドアロンHTMLファイルを生成するかどうかを示す真偽値。
 997        :type standalone: bool
 998        :param encoding: Pandocの出力デコードに使用するエンコーディング。
 999        :type encoding: str
1000    戻り値:
1001        :returns: Pandocの終了コード、標準出力、標準エラー出力のタプル。
1002        :rtype: tuple[int, str, str]
1003    """
1004    cmd = [pandoc_cmd, str(report_md), "-o", str(report_html)]
1005
1006    if standalone:
1007        cmd.insert(1, "-s")
1008
1009    if toc:
1010        cmd.insert(1, "--toc")
1011
1012    completed = subprocess.run(
1013        cmd,
1014        cwd=str(report_md.parent),
1015        text=True,
1016        encoding=encoding,
1017        errors="replace",
1018        stdout=subprocess.PIPE,
1019        stderr=subprocess.PIPE,
1020    )
1021    return completed.returncode, completed.stdout or "", completed.stderr or ""
1022
1023
1024def positive_or_zero_float(text: str) -> float:
1025    """
1026    概要:
1027        文字列を非負の浮動小数点数に変換します。
1028    詳細説明:
1029        argparse の型として使用され、入力文字列を浮動小数点数に変換します。
1030        負の値やゼロも許容します。
1031    引数:
1032        :param text: 浮動小数点数に変換する文字列。
1033        :type text: str
1034    戻り値:
1035        :returns: 変換された浮動小数点数。
1036        :rtype: float
1037    """
1038    value = float(text)
1039    return value
1040
1041
1042def build_argparser() -> argparse.ArgumentParser:
1043    """
1044    概要:
1045        コマンドライン引数パーサーを構築します。
1046    詳細説明:
1047        スクリプトが受け入れるすべてのコマンドライン引数を定義し、
1048        ヘルプメッセージとデフォルト値、型変換などを設定します。
1049    戻り値:
1050        :returns: 構築された argparse.ArgumentParser オブジェクト。
1051        :rtype: argparse.ArgumentParser
1052    """
1053    p = argparse.ArgumentParser(
1054        description="Run a command and generate Markdown/HTML execution report.",
1055        formatter_class=argparse.RawTextHelpFormatter,
1056    )
1057
1058    p.add_argument("--workdir", type=str, default=".", help="作業ディレクトリ")
1059    p.add_argument("--cmd", type=str, required=True, help="workdir 内で実行するコマンド文字列")
1060
1061    p.add_argument(
1062        "--report",
1063        type=str,
1064        default="report.md",
1065        help="Markdownレポート名。workdir内に作成される。",
1066    )
1067
1068    p.add_argument(
1069        "--html",
1070        type=int,
1071        default=0,
1072        choices=[0, 1],
1073        help="1ならpandocでHTMLも作成する",
1074    )
1075    p.add_argument(
1076        "--html-name",
1077        type=str,
1078        default="report.html",
1079        help="HTML出力名。workdir内に作成される。",
1080    )
1081    p.add_argument(
1082        "--pandoc-cmd",
1083        type=str,
1084        default="pandoc",
1085        help="pandocコマンド名またはパス",
1086    )
1087    p.add_argument("--toc", type=int, default=1, choices=[0, 1], help="HTMLに目次を付ける")
1088    p.add_argument(
1089        "--standalone",
1090        type=int,
1091        default=1,
1092        choices=[0, 1],
1093        help="pandoc -s を使う",
1094    )
1095
1096    p.add_argument(
1097        "--shell",
1098        type=int,
1099        default=1,
1100        choices=[0, 1],
1101        help="1なら shell=True で実行する。Windowsでは通常1が便利。",
1102    )
1103    p.add_argument(
1104        "--timeout",
1105        type=positive_or_zero_float,
1106        default=0.0,
1107        help="タイムアウト秒。0以下なら無制限。",
1108    )
1109
1110    p.add_argument(
1111        "--ignore",
1112        type=str,
1113        default=DEFAULT_IGNORE,
1114        help="監視から除外するパターンのカンマ区切り",
1115    )
1116
1117    p.add_argument(
1118        "--log-exts",
1119        type=str,
1120        default=LOG_EXTS_DEFAULT,
1121        help="warning/error を走査する拡張子のカンマ区切り",
1122    )
1123
1124    p.add_argument(
1125        "--error-regex",
1126        type=str,
1127        default=r"\b(error|exception|traceback|failed|failure|not found|permission denied|overflow|underflow)\b",
1128        help="ERRORとして拾う正規表現",
1129    )
1130    p.add_argument(
1131        "--warning-regex",
1132        type=str,
1133        default=r"\b(warning|warn|deprecated|convergence|ill-conditioned)\b",
1134        help="WARNINGとして拾う正規表現",
1135    )
1136    p.add_argument(
1137        "--ignore-log-regex",
1138        type=str,
1139        default="",
1140        help="log走査時に無視する行の正規表現。例: 'mean squared error'",
1141    )
1142
1143    p.add_argument("--encoding", type=str, default="utf-8", help="stdout/stderr/log 読み込みencoding")
1144    p.add_argument("--max-read-bytes", type=int, default=2_000_000, help="logファイル読み込み最大byte数")
1145    p.add_argument("--max-output-chars", type=int, default=30000, help="stdout/stderrのMarkdown出力最大文字数")
1146    p.add_argument("--max-tree-items", type=int, default=500, help="実行前ファイルツリーの最大表示数")
1147    p.add_argument("--max-file-items", type=int, default=300, help="created/modified/deleted 表の最大表示数")
1148    p.add_argument("--max-image-items", type=int, default=50, help="画像埋め込み最大数")
1149    p.add_argument("--max-log-hits", type=int, default=300, help="warning/error hit 表示最大数")
1150    p.add_argument("--max-hits-per-file", type=int, default=100, help="1ファイルあたりのwarning/error hit最大数")
1151
1152    return p
1153
1154
1155def make_inside_workdir(workdir: Path, name: str) -> Path:
1156    """
1157    概要:
1158        指定された名前のパスを作業ディレクトリ内に安全に構築します。
1159    詳細説明:
1160        出力パスが必ず作業ディレクトリのサブパスとなるようにします。
1161        name が絶対パスであっても、ファイル名部分のみを使用して workdir 内にパスを構築します。
1162        これにより、workdir 外への不正なファイル作成を防ぎます。
1163        必要であれば親ディレクトリを作成します。
1164    引数:
1165        :param workdir: 基準となる作業ディレクトリのパス。
1166        :type workdir: Path
1167        :param name: 構築するパスのファイル名または相対パス。
1168        :type name: str
1169    戻り値:
1170        :returns: 作業ディレクトリ内にある解決済みのパス。
1171        :rtype: Path
1172    例外:
1173        :raises ValueError: 出力パスが作業ディレクトリ内に構築できない場合。
1174    """
1175    raw = Path(name)
1176
1177    if raw.is_absolute():
1178        # 絶対パスを許すと「workdir内に作る」という仕様が崩れるため、ファイル名だけ使う。
1179        raw = Path(raw.name)
1180
1181    path = (workdir / raw).resolve()
1182    workdir_resolved = workdir.resolve()
1183
1184    try:
1185        path.relative_to(workdir_resolved)
1186    except ValueError:
1187        raise ValueError(f"Output path must be inside workdir: {path}")
1188
1189    path.parent.mkdir(parents=True, exist_ok=True)
1190    return path
1191
1192
1193def main() -> int:
1194    """
1195    概要:
1196        スクリプトのメイン実行ロジック。
1197    詳細説明:
1198        コマンドライン引数を解析し、指定されたコマンドを実行し、
1199        ファイルシステムのスナップショットを比較してレポートを生成します。
1200        Markdownレポートファイルと、オプションでHTMLレポートファイルを生成します。
1201        エラー処理と Pandoc の実行も行います。
1202    戻り値:
1203        :returns: テスト対象コマンドの終了コード。エラーの場合は1。
1204        :rtype: int
1205    """
1206    parser = build_argparser()
1207    args = parser.parse_args()
1208
1209    workdir = Path(args.workdir).resolve()
1210
1211    if not workdir.exists():
1212        print(f"ERROR: workdir does not exist: {workdir}", file=sys.stderr)
1213        return 1
1214
1215    if not workdir.is_dir():
1216        print(f"ERROR: workdir is not a directory: {workdir}", file=sys.stderr)
1217        return 1
1218
1219    try:
1220        report_md = make_inside_workdir(workdir, args.report)
1221        report_html = make_inside_workdir(workdir, args.html_name)
1222    except Exception:
1223        traceback.print_exc()
1224        return 1
1225
1226    ignore_patterns = parse_csv_list(args.ignore)
1227    log_exts = {x.lower() for x in parse_csv_list(args.log_exts)}
1228
1229    error_regex = re.compile(args.error_regex, re.IGNORECASE)
1230    warning_regex = re.compile(args.warning_regex, re.IGNORECASE)
1231    ignore_log_regex = compile_optional_regex(args.ignore_log_regex)
1232
1233    print(f"[run_report] workdir: {workdir}")
1234    print(f"[run_report] command: {args.cmd}")
1235
1236    before = snapshot_files(workdir, ignore_patterns)
1237
1238    run_result = run_command(
1239        command=args.cmd,
1240        workdir=workdir,
1241        shell=bool(args.shell),
1242        timeout_sec=args.timeout,
1243        encoding=args.encoding,
1244    )
1245
1246    after = snapshot_files(workdir, ignore_patterns)
1247
1248    created, modified, deleted = diff_snapshots(before, after)
1249
1250    changed = created + modified
1251
1252    log_hits = scan_log_files(
1253        workdir=workdir,
1254        files=changed,
1255        log_exts=log_exts,
1256        encoding=args.encoding,
1257        error_regex=error_regex,
1258        warning_regex=warning_regex,
1259        ignore_regex=ignore_log_regex,
1260        max_read_bytes=args.max_read_bytes,
1261        max_hits_per_file=args.max_hits_per_file,
1262    )
1263
1264    # stderr も warning/error scan 対象にする
1265    log_hits.extend(
1266        scan_text_for_hits(
1267            relpath="__stderr__",
1268            text=run_result.stderr,
1269            error_regex=error_regex,
1270            warning_regex=warning_regex,
1271            ignore_regex=ignore_log_regex,
1272            max_hits_per_file=args.max_hits_per_file,
1273        )
1274    )
1275
1276    report_text = make_report(
1277        workdir=workdir,
1278        before=before,
1279        after=after,
1280        created=created,
1281        modified=modified,
1282        deleted=deleted,
1283        run_result=run_result,
1284        log_hits=log_hits,
1285        args=args,
1286    )
1287
1288    report_md.write_text(report_text, encoding="utf-8", newline="\n")
1289    print(f"[run_report] wrote: {report_md}")
1290
1291    if args.html == 1:
1292        try:
1293            rc, out, err = run_pandoc(
1294                report_md=report_md,
1295                report_html=report_html,
1296                pandoc_cmd=args.pandoc_cmd,
1297                toc=bool(args.toc),
1298                standalone=bool(args.standalone),
1299                encoding=args.encoding,
1300            )
1301            if rc == 0:
1302                print(f"[run_report] wrote: {report_html}")
1303            else:
1304                print(f"[run_report] pandoc failed: returncode={rc}", file=sys.stderr)
1305                if out:
1306                    print(out)
1307                if err:
1308                    print(err, file=sys.stderr)
1309        except FileNotFoundError:
1310            print(
1311                "[run_report] pandoc was not found. Markdown report was generated, but HTML was not.",
1312                file=sys.stderr,
1313            )
1314        except Exception:
1315            traceback.print_exc()
1316
1317    # テスト対象コマンドの return code をそのまま返す。
1318    return run_result.returncode
1319
1320
1321if __name__ == "__main__":
1322    try:
1323        sys.exit(main())
1324    except Exception:
1325        traceback.print_exc()
1326        input("\nPress ENTER to terminate>>\n")
1327        sys.exit(1)