【Python】Typer・shutil・hashlibでバックアップ世代管理CLIを作ってみた

【Python】Typer・shutil・hashlibでバックアップ世代管理CLIを作ってみた

公開:
CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座

フォルダを手作業でコピーして日付を付けるだけのバックアップは、世代が増えるほど「中身が本当に同じか」が分からなくなります。そこでPythonのTyperでCLIを組み、shutilで世代別コピー、hashlibでSHA-256による整合性照合、保持世代数に応じた古い世代の整理までを1本のツールにまとめました。

配布コードはbackup_gen.pyの1ファイルで、サンプル作成から複数世代の保存・照合・整理まで公開コマンドだけで追体験できます。削除が届く範囲は、ツール自身が作った世代フォルダに限定しました。

Typerの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。

【Python】Typer・shutil・hashlibでバックアップ世代管理CLIを作ってみた|フルカリキュラム
動画の内容をテキストで確認する

オープニング。Typer・shutil・hashlibを使ってバックアップ世代管理CLIを作るカリキュラムを始めます。概要紹介。

Typer・shutil・hashlibの役割と使い方を学ぶ バックアップ世代管理CLIの要件を整理する 完成コードと実行結果を確認する 最後に実コマンドとファイル状態で完成挙動を確かめる 具体的にやること。

sampleで動作確認用のフォルダを作るbackupでフォルダを世代別にコピーするverifyで記録と実ファイルのSHA-256を照合するlistで保存した世代と容量を確認するpruneで古い世代を確認してから整理する 実装環境・必須アプリ。

OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、ターミナル、エクスプローラー パッケージ:pip、typer PythonのTyper・shutil・hashlibとは。

Typer:関数の型ヒントを使い、コマンドと引数を組み立てるCLIライブラリshutil:フォルダのコピーと削除を担うPythonの標準ライブラリhashlib:ファイル内容の照合に使うハッシュ値を計算する標準ライブラリ Typerで作るバックアップ世代管理CLIの要点。

世代名とマニフェストで管理対象を判定するSHA-256とサイズでコピー内容を照合する 整理候補を先に確認し、新しい世代を残す バックアップ世代管理CLIの要件定義。

保存元を変更せず、世代別のフォルダへコピーする ハッシュとサイズの一致を確認してから世代を記録する ファイルの不一致・欠落・未登録を検出する 保存した世代と容量を古い順に一覧表示する 整理は候補の確認を先に行い、指定した新しい世代を残す 保存元との包含関係やリンクを拒否する INTRO: Monaco Editorでバックアップ世代管理CLIを実装。

コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。LINE 001: モジュールの説明文。このファイル全体がTyper・shutil・hashlibを使ったバックアップ世代管理CLIであることを示すドキュメント文字列です。

実行時の動作には影響せず、コードの目的を説明する役割を持ちます。LINE 003: 型ヒントの将来構文を有効化。型注釈の評価を後回しにする宣言です。

今回のPython 3.13では注釈を文字列として保持し、Typerが必要に応じて型情報を取得します。LINE 005: hashlibモジュールの読み込み。ファイルのハッシュ値(SHA-256)を計算するための標準ライブラリを読み込んでいます。

バックアップ内容が改変されていないかの照合に使います。LINE 006: jsonモジュールの読み込み。マニフェスト情報をJSON形式で読み書きするための標準ライブラリを読み込んでいます。

世代情報の保存や読み込みに利用します。LINE 007: reモジュールの読み込み。正規表現を使って世代フォルダ名の形式を判定するための標準ライブラリを読み込んでいます。

命名規則に合うかどうかのチェックに使います。LINE 008: shutilモジュールの読み込み。フォルダのコピーや削除を行うための標準ライブラリを読み込んでいます。

バックアップ作成や世代削除の処理で使われます。LINE 009: datetimeクラスの読み込み。現在時刻を取得して世代フォルダ名に埋め込むためのクラスを読み込んでいます。

作成日時の記録にも利用します。LINE 010: Pathクラスの読み込み。ファイルやフォルダのパスを扱いやすくするための標準ライブラリのクラスを読み込んでいます。

パス操作の中心となる部品です。LINE 011: Optional型の読み込み。ある型の値またはNoneを受け取る型注釈に使うOptionalを読み込みます。

引数を省略できるかどうかは、別に指定する既定値で決まります。LINE 013: typerライブラリの読み込み。コマンドラインアプリを簡単に作るための外部ライブラリを読み込んでいます。

コマンドの定義やメッセージ表示に使います。LINE 016: ツール名の定数。このツール自身を識別するための名前を定数として定義しています。

マニフェストに記録し、他のツールが作ったフォルダと区別するために使います。LINE 017: マニフェスト形式のバージョン。マニフェストファイルの形式が何版かを表す数値を定義しています。

将来形式が変わった際に互換性を判断する目印になります。LINE 018: マニフェストファイル名の定数。世代フォルダの中に置く記録ファイルの名前を定義しています。

この名前でマニフェストの読み書きを行います。LINE 019: データフォルダ名の定数。実際にコピーしたファイルを格納するサブフォルダの名前を定義しています。

世代フォルダの中でデータ本体を分けて管理するために使います。LINE 020: 世代フォルダ名の正規表現。世代フォルダの名前が「gen_連番_日時」という形式に一致するかを調べる正規表現を用意しています。

この形式に合わないフォルダはツール管理外として扱われます。LINE 021: 読み込み単位のサイズ。ファイルを少しずつ読み込む際の1回あたりのサイズを64キロバイトとして定義しています。

大きなファイルでもメモリを圧迫せずにハッシュ計算できるようにするための値です。LINE 022: 表示用ハッシュの桁数。照合結果を表示するときにハッシュ値の先頭何文字を出すかを定義しています。

長いハッシュ値を短く見やすく表示するために使います。LINE 024: サンプルファイル定義の開始。動作確認用に自動生成するサンプルファイルの内容をまとめる辞書の定義を開始しています。

以降の行でファイルパスと中身の組を列挙します。LINE 025: サンプルREADMEの内容。サンプルフォルダに作る説明用ファイルのパスと中身を定義しています。

動作確認のための簡単な説明文が記述されます。LINE 026: サンプル仕様メモの内容。サンプルフォルダに作る仕様メモファイルのパスと中身を定義しています。

バックアップ機能の概要を箇条書きで示しています。LINE 027: サンプルメモの内容。サンプルフォルダに作るテキストメモのパスと中身を定義しています。

打ち合わせ内容を模した文章が入っています。LINE 028: サンプル設定JSONの内容。バックアップ対象に含めるJSONのサンプル内容です。

ここに書いたretentionはデータ例であり、このCLIの保持世代数を設定する値ではありません。LINE 029: サンプルCSVの内容。サンプルフォルダに作るCSVファイルのパスと中身を定義しています。

名前と役割の一覧データが入っています。LINE 030: サンプルファイル定義の終了。サンプルファイルをまとめた辞書の定義を閉じています。

この辞書はsampleコマンドで実際のファイル作成に使われます。RUN 1/10: サンプルデータと世代名のルールを確認する。定数とサンプル内容を定義したところです。

辞書に入れたファイル数や世代名の判定に使う正規表現を表示し、後の処理が参照する土台を確かめます。CHECK 1/10: 途中実行に成功。定数とサンプル内容を定義したところです。

辞書に入れたファイル数や世代名の判定に使う正規表現を表示し、後の処理が参照する土台を確かめます。RETURN 01: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 032: Typerアプリの作成。Typerアプリを作り、ヘルプ文を設定しています。add_completion=Falseは、シェル補完のインストール用オプションを表示しない設定です。

LINE 036: ハッシュ計算関数の定義開始。指定したファイルのSHA-256ハッシュ値を計算する関数の定義を始めています。この関数はファイルの内容が変わっていないか確認する際に呼び出されます。

LINE 037: 関数の説明文。この関数がファイルを分割して読みながらハッシュを計算することを説明するドキュメント文字列です。LINE 038: ハッシュ計算器の準備。

SHA-256方式のハッシュ計算器を新しく用意しています。この後の処理でファイルの内容を少しずつ渡して計算していきます。LINE 039: ファイルをバイナリで開く。

対象ファイルをバイナリモードで開き、処理が終わったら自動的に閉じるようにしています。安全にファイルを読み込むための書き方です。LINE 040: ファイルを一定量ずつ読み込むループ。

ファイルの内容を決まったサイズごとに読み込み、データが無くなるまで繰り返す処理です。大きなファイルでも一度に全部をメモリへ載せずに済みます。LINE 041: 読み込んだ内容をハッシュへ反映。

読み込んだ一部分のデータをハッシュ計算器に渡して、計算内容を更新しています。ループが進むごとにファイル全体の内容が反映されていきます。LINE 042: ハッシュ値の文字列化と返却。

計算し終えたハッシュ値を16進数の文字列に変換して呼び出し元へ返しています。この値がファイルの指紋として利用されます。RUN 2/10: 1ファイルのSHA-256を計算する。

分割読み込みでハッシュを求める関数が完成しました。一時ファイルを作って呼び出し、16進の値と表示用に切り出す先頭部分を確認します。CHECK 2/10: 途中実行に成功。

分割読み込みでハッシュを求める関数が完成しました。一時ファイルを作って呼び出し、16進の値と表示用に切り出す先頭部分を確認します。RETURN 02: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 045: ファイル一覧取得関数の定義開始。指定フォルダ配下のファイルを一覧化する関数の定義を始めています。

バックアップ元と保存先の内容を比較する際の基礎データを作る関数です。LINE 046: 関数の説明文。この関数がファイルを相対パス・サイズ・ハッシュの情報付きで一覧化することを説明するドキュメント文字列です。

LINE 047: 結果格納用リストの準備。ファイル情報を順番に追加していくための空のリストを用意しています。この後のループでここへ情報が積み重ねられます。

LINE 048: フォルダ内を再帰的に走査。指定フォルダの中身をサブフォルダも含めてすべて取得し、名前順に並べて1件ずつ処理します。並び替えることで実行のたびに同じ順序で結果が得られます。

LINE 049: シンボリックリンクの検出。対象がシンボリックリンクやジャンクションであるかを確認しています。リンクを含むと想定外の場所を参照する恐れがあるための安全確認です。

LINE 050: リンク検出時のエラー表示。リンクが見つかった場合に、扱えない旨のエラーメッセージを画面へ表示しています。ユーザーに問題箇所を知らせるためのメッセージです。

LINE 051: 処理の中断。リンクが見つかった場合に、これ以上処理を進めずに終了コード1でプログラムを終わらせています。異常終了を呼び出し元へ伝える仕組みです。

LINE 052: ファイル以外の除外判定。対象がファイルでない場合、つまりフォルダなどである場合を判定しています。ファイル以外は一覧に含めないための条件です。

LINE 053: 次の対象へ処理を進める。ファイルでないものについては後続の処理を飛ばし、ループの次の要素へ移ります。フォルダなどを一覧に加えないための制御です。

LINE 054: ファイル情報の追加開始。対象ファイルの情報をまとめた辞書を、結果リストへ追加する処理を始めています。以降の行でその辞書の中身を組み立てます。

LINE 055: ファイル情報辞書の開始。1件のファイルについてのパス・サイズ・ハッシュをまとめる辞書リテラルの記述を始めています。LINE 056: 相対パスの記録。

基準フォルダから見た相対パスを、区切り文字を統一した形式で記録しています。世代フォルダ間でパスを比較する際の基準になります。LINE 057: ファイルサイズの記録。

対象ファイルのバイトサイズを取得して記録しています。合計容量の表示や整合性チェックに使われます。LINE 058: ハッシュ値の記録。

先に定義したハッシュ計算関数を呼び出し、ファイルの内容から得られたハッシュ値を記録しています。内容の同一性を確認するための重要な情報です。LINE 059: ファイル情報辞書の終了。

1件のファイル情報をまとめた辞書の記述を閉じています。この辞書がリストへ追加されます。LINE 060: 追加処理の終了。

結果リストへの追加処理の呼び出しを閉じています。1件分のファイル情報が正式にリストへ格納されます。LINE 061: 一覧の返却。

組み立てたファイル情報のリストを呼び出し元へ返しています。この一覧がバックアップ処理や照合処理の元データとして使われます。RUN 3/10: フォルダ配下のファイルを一覧化する。

相対パス・サイズ・ハッシュをまとめる関数までを入力しました。階層のあるフォルダを作って渡し、並び順と件数を確かめます。CHECK 3/10: 途中実行に成功。

相対パス・サイズ・ハッシュをまとめる関数までを入力しました。階層のあるフォルダを作って渡し、並び順と件数を確かめます。RETURN 03: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 064: 比較関数の定義開始。記録済みのファイル情報と実際のファイル情報を突き合わせる関数の定義を始めています。

バックアップの整合性を確認する中心的な処理です。LINE 065: 関数の説明文。この関数が記録側と実体側のハッシュを比較して結果を分類することを説明するドキュメント文字列です。

LINE 066: 記録側の索引作成。記録済みのファイル一覧を、パスをキーにした検索しやすい辞書へ変換しています。後の比較処理で素早く該当ファイルを探すために使います。

