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""
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)