treex-cli 0.2.3__tar.gz → 0.2.4__tar.gz
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_cli-0.2.3 → treex_cli-0.2.4}/Cargo.lock +3 -3
- {treex_cli-0.2.3 → treex_cli-0.2.4}/Cargo.toml +1 -1
- {treex_cli-0.2.3 → treex_cli-0.2.4}/PKG-INFO +1 -1
- {treex_cli-0.2.3 → treex_cli-0.2.4}/pyproject.toml +7 -8
- {treex_cli-0.2.3 → treex_cli-0.2.4}/src/treex/treex.py +86 -39
- {treex_cli-0.2.3 → treex_cli-0.2.4}/src/treex_cli.egg-info/PKG-INFO +1 -1
- {treex_cli-0.2.3 → treex_cli-0.2.4}/tests/test_treex.py +22 -6
- {treex_cli-0.2.3 → treex_cli-0.2.4}/LICENSE +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/MANIFEST.in +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/README.md +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/rust/lib.rs +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/setup.cfg +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/src/treex/__init__.py +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/src/treex_cli.egg-info/SOURCES.txt +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/src/treex_cli.egg-info/dependency_links.txt +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/src/treex_cli.egg-info/entry_points.txt +0 -0
- {treex_cli-0.2.3 → treex_cli-0.2.4}/src/treex_cli.egg-info/top_level.txt +0 -0
|
@@ -1257,9 +1257,9 @@ checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
|
|
1257
1257
|
|
|
1258
1258
|
[[package]]
|
|
1259
1259
|
name = "rustix"
|
|
1260
|
-
version = "1.1.
|
|
1260
|
+
version = "1.1.6"
|
|
1261
1261
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1262
|
-
checksum = "
|
|
1262
|
+
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
|
1263
1263
|
dependencies = [
|
|
1264
1264
|
"bitflags 2.13.2",
|
|
1265
1265
|
"errno",
|
|
@@ -1411,7 +1411,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
|
|
1411
1411
|
|
|
1412
1412
|
[[package]]
|
|
1413
1413
|
name = "treex-native"
|
|
1414
|
-
version = "0.2.
|
|
1414
|
+
version = "0.2.4"
|
|
1415
1415
|
dependencies = [
|
|
1416
1416
|
"gix",
|
|
1417
1417
|
"pyo3",
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "treex-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description="Command-line directory tree utility with metadata and Git ignore support"
|
|
9
9
|
requires-python = ">= 3.13"
|
|
10
10
|
license = "MIT"
|
|
@@ -37,13 +37,14 @@ repository = "https://github.com/cgoldberg/treex"
|
|
|
37
37
|
|
|
38
38
|
[dependency-groups]
|
|
39
39
|
build = [
|
|
40
|
-
"cibuildwheel==4.3.
|
|
40
|
+
"cibuildwheel==4.3.5",
|
|
41
|
+
"twine==7.0.0",
|
|
41
42
|
]
|
|
42
43
|
lint = [
|
|
43
|
-
"ruff==0.17.
|
|
44
|
+
"ruff==0.17.3",
|
|
44
45
|
]
|
|
45
46
|
test = [
|
|
46
|
-
"pytest==9.
|
|
47
|
+
"pytest==9.2.0",
|
|
47
48
|
]
|
|
48
49
|
validate = [
|
|
49
50
|
"validate-pyproject[all]==0.27",
|
|
@@ -64,12 +65,10 @@ py-limited-api = true
|
|
|
64
65
|
|
|
65
66
|
[tool.cibuildwheel]
|
|
66
67
|
build = "cp313-*"
|
|
67
|
-
test-requires = ["pytest==9.1.1"]
|
|
68
|
-
test-command = "pytest {project}/tests"
|
|
69
|
-
|
|
70
|
-
[tool.cibuildwheel.linux]
|
|
71
68
|
before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal"
|
|
72
69
|
environment = { PATH = "$HOME/.cargo/bin:$PATH" }
|
|
70
|
+
test-requires = ["pytest==9.2.0"]
|
|
71
|
+
test-command = "pytest {project}"
|
|
73
72
|
|
|
74
73
|
[tool.pytest]
|
|
75
74
|
addopts = ["-v"]
|
|
@@ -15,6 +15,7 @@ the filesystem is scanned normally without Git filtering.
|
|
|
15
15
|
|
|
16
16
|
import argparse
|
|
17
17
|
import fnmatch
|
|
18
|
+
import os
|
|
18
19
|
import re
|
|
19
20
|
import sys
|
|
20
21
|
from datetime import datetime
|
|
@@ -56,26 +57,29 @@ class GitIgnore:
|
|
|
56
57
|
"""Check if Git considers a path ignored."""
|
|
57
58
|
return bool(self.ignored_many([path]))
|
|
58
59
|
|
|
59
|
-
def ignored_many(self, paths):
|
|
60
|
-
"""
|
|
60
|
+
def ignored_many(self, paths, directory_flags=None):
|
|
61
|
+
"""Return the paths that Git considers ignored."""
|
|
61
62
|
if not self.enabled or self.repo_root is None or self.matcher is None:
|
|
62
63
|
return set()
|
|
63
|
-
|
|
64
|
+
candidates = []
|
|
64
65
|
matcher_paths = []
|
|
65
66
|
for path in paths:
|
|
66
67
|
try:
|
|
67
|
-
relative = path.
|
|
68
|
+
relative = path.relative_to(self.repo_root)
|
|
68
69
|
except (ValueError, OSError):
|
|
69
70
|
continue
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
candidates.append(path)
|
|
72
|
+
is_dir = (
|
|
73
|
+
directory_flags[path]
|
|
74
|
+
if directory_flags is not None and path in directory_flags
|
|
75
|
+
else path.is_dir()
|
|
76
|
+
)
|
|
77
|
+
matcher_paths.append((relative.as_posix(), is_dir))
|
|
72
78
|
if not matcher_paths:
|
|
73
79
|
return set()
|
|
74
80
|
results = self.matcher.ignored_many(matcher_paths)
|
|
75
81
|
return {
|
|
76
|
-
|
|
77
|
-
for relative, ignored in zip(relative_paths, results, strict=True)
|
|
78
|
-
if ignored
|
|
82
|
+
path for path, ignored in zip(candidates, results, strict=True) if ignored
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
|
|
@@ -128,7 +132,14 @@ def _count_lines(path):
|
|
|
128
132
|
|
|
129
133
|
|
|
130
134
|
def _file_metadata(path):
|
|
131
|
-
"""
|
|
135
|
+
"""Collect file metadata, identifying binary files and counting text lines.
|
|
136
|
+
|
|
137
|
+
Reads files in chunks to avoid loading large files into memory. Checks
|
|
138
|
+
the initial bytes for NUL characters and invalid UTF-8 to identify binary
|
|
139
|
+
files, and counts lines for text files in a single pass. Empty files are
|
|
140
|
+
handled without opening them. Returns formatted size, line count or file
|
|
141
|
+
status, byte size, and modification time.
|
|
142
|
+
"""
|
|
132
143
|
try:
|
|
133
144
|
stat = path.stat()
|
|
134
145
|
size = stat.st_size
|
|
@@ -136,37 +147,71 @@ def _file_metadata(path):
|
|
|
136
147
|
except OSError:
|
|
137
148
|
return "?", "[unreadable]", 0, None
|
|
138
149
|
size_text = _format_size(size)
|
|
139
|
-
if
|
|
150
|
+
if size == 0:
|
|
151
|
+
return size_text, "0 lines", size, modified
|
|
152
|
+
try:
|
|
153
|
+
f = path.open("rb")
|
|
154
|
+
except OSError:
|
|
140
155
|
return size_text, "[binary]", size, modified
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
156
|
+
with f:
|
|
157
|
+
try:
|
|
158
|
+
chunk = f.read(8192)
|
|
159
|
+
except OSError:
|
|
160
|
+
return size_text, "[binary]", size, modified
|
|
161
|
+
if chunk:
|
|
162
|
+
if b"\x00" in chunk:
|
|
163
|
+
return size_text, "[binary]", size, modified
|
|
164
|
+
try:
|
|
165
|
+
chunk.decode("utf-8")
|
|
166
|
+
except UnicodeDecodeError:
|
|
167
|
+
return size_text, "[binary]", size, modified
|
|
168
|
+
lines = chunk.count(b"\n")
|
|
169
|
+
last_byte = chunk[-1:] if chunk else b""
|
|
170
|
+
has_data = bool(chunk)
|
|
171
|
+
# A short read from a regular file normally means EOF.
|
|
172
|
+
if len(chunk) == 8192:
|
|
173
|
+
try:
|
|
174
|
+
while True:
|
|
175
|
+
chunk = f.read(65536)
|
|
176
|
+
if not chunk:
|
|
177
|
+
break
|
|
178
|
+
has_data = True
|
|
179
|
+
lines += chunk.count(b"\n")
|
|
180
|
+
last_byte = chunk[-1:]
|
|
181
|
+
except OSError:
|
|
182
|
+
return size_text, "[unreadable]", size, modified
|
|
183
|
+
if has_data and last_byte != b"\n":
|
|
184
|
+
lines += 1
|
|
144
185
|
return size_text, f"{lines:,} lines", size, modified
|
|
145
186
|
|
|
146
187
|
|
|
147
188
|
def _get_tree_entries(path, gitignore):
|
|
148
|
-
"""Get entries to include in the tree."""
|
|
189
|
+
"""Get entries to include in the tree with cached type information."""
|
|
190
|
+
typed_entries = []
|
|
149
191
|
try:
|
|
150
|
-
|
|
192
|
+
# Use os.scandir() instead of Path.iterdir(): DirEntry caches
|
|
193
|
+
# filesystem metadata, reducing extra system calls for type checks.
|
|
194
|
+
entries = os.scandir(path)
|
|
151
195
|
except PermissionError:
|
|
152
196
|
return None
|
|
153
|
-
|
|
197
|
+
with entries:
|
|
198
|
+
for dir_entry in entries:
|
|
199
|
+
if dir_entry.is_symlink():
|
|
200
|
+
continue
|
|
201
|
+
is_dir = dir_entry.is_dir(follow_symlinks=False)
|
|
202
|
+
is_file = dir_entry.is_file(follow_symlinks=False)
|
|
203
|
+
typed_entries.append((Path(dir_entry.path), is_dir, is_file))
|
|
154
204
|
if gitignore is not None:
|
|
155
|
-
|
|
205
|
+
typed_entries = [item for item in typed_entries if item[0].name != ".git"]
|
|
156
206
|
if gitignore.enabled and gitignore.repo_root is not None:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if relative.as_posix() not in ignored:
|
|
166
|
-
filtered_entries.append(entry)
|
|
167
|
-
entries = filtered_entries
|
|
168
|
-
entries.sort(key=lambda p: (p.is_file(), p.name.casefold()))
|
|
169
|
-
return entries
|
|
207
|
+
directory_flags = {entry: is_dir for entry, is_dir, _ in typed_entries}
|
|
208
|
+
ignored = gitignore.ignored_many(
|
|
209
|
+
[entry for entry, _, _ in typed_entries],
|
|
210
|
+
directory_flags,
|
|
211
|
+
)
|
|
212
|
+
typed_entries = [item for item in typed_entries if item[0] not in ignored]
|
|
213
|
+
typed_entries.sort(key=lambda item: (item[2], item[0].name.casefold()))
|
|
214
|
+
return typed_entries
|
|
170
215
|
|
|
171
216
|
|
|
172
217
|
def _matches_pattern(path, pattern):
|
|
@@ -186,6 +231,7 @@ def _scan_tree(
|
|
|
186
231
|
max_depth=None,
|
|
187
232
|
depth=0,
|
|
188
233
|
root=None,
|
|
234
|
+
relative_prefix="",
|
|
189
235
|
):
|
|
190
236
|
"""Scan and filter the tree, returning nodes for rendering.
|
|
191
237
|
|
|
@@ -197,8 +243,8 @@ def _scan_tree(
|
|
|
197
243
|
Directory sizes include only files within the permitted depth
|
|
198
244
|
that pass the include/exclude filters.
|
|
199
245
|
"""
|
|
200
|
-
path = path.resolve()
|
|
201
246
|
if root is None:
|
|
247
|
+
path = path.resolve()
|
|
202
248
|
root = path
|
|
203
249
|
if max_depth is not None and depth >= max_depth:
|
|
204
250
|
return []
|
|
@@ -217,14 +263,14 @@ def _scan_tree(
|
|
|
217
263
|
]
|
|
218
264
|
nodes = []
|
|
219
265
|
has_include = include_pattern is not None or include_regex is not None
|
|
220
|
-
for entry in entries:
|
|
221
|
-
|
|
222
|
-
|
|
266
|
+
for entry, is_dir, is_file in entries:
|
|
267
|
+
name = entry.name
|
|
268
|
+
relative_path = f"{relative_prefix}/{name}" if relative_prefix else name
|
|
223
269
|
if _matches_pattern(relative_path, exclude_pattern):
|
|
224
270
|
continue
|
|
225
271
|
if exclude_regex is not None and exclude_regex.search(relative_path):
|
|
226
272
|
continue
|
|
227
|
-
if
|
|
273
|
+
if is_dir:
|
|
228
274
|
entry_depth = depth + 1
|
|
229
275
|
if max_depth is not None and entry_depth >= max_depth:
|
|
230
276
|
children = []
|
|
@@ -241,6 +287,7 @@ def _scan_tree(
|
|
|
241
287
|
max_depth,
|
|
242
288
|
entry_depth,
|
|
243
289
|
root,
|
|
290
|
+
relative_path,
|
|
244
291
|
)
|
|
245
292
|
contains_match = any(child["contains_match"] for child in children)
|
|
246
293
|
if has_include and not contains_match:
|
|
@@ -250,7 +297,7 @@ def _scan_tree(
|
|
|
250
297
|
)
|
|
251
298
|
nodes.append(
|
|
252
299
|
{
|
|
253
|
-
"name":
|
|
300
|
+
"name": name,
|
|
254
301
|
"kind": "directory",
|
|
255
302
|
"children": children,
|
|
256
303
|
"size": size,
|
|
@@ -260,7 +307,7 @@ def _scan_tree(
|
|
|
260
307
|
}
|
|
261
308
|
)
|
|
262
309
|
continue
|
|
263
|
-
if not
|
|
310
|
+
if not is_file:
|
|
264
311
|
continue
|
|
265
312
|
if include_pattern is not None and not _matches_pattern(
|
|
266
313
|
relative_path,
|
|
@@ -275,7 +322,7 @@ def _scan_tree(
|
|
|
275
322
|
size_text, info, size, modified = None, None, None, None
|
|
276
323
|
nodes.append(
|
|
277
324
|
{
|
|
278
|
-
"name":
|
|
325
|
+
"name": name,
|
|
279
326
|
"kind": "file",
|
|
280
327
|
"children": [],
|
|
281
328
|
"size": size,
|
|
@@ -223,7 +223,7 @@ class TestGitIgnore:
|
|
|
223
223
|
ignored_dir.mkdir()
|
|
224
224
|
paths = [ignored_file, normal_file, ignored_dir]
|
|
225
225
|
gitignore = treex.GitIgnore(tmp_path)
|
|
226
|
-
assert gitignore.ignored_many(paths) == {
|
|
226
|
+
assert gitignore.ignored_many(paths) == {ignored_file, ignored_dir}
|
|
227
227
|
|
|
228
228
|
def test_ignored_many_handles_spaces(self, tmp_path):
|
|
229
229
|
init_git_repo(tmp_path)
|
|
@@ -234,7 +234,7 @@ class TestGitIgnore:
|
|
|
234
234
|
ignored_dir.mkdir()
|
|
235
235
|
paths = [ignored_file, ignored_dir]
|
|
236
236
|
gitignore = treex.GitIgnore(tmp_path)
|
|
237
|
-
assert gitignore.ignored_many(paths) == {
|
|
237
|
+
assert gitignore.ignored_many(paths) == {ignored_file, ignored_dir}
|
|
238
238
|
|
|
239
239
|
def test_missing_git_index(self, tmp_path):
|
|
240
240
|
init_git_repo(tmp_path)
|
|
@@ -260,7 +260,7 @@ class TestTreeEntries:
|
|
|
260
260
|
(tmp_path / "Docs").mkdir()
|
|
261
261
|
(tmp_path / ".git").mkdir()
|
|
262
262
|
entries = treex._get_tree_entries(tmp_path, gitignore=None)
|
|
263
|
-
assert [entry.name for entry in entries] == [
|
|
263
|
+
assert [entry[0].name for entry in entries] == [
|
|
264
264
|
".git",
|
|
265
265
|
"Docs",
|
|
266
266
|
"src",
|
|
@@ -278,7 +278,7 @@ class TestTreeEntries:
|
|
|
278
278
|
link = tmp_path / "link.txt"
|
|
279
279
|
link.symlink_to(target)
|
|
280
280
|
entries = treex._get_tree_entries(tmp_path, gitignore=None)
|
|
281
|
-
assert [entry.name for entry in entries] == ["target.txt"]
|
|
281
|
+
assert [entry[0].name for entry in entries] == ["target.txt"]
|
|
282
282
|
|
|
283
283
|
|
|
284
284
|
class TestCollectTree:
|
|
@@ -380,7 +380,7 @@ class TestCollectTree:
|
|
|
380
380
|
def test_collect_tree_permission_denied(self, tmp_path):
|
|
381
381
|
rows = []
|
|
382
382
|
stats = {"directories": 0, "files": 0, "total_size": 0}
|
|
383
|
-
with patch
|
|
383
|
+
with patch("os.scandir", side_effect=PermissionError):
|
|
384
384
|
treex._collect_tree(tmp_path, "", stats, None, rows, True, False, True)
|
|
385
385
|
assert rows == [("└── [permission denied]", None)]
|
|
386
386
|
assert stats["directories"] == 0
|
|
@@ -562,6 +562,22 @@ class TestScanTree:
|
|
|
562
562
|
)
|
|
563
563
|
assert nodes == []
|
|
564
564
|
|
|
565
|
+
def test_relative_prefix_matches_nested_paths(self, tmp_path):
|
|
566
|
+
nested = tmp_path / "nested"
|
|
567
|
+
nested.mkdir()
|
|
568
|
+
(nested / "app.py").touch()
|
|
569
|
+
(nested / "notes.txt").touch()
|
|
570
|
+
nodes = treex._scan_tree(
|
|
571
|
+
nested,
|
|
572
|
+
None,
|
|
573
|
+
False,
|
|
574
|
+
False,
|
|
575
|
+
include_pattern="src/*.py",
|
|
576
|
+
relative_prefix="src",
|
|
577
|
+
)
|
|
578
|
+
assert [node["name"] for node in nodes] == ["app.py"]
|
|
579
|
+
assert nodes[0]["contains_match"] is True
|
|
580
|
+
|
|
565
581
|
|
|
566
582
|
class TestPrintTreeStatistics:
|
|
567
583
|
def test_counts_files_and_directories(self, tmp_path):
|
|
@@ -913,7 +929,7 @@ class TestPrintTree:
|
|
|
913
929
|
def test_reports_permission_denied(self, tmp_path, capsys):
|
|
914
930
|
directory = tmp_path / "restricted"
|
|
915
931
|
directory.mkdir()
|
|
916
|
-
with patch
|
|
932
|
+
with patch("os.scandir", side_effect=PermissionError):
|
|
917
933
|
treex.print_tree(directory)
|
|
918
934
|
assert "[permission denied]" in capsys.readouterr().out
|
|
919
935
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|