LINE 067: 実体側の索引作成。実際に存在するファイル一覧を、パスをキーにした検索しやすい辞書へ変換しています。記録側と同じ形式にして比較しやすくしています。

LINE 068: 一致リストの準備。ハッシュとサイズが一致したファイルのパスを集めるための空のリストを用意しています。LINE 069: 不一致リストの準備。

ハッシュまたはサイズが一致しなかったファイルのパスを集めるための空のリストを用意しています。LINE 070: 記録済みファイルを1件ずつ確認。記録側の辞書に含まれる各ファイルについて、順番にパスと情報を取り出して確認していく繰り返し処理です。

LINE 071: 対応する実体ファイルの検索。記録側のパスに対応する実体側の情報を探し出しています。見つからなければ後続の処理でNoneとして扱われます。

LINE 072: 実体が見つからない場合の判定。対応するファイルが実体側に存在しなかったかどうかを確認しています。存在しなければ欠落として別の場所で扱われます。

LINE 073: 次のファイルへ処理を進める。実体が見つからなかった場合は一致・不一致の判定を行わず、次のファイルの確認へ移ります。LINE 074: ハッシュとサイズの一致確認。

記録側と実体側でハッシュ値とファイルサイズの両方が一致しているかどうかを判定しています。両方一致していれば内容が変わっていないと判断できます。LINE 075: 一致リストへの追加。

ハッシュとサイズが一致した場合に、そのファイルのパスを一致リストへ追加しています。LINE 076: 一致しなかった場合の分岐。ハッシュまたはサイズのどちらかが一致しなかった場合の処理へ分岐しています。

LINE 077: 不一致リストへの追加。内容が一致しなかったファイルのパスを不一致リストへ追加しています。バックアップ内容の破損や変更を示す情報になります。

LINE 078: 分類結果辞書の返却開始。一致・不一致・欠落・未登録の分類結果をまとめた辞書を返す処理を始めています。この後の行で各項目が組み立てられます。

LINE 079: 一致ファイルの並び替え。ハッシュとサイズが一致したファイルの一覧を、パス名順に並べ替えて辞書の値として登録します。表示や比較結果を見やすくするための整列処理です。

LINE 080: 不一致ファイルの並び替え。内容が異なっていたファイルの一覧を、パス名順に並べ替えて格納します。どのファイルが変化したかを分かりやすく示すための処理です。

LINE 081: 欠落ファイルの抽出と並び替え。記録側にはあるのに実体側に存在しないファイルを集合の差分で求め、パス名順に並べ替えます。削除されたファイルを検出するための処理です。

LINE 082: 未登録ファイルの抽出と並び替え。実体側にはあるのに記録側にないファイルを集合の差分で求め、パス名順に並べ替えます。新しく追加されたファイルを検出するための処理です。

LINE 083: 分類結果の辞書を閉じる。matched・mismatched・missing・unknownの4種類の分類結果をまとめた辞書を作り、関数の戻り値として返す部分の締めくくりです。RUN 4/10: 記録と実体の差を4分類する。

照合の中心になる関数が動く状態です。記録側と実体側のダミーを渡し、一致・不一致・欠落・未登録がどう振り分けられるかを確認します。CHECK 4/10: 途中実行に成功。

照合の中心になる関数が動く状態です。記録側と実体側のダミーを渡し、一致・不一致・欠落・未登録がどう振り分けられるかを確認します。RETURN 04: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 086: サイズ整形関数の定義。バイト数を受け取り、人が読みやすい単位の文字列に変換する関数を定義しています。

ファイルサイズの表示に使われます。LINE 087: 関数の説明を記すdocstring。この関数がバイト数を読みやすい単位に整えることを説明する文章です。

処理の目的を示しています。LINE 088: 1024バイト未満かどうかの判定。サイズが1024バイト未満であればB単位のまま扱うと判断する条件式です。

単位変換の分岐の最初の段階になります。LINE 089: バイト単位の文字列を返す。サイズが小さい場合に、そのままバイト数の後ろに『B』を付けた文字列を作って返します。

単位変換せずに表示する処理です。LINE 090: 1メガバイト未満かどうかの判定。サイズが1024×1024バイト未満であればKB単位で表示すると判断する条件式です。

中間サイズの分岐にあたります。LINE 091: キロバイト単位の文字列を返す。サイズを1024で割ってキロバイトに換算し、小数点以下1桁のKB表記の文字列を作って返します。

LINE 092: メガバイト単位の文字列を返す。サイズを1024の2乗で割ってメガバイトに換算し、小数点以下1桁のMB表記の文字列を作って返します。大きなサイズの場合の最終的な変換結果です。

RUN 5/10: バイト数を読みやすい単位へ変える。サイズ表示用の関数を追加しました。境界に近い値を渡して、B・KB・MBの切り替わり方を確認します。

CHECK 5/10: 途中実行に成功。サイズ表示用の関数を追加しました。境界に近い値を渡して、B・KB・MBの切り替わり方を確認します。

RETURN 05: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 096: マニフェスト読み込み関数の定義。

世代フォルダを渡すと、条件を満たす場合だけマニフェストの内容を返す関数を定義しています。世代の正当性を確認する中心となる関数です。LINE 097: 関数の説明を記すdocstring。

世代フォルダの目印を確認し、条件を満たすときだけマニフェストを返すという関数の役割を説明しています。LINE 098: リンクやフォルダ判定の最初のチェック。渡されたパスがシンボリックリンクやジャンクションであったり、そもそもフォルダでない場合を判定します。

不正な対象を早い段階で除外するための処理です。LINE 099: 条件を満たさない場合はNoneを返す。直前の条件に当てはまった場合、このフォルダは世代として扱えないためNoneを返して処理を打ち切ります。

LINE 100: フォルダ名が命名規則に合うか確認。フォルダ名が世代フォルダの命名パターンに一致するかを正規表現で確認します。一致しなければツール製の世代ではないと判断します。

LINE 101: 命名規則に合わない場合はNoneを返す。フォルダ名がパターンに一致しなかった場合、世代フォルダとして扱わずNoneを返します。LINE 102: マニフェストファイルのパスを組み立てる。

世代フォルダの下にあるマニフェストファイルのパスを、フォルダ名と定数を組み合わせて作成します。LINE 103: データフォルダのパスを組み立てる。世代フォルダの下にある実データ格納用フォルダのパスを、フォルダ名と定数を組み合わせて作成します。

LINE 104: マニフェストとデータフォルダのリンク判定。マニフェストファイルやデータフォルダがシンボリックリンクやジャンクションでないかを確認します。安全に扱えない場合を除外するための処理です。

LINE 105: リンクだった場合はNoneを返す。直前の条件でリンクだと判定された場合、信頼できない世代としてNoneを返して処理を打ち切ります。LINE 106: 必須ファイルとフォルダの存在確認。

マニフェストファイルが実在するファイルであり、データフォルダが実在するフォルダであるかを確認します。LINE 107: 存在しない場合はNoneを返す。マニフェストファイルまたはデータフォルダが見つからない場合、世代として不完全と判断しNoneを返します。

LINE 108: 例外処理の開始。マニフェストファイルの読み込みで発生しうるエラーに備え、try文でこの後の処理を囲みます。LINE 109: マニフェストファイルの読み込みと解析。

マニフェストファイルの中身をUTF-8で読み込み、JSONとして解析してPythonの辞書やリストに変換します。LINE 110: 読み込みエラーの捕捉。ファイル読み込みの失敗やJSON形式が壊れている場合に発生する例外をまとめて捕まえます。

LINE 111: 読み込み失敗時はNoneを返す。例外が発生した場合は、正しいマニフェストとして扱えないためNoneを返して処理を終えます。LINE 112: 解析結果が辞書型か確認。

JSONを解析した結果が辞書型であるかを確認します。想定外の形式のデータを弾くための処理です。LINE 113: 辞書型でない場合はNoneを返す。

解析結果が辞書でなかった場合、マニフェストとして扱えないためNoneを返します。LINE 114: ツール名の一致を確認。マニフェスト内のtool項目がこのツールの名前と一致するかを確認します。

他ツールが作ったフォルダを除外するための処理です。LINE 115: ツール名が異なる場合はNoneを返す。tool項目が一致しなかった場合、このツール製の世代でないと判断しNoneを返します。

LINE 116: マニフェストバージョンの一致を確認。マニフェストの形式バージョンが、対応するMANIFEST_VERSIONと一致するかを確認します。異なる形式の記録を読み込まないための条件です。

LINE 117: バージョンが異なる場合はNoneを返す。バージョンが一致しなかった場合、扱えないマニフェストと判断してNoneを返します。LINE 118: 検証済みマニフェストを返す。

すべての条件を満たした場合に、読み込んだマニフェストの内容をそのまま関数の戻り値として返します。LINE 121: 削除可否判定関数の定義。保存先フォルダと世代フォルダを受け取り、削除してよい世代かどうかを判定する関数を定義しています。

LINE 122: 関数の説明を記すdocstring。削除対象にできるのは保存先直下にあるツール製の世代だけであるという条件を説明しています。LINE 123: 直下配置とマニフェスト有効性を確認して返す。

世代フォルダの親が保存先フォルダと一致し、かつload_manifestで有効なマニフェストが取得できることを合わせて確認し、その結果を真偽値として返します。LINE 126: 保存先走査関数の定義。保存先フォルダを受け取り、ツール管理下の世代一覧とそれ以外の項目一覧を分けて返す関数を定義しています。

LINE 127: 関数の説明を記すdocstring。保存先を走査し、ツール管理下の世代とそれ以外に分けるという関数の役割を説明しています。LINE 128: 世代一覧の変数を初期化。

世代フォルダとそのマニフェストの組を格納するための空のリストを用意します。LINE 129: 管理外項目の変数を初期化。ツール管理外と判定されたフォルダやファイルを格納するための空のリストを用意します。

LINE 130: 保存先フォルダの存在確認。保存先として渡されたパスがフォルダとして存在するかを確認します。存在しなければこれ以上走査できません。

LINE 131: 存在しない場合は空の結果を返す。保存先フォルダが存在しない場合、初期化した空のリストをそのまま結果として返し処理を終えます。LINE 132: 保存先直下の項目を順に処理。

保存先フォルダの直下にある項目を名前順に並べ替えてから、1つずつ順に処理していくループです。LINE 133: 各項目のマニフェスト取得を試みる。ループで取り出した各項目に対してload_manifestを呼び出し、有効なマニフェストがあるかどうかを確認します。

LINE 134: マニフェストが無い場合の判定。マニフェストが取得できなかった場合、その項目はツール管理外のものであると判断します。LINE 135: 管理外リストへの追加。

ツール管理外と判断された項目を、foreignリストに追加していきます。LINE 136: マニフェストがある場合の分岐。直前の条件に当てはまらなかった場合、つまり有効なマニフェストが得られた場合の処理に進みます。

LINE 137: 世代一覧への追加。フォルダとそのマニフェストを組にして、generationsリストに追加します。LINE 138: 世代の並び替え。

集まった世代一覧を、フォルダ名の文字列順に並べ替えます。命名規則によりこれが世代の生成順にも対応します。LINE 139: 走査結果を返す。

整理し終えた世代一覧と管理外項目一覧をタプルとしてまとめ、呼び出し元に返します。RUN 6/10: 管理下の世代と管理外の項目を仕分ける。マニフェストの判定と保存先の走査までがそろいました。

世代らしいフォルダと無関係なフォルダを並べて置き、どちらに分類されるかを確かめます。CHECK 6/10: 途中実行に成功。マニフェストの判定と保存先の走査までがそろいました。

世代らしいフォルダと無関係なフォルダを並べて置き、どちらに分類されるかを確かめます。RETURN 06: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 142: 世代一覧表示関数の定義。世代の一覧を受け取り、画面に一覧表示する関数を定義しています。LINE 143: 関数の説明を記すdocstring。

世代一覧を古い順に表示するという、この関数の役割を説明しています。LINE 144: 世代が存在するかの確認。渡された世代一覧が空かどうかを確認します。

表示すべき世代がない場合に備えた分岐です。LINE 145: 世代が無い場合のメッセージ表示。世代が1つもない場合に、その旨を示すメッセージを画面に表示します。

LINE 146: 表示処理の早期終了。世代が無い場合はこれ以上処理する必要がないため、ここで関数の実行を終了します。LINE 147: 合計サイズ集計用の変数を初期化。

すべての世代の合計サイズを積み上げていくための変数を、0から開始するために用意します。LINE 148: 世代を番号付きで順に処理。世代一覧を1から始まる番号付きで取り出し、フォルダとマニフェストの組として1件ずつ処理していきます。

LINE 149: マニフェストからファイル一覧を取得。マニフェストの中からファイル情報の一覧を取り出します。見つからない場合に備えて空リストを既定値としています。

LINE 150: 世代内の合計サイズを計算。マニフェストに記録された各ファイルのサイズを合計します。ここでは実ファイルを再計測せず、記録上の容量を一覧表示に使います。

LINE 151: 全体合計への加算。今処理している世代のサイズを、すべての世代を通した合計サイズに加算していきます。LINE 152: 作成日時の取得。

