#!/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
# -----------------------------
# 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())