treex-cli 0.1.0__py3-none-any.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.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Copyright (c) 2026 Corey Goldberg
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
"""Print a directory tree with file metadata.
|
|
6
|
+
|
|
7
|
+
The directory tree is rendered using Unicode box-drawing characters. File names are
|
|
8
|
+
displayed with their human-readable size and, optionally, modification time. Text
|
|
9
|
+
files also include their line count. Binary and unreadable files are marked with
|
|
10
|
+
their type.
|
|
11
|
+
|
|
12
|
+
When run inside a Git repository, files and directories ignored by Git
|
|
13
|
+
are automatically excluded using Git's own ignore rules. If Git is not
|
|
14
|
+
installed, or the directory is not part of a Git repository, the
|
|
15
|
+
filesystem is scanned normally without Git filtering.
|
|
16
|
+
|
|
17
|
+
Use --all to disable Git ignore filtering and show all files.
|
|
18
|
+
Use --width to control the column at which file metadata starts.
|
|
19
|
+
Use --modified to show file modification times.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import subprocess
|
|
24
|
+
from contextlib import suppress
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
DEFAULT_METADATA_COLUMN = 50
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GitIgnore:
|
|
32
|
+
"""Use Git's own ignore machinery when available."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, directory):
|
|
35
|
+
self.directory = Path(directory).resolve()
|
|
36
|
+
self.enabled = False
|
|
37
|
+
self.repo_root = None
|
|
38
|
+
# Git isn't installed or can't be executed.
|
|
39
|
+
with suppress(OSError):
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
42
|
+
cwd=self.directory,
|
|
43
|
+
stdout=subprocess.PIPE,
|
|
44
|
+
stderr=subprocess.DEVNULL,
|
|
45
|
+
text=True,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
if result.returncode == 0:
|
|
49
|
+
self.repo_root = Path(result.stdout.strip()).resolve()
|
|
50
|
+
self.enabled = True
|
|
51
|
+
|
|
52
|
+
def ignored(self, path):
|
|
53
|
+
"""Return True if Git considers the path ignored."""
|
|
54
|
+
if not self.enabled:
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
relative_path = path.resolve().relative_to(self.repo_root)
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["git", "check-ignore", "--quiet", "--", str(relative_path)],
|
|
60
|
+
cwd=self.repo_root,
|
|
61
|
+
stdout=subprocess.DEVNULL,
|
|
62
|
+
stderr=subprocess.DEVNULL,
|
|
63
|
+
check=False,
|
|
64
|
+
)
|
|
65
|
+
except (ValueError, OSError):
|
|
66
|
+
return False
|
|
67
|
+
else:
|
|
68
|
+
return result.returncode == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_size(size): # noqa: RET503
|
|
72
|
+
"""Format bytes as a human-readable size."""
|
|
73
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
74
|
+
for unit in units:
|
|
75
|
+
if size < 1024 or unit == units[-1]:
|
|
76
|
+
if unit == "B":
|
|
77
|
+
return f"{size} B"
|
|
78
|
+
return f"{size:.1f} {unit}"
|
|
79
|
+
size /= 1024
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_modified(timestamp):
|
|
83
|
+
"""Format a modification timestamp for display."""
|
|
84
|
+
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_binary_file(path, chunk_size=8192):
|
|
88
|
+
"""Return True if the file appears to be binary."""
|
|
89
|
+
try:
|
|
90
|
+
with path.open("rb") as f:
|
|
91
|
+
chunk = f.read(chunk_size)
|
|
92
|
+
if not chunk:
|
|
93
|
+
return False
|
|
94
|
+
# Check for NUL bytes.
|
|
95
|
+
if b"\x00" in chunk:
|
|
96
|
+
return True
|
|
97
|
+
# Treat files that aren't valid UTF-8 as binary.
|
|
98
|
+
try:
|
|
99
|
+
chunk.decode("utf-8")
|
|
100
|
+
except UnicodeDecodeError:
|
|
101
|
+
return True
|
|
102
|
+
else:
|
|
103
|
+
return False
|
|
104
|
+
except OSError:
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def count_lines(path):
|
|
109
|
+
"""Count lines without loading the entire file into memory."""
|
|
110
|
+
count = 0
|
|
111
|
+
try:
|
|
112
|
+
with path.open("rb") as f:
|
|
113
|
+
for _ in f:
|
|
114
|
+
count += 1
|
|
115
|
+
except OSError:
|
|
116
|
+
return None
|
|
117
|
+
return count
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def file_info(path):
|
|
121
|
+
"""Return size, description, raw size, and modification time."""
|
|
122
|
+
try:
|
|
123
|
+
stat = path.stat()
|
|
124
|
+
size = stat.st_size
|
|
125
|
+
modified = format_modified(stat.st_mtime)
|
|
126
|
+
except OSError:
|
|
127
|
+
return "?", "[unreadable]", 0, None
|
|
128
|
+
size_text = format_size(size)
|
|
129
|
+
if is_binary_file(path):
|
|
130
|
+
return size_text, "[binary]", size, modified
|
|
131
|
+
lines = count_lines(path)
|
|
132
|
+
if lines is None:
|
|
133
|
+
return size_text, "[unreadable]", size, modified
|
|
134
|
+
return size_text, f"{lines:,} lines", size, modified
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def print_tree(
|
|
138
|
+
directory,
|
|
139
|
+
prefix="",
|
|
140
|
+
stats=None,
|
|
141
|
+
gitignore=None,
|
|
142
|
+
width=DEFAULT_METADATA_COLUMN,
|
|
143
|
+
show_modified=False,
|
|
144
|
+
):
|
|
145
|
+
"""Recursively print the directory tree."""
|
|
146
|
+
if stats is None:
|
|
147
|
+
stats = {
|
|
148
|
+
"directories": 0,
|
|
149
|
+
"files": 0,
|
|
150
|
+
"total_size": 0,
|
|
151
|
+
}
|
|
152
|
+
path = Path(directory)
|
|
153
|
+
try:
|
|
154
|
+
entries = list(path.iterdir())
|
|
155
|
+
except PermissionError:
|
|
156
|
+
print(prefix + "└── [permission denied]")
|
|
157
|
+
return stats
|
|
158
|
+
# Filter ignored entries before determining which entry is last.
|
|
159
|
+
visible_entries = []
|
|
160
|
+
for entry in entries:
|
|
161
|
+
# Git's internal repository data is never useful in the tree output.
|
|
162
|
+
if entry.name == ".git":
|
|
163
|
+
continue
|
|
164
|
+
if gitignore and gitignore.ignored(entry):
|
|
165
|
+
continue
|
|
166
|
+
visible_entries.append(entry)
|
|
167
|
+
visible_entries.sort(key=lambda p: (p.is_file(), p.name.lower()))
|
|
168
|
+
for index, entry in enumerate(visible_entries):
|
|
169
|
+
is_last = index == len(visible_entries) - 1
|
|
170
|
+
connector = "└── " if is_last else "├── "
|
|
171
|
+
if entry.is_dir():
|
|
172
|
+
stats["directories"] += 1
|
|
173
|
+
print(prefix + connector + entry.name)
|
|
174
|
+
extension = " " if is_last else "│ "
|
|
175
|
+
print_tree(
|
|
176
|
+
entry,
|
|
177
|
+
prefix + extension,
|
|
178
|
+
stats,
|
|
179
|
+
gitignore,
|
|
180
|
+
width,
|
|
181
|
+
show_modified,
|
|
182
|
+
)
|
|
183
|
+
elif entry.is_file():
|
|
184
|
+
stats["files"] += 1
|
|
185
|
+
size, info, raw_size, modified = file_info(entry)
|
|
186
|
+
stats["total_size"] += raw_size
|
|
187
|
+
# The complete tree/name portion is padded to the
|
|
188
|
+
# requested width so metadata lines up vertically.
|
|
189
|
+
tree_name = prefix + connector + entry.name
|
|
190
|
+
output = f"{tree_name:<{width}}{size:>10} {info}"
|
|
191
|
+
if show_modified and modified is not None:
|
|
192
|
+
output += f" {modified}"
|
|
193
|
+
print(output)
|
|
194
|
+
return stats
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_args(argv=None):
|
|
198
|
+
"""Parse command-line arguments."""
|
|
199
|
+
parser = argparse.ArgumentParser(
|
|
200
|
+
description="print a directory tree with file metadata.",
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"directory",
|
|
204
|
+
nargs="?",
|
|
205
|
+
default=Path(),
|
|
206
|
+
type=Path,
|
|
207
|
+
help="directory to scan (default: current directory)",
|
|
208
|
+
)
|
|
209
|
+
parser.add_argument(
|
|
210
|
+
"-a",
|
|
211
|
+
"--all",
|
|
212
|
+
action="store_true",
|
|
213
|
+
help="show all files, including git-ignored",
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"-w",
|
|
217
|
+
"--width",
|
|
218
|
+
type=int,
|
|
219
|
+
default=DEFAULT_METADATA_COLUMN,
|
|
220
|
+
metavar="N",
|
|
221
|
+
help=f"starting column for file metadata (default: {DEFAULT_METADATA_COLUMN})",
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"-m",
|
|
225
|
+
"--modified",
|
|
226
|
+
action="store_true",
|
|
227
|
+
help="show file modification times",
|
|
228
|
+
)
|
|
229
|
+
return parser.parse_args(argv)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main():
|
|
233
|
+
args = parse_args()
|
|
234
|
+
path = args.directory
|
|
235
|
+
if not path.is_dir():
|
|
236
|
+
print(f"Not a directory: {path}")
|
|
237
|
+
return 1
|
|
238
|
+
if args.width < 1:
|
|
239
|
+
print("Width must be greater than zero.")
|
|
240
|
+
return 1
|
|
241
|
+
# Only initialize Git integration when it will actually be used.
|
|
242
|
+
gitignore = None if args.all else GitIgnore(path)
|
|
243
|
+
print(path.resolve())
|
|
244
|
+
stats = print_tree(
|
|
245
|
+
path, gitignore=gitignore, width=args.width, show_modified=args.modified
|
|
246
|
+
)
|
|
247
|
+
print(
|
|
248
|
+
f""
|
|
249
|
+
f""
|
|
250
|
+
f"{stats['directories']} directories, "
|
|
251
|
+
f"{stats['files']} files, "
|
|
252
|
+
f"{format_size(stats['total_size'])}"
|
|
253
|
+
)
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: treex-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Print a directory tree with file metadata
|
|
5
|
+
Author: Corey Goldberg
|
|
6
|
+
Maintainer: Corey Goldberg
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: homepage, https://github.com/cgoldberg/treex
|
|
9
|
+
Project-URL: source, https://github.com/cgoldberg/treex
|
|
10
|
+
Project-URL: download, https://pypi.org/project/treex-cli
|
|
11
|
+
Keywords: tree,directory,files,cli,metadata
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# treex
|
|
27
|
+
|
|
28
|
+
## Print a directory tree with file metadata
|
|
29
|
+
|
|
30
|
+
- Copyright (c) 2026 [Corey Goldberg][github-profile]
|
|
31
|
+
- Development: [GitHub][github-repo]
|
|
32
|
+
- Releases: [PyPI][pypi-home]
|
|
33
|
+
- License: [MIT][mit-license]
|
|
34
|
+
|
|
35
|
+
----
|
|
36
|
+
|
|
37
|
+
## About
|
|
38
|
+
|
|
39
|
+
`treex` is a command-line utility (similar to [tree][tree-home]) that recursively
|
|
40
|
+
scans a directory and prints its contents as a tree using Unicode box-drawing
|
|
41
|
+
characters. File names include their human-readable size and, optionally,
|
|
42
|
+
modification time. Text files also include their line count, while binary and
|
|
43
|
+
unreadable files are marked with their type. When Git is installed, it respects
|
|
44
|
+
`.gitignore` rules.
|
|
45
|
+
|
|
46
|
+
Requirements:
|
|
47
|
+
|
|
48
|
+
- Python 3.10+
|
|
49
|
+
- Git 2.0+ (optional)
|
|
50
|
+
- UTF-8-compatible terminal
|
|
51
|
+
|
|
52
|
+
----
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
Install `treex` from [PyPI][pypi-home] using either `pip` or `pipx`:
|
|
57
|
+
|
|
58
|
+
- `pip install treex-cli`
|
|
59
|
+
- `pipx install treex-cli`
|
|
60
|
+
|
|
61
|
+
----
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ treex --help
|
|
67
|
+
usage: treex [-h] [-a] [-w N] [-m] [directory]
|
|
68
|
+
|
|
69
|
+
print a directory tree with file metadata.
|
|
70
|
+
|
|
71
|
+
positional arguments:
|
|
72
|
+
directory directory to scan (default: current directory)
|
|
73
|
+
|
|
74
|
+
options:
|
|
75
|
+
-h, --help show this help message and exit
|
|
76
|
+
-a, --all show all files, including git-ignored
|
|
77
|
+
-w, --width N starting column for file metadata (default: 50)
|
|
78
|
+
-m, --modified show file modification times
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
[github-profile]: https://github.com/cgoldberg
|
|
82
|
+
[github-repo]: https://github.com/cgoldberg/treex
|
|
83
|
+
[pypi-home]: https://pypi.org/project/treex-cli
|
|
84
|
+
[mit-license]: https://raw.githubusercontent.com/cgoldberg/treex/refs/heads/main/LICENSE
|
|
85
|
+
[tree-home]: https://oldmanprogrammer.net/source.php?dir=projects/tree
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
treex.py,sha256=tij4JJROSG_1seIVUKbMJlHDu84-UTK_lNuD2zRmYE8,7868
|
|
2
|
+
treex_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9cKDUev9NW0GecCyLOnJpvrIFcDRFusy2XrWyiWG304,1071
|
|
3
|
+
treex_cli-0.1.0.dist-info/METADATA,sha256=LefKFkAP9qoNbGQJ5IBHczbXwpkRYVD3N-8whaUEou0,2548
|
|
4
|
+
treex_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
treex_cli-0.1.0.dist-info/entry_points.txt,sha256=hR-28y-rXChHXzu19fhhky5NZt3P6EKxrB-laq78VOg,37
|
|
6
|
+
treex_cli-0.1.0.dist-info/top_level.txt,sha256=Q2lsPiM6enBh4T-ZtRWruXPJPoyJTORjjQNeJtEl-I8,6
|
|
7
|
+
treex_cli-0.1.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
|