マニフェストから作成日時の情報を取り出します。存在しない場合に備えてハイフンを既定値としています。LINE 153: 世代情報の1行表示を開始。

番号・世代名・作成日時・ファイル件数・サイズをまとめて画面に表示する処理の呼び出しを開始しています。LINE 154: 世代1件分の情報を表示。世代の番号・フォルダ名・作成日時・ファイル件数・サイズを1行にまとめて表示します。

format_sizeを使うことで、サイズを読みやすい単位に変換して表示しています。LINE 155: echo呼び出しの文字列を閉じる。typer.echoに渡す文字列の組み立てを終える閉じ括弧です。

前の行で作った表示内容をここで確定させます。LINE 156: 世代の合計情報を表示。すべての世代数と合計サイズをまとめて画面に表示します。

ここまでの繰り返しで積み上げてきたtotalの値を使って、最後にまとめの1行を出力しています。LINE 159: 次の世代名を作る関数の定義。既存世代と作成日時から次の世代名を作る関数です。

同じ保存先へ順番にバックアップを作成する前提で、現在の最大番号の次を使います。LINE 160: 関数の説明文(docstring)。既存の最大番号に1を足す方針を説明しています。

順番に実行する場合は、日時が同じ秒でも連番によって世代名を分けられます。LINE 161: 連番を集める空リストの用意。これから見つかる世代番号を溜めておくための空のリストを用意します。

この後のループでここに番号を追加していきます。LINE 162: 既存世代を1件ずつ取り出す。渡された世代一覧を1件ずつ順番に取り出して処理するループです。

フォルダのパスだけを使い、マニフェスト情報は今回使わないので捨てています。LINE 163: フォルダ名から番号部分を抽出。世代フォルダの名前が命名規則に合っているかを正規表現で確認し、一致した部分を取り出します。

ここで数字の部分だけを後で使えるようにしています。LINE 164: 命名規則に一致したかを判定。直前の正規表現の照合結果があるかどうかを確認する条件分岐です。

一致しなかった場合は番号の取得をスキップします。LINE 165: 連番の数値をリストに追加。一致した部分から連番の数字を取り出して整数に変換し、先ほど用意したリストに追加します。

これにより既存の世代番号が数値として集まります。LINE 166: 次に使う連番を計算。集めた番号の中から最大値を求め、それに1を加えて次の連番を決めます。

まだ世代が1つもない場合は0を初期値として使い、最初の番号を1にしています。LINE 167: 連番の上限チェック。新しい連番が上限の9999を超えていないかを確認する条件分岐です。

上限を超える場合は、これ以上番号を発行できないためエラー処理に進みます。LINE 168: 上限超過時のエラーメッセージ表示。世代番号が上限に達したことを知らせるメッセージを画面に表示します。

利用者に別の保存先を使うよう案内しています。LINE 169: エラーで処理を終了。終了コード1でプログラムを終了させ、これ以上バックアップ処理を続けないようにします。

エラーが発生したことをコマンド実行結果としても伝えます。LINE 170: 世代フォルダ名の文字列を作成。連番をゼロ埋めした4桁の数字と、作成日時を組み合わせて世代フォルダ名の文字列を作り、関数の戻り値として返します。

これが新しい世代フォルダの名前になります。RUN 7/10: 次に作る世代名を決める。連番と日時から世代名を作る関数までを入力しました。

既存世代が無い場合と、連番が進んでいる場合の名前を比べます。CHECK 7/10: 途中実行に成功。連番と日時から世代名を作る関数までを入力しました。

既存世代が無い場合と、連番が進んでいる場合の名前を比べます。RETURN 07: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 174: 保存元と保存先の関係を確認する関数。保存元フォルダと保存先フォルダの組み合わせが安全かどうかをチェックする関数を定義します。危険な組み合わせであれば処理を止める役割を持ちます。

LINE 175: 関数の説明文(docstring)。この関数がどのような組み合わせを拒否するのかを説明する文章です。同じフォルダや、片方がもう片方の内側にある場合を対象にしています。

LINE 176: 保存元と保存先が同じか確認。保存元フォルダと保存先フォルダが完全に同じ場所かどうかを確認します。同じ場合はバックアップとして意味がないため、エラーとして扱います。

LINE 177: 同一フォルダ時のエラーメッセージ表示。保存元と保存先が同じフォルダであることを利用者に伝えるメッセージを表示します。設定ミスに気付いてもらうための案内です。

LINE 178: エラーで処理を終了。終了コード1でプログラムを終了させ、これ以上バックアップ処理を進めないようにします。危険な組み合わせのまま処理を続けないための安全策です。

LINE 179: 保存先が保存元の内側にあるか確認。保存先フォルダが保存元フォルダの子孫にあたるかどうかを確認します。保存先が保存元の中にあると、コピー時に無限ループのような問題が起きるおそれがあります。

LINE 180: 包含関係エラーのメッセージ表示。保存先が保存元の内側にあることを知らせるメッセージを表示します。どのフォルダが問題なのかをパスと一緒に伝えています。

LINE 181: エラーで処理を終了。終了コード1でプログラムを終了させ、危険な組み合わせのままバックアップを進めないようにします。LINE 182: 保存元が保存先の内側にあるか確認。

今度は逆に、保存元フォルダが保存先フォルダの子孫にあたるかどうかを確認します。これも意図しないコピーやデータ破損を防ぐためのチェックです。LINE 183: 包含関係エラーのメッセージ表示。

保存元が保存先の内側にあることを知らせるメッセージを表示します。どのフォルダが原因かをパス付きで案内しています。LINE 184: エラーで処理を終了。

終了コード1でプログラムを終了させ、これ以上処理を進めないようにします。この関数での安全確認をすべて通過した場合のみ、後続の処理に進めます。LINE 188: sampleコマンドの登録。

Typerに対して、このコマンドを「sample」という名前で呼び出せるように登録するデコレータです。これにより、コマンドラインからsampleと入力すると次の関数が実行されます。LINE 189: サンプル作成関数の定義開始。

サンプルフォルダを作成するコマンドの関数定義を始めます。実際の引数は次の行で定義されています。LINE 190: サンプル作成先の引数定義。

サンプルフォルダを作る場所を--destオプションで受け取れるように定義します。指定は必須で、ヘルプメッセージも合わせて設定しています。LINE 191: 関数の引数リストを閉じる。

ここまでの引数定義を終える閉じ括弧で、関数本体がこの後に続くことを示します。LINE 192: 関数の説明文(docstring)。このコマンドが固定内容のサンプルフォルダを作ること、そして既存のファイルには手を加えないことを説明しています。

LINE 193: 指定パスを絶対パスに変換。利用者が入力したフォルダのパスを、~展開と絶対パス化によって扱いやすい形に変換します。以降の処理ではこの変換後のパスを使います。

LINE 194: 既存パスがフォルダでないか確認。指定した場所が既に存在していて、かつフォルダではない場合を確認する条件分岐です。ファイルなどが既にある場合はサンプル作成ができないため、エラーとして扱います。

LINE 195: フォルダでない場合のエラーメッセージ表示。指定した場所がフォルダではないことを知らせるメッセージを表示します。利用者に別の場所を指定するよう促す内容です。

LINE 196: エラーで処理を終了。終了コード1でプログラムを終了させ、サンプル作成処理を行わずに止めます。LINE 197: 空でないフォルダかどうかの確認。

作成先フォルダの中に、ファイルやサブフォルダなどの項目が1つでもあるかを確認します。空でない場所にはサンプルを書き込まないための条件です。LINE 198: 空でない場合のエラーメッセージ表示。

空でないフォルダにはサンプルを作成しないことを知らせるメッセージを表示します。既存データの保護を利用者にも分かりやすく伝えています。LINE 199: エラーで処理を終了。

終了コード1でプログラムを終了させ、これ以上サンプル作成処理を進めないようにします。LINE 201: サンプルファイルを1件ずつ処理。あらかじめ用意されたサンプルファイルの辞書から、相対パスと内容を1組ずつ取り出して処理するループです。

この繰り返しで複数のサンプルファイルを作成していきます。LINE 202: 作成先のファイルパスを組み立て。サンプル作成先のフォルダと、サンプルごとの相対パスを組み合わせて、実際に書き込むファイルのパスを作ります。

LINE 203: 必要な親フォルダを作成。ファイルを書き込む前に、その親フォルダが存在しない場合は自動的に作成します。既にフォルダが存在してもエラーにならないようにしています。

LINE 204: サンプル内容をファイルに書き込み。あらかじめ用意された文章をUTF-8の文字コードでファイルに書き込みます。この処理を繰り返すことで、複数のサンプルファイルが実際に作成されます。

LINE 206: 作成したファイルの一覧を取得。作成したサンプルを走査し、相対パス・サイズ・ハッシュ値を取得します。この後のファイル一覧と合計容量の表示に使う情報です。

LINE 207: サンプル作成完了のメッセージ表示。サンプルフォルダを作成したことと、その場所を利用者に知らせるメッセージを表示します。LINE 208: 作成したファイルを1件ずつ処理。

取得したファイル一覧を1件ずつ取り出して処理するループです。ここでは表示のための準備として、ファイルごとの情報を扱います。LINE 209: 個々のファイル情報を表示。

ファイルの相対パスと、読みやすく変換したサイズを1行ずつ表示します。利用者はどのファイルがどれくらいの大きさで作られたかを確認できます。LINE 210: 作成したファイルの合計情報を表示。

作成したファイルの総件数と合計サイズをまとめて表示します。各ファイルのサイズを合計し、読みやすい単位に変換して1行で示しています。RUN 8/10: sampleコマンドでサンプルを作る。

最初のコマンドが完成しました。作成先を指定して呼び出し、固定内容のファイルが並ぶ様子と合計件数を確認します。CHECK 8/10: 途中実行に成功。

最初のコマンドが完成しました。作成先を指定して呼び出し、固定内容のファイルが並ぶ様子と合計件数を確認します。RETURN 08: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 214: backupコマンドの登録。Typerに対して、このコマンドを「backup」という名前で呼び出せるように登録するデコレータです。

これにより、コマンドラインからbackupと入力すると次の関数が実行されます。LINE 215: バックアップ関数の定義開始。バックアップを実行するコマンドの関数定義を始めます。

実際の引数は次の行以降で定義されています。LINE 216: バックアップ元の引数定義。バックアップしたい元のフォルダを、コマンドの1番目の引数として受け取れるように定義します。

指定は必須で、ヘルプメッセージも設定しています。LINE 217: 保存先の引数定義。世代を保存する先のフォルダを、コマンドの2番目の引数として受け取れるように定義します。

こちらも必須の引数として扱っています。LINE 218: 関数の引数リストを閉じる。ここまでの引数定義を終える閉じ括弧で、関数本体がこの後に続くことを示します。

LINE 219: 関数の説明文(docstring)。このコマンドが保存元を新しい世代フォルダへコピーし、その内容をマニフェストとして記録することを説明しています。LINE 220: 保存元パスを絶対パスに変換。

利用者が入力した保存元フォルダのパスを、~展開と絶対パス化によって扱いやすい形に変換します。以降はこの変換後のパスを使います。LINE 221: 保存先パスを絶対パスに変換。

利用者が入力した保存先フォルダのパスも同様に、扱いやすい絶対パスへ変換します。この後の安全確認やフォルダ作成で使われます。LINE 222: 保存元フォルダの存在確認。

変換した保存元パスが実際にフォルダとして存在するかどうかを確認する条件分岐です。存在しない場合はコピーのしようがないためエラーとして扱います。LINE 223: 存在しない場合のエラーメッセージ表示。

保存元フォルダが見つからないことを知らせるメッセージを表示します。利用者に入力したパスを確認してもらうための案内です。LINE 224: エラーで処理を終了。

終了コード1でプログラムを終了させ、これ以上バックアップ処理を進めないようにします。LINE 225: 保存元と保存先の安全確認。先ほど定義した関数を呼び出し、保存元と保存先の組み合わせが同一や包含関係でないかを確認します。

問題があればこの呼び出しの中でエラー終了します。LINE 227: 保存元ファイルの一覧を取得。保存元フォルダの中身を走査し、ファイルごとの相対パス・サイズ・ハッシュ値の一覧を取得します。

この情報がコピー後の照合の基準になります。LINE 228: 保存元が空でないか確認。取得したファイル一覧が空かどうかを確認する条件分岐です。

ファイルが1件もない場合はバックアップする対象がないため、エラーとして扱います。LINE 229: ファイルなしの場合のエラーメッセージ表示。保存元フォルダにファイルが1件もないことを知らせるメッセージを表示します。

利用者にフォルダの中身を確認してもらう案内です。LINE 230: エラーで処理を終了。終了コード1でプログラムを終了させ、これ以上バックアップ処理を進めないようにします。

LINE 232: 保存先フォルダの作成。保存先フォルダが存在しない場合は、必要な親フォルダも含めて自動的に作成します。既に存在している場合でもエラーにならないようにしています。

LINE 233: 既存世代の走査。保存先フォルダを走査し、すでに存在する世代一覧を取得します。ここで得た一覧は次の世代番号を決める際に使われます。

