develop.make_mini_tklib のソースコード

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
make_mini_tklib.py

:doc:`make_mini_tklib_usage`

概要:
Pythonスクリプトのエントリポイントからインポートされるモジュールを静的解析し、
指定されたパッケージ配下の必要なPythonファイルを収集してコピーします。

詳細説明:
このスクリプトは、特定のPythonエントリスクリプトが依存するモジュールを静的に解析し、
`pkg_name` で指定されたパッケージ(通常は `tklib`)に属するファイルのみを抽出します。
抽出されたファイルは、`pkg_root` からの相対パスを保ったまま `out` ディレクトリにコピーされ、
最小限のパッケージサブセットを構築します(mini-packaging 用途)。
また、依存関係のレポート(未使用ファイルのリスト、Graphviz dot形式での依存グラフ)を生成する機能も提供します。

追加機能:
- `--report-unused`: `pkg_root` 配下のPythonファイルのうち、選択されなかったものを `unused_files.txt` に出力します。
- `--dot-out FILE`: 依存関係を Graphviz dot 形式で出力します(A -> B は A が B を import していることを示します)。
- `--ignore-if-imports`: `if` ブロック内のインポート文を無視し、より小さなサブセットを生成します。

デフォルト値 (要求):
- `pkg_name` のデフォルト: "tklib"
- `pkg_root` のデフォルト:
    1. 環境変数 `tkProg_Root` が設定されている場合、`join(env['tkProg_Root'], 'tklib', 'python', 'tklib')` が存在すればそれを使用します。
    2. 上記が存在しない場合、`'d:/git/tkProg/tklib/python/tklib'` が存在すればそれを使用します。
    3. それでも見つからない場合、`entry` ファイルから親ディレクトリをたどり、`pkg_name` フォルダを探索します。

制限事項:
- `importlib` などを用いた動的なインポートは検出できません。
- `from X import name` 形式で `name` が属性の場合、完全に追跡できないことがあります(この場合、`X/__init__.py` を保険として含めます)。

使用例:

1) デフォルトでtklibを検索
   python make_mini_tklib.py --entry Ne-T_fit.py --out tklib_sub --verbose

2) tklib の場所を明示する場合
   python make_mini_tklib.py --entry Ne-T_fit.py --pkg-root D:\path\to\tklib --out tklib_sub --verbose

3) パッケージ名を指定し、その場所を明示する場合
   python make_mini_tklib.py --entry optimize_mup.py --pkg-name mypkg --pkg-root D:\path\to\mypkg --out mypkg_sub --verbose

4) まずは何がコピーされるかだけ確認 (ドライラン)
   python make_mini_tklib.py --entry optimize_mup.py --pkg-name tklib --pkg-root D:\git\tkProg\tklib\python\tklib --out tklib_sub --dry-run --verbose

5) `if` ブロックでインポートするモジュールをコピーしない
   python make_mini_tklib.py --entry some_script.py --out output --ignore-if-imports

6) 未使用ライブラリを報告
   python make_mini_tklib.py --entry some_script.py --out output --report-unused

7) 依存関係を可視化
   python make_mini_tklib.py --entry some_script.py --out output --dot-out deps.dot
