dirlens 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dirlens.py +219 -39
- package/package.json +1 -1
package/dirlens.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
dirlens –
|
|
3
|
+
dirlens – ファイルサイズ付きディレクトリツリー表示ツール
|
|
4
4
|
対応環境: macOS / Linux / Windows (Python 3.8+)
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
8
|
import sys
|
|
9
9
|
import argparse
|
|
10
|
+
import fnmatch
|
|
11
|
+
import datetime
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
|
|
12
14
|
# ─── カラー設定 ──────────────────────────────────────────────
|
|
@@ -41,6 +43,7 @@ MAGENTA = "\033[35m"
|
|
|
41
43
|
def c(text, *codes):
|
|
42
44
|
return ("".join(codes) + text + RESET) if USE_COLOR else text
|
|
43
45
|
|
|
46
|
+
|
|
44
47
|
# ─── サイズ表示 ───────────────────────────────────────────────
|
|
45
48
|
def fmt_size(n):
|
|
46
49
|
if n == 0:
|
|
@@ -51,6 +54,68 @@ def fmt_size(n):
|
|
|
51
54
|
return f"{s} {unit}"
|
|
52
55
|
return f"{n} {'byte' if n == 1 else 'bytes'}"
|
|
53
56
|
|
|
57
|
+
|
|
58
|
+
# ─── アイテム数表示 ────────────────────────────────────────────
|
|
59
|
+
def fmt_count(nd, nf):
|
|
60
|
+
d = f"{nd} {'dir' if nd == 1 else 'dirs'}"
|
|
61
|
+
f_str = f"{nf} {'file' if nf == 1 else 'files'}"
|
|
62
|
+
return f"{d}, {f_str}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ─── 日時表示 ─────────────────────────────────────────────────
|
|
66
|
+
def fmt_date(mtime):
|
|
67
|
+
sec = int((datetime.datetime.now() -
|
|
68
|
+
datetime.datetime.fromtimestamp(mtime)).total_seconds())
|
|
69
|
+
if sec < 60: return "今"
|
|
70
|
+
if sec < 3600: return f"{sec // 60}分前"
|
|
71
|
+
if sec < 86400: return f"{sec // 3600}時間前"
|
|
72
|
+
days = sec // 86400
|
|
73
|
+
if days == 1: return "昨日"
|
|
74
|
+
if days < 7: return f"{days}日前"
|
|
75
|
+
if days < 30: return f"{days // 7}週間前"
|
|
76
|
+
if days < 365: return f"{days // 30}ヶ月前"
|
|
77
|
+
return f"{days // 365}年前"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ─── .gitignore サポート ──────────────────────────────────────
|
|
81
|
+
def load_gitignore(directory):
|
|
82
|
+
"""指定ディレクトリの .gitignore パターンを読み込む。"""
|
|
83
|
+
patterns = []
|
|
84
|
+
path = os.path.join(directory, ".gitignore")
|
|
85
|
+
if os.path.isfile(path):
|
|
86
|
+
try:
|
|
87
|
+
with open(path, encoding="utf-8", errors="ignore") as f:
|
|
88
|
+
for line in f:
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if line and not line.startswith("#"):
|
|
91
|
+
patterns.append(line)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
return patterns
|
|
95
|
+
|
|
96
|
+
def is_ignored(name, rel_path, is_dir, patterns):
|
|
97
|
+
"""gitignore パターンにマッチするか判定する(簡易実装)。"""
|
|
98
|
+
rel = rel_path.replace("\\", "/")
|
|
99
|
+
for pat in patterns:
|
|
100
|
+
if pat.startswith("!"):
|
|
101
|
+
continue
|
|
102
|
+
dir_only = pat.endswith("/")
|
|
103
|
+
p = pat.rstrip("/")
|
|
104
|
+
if dir_only and not is_dir:
|
|
105
|
+
continue
|
|
106
|
+
if p.startswith("/"):
|
|
107
|
+
if fnmatch.fnmatch(rel, p.lstrip("/")):
|
|
108
|
+
return True
|
|
109
|
+
else:
|
|
110
|
+
if fnmatch.fnmatch(name, p):
|
|
111
|
+
return True
|
|
112
|
+
if fnmatch.fnmatch(rel, p):
|
|
113
|
+
return True
|
|
114
|
+
if fnmatch.fnmatch(rel, "*/" + p):
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
54
119
|
# ─── ディレクトリサイズ(キャッシュ付き) ─────────────────────
|
|
55
120
|
_cache = {}
|
|
56
121
|
|
|
@@ -73,24 +138,42 @@ def dir_size(path):
|
|
|
73
138
|
_cache[path] = total
|
|
74
139
|
return total
|
|
75
140
|
|
|
141
|
+
|
|
76
142
|
# ─── アイテム数カウント ────────────────────────────────────────
|
|
77
|
-
def
|
|
78
|
-
"""ディレクトリ直下のアイテム数 (num_dirs, num_files) を返す。"""
|
|
143
|
+
def count_entries(path, show_all, active_pats, root, type_ext, use_gitignore=False):
|
|
79
144
|
try:
|
|
80
|
-
|
|
145
|
+
raw = list(os.scandir(path))
|
|
81
146
|
except OSError:
|
|
82
|
-
return
|
|
83
|
-
if not
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
147
|
+
return 0, 0
|
|
148
|
+
entries = [e for e in raw if show_all or not e.name.startswith(".")]
|
|
149
|
+
|
|
150
|
+
# count_entries の対象ディレクトリ自身の .gitignore も反映する
|
|
151
|
+
local_pats = active_pats
|
|
152
|
+
if use_gitignore:
|
|
153
|
+
local = load_gitignore(path)
|
|
154
|
+
if local:
|
|
155
|
+
rel_dir = os.path.relpath(path, root).replace("\\", "/")
|
|
156
|
+
adjusted = []
|
|
157
|
+
for pat in local:
|
|
158
|
+
if not pat.startswith("!") and pat.startswith("/"):
|
|
159
|
+
adjusted.append("/" + rel_dir + pat)
|
|
160
|
+
else:
|
|
161
|
+
adjusted.append(pat)
|
|
162
|
+
local_pats = active_pats + adjusted
|
|
163
|
+
|
|
164
|
+
if local_pats:
|
|
165
|
+
entries = [e for e in entries
|
|
166
|
+
if not is_ignored(e.name,
|
|
167
|
+
os.path.relpath(e.path, root),
|
|
168
|
+
e.is_dir(follow_symlinks=False),
|
|
169
|
+
local_pats)]
|
|
170
|
+
nd = sum(1 for e in entries if e.is_dir(follow_symlinks=False))
|
|
171
|
+
files = [e for e in entries if not e.is_dir(follow_symlinks=False)]
|
|
172
|
+
if type_ext:
|
|
173
|
+
files = [f for f in files
|
|
174
|
+
if os.path.splitext(f.name)[1].lower() == type_ext]
|
|
175
|
+
return nd, len(files)
|
|
176
|
+
|
|
94
177
|
|
|
95
178
|
# ─── ツリー描画 ───────────────────────────────────────────────
|
|
96
179
|
PIPE = "│ "
|
|
@@ -98,10 +181,32 @@ FORK = "├── "
|
|
|
98
181
|
LAST = "└── "
|
|
99
182
|
BLANK = " "
|
|
100
183
|
|
|
101
|
-
def render(path, prefix, depth,
|
|
184
|
+
def render(path, prefix, depth, opts, stats, active_pats):
|
|
185
|
+
"""
|
|
186
|
+
active_pats: 現在の階層で有効な gitignore パターンの累積リスト。
|
|
187
|
+
サブディレクトリに入るたびにローカルの .gitignore を読み込んで追記する。
|
|
188
|
+
リストは新規作成して渡すため、兄弟ディレクトリには影響しない。
|
|
189
|
+
"""
|
|
190
|
+
max_depth, show_all, by_size, show_date, use_gitignore, root, type_ext = opts
|
|
191
|
+
|
|
102
192
|
if max_depth is not None and depth >= max_depth:
|
|
103
193
|
return
|
|
104
194
|
|
|
195
|
+
# このディレクトリの .gitignore を読んでパターンを積み重ねる(ルートは main で読み済み)
|
|
196
|
+
if use_gitignore and depth > 0:
|
|
197
|
+
local = load_gitignore(path)
|
|
198
|
+
if local:
|
|
199
|
+
# アンカーパターン(/xxx)をルートからの相対パスに変換する
|
|
200
|
+
# 例: src/.gitignore の /build → /src/build
|
|
201
|
+
rel_dir = os.path.relpath(path, root).replace("\\", "/")
|
|
202
|
+
adjusted = []
|
|
203
|
+
for pat in local:
|
|
204
|
+
if not pat.startswith("!") and pat.startswith("/"):
|
|
205
|
+
adjusted.append("/" + rel_dir + pat)
|
|
206
|
+
else:
|
|
207
|
+
adjusted.append(pat)
|
|
208
|
+
active_pats = active_pats + adjusted # 新しいリストを作成(親に影響しない)
|
|
209
|
+
|
|
105
210
|
try:
|
|
106
211
|
raw = list(os.scandir(path))
|
|
107
212
|
except PermissionError:
|
|
@@ -109,18 +214,32 @@ def render(path, prefix, depth, max_depth, show_all, by_size, stats):
|
|
|
109
214
|
return
|
|
110
215
|
|
|
111
216
|
entries = [e for e in raw if show_all or not e.name.startswith(".")]
|
|
217
|
+
|
|
218
|
+
if active_pats:
|
|
219
|
+
entries = [e for e in entries
|
|
220
|
+
if not is_ignored(e.name,
|
|
221
|
+
os.path.relpath(e.path, root),
|
|
222
|
+
e.is_dir(follow_symlinks=False),
|
|
223
|
+
active_pats)]
|
|
224
|
+
|
|
112
225
|
dirs = [e for e in entries if e.is_dir(follow_symlinks=False)]
|
|
113
226
|
files = [e for e in entries if not e.is_dir(follow_symlinks=False)]
|
|
114
227
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
228
|
+
if type_ext:
|
|
229
|
+
files = [f for f in files
|
|
230
|
+
if os.path.splitext(f.name)[1].lower() == type_ext]
|
|
231
|
+
|
|
232
|
+
def esz(e):
|
|
233
|
+
try: return e.stat(follow_symlinks=True).st_size
|
|
234
|
+
except OSError: return 0
|
|
235
|
+
|
|
236
|
+
def emtime(e):
|
|
237
|
+
try: return e.stat(follow_symlinks=True).st_mtime
|
|
238
|
+
except OSError: return 0
|
|
120
239
|
|
|
121
240
|
if by_size:
|
|
122
241
|
dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
|
|
123
|
-
files.sort(key=lambda e:
|
|
242
|
+
files.sort(key=lambda e: esz(e), reverse=True)
|
|
124
243
|
else:
|
|
125
244
|
dirs.sort(key=lambda e: e.name.casefold())
|
|
126
245
|
files.sort(key=lambda e: e.name.casefold())
|
|
@@ -133,20 +252,41 @@ def render(path, prefix, depth, max_depth, show_all, by_size, stats):
|
|
|
133
252
|
cont = BLANK if is_last else PIPE
|
|
134
253
|
|
|
135
254
|
if entry.is_dir(follow_symlinks=False):
|
|
136
|
-
sz
|
|
137
|
-
nd, nf
|
|
255
|
+
sz = dir_size(entry.path)
|
|
256
|
+
nd, nf = count_entries(entry.path, show_all, active_pats, root, type_ext, use_gitignore)
|
|
138
257
|
stats["dirs"] += 1
|
|
258
|
+
|
|
259
|
+
parts = [fmt_count(nd, nf), fmt_size(sz)]
|
|
260
|
+
if show_date:
|
|
261
|
+
try:
|
|
262
|
+
parts.append(fmt_date(entry.stat(follow_symlinks=False).st_mtime))
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
139
266
|
name = c(f"{entry.name}/", BOLD, CYAN)
|
|
140
|
-
meta = c(
|
|
267
|
+
meta = c(f"({', '.join(parts)})", DIM)
|
|
141
268
|
print(f"{prefix}{branch}{name} {meta}")
|
|
142
|
-
render(entry.path, prefix + cont, depth + 1,
|
|
269
|
+
render(entry.path, prefix + cont, depth + 1, opts, stats, active_pats)
|
|
270
|
+
|
|
143
271
|
else:
|
|
144
|
-
sz =
|
|
272
|
+
sz = esz(entry)
|
|
145
273
|
sym = " →" if entry.is_symlink() else ""
|
|
146
274
|
stats["files"] += 1
|
|
275
|
+
|
|
276
|
+
ext = os.path.splitext(entry.name)[1].lower()
|
|
277
|
+
key = ext if ext else "(no ext)"
|
|
278
|
+
stats["extensions"][key] = stats["extensions"].get(key, 0) + 1
|
|
279
|
+
|
|
280
|
+
parts = [fmt_size(sz)]
|
|
281
|
+
if show_date:
|
|
282
|
+
mt = emtime(entry)
|
|
283
|
+
if mt:
|
|
284
|
+
parts.append(fmt_date(mt))
|
|
285
|
+
|
|
147
286
|
name = c(f"{entry.name}{sym}", MAGENTA if entry.is_symlink() else GREEN)
|
|
148
|
-
|
|
149
|
-
print(f"{prefix}{branch}{name} {
|
|
287
|
+
meta = c(f"({', '.join(parts)})", DIM)
|
|
288
|
+
print(f"{prefix}{branch}{name} {meta}")
|
|
289
|
+
|
|
150
290
|
|
|
151
291
|
# ─── エントリポイント ─────────────────────────────────────────
|
|
152
292
|
def main():
|
|
@@ -155,7 +295,7 @@ def main():
|
|
|
155
295
|
|
|
156
296
|
ap = argparse.ArgumentParser(
|
|
157
297
|
prog="dirlens",
|
|
158
|
-
description="
|
|
298
|
+
description="ファイルサイズ付きのディレクトリツリーを表示します",
|
|
159
299
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
160
300
|
epilog=(
|
|
161
301
|
"使用例:\n"
|
|
@@ -164,6 +304,10 @@ def main():
|
|
|
164
304
|
" dirlens -d 2 深さ 2 階層まで表示\n"
|
|
165
305
|
" dirlens -a 隠しファイル (.xxx) も表示\n"
|
|
166
306
|
" dirlens -s サイズの大きい順に表示\n"
|
|
307
|
+
" dirlens -g .gitignore のファイルを除外\n"
|
|
308
|
+
" dirlens --date 最終更新日時を表示\n"
|
|
309
|
+
" dirlens -t py .py ファイルのみ表示\n"
|
|
310
|
+
" dirlens -m Markdown コードブロックで出力\n"
|
|
167
311
|
" dirlens --no-color カラーなしで表示"
|
|
168
312
|
),
|
|
169
313
|
)
|
|
@@ -171,10 +315,14 @@ def main():
|
|
|
171
315
|
ap.add_argument("-d", "--depth", type=int, metavar="N", help="表示する最大の深さ")
|
|
172
316
|
ap.add_argument("-a", "--all", action="store_true", help="隠しファイルも表示する")
|
|
173
317
|
ap.add_argument("-s", "--sort-size", action="store_true", help="サイズが大きい順に並べる")
|
|
318
|
+
ap.add_argument("-g", "--gitignore", action="store_true", help=".gitignore のファイルを除外する(サブディレクトリも対応)")
|
|
319
|
+
ap.add_argument("--date", action="store_true", help="最終更新日時を表示する")
|
|
320
|
+
ap.add_argument("-t", "--type", metavar="EXT", help="指定した拡張子のみ表示 (例: py, md)")
|
|
321
|
+
ap.add_argument("-m", "--markdown", action="store_true", help="Markdown コードブロックで出力")
|
|
174
322
|
ap.add_argument("--no-color", action="store_true", help="カラー表示を無効化する")
|
|
175
323
|
args = ap.parse_args()
|
|
176
324
|
|
|
177
|
-
if args.no_color:
|
|
325
|
+
if args.no_color or args.markdown:
|
|
178
326
|
USE_COLOR = False
|
|
179
327
|
|
|
180
328
|
target = Path(args.path).resolve()
|
|
@@ -185,19 +333,51 @@ def main():
|
|
|
185
333
|
print(f"エラー: '{args.path}' はディレクトリではありません", file=sys.stderr)
|
|
186
334
|
sys.exit(1)
|
|
187
335
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
336
|
+
# ルートの .gitignore を起点として読み込む
|
|
337
|
+
active_pats = load_gitignore(str(target)) if args.gitignore else []
|
|
338
|
+
type_ext = ("." + args.type.lstrip(".")).lower() if args.type else None
|
|
339
|
+
opts = (args.depth, args.all, args.sort_size, args.date,
|
|
340
|
+
args.gitignore, str(target), type_ext)
|
|
341
|
+
|
|
342
|
+
if args.markdown:
|
|
343
|
+
print("```")
|
|
344
|
+
|
|
345
|
+
# ルートを表示
|
|
346
|
+
root_sz = dir_size(str(target))
|
|
347
|
+
root_nd, root_nf = count_entries(str(target), args.all, active_pats, str(target), type_ext, args.gitignore)
|
|
348
|
+
root_label = target.name if target.name else str(target)
|
|
349
|
+
|
|
350
|
+
parts = [fmt_count(root_nd, root_nf), fmt_size(root_sz)]
|
|
351
|
+
if args.date:
|
|
352
|
+
try:
|
|
353
|
+
parts.append(fmt_date(target.stat().st_mtime))
|
|
354
|
+
except OSError:
|
|
355
|
+
pass
|
|
191
356
|
|
|
192
357
|
root_name = c(f"{root_label}/", BOLD, BLUE)
|
|
193
|
-
root_meta = c(
|
|
358
|
+
root_meta = c(f"({', '.join(parts)})", DIM)
|
|
194
359
|
print(f"{root_name} {root_meta}")
|
|
195
360
|
|
|
196
|
-
stats = {"files": 0, "dirs": 0}
|
|
197
|
-
render(str(target), "", 0,
|
|
361
|
+
stats = {"files": 0, "dirs": 0, "extensions": {}}
|
|
362
|
+
render(str(target), "", 0, opts, stats, active_pats)
|
|
198
363
|
|
|
364
|
+
# ─ サマリー ──────────────────────────────────────────────
|
|
199
365
|
print()
|
|
200
|
-
|
|
366
|
+
summary = f" {stats['dirs']} ディレクトリ, {stats['files']} ファイル"
|
|
367
|
+
if args.gitignore:
|
|
368
|
+
summary += " (.gitignore 適用済み)"
|
|
369
|
+
if type_ext:
|
|
370
|
+
summary += f" (フィルタ: {type_ext})"
|
|
371
|
+
print(c(summary, DIM))
|
|
372
|
+
|
|
373
|
+
if stats["extensions"]:
|
|
374
|
+
sorted_exts = sorted(stats["extensions"].items(), key=lambda x: -x[1])
|
|
375
|
+
ext_line = " " + " ".join(f"{ext} ×{n}" for ext, n in sorted_exts[:8])
|
|
376
|
+
print(c(ext_line, DIM))
|
|
377
|
+
|
|
378
|
+
if args.markdown:
|
|
379
|
+
print("```")
|
|
380
|
+
|
|
201
381
|
|
|
202
382
|
if __name__ == "__main__":
|
|
203
383
|
main()
|