LINE 234: 作成日時の取得。現在の日時を取得し、変数createdに保存します。この値は世代名やマニフェストの作成日時として利用されます。

LINE 235: 新しい世代名の生成。既存の最大番号の次を使い、日時を付けた世代名を取得します。同じ保存先への操作を順番に実行する前提の命名処理です。

LINE 236: 世代フォルダのパス作成。保存先フォルダと生成した世代名を組み合わせて、実際に作成する世代フォルダの完全なパスを作ります。LINE 237: ファイルのコピー実行。

保存元フォルダの中身を丸ごと、世代フォルダ内のdataフォルダへコピーします。この処理でバックアップの実体が作られます。LINE 240: コピー後のファイル一覧取得。

コピー後のdataフォルダを走査し、ファイルごとの相対パス・サイズ・ハッシュを取得します。保存元との照合に使う一覧です。LINE 241: コピー前後の比較。

保存元とコピー後のファイル一覧を比較し、一致・不一致・欠落・未登録の4種類に分類します。LINE 242: 不整合の判定。不一致・欠落・未登録が1件でもあるかを確認します。

問題があるコピーを完了した世代として記録しないための分岐です。LINE 243: エラーメッセージの表示。コピー結果が保存元と一致しなかったことを画面に表示し、この世代を完了として登録しない旨を伝えます。

LINE 244: 異常終了の指示。エラー終了コード1でプログラムを終了させ、以降のマニフェスト作成などの処理を行わせないようにします。LINE 245: マニフェスト辞書の開始。

この世代の情報をまとめて記録するための辞書manifestを作り始めます。この後の各行でキーと値を追加していきます。LINE 246: ツール名の記録。

このバックアップがこのツールによって作られたことを識別できるよう、ツール名をマニフェストに記録します。LINE 247: マニフェストの版数記録。マニフェストの形式バージョンを記録します。

将来形式が変わった際に読み込み側で判別できるようにするためです。LINE 248: 世代名の記録。作成した世代フォルダの名前をマニフェストに記録し、どの世代の情報かを後から特定できるようにします。

LINE 249: 作成日時の記録。先ほど取得した日時を読みやすい文字列に整形し、作成日時としてマニフェストに記録します。LINE 250: 保存元パスの記録。

バックアップ元フォルダの絶対パスを文字列に変換し、マニフェストに記録します。復元時などに参照する情報です。LINE 251: ハッシュアルゴリズム名の記録。

ファイルの照合にSHA-256を使っていることをマニフェストに明記し、後で照合方式が分かるようにします。LINE 252: ファイル一覧の記録。コピー後に集めたファイルごとのパス・サイズ・ハッシュの一覧をマニフェストに記録します。

LINE 253: マニフェスト辞書の完成。ここまでの項目をまとめた辞書の定義を閉じ、変数manifestとして完成させます。LINE 254: マニフェストファイルへの書き込み開始。

世代フォルダ内にマニフェストファイルを作成し、内容を書き込む処理を開始します。LINE 255: JSON形式への変換。manifest辞書を日本語をそのまま含む読みやすいJSON文字列に変換し、書き込む内容として渡します。

LINE 256: 書き込み処理の完了。write_text呼び出しを閉じ、UTF-8でマニフェストファイルへの書き込みを完了させます。LINE 258: 合計サイズの算出。

コピーした全ファイルのサイズを合計し、あとで表示する合計容量を求めます。LINE 259: 作成完了メッセージの表示。新しい世代が作成できたことと、その世代名を画面に表示します。

LINE 260: 保存元の表示。バックアップ元として使われたフォルダのパスを画面に表示し、利用者に確認してもらいます。LINE 261: 保存先の表示。

今回作成した世代フォルダのパスを画面に表示し、どこに保存されたかを分かりやすくします。LINE 262: コピー件数とサイズの表示。コピーしたファイルの件数と、読みやすく整形した合計サイズを画面に表示します。

LINE 263: 照合結果表示の開始。SHA-256による照合結果を表示するためのecho呼び出しを開始します。LINE 264: 照合件数の整形。

一致・不一致・欠落の件数をそれぞれ数え、表示用の文字列に埋め込む準備をします。LINE 265: 件数のフォーマット指定。matched・mismatched・missingそれぞれの件数を取り出し、format文字列の順番通りに渡します。

LINE 266: 照合メッセージの完成。format呼び出しの引数指定を閉じ、照合結果を表す文字列を完成させます。LINE 267: 照合結果メッセージの表示。

組み立てた照合結果の文字列をechoで画面に表示し、利用者にコピーの信頼性を伝えます。LINE 268: マニフェストの場所を表示。今回作成したマニフェストファイルのパスを画面に表示し、内容を確認したい場合の目印にします。

LINE 269: 問題の有無を再確認。照合結果に不一致または欠落があるかどうかを再度確認します。あればエラー終了させる判断に使います。

LINE 270: 問題ありの終了処理。不一致や欠落がある場合はエラー終了コード1でプログラムを終了させ、問題があったことを呼び出し元に伝えます。RUN 9/10: backupコマンドで1世代目を保存する。

コピーと照合、マニフェストの書き出しまでがつながりました。sampleで作ったフォルダを保存し、照合結果の行を確認します。CHECK 9/10: 途中実行に成功。

コピーと照合、マニフェストの書き出しまでがつながりました。sampleで作ったフォルダを保存し、照合結果の行を確認します。RETURN 09: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 274: verifyコマンドの登録。このデコレータにより、以降の関数がverifyというサブコマンドとしてCLIに登録されます。

LINE 275: verify関数の定義開始。整合性照合を行うverify関数の定義を開始します。ここから続く行が引数の設定です。

LINE 276: 保存先引数の定義。照合対象となる世代を保存したフォルダを必須の引数storeとして受け取れるようにします。LINE 277: 世代指定オプションの定義開始。

照合したい世代名を任意で指定できるオプションgenerationの定義を開始します。既定値はNoneです。LINE 278: オプションの名前とヘルプ設定。

コマンドラインで--generationや-gとして指定できるようにし、ヘルプ文言も合わせて設定します。LINE 279: オプション定義の完了。typer.Optionの呼び出しとgenerationパラメータの定義を閉じます。

LINE 280: 関数シグネチャの完了。引数定義を締めくくり、この関数が値を返さないことを示すために戻り値の型をNoneと指定します。LINE 281: 関数の説明文。

マニフェストに記録された内容と実際のファイルのハッシュを突き合わせる処理であることを説明するドキュメント文字列です。LINE 282: 保存先パスの正規化。入力された保存先フォルダのパスを展開し、絶対パスに変換して変数rootに保存します。

LINE 283: 世代一覧の取得。保存先フォルダを走査し、ツールが管理している世代の一覧を取得します。LINE 284: 世代指定の有無を確認。

利用者が--generationオプションで特定の世代名を指定したかどうかを判定します。LINE 285: 世代の絞り込み。指定された世代名と一致する世代だけを残すように、世代一覧を絞り込みます。

LINE 286: 照合対象の有無を確認。絞り込んだ結果、照合できる世代が一つも残っていないかどうかを確認します。LINE 287: 対象なしエラーの表示。

照合できる世代が見つからなかったことを保存先のパスとともに画面に表示します。LINE 288: 対象なしでの終了。照合対象がないためエラー終了コード1でプログラムを終了させます。

LINE 290: 問題件数の初期化。照合中に見つかった問題の件数を数えるための変数problemsを0で初期化します。LINE 291: 世代ごとのループ開始。

対象となる各世代フォルダとそのマニフェストに対して、順番に照合処理を行うループを開始します。LINE 292: 記録済みファイル一覧の取得。マニフェストに記録されているファイル一覧を取り出し、変数recordedに保存します。

LINE 293: 実際のファイル一覧の取得。世代フォルダ内のdataフォルダを走査し、現在実際に存在するファイルの一覧を取得します。LINE 294: 記録と実体の比較。

記録されていたファイル一覧と実際のファイル一覧を突き合わせ、一致・不一致・欠落・未登録に分類します。LINE 295: 実体ファイルの索引作成。実際のファイル情報をパスをキーにした辞書に変換し、後で個別のファイル情報を素早く参照できるようにします。

LINE 296: 照合開始メッセージの表示。どの世代の何件を照合するのかを画面に表示し、これから始まる照合処理の対象を明示します。LINE 297: 記録ファイルごとのループ開始。

マニフェストに記録されている各ファイルについて、一件ずつ照合結果を表示するループを開始します。LINE 298: 対象パスの取得。照合中のファイルの相対パスを取り出し、以降の判定や表示に使う変数rel_pathに保存します。

LINE 299: 記録ハッシュの先頭取得。記録されているSHA-256ハッシュ値の先頭部分だけを取り出し、表示を短く見やすくします。LINE 300: 一致ファイルの判定。

このファイルが比較結果の一致リストに含まれているかどうかを確認します。LINE 301: 一致結果の表示。ファイルが一致していることと、その記録されたハッシュの先頭部分を画面に表示します。

LINE 302: 不一致ファイルの判定。一致していない場合に、次はこのファイルが不一致リストに含まれているかどうかを確認します。LINE 303: 実体ハッシュの先頭取得。

実際のファイルから計算されたハッシュ値の先頭部分を取り出し、記録値との違いを表示するために使います。LINE 304: 不一致内容の表示。記録と実体のハッシュ値を、それぞれ先頭16文字だけ表示します。

不一致の判定には完全な値を使い、この表示から変更箇所そのものは分かりません。LINE 305: 分岐の切り替え。一致でも不一致でもなかった場合の処理へ進むための分岐です。

次の欠落表示につながります。LINE 306: 欠落ファイルの表示。記録にはあるのに実体フォルダに見つからなかったファイルを「欠落」として表示します。

バックアップが壊れている可能性を知らせます。LINE 307: 未登録ファイルの走査。マニフェストに記録されていないのに実体側に存在するファイルを1件ずつ取り出します。

後から追加されたファイルなどを検出する処理です。LINE 308: 未登録ファイルの表示。記録に存在しない実体ファイルを「未登録」として表示します。

バックアップ後に手動で追加されたファイルなどに気付けます。LINE 309: 集計結果の準備。一致・不一致・欠落・未登録それぞれの件数をまとめてタプルにします。

この後のメッセージ表示や合計計算に使う数値です。LINE 310: 一致件数の取得。ハッシュとサイズが一致したファイルの件数を数えます。

正常にバックアップされたファイルの数を表します。LINE 311: 不一致件数の取得。ハッシュまたはサイズが一致しなかったファイルの件数を数えます。

内容が変わってしまったファイルの数を表します。LINE 312: 欠落件数の取得。記録にはあるのに実体側で見つからなかったファイルの件数を数えます。

ファイルが消えてしまった可能性を示す数です。LINE 313: 未登録件数の取得。記録にないのに実体側に存在するファイルの件数を数えます。

想定外に増えたファイルの数を示します。LINE 314: 集計タプルの完成。一致・不一致・欠落・未登録の4つの件数をまとめたタプルを完成させます。

この値が変数countsに代入されます。LINE 315: 照合結果サマリーの表示。先ほど集計した4つの件数をまとめて1行の文章として表示します。

世代ごとの照合結果を分かりやすくまとめる部分です。LINE 316: 問題件数の積み上げ。不一致・欠落・未登録の件数を合計し、全体の問題件数に加算します。

すべての世代を終えた後の総合判定に使われます。LINE 318: 全体結果の表示。照合した世代数と、見つかった問題の合計件数をまとめて表示します。

ここまでの処理結果を利用者に伝える最終行です。LINE 319: 問題有無の判定。問題件数が1件以上あるかどうかを確認します。

問題があった場合は次の行でエラー終了させる分岐です。LINE 320: 異常終了の通知。照合で問題が見つかった場合に、終了コード1でプログラムを終わらせます。

呼び出し元に失敗があったことを伝えます。LINE 324: listコマンドの登録。このあとの関数を「list」というサブコマンドとしてTyperアプリに登録します。

コマンドラインからlistと打つと実行される関数です。LINE 325: list_generations関数の定義。世代一覧を表示するための関数を定義しています。

次の行で受け取る保存先フォルダの情報をもとに処理を進めます。LINE 326: 保存先引数の受け取り。コマンドラインから渡された保存先フォルダのパスを受け取る設定です。

この値がstoreという名前で関数内に渡されます。LINE 327: 関数本体の開始。引数の受け取り部分が終わり、ここから実際の一覧表示処理が始まります。

戻り値がないことも示しています。LINE 328: 関数の説明文。この関数が保存先の世代を古い順に一覧表示するものであることを説明するドキュメント文字列です。

LINE 329: パスの正規化。入力された保存先のパスをホームディレクトリ展開・絶対パス化して扱いやすい形に整えます。以降の処理で使う正式なパスになります。

LINE 330: 保存先存在チェック。指定された保存先フォルダが実際に存在するディレクトリかどうかを確認します。存在しない場合はこのあとエラー処理に進みます。

