0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python文法100本ノック vol.7 ~ファイル・ディレクトリ・パス管理~

Posted at

まえがき

Python100本ノックについての記事です。既存の100本ノックは幾分簡単すぎるようにも感じており、それに対するアンサー記事となります。誤りなどがあれば、ご指摘ください。今回は本番編として、ファイル・ディレクトリ・パス管理を中心に12問扱います。

Q.63

項目 内容
概要 ファイル読み込み処理を FileReader クラスにカプセル化し、自動エンコーディング判定・読み込みエラーの抑制・キャッシュ・ログ記録を統合的に実現せよ。
問題文 FileReader クラスを実装せよ。このクラスは以下を満たすこと:
1. __call__ によりファイルパスを受け取り、内容(文字列)を返す。
2. 読み込み時は chardet(あれば)でエンコーディングを自動判定する。
3. 読み込んだ内容はパス単位でキャッシュされ、同一パスへの再呼び出し時は再読み込みを行わない(force_reload=Trueで強制読み直し可)。
4. ファイルが存在しない場合や読み込みエラー(デコード含む)が発生した場合、例外は発生させず None を返し、エラーログを loguru を使って記録すること。
5. __str__ および __repr__ により、現在キャッシュされているファイル情報を明示的に表示可能とする。
6. 読み込み成功時とキャッシュ利用時には INFO、例外時には ERROR ログを出力せよ。
使用構文 with open, try-except, os.path.isfile, encoding, chardet.detect, __call__, @property, loguru.logger, dict, fallback, force_reload, __str__, __repr__

A.63

■ 模範解答

import os
from typing import Optional
from loguru import logger

# chardetがある場合のみ使用、なければNone
try:
    import chardet
except ImportError:
    chardet = None


class FileReader:
    def __init__(self):
        # ファイルキャッシュ:パス→内容
        self._cache: dict[str, str] = {}

    def __call__(self, path: str, force_reload: bool = False) -> Optional[str]:
        # すでにキャッシュされていて再読み込み不要な場合はキャッシュを返す
        if not force_reload and path in self._cache:
            logger.info(f"[CACHE] Using cached content for: {path}")
            return self._cache[path]

        # ファイルの存在チェック
        if not os.path.isfile(path):
            logger.error(f"[NOT FOUND] File does not exist: {path}")
            return None

        # エンコーディングの自動判定またはデフォルト設定
        encoding = "utf-8"
        try:
            if chardet:
                with open(path, "rb") as f:
                    raw_bytes = f.read()
                    result = chardet.detect(raw_bytes)
                    encoding = result.get("encoding", "utf-8")
                    logger.info(f"[ENCODING] Detected encoding '{encoding}' for: {path}")
            else:
                logger.warning(f"[FALLBACK] chardet not available. Using default utf-8 for: {path}")

            # 読み込み(デコードエラーは"replace"で無害化)
            with open(path, "r", encoding=encoding, errors="replace") as f:
                content = f.read()
                self._cache[path] = content
                logger.info(f"[LOAD] Successfully read: {path}")
                return content

        except Exception as e:
            # 例外を抑制してログ出力し、Noneを返す
            logger.error(f"[ERROR] Failed to read '{path}': {e}")
            return None

    def __str__(self) -> str:
        # キャッシュされたファイル情報を出力
        return f"<FileReader: {len(self._cache)} file(s) cached>"

    def __repr__(self) -> str:
        # キャッシュされたパスを明示的に表示
        return f"FileReader(cached_paths={list(self._cache.keys())})"
実行例1:存在する UTF-8 ファイルの読み込み
# ログ出力設定は不要(loguruは即時出力)
reader = FileReader()

# UTF-8エンコーディングのファイルを読み込む
content = reader("example_utf8.txt")
if content is not None:
    print(content[:100])  # 内容の先頭100文字のみ出力
else:
    print("読み込みに失敗しました。")
実行結果1
[ENCODING] Detected encoding 'utf-8' for: example_utf8.txt
[LOAD] Successfully read: example_utf8.txt
実行例2:存在しないファイルとキャッシュ利用の挙動
reader = FileReader()

# 存在しないファイルを読み込む
content = reader("missing_file.txt")
assert content is None

# 存在するファイルを2回読み込む(2回目はキャッシュ利用)
reader("data.csv")           # 初回読み込み
reader("data.csv")           # キャッシュ利用
実行結果2
[NOT FOUND] File does not exist: missing_file.txt
[ENCODING] Detected encoding 'utf-8' for: data.csv
[LOAD] Successfully read: data.csv
[CACHE] Using cached content for: data.csv

■ 文法・構文まとめ

