treex-cli 0.2.0__cp313-abi3-win_amd64.whl
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.
- treex/__init__.py +2 -0
- treex/_native.pyd +0 -0
- treex/treex.py +590 -0
- treex_cli-0.2.0.dist-info/METADATA +212 -0
- treex_cli-0.2.0.dist-info/RECORD +9 -0
- treex_cli-0.2.0.dist-info/WHEEL +5 -0
- treex_cli-0.2.0.dist-info/entry_points.txt +2 -0
- treex_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- treex_cli-0.2.0.dist-info/top_level.txt +1 -0
treex/__init__.py
ADDED
treex/_native.pyd
ADDED
|
Binary file
|
treex/treex.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
# Copyright (c) 2026 Corey Goldberg
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
"""List directory contents as a tree with file and directory metadata.
|
|
6
|
+
|
|
7
|
+
The directory tree is rendered using Unicode box-drawing characters.
|
|
8
|
+
|
|
9
|
+
When run inside a Git repository, files and directories ignored by Git
|
|
10
|
+
are automatically excluded using applicable `.gitignore` rules. Ignore
|
|
11
|
+
matching is handled natively by Gitoxide (`gix`) and does not require
|
|
12
|
+
Git to be installed. If the directory is not part of a Git repository,
|
|
13
|
+
the filesystem is scanned normally without Git filtering.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import fnmatch
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ._native import IgnoreMatcher
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
__version__ = version("treex-cli")
|
|
28
|
+
except PackageNotFoundError:
|
|
29
|
+
__version__ = "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GitIgnore:
|
|
33
|
+
"""Apply Git ignore rules using Gitoxide when inside a repository."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, directory):
|
|
36
|
+
self.directory = Path(directory).resolve()
|
|
37
|
+
self.enabled = False
|
|
38
|
+
self.repo_root = None
|
|
39
|
+
self.matcher = None
|
|
40
|
+
# Discover the repository and initialize ignore matching
|
|
41
|
+
try:
|
|
42
|
+
self.matcher = IgnoreMatcher(str(self.directory))
|
|
43
|
+
except RuntimeError:
|
|
44
|
+
return
|
|
45
|
+
if self.matcher.repo_root is None:
|
|
46
|
+
self.matcher = None
|
|
47
|
+
return
|
|
48
|
+
# Enable Git support if this is a repository
|
|
49
|
+
self.repo_root = Path(self.matcher.repo_root).resolve()
|
|
50
|
+
self.enabled = True
|
|
51
|
+
|
|
52
|
+
def ignored(self, path):
|
|
53
|
+
"""Check if Git considers a path ignored."""
|
|
54
|
+
return bool(self.ignored_many([path]))
|
|
55
|
+
|
|
56
|
+
def ignored_many(self, paths):
|
|
57
|
+
"""Check which paths Git considers ignored."""
|
|
58
|
+
if not self.enabled or self.repo_root is None or self.matcher is None:
|
|
59
|
+
return set()
|
|
60
|
+
relative_paths = []
|
|
61
|
+
matcher_paths = []
|
|
62
|
+
for path in paths:
|
|
63
|
+
try:
|
|
64
|
+
relative = path.resolve().relative_to(self.repo_root)
|
|
65
|
+
except (ValueError, OSError):
|
|
66
|
+
continue
|
|
67
|
+
relative_paths.append(relative.as_posix())
|
|
68
|
+
matcher_paths.append((relative.as_posix(), path.is_dir()))
|
|
69
|
+
if not matcher_paths:
|
|
70
|
+
return set()
|
|
71
|
+
results = self.matcher.ignored_many(matcher_paths)
|
|
72
|
+
return {
|
|
73
|
+
relative
|
|
74
|
+
for relative, ignored in zip(relative_paths, results, strict=True)
|
|
75
|
+
if ignored
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _format_size(size):
|
|
80
|
+
"""Format bytes as a human-readable size."""
|
|
81
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
82
|
+
for unit in units:
|
|
83
|
+
if size < 1024:
|
|
84
|
+
if unit == "B":
|
|
85
|
+
return f"{size} B"
|
|
86
|
+
return f"{size:.1f} {unit}"
|
|
87
|
+
size /= 1024
|
|
88
|
+
# Anything above 1 TB is shown in TB
|
|
89
|
+
return f"{size:.1f} TB"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_modified(timestamp):
|
|
93
|
+
"""Format a modification timestamp."""
|
|
94
|
+
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_binary_file(path, chunk_size=8192):
|
|
98
|
+
"""Check if the file appears to be binary."""
|
|
99
|
+
try:
|
|
100
|
+
with path.open("rb") as f:
|
|
101
|
+
chunk = f.read(chunk_size)
|
|
102
|
+
if not chunk:
|
|
103
|
+
return False
|
|
104
|
+
# Check for NUL bytes
|
|
105
|
+
if b"\x00" in chunk:
|
|
106
|
+
return True
|
|
107
|
+
# Treat files that aren't valid UTF-8 as binary
|
|
108
|
+
try:
|
|
109
|
+
chunk.decode("utf-8")
|
|
110
|
+
except UnicodeDecodeError:
|
|
111
|
+
return True
|
|
112
|
+
else:
|
|
113
|
+
return False
|
|
114
|
+
except OSError:
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _count_lines(path):
|
|
119
|
+
"""Count lines without loading the entire file into memory."""
|
|
120
|
+
try:
|
|
121
|
+
with path.open("rb") as f:
|
|
122
|
+
return sum(1 for _ in f)
|
|
123
|
+
except OSError:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _file_metadata(path):
|
|
128
|
+
"""Get file metadata."""
|
|
129
|
+
try:
|
|
130
|
+
stat = path.stat()
|
|
131
|
+
size = stat.st_size
|
|
132
|
+
modified = _format_modified(stat.st_mtime)
|
|
133
|
+
except OSError:
|
|
134
|
+
return "?", "[unreadable]", 0, None
|
|
135
|
+
size_text = _format_size(size)
|
|
136
|
+
if _is_binary_file(path):
|
|
137
|
+
return size_text, "[binary]", size, modified
|
|
138
|
+
lines = _count_lines(path)
|
|
139
|
+
if lines is None:
|
|
140
|
+
return size_text, "[unreadable]", size, modified
|
|
141
|
+
return size_text, f"{lines:,} lines", size, modified
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _get_tree_entries(path, gitignore):
|
|
145
|
+
"""Get entries to include in the tree."""
|
|
146
|
+
try:
|
|
147
|
+
entries = list(path.iterdir())
|
|
148
|
+
except PermissionError:
|
|
149
|
+
return None
|
|
150
|
+
entries = [entry for entry in entries if not entry.is_symlink()]
|
|
151
|
+
if gitignore is not None:
|
|
152
|
+
entries = [entry for entry in entries if entry.name != ".git"]
|
|
153
|
+
if gitignore.enabled and gitignore.repo_root is not None:
|
|
154
|
+
ignored = gitignore.ignored_many(entries)
|
|
155
|
+
filtered_entries = []
|
|
156
|
+
for entry in entries:
|
|
157
|
+
try:
|
|
158
|
+
relative = entry.resolve().relative_to(gitignore.repo_root)
|
|
159
|
+
except (ValueError, OSError):
|
|
160
|
+
filtered_entries.append(entry)
|
|
161
|
+
continue
|
|
162
|
+
if relative.as_posix() not in ignored:
|
|
163
|
+
filtered_entries.append(entry)
|
|
164
|
+
entries = filtered_entries
|
|
165
|
+
entries.sort(key=lambda p: (p.is_file(), p.name.casefold()))
|
|
166
|
+
return entries
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _matches_pattern(path, pattern):
|
|
170
|
+
"""Match a relative path using a case-sensitive glob."""
|
|
171
|
+
return pattern is not None and fnmatch.fnmatchcase(path, pattern)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _scan_tree(
|
|
175
|
+
path,
|
|
176
|
+
gitignore,
|
|
177
|
+
show_metadata,
|
|
178
|
+
show_modified,
|
|
179
|
+
include_pattern=None,
|
|
180
|
+
exclude_pattern=None,
|
|
181
|
+
include_regex=None,
|
|
182
|
+
exclude_regex=None,
|
|
183
|
+
max_depth=None,
|
|
184
|
+
depth=0,
|
|
185
|
+
root=None,
|
|
186
|
+
):
|
|
187
|
+
"""Scan and filter the tree, returning nodes for rendering.
|
|
188
|
+
|
|
189
|
+
The starting directory has depth 0. Its immediate entries have
|
|
190
|
+
depth 1. Directories at the depth limit are shown without
|
|
191
|
+
descending into them. Include/exclude patterns and regular
|
|
192
|
+
expressions match paths relative to the starting directory.
|
|
193
|
+
Directories are retained only when they contain matching files.
|
|
194
|
+
Directory sizes include only files within the permitted depth
|
|
195
|
+
that pass the include/exclude filters.
|
|
196
|
+
"""
|
|
197
|
+
path = path.resolve()
|
|
198
|
+
if root is None:
|
|
199
|
+
root = path
|
|
200
|
+
if max_depth is not None and depth >= max_depth:
|
|
201
|
+
return []
|
|
202
|
+
entries = _get_tree_entries(path, gitignore)
|
|
203
|
+
if entries is None:
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
"name": "[permission denied]",
|
|
207
|
+
"kind": "error",
|
|
208
|
+
"children": [],
|
|
209
|
+
"size": 0,
|
|
210
|
+
"info": None,
|
|
211
|
+
"modified": None,
|
|
212
|
+
"contains_match": False,
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
nodes = []
|
|
216
|
+
has_include = include_pattern is not None or include_regex is not None
|
|
217
|
+
for entry in entries:
|
|
218
|
+
relative_path = entry.relative_to(root).as_posix()
|
|
219
|
+
# Exclusions take precedence over inclusions.
|
|
220
|
+
if _matches_pattern(relative_path, exclude_pattern):
|
|
221
|
+
continue
|
|
222
|
+
if exclude_regex is not None and exclude_regex.search(relative_path):
|
|
223
|
+
continue
|
|
224
|
+
if entry.is_dir():
|
|
225
|
+
entry_depth = depth + 1
|
|
226
|
+
if max_depth is not None and entry_depth >= max_depth:
|
|
227
|
+
children = []
|
|
228
|
+
else:
|
|
229
|
+
children = _scan_tree(
|
|
230
|
+
entry,
|
|
231
|
+
gitignore,
|
|
232
|
+
show_metadata,
|
|
233
|
+
show_modified,
|
|
234
|
+
include_pattern,
|
|
235
|
+
exclude_pattern,
|
|
236
|
+
include_regex,
|
|
237
|
+
exclude_regex,
|
|
238
|
+
max_depth,
|
|
239
|
+
entry_depth,
|
|
240
|
+
root,
|
|
241
|
+
)
|
|
242
|
+
contains_match = any(child["contains_match"] for child in children)
|
|
243
|
+
if has_include and not contains_match:
|
|
244
|
+
continue
|
|
245
|
+
size = (
|
|
246
|
+
sum(child["size"] or 0 for child in children) if show_metadata else None
|
|
247
|
+
)
|
|
248
|
+
nodes.append(
|
|
249
|
+
{
|
|
250
|
+
"name": entry.name,
|
|
251
|
+
"kind": "directory",
|
|
252
|
+
"children": children,
|
|
253
|
+
"size": size,
|
|
254
|
+
"info": None,
|
|
255
|
+
"modified": None,
|
|
256
|
+
"contains_match": contains_match,
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
continue
|
|
260
|
+
if not entry.is_file():
|
|
261
|
+
continue
|
|
262
|
+
if include_pattern is not None and not _matches_pattern(
|
|
263
|
+
relative_path,
|
|
264
|
+
include_pattern,
|
|
265
|
+
):
|
|
266
|
+
continue
|
|
267
|
+
if include_regex is not None and not include_regex.search(relative_path):
|
|
268
|
+
continue
|
|
269
|
+
if show_metadata:
|
|
270
|
+
size_text, info, size, modified = _file_metadata(entry)
|
|
271
|
+
else:
|
|
272
|
+
size_text, info, size, modified = None, None, None, None
|
|
273
|
+
nodes.append(
|
|
274
|
+
{
|
|
275
|
+
"name": entry.name,
|
|
276
|
+
"kind": "file",
|
|
277
|
+
"children": [],
|
|
278
|
+
"size": size,
|
|
279
|
+
"size_text": size_text,
|
|
280
|
+
"info": info,
|
|
281
|
+
"modified": modified if show_modified else None,
|
|
282
|
+
"contains_match": True,
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
return nodes
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _collect_stats(nodes, stats):
|
|
289
|
+
"""Add node counts and sizes to the summary statistics."""
|
|
290
|
+
for node in nodes:
|
|
291
|
+
if node["kind"] == "directory":
|
|
292
|
+
stats["directories"] += 1
|
|
293
|
+
_collect_stats(node["children"], stats)
|
|
294
|
+
elif node["kind"] == "file":
|
|
295
|
+
stats["files"] += 1
|
|
296
|
+
if stats["total_size"] is not None:
|
|
297
|
+
stats["total_size"] += node["size"]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _render_tree(nodes, prefix, rows, show_metadata, show_files):
|
|
301
|
+
"""Render nodes as tree rows."""
|
|
302
|
+
visible = [node for node in nodes if show_files or node["kind"] != "file"]
|
|
303
|
+
for index, node in enumerate(visible):
|
|
304
|
+
is_last = index == len(visible) - 1
|
|
305
|
+
connector = "└── " if is_last else "├── "
|
|
306
|
+
tree_name = prefix + connector + node["name"]
|
|
307
|
+
if node["kind"] == "error":
|
|
308
|
+
rows.append((tree_name, None))
|
|
309
|
+
continue
|
|
310
|
+
if node["kind"] == "directory":
|
|
311
|
+
metadata = (
|
|
312
|
+
(_format_size(node["size"]), None, None) if show_metadata else None
|
|
313
|
+
)
|
|
314
|
+
rows.append((tree_name, metadata))
|
|
315
|
+
extension = " " if is_last else "│ "
|
|
316
|
+
_render_tree(
|
|
317
|
+
node["children"],
|
|
318
|
+
prefix + extension,
|
|
319
|
+
rows,
|
|
320
|
+
show_metadata,
|
|
321
|
+
show_files,
|
|
322
|
+
)
|
|
323
|
+
elif show_files:
|
|
324
|
+
metadata = None
|
|
325
|
+
if show_metadata:
|
|
326
|
+
metadata = (
|
|
327
|
+
node["size_text"],
|
|
328
|
+
node["info"],
|
|
329
|
+
node["modified"],
|
|
330
|
+
)
|
|
331
|
+
rows.append((tree_name, metadata))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _collect_tree(
|
|
335
|
+
path,
|
|
336
|
+
prefix,
|
|
337
|
+
stats,
|
|
338
|
+
gitignore,
|
|
339
|
+
rows,
|
|
340
|
+
show_metadata,
|
|
341
|
+
show_modified,
|
|
342
|
+
show_files,
|
|
343
|
+
include_pattern=None,
|
|
344
|
+
exclude_pattern=None,
|
|
345
|
+
include_regex=None,
|
|
346
|
+
exclude_regex=None,
|
|
347
|
+
max_depth=None,
|
|
348
|
+
):
|
|
349
|
+
"""Collect filtered tree rows and update summary statistics."""
|
|
350
|
+
if not show_metadata:
|
|
351
|
+
stats["total_size"] = None
|
|
352
|
+
nodes = _scan_tree(
|
|
353
|
+
Path(path),
|
|
354
|
+
gitignore,
|
|
355
|
+
show_metadata,
|
|
356
|
+
show_modified,
|
|
357
|
+
include_pattern,
|
|
358
|
+
exclude_pattern,
|
|
359
|
+
include_regex,
|
|
360
|
+
exclude_regex,
|
|
361
|
+
max_depth,
|
|
362
|
+
)
|
|
363
|
+
_collect_stats(nodes, stats)
|
|
364
|
+
_render_tree(nodes, prefix, rows, show_metadata, show_files)
|
|
365
|
+
return sum(node["size"] or 0 for node in nodes)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def print_tree(
|
|
369
|
+
directory,
|
|
370
|
+
prefix="",
|
|
371
|
+
stats=None,
|
|
372
|
+
gitignore=None,
|
|
373
|
+
show_metadata=True,
|
|
374
|
+
show_modified=False,
|
|
375
|
+
show_tree=True,
|
|
376
|
+
show_files=True,
|
|
377
|
+
include_pattern=None,
|
|
378
|
+
exclude_pattern=None,
|
|
379
|
+
include_regex=None,
|
|
380
|
+
exclude_regex=None,
|
|
381
|
+
max_depth=None,
|
|
382
|
+
):
|
|
383
|
+
"""Print the directory tree with aligned metadata."""
|
|
384
|
+
if stats is None:
|
|
385
|
+
stats = {"directories": 0, "files": 0, "total_size": 0}
|
|
386
|
+
rows = []
|
|
387
|
+
_collect_tree(
|
|
388
|
+
Path(directory),
|
|
389
|
+
prefix,
|
|
390
|
+
stats,
|
|
391
|
+
gitignore,
|
|
392
|
+
rows,
|
|
393
|
+
show_metadata,
|
|
394
|
+
show_modified,
|
|
395
|
+
show_files,
|
|
396
|
+
include_pattern,
|
|
397
|
+
exclude_pattern,
|
|
398
|
+
include_regex,
|
|
399
|
+
exclude_regex,
|
|
400
|
+
max_depth,
|
|
401
|
+
)
|
|
402
|
+
if show_tree:
|
|
403
|
+
# Align metadata with the longest tree/name
|
|
404
|
+
max_width = max((len(tree_name) for tree_name, _ in rows), default=0)
|
|
405
|
+
for tree_name, metadata in rows:
|
|
406
|
+
if metadata is None:
|
|
407
|
+
print(tree_name)
|
|
408
|
+
continue
|
|
409
|
+
size_text, info, modified = metadata
|
|
410
|
+
output = f"{tree_name:<{max_width}}{size_text:>10}"
|
|
411
|
+
if info is not None:
|
|
412
|
+
output += f" {info:<12}"
|
|
413
|
+
if modified is not None:
|
|
414
|
+
output += f" {modified}"
|
|
415
|
+
print(output)
|
|
416
|
+
return stats
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def print_summary(stats):
|
|
420
|
+
"""Print a summary of directory/file counts and total size."""
|
|
421
|
+
directories = stats["directories"]
|
|
422
|
+
files = stats["files"]
|
|
423
|
+
total_size = stats["total_size"]
|
|
424
|
+
directory_label = "directory" if directories == 1 else "directories"
|
|
425
|
+
file_label = "file" if files == 1 else "files"
|
|
426
|
+
summary = f"{directories:,} {directory_label} • {files:,} {file_label}"
|
|
427
|
+
if total_size is not None:
|
|
428
|
+
summary += f" • {_format_size(total_size)}"
|
|
429
|
+
print(summary)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _positive_int(value):
|
|
433
|
+
"""Parse a positive integer."""
|
|
434
|
+
try:
|
|
435
|
+
number = int(value)
|
|
436
|
+
except ValueError:
|
|
437
|
+
msg = "must be a positive integer"
|
|
438
|
+
raise argparse.ArgumentTypeError(msg) from None
|
|
439
|
+
if number < 1:
|
|
440
|
+
msg = "must be at least 1"
|
|
441
|
+
raise argparse.ArgumentTypeError(msg)
|
|
442
|
+
return number
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def parse_args(argv=None):
|
|
446
|
+
"""Parse command-line arguments."""
|
|
447
|
+
|
|
448
|
+
def formatter(prog):
|
|
449
|
+
return argparse.HelpFormatter(
|
|
450
|
+
prog,
|
|
451
|
+
width=80,
|
|
452
|
+
max_help_position=32,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
parser = argparse.ArgumentParser(
|
|
456
|
+
description="List directory contents as a tree "
|
|
457
|
+
"with file and directory metadata.",
|
|
458
|
+
formatter_class=formatter,
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
"directory",
|
|
462
|
+
nargs="?",
|
|
463
|
+
default=Path(),
|
|
464
|
+
type=Path,
|
|
465
|
+
help="directory to scan (default: current directory)",
|
|
466
|
+
)
|
|
467
|
+
parser.add_argument(
|
|
468
|
+
"-V",
|
|
469
|
+
"--version",
|
|
470
|
+
action="version",
|
|
471
|
+
version=(f"%(prog)s {__version__} "),
|
|
472
|
+
help="show version information and exit",
|
|
473
|
+
)
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
"-a",
|
|
476
|
+
"--all",
|
|
477
|
+
action="store_true",
|
|
478
|
+
help="show all files, including those ignored by Git",
|
|
479
|
+
)
|
|
480
|
+
parser.add_argument(
|
|
481
|
+
"-m",
|
|
482
|
+
"--modified",
|
|
483
|
+
action="store_true",
|
|
484
|
+
help="show file modification times",
|
|
485
|
+
)
|
|
486
|
+
parser.add_argument(
|
|
487
|
+
"-q",
|
|
488
|
+
"--quiet",
|
|
489
|
+
action="store_true",
|
|
490
|
+
help="don't show file metadata",
|
|
491
|
+
)
|
|
492
|
+
parser.add_argument(
|
|
493
|
+
"-d",
|
|
494
|
+
"--dirs",
|
|
495
|
+
action="store_true",
|
|
496
|
+
help="show directories only",
|
|
497
|
+
)
|
|
498
|
+
parser.add_argument(
|
|
499
|
+
"-s",
|
|
500
|
+
"--summary",
|
|
501
|
+
action="store_true",
|
|
502
|
+
help="show summary only",
|
|
503
|
+
)
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
"-i",
|
|
506
|
+
"--include",
|
|
507
|
+
metavar="PATTERN",
|
|
508
|
+
help="include paths matching a glob pattern",
|
|
509
|
+
)
|
|
510
|
+
parser.add_argument(
|
|
511
|
+
"-e",
|
|
512
|
+
"--exclude",
|
|
513
|
+
metavar="PATTERN",
|
|
514
|
+
help="exclude paths matching a glob pattern",
|
|
515
|
+
)
|
|
516
|
+
parser.add_argument(
|
|
517
|
+
"-I",
|
|
518
|
+
"--include-regex",
|
|
519
|
+
metavar="PATTERN",
|
|
520
|
+
help="include paths matching a regular expression",
|
|
521
|
+
)
|
|
522
|
+
parser.add_argument(
|
|
523
|
+
"-E",
|
|
524
|
+
"--exclude-regex",
|
|
525
|
+
metavar="PATTERN",
|
|
526
|
+
help="exclude paths matching a regular expression",
|
|
527
|
+
)
|
|
528
|
+
parser.add_argument(
|
|
529
|
+
"-D",
|
|
530
|
+
"--depth",
|
|
531
|
+
type=_positive_int,
|
|
532
|
+
metavar="DEPTH",
|
|
533
|
+
help="limit levels displayed below starting directory",
|
|
534
|
+
)
|
|
535
|
+
return parser.parse_args(argv)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def main():
|
|
539
|
+
try:
|
|
540
|
+
args = parse_args()
|
|
541
|
+
path = args.directory
|
|
542
|
+
if not path.is_dir():
|
|
543
|
+
print(f"Not a directory: {path}", file=sys.stderr)
|
|
544
|
+
return 1
|
|
545
|
+
try:
|
|
546
|
+
include_regex = (
|
|
547
|
+
re.compile(args.include_regex)
|
|
548
|
+
if args.include_regex is not None
|
|
549
|
+
else None
|
|
550
|
+
)
|
|
551
|
+
exclude_regex = (
|
|
552
|
+
re.compile(args.exclude_regex)
|
|
553
|
+
if args.exclude_regex is not None
|
|
554
|
+
else None
|
|
555
|
+
)
|
|
556
|
+
except re.error as exc:
|
|
557
|
+
print(f"Invalid regular expression: {exc}", file=sys.stderr)
|
|
558
|
+
return 2
|
|
559
|
+
# Only initialize Git support if it will be used
|
|
560
|
+
gitignore = None if args.all else GitIgnore(path)
|
|
561
|
+
show_tree = not args.summary
|
|
562
|
+
show_metadata = not args.quiet
|
|
563
|
+
show_files = not args.dirs
|
|
564
|
+
if show_tree:
|
|
565
|
+
print(path)
|
|
566
|
+
stats = print_tree(
|
|
567
|
+
path,
|
|
568
|
+
gitignore=gitignore,
|
|
569
|
+
show_metadata=show_metadata,
|
|
570
|
+
show_modified=args.modified,
|
|
571
|
+
show_tree=show_tree,
|
|
572
|
+
show_files=show_files,
|
|
573
|
+
include_pattern=args.include,
|
|
574
|
+
exclude_pattern=args.exclude,
|
|
575
|
+
include_regex=include_regex,
|
|
576
|
+
exclude_regex=exclude_regex,
|
|
577
|
+
max_depth=args.depth,
|
|
578
|
+
)
|
|
579
|
+
if show_tree:
|
|
580
|
+
print()
|
|
581
|
+
print_summary(stats)
|
|
582
|
+
except KeyboardInterrupt:
|
|
583
|
+
print("\nInterrupted", file=sys.stderr)
|
|
584
|
+
return 130
|
|
585
|
+
else:
|
|
586
|
+
return 0
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
if __name__ == "__main__":
|
|
590
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: treex-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Command-line directory tree utility with metadata and Git ignore support
|
|
5
|
+
Author: Corey Goldberg
|
|
6
|
+
Maintainer: Corey Goldberg
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: homepage, https://github.com/cgoldberg/treex
|
|
9
|
+
Project-URL: repository, https://github.com/cgoldberg/treex
|
|
10
|
+
Keywords: tree,cli,directories,files,git,gitignore,gitoxide,metadata,python,rust
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
18
|
+
Classifier: Programming Language :: Rust
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# treex
|
|
25
|
+
|
|
26
|
+
## Git-aware directory tree utility
|
|
27
|
+
|
|
28
|
+
Command-line directory tree utility with metadata and Git ignore support.
|
|
29
|
+
|
|
30
|
+
- **Development:** [GitHub][github-repo]
|
|
31
|
+
- **Releases:** [PyPI][pypi-home]
|
|
32
|
+
- **License:** [MIT][mit-license]
|
|
33
|
+
- **Copyright:** © 2026 [Corey Goldberg][github-profile]
|
|
34
|
+
|
|
35
|
+
----
|
|
36
|
+
|
|
37
|
+
## About
|
|
38
|
+
|
|
39
|
+
`treex` recursively scans a directory and displays its contents as a tree,
|
|
40
|
+
including file and directory metadata. It respects `.gitignore` rules inside Git
|
|
41
|
+
repositories and provides additional options for filtering and customizing the
|
|
42
|
+
output.
|
|
43
|
+
|
|
44
|
+
### Features
|
|
45
|
+
|
|
46
|
+
- Respects `.gitignore` rules inside Git repositories
|
|
47
|
+
- Displays human-readable file sizes
|
|
48
|
+
- Displays recursive directory sizes
|
|
49
|
+
- Displays line counts for text files
|
|
50
|
+
- Identifies binary and unreadable files by type
|
|
51
|
+
- Optionally displays modification timestamps
|
|
52
|
+
- Optionally includes or excludes paths using glob patterns
|
|
53
|
+
- Optionally includes or excludes paths using regular expressions
|
|
54
|
+
- Optionally limits the depth of the displayed tree
|
|
55
|
+
|
|
56
|
+
----
|
|
57
|
+
|
|
58
|
+
## Status
|
|
59
|
+
|
|
60
|
+
<table>
|
|
61
|
+
<tr>
|
|
62
|
+
<td>Latest Version</td>
|
|
63
|
+
<td>
|
|
64
|
+
<a href="https://pypi.org/project/treex-cli">
|
|
65
|
+
<img src="https://img.shields.io/pypi/v/treex-cli.svg">
|
|
66
|
+
</a>
|
|
67
|
+
</td>
|
|
68
|
+
</tr>
|
|
69
|
+
<tr>
|
|
70
|
+
<td>Tests</td>
|
|
71
|
+
<td>
|
|
72
|
+
<a href="https://github.com/cgoldberg/treex/actions/workflows/test.yml">
|
|
73
|
+
<img src="https://github.com/cgoldberg/treex/actions/workflows/test.yml/badge.svg">
|
|
74
|
+
</a>
|
|
75
|
+
</td>
|
|
76
|
+
</tr>
|
|
77
|
+
<tr>
|
|
78
|
+
<td>Supported Python Versions</td>
|
|
79
|
+
<td>
|
|
80
|
+
<a href="https://pypi.org/project/treex-cli">
|
|
81
|
+
<img src="https://img.shields.io/pypi/pyversions/treex-cli">
|
|
82
|
+
</a>
|
|
83
|
+
</td>
|
|
84
|
+
</tr>
|
|
85
|
+
</table>
|
|
86
|
+
|
|
87
|
+
----
|
|
88
|
+
|
|
89
|
+
## Requirements
|
|
90
|
+
|
|
91
|
+
- Python 3.13+
|
|
92
|
+
- UTF-8-compatible terminal
|
|
93
|
+
|
|
94
|
+
----
|
|
95
|
+
|
|
96
|
+
## Installation
|
|
97
|
+
|
|
98
|
+
Install `treex` from [PyPI][pypi-home] using your preferred Python package
|
|
99
|
+
manager:
|
|
100
|
+
|
|
101
|
+
- **pip**: `pip install treex-cli`
|
|
102
|
+
- **pipx**: `pipx install treex-cli`
|
|
103
|
+
- **uv**: `uv tool install treex-cli`
|
|
104
|
+
|
|
105
|
+
Prebuilt wheels are available for common platforms. See
|
|
106
|
+
[Platform Support](#platform-support) for details.
|
|
107
|
+
|
|
108
|
+
----
|
|
109
|
+
|
|
110
|
+
## Git ignore support
|
|
111
|
+
|
|
112
|
+
`treex` uses [Gitoxide][gitoxide-home] (`gix`), a native Rust implementation of
|
|
113
|
+
Git functionality, to evaluate `.gitignore` rules without invoking the Git
|
|
114
|
+
executable. Git does not need to be installed for ignore matching to work.
|
|
115
|
+
|
|
116
|
+
When the specified directory is inside a Git working tree, `treex` respects
|
|
117
|
+
applicable `.gitignore` rules by default. Outside a Git working tree, Git ignore
|
|
118
|
+
filtering is disabled. Use `--all` to include files ignored by Git.
|
|
119
|
+
|
|
120
|
+
----
|
|
121
|
+
|
|
122
|
+
## Usage
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
usage: treex [-h] [-V] [-a] [-m] [-q] [-d] [-s] [-i PATTERN] [-e PATTERN]
|
|
126
|
+
[-I PATTERN] [-E PATTERN] [-D DEPTH]
|
|
127
|
+
[directory]
|
|
128
|
+
|
|
129
|
+
List directory contents as a tree with file and directory metadata.
|
|
130
|
+
|
|
131
|
+
positional arguments:
|
|
132
|
+
directory directory to scan (default: current directory)
|
|
133
|
+
|
|
134
|
+
options:
|
|
135
|
+
-h, --help show this help message and exit
|
|
136
|
+
-V, --version show version information and exit
|
|
137
|
+
-a, --all show all files, including those ignored by Git
|
|
138
|
+
-m, --modified show file modification times
|
|
139
|
+
-q, --quiet don't show file metadata
|
|
140
|
+
-d, --dirs show directories only
|
|
141
|
+
-s, --summary show summary only
|
|
142
|
+
-i, --include PATTERN include paths matching a glob pattern
|
|
143
|
+
-e, --exclude PATTERN exclude paths matching a glob pattern
|
|
144
|
+
-I, --include-regex PATTERN include paths matching a regular expression
|
|
145
|
+
-E, --exclude-regex PATTERN exclude paths matching a regular expression
|
|
146
|
+
-D, --depth DEPTH limit levels displayed below starting directory
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Filtering paths
|
|
150
|
+
|
|
151
|
+
Use `--include` and `--exclude` to filter paths by glob pattern:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
treex --include '*.py'
|
|
155
|
+
treex --exclude 'tests/*.py'
|
|
156
|
+
treex --include '*.py' --exclude 'test_*.py'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Use `--include-regex` and `--exclude-regex` to filter paths by regular
|
|
160
|
+
expression:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
treex --include-regex '^src/'
|
|
164
|
+
treex --exclude-regex '(^|/)tests/'
|
|
165
|
+
treex --include-regex '\.py$' --exclude-regex '(^|/)test_[^/]*\.py$'
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Patterns match paths relative to the starting directory, using forward slashes
|
|
169
|
+
as separators. Matching is case-sensitive. Glob patterns use `*` to match any
|
|
170
|
+
characters, including directory separators. Regular expressions use Python's
|
|
171
|
+
regex syntax. Exclusions take precedence over inclusions.
|
|
172
|
+
|
|
173
|
+
Note: quote glob and regex patterns to prevent the shell from interpreting them
|
|
174
|
+
before `treex` receives them.
|
|
175
|
+
|
|
176
|
+
----
|
|
177
|
+
|
|
178
|
+
## Screenshot
|
|
179
|
+
|
|
180
|
+

|
|
181
|
+
|
|
182
|
+
## Platform support
|
|
183
|
+
|
|
184
|
+
Prebuilt wheels are available for Python 3.13+ on the following platforms:
|
|
185
|
+
|
|
186
|
+
| Platform | Architecture |
|
|
187
|
+
| ------------- | ----------------------------- |
|
|
188
|
+
| Linux (glibc) | x86_64, aarch64 |
|
|
189
|
+
| Linux (musl) | x86_64, aarch64 |
|
|
190
|
+
| macOS | Apple Silicon (ARM64) |
|
|
191
|
+
| Windows | 32-bit (x86), 64-bit (x86_64) |
|
|
192
|
+
|
|
193
|
+
These prebuilt [binary wheels][python-wheels] let you install the `treex-cli`
|
|
194
|
+
package without compiling the Rust extension yourself.
|
|
195
|
+
|
|
196
|
+
### Installing on other platforms
|
|
197
|
+
|
|
198
|
+
If a prebuilt binary is not available for your platform, you can install the
|
|
199
|
+
package from the [source distribution on PyPI][pypi-files] or directly from the
|
|
200
|
+
[source repository][github-repo]. This requires a Rust toolchain.
|
|
201
|
+
|
|
202
|
+
For more information about installing Rust, see the
|
|
203
|
+
[official Rust installation guide][rust-install]
|
|
204
|
+
|
|
205
|
+
[github-profile]: https://github.com/cgoldberg
|
|
206
|
+
[github-repo]: https://github.com/cgoldberg/treex
|
|
207
|
+
[gitoxide-home]: https://rustutils.com/tools/gitoxide
|
|
208
|
+
[mit-license]: https://raw.githubusercontent.com/cgoldberg/treex/refs/heads/main/LICENSE
|
|
209
|
+
[pypi-files]: https://pypi.org/project/treex-cli/#files
|
|
210
|
+
[pypi-home]: https://pypi.org/project/treex-cli
|
|
211
|
+
[python-wheels]: https://packaging.python.org/en/latest/specifications/binary-distribution-format
|
|
212
|
+
[rust-install]: https://www.rust-lang.org/tools/install
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
treex/__init__.py,sha256=Bm3KzqFFQlDEqB02-g_PJumVWHMnYFxon1D4j7Glw6A,69
|
|
2
|
+
treex/_native.pyd,sha256=VuFtEzG35JtgFwR5zL84ZG0WE4gcf4BkKjVPU9Zg6QI,2063360
|
|
3
|
+
treex/treex.py,sha256=ifdeEOraXR81D4Q3vYj1oorxcxZq0EAejrTdyvziDmE,18361
|
|
4
|
+
treex_cli-0.2.0.dist-info/licenses/LICENSE,sha256=wR0s0UvmKKWAHQx2MsbQ1SVq7wYoyBlj9G13YiEBtYU,1092
|
|
5
|
+
treex_cli-0.2.0.dist-info/METADATA,sha256=p5q2gt3ZvYe0mjY4t8cZsezC_qHYxZvOypYqOG-BoXg,7122
|
|
6
|
+
treex_cli-0.2.0.dist-info/WHEEL,sha256=UDpYo6hQfdddzU3J0xB_VgXoPsit6UeN_XKOQCX2bW8,100
|
|
7
|
+
treex_cli-0.2.0.dist-info/entry_points.txt,sha256=O25Pgu70GDYn0X_bgwlRnhCljpzrjLhodZcdOLmsSlk,43
|
|
8
|
+
treex_cli-0.2.0.dist-info/top_level.txt,sha256=Q2lsPiM6enBh4T-ZtRWruXPJPoyJTORjjQNeJtEl-I8,6
|
|
9
|
+
treex_cli-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Corey Goldberg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
treex
|