LINE 331: 存在しない場合のエラー表示。保存先フォルダが見つからない旨のエラーメッセージを表示します。利用者に指定ミスに気付いてもらうためのメッセージです。

LINE 332: 異常終了処理。保存先が存在しない場合に終了コード1でプログラムを終了させます。以降の処理を行わずに安全に止める役割です。

LINE 333: 世代情報の取得。保存先を走査し、管理下の世代と、それ以外のファイルやフォルダを分けて取得します。この結果を一覧表示に使います。

LINE 334: 保存先パスの表示。どのフォルダを対象に一覧表示しているのかを、パスとともに画面へ表示します。LINE 335: 世代一覧の表示呼び出し。

取得した世代一覧を渡して、古い順に整形して表示する関数を呼び出します。実際の一覧表示処理をまとめて任せています。LINE 336: 管理外項目の件数表示。

管理対象外のファイルやフォルダの件数を表示します。これらを削除対象にしないことも利用者に伝えています。RUN 10/10: listコマンドで世代一覧を表示する。

一覧表示のコマンドまで入力できました。世代を2つ保存してから呼び出し、古い順の並びと合計サイズを確かめます。CHECK 10/10: 途中実行に成功。

一覧表示のコマンドまで入力できました。世代を2つ保存してから呼び出し、古い順の並びと合計サイズを確かめます。RETURN 10: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 340: pruneコマンドの登録。このあとの関数を「prune」というサブコマンドとしてTyperアプリに登録します。

古い世代を整理するためのコマンドです。LINE 341: prune関数の定義。保持世代数を超えた古い世代を削除するための関数を定義しています。

次の行以降で受け取る引数の設定が続きます。LINE 342: 保存先引数の受け取り。コマンドラインから渡された、世代を保存しているフォルダのパスを受け取ります。

この値をもとに整理対象を探します。LINE 343: 保持世代数オプションの受け取り。何世代分を残すかを指定する--keepオプションを受け取ります。

省略時は2世代を残す設定になっています。LINE 344: 適用フラグの受け取り。実際に削除を実行するかどうかを指定する--applyオプションを受け取ります。

指定がなければ確認表示だけにとどめる設計です。LINE 345: 関数本体の開始。引数の受け取りが終わり、ここから世代整理の実処理が始まります。

戻り値を返さない関数であることも示しています。LINE 346: 関数の説明文。この関数が保持世代数を超えた古い世代だけを削除するものであることを説明するドキュメント文字列です。

LINE 347: パスの正規化。入力された保存先パスをホームディレクトリ展開・絶対パス化して整えます。以降の処理で使う正式なパスとして扱われます。

LINE 348: 保存先存在チェック。指定された保存先フォルダが実際に存在するかどうかを確認します。存在しなければこのあとエラーとして処理されます。

LINE 349: 存在しない場合のエラー表示。保存先フォルダが見つからないことを知らせるエラーメッセージを表示します。誤ったパス指定に気付けるようにしています。

LINE 350: 異常終了処理。保存先が存在しない場合、終了コード1でプログラムを終了させます。整理処理を行わずに安全に止めます。

LINE 352: 世代情報の取得。保存先を走査し、管理下の世代と、それ以外のファイルやフォルダを取得します。管理下の世代から整理候補を選ぶための情報です。

LINE 353: 削除対象の絞り込み。古い順の一覧から、末尾の新しいkeep世代を除いた部分を候補にします。世代数が保持数以下なら、空のリストを使います。

LINE 354: 保存先パスの表示。どのフォルダを対象に整理を行っているのかを、パスとともに画面へ表示します。LINE 355: 世代数と保持数の表示。

現在の世代数と、指定された保持世代数を合わせて表示します。今回の整理でどれくらい削除される見込みかの目安になります。LINE 356: 削除候補の有無判定。

削除候補が1件もないかどうかを確認します。候補がない場合は次の行でその旨を伝えます。LINE 357: 候補なしの表示。

削除候補が存在しない場合に、その旨のメッセージを表示します。整理の必要がないことを利用者に伝えます。LINE 358: 削除候補の走査。

削除対象となった世代フォルダとそのマニフェストを1件ずつ取り出します。次の行での候補表示に使います。LINE 359: 削除候補の表示。

削除対象となる世代の名前と作成日時を表示します。実際に削除する前に、対象を確認できるようにしています。LINE 362: 適用フラグの判定。

--applyが指定されていない場合の分岐です。削除は行わず、確認のみという案内と現在の一覧を表示します。LINE 363: 確認のみメッセージの表示。

実際には削除せず、確認だけを行ったことを伝えるメッセージを表示します。--applyを付けると削除が実行されることも案内しています。LINE 364: 削除実行分岐の開始。

--applyが指定されていた場合の処理をここから始めます。実際に世代フォルダを削除する処理につながります。LINE 365: 削除件数カウンタの初期化。

実際に削除した世代の数を数えるための変数を0で用意します。この後の削除処理で1件ごとに加算されます。LINE 366: 削除候補の走査。

削除対象となった世代フォルダを1件ずつ取り出して、順番に削除処理を行っていきます。LINE 367: 削除可否の再確認。削除する直前に、そのフォルダが本当にツール製の世代かどうかをもう一度確認します。

誤って無関係なフォルダを消さないための安全策です。LINE 368: スキップメッセージの表示。ツール製の世代と確認できなかった場合に、削除をスキップした旨を表示します。

安全のため削除を見送ったことを伝えます。LINE 369: 次の候補への移行。確認できなかった世代の削除処理を中断し、次の削除候補の処理へ進みます。

LINE 370: 世代フォルダの削除。確認が取れた世代フォルダを、中身ごとまとめて削除します。実際にディスクからデータが消える処理です。

LINE 371: 削除件数の加算。実際に削除できた件数を1つ増やします。最終的な削除件数の表示に使われます。

LINE 372: 削除完了の表示。その世代を削除したことを、フォルダ名とともに表示します。処理の進行状況を利用者に伝えます。

LINE 373: 削除件数合計の表示。実際に削除した世代の総数をまとめて表示します。--apply実行時の結果を利用者に伝える行です。

LINE 375: 最新状態の再取得。保存先を再走査して、現在の世代一覧を取得します。--applyを付けた場合は削除後の状態、付けない場合は変更していない状態を確認できます。

LINE 376: 一覧見出しの表示。これから現在の世代一覧を表示することを知らせます。確認のみの場合と削除した場合の両方で表示される見出しです。

LINE 377: 最新一覧の表示。現在残っている世代を古い順に表示します。確認のみの場合も、削除した場合も、最後に保存先の状態を確認できる処理です。

LINE 378: 管理外項目の件数表示。最初の走査で見つけた管理対象外のファイルやフォルダの件数を表示します。これらを削除対象にしなかったことも伝えています。

LINE 382: エントリポイント関数の定義。main関数を定義しており、このCLIツールを起動したときに最初に呼び出される処理のまとまりを表しています。中身はシンプルに次の行でTyperアプリを実行する形になっています。

LINE 383: Typerアプリの実行。app()を呼び出すことで、これまで@app.commandで登録してきたsampleやbackupなどの各コマンドをコマンドライン引数に応じて振り分けて実行します。ユーザーが入力したコマンド名とオプションはここで解析されます。

LINE 386: スクリプト直接実行の判定。このファイルがモジュールとしてインポートされたのではなく、直接スクリプトとして実行されたときだけ真になる条件です。これによって他のファイルからimportされた場合にmain関数が勝手に実行されないようにしています。

LINE 387: main関数の呼び出し。条件を満たした場合にmain関数を呼び出し、CLIツール全体の処理を開始します。これによりTyperアプリが起動し、ユーザーが指定したサブコマンドが実際に実行されます。

実行1/5: サンプルフォルダを作成する。最初にsampleコマンドで、動作確認用のフォルダを作ります。作成したファイルの一覧と合計件数がそのまま表示されます。

サンプルフォルダを作成する。最初にsampleコマンドで、動作確認用のフォルダを作ります。作成したファイルの一覧と合計件数がそのまま表示されます。

世代を2つ作って一覧を確認する。backupコマンドで世代を保存し、ファイルを書き換えてからもう一度実行します。listコマンドで世代が2つ並ぶ様子を確認します。

SHA-256で世代の中身を照合する。verifyコマンドでマニフェストの記録と実ファイルを突き合わせます。世代の中のファイルを書き換えると、不一致として報告される様子も確認できます。

古い世代を保持数に合わせて整理する。世代を3つ作ってからpruneコマンドを実行します。--applyなしでは候補の表示だけで終わり、付けたときに古い世代が削除されます。

保存先の指定ミスを検知する。保存先を保存元の内側に指定した場合の挙動を確認します。コピーを始める前にエラーを表示して中止します。

学習内容のまとめ。Typerで目的別のサブコマンドを定義するshutilで世代をコピーし、hashlibで内容を照合する マニフェストでファイルと世代の情報を記録する 保持世代数とドライランで整理範囲を確認する 元データと管理外のフォルダを保護する エンディング。Python研修はCodeCampでご確認ください。

PythonのTyper・shutil・hashlibとは

今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。

コマンドを組み立てるTyperの役割

Typerは、Pythonの関数に型ヒントを書くだけでコマンド名や引数、ヘルプ表示までを組み立ててくれるCLIライブラリです。今回のバックアップ世代管理CLIでは、sampleやbackupといったサブコマンドをデコレータで登録し、利用者が渡すフォルダのパスは関数の引数として受け取ります。

画面表示はtyper.echoに統一し、処理を続けられない場面ではtyper.Exitで終了コードを返す形にまとめました。

Typerがこのバックアップ世代管理CLIで担当している具体的な処理を挙げます

  • @app.commandでsampleやbackupを登録する
  • typer.Argumentで保存元と保存先を必須にする
  • typer.Optionで--keepや--applyを足す
  • min=1で保持世代数の下限を検証する
  • typer.echoで結果行を標準出力へ出す
  • typer.Exit(code=1)で異常終了を伝える
  • add_completion=Falseで補完機能を外す

コピーと照合を支えるshutilとhashlib

shutilは、フォルダのコピーや削除を扱うPythonの標準ライブラリです。hashlibは内容からハッシュ値を計算する標準ライブラリで、本記事ではSHA-256を使って保存元とコピー先を照合します。

世代の作成をshutil.copytree、整理をshutil.rmtreeが担当し、backupとverifyでhashlibによる照合を行う構成にしました。

shutilとhashlibが世代の作成から整理までのどこで動くかを並べます

  • shutil.copytreeでdataフォルダへ丸ごと複製する
  • shutil.rmtreeで保持数を超えた世代を削除する
  • hashlib.sha256でファイル内容を要約する
  • 64KB単位でreadとupdateを繰り返す
  • hexdigestの先頭16文字を照合行に表示する
  • サイズとハッシュが両方一致した場合だけ一致と判定する
さらに詳しく学べるPython研修の詳細はこちら

Python・Typerで開発する場合の環境構築

この記事のセットアップ手順と掲載コードは、Windows 11 Pro、PowerShell 5.1、Python 3.13.3、Typer 0.27.0で動作確認しています。仮想環境を有効化せず、その中のPythonを直接指定するため、以下のコマンドはPowerShellとコマンドプロンプト(cmd)の両方で使えます。

python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install typer

macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。

python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install typer
  • typerを入れるとrichなどの依存パッケージも導入され、--helpの表示が整形されます。
  • 保存先フォルダはbackupコマンドが作るため事前作成は不要です。sampleコマンドは空でないフォルダには書き込みません。
  • pruneは既定でドライランです。実際に削除するには--applyを付けます。
さらに詳しく学べるPython研修の詳細はこちら

バックアップ世代管理CLIの要件定義

目的は、PythonのTyperで作ったCLIから、shutilによる世代別コピーとhashlibのSHA-256照合、保持世代数に応じた古い世代の整理までを1つの流れで確認できるようにすることです。

対象者として、PythonでCLIツールの作り方を学びたい人や、手作業のフォルダバックアップを世代管理と整合性照合まで含めて整理したい人を想定しています。

完成物は、Typer・shutil・hashlibで作るbackup_gen.py1本のバックアップ世代管理CLIで、サンプル作成、世代コピー、SHA-256照合、世代一覧、古い世代の整理を公開コマンドから確認できるものです。

実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。

機能要件

  • サンプルフォルダを固定内容で用意するsampleコマンド
  • 保存元を新しい世代フォルダへ複製するbackupコマンド
  • コピー直後にSHA-256とサイズを突き合わせる整合性チェック
  • 世代名・作成日時・ファイル一覧を残すmanifest.jsonの書き出し
  • 記録と実体を照合するverifyコマンド
  • -gで1世代だけに絞る照合対象の指定
  • 世代を古い順に件数と容量付きで表示するlistコマンド
  • 保持世代数を超えた古い世代を整理するpruneコマンド
  • --keepで残す世代数を指定するオプション
  • --applyの有無で確認のみと削除を切り替える動作
  • 一致・不一致・欠落・未登録の4分類での結果表示
  • バイト数をB・KB・MBで読みやすく整える表示