構文/技法 説明
__call__ 関数のようにインスタンスを使えるようにし、読み込みの入口に
try-except 読み込み中の全例外を捕捉し、ログ出力して失敗時は None を返す
chardet.detect() バイト列からエンコーディングを推定。なければ utf-8 にフォールバック
loguru.logger info, warning, error ログを即時かつ色付きで出力可能。設定不要で便利
os.path.isfile(path) ファイル存在の事前チェックで FileNotFoundError を抑制
errors="replace" デコードエラーによるクラッシュを防ぎ、不正文字を置換
キャッシュ機構(self._cache 同一パスへの複数読み込みを避け、効率化&制御可能な再読み込み設計(force_reload
__str__, __repr__ 状態の簡易確認や対話的使用に役立つ

Q.64

項目 内容
概要 指定された拡張子にマッチするファイルを os.walk を用いて再帰的に探索し、各ディレクトリごとに分類して保持する RecursiveFileScanner クラスを設計せよ。
問題文 RecursiveFileScanner クラスは以下を満たすこと:
1. 指定ディレクトリ以下を再帰的に探索し、ファイル拡張子やパターンに基づきフィルタリングされたファイルを収集する。
2. 結果は defaultdict(list) に保存し、results プロパティ経由でアクセスする。
3. .scan() メソッドで探索を実行、.from_path() によりクラスメソッド経由でのインスタンス生成も可能。
4. 検索対象パターンには *.py, *.md, *.csv など fnmatch 互換の複数指定が可能。
5. loguru を使って INFO/ERROR/WARNING レベルでログ出力する。
6. ファイルパスが誤って指定された場合(存在しない/ファイルがベース)も例外は出さずログに記録する。
7. 各探索ディレクトリの結果は辞書形式に格納し、ファイル名順にソートされる。
8. __repr__() はキャッシュされたファイル数とベースパスを表示する。
使用構文 os.walk, fnmatch.fnmatch, defaultdict(list), @classmethod, property, try-except, sorted, loguru.logger, __repr__, type hints, os.path.isfile

A.64

■ 模範解答

import os
import fnmatch
from collections import defaultdict
from typing import List, Dict
from loguru import logger


class RecursiveFileScanner:
    def __init__(self, base_path: str, patterns: List[str]) -> None:
        # ベースディレクトリと検索パターンを保存
        self._base_path = base_path
        self._patterns = patterns
        self._results: Dict[str, List[str]] = defaultdict(list)  # ディレクトリごとのマッチファイル群

    def scan(self) -> None:
        """再帰的に探索を実行し、条件に一致するファイルを収集"""
        self._results.clear()

        if not os.path.exists(self._base_path):
            logger.error(f"[NOT FOUND] Path does not exist: {self._base_path}")
            return

        if os.path.isfile(self._base_path):
            logger.warning(f"[SKIP] Base path is a file, not a directory: {self._base_path}")
            return

        # os.walkでディレクトリツリーを再帰探索
        for dirpath, _, filenames in os.walk(self._base_path):
            try:
                for filename in sorted(filenames):
                    # すべてのパターンと照合(*.py, *.md など)
                    if any(fnmatch.fnmatch(filename, pattern) for pattern in self._patterns):
                        full_path = os.path.join(dirpath, filename)
                        self._results[dirpath].append(full_path)
                        logger.info(f"[MATCH] {full_path}")
            except PermissionError as e:
                logger.warning(f"[PERMISSION] Skipped {dirpath}: {e}")
            except Exception as e:
                logger.error(f"[ERROR] Unexpected error in {dirpath}: {e}")

    @property
    def results(self) -> Dict[str, List[str]]:
        """探索結果を返す(各リストはファイル名順でソート済み)"""
        return {k: sorted(v) for k, v in self._results.items()}

    @classmethod
    def from_path(cls, path: str, patterns: List[str]) -> "RecursiveFileScanner":
        """初期化と同時に探索を行うファクトリメソッド"""
        instance = cls(path, patterns)
        instance.scan()
        return instance

    def __repr__(self) -> str:
        """インスタンスの要約情報を表示"""
        total = sum(len(v) for v in self._results.values())
        return f"<RecursiveFileScanner(base_path={self._base_path!r}, matched_files={total})>"
実行例1:カレントディレクトリ以下の .py ファイルを検索
# ログ出力設定は不要(loguruは即時に標準出力)
scanner = RecursiveFileScanner.from_path(".", ["*.py"])

print(scanner)  # => <RecursiveFileScanner(base_path='.', matched_files=XX)>

for dirpath, files in scanner.results.items():
    print(f"Directory: {dirpath}")
    for f in files:
        print(f"  - {os.path.basename(f)}")
実行例2:存在しないパス/ファイル単体に対する呼び出し
# 存在しないパス(例外は出ない)
scanner = RecursiveFileScanner.from_path("nonexistent_path", ["*.txt"])
print(scanner.results)  # => {}

# ファイルをベースにしてしまった場合も処理継続
scanner = RecursiveFileScanner.from_path("README.md", ["*.md"])
print(scanner.results)  # => {}
実行結果2
[NOT FOUND] Path does not exist: nonexistent_path
[SKIP] Base path is a file, not a directory: README.md

■ 文法・構文まとめ

使用構文 役割と目的
os.walk 指定ディレクトリ以下を再帰的に走査
fnmatch.fnmatch ワイルドカード指定(*.py, *.md など)でファイル名マッチング
defaultdict(list) ディレクトリ単位でマッチしたファイルパスをまとめる
@classmethod .from_path() によりファクトリパターン的に初期化&実行を同時に行う
@property results を安全にアクセス可能な読み取り専用プロパティとして提供
__repr__ インスタンスの状態を人間が理解しやすく表示
try-except パーミッション/ファイル読み込みエラーを補足し安全にスキップ
loguru.logger 各ステップを INFO/WARNING/ERROR レベルで可視化

Q.65

項目 内容
概要 指定されたパス以下のすべてのファイルを再帰的に探索し、各ファイルの「パス」「拡張子」「バイトサイズ」「更新日時」「パーミッション」などの詳細メタ情報を辞書形式で収集するクラスを設計せよ。
問題文 FileMetaCollector クラスを実装せよ。このクラスは以下を満たすこと:
1. pathlib.Path を用いてディレクトリを再帰的に探索し、ファイルのメタ情報を収集する。
2. 各ファイルのメタ情報(パス, 拡張子, サイズ, 更新日時, パーミッション, 種別など)を辞書形式で格納する。
3. 結果は list[dict] として results プロパティで取得可能とする。
4. 日時は datetime.datetime 形式で記録され、ISO形式やカスタムフォーマットにも変換可能である。
5. ログ出力には loguru を用い、収集件数・失敗件数・警告などを INFO/WARNING/ERROR レベルで記録する。
6. 無効なパス・アクセス権限エラーなどは例外を出さずログに記録してスキップする。
7. __repr__ で要約(件数、対象ディレクトリ)を表示する。
8. .to_json() でメタ情報を JSON ファイルとして出力可能にする。
使用構文 pathlib.Path, os.stat, stat, datetime, dict, list, 内包表記, try-except, loguru.logger, property, with open, json.dump, is_file, suffix

A.65

■ 模範解答

import os
import stat
import json
from typing import List, Dict, Optional
from pathlib import Path
from datetime import datetime
from loguru import logger


class FileMetaCollector:
    def __init__(self, base_path: str):
        # 対象のパス(Pathオブジェクトに変換して保持)
        self._base_path = Path(base_path)
        self._results: List[Dict] = []

    def collect(self) -> None:
        """ファイルメタ情報を再帰的に収集する"""
        self._results.clear()

        if not self._base_path.exists():
            logger.error(f"[NOT FOUND] Path does not exist: {self._base_path}")
            return

        if not self._base_path.is_dir():
            logger.warning(f"[SKIP] Path is not a directory: {self._base_path}")
            return

        count = 0
        failed = 0

        # 再帰的にファイル探索
        for path in self._base_path.rglob("*"):
            try:
                if not path.is_file():
                    continue

                stat_info = path.stat()  # os.stat_result
                meta = {
                    "path": str(path.resolve()),
                    "name": path.name,
                    "extension": path.suffix or "",  # 空文字も許容
                    "size_bytes": stat_info.st_size,
                    "modified": datetime.fromtimestamp(stat_info.st_mtime),
                    "permissions": stat.filemode(stat_info.st_mode),
                    "is_symlink": path.is_symlink()
                }
                self._results.append(meta)
                count += 1
            except Exception as e:
                logger.error(f"[ERROR] Failed to stat file: {path} ({e})")
                failed += 1

        logger.info(f"[DONE] Collected {count} files (failed: {failed})")

    @property
    def results(self) -> List[Dict]:
        """収集されたファイルメタ情報(リスト辞書形式)"""
        return self._results

    def to_json(self, output_path: str, datetime_format: Optional[str] = None) -> None:
        """収集したメタ情報を JSON 形式で保存"""
        output = []
        for item in self._results:
            copy = item.copy()
            if isinstance(copy["modified"], datetime):
                copy["modified"] = copy["modified"].isoformat() if datetime_format is None else copy["modified"].strftime(datetime_format)
            output.append(copy)

        try:
            with open(output_path, "w", encoding="utf-8") as f:
                json.dump(output, f, indent=2, ensure_ascii=False)
            logger.info(f"[EXPORT] Metadata saved to {output_path}")
        except Exception as e:
            logger.error(f"[ERROR] Failed to write JSON: {e}")

    def __repr__(self):
        return f"<FileMetaCollector(path={str(self._base_path)}, files={len(self._results)})>"
実行例1:カレントディレクトリ以下のファイルを収集して表示
collector = FileMetaCollector(".")
collector.collect()
print(collector)

# 最初の5件を確認
for entry in collector.results[:5]:
    print(entry)
実行例2:収集した情報を JSON にエクスポート(ISO日時)
collector = FileMetaCollector("./src")
collector.collect()
collector.to_json("file_metadata.json")
実行結果2
[DONE] Collected 47 files (failed: 0)
[EXPORT] Metadata saved to file_metadata.json

■ 文法・構文まとめ

構文・技術 解説
pathlib.Path.rglob() ワイルドカード指定による再帰的なファイル走査
path.stat() / os.stat_result ファイルのサイズ・更新日時・モードなどの取得
stat.filemode() -rw-r--r-- のようなUNIX風のパーミッション表現に変換
datetime.fromtimestamp() UNIXタイムスタンプから datetime オブジェクトに変換
@property results を読み取り専用で提供
loguru.logger ログ出力(成功時は info、失敗は error、スキップ時は warning
json.dump() ファイルメタ情報を構造化して外部に保存
__repr__ インスタンス概要(パスと収集件数)をインタラクティブに表示可能に
dict, list, 内包表記 ファイル情報のリスト化/辞書整形を簡潔に表現

Q.66

項目 内容
概要 指定ディレクトリを .bak_YYYYMMDD_HHMMSS 形式のサフィックス付きでバックアップするクラス BackupManager を設計する。
問題文 BackupManager クラスは以下を満たすこと:
1. shutil.copytree を用いて指定ディレクトリを再帰コピーし、バックアップ先には .bak_YYYYMMDD_HHMMSS を付与する。
2. バックアップ元が存在しない、もしくはフォルダでない場合はログにエラーを出して終了。
3. バックアップ先ディレクトリが存在しない場合は自動生成する。
4. last_backup_path プロパティで直近のバックアップパスを取得可能にする。
5. .list_backups() メソッドで過去バックアップの一覧(作成日時降順)を取得できるようにする。
6. 例外発生時は loguru で詳細なエラーを記録し、例外は再送出せず安全に停止する。
7. __repr__ はバックアップ元・バックアップルート・直近のバックアップ情報を表示する。
使用構文 shutil.copytree, datetime, os.path.exists, pathlib.Path, try-except, mkdir, loguru.logger, property, sorted, list comprehension, __repr__
発展案 - バックアップ履歴を日時順に管理し、古いバックアップを自動削除する clean_old_backups(max_keep=5) を実装。
- バックアップ時にコピー対象ファイル数・合計サイズをログ出力する。
- @classmethod を用いた簡易ファクトリメソッド(例:BackupManager.create_and_run(...))を追加。

A.66

■ 模範解答

import shutil
from pathlib import Path
from datetime import datetime
from loguru import logger


class BackupManager:
    def __init__(self, source_dir: str, backup_root: str):
        # バックアップ元ディレクトリを Path オブジェクトとして保持
        self._source = Path(source_dir).resolve()
        # バックアップ先のルートディレクトリ
        self._backup_root = Path(backup_root).resolve()
        # 直近のバックアップ先パス(初期状態ではNone)
        self._last_backup_path: Path | None = None

    def backup(self) -> None:
        """バックアップを実行"""
        if not self._source.exists() or not self._source.is_dir():
            logger.error(f"[NOT FOUND] Source directory does not exist or is not a directory: {self._source}")
            return

        # バックアップ名に日付・時刻のサフィックスを付与
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_name = f"{self._source.name}.bak_{timestamp}"
        destination = self._backup_root / backup_name

        try:
            # バックアップ先のルートディレクトリが存在しない場合は作成
            self._backup_root.mkdir(parents=True, exist_ok=True)
            # ディレクトリを丸ごとコピー
            shutil.copytree(self._source, destination)
            self._last_backup_path = destination
            # ログ出力
            logger.info(f"[BACKUP] Successfully backed up '{self._source}' to '{destination}'")
        except Exception as e:
            logger.error(f"[ERROR] Failed to backup {self._source}{destination}: {e}")

    @property
    def last_backup_path(self) -> Path | None:
        """直近のバックアップパスを返す"""
        return self._last_backup_path

    def list_backups(self) -> list[Path]:
        """バックアップ先ディレクトリ内のバックアップを日時順で返す"""
        if not self._backup_root.exists():
            return []
        backups = [p for p in self._backup_root.iterdir() if p.is_dir() and p.name.startswith(self._source.name + ".bak_")]
        backups.sort(key=lambda p: p.stat().st_mtime, reverse=True)
        return backups

    def clean_old_backups(self, max_keep: int = 5) -> None:
        """古いバックアップを自動削除(保持件数を max_keep に制限)"""
        backups = self.list_backups()
        for old in backups[max_keep:]:
            try:
                shutil.rmtree(old)
                logger.info(f"[CLEAN] Removed old backup: {old}")
            except Exception as e:
                logger.error(f"[ERROR] Failed to remove backup {old}: {e}")

    def __repr__(self) -> str:
        return (
            f"<BackupManager(source={self._source}, "
            f"backup_root={self._backup_root}, "
            f"last_backup={self._last_backup_path})>"
        )
実行例1:通常のバックアップ
manager = BackupManager("./data", "./backups")
manager.backup()
print(manager.last_backup_path)  # => ./backups/data.bak_YYYYMMDD_HHMMSS
print(manager.list_backups())    # => [Path(...), Path(...)]
実行結果1
[BACKUP] Successfully backed up '/absolute/path/data' to '/absolute/path/backups/data.bak_20250801_120512'
実行例2:古いバックアップのクリーンアップ
manager = BackupManager("./data", "./backups")
manager.clean_old_backups(max_keep=3)
print("Remaining backups:", manager.list_backups())
実行結果2
[CLEAN] Removed old backup: /absolute/path/backups/data.bak_20250730_101010

■ 文法・構文まとめ

使用要素 解説
shutil.copytree() ディレクトリを再帰的にコピーする標準関数
datetime.now().strftime() バックアップ名に時刻サフィックスを付与
Path.mkdir(parents=True) バックアップ先ルートを自動作成
try-except コピー失敗時に例外をキャッチしログに記録
loguru.logger INFO, ERROR ログを簡潔に記録可能
list comprehension list_backups() でディレクトリ一覧を効率的に生成
__repr__ インスタンス情報をわかりやすく出力

Q.67

項目 内容
概要 指定されたパス以下を再帰的に探索し、ファイルサイズごとにファイル群をグループ化して辞書で返すクラス SizeGrouper を作成せよ。
問題文 クラス SizeGrouper を作成し、以下の機能を実装せよ:
1. pathlib.Path により再帰的にファイルを走査する。
2. os.path.getsize() を用いてファイルサイズ(バイト)を取得し、defaultdict[list] にグループ化する。
3. 同サイズファイルのグループは grouped_files プロパティで取得できる。
4. サイズが1バイト未満(通常0バイト)や、アクセス不可なファイルはログ出力の上スキップする。
5. .filter(min_count=2) により、同サイズファイルが min_count 個以上のグループのみを抽出する機能を提供する。
6. ログは loguru により INFO/ERROR レベルで出力し、処理統計を残す。
7. __repr__() により探索パスとグループ件数を表示せよ。
使用構文 os.path.getsize, pathlib.Path.rglob, defaultdict, property, try-except, loguru.logger, __repr__, filter, dict comprehension
発展案 - ファイルの SHA256 ハッシュによる内容比較付き(同サイズかつ同内容)重複ファイル検出対応。
- .export_to_json() によりサイズごとのファイル一覧を JSON 出力可にする。

A.67

■ 模範解答

import os
from pathlib import Path
from collections import defaultdict
from typing import Dict, List
from loguru import logger


class SizeGrouper:
    def __init__(self, target_dir: str):
        # 探索対象のディレクトリを Path として保持
        self._base_path = Path(target_dir).resolve()
        self._groups: Dict[int, List[Path]] = defaultdict(list)

    def group_by_size(self) -> None:
        """ファイルサイズでグルーピングを実行"""
        self._groups.clear()

        if not self._base_path.exists() or not self._base_path.is_dir():
            logger.error(f"[NOT FOUND] Invalid target directory: {self._base_path}")
            return

        count, skipped = 0, 0

        # 再帰的にファイルを探索
        for file in self._base_path.rglob("*"):
            try:
                if not file.is_file():
                    continue
                size = os.path.getsize(file)
                if size == 0:
                    logger.warning(f"[SKIP] Zero-byte file: {file}")
                    skipped += 1
                    continue
                self._groups[size].append(file)
                count += 1
            except Exception as e:
                logger.error(f"[ERROR] Failed to access file: {file} ({e})")
                skipped += 1

        logger.info(f"[DONE] Processed files: {count}, Skipped: {skipped}")

    @property
    def grouped_files(self) -> Dict[int, List[Path]]:
        """サイズごとのファイルグループを返す(バイト数 → ファイル一覧)"""
        return self._groups

    def filter(self, min_count: int = 2) -> Dict[int, List[Path]]:
        """min_count 個以上のファイルを持つサイズのみ抽出"""
        return {size: files for size, files in self._groups.items() if len(files) >= min_count}

    def __repr__(self) -> str:
        return f"<SizeGrouper(base={self._base_path}, group_count={len(self._groups)})>"
実行例1:通常のバックアップ
grouper = SizeGrouper("./sample_dir")
grouper.group_by_size()

for size, files in grouper.grouped_files.items():
    print(f"Size: {size} bytes")
    for f in files:
        print(f"  - {f}")
仮想ディレクトリ
./sample_dir/
├── a.txt             (1024 bytes)
├── b.txt             (2048 bytes)
├── subdir/
│   ├── c.txt         (1024 bytes)
│   ├── d.log         (0 bytes)
│   └── e.csv         (4096 bytes)
├── f.txt             (1024 bytes)
├── g.tmp             (2048 bytes)
└── broken_link       (アクセス不能・例外発生)
ログ出力
[WARNING] [SKIP] Zero-byte file: /absolute/path/sample_dir/subdir/d.log
[ERROR] Failed to access file: /absolute/path/sample_dir/broken_link ([Errno 2] No such file or directory)
[INFO] [DONE] Processed files: 6, Skipped: 2
実行結果1:コンソール出力
Size: 1024 bytes
  - /absolute/path/sample_dir/a.txt
  - /absolute/path/sample_dir/subdir/c.txt
  - /absolute/path/sample_dir/f.txt
Size: 2048 bytes
  - /absolute/path/sample_dir/b.txt
  - /absolute/path/sample_dir/g.tmp
Size: 4096 bytes
  - /absolute/path/sample_dir/subdir/e.csv
実行例2:重複候補(サイズ一致2件以上)の抽出
filtered = grouper.filter(min_count=2)
for size, files in filtered.items():
    print(f"[{size} bytes] ({len(files)} files):")
    for f in files:
        print(f"  - {f}")
実行結果2:コンソール出力
[1024 bytes] (3 files):
  - /absolute/path/sample_dir/a.txt
  - /absolute/path/sample_dir/subdir/c.txt
  - /absolute/path/sample_dir/f.txt
[2048 bytes] (2 files):
  - /absolute/path/sample_dir/b.txt
  - /absolute/path/sample_dir/g.tmp

■ 文法・構文まとめ

使用構文/構造 説明
os.path.getsize() ファイルサイズ取得
pathlib.Path.rglob("*") 再帰的ファイル探索(ジェネレータ)
defaultdict(list) サイズをキーに複数ファイルを効率的にグルーピング
try-except アクセス不能ファイルや例外発生時にログ出力
loguru.logger 処理結果・エラー・スキップ対象を INFO/WARNING/ERROR として記録
@property grouped_files を読み取り専用で提供
dict comprehension filter() 内で条件付き辞書フィルタリング
__repr__() インスタンス状態のインタラクティブ出力(パスとグループ数)

Q.68

項目 内容
概要 ワイルドカードパターンにマッチするファイルを削除する PatternCleaner クラスを作成せよ。削除件数や削除失敗をログ記録し、統計情報を取得可能とする。
問題文 PatternCleaner クラスを設計し、以下を満たすこと:
1. 複数のパターン(例:*.log, *.tmp)を受け取り、各パターンでマッチしたファイルを削除。
2. 再帰的に探索する。
3. dry_run=True にすると削除せずにログだけ記録。
4. 削除したファイル数をパターン別に collections.Counter で集計し、.stats プロパティで取得可能。
5. loguru による詳細ログ記録(成功、スキップ、失敗)
6. .clean() メソッドで一括削除を行う。
7. .summary() でログ出力形式で削除件数の要約を表示。
8. 例外(PermissionError, FileNotFoundErrorなど)は安全に握りつぶして記録。
使用構文 pathlib.Path.rglob, fnmatch, os.remove, collections.Counter, @property, loguru.logger, dry-run設計, try-except, __repr__, str.format, set
発展案 - .preview() メソッドで削除対象一覧だけを返す。
- clean(patterns: list[str], exclude: list[str]) のように除外パターンも対応。
- .export_log_to_json() で削除記録を外部保存。

A.68

■ 模範解答

import os
from pathlib import Path
from collections import Counter
from loguru import logger
from typing import List


class PatternCleaner:
    def __init__(self, target_dir: str, patterns: List[str], dry_run: bool = False):
        self._target_dir = Path(target_dir).resolve()
        self._patterns = patterns
        self._dry_run = dry_run
        self._counter = Counter()

    def clean(self) -> None:
        """指定されたパターンに一致するファイルを削除"""
        if not self._target_dir.exists() or not self._target_dir.is_dir():
            logger.error(f"[NOT FOUND] Invalid directory: {self._target_dir}")
            return

        for pattern in self._patterns:
            matched_files = list(self._target_dir.rglob(pattern))
            for file in matched_files:
                try:
                    if self._dry_run:
                        logger.info(f"[DRY-RUN] Would remove: {file}")
                    else:
                        os.remove(file)
                        logger.info(f"[DELETE] Removed: {file}")
                    self._counter[pattern] += 1
                except Exception as e:
                    logger.error(f"[ERROR] Failed to delete: {file} ({e})")

    @property
    def stats(self) -> Counter:
        """削除件数の統計情報(パターン別)"""
        return self._counter

    def summary(self) -> None:
        """削除統計をログに出力"""
        logger.info("[SUMMARY] File deletion statistics:")
        for pattern, count in self._counter.items():
            logger.info(f"  Pattern '{pattern}': {count} file(s)")

    def preview(self) -> List[Path]:
        """dry-run 的に削除候補一覧を返す"""
        files = []
        for pattern in self._patterns:
            files.extend(self._target_dir.rglob(pattern))
        return files

    def __repr__(self) -> str:
        return (
            f"<PatternCleaner(dir={self._target_dir}, "
            f"patterns={self._patterns}, dry_run={self._dry_run})>"
        )
実行例1:実際に削除(dry-run=False)
cleaner = PatternCleaner("./test_dir", ["*.log", "*.tmp"])
cleaner.clean()
cleaner.summary()
実行結果1:ログ出力
[DELETE] Removed: /absolute/path/test_dir/a.log
[DELETE] Removed: /absolute/path/test_dir/subdir/b.tmp
[ERROR] Failed to delete: /absolute/path/test_dir/locked.log ([Errno 13] Permission denied)
[SUMMARY] File deletion statistics:
  Pattern '*.log': 1 file(s)
  Pattern '*.tmp': 1 file(s)
実行例2:dry-run モードでのプレビュー確認
cleaner = PatternCleaner("./test_dir", ["*.bak", "*.log"], dry_run=True)
cleaner.clean()
print("Would delete:", cleaner.preview())
実行結果2:コンソール出力(dry-run)
[DRY-RUN] Would remove: /absolute/path/test_dir/a.bak
[DRY-RUN] Would remove: /absolute/path/test_dir/a.log
Would delete: [
  PosixPath('/absolute/path/test_dir/a.bak'),
  PosixPath('/absolute/path/test_dir/a.log')
]

■ 文法・構文まとめ

使用要素 解説
Path.rglob(pattern) ワイルドカードで再帰的なファイルマッチング
os.remove() ファイル削除(例外発生を考慮)
dry_run フラグ 実際に削除せずにログのみ出すトグル
Counter パターンごとの削除件数を集計
loguru.logger エラー・成功・dry-run などをすべて INFO/ERROR レベルで詳細記録
__repr__() クラスの状態(対象ディレクトリ、パターン、dry-run有無)を簡潔に表示
try-except PermissionError, FileNotFoundError 等の例外処理

Q.69

項目 内容
概要 DirectoryZipper クラスを作成し、指定ディレクトリを .zip 形式でアーカイブする。圧縮対象のファイルパターン指定・除外条件・圧縮モードの指定が可能であること。
問題文 DirectoryZipper クラスを作成せよ。次の要件を満たすこと:
1. 指定されたディレクトリ配下を再帰的に走査し、パターン(例: "*.py", "*.txt")に一致するファイルを選別。
2. 除外パターンも受け付け、fnmatch.fnmatch() により除外対象を指定可能。
3. .zip の出力先ファイルパスは指定可能で、既存ファイルは上書きされる。
4. 圧縮モード(ZIP_STORED, ZIP_DEFLATED)は引数で切り替え可能。
5. loguru によるアーカイブ成功・スキップ・例外のログ記録を行う。
6. 圧縮したファイル数は .summary() メソッドで出力できる。
7. .preview() で実際にアーカイブ対象のファイル一覧だけ取得可能。
使用構文 zipfile.ZipFile, os.walk, os.path.relpath, fnmatch, loguru.logger, try-except, with, set, property, str.format, enumerate
発展案 - dry_run オプションでアーカイブせずに対象だけログ出力
- 除外対象をログで明示記録
- zipファイルに含まれるファイルサイズ合計を取得する .total_size プロパティ付き

A.69

■ 模範解答

import os
import fnmatch
import zipfile
from pathlib import Path
from typing import List
from loguru import logger


class DirectoryZipper:
    def __init__(
        self,
        src_dir: str,
        dest_zip: str,
        include_patterns: List[str] = ["*"],
        exclude_patterns: List[str] = [],
        compression: int = zipfile.ZIP_DEFLATED
    ):
        # 圧縮対象ディレクトリ
        self.src_dir = Path(src_dir).resolve()
        # 出力ZIPファイルパス
        self.dest_zip = Path(dest_zip).resolve()
        self.include_patterns = include_patterns
        self.exclude_patterns = exclude_patterns
        self.compression = compression
        self._added_files = []

    def _should_include(self, filename: str) -> bool:
        """パターンに一致すればTrue、除外パターンに一致すればFalse"""
        return (any(fnmatch.fnmatch(filename, pat) for pat in self.include_patterns)
                and not any(fnmatch.fnmatch(filename, ex) for ex in self.exclude_patterns))

    def zip(self) -> None:
        """ディレクトリをZIPファイルに圧縮"""
        if not self.src_dir.exists() or not self.src_dir.is_dir():
            logger.error(f"[ERROR] Invalid source directory: {self.src_dir}")
            return

        try:
            with zipfile.ZipFile(self.dest_zip, "w", compression=self.compression) as zipf:
                for root, _, files in os.walk(self.src_dir):
                    for file in files:
                        full_path = Path(root) / file
                        rel_path = os.path.relpath(full_path, start=self.src_dir)

                        if self._should_include(file):
                            try:
                                zipf.write(full_path, arcname=rel_path)
                                self._added_files.append(rel_path)
                                logger.info(f"[ADDED] {rel_path}")
                            except Exception as e:
                                logger.error(f"[FAILED] {rel_path} ({e})")
                        else:
                            logger.debug(f"[SKIPPED] {rel_path}")
            logger.success(f"[DONE] Created ZIP: {self.dest_zip}")
        except Exception as e:
            logger.exception(f"[FATAL] Failed to create ZIP: {e}")

    def preview(self) -> List[str]:
        """対象となるファイル一覧を返す(実行前プレビュー用)"""
        result = []
        for root, _, files in os.walk(self.src_dir):
            for file in files:
                if self._should_include(file):
                    rel_path = os.path.relpath(Path(root) / file, start=self.src_dir)
                    result.append(rel_path)
        return result

    @property
    def total_files(self) -> int:
        return len(self._added_files)

    def summary(self) -> None:
        logger.info(f"[SUMMARY] Total files added to ZIP: {self.total_files}")

    def __repr__(self):
        return f"<DirectoryZipper(src={self.src_dir}, zip={self.dest_zip}, files={self.total_files})>"
実行例1:通常圧縮(*.py, *.txt を対象に .zip 作成)
zipper = DirectoryZipper(
    src_dir="./src_project",
    dest_zip="./backup/output.zip",
    include_patterns=["*.py", "*.txt"],
    exclude_patterns=["test_*.py"]
)
zipper.zip()
zipper.summary()
実行結果1:ログ出力
[ADDED] main.py
[ADDED] utils/helper.py
[ADDED] README.txt
[SKIPPED] test_utils.py
[DONE] Created ZIP: /absolute/path/backup/output.zip
[SUMMARY] Total files added to ZIP: 3
実行例2:.preview() で圧縮対象を確認(dry-run的)
zipper = DirectoryZipper(
    src_dir="./docs",
    dest_zip="./zipped/docs.zip",
    include_patterns=["*.md"],
    exclude_patterns=["draft_*"]
)
for f in zipper.preview():
    print(f"[TO ZIP] {f}")
実行結果2:出力
[TO ZIP] README.md
[TO ZIP] changelog.md

■ 文法・構文まとめ

使用構文/設計 解説
zipfile.ZipFile with 構文により自動クローズで安全にアーカイブ処理
os.walk() ディレクトリ再帰探索
os.path.relpath() ZIP に書き込むファイルの相対パスを指定
fnmatch.fnmatch() パターンマッチング(*.pytest_*.py に対応)
loguru.logger INFO / DEBUG / ERROR / SUCCESS / EXCEPTION でログを出力
try-except 圧縮中の個別ファイルで発生する例外を握りつぶして安全に継続
List[str](typing) インタフェースの明示的な型ヒント
@property .total_files を読み取り専用のプロパティとして提供

Q.70

項目 内容
概要 SafeTempWriter クラスを作成し、一時ファイルに安全に書き込み、明示的に .commit() されたときのみ指定パスに永続化する。クリーンアップとログ出力、with構文対応も含むこと。
問題文 SafeTempWriter クラスを作成せよ。以下の条件を満たすこと:
1. tempfile.NamedTemporaryFile(delete=False) を使い、一時ファイルに一時的に書き込み。
2. .commit() メソッドで、指定されたファイルに os.replace() で上書き保存。
3. .discard() で一時ファイルを削除して終了。
4. __enter__, __exit__ により with 構文対応。
5. .committed プロパティで永続化有無を確認可能。
6. loguru による詳細ログ(書き込み、保存、削除、例外など)を出力すること。
使用構文 tempfile.NamedTemporaryFile, os.replace, contextlib.AbstractContextManager, property, with, flush, loguru.logger, try-except, Path
発展案 - write_text() / write_binary() 両対応。
- 自動的に commit_on_exit=True オプションを持たせ、__exit__時に永続化可能。
- .read_preview() で内容の先頭だけ確認。

A.70

■ 模範解答

import os
import tempfile
from pathlib import Path
from loguru import logger
from contextlib import AbstractContextManager


class SafeTempWriter(AbstractContextManager):
    def __init__(self, dest_path: str, mode: str = 'w', encoding: str = 'utf-8', commit_on_exit: bool = False):
        self.dest_path = Path(dest_path).resolve()
        self.mode = mode
        self.encoding = encoding
        self._committed = False
        self._commit_on_exit = commit_on_exit

        # 一時ファイルを作成(delete=False により明示的に削除する)
        self._temp_file = tempfile.NamedTemporaryFile(mode=mode, encoding=encoding,
                                                      delete=False, suffix=".tmp")
        self._temp_path = Path(self._temp_file.name).resolve()
        logger.info(f"[CREATE] Temp file: {self._temp_path}")

    def write(self, data: str) -> None:
        """データを書き込み"""
        try:
            self._temp_file.write(data)
            self._temp_file.flush()
            logger.info(f"[WRITE] Written to temp file: {self._temp_path}")
        except Exception as e:
            logger.exception(f"[ERROR] Failed to write: {e}")
            raise

    def commit(self) -> None:
        """一時ファイルを目的ファイルに保存(上書き)"""
        try:
            self._temp_file.close()  # 書き込み終了
            os.replace(self._temp_path, self.dest_path)
            self._committed = True
            logger.success(f"[COMMIT] Saved to: {self.dest_path}")
        except Exception as e:
            logger.exception(f"[ERROR] Failed to commit: {e}")
            raise

    def discard(self) -> None:
        """明示的に一時ファイルを削除"""
        try:
            if self._temp_path.exists():
                os.remove(self._temp_path)
                logger.info(f"[DISCARD] Temp file removed: {self._temp_path}")
        except Exception as e:
            logger.warning(f"[WARN] Failed to delete temp file: {e}")

    def __exit__(self, exc_type, exc_val, exc_tb):
        """with 構文終了時の自動処理"""
        if exc_type:
            logger.error(f"[EXIT] Exception occurred: {exc_val}")
        if self._commit_on_exit and not self._committed:
            self.commit()
        else:
            self.discard()

    @property
    def committed(self) -> bool:
        return self._committed

    def __repr__(self):
        return f"<SafeTempWriter(dest={self.dest_path}, committed={self._committed})>"
実行例1:通常使用(明示的に .commit())
writer = SafeTempWriter("output/result.txt")
writer.write("これは一時的に書かれた内容です。\n")
writer.write("保存するには commit() を呼びます。\n")
writer.commit()
実行結果1:想定ログ出力
[CREATE] Temp file: /tmp/tmpa9gkzq10.tmp
[WRITE] Written to temp file: /tmp/tmpa9gkzq10.tmp
[WRITE] Written to temp file: /tmp/tmpa9gkzq10.tmp
[COMMIT] Saved to: /absolute/path/output/result.txt
実行例2:with 構文で自動永続化
with SafeTempWriter("final/output.txt", commit_on_exit=True) as writer:
    writer.write("このファイルは context により自動保存されます。\n")
実行結果2:想定ログ出力
[CREATE] Temp file: /tmp/tmpasqf6g4e.tmp
[WRITE] Written to temp file: /tmp/tmpasqf6g4e.tmp
[COMMIT] Saved to: /absolute/path/final/output.txt

■ 文法・構文まとめ

使用構文/要素 解説
tempfile.NamedTemporaryFile 実ファイルとして存在する一時ファイルを安全に生成(delete=False
os.replace 目的ファイルを原子的に上書き(失敗時にも中途半端な状態にならない)
flush() バッファリングされたデータをディスクに書き込む
with / __enter__ / __exit__ with 構文で利用することで例外安全性が向上
loguru.logger 操作ログ(作成/書込/削除/永続化)を詳細に記録
commit_on_exit オプションで with の自動保存モードをトグル
@property committed 状態を安全に取得

Q.71

項目 内容
概要 FileDiffer クラスを作成せよ。2つのファイルを比較し、行単位で差分(挿入・削除・変更)を記録すること。空白や大文字小文字の無視など柔軟な比較オプションを持ち、結果はログ出力またはファイル保存可能であること。
要件
  • 1. compare(file1, file2) メソッドで比較処理
  • 2. オプションで ignore_whitespace, ignore_case を持つ
  • 3. 結果は .diff にリスト形式で格納
  • 4. .save_diff(path) で差分レポートをファイルに出力可能
  • 5. loguru によるログ出力(開始・成功・差分・例外)
  • 6. difflib.unified_diff による比較
  • 7. with構文、例外補足、安全性を考慮
使用構文 with open, difflib.unified_diff, os.path.isfile, contextlib.suppress, @staticmethod, enumerate, zip, try-except, Path, loguru.logger
発展案
  • 行数付き出力
  • 出力行のハイライト(+/-)
  • 最大差分数の制限
  • @staticmethod による行前処理の共通化
  • 差分タイプ(変更/追加/削除)分類表示

A.70

■ 模範解答

import difflib
from pathlib import Path
from typing import List
from contextlib import suppress
from loguru import logger


class FileDiffer:
    def __init__(self, ignore_whitespace: bool = False, ignore_case: bool = False):
        self.ignore_whitespace = ignore_whitespace
        self.ignore_case = ignore_case
        self.diff: List[str] = []

    @staticmethod
    def _normalize(line: str, ignore_ws: bool, ignore_case: bool) -> str:
        """オプションに応じて行を前処理"""
        if ignore_ws:
            line = ''.join(line.split())  # 空白削除
        if ignore_case:
            line = line.lower()
        return line

    def compare(self, file1: str, file2: str) -> None:
        """2ファイルを比較し、差分を self.diff に格納"""
        path1, path2 = Path(file1), Path(file2)
        self.diff = []

        if not path1.is_file() or not path2.is_file():
            logger.error(f"[ERROR] Invalid file path: {file1}, {file2}")
            return

        try:
            # ファイル読み込み(with構文で安全に)
            with path1.open("r", encoding="utf-8") as f1, path2.open("r", encoding="utf-8") as f2:
                lines1 = f1.readlines()
                lines2 = f2.readlines()

            # 正規化(比較のため)
            norm1 = [self._normalize(l, self.ignore_whitespace, self.ignore_case) for l in lines1]
            norm2 = [self._normalize(l, self.ignore_whitespace, self.ignore_case) for l in lines2]

            # difflib による差分抽出(元行は保持)
            self.diff = list(difflib.unified_diff(
                lines1, lines2,
                fromfile=str(path1),
                tofile=str(path2),
                lineterm=''
            ))

            if self.diff:
                logger.info(f"[DIFF] Detected differences between {path1.name} and {path2.name}")
            else:
                logger.success(f"[MATCH] Files are identical (after normalization).")

        except Exception as e:
            logger.exception(f"[EXCEPTION] Comparison failed: {e}")

    def save_diff(self, out_path: str) -> None:
        """差分出力をファイルに保存"""
        try:
            out_path = Path(out_path)
            with out_path.open("w", encoding="utf-8") as f:
                for line in self.diff:
                    f.write(line + "\n")
            logger.success(f"[OUTPUT] Diff saved to: {out_path}")
        except Exception as e:
            logger.error(f"[ERROR] Failed to write diff: {e}")

    def __repr__(self):
        return f"<FileDiffer(whitespace={self.ignore_whitespace}, case={self.ignore_case})>"
実行例1:大文字小文字と空白を無視して比較
differ = FileDiffer(ignore_whitespace=True, ignore_case=True)
differ.compare("sample_v1.txt", "sample_v2.txt")
for line in differ.diff:
    print(line)
実行結果1:想定出力(行番号付き差分)
--- sample_v1.txt
+++ sample_v2.txt
@@ -1,4 +1,4 @@
 This is a sample line.
-Another Line with TEXT.
+another line with text!
 Some unchanged content.
-End of File.
+End of file!
実行例2:差分ファイルに出力
differ = FileDiffer()
differ.compare("data_a.txt", "data_b.txt")
differ.save_diff("diff_report.txt")
実行結果2:loguru出力
[DIFF] Detected differences between data_a.txt and data_b.txt
[OUTPUT] Diff saved to: /absolute/path/diff_report.txt

■ 文法・構文まとめ

使用要素 解説
difflib.unified_diff() 差分を unified diff 形式で取得
with open() ファイルを安全に読み込み/書き込み
contextlib.suppress 非致命的エラーの抑制(今回使用しないが発展にて活用可能)
Path / os.path.isfile ファイル存在チェックとパスの抽象化
@staticmethod 前処理関数 _normalize を静的メソッド化して共通化
loguru.logger 操作ログ・エラー・成功・例外出力を整理された形式で提供

Q.72

項目 内容
概要 任意のファイルパス(相対/絶対/ユーザホーム/冗長パス)を正規化・分解・整形して管理する NormalizedPath クラスを定義せよ。
パス文字列を入力として受け取り、ユーザディレクトリ展開(~)・絶対パス変換・冗長パス除去を一貫して行い、ファイルパスを一元的に扱えるようにすること。ログ記録・存在確認・構成部品へのアクセスなどの機能も実装すること。
要件
  • __init__(self, raw_path: str) にて正規化(expanduser → resolve)
  • 正規化済みパスへのアクセス:path, as_posix, parts, filename, parent_dir
  • ファイルの存在確認:validate_exists(strict=True) メソッド(strict=False で警告のみ)
  • ログ出力:処理開始・正常完了・失敗をすべて loguru.logger に記録
  • クラスメソッド from_parts(*parts: str) により構成部品から生成
発展仕様
  • Windows / POSIX 両対応(Path.as_posix() によるスラッシュ変換)
  • 絶対/相対パスの判定:is_absolute プロパティ
  • エラー発生時は FileNotFoundError を明示的に発生
  • インスタンス表現の整備:__repr__()
  • 将来拡張に備え、例外処理・ログを詳細化(try-exceptlogger.exception
使用構文 pathlib.Path.resolve, .expanduser(), .absolute(), .parts, .as_posix(), os.path.join, @property, @classmethod, try-except, loguru.logger

A.72

■ 模範解答

import os
from pathlib import Path
from loguru import logger


class NormalizedPath:
    def __init__(self, raw_path: str):
        try:
            # 元の文字列を Path に変換
            self._raw = Path(raw_path)

            # ユーザディレクトリ(~)展開
            expanded = self._raw.expanduser()

            # 絶対パス化 + resolve() によるシンボリックリンク展開/正規化
            self._resolved = expanded.resolve(strict=False)

            logger.info(f"[NORMALIZED] Input: {raw_path}{self._resolved}")
        except Exception as e:
            logger.exception(f"[ERROR] Failed to normalize path: {raw_path}")
            raise

    @property
    def path(self) -> Path:
        """正規化済み Path オブジェクトを返す"""
        return self._resolved

    @property
    def parts(self):
        """パスの構成要素(タプル)"""
        return self._resolved.parts

    @property
    def filename(self):
        """ファイル名だけを取得"""
        return self._resolved.name

    @property
    def parent_dir(self):
        """親ディレクトリ"""
        return self._resolved.parent

    @property
    def as_posix(self):
        """POSIX形式の文字列出力(Windows対応)"""
        return self._resolved.as_posix()

    @property
    def is_absolute(self):
        """絶対パスかどうか"""
        return self._resolved.is_absolute()

    def validate_exists(self, strict=True):
        """存在確認。strict=False の場合はログ警告のみ"""
        if not self._resolved.exists():
            msg = f"[NOT FOUND] Path does not exist: {self._resolved}"
            if strict:
                logger.error(msg)
                raise FileNotFoundError(msg)
            else:
                logger.warning(msg)
        else:
            logger.success(f"[FOUND] Path exists: {self._resolved}")

    @classmethod
    def from_parts(cls, *parts: str):
        """パス構成要素から再構築"""
        joined = os.path.join(*parts)
        return cls(joined)

    def __repr__(self):
        return f"<NormalizedPath({str(self._resolved)!r})>"
実行例1:ユーザディレクトリと冗長パスを正規化
np = NormalizedPath("~/.././.bashrc")
print("POSIX:", np.as_posix)
print("Filename:", np.filename)
print("Parent:", np.parent_dir)
np.validate_exists(strict=False)
実行結果1:想定出力(行番号付き差分)
[NORMALIZED] Input: ~/.././.bashrc  /home/user/.bashrc
[FOUND] Path exists: /home/user/.bashrc
実行例2:パーツからのパス構築と存在チェック(strict)
np2 = NormalizedPath.from_parts("nonexistent", "dir", "file.txt")
np2.validate_exists(strict=True)
実行結果2:想定ログ出力
[NORMALIZED] Input: nonexistent/dir/file.txt  /absolute/path/nonexistent/dir/file.txt
[ERROR] Path does not exist: /absolute/path/nonexistent/dir/file.txt
実行結果2:想定エラー
FileNotFoundError: [NOT FOUND] Path does not exist: /absolute/path/nonexistent/dir/file.txt

■ 文法・構文まとめ

文法/構文 解説
Path.expanduser() ~ をユーザホームに展開(例:~/file.txt/home/user/file.txt
Path.resolve(strict=False) シンボリックリンクや相対指定(.., .)を解決し、絶対パスへ変換(ファイルの有無は問わない)
as_posix() Windows でもスラッシュ区切りで表現可能(例:C:\\dir\\fileC:/dir/file
@classmethod パス部品(dir, file.txt など)から構築できる柔軟な初期化方法
validate_exists(strict=True/False) パスの存在チェックをオプション付きで実施(ログ付き)
loguru.logger 通常の print() に比べて優れたログ管理機能を提供(レベル別・色分け・ファイル記録など)

Q.73

項目 内容
概要 ファイルを任意ディレクトリに安全に移動する SafeFileMover クラスを定義せよ。
移動先に同名ファイルが存在する場合は、ファイル名末尾に _1, _2, ... のようなインクリメントを付加してリネームし、衝突を回避すること。
要件
  • メソッド move(src: str, dst_dir: str):ファイルを指定ディレクトリに移動
  • 衝突時は base_1.ext, base_2.ext... の形式で連番付け
  • 移動先ファイルパスを返す
  • ログ出力:開始/衝突検知/成功/例外
発展仕様
  • ファイル拡張子保持(os.path.splitext
  • ログ出力に loguru を使用
  • 存在チェックに os.path.exists
  • リネームループに while を使用
  • 例外処理を try-except で包括
使用構文 shutil.move, os.path.exists, os.path.splitext, os.path.basename, os.path.join, f-string, while, try-except, loguru.logger

A.73

■ 模範解答

import os
import shutil
from pathlib import Path
from loguru import logger


class SafeFileMover:
    def __init__(self):
        logger.debug("SafeFileMover initialized.")

    def move(self, src: str, dst_dir: str) -> str:
        """
        指定されたファイルをdst_dirに移動。既に同名ファイルが存在する場合はリネームして保存。
        :param src: 元ファイルパス
        :param dst_dir: 移動先ディレクトリパス
        :return: 実際に移動されたファイルの絶対パス
        """
        try:
            src_path = Path(src)
            dst_dir_path = Path(dst_dir)

            logger.info(f"[START] Moving {src_path}{dst_dir_path}")

            # 移動元ファイル存在チェック
            if not src_path.is_file():
                raise FileNotFoundError(f"Source file does not exist: {src_path}")

            # 移動先ディレクトリ存在チェック
            if not dst_dir_path.is_dir():
                raise NotADirectoryError(f"Destination directory does not exist: {dst_dir_path}")

            base_name = src_path.stem    # 拡張子なしのファイル名
            ext = src_path.suffix        # 拡張子(.txtなど)
            counter = 0

            # 初期の移動先パス
            candidate = dst_dir_path / f"{base_name}{ext}"

            # 衝突していればリネームしていく
            while candidate.exists():
                counter += 1
                candidate = dst_dir_path / f"{base_name}_{counter}{ext}"
                logger.debug(f"[CONFLICT] Trying alternative name: {candidate.name}")

            # ファイルを移動
            shutil.move(str(src_path), str(candidate))
            logger.success(f"[DONE] Moved to: {candidate}")

            return str(candidate.resolve())

        except Exception as e:
            logger.exception(f"[FAILED] Move operation failed: {e}")
            raise
実行例1:同名ファイルがない場合の移動
np = NormalizedPath("~/.././.bashrc")
print("POSIX:", np.as_posix)
print("Filename:", np.filename)
print("Parent:", np.parent_dir)
np.validate_exists(strict=False)
実行結果1:ログ出力例
[START] Moving example.txt  backup/
[DONE] Moved to: /absolute/path/backup/example.txt
実行例2:衝突ファイルが複数ある場合
# backup/example.txt, backup/example_1.txt, backup/example_2.txt がすでに存在する場合

mover = SafeFileMover()
moved_path = mover.move("example.txt", "backup/")
print("Moved to:", moved_path)
実行結果2:ログ出力例
[START] Moving example.txt  backup/
[CONFLICT] Trying alternative name: example_1.txt
[CONFLICT] Trying alternative name: example_2.txt
[CONFLICT] Trying alternative name: example_3.txt
[DONE] Moved to: /absolute/path/backup/example_3.txt

■ 文法・構文まとめ

使用構文 説明
shutil.move ファイルを物理的に移動。移動後のパスは戻り値として取得可能
os.path.exists 指定パスに既存ファイルがあるかチェック
os.path.splitext 拡張子とファイル名を分離(example.txt'example', '.txt'
while 名前衝突を避けるため、ファイル名をインクリメントして探索
f-string 可読性の高い文字列フォーマット(例:f"{name}_{i}.ext"
try-except 実行時エラーを捕捉し、ログ出力しながら制御された例外処理を行う
loguru.logger print()より優れたロギング。ログレベル、色分け、ファイル保存対応。特に開発・監査に有用

Q.73

項目 内容
概要 指定ディレクトリ内のファイルに対し、正規表現に基づいた一括リネーム処理を行う RegexRenamer クラスを設計せよ。
変更前後のマッピングプレビュー機能を備え、dry-run としてログ出力だけ行うモードも搭載すること。
要件
  • 任意の正規表現パターンと置換文字列を用いてファイル名の変換
  • 実行結果の統計(成功数、失敗数、スキップ数)を collections.Counter で記録
  • 例外発生時はスキップし、ログを記録
  • dry-run モードによる実行前プレビュー
発展仕様
  • ディレクトリが存在しない場合は例外を発生
  • .rename_files(...)@classmethod として設計可能
  • 既に同名ファイルがある場合のスキップ/ログ記録対応
  • 置換対象がない場合もログに記録
使用構文 re.sub, os.rename, pathlib.Path.iterdir, Counter, @classmethod, try-except, loguru.logger, Path.name, Path.with_name, Path.resolve()

A.74

■ 模範解答

import re
import os
from pathlib import Path
from collections import Counter
from loguru import logger


class RegexRenamer:
    def __init__(self, pattern: str, repl: str, preview: bool = True):
        self.pattern = re.compile(pattern)  # 正規表現をコンパイル
        self.repl = repl                    # 置換文字列
        self.preview = preview             # dry-run フラグ(True: 実行せずログのみ)
        self.stats = Counter()             # 統計記録(success, fail, skipped)

        logger.info(f"[INIT] RegexRenamer initialized. preview={self.preview}")

    def rename_files(self, target_dir: str) -> dict:
        """
        指定ディレクトリのファイル名を正規表現で一括置換してリネームする。
        :param target_dir: 対象ディレクトリ(文字列)
        :return: 旧→新ファイル名のマッピング辞書
        """
        try:
            dir_path = Path(target_dir)
            if not dir_path.is_dir():
                raise NotADirectoryError(f"Invalid directory: {dir_path}")

            rename_map = {}  # 変換マッピング保持用

            for file_path in dir_path.iterdir():
                if not file_path.is_file():
                    self.stats['skipped'] += 1
                    continue  # サブディレクトリはスキップ

                original_name = file_path.name
                new_name = self.pattern.sub(self.repl, original_name)

                if new_name == original_name:
                    logger.debug(f"[SKIP] No change for: {original_name}")
                    self.stats['skipped'] += 1
                    continue

                new_path = file_path.with_name(new_name)

                if new_path.exists():
                    logger.warning(f"[COLLISION] {new_name} already exists. Skipped.")
                    self.stats['skipped'] += 1
                    continue

                rename_map[original_name] = new_name

                if self.preview:
                    logger.info(f"[PREVIEW] {original_name}{new_name}")
                    continue

                try:
                    os.rename(file_path, new_path)
                    logger.success(f"[RENAMED] {original_name}{new_name}")
                    self.stats['success'] += 1
                except Exception as e:
                    logger.error(f"[FAIL] {original_name}{new_name} ({e})")
                    self.stats['fail'] += 1

            return rename_map

        except Exception as e:
            logger.exception(f"[ERROR] Failed to process directory: {e}")
            raise

    def summary(self):
        """実行結果の統計を返す"""
        return dict(self.stats)
実行例1:dry-run モード(リネームは行わずログ出力のみ)
renamer = RegexRenamer(pattern=r"^(img)(\d+)", repl=r"image_\2", preview=True)
result = renamer.rename_files("test_images/")
print("Preview:", result)
print("Stats:", renamer.summary())
実行結果1:出力ログ例
[INIT] RegexRenamer initialized. preview=True
[PREVIEW] img001.png  image_001.png
[PREVIEW] img002.png  image_002.png
[SKIP] No change for: readme.txt
実行例2:衝突ファイルが複数ある場合
renamer = RegexRenamer(pattern=r"(\.bak)$", repl=r".backup", preview=False)
renamer.rename_files("configs/")
print("Stats:", renamer.summary())
実行結果2:ログ出力例
[INIT] RegexRenamer initialized. preview=False
[RENAMED] config1.bak  config1.backup
[RENAMED] config2.bak  config2.backup
[SKIP] No change for: config_final.yaml

■ 文法・構文まとめ

使用構文 説明
re.sub() 正規表現パターンに基づく文字列置換
os.rename() ファイルのリネーム処理(物理的に変更)
Path.iterdir() ディレクトリ内のファイル列挙
Counter() 成功/失敗/スキップ数などの統計カウント用
Path.name / with_name() パスのファイル名部分を取得/置換
@classmethod(設計応用) rename_files を将来クラスメソッド化することで外部から柔軟に呼び出せる
loguru.logger ログ出力(成功・失敗・警告・プレビュー・例外)をレベル分けして記録
try-except 全体と個別のエラーを捕捉。対象ディレクトリが存在しない場合やファイル操作時の保護にも使用
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?