"""

from __future__ import annotations

import argparse
import ast
import os
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple


# -----------------------------
# Path helpers
# -----------------------------
[ドキュメント] def norm(p: Path) -> Path: """ 概要: パスを正規化し、絶対パスに解決します。 詳細説明: 与えられたパスのユーザーディレクトリを展開し、絶対パスに解決します。 これにより、パスの一貫性を保ちます。 :param p: Pathオブジェクト :returns: 正規化された絶対パスのPathオブジェクト """ return p.expanduser().resolve()
[ドキュメント] def is_within(child: Path, parent: Path) -> bool: """ 概要: あるパスが別のパスのサブパスであるかどうかを判定します。 詳細説明: `child` パスが `parent` パスの配下にあるかどうかをチェックします。 両方のパスは正規化されて比較されます。 :param child: 子パスとしてチェックするPathオブジェクト :param parent: 親パスとしてチェックするPathオブジェクト :returns: childがparentの配下にあればTrue、そうでなければFalse """ try: norm(child).relative_to(norm(parent)) return True except Exception: return False
[ドキュメント] def find_pkg_root_upward(entry: Path, pkg_name: str) -> Optional[Path]: """ 概要: エントリスクリプトの親ディレクトリから指定されたパッケージのルートを探索します。 詳細説明: `entry` パスから親ディレクトリを最大50階層まで遡り、 その中に `pkg_name` という名前のディレクトリが存在するかどうかをチェックします。 見つかった場合、そのディレクトリをパッケージのルートとして返します。 :param entry: エントリスクリプトのPathオブジェクト :param pkg_name: 探索するパッケージ名 :returns: パッケージルートのPathオブジェクト、または見つからなかった場合はNone """ cur = norm(entry).parent for _ in range(50): # Limit search depth cand = cur / pkg_name if cand.exists() and cand.is_dir(): return norm(cand) if cur.parent == cur: # Reached root of filesystem break cur = cur.parent return None
[ドキュメント] def default_pkg_root_from_env_or_fallback() -> Optional[Path]: """ 概要: 環境変数またはデフォルトのパスからパッケージルートを決定します。 詳細説明: まず環境変数 `tkProg_Root` を確認し、そこから `tklib/python/tklib` へのパスが存在すれば返します。 次に、ハードコードされたデフォルトパス `d:/git/tkProg/tklib/python/tklib` が存在すれば返します。 いずれも見つからない場合はNoneを返します。 :returns: パッケージルートのPathオブジェクト、または見つからなかった場合はNone """ tkprog_root = os.environ.get("tkProg_Root") if tkprog_root: p1 = Path(os.path.join(tkprog_root, "tklib", "python", "tklib")) if p1.exists() and p1.is_dir(): return norm(p1) p2 = Path("d:/git/tkProg/tklib/python/tklib") if p2.exists() and p2.is_dir(): return norm(p2) return None
[ドキュメント] def iter_py_files_under(root: Path) -> List[Path]: """ 概要: 指定されたルートディレクトリ以下のすべてのPythonファイルを列挙します。 詳細説明: `root` パス以下のすべての `.py` ファイルを再帰的に検索し、リストとして返します。 :param root: 検索を開始するルートディレクトリのPathオブジェクト :returns: 検出されたPythonファイルのPathオブジェクトのリスト """ return [p for p in norm(root).rglob("*.py") if p.is_file()]
# ----------------------------- # Import extraction # -----------------------------
[ドキュメント] @dataclass(frozen=True) class ImportRef: """ 概要: Pythonのインポート文を表すデータクラスです。 詳細説明: `import X` または `from X import Y` 形式のインポート情報を格納します。 :param module: インポートされるモジュールまたはパッケージの名前 (例: "os", "pkg.sub")。 `from . import name` のような相対インポートの場合、`module` は `.` や `..` を含まず、 インポートのベースとなるモジュール名(例: `submodule`)になります。 `import X` の場合は `X`、`from X import Y` の場合は `X`。 :param names: `from X import Y, Z` の `Y`, `Z` に相当する名前のタプル。`import X` の場合はNone。 :param level: 相対インポートのレベル。0は絶対インポート、1は `.`、2は `..` など。 """ module: Optional[str] names: Optional[Tuple[str, ...]] # for from-import level: int # 0 absolute, >0 relative
[ドキュメント] def extract_imports(pyfile: Path, ignore_if_imports: bool = False) -> List[ImportRef]: """ 概要: Pythonファイルからインポート文を抽出します。 詳細説明: 指定されたPythonファイルの内容をAST(抽象構文木)として解析し、 `import` および `from ... import` 文を `ImportRef` オブジェクトのリストとして抽出します。 `ignore_if_imports=True` の場合、`if` ブロック内にあるインポート文は無視されます。 :param pyfile: 解析対象のPythonファイルのPathオブジェクト :param ignore_if_imports: Trueの場合、ifブロック内のインポートを無視します。 :returns: 抽出されたImportRefオブジェクトのリスト """ try: src = pyfile.read_text(encoding="utf-8") except UnicodeDecodeError: src = pyfile.read_text(encoding="utf-8", errors="replace") try: tree = ast.parse(src, filename=str(pyfile)) except SyntaxError: # 構文エラーのあるファイルは無視 return [] out: List[ImportRef] = [] class ImportCollector(ast.NodeVisitor): """ 概要: ASTを走査してインポート文を収集するNodeVisitorです。 詳細説明: `ast.NodeVisitor` を継承し、`visit_Import` と `visit_ImportFrom` メソッドをオーバーライドして、 ASTノードからインポート情報を抽出します。 `ignore_if_imports` がTrueの場合、`if` ノードの深さを追跡し、条件付きインポートをスキップします。 """ def __init__(self) -> None: """ 概要: ImportCollectorのインスタンスを初期化します。 """ self.if_depth = 0 def _should_collect(self) -> bool: """ 概要: 現在のコンテキストでインポートを収集すべきかを判定します。 詳細説明: `ignore_if_imports` がTrueの場合、`if` ブロック内(`if_depth > 0`)ではインポートを収集しません。 :returns: インポートを収集すべきであればTrue、そうでなければFalse """ return not (ignore_if_imports and self.if_depth > 0) def visit_If(self, node: ast.If) -> None: """ 概要: `if` 文ノードを訪問します。 詳細説明: `if_depth` をインクリメントし、`if` ブロック内のステートメントを再帰的に訪問します。 訪問後、`if_depth` をデクリメントします。`if` 文の条件式自体は訪問しません。 :param node: 訪問するast.Ifノード """ self.if_depth += 1 for stmt in node.body: self.visit(stmt) for stmt in node.orelse: self.visit(stmt) self.if_depth -= 1 # do not visit node.test (imports won't be there) def visit_Import(self, node: ast.Import) -> None: """ 概要: `import` 文ノードを訪問します。 詳細説明: `_should_collect` がTrueの場合、`import X, Y` 形式のインポートから 各モジュール名を抽出し、`ImportRef` オブジェクトとして `out` リストに追加します。 :param node: 訪問するast.Importノード """ if self._should_collect(): for alias in node.names: if alias.name: out.append(ImportRef(module=alias.name, names=None, level=0)) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: """ 概要: `from ... import` 文ノードを訪問します。 詳細説明: `_should_collect` がTrueの場合、`from X import Y, Z` 形式のインポートから ベースモジュール名、インポートされる名前、および相対インポートレベルを抽出し、 `ImportRef` オブジェクトとして `out` リストに追加します。 :param node: 訪問するast.ImportFromノード """ if self._should_collect(): mod = node.module lvl = int(node.level or 0) names = tuple(a.name for a in node.names if a.name) out.append(ImportRef(module=mod, names=names if names else None, level=lvl)) def generic_visit(self, node: ast.AST) -> None: """ 概要: 一般的なASTノードを訪問します。 詳細説明: デフォルトの訪問動作を提供し、子ノードを再帰的に訪問します。 :param node: 訪問するast.ASTノード """ super().generic_visit(node) ImportCollector().visit(tree) return out
# ----------------------------- # Module resolution # -----------------------------
[ドキュメント] def module_to_candidate_paths_under_root(pkg_root: Path, pkg_name: str, module: str) -> List[Path]: """ 概要: モジュール名からパッケージルート以下の候補パスを生成します。 詳細説明: `pkg_root` と `pkg_name` を元に、`module` という名前のモジュールまたはパッケージに対応する 可能性のあるファイルパス(`.py` ファイルまたは `__init__.py` ファイル)を生成します。 モジュール名が `pkg_name` で始まる場合、その部分を削除して相対パスを構築します。 :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param pkg_name: パッケージ名 :param module: 解決するモジュール名 (例: "pkg.foo.bar" または "foo.bar") :returns: 候補となるファイルパスのリスト """ parts = module.split(".") if parts and parts[0] == pkg_name: parts = parts[1:] cand1 = pkg_root.joinpath(*parts).with_suffix(".py") # foo.bar -> pkg_root/foo/bar.py cand2 = pkg_root.joinpath(*parts) / "__init__.py" # foo.bar -> pkg_root/foo/bar/__init__.py return [cand1, cand2]
[ドキュメント] def resolve_from_import_targets( pkg_root: Path, pkg_name: str, base_module: str, names: Tuple[str, ...], ) -> List[Path]: """ 概要: `from X import Y, Z` 形式のインポートで参照されるファイルを解決します。 詳細説明: `from base_module import name1, name2` の形式でインポートされる場合に、 `base_module` 自体と、`base_module.name1`、`base_module.name2` などのサブルートに 対応するファイルパスを解決します。存在しないパスはリストに含まれません。 :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param pkg_name: パッケージ名 :param base_module: `from` 文のベースとなるモジュール名 (例: "pkg.foo") :param names: インポートされる名前のタプル (例: ("bar", "baz")) :returns: 解決されたファイルパスのリスト """ out: List[Path] = [] # base module itself (e.g., from pkg.foo import bar -> resolve pkg.foo) out.extend([p for p in module_to_candidate_paths_under_root(pkg_root, pkg_name, base_module) if p.exists()]) # base_module.name as submodule/package (e.g., from pkg.foo import bar -> resolve pkg.foo.bar) for nm in names: out.extend( [p for p in module_to_candidate_paths_under_root(pkg_root, pkg_name, f"{base_module}.{nm}") if p.exists()] ) return out
[ドキュメント] def resolve_import_ref_to_files( ref: ImportRef, pkg_root: Path, pkg_name: str, current_file: Path, ) -> List[Path]: """ 概要: `ImportRef` オブジェクトが参照する実際のファイルパスを解決します。 詳細説明: `ImportRef` の種類(絶対/相対、通常のインポート/fromインポート)に基づいて、 `pkg_root` 配下にある可能性のあるファイルパスを特定します。 相対インポートの場合、`current_file` を基準にパスを解決します。 :param ref: 解決するImportRefオブジェクト :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param pkg_name: パッケージ名 :param current_file: 現在解析中のPythonファイルのPathオブジェクト(相対インポートの基準点) :returns: 解決されたファイルパスのリスト """ files: List[Path] = [] # Relative imports (filesystem-based) if ref.level and ref.level > 0: base_dir = current_file.parent for _ in range(ref.level - 1): # Adjust for levels like `.` (level 1 means current dir) base_dir = base_dir.parent if is_within(base_dir, pkg_root): target_dir = base_dir if ref.module: target_dir = target_dir.joinpath(*ref.module.split(".")) if ref.names: for nm in ref.names: cand_file = target_dir / f"{nm}.py" cand_pkg = target_dir / nm / "__init__.py" if cand_file.exists(): files.append(cand_file) if cand_pkg.exists(): files.append(cand_pkg) # If from . import name, the current package's __init__.py might be relevant initp = target_dir / "__init__.py" if initp.exists(): files.append(initp) else: # from . import (implicit current package) initp = target_dir / "__init__.py" if initp.exists(): files.append(initp) return files # Absolute imports if not ref.module: return files # import X (resolve X as module or package) if ref.names is None: for p in module_to_candidate_paths_under_root(pkg_root, pkg_name, ref.module): if p.exists(): files.append(p) return files # from X import a,b (resolve X as base module, and X.a, X.b as submodules/attributes) files.extend(resolve_from_import_targets(pkg_root, pkg_name, ref.module, ref.names)) return files
# ----------------------------- # Copy logic # -----------------------------
[ドキュメント] def rel_under_root(pkg_root: Path, file_path: Path) -> Path: """ 概要: ファイルパスをパッケージルートからの相対パスとして取得します。 詳細説明: `file_path` が `pkg_root` の配下にある場合、その相対パスを返します。 両方のパスは正規化されて処理されます。 :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param file_path: 相対パスを取得するファイルのPathオブジェクト :returns: `pkg_root` からの相対パスのPathオブジェクト """ return norm(file_path).relative_to(norm(pkg_root))
[ドキュメント] def ensure_parent_init_files(pkg_root: Path, file_path: Path, selected: Set[Path]) -> None: """ 概要: コピー対象ファイルの親パッケージの `__init__.py` ファイルを選択セットに追加します。 詳細説明: `file_path` が `pkg_root` の配下にある場合、その親ディレクトリのパスを遡り、 各パッケージディレクトリに存在する `__init__.py` ファイルを `selected` セットに追加します。 これにより、パッケージ構造が正しく維持されます。 :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param file_path: コピー対象として選択されたファイルのPathオブジェクト :param selected: 選択されたファイルのPathオブジェクトを格納するセット """ rel = rel_under_root(pkg_root, file_path) parts = list(rel.parts) if len(parts) <= 1: # Top-level file or pkg_root itself return # Iterate through parent directories up to pkg_root for i in range(1, len(parts)): d = pkg_root.joinpath(*parts[:i]) initp = d / "__init__.py" if initp.exists(): selected.add(norm(initp))
[ドキュメント] def copy_selected_files(pkg_root: Path, out_root: Path, selected: Set[Path], dry_run: bool = False) -> None: """ 概要: 選択されたファイルを指定された出力ディレクトリにコピーします。 詳細説明: `selected` セット内の各ファイルを `pkg_root` からの相対パスを保ったまま `out_root` にコピーします。 必要に応じて出力先のディレクトリ構造を作成します。 `dry_run` がTrueの場合、実際のファイルコピーは行わず、コピー予定のパスを出力します。 :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param out_root: コピー先のルートディレクトリのPathオブジェクト :param selected: コピー対象のファイルのPathオブジェクトのセット :param dry_run: Trueの場合、ファイルコピーを実行せずログ出力のみ行います。 :returns: None """ out_root.mkdir(parents=True, exist_ok=True) for src in sorted(selected): if not is_within(src, pkg_root): # Should not happen if build_mini_pkg logic is correct continue rel = rel_under_root(pkg_root, src) dst = out_root / rel if dry_run: print(f"[DRY] {src} -> {dst}") continue dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst)
# ----------------------------- # Reports # -----------------------------
[ドキュメント] def write_unused_report(pkg_root: Path, selected: Set[Path], out_dir: Path, filename: str = "unused_files.txt") -> Path: """ 概要: `pkg_root` 配下で選択されなかったPythonファイルのリストをファイルに書き出します。 詳細説明: `pkg_root` 配下のすべてのPythonファイルから `selected` セットに含まれるファイルを除外し、 残りの未使用ファイルを `out_dir` 内の指定されたファイルにリストアップします。 ファイルパスは `pkg_root` からの相対パスとして記述されます。 :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param selected: 選択されたPythonファイルのPathオブジェクトのセット :param out_dir: レポートファイルを出力するディレクトリのPathオブジェクト :param filename: 出力するレポートファイルの名前 (デフォルト: "unused_files.txt") :returns: 出力されたレポートファイルのPathオブジェクト """ pkg_root = norm(pkg_root) out_dir = norm(out_dir) out_dir.mkdir(parents=True, exist_ok=True) all_py = {norm(p) for p in iter_py_files_under(pkg_root)} unused = sorted(all_py - {norm(p) for p in selected}) out_path = out_dir / filename lines: List[str] = [] lines.append(f"# pkg_root = {pkg_root}") lines.append(f"# total_py = {len(all_py)}") lines.append(f"# selected = {len(selected)}") lines.append(f"# unused = {len(unused)}") lines.append("") for p in unused: try: rel = p.relative_to(pkg_root) except Exception: # If for some reason 'p' is not relative to pkg_root, use absolute path. rel = p lines.append(str(rel).replace("\\", "/")) out_path.write_text("\n".join(lines), encoding="utf-8") return out_path
[ドキュメント] def write_dot_graph( pkg_root: Path, entry: Path, edges: Dict[Path, Set[Path]], out_path: Path, ) -> Path: """ 概要: モジュール間の依存関係を示すGraphviz dotファイルを書き出します。 詳細説明: `edges` で表現されるモジュール間の依存関係グラフをGraphviz dot形式でファイルに保存します。 ノードは可能な限り `pkg_root` からの相対パスでラベル付けされ、 エントリスクリプトは特別にハイライトされます。 :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param entry: エントリスクリプトのPathオブジェクト(グラフ内で強調表示されます) :param edges: 依存関係の辞書 (キー: 依存元ファイルのPathオブジェクト、値: 依存先ファイルのPathオブジェクトのセット) :param out_path: 出力するdotファイルのPathオブジェクト :returns: 出力されたdotファイルのPathオブジェクト """ pkg_root = norm(pkg_root) entry = norm(entry) out_path = Path(out_path).expanduser().resolve() out_path.parent.mkdir(parents=True, exist_ok=True) def label(p: Path) -> str: """ 概要: パスをGraphvizノードのラベルに適した文字列に変換します。 """ p = norm(p) if is_within(p, pkg_root): s = str(p.relative_to(pkg_root)).replace("\\", "/") else: s = str(p).replace("\\", "/") return s lines: List[str] = [] lines.append("digraph deps {") lines.append(' graph [rankdir="LR"];') lines.append(' node [shape="box", fontsize=10];') lines.append("") # highlight entry entry_label = label(entry) lines.append(f' "{entry_label}" [shape="box", style="rounded,bold"];') # nodes + edges for src, dsts in sorted(edges.items(), key=lambda kv: label(kv[0])): src_lab = label(src) for dst in sorted(dsts, key=label): dst_lab = label(dst) lines.append(f' "{src_lab}" -> "{dst_lab}";') lines.append("}") out_path.write_text("\n".join(lines), encoding="utf-8") return out_path
# ----------------------------- # Main crawl # -----------------------------
[ドキュメント] def build_mini_pkg( entry: Path, pkg_root: Path, pkg_name: str, out_root: Path, dry_run: bool = False, verbose: bool = False, ignore_if_imports: bool = False, ) -> Tuple[Set[Path], Dict[Path, Set[Path]]]: """ 概要: 最小限のパッケージサブセットを構築するためのコアロジックを実行します。 詳細説明: エントリスクリプトから開始し、Pythonファイルのインポート依存関係を再帰的に静的解析します。 解析中に見つかった `pkg_root` 配下のすべての依存ファイルを `selected` セットに追加し、 ファイル間の依存関係を `edges` 辞書に記録します。 最終的に、`selected` に含まれるファイルを `out_root` へコピーします。 :param entry: 解析を開始するエントリスクリプトのPathオブジェクト :param pkg_root: パッケージのルートディレクトリのPathオブジェクト :param pkg_name: ターゲットパッケージ名 :param out_root: 選択されたファイルをコピーする出力ディレクトリのPathオブジェクト :param dry_run: Trueの場合、実際のファイルコピーは行いません。 :param verbose: Trueの場合、スキャン中のファイルパスをログに出力します。 :param ignore_if_imports: Trueの場合、ifブロック内のインポート文を無視します。 :returns: - selected_files (Set[Path]): 必要なファイルのPathオブジェクトのセット - edges (Dict[Path, Set[Path]]): 選択されたファイル間の依存関係 (依存元 -> 依存先) """ entry = norm(entry) pkg_root = norm(pkg_root) out_root = out_root.expanduser().resolve() to_scan: List[Path] = [entry] scanned: Set[Path] = set() selected: Set[Path] = set() edges: Dict[Path, Set[Path]] = {} while to_scan: cur = norm(to_scan.pop()) if cur in scanned: continue scanned.add(cur) if verbose: print(f"[SCAN] {cur}") if cur.suffix.lower() != ".py": continue # parse imports refs = extract_imports(cur, ignore_if_imports=ignore_if_imports) for ref in refs: files = resolve_import_ref_to_files(ref, pkg_root, pkg_name, cur) if not files: continue # only consider dst within pkg_root dsts: List[Path] = [] for f in files: f = norm(f) if is_within(f, pkg_root): dsts.append(f) if not dsts: continue # if cur itself is within pkg_root, record edges if is_within(cur, pkg_root): edges.setdefault(cur, set()).update(dsts) # add selected and enqueue for f in dsts: if f not in selected: selected.add(f) ensure_parent_init_files(pkg_root, f, selected) # Add __init__.py for parent packages if f not in scanned: to_scan.append(f) copy_selected_files(pkg_root, out_root, selected, dry_run=dry_run) return selected, edges
[ドキュメント] def main() -> int: """ 概要: スクリプトのメインエントリポイントです。 詳細説明: コマンドライン引数を解析し、`build_mini_pkg` 関数を呼び出して最小パッケージを構築します。 構築後、選択されたファイルの数、および生成されたレポート(未使用ファイル、dotグラフ)のパスを出力します。 引数の検証も行い、エラーがあれば適切な終了コードで終了します。 :returns: 成功時は0、エラー時は2の終了コード """ ap = argparse.ArgumentParser( description="Generate a minimal subset of a package used by an entry script, preserving tree under output root." ) ap.add_argument("--entry", required=True, help="Path to entry (parent) Python script, e.g. Ne-T_fit.py") ap.add_argument("--pkg-name", default="tklib", help='Target package name (default: "tklib")') ap.add_argument( "--pkg-root", default=None, help="Path to the package directory (the folder that contains __init__.py). " "If omitted, uses tkProg_Root-based default then fallback then upward search.", ) ap.add_argument("--out", default="tklib_sub", help="Output folder to write subset tree (default: tklib_sub)") ap.add_argument("--dry-run", action="store_true", help="Print planned copies but do not write files") ap.add_argument("--verbose", action="store_true", help="Verbose scan logs") # minimum-oriented options ap.add_argument( "--ignore-if-imports", action="store_true", help="Ignore imports that appear inside any 'if' block (more minimal subset).", ) # reports ap.add_argument( "--report-unused", action="store_true", help="Write unused_files.txt under --out (all .py under pkg_root that were NOT selected).", ) ap.add_argument( "--dot-out", default=None, help="Write Graphviz dot file of dependency edges among selected files (e.g. deps.dot).", ) args = ap.parse_args() entry = Path(args.entry).expanduser() if not entry.exists(): print(f"ERROR: entry not found: {entry}", file=sys.stderr) return 2 pkg_name = args.pkg_name # Resolve pkg_root with requested defaults pkg_root: Optional[Path] if args.pkg_root: cand = Path(args.pkg_root).expanduser() if not (cand.exists() and cand.is_dir()): print(f"ERROR: --pkg-root not found or not a directory: {cand}", file=sys.stderr) return 2 pkg_root = norm(cand) else: pkg_root = default_pkg_root_from_env_or_fallback() if pkg_root is None: pkg_root = find_pkg_root_upward(entry, pkg_name) if pkg_root is None or not (pkg_root.exists() and pkg_root.is_dir()): print( "ERROR: Could not determine pkg_root.\n" " Provide --pkg-root explicitly, or set tkProg_Root env, or place the package folder in parents of entry.", file=sys.stderr, ) return 2 out_root = Path(args.out).expanduser() selected, edges = build_mini_pkg( entry=entry, pkg_root=pkg_root, pkg_name=pkg_name, out_root=out_root, dry_run=args.dry_run, verbose=args.verbose, ignore_if_imports=args.ignore_if_imports, ) print(f"pkg_name : {pkg_name}") print(f"pkg_root : {pkg_root}") print(f"entry : {norm(entry)}") print(f"out : {out_root.resolve()}") print(f"selected : {len(selected)} file(s)") # Reports if args.report_unused: unused_path = write_unused_report(pkg_root, selected, out_root) print(f"unused_report : {unused_path}") if args.dot_out: dot_path = write_dot_graph(pkg_root, entry, edges, Path(args.dot_out)) print(f"dot_graph : {dot_path}") print(" (render example) dot -Tpng deps.dot -o deps.png") return 0
if __name__ == "__main__": raise SystemExit(main())