非機能要件

  • backup_gen.pyの1ファイルで完結する構成
  • 外部依存はTyperだけで残りは標準ライブラリ
  • シンボリックリンクやジャンクションを含むフォルダの拒否
  • 保存元と保存先が同一または包含関係の場合の中止
  • 空でないフォルダへのサンプル作成の禁止
  • toolとmanifest_versionが一致する世代だけの認識
  • 削除対象を保存先直下のツール製世代に限定
  • pruneの既定は確認のみで--apply指定時だけ削除
  • 64KB単位の分割読み込みによるハッシュ計算
  • コピー結果が保存元と一致しない場合は世代として未登録
  • ツール管理外の項目は件数表示のみで削除対象外
  • 世代番号が9999を超える場合の処理中止

実装方針

今回はTyper・shutil・hashlibの基本動作を追いやすくするため、バックアップ世代管理CLI本体を1つのPythonファイルへまとめます。

入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。

バックアップ世代管理CLIを安全に組み立てるための実装方針は次のとおりです。

  • backup_gen.pyの1ファイルで完結する構成
  • 外部依存はTyperだけで残りは標準ライブラリ
  • シンボリックリンクやジャンクションを含むフォルダの拒否
  • 保存元と保存先が同一または包含関係の場合の中止
  • 空でないフォルダへのサンプル作成の禁止
  • toolとmanifest_versionが一致する世代だけの認識
  • 削除対象を保存先直下のツール製世代に限定
  • pruneの既定は確認のみで--apply指定時だけ削除
  • 64KB単位の分割読み込みによるハッシュ計算
  • コピー結果が保存元と一致しない場合は世代として未登録
  • ツール管理外の項目は件数表示のみで削除対象外
  • 世代番号が9999を超える場合の処理中止

完成と判断する条件

  1. sample実行で5件のファイルと合計サイズが表示される
  2. backup実行でgen_0001_で始まる世代が作られる
  3. backupの照合行に一致5件と表示される
  4. 2回目のbackupでgen_0002_の世代が追加される
  5. listで合計2世代と容量が表示される
  6. verifyで記録した各ファイルが一致として並ぶ
  7. 世代内のファイルを書き換えるとverifyが不一致を報告する
  8. 問題がある照合で終了コード1が返る
  9. --applyなしのpruneが削除候補の表示だけで終わる
  10. prune --keep 1 --applyで2世代が削除され1世代が残る
  11. 保存先を保存元の内側に指定するとエラーで中止される
さらに詳しく学べるPython研修の詳細はこちら

Typerでバックアップ世代管理CLIを作る際の重要ポイント

このCLIの中心にあるのは、gen_0001_20260904-101500のような世代名と、世代ごとに残すmanifest.jsonです。ツールは名前の形式とマニフェストの中身の両方を確認し、条件を満たしたフォルダだけを管理下の世代として扱います。

照合ではマニフェストの記録と実ファイルを突き合わせ、一致・不一致・欠落・未登録の4分類で報告し、整理では古い側だけを候補に選ぶ流れになりました。

世代の作成・照合・整理を支える判断ルールを具体的に挙げます

  • gen_0001_20260904-101500の形式の世代名
  • GEN_PATTERNで世代名の形式を判定する
  • manifest_versionが1の世代だけ読み込む
  • created_atとfilesをマニフェストへ残す
  • 一致・不一致・欠落・未登録の4分類で報告する
  • generations[:-keep]を削除候補にする
  • 管理外の項目は件数だけ数える

世代名とマニフェストで管理範囲を決める

保存先には、利用者が別の目的で置いたフォルダが混ざることがあります。そこでload_manifestが世代名の形式、manifest.jsonとdataフォルダの有無、JSONとして読めるか、toolとmanifest_versionの値までを順に確かめ、すべて通ったものだけを管理下の世代として返す作りにしました。

条件から外れた項目はforeignとして数えるだけで、削除や書き換えの対象になりません。

load_manifestがNoneを返して管理対象から外す条件を並べます

  • 名前がgen_連番_日時の規則に合わない
  • manifest.jsonまたはdataフォルダが無い
  • JSONとして読み込めない中身
  • toolの値がbackup-genではない
  • manifest_versionが1以外の値
  • リンクとして作られている項目

compare_entriesによる4分類の照合

compare_entriesは、マニフェストの記録と実ファイルの一覧をそれぞれ相対パスの辞書へ直してから突き合わせます。サイズとSHA-256の両方が同じものだけを一致とし、どちらかが違えば不一致へ分けました。記録側にしかないパスは欠落、実体側にしかないパスは未登録となり、verifyはこの4分類を1行ずつ表示してから合計を出します。

verifyの結果行に現れる4つの分類の意味を整理します

  • 一致はサイズもハッシュも記録どおりの状態
  • 不一致は記録と実体でサイズかハッシュが違う状態
  • 欠落は記録にあるファイルが見つからない状態
  • 未登録は世代へ後から増えたファイル
  • 問題件数が1件以上なら終了コード1

保持世代数に応じた整理の考え方

世代フォルダの名前は連番と日時で始まるため、名前順に並べるだけで古い順の一覧になります。pruneはこの並びを利用し、世代数が保持数を超えたときだけ先頭側を削除候補に選びました。保持数はmin=1で下限が決まっており、少なくとも1世代は必ず残る計算です。

pruneが削除候補を決めるまでの計算手順を順に示します

  • scan_storeで管理下の世代を集める
  • 世代名の昇順で古い順に並べる
  • 世代数が保持数以下なら候補なし
  • 先頭側の超過分を候補として表示する
  • 削除直前に世代の条件を再判定する

Python研修一覧はこちら

目的に合うPython研修を一覧形式から探したい方は、ぜひご利用ください。

Python研修を比較する

Java研修一覧はこちら

目的に合うJava研修を一覧形式から探したい方は、ぜひご利用ください。

Java研修を比較する

PHP研修一覧はこちら

目的に合うPHP研修を一覧形式から探したい方は、ぜひご利用ください。

PHP研修を比較する

新入社員研修

目的に合う新入社員研修を一覧形式から探したい方は、ぜひご利用ください。

新入社員研修を比較する

全ての研修からも探したい方はこちら

Pythonでバックアップ世代管理CLIの完成コード

backup_gen.pyは、定数の定義、ハッシュ計算、フォルダの一覧化、記録との比較、各サブコマンドという順に並んでいます。処理の主役はsha256_of_filecollect_entriesの2つです。

コピー自体はshutil.copytreeに任せ、コピー前の元フォルダとコピー後の複製を同じ手順で一覧化して突き合わせます。判定ロジックを1か所へ寄せておくと、backupverifyで同じ基準を使い回せます。

ここでは全行を追わず、動きを理解するうえで外せない部分だけを抜き出して見ていきましょう。

このセクションの用語

Typer
型ヒントを書くだけで引数やオプションを組み立てられるCLI作成用のライブラリです。ヘルプ表示も自動で用意されます。
hashlib
SHA-256などのハッシュ計算をまとめたPythonの標準ライブラリです。追加インストールなしで使えます。
shutil.copytree
フォルダを中身ごと別の場所へ再帰的にコピーする標準ライブラリの関数です。1回の呼び出しで階層ごと複製できます。
rglob
pathlibのメソッドで、フォルダの下を再帰的にたどって条件に合うパスを列挙します。
正規表現
文字列のパターンを記号で表す書き方です。ここでは世代フォルダ名の形が正しいかどうかの判定に使っています。
シンボリックリンク
別の場所にあるファイルやフォルダを指す入口だけの特殊なファイルです。たどると想定外の場所へ処理が届く場合があります。
backupコマンドでの一覧化からmanifest.json記録までのデータの流れ collect_entriesで元フォルダを一覧化sha256_of_fileで各ファイルをハッシュ化copytreeで世代フォルダへ複製複製側も同じ手順で一覧化compare_entriesで一致状況を分類manifest.jsonへ記録
backupコマンドでの一覧化からmanifest.json記録までのデータの流れ
"""Typer・shutil・hashlibで作るバックアップ世代管理CLI。"""

from __future__ import annotations

import hashlib
import json
import re
import shutil
from datetime import datetime
from pathlib import Path
from typing import Optional

import typer

# 基本設定(世代フォルダの命名規則とツールの目印)
TOOL_NAME = "backup-gen"
MANIFEST_VERSION = 1
MANIFEST_NAME = "manifest.json"
DATA_DIR_NAME = "data"
GEN_PATTERN = re.compile(r"^gen_(\d{4})_\d{8}-\d{6}$")
CHUNK_SIZE = 64 * 1024
HASH_HEAD = 16

SAMPLE_FILES = {
    "docs/readme.md": "# サンプル資料\n\nバックアップ世代管理の動作確認に使うフォルダです。\n",
    "docs/spec.md": "# 仕様メモ\n\n- 世代別コピー\n- ハッシュ照合\n- 保持世代数での整理\n",
    "notes/memo.txt": "打ち合わせメモ\n1. 週次バックアップ\n2. 復元手順の確認\n",
    "config/app.json": '{\n  "name": "sample-app",\n  "retention": 3\n}\n',
    "config/hosts.csv": "name,role\nweb01,web\ndb01,database\n",
}

app = typer.Typer(add_completion=False, help="指定フォルダを世代別にバックアップするCLI")


# ハッシュ計算とファイル一覧
def sha256_of_file(path: Path) -> str:
    """ファイルを分割読みしながらSHA-256を計算する。"""
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(CHUNK_SIZE), b""):
            digest.update(chunk)
    return digest.hexdigest()


def collect_entries(root: Path) -> list[dict]:
    """フォルダ配下のファイルを相対パス・サイズ・ハッシュで一覧化する。"""
    entries: list[dict] = []
    for path in sorted(root.rglob("*")):
        if path.is_symlink() or path.is_junction():
            typer.echo(f"[エラー] リンクを含むフォルダは扱いません: {path}")
            raise typer.Exit(code=1)
        if not path.is_file():
            continue
        entries.append(
            {
                "path": path.relative_to(root).as_posix(),
                "size": path.stat().st_size,
                "sha256": sha256_of_file(path),
            }
        )
    return entries


def compare_entries(recorded: list[dict], actual: list[dict]) -> dict[str, list[str]]:
    """記録側と実体側のハッシュを突き合わせて分類する。"""
    recorded_map = {item["path"]: item for item in recorded}
    actual_map = {item["path"]: item for item in actual}
    matched: list[str] = []
    mismatched: list[str] = []
    for rel_path, item in recorded_map.items():
        found = actual_map.get(rel_path)
        if found is None:
            continue
        if found["sha256"] == item["sha256"] and found["size"] == item["size"]:
            matched.append(rel_path)
        else:
            mismatched.append(rel_path)
    return {
        "matched": sorted(matched),
        "mismatched": sorted(mismatched),
        "missing": sorted(set(recorded_map) - set(actual_map)),
        "unknown": sorted(set(actual_map) - set(recorded_map)),
    }


def format_size(size: int) -> str:
    """バイト数を読みやすい単位に整える。"""
    if size < 1024:
        return f"{size}B"
    if size < 1024 * 1024:
        return f"{size / 1024:.1f}KB"
    return f"{size / (1024 * 1024):.1f}MB"


# 世代の判定と走査(ツール自身が作った世代だけを認識する)
def load_manifest(gen_dir: Path) -> Optional[dict]:
    """世代フォルダの目印を確認し、条件を満たすときだけマニフェストを返す。"""
    if gen_dir.is_symlink() or gen_dir.is_junction() or not gen_dir.is_dir():
        return None
    if GEN_PATTERN.match(gen_dir.name) is None:
        return None
    manifest_path = gen_dir / MANIFEST_NAME
    data_dir = gen_dir / DATA_DIR_NAME
    if manifest_path.is_symlink() or data_dir.is_symlink() or data_dir.is_junction():
        return None
    if not manifest_path.is_file() or not data_dir.is_dir():
        return None
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return None
    if not isinstance(manifest, dict):
        return None
    if manifest.get("tool") != TOOL_NAME:
        return None
    if manifest.get("manifest_version") != MANIFEST_VERSION:
        return None
    return manifest


def is_removable_generation(store: Path, gen_dir: Path) -> bool:
    """削除してよいのは保存先直下にあるツール製の世代だけとする。"""
    return gen_dir.parent == store and load_manifest(gen_dir) is not None


def scan_store(store: Path) -> tuple[list[tuple[Path, dict]], list[Path]]:
    """保存先を走査し、ツール管理下の世代とそれ以外に分ける。"""
    generations: list[tuple[Path, dict]] = []
    foreign: list[Path] = []
    if not store.is_dir():
        return generations, foreign
    for child in sorted(store.iterdir()):
        manifest = load_manifest(child)
        if manifest is None:
            foreign.append(child)
        else:
            generations.append((child, manifest))
    generations.sort(key=lambda item: item[0].name)
    return generations, foreign


def render_generations(generations: list[tuple[Path, dict]]) -> None:
    """世代一覧を古い順に表示する。"""
    if not generations:
        typer.echo("  (世代はまだありません)")
        return
    total = 0
    for number, (gen_dir, manifest) in enumerate(generations, start=1):
        files = manifest.get("files", [])
        size = sum(int(item.get("size", 0)) for item in files)
        total += size
        created = manifest.get("created_at", "-")
        typer.echo(
            f"  {number}) {gen_dir.name}  作成 {created}  {len(files)}件  {format_size(size)}"
        )
    typer.echo(f"  合計 {len(generations)}世代 / {format_size(total)}")


