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.
@@ -1257,9 +1257,9 @@ checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
1257
1257
 
1258
1258
  [[package]]
1259
1259
  name = "rustix"
1260
- version = "1.1.5"
1260
+ version = "1.1.6"
1261
1261
  source = "registry+https://github.com/rust-lang/crates.io-index"
1262
- checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
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.3"
1414
+ version = "0.2.4"
1415
1415
  dependencies = [
1416
1416
  "gix",
1417
1417
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "treex-native"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  edition = "2024"
5
5
 
6
6
  [lib]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: treex-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Command-line directory tree utility with metadata and Git ignore support
5
5
  Author: Corey Goldberg
6
6
  Maintainer: Corey Goldberg
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "treex-cli"
7
- version = "0.2.3"
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.4",
40
+ "cibuildwheel==4.3.5",
41
+ "twine==7.0.0",
41
42
  ]
42
43
  lint = [
43
- "ruff==0.17.2",
44
+ "ruff==0.17.3",
44
45
  ]
45
46
  test = [
46
- "pytest==9.1.1",
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
- """Check which paths Git considers ignored."""
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
- relative_paths = []
64
+ candidates = []
64
65
  matcher_paths = []
65
66
  for path in paths:
66
67
  try:
67
- relative = path.resolve().relative_to(self.repo_root)
68
+ relative = path.relative_to(self.repo_root)
68
69
  except (ValueError, OSError):
69
70
  continue
70
- relative_paths.append(relative.as_posix())
71
- matcher_paths.append((relative.as_posix(), path.is_dir()))
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
- relative
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
- """Get file metadata."""
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 _is_binary_file(path):
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
- lines = _count_lines(path)
142
- if lines is None:
143
- return size_text, "[unreadable]", size, modified
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
- entries = list(path.iterdir())
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
- entries = [entry for entry in entries if not entry.is_symlink()]
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
- entries = [entry for entry in entries if entry.name != ".git"]
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
- ignored = gitignore.ignored_many(entries)
158
- filtered_entries = []
159
- for entry in entries:
160
- try:
161
- relative = entry.resolve().relative_to(gitignore.repo_root)
162
- except (ValueError, OSError):
163
- filtered_entries.append(entry)
164
- continue
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
- relative_path = entry.relative_to(root).as_posix()
222
- # Exclusions take precedence over inclusions.
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 entry.is_dir():
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": entry.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 entry.is_file():
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": entry.name,
325
+ "name": name,
279
326
  "kind": "file",
280
327
  "children": [],
281
328
  "size": size,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: treex-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Command-line directory tree utility with metadata and Git ignore support
5
5
  Author: Corey Goldberg
6
6
  Maintainer: Corey Goldberg
@@ -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) == {"debug.log", "build"}
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) == {"debug output.log", "build output"}
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.object(Path, "iterdir", side_effect=PermissionError):
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.object(Path, "iterdir", side_effect=PermissionError):
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