def next_generation_name(generations: list[tuple[Path, dict]], created: datetime) -> str:
    """既存世代の連番+1で、同じ秒でも衝突しない世代名を作る。"""
    numbers = []
    for gen_dir, _ in generations:
        found = GEN_PATTERN.match(gen_dir.name)
        if found is not None:
            numbers.append(int(found.group(1)))
    index = max(numbers, default=0) + 1
    if index > 9999:
        typer.echo("[エラー] 世代番号の上限です。別の保存先を指定してください。")
        raise typer.Exit(code=1)
    return f"gen_{index:04d}_{created:%Y%m%d-%H%M%S}"


# 保存元と保存先の関係チェック
def ensure_safe_pair(source: Path, store: Path) -> None:
    """同一・包含関係の組み合わせを拒否する。"""
    if source == store:
        typer.echo("[エラー] 保存元と保存先が同じフォルダです。")
        raise typer.Exit(code=1)
    if source in store.parents:
        typer.echo(f"[エラー] 保存先が保存元の内側にあります: {store}")
        raise typer.Exit(code=1)
    if store in source.parents:
        typer.echo(f"[エラー] 保存元が保存先の内側にあります: {source}")
        raise typer.Exit(code=1)


# コマンド: 動作確認用サンプルフォルダの作成
@app.command("sample")
def create_sample(
    dest: Path = typer.Option(..., "--dest", help="サンプルを作るフォルダ"),
) -> None:
    """固定内容のサンプルフォルダを作る(既存ファイルは変更しない)。"""
    target = dest.expanduser().resolve()
    if target.exists() and not target.is_dir():
        typer.echo(f"[エラー] フォルダではありません: {target}")
        raise typer.Exit(code=1)
    if target.is_dir() and any(target.iterdir()):
        typer.echo(f"[エラー] 空でないフォルダには作成しません: {target}")
        raise typer.Exit(code=1)

    for rel_path, text in SAMPLE_FILES.items():
        file_path = target / rel_path
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(text, encoding="utf-8")

    entries = collect_entries(target)
    typer.echo(f"サンプルフォルダを作成しました: {target}")
    for entry in entries:
        typer.echo(f"  {entry['path']}  {format_size(entry['size'])}")
    typer.echo(f"合計 {len(entries)}件 / {format_size(sum(e['size'] for e in entries))}")


# コマンド: 世代バックアップ(shutilでコピーしSHA-256を記録)
@app.command("backup")
def backup(
    source: Path = typer.Argument(..., help="バックアップ元フォルダ"),
    store: Path = typer.Argument(..., help="世代を保存するフォルダ"),
) -> None:
    """保存元を新しい世代フォルダへコピーし、マニフェストを書き出す。"""
    src = source.expanduser().resolve()
    root = store.expanduser().resolve()
    if not src.is_dir():
        typer.echo(f"[エラー] 保存元フォルダがありません: {src}")
        raise typer.Exit(code=1)
    ensure_safe_pair(src, root)

    source_entries = collect_entries(src)
    if not source_entries:
        typer.echo(f"[エラー] 保存元にファイルがありません: {src}")
        raise typer.Exit(code=1)

    root.mkdir(parents=True, exist_ok=True)
    generations, _ = scan_store(root)
    created = datetime.now()
    gen_name = next_generation_name(generations, created)
    gen_dir = root / gen_name
    shutil.copytree(src, gen_dir / DATA_DIR_NAME)

    # コピー結果をハッシュで照合し、マニフェストに記録する
    copied_entries = collect_entries(gen_dir / DATA_DIR_NAME)
    result = compare_entries(source_entries, copied_entries)
    if result["mismatched"] or result["missing"] or result["unknown"]:
        typer.echo("[エラー] コピー結果が保存元と一致しません。完了世代として登録しません。")
        raise typer.Exit(code=1)
    manifest = {
        "tool": TOOL_NAME,
        "manifest_version": MANIFEST_VERSION,
        "generation": gen_name,
        "created_at": created.strftime("%Y-%m-%d %H:%M:%S"),
        "source": str(src),
        "algorithm": "sha256",
        "files": copied_entries,
    }
    (gen_dir / MANIFEST_NAME).write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
    )

    total = sum(entry["size"] for entry in copied_entries)
    typer.echo(f"世代を作成しました: {gen_name}")
    typer.echo(f"  保存元: {src}")
    typer.echo(f"  保存先: {gen_dir}")
    typer.echo(f"  コピー: {len(copied_entries)}件 / {format_size(total)}")
    typer.echo(
        "  照合(SHA-256): 一致 {}件 / 不一致 {}件 / 欠落 {}件".format(
            len(result["matched"]), len(result["mismatched"]), len(result["missing"])
        )
    )
    typer.echo(f"  マニフェスト: {gen_dir / MANIFEST_NAME}")
    if result["mismatched"] or result["missing"]:
        raise typer.Exit(code=1)


# コマンド: SHA-256による整合性照合
@app.command("verify")
def verify(
    store: Path = typer.Argument(..., help="世代を保存したフォルダ"),
    generation: Optional[str] = typer.Option(
        None, "--generation", "-g", help="照合する世代名(既定は全世代)"
    ),
) -> None:
    """マニフェストの記録と実ファイルのハッシュを突き合わせる。"""
    root = store.expanduser().resolve()
    generations, _ = scan_store(root)
    if generation is not None:
        generations = [item for item in generations if item[0].name == generation]
    if not generations:
        typer.echo(f"[エラー] 照合できる世代がありません: {root}")
        raise typer.Exit(code=1)

    problems = 0
    for gen_dir, manifest in generations:
        recorded = manifest.get("files", [])
        actual = collect_entries(gen_dir / DATA_DIR_NAME)
        result = compare_entries(recorded, actual)
        actual_map = {item["path"]: item for item in actual}
        typer.echo(f"[{gen_dir.name}] 記録 {len(recorded)}件を照合します")
        for item in recorded:
            rel_path = item["path"]
            head = str(item.get("sha256", ""))[:HASH_HEAD]
            if rel_path in result["matched"]:
                typer.echo(f"  一致   {rel_path}  sha256={head}...")
            elif rel_path in result["mismatched"]:
                now_head = actual_map[rel_path]["sha256"][:HASH_HEAD]
                typer.echo(f"  不一致 {rel_path}  記録={head}... 実体={now_head}...")
            else:
                typer.echo(f"  欠落   {rel_path}")
        for rel_path in result["unknown"]:
            typer.echo(f"  未登録 {rel_path}")
        counts = (
            len(result["matched"]),
            len(result["mismatched"]),
            len(result["missing"]),
            len(result["unknown"]),
        )
        typer.echo("  結果: 一致 {}件 / 不一致 {}件 / 欠落 {}件 / 未登録 {}件".format(*counts))
        problems += counts[1] + counts[2] + counts[3]

    typer.echo(f"照合した世代: {len(generations)}世代 / 問題 {problems}件")
    if problems:
        raise typer.Exit(code=1)


# コマンド: 世代一覧の表示
@app.command("list")
def list_generations(
    store: Path = typer.Argument(..., help="世代を保存したフォルダ"),
) -> None:
    """保存先にある世代を古い順に一覧表示する。"""
    root = store.expanduser().resolve()
    if not root.is_dir():
        typer.echo(f"[エラー] 保存先フォルダがありません: {root}")
        raise typer.Exit(code=1)
    generations, foreign = scan_store(root)
    typer.echo(f"保存先: {root}")
    render_generations(generations)
    typer.echo(f"ツール管理外の項目: {len(foreign)}件(削除対象にしません)")


# コマンド: 保持世代数に応じた古い世代の整理
@app.command("prune")
def prune(
    store: Path = typer.Argument(..., help="世代を保存したフォルダ"),
    keep: int = typer.Option(2, "--keep", min=1, help="残す世代数(新しい方から)"),
    do_apply: bool = typer.Option(False, "--apply", help="実際に削除する(既定は確認のみ)"),
) -> None:
    """保持世代数を超えた古い世代だけを削除する。"""
    root = store.expanduser().resolve()
    if not root.is_dir():
        typer.echo(f"[エラー] 保存先フォルダがありません: {root}")
        raise typer.Exit(code=1)

    generations, foreign = scan_store(root)
    targets = generations[:-keep] if len(generations) > keep else []
    typer.echo(f"保存先: {root}")
    typer.echo(f"世代数 {len(generations)} / 保持 {keep}")
    if not targets:
        typer.echo("削除候補はありません。")
    for gen_dir, manifest in targets:
        typer.echo(f"  削除候補: {gen_dir.name}(作成 {manifest.get('created_at', '-')})")

    # 削除直前にツール製の世代かを再判定してから実行する
    if not do_apply:
        typer.echo("確認のみです。実際に削除するには --apply を付けてください。")
    else:
        removed = 0
        for gen_dir, _ in targets:
            if not is_removable_generation(root, gen_dir):
                typer.echo(f"  スキップ: {gen_dir.name}(ツール製の世代と確認できません)")
                continue
            shutil.rmtree(gen_dir)
            removed += 1
            typer.echo(f"  削除しました: {gen_dir.name}")
        typer.echo(f"削除 {removed}世代")

    remaining, _ = scan_store(root)
    typer.echo("現在の世代一覧:")
    render_generations(remaining)
    typer.echo(f"ツール管理外の項目: {len(foreign)}件(削除対象にしません)")


# エントリポイント
def main() -> None:
    app()


if __name__ == "__main__":
    main()

コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。

Typerでサブコマンドの土台を作る

app = typer.Typer(add_completion=False, help="指定フォルダを世代別にバックアップするCLI")

この1行がCLI全体の入れ物になります。ここで作ったappにデコレータで関数をぶら下げると、backupverifyといったサブコマンドが増えていきます。helpに書いた文章は、--helpを付けたときに表示される説明文です。

世代名と目印を定数でそろえる

TOOL_NAME = "backup-gen"
MANIFEST_VERSION = 1
MANIFEST_NAME = "manifest.json"
DATA_DIR_NAME = "data"
GEN_PATTERN = re.compile(r"^gen_(\d{4})_\d{8}-\d{6}$")
CHUNK_SIZE = 64 * 1024
HASH_HEAD = 16

世代フォルダ名の形をGEN_PATTERNという正規表現で固定しています。gen_+連番4桁+日時という形以外は世代として扱わないので、保存先に置かれた無関係なフォルダを誤って削除対象にしません。CHUNK_SIZEは後のハッシュ計算で一度に読み込むバイト数です。

sha256_of_fileで分割読みしながら計算

def sha256_of_file(path: Path) -> str:
    """ファイルを分割読みしながらSHA-256を計算する。"""
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(CHUNK_SIZE), b""):
            digest.update(chunk)
    return digest.hexdigest()

ファイルを丸ごとメモリへ読み込まず、64KBずつ読んでupdateに渡していきます。大きなファイルでもメモリ使用量が一定に保たれるのが利点です。最後のhexdigest()で、比較に使う16進数の文字列が得られます。

collect_entriesでリンクを弾く

def collect_entries(root: Path) -> list[dict]:
    """フォルダ配下のファイルを相対パス・サイズ・ハッシュで一覧化する。"""
    entries: list[dict] = []
    for path in sorted(root.rglob("*")):
        if path.is_symlink() or path.is_junction():
            typer.echo(f"[エラー] リンクを含むフォルダは扱いません: {path}")
            raise typer.Exit(code=1)
        if not path.is_file():
            continue

rglob("*")でフォルダの下を再帰的にたどり、sortedで並び順を固定しています。シンボリックリンクやジャンクションを見つけた時点で処理を止めるので、リンクをたどった先の想定外の場所までコピーや削除が広がりません。フォルダ自体はcontinueで読み飛ばします。

パス・サイズ・ハッシュを記録する

        entries.append(
            {
                "path": path.relative_to(root).as_posix(),
                "size": path.stat().st_size,
                "sha256": sha256_of_file(path),
            }
        )

1ファイルにつき、起点フォルダからの相対パス、バイト数、ハッシュの3点を辞書にして貯めます。as_posix()で区切り文字をスラッシュにそろえるため、WindowsとmacOSで記録の形が食い違いません。この辞書の並びが、そのままmanifest.jsonの中身になります。

compare_entriesで一致と不一致を分ける

    for rel_path, item in recorded_map.items():
        found = actual_map.get(rel_path)
        if found is None:
            continue
        if found["sha256"] == item["sha256"] and found["size"] == item["size"]:
            matched.append(rel_path)
        else:
            mismatched.append(rel_path)

記録側のパスをキーにして、実体側の同じパスを探します。ハッシュとサイズの両方が一致したものだけをmatchedに入れ、片方でも違えばmismatchedへ振り分ける仕組みです。実体側に見つからなかったものは、この段階では飛ばして後でまとめて扱います。

欠落と未知のファイルを集合演算で洗う

    return {
        "matched": sorted(matched),
        "mismatched": sorted(mismatched),
        "missing": sorted(set(recorded_map) - set(actual_map)),
        "unknown": sorted(set(actual_map) - set(recorded_map)),
    }

辞書をsetに変換して引き算すると、記録にあるのに実体がないmissingと、記録にない余分なunknownが一度に求まります。4種類に分けて返すので、呼び出し側は件数を数えるだけで結果を表示できます。verifyはこの戻り値をそのまま判定に使う仕組みです。

参考:

©Python公式ドキュメントshutil

srcをルートとするディレクトリツリー全体を再帰的にコピーし、dstという名前のディレクトリに配置して、コピー先のディレクトリを返します。


Python基礎・実践(Django)

企業・法人向けのPython研修では、基礎から応用まで体系的に学べます。

Python研修の詳細

DX社員研修

企業・法人向けのDX研修では、実務に繋がるリスキリングでITレベルを向上させます。

DX研修の詳細

Javaエンジニア育成研修

企業・法人向けのJavaエンジニア育成研修では、Javaの基礎から応用まで確実に習得できます。

Java研修の詳細

新卒・新入社員向け研修

企業・法人に新入社員・新卒社員に向けたプログラミング研修を提供しています。

新入社員研修の詳細

コードキャンプのIT研修を全て見る

バックアップ世代管理CLIの動作確認

検証は外部通信なしで、手元のフォルダだけを使いました。実行したのはsampleからpruneまでの5回のコマンドで、いずれも終了コード0で完了しています。

まずpython backup_gen.py sample --dest backup_demo/srcでサンプルフォルダを作り、続けてbackupを2回実行して世代を2つ用意しました。

その後verifyで記録済みのハッシュと実ファイルの再計算値を比較し、最後にprune--keep 1 --applyを付けて古い世代の整理まで進めています。

このセクションの用語

終了コード
コマンドが終わるときに返す数値です。0は正常終了を表し、それ以外は何らかの異常を示します。
オプション
--dest--keepのようにハイフンで始まる指定です。コマンドの動きを細かく変えられます。

実行したコマンドと、キャプチャで確認できる内容は次の5件です。

  1. python backup_gen.py sample --dest backup_demo/srcを実行後、ls backup_demo/srcでテキストファイルとサブフォルダが揃っているかを目視で確かめる
  2. python backup_gen.py backup backup_demo/src backup_demo/storeで1つ目の世代を保存
  3. 同じbackupコマンドをもう一度実行して2つ目の世代を追加
  4. python backup_gen.py verify backup_demo/storeで記録値と実体を照合
  5. python backup_gen.py prune backup_demo/store --keep 1 --applyで古い世代を整理
python backup_gen.py sample --dest backup_demo/srcの実行結果(終了コード0)
python backup_gen.py sample --dest backup_demo/srcの実行結果(終了コード0)
python backup_gen.py backup backup_demo/src backup_demo/storeの実行結果(終了コード0)
python backup_gen.py backup backup_demo/src backup_demo/storeの実行結果(終了コード0)
python backup_gen.py backup backup_demo/src backup_demo/storeの実行結果(終了コード0)
python backup_gen.py backup backup_demo/src backup_demo/storeの実行結果(終了コード0)
python backup_gen.py verify backup_demo/storeの実行結果(終了コード0)
python backup_gen.py verify backup_demo/storeの実行結果(終了コード0)
python backup_gen.py prune backup_demo/store --keep 1 --applyの実行結果(終了コード0)
python backup_gen.py prune backup_demo/store --keep 1 --applyの実行結果(終了コード0)
さらに詳しく学べるPython研修の詳細はこちら

Pythonでバックアップ世代管理CLIのエラー対処

ここからは、この種のCLIを動かすときに一般に起こりやすいエラーを整理します。原因の多くは、実行環境の準備不足かパス指定の食い違いです。

メッセージの最終行だけを読むと原因を取り違えがちになります。どのコマンドのどの引数で止まったかまでさかのぼって読むと、直し方が見えてきます。

このセクションの用語

ModuleNotFoundError
指定した名前のライブラリが見つからないときに出る例外です。インストール先と実行環境のずれが典型的な原因になります。
仮想環境
プロジェクトごとにライブラリを分けて入れておく仕組みです。導入先と別のPythonで実行すると読み込みに失敗します。
カレントディレクトリ
コマンドを実行しているいまの場所です。相対パスの起点になるため、指定したフォルダが見つからない原因になりやすくなります。
PermissionError
読み書きの権限が足りないときに出る例外です。使用中のファイルや保護された場所を触ると発生します。
エラー例 原因 対処
ModuleNotFoundError: No module named 'typer' Typerが入っていない環境で実行した 導入手順で使った仮想環境のPythonを直接指定し、そのPythonにTyperを入れて実行し直す
[エラー]保存元フォルダがありません 存在しないフォルダを保存元に指定した sampleで作ったフォルダ名と、いま実行しているカレントディレクトリを見比べる
FileExistsError: [Errno 17] File exists 保存先に同名のフォルダがすでにある 世代名は連番と日時で自動生成されるため、手作業で作った同名フォルダが残っていないか確認する
PermissionError: [Errno 13] Permission denied 他のアプリが開いているファイルや、書き込み権限のない場所を触った 対象ファイルを閉じ、書き込み権限のある場所を保存先に指定する
[エラー]リンクを含むフォルダは扱いません 保存元にシンボリックリンクやジャンクションが含まれる リンクを含まない実体のフォルダを指定するか、リンクを外してから再実行する

バックアップ世代管理CLIで注意したい点

最初に引っかかりやすいのは保存先の置き場所です。元フォルダの中に保存先を作ると入れ子になるので、このツールは同一・包含関係を拒否します。

次に迷いやすいのが世代名の読み方でしょう。gen_のあとに連番4桁と日時が並ぶ形なので、番号が大きいほど後に作成した世代と判断できます。

--keepは残す世代数の指定であって、削除する数ではありません。--keep 1で候補を確認し、削除する場合はさらに--applyを付けると、新しい1世代だけが残ります。

ポイントとしては、保存先の置き場所と--keepの数え方です。

手を動かす前に確認しておくと安心な、実行時のチェック項目です。

  • 保存先は元フォルダの外側に用意し、backup_demo/storeのように別ディレクトリを指定する
  • listで世代名を一覧してからpruneを実行し、消える世代を先に把握しておく
  • pruneでは残す世代数を--keepで指定し、削除まで進めるときに--applyを付ける
  • 実行のたびに終了コードを確認し、0以外ならメッセージ全体を読み返す
POINT

keep:残す世代数の指定

世代名:連番と日時の組み合わせ

終了コード0:正常終了の目印

さらに詳しく学べるPython研修の詳細はこちら

Pythonでrmtreeを暴走させない安全策

フォルダの削除は、対象の指定を一つ間違えると取り返しがつきません。そこでpruneは削除の直前に、そのフォルダが本当にツール自身の作った世代かどうかを毎回確かめます。

確認は5項目あり、どれか一つでも欠ければ削除対象から外れる作りにしました。判定を通ったフォルダにだけshutil.rmtreeが呼ばれます。

入口側にも保険を掛けています。保存元と保存先が同じ場所、または一方がもう一方を含む関係のときは、コピーが入れ子に膨らむため受け付けません。

気を付けたいポイントとしては、削除が届く範囲の狭さと、保存先の置き場所です。

このセクションの用語

shutil.rmtree
フォルダを中身ごと再帰的に削除する関数です。取り消しができないため、対象の絞り込みが重要になります。
包含関係
一方のフォルダがもう一方の内側に入っている状態です。保存先が元フォルダの中にあると、コピーが入れ子に増えていきます。
ジャンクション
Windowsで使われる、別のフォルダを指し示す仕組みです。シンボリックリンクと同様に、たどると別の場所へ到達します。
確認項目 防ぎたい事故
保存先の直下にある 別の場所にある同名フォルダを巻き込む
世代の命名規則に一致する 利用者が置いた無関係なフォルダの削除
シンボリックリンクではない リンクをたどって本体まで削除が届く
有効なマニフェストを持つ ツール以外が作ったフォルダの誤認
data/が存在する 中身のない別用途フォルダの削除
POINT

削除対象:ツール製の世代のみ

保存先:元フォルダの外に置く

prune:--applyで実行

さらに詳しく学べるPython研修の詳細はこちら

世代管理CLIが活きる実務の場面

このツールは専用のサーバや常駐プロセスを必要とせず、フォルダとコマンドだけで完結します。そのため個人の作業データや小規模な設定ファイルの管理と相性が良い作りです。

一般には、変更の前後で状態を残しておきたい場面や、コピーが壊れていないか確かめたい場面で役立ちます。

このセクションの用語

リテンション
どれくらいの期間や世代数を保管し続けるかという運用方針です。--keepの値がその設定にあたります。
チェックサム
ファイルの中身から計算した検査用の値です。コピーの前後で比べると、壊れや欠落を見つけられます。
使える場面 具体的な使い方
設定ファイルの変更前後 編集の直前にbackupで1世代残し、不具合が出たらその世代のdata/から元の設定を取り出す
外部へ渡す資料の受け渡し 納品用フォルダをbackupで固め、渡したあとにverifyでハッシュが変わっていないか確かめる
外付けディスクへの定期保存 保存先を外付けドライブにし、prune <保存先> --keep 5で候補を確認してから--applyを付けて整理する
作業ログや実験データの蓄積 区切りごとに世代を作り、listで世代名を並べて必要な時点のフォルダを探し出す
さらに詳しく学べるPython研修の詳細はこちら

バックアップ世代管理CLI開発のまとめ

Typerでサブコマンドを分け、shutilにコピー、hashlibに照合を任せる役割分担にしたところ、1ファイルでも扱いやすいバックアップCLIになりました。

実際に5回のコマンドを流し、サンプル作成から2世代の保存、照合、古い世代の整理まで全て終了コード0で完了しています。

削除を伴うツールでは、機能よりも先に「どこまで消えるか」を決めておくと安心です。次に手を入れるなら、除外パターンの指定や世代間の差分表示あたりが伸ばしどころだと感じました。

さらに詳しく学べるPython研修の詳細はこちら

参考にした一次情報

  1. ^ Typer公式ドキュメント. https://typer.tiangolo.com/, (参照26-09-04).
  2. ^ shutil — 高水準のファイル操作 (Python標準ライブラリ). https://docs.python.org/ja/3/library/shutil.html, (参照26-09-04).
  3. ^ hashlib — セキュアハッシュおよびメッセージダイジェスト (Python標準ライブラリ). https://docs.python.org/ja/3/library/hashlib.html, (参照26-09-04).
  4. ^ pathlib — オブジェクト指向のファイルシステムパス (Python標準ライブラリ). https://docs.python.org/ja/3/library/pathlib.html, (参照26-09-04).

※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。

ブログに戻る

コメントを残す

コメントは公開前に承認される必要があることにご注意ください。

企業・法人向けのIT・プログラミング・生成AI研修を探す、比較する - IT・プログラミングを知って学べるコネクトメディア CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプが提供する無料で学べるプログラミングスクール講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア 3.5日の研修で、年間1,600時間の削減効果が見込まれる。東京きらぼしフィナンシャルグループのDX人材育成事例 - IT・プログラミングを知って学べるコネクトメディア 配属3ヶ月で30%の生産性向上を実現するいよぎんコンピュータサービスの新人研修に迫る - IT・プログラミングを知って学べるコネクトメディア 金融業界の業務効率化を加速するニッセイアセットマネジメントの生成AI×GAS活用研修事例 - IT・プログラミングを知って学べるコネクトメディア 【製造業のDX人材育成事例】デジタル人材の即戦力化を実現する、日本ガイシ株式会社の異動者向オンボーディング研修 - ITやプログラミングを知って学べるコネクトメディア フューチャーアーキテクト株式会社が実現した新入社員向けIT研修プログラムでタスクフォース制度が主体的な学びと成長を生み出す - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【IT新入社員研修】オンラインとオフラインの最適バランスを実現したFutureOneの導入事例 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【新入社員研修】柔軟なハイブリッド型Java研修で実現した新卒20名の成長と成果|サークレイス株式会社 - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/現場により近いところにデジタルを根付かせるDX基礎講座研修|株式会社ブリヂストン - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/業務の効率化・DX推進に向けたIT人材育成への第一歩|株式会社カナエ - ITやプログラミングを知って学べるコネクトメディア 企業・法人向けのIT・プログラミング研修 - ITやプログラミングを知って学べるコネクトメディア

What's New

新着記事

対象者別で探す

子供(小学生・中学生・高校生)向け
プログラミング教室検索する

子供(小学生・中学生・高校生)がロボットやプログラミング言語を学ぶことができるオフラインからオンラインスクールを検索、比較することが可能です。

子供(小学生・中学生・高校生)
プログラミング教室検索する

ITやプログラムなどの
最新情報を検索する

日々、新しいITやプログラミング言語の情報が流れていきますが、特定の情報を時系列でニュースやコラムを確認することができます。

ITやプログラムなどの
最新情報を検索する