move file_tree() to own helper module and adjust the manual visual test import
Rafael Uzarowski committed
Nov 9, 2025 at 21:32 UTC
94089bb9c77baf57b6c3187463c7c05ad0b4d355
3 files changed
+576
-576
python/helpers/file_tree.py
new
+564
@@ -0,0 +1,564 @@
1
+from __future__ import annotations
2
+
3
+from collections import deque
4
+from dataclasses import dataclass
5
+from datetime import datetime, timezone
6
+import os
7
+from typing import Any, Callable, Iterable, Literal, Optional, Sequence
8
+
9
+from pathspec import PathSpec
10
+
11
+from python.helpers.files import get_abs_path
12
+
13
+SORT_BY_NAME = "name"
14
+SORT_BY_CREATED = "created"
15
+SORT_BY_MODIFIED = "modified"
16
+
17
+SORT_ASC = "asc"
18
+SORT_DESC = "desc"
19
+
20
+OUTPUT_MODE_STRING = "string"
21
+OUTPUT_MODE_FLAT = "flat"
22
+OUTPUT_MODE_NESTED = "nested"
23
+
24
+
25
+def file_tree(
26
+ relative_path: str,
27
+ *,
28
+ max_depth: int = 0,
29
+ max_lines: int = 0,
30
+ folders_first: bool = True,
31
+ max_folders: int | None = None,
32
+ max_files: int | None = None,
33
+ sort: tuple[str, str] = (SORT_BY_MODIFIED, SORT_DESC),
34
+ ignore: str | None = None,
35
+ output_mode: str = OUTPUT_MODE_STRING,
36
+) -> str | list[dict]:
37
+ """Render a directory tree relative to the repository base path.
38
+
39
+ Parameters:
40
+ relative_path: Base directory (relative to project root) to scan with :func:`get_abs_path`.
41
+ max_depth: Maximum depth of traversal (0 = unlimited). Depth starts at 1 for root entries.
42
+ max_lines: Global limit for rendered lines (0 = unlimited). When exceeded, the current depth
43
+ finishes rendering before deeper levels are skipped.
44
+ folders_first: When True, folders render before files within each directory.
45
+ max_folders: Optional per-directory cap (0 = unlimited) on rendered folder entries before adding a
46
+ ``# N more folders`` comment. When only a single folder exceeds the limit and ``max_folders`` is greater than zero, that folder is rendered
47
+ directly instead of emitting a summary comment.
48
+ max_files: Optional per-directory cap (0 = unlimited) on rendered file entries before adding a ``# N more files`` comment.
49
+ As with folders, a single excess file is rendered when ``max_files`` is greater than zero.
50
+ sort: Tuple of ``(key, direction)`` where key is one of :data:`SORT_BY_NAME`,
51
+ :data:`SORT_BY_CREATED`, or :data:`SORT_BY_MODIFIED`; direction is :data:`SORT_ASC`
52
+ or :data:`SORT_DESC`.
53
+ ignore: Inline ``.gitignore`` content or ``file:`` reference. Examples::
54
+
55
+ ignore=\"\"\"\\n*.pyc\\n__pycache__/\\n!important.py\\n\"\"\"
56
+ ignore=\"file:.gitignore\" # relative to scan root
57
+ ignore=\"file://.gitignore\" # URI-style relative path
58
+ ignore=\"file:/abs/path/.gitignore\"
59
+ ignore=\"file:///abs/path/.gitignore\"
60
+
61
+ output_mode: One of :data:`OUTPUT_MODE_STRING`, :data:`OUTPUT_MODE_FLAT`, or
62
+ :data:`OUTPUT_MODE_NESTED`.
63
+
64
+ Returns:
65
+ ``OUTPUT_MODE_STRING`` → ``str``: multi-line ASCII tree.
66
+ ``OUTPUT_MODE_FLAT`` → ``list[dict]``: flattened sequence of TreeItem dictionaries.
67
+ ``OUTPUT_MODE_NESTED`` → ``list[dict]``: nested TreeItem dictionaries where folders
68
+ include ``items`` arrays.
69
+
70
+ Notes:
71
+ * The utility is synchronous; avoid calling from latency-sensitive async loops.
72
+ * The ASCII renderer walks the established tree depth-first so connectors reflect parent/child structure,
73
+ while traversal and limit calculations remain breadth-first by depth.
74
+ * ``created`` and ``modified`` values in structured outputs are timezone-aware UTC
75
+ :class:`datetime.datetime` objects::
76
+
77
+ item = flat_items[0]
78
+ iso = item[\"created\"].isoformat()
79
+ epoch = item[\"created\"].timestamp()
80
+
81
+ """
82
+ abs_root = get_abs_path(relative_path)
83
+
84
+ if not os.path.exists(abs_root):
85
+ raise FileNotFoundError(f"Path does not exist: {relative_path!r}")
86
+ if not os.path.isdir(abs_root):
87
+ raise NotADirectoryError(f"Expected a directory, received: {relative_path!r}")
88
+
89
+ sort_key, sort_direction = sort
90
+ if sort_key not in {SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED}:
91
+ raise ValueError(f"Unsupported sort key: {sort_key!r}")
92
+ if sort_direction not in {SORT_ASC, SORT_DESC}:
93
+ raise ValueError(f"Unsupported sort direction: {sort_direction!r}")
94
+ if output_mode not in {OUTPUT_MODE_STRING, OUTPUT_MODE_FLAT, OUTPUT_MODE_NESTED}:
95
+ raise ValueError(f"Unsupported output mode: {output_mode!r}")
96
+ if max_depth < 0:
97
+ raise ValueError("max_depth must be >= 0")
98
+ if max_lines < 0:
99
+ raise ValueError("max_lines must be >= 0")
100
+
101
+ ignore_spec = _resolve_ignore_patterns(ignore, abs_root)
102
+
103
+ root_stat = os.stat(abs_root, follow_symlinks=False)
104
+ root_name = os.path.basename(os.path.normpath(abs_root)) or os.path.basename(abs_root)
105
+ root_node = _TreeEntry(
106
+ name=root_name,
107
+ level=0,
108
+ item_type="folder",
109
+ created=datetime.fromtimestamp(root_stat.st_ctime, tz=timezone.utc),
110
+ modified=datetime.fromtimestamp(root_stat.st_mtime, tz=timezone.utc),
111
+ parent=None,
112
+ items=[],
113
+ rel_path="",
114
+ )
115
+
116
+ queue: deque[tuple[_TreeEntry, str, int]] = deque([(root_node, abs_root, 1)])
117
+ nodes_in_order: list[_TreeEntry] = []
118
+ limit_level: Optional[int] = None
119
+ visibility_cache: dict[str, bool] = {}
120
+
121
+ def make_entry(entry: os.DirEntry, parent: _TreeEntry, level: int, item_type: Literal["file", "folder"]) -> _TreeEntry:
122
+ stat = entry.stat(follow_symlinks=False)
123
+ rel_path = os.path.relpath(entry.path, abs_root)
124
+ rel_posix = _normalize_relative_path(rel_path)
125
+ return _TreeEntry(
126
+ name=entry.name,
127
+ level=level,
128
+ item_type=item_type,
129
+ created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
130
+ modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
131
+ parent=parent,
132
+ items=[] if item_type == "folder" else None,
133
+ rel_path=rel_posix,
134
+ )
135
+
136
+ while queue:
137
+ parent_node, current_dir, level = queue.popleft()
138
+
139
+ if max_depth and level > max_depth:
140
+ continue
141
+
142
+ remaining_depth = max_depth - level if max_depth else -1
143
+ folders, files = _list_directory_children(
144
+ current_dir,
145
+ abs_root,
146
+ ignore_spec,
147
+ max_depth_remaining=remaining_depth,
148
+ cache=visibility_cache,
149
+ )
150
+
151
+ folder_entries = [make_entry(folder, parent_node, level, "folder") for folder in folders]
152
+ file_entries = [make_entry(file_entry, parent_node, level, "file") for file_entry in files]
153
+
154
+ children = _apply_sorting_and_limits(
155
+ folder_entries,
156
+ file_entries,
157
+ folders_first=folders_first,
158
+ sort=sort,
159
+ max_folders=max_folders,
160
+ max_files=max_files,
161
+ directory_node=parent_node,
162
+ )
163
+
164
+ parent_node.items = children
165
+ nodes_in_order.extend(children)
166
+
167
+ if max_lines and limit_level is None and len(nodes_in_order) >= max_lines:
168
+ limit_level = level
169
+
170
+ for child in children:
171
+ if child.item_type != "folder":
172
+ continue
173
+ if max_depth and level >= max_depth:
174
+ continue
175
+ if limit_level is not None and level >= limit_level:
176
+ continue
177
+ child_abs = os.path.join(current_dir, child.name)
178
+ queue.append((child, child_abs, level + 1))
179
+
180
+ pruned_nodes: list[_TreeEntry] = nodes_in_order
181
+ if max_lines and limit_level is not None:
182
+ _prune_nested_children(
183
+ root_node,
184
+ lambda entry: entry.level <= limit_level,
185
+ )
186
+ pruned_nodes = [node for node in nodes_in_order if node.level <= limit_level]
187
+
188
+ visible_nodes: list[_TreeEntry]
189
+ if max_lines and limit_level is None:
190
+ visible_nodes = pruned_nodes[:max_lines]
191
+ else:
192
+ visible_nodes = pruned_nodes
193
+
194
+ visible_ids = {id(node) for node in visible_nodes}
195
+ if visible_ids:
196
+ _prune_to_visible(root_node, visible_ids)
197
+
198
+ _mark_last_flags(root_node)
199
+ _refresh_render_metadata(root_node)
200
+
201
+ def iter_visible() -> Iterable[_TreeEntry]:
202
+ for node in _iter_depth_first(root_node.items or []):
203
+ if not visible_ids or id(node) in visible_ids:
204
+ yield node
205
+
206
+ if output_mode == OUTPUT_MODE_STRING:
207
+ display_name = relative_path.strip() or root_name
208
+ root_line = f"{display_name.rstrip(os.sep)}/"
209
+ lines = [root_line]
210
+ for node in iter_visible():
211
+ lines.append(node.text)
212
+ return "\n".join(lines)
213
+
214
+ if output_mode == OUTPUT_MODE_FLAT:
215
+ return _build_tree_items_flat(list(iter_visible()))
216
+
217
+ return _to_nested_structure(root_node.items or [])
218
+
219
+
220
+@dataclass(slots=True)
221
+class _TreeEntry:
222
+ name: str
223
+ level: int
224
+ item_type: Literal["file", "folder", "comment"]
225
+ created: datetime
226
+ modified: datetime
227
+ parent: Optional["_TreeEntry"] = None
228
+ items: Optional[list["_TreeEntry"]] = None
229
+ is_last: bool = False
230
+ rel_path: str = ""
231
+ text: str = ""
232
+
233
+ def as_dict(self) -> dict[str, Any]:
234
+ return {
235
+ "name": self.name,
236
+ "level": self.level,
237
+ "type": self.item_type,
238
+ "created": self.created,
239
+ "modified": self.modified,
240
+ "text": self.text,
241
+ "items": [child.as_dict() for child in self.items] if self.items is not None else None,
242
+ }
243
+
244
+
245
+def _normalize_relative_path(path: str) -> str:
246
+ normalized = path.replace(os.sep, "/")
247
+ if normalized in {".", ""}:
248
+ return ""
249
+ while normalized.startswith("./"):
250
+ normalized = normalized[2:]
251
+ return normalized
252
+
253
+
254
+def _directory_has_visible_entries(
255
+ directory: str,
256
+ root_abs_path: str,
257
+ ignore_spec: PathSpec,
258
+ cache: dict[str, bool],
259
+ max_depth_remaining: int,
260
+) -> bool:
261
+ if max_depth_remaining == 0:
262
+ return False
263
+
264
+ cached = cache.get(directory)
265
+ if cached is not None:
266
+ return cached
267
+
268
+ try:
269
+ with os.scandir(directory) as iterator:
270
+ for entry in iterator:
271
+ rel_path = os.path.relpath(entry.path, root_abs_path)
272
+ rel_posix = _normalize_relative_path(rel_path)
273
+ is_dir = entry.is_dir(follow_symlinks=False)
274
+
275
+ if is_dir:
276
+ ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
277
+ if ignored:
278
+ next_depth = max_depth_remaining - 1 if max_depth_remaining > 0 else -1
279
+ if next_depth == 0:
280
+ continue
281
+ if _directory_has_visible_entries(
282
+ entry.path,
283
+ root_abs_path,
284
+ ignore_spec,
285
+ cache,
286
+ next_depth,
287
+ ):
288
+ cache[directory] = True
289
+ return True
290
+ continue
291
+ else:
292
+ if ignore_spec.match_file(rel_posix):
293
+ continue
294
+
295
+ cache[directory] = True
296
+ return True
297
+ except FileNotFoundError:
298
+ cache[directory] = False
299
+ return False
300
+
301
+ cache[directory] = False
302
+ return False
303
+
304
+
305
+def _create_summary_comment(parent: _TreeEntry, noun: str, count: int) -> _TreeEntry:
306
+ label = noun
307
+ if count == 1 and noun.endswith("s"):
308
+ label = noun[:-1]
309
+ elif count > 1 and not noun.endswith("s"):
310
+ label = f"{noun}s"
311
+ return _TreeEntry(
312
+ name=f"{count} more {label}",
313
+ level=parent.level + 1,
314
+ item_type="comment",
315
+ created=parent.created,
316
+ modified=parent.modified,
317
+ parent=parent,
318
+ items=None,
319
+ rel_path=f"{parent.rel_path}#summary:{noun}:{count}",
320
+ )
321
+
322
+
323
+def _prune_nested_children(node: _TreeEntry, predicate: Callable[[_TreeEntry], bool]) -> None:
324
+ if node.items is None:
325
+ return
326
+ pruned: list[_TreeEntry] = []
327
+ for child in node.items:
328
+ if predicate(child):
329
+ _prune_nested_children(child, predicate)
330
+ pruned.append(child)
331
+ node.items = pruned
332
+
333
+
334
+def _prune_to_visible(node: _TreeEntry, visible_ids: set[int]) -> None:
335
+ if node.items is None:
336
+ return
337
+ filtered: list[_TreeEntry] = []
338
+ for child in node.items:
339
+ if not visible_ids or id(child) in visible_ids:
340
+ _prune_to_visible(child, visible_ids)
341
+ filtered.append(child)
342
+ node.items = filtered
343
+
344
+
345
+def _mark_last_flags(node: _TreeEntry) -> None:
346
+ if node.items is None:
347
+ return
348
+ total = len(node.items)
349
+ for index, child in enumerate(node.items):
350
+ child.is_last = index == total - 1
351
+ _mark_last_flags(child)
352
+
353
+
354
+def _refresh_render_metadata(node: _TreeEntry) -> None:
355
+ if node.items is None:
356
+ return
357
+ for child in node.items:
358
+ child.text = _format_line(child)
359
+ _refresh_render_metadata(child)
360
+
361
+
362
+def _resolve_ignore_patterns(ignore: str | None, root_abs_path: str) -> Optional[PathSpec]:
363
+ if ignore is None:
364
+ return None
365
+
366
+ content: str
367
+ if ignore.startswith("file:"):
368
+ reference = ignore[5:]
369
+ if reference.startswith("///"):
370
+ reference_path = reference[2:]
371
+ elif reference.startswith("//"):
372
+ reference_path = os.path.join(root_abs_path, reference[2:])
373
+ elif reference.startswith("/"):
374
+ reference_path = reference
375
+ else:
376
+ reference_path = os.path.join(root_abs_path, reference)
377
+
378
+ try:
379
+ with open(reference_path, "r", encoding="utf-8") as handle:
380
+ content = handle.read()
381
+ except FileNotFoundError as exc:
382
+ raise FileNotFoundError(f"Ignore file not found: {reference_path}") from exc
383
+ else:
384
+ content = ignore
385
+
386
+ lines = [
387
+ line.strip()
388
+ for line in content.splitlines()
389
+ if line.strip() and not line.strip().startswith("#")
390
+ ]
391
+
392
+ if not lines:
393
+ return None
394
+
395
+ return PathSpec.from_lines("gitwildmatch", lines)
396
+
397
+
398
+def _list_directory_children(
399
+ directory: str,
400
+ root_abs_path: str,
401
+ ignore_spec: Optional[PathSpec],
402
+ *,
403
+ max_depth_remaining: int,
404
+ cache: dict[str, bool],
405
+) -> tuple[list[os.DirEntry], list[os.DirEntry]]:
406
+ folders: list[os.DirEntry] = []
407
+ files: list[os.DirEntry] = []
408
+
409
+ try:
410
+ with os.scandir(directory) as iterator:
411
+ for entry in iterator:
412
+ if entry.name in (".", ".."):
413
+ continue
414
+ rel_path = os.path.relpath(entry.path, root_abs_path)
415
+ rel_posix = _normalize_relative_path(rel_path)
416
+ is_directory = entry.is_dir(follow_symlinks=False)
417
+
418
+ if ignore_spec:
419
+ if is_directory:
420
+ ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
421
+ if ignored:
422
+ if _directory_has_visible_entries(
423
+ entry.path,
424
+ root_abs_path,
425
+ ignore_spec,
426
+ cache,
427
+ max_depth_remaining - 1,
428
+ ):
429
+ folders.append(entry)
430
+ continue
431
+ else:
432
+ if ignore_spec.match_file(rel_posix):
433
+ continue
434
+
435
+ if is_directory:
436
+ folders.append(entry)
437
+ else:
438
+ files.append(entry)
439
+ except FileNotFoundError:
440
+ return ([], [])
441
+
442
+ return (folders, files)
443
+
444
+
445
+def _apply_sorting_and_limits(
446
+ folders: list[_TreeEntry],
447
+ files: list[_TreeEntry],
448
+ *,
449
+ folders_first: bool,
450
+ sort: tuple[str, str],
451
+ max_folders: int | None,
452
+ max_files: int | None,
453
+ directory_node: _TreeEntry,
454
+) -> list[_TreeEntry]:
455
+ sort_key, sort_dir = sort
456
+ reverse = sort_dir == SORT_DESC
457
+
458
+ def key_fn(node: _TreeEntry):
459
+ if sort_key == SORT_BY_NAME:
460
+ return node.name.casefold()
461
+ if sort_key == SORT_BY_CREATED:
462
+ return node.created
463
+ return node.modified
464
+
465
+ folders_sorted = sorted(folders, key=key_fn, reverse=reverse)
466
+ files_sorted = sorted(files, key=key_fn, reverse=reverse)
467
+ combined: list[_TreeEntry] = []
468
+
469
+ def append_group(group: list[_TreeEntry], limit: int | None, noun: str) -> None:
470
+ if limit == 0:
471
+ limit = None
472
+ if not group:
473
+ return
474
+ if limit is None:
475
+ combined.extend(group)
476
+ return
477
+
478
+ limit = max(limit, 0)
479
+ visible = group[:limit]
480
+ combined.extend(visible)
481
+
482
+ overflow = group[limit:]
483
+ if not overflow:
484
+ return
485
+
486
+ if len(overflow) == 1 and limit > 0:
487
+ combined.append(overflow[0])
488
+ return
489
+
490
+ combined.append(
491
+ _create_summary_comment(
492
+ directory_node,
493
+ noun,
494
+ len(overflow),
495
+ )
496
+ )
497
+
498
+ if folders_first:
499
+ append_group(folders_sorted, max_folders, "folder")
500
+ append_group(files_sorted, max_files, "file")
501
+ else:
502
+ append_group(files_sorted, max_files, "file")
503
+ append_group(folders_sorted, max_folders, "folder")
504
+
505
+ return combined
506
+
507
+
508
+def _format_line(node: _TreeEntry) -> str:
509
+ segments: list[str] = []
510
+ ancestor = node.parent
511
+ while ancestor and ancestor.parent is not None:
512
+ segments.append(" " if ancestor.is_last else "│ ")
513
+ ancestor = ancestor.parent
514
+ segments.reverse()
515
+
516
+ connector = "└── " if node.is_last else "├── "
517
+ if node.item_type == "folder":
518
+ label = f"{node.name}/"
519
+ elif node.item_type == "comment":
520
+ label = f"# {node.name}"
521
+ else:
522
+ label = node.name
523
+
524
+ return "".join(segments) + connector + label
525
+
526
+
527
+def _build_tree_items_flat(items: Sequence[_TreeEntry]) -> list[dict]:
528
+ return [
529
+ {
530
+ "name": node.name,
531
+ "level": node.level,
532
+ "type": node.item_type,
533
+ "created": node.created,
534
+ "modified": node.modified,
535
+ "text": node.text,
536
+ "items": None,
537
+ }
538
+ for node in items
539
+ ]
540
+
541
+
542
+def _to_nested_structure(items: Sequence[_TreeEntry]) -> list[dict]:
543
+ def convert(node: _TreeEntry) -> dict:
544
+ children = None
545
+ if node.items is not None:
546
+ children = [convert(child) for child in node.items]
547
+ return {
548
+ "name": node.name,
549
+ "level": node.level,
550
+ "type": node.item_type,
551
+ "created": node.created,
552
+ "modified": node.modified,
553
+ "text": node.text,
554
+ "items": children,
555
+ }
556
+
557
+ return [convert(item) for item in items]
558
+
559
+
560
+def _iter_depth_first(items: Sequence[_TreeEntry]) -> Iterable[_TreeEntry]:
561
+ for node in items:
562
+ yield node
563
+ if node.items:
564
+ yield from _iter_depth_first(node.items)
python/helpers/files.py
+10
-571
@@ -1,29 +1,25 @@
1
-from __future__ import annotations
2
-
1
from abc import ABC, abstractmethod
4
-from collections import deque
5
-from dataclasses import dataclass
6
-from datetime import datetime, timezone
2
from fnmatch import fnmatch
3
import json
4
+from ntpath import isabs
5
import os
6
+import sys
7
import re
8
import base64
9
import shutil
10
import tempfile
14
-from typing import Any, Callable, Iterable, Literal, Optional, Sequence, Type, cast
11
+from typing import Any
12
import zipfile
13
+import importlib
14
+import importlib.util
15
+import inspect
16
import glob
17
import mimetypes
18
19
-from pathspec import PathSpec
20
-
19
20
class VariablesPlugin(ABC):
21
@abstractmethod
24
- def get_variables(
25
- self, file: str, backup_dirs: list[str] | None = None
26
- ) -> dict[str, Any]: # type: ignore[override]
22
+ def get_variables(self, file: str, backup_dirs: list[str] | None = None) -> dict[str, Any]: # type: ignore
23
pass
24
25
@@ -48,14 +44,11 @@ def load_plugin_variables(
44
45
from python.helpers import extract_tools
46
51
- plugin_base: Any = VariablesPlugin
47
classes = extract_tools.load_classes_from_file(
53
- plugin_file, plugin_base, one_per_file=False
48
+ plugin_file, VariablesPlugin, one_per_file=False
49
)
50
for cls in classes:
56
- if isinstance(cls, type) and issubclass(cls, VariablesPlugin):
57
- plugin_cls = cast(Type[VariablesPlugin], cls)
58
- return plugin_cls().get_variables(file, backup_dirs)
51
+ return cls().get_variables(file, backup_dirs) # type: ignore < abstract class here is ok, it is always a subclass
52
53
# load python code and extract variables variables from it
54
# module = None
@@ -354,7 +347,7 @@ def delete_dir(relative_path: str):
347
348
# try again after changing permissions
349
shutil.rmtree(abs_path, ignore_errors=True)
357
- except Exception:
350
+ except:
351
# suppress all errors - we're ensuring no errors propagate
352
pass
353
@@ -537,557 +530,3 @@ def read_text_files_in_dir(
530
except Exception:
531
continue
532
return result
540
-
541
-
542
-SORT_BY_NAME = "name"
543
-SORT_BY_CREATED = "created"
544
-SORT_BY_MODIFIED = "modified"
545
-
546
-SORT_ASC = "asc"
547
-SORT_DESC = "desc"
548
-
549
-OUTPUT_MODE_STRING = "string"
550
-OUTPUT_MODE_FLAT = "flat"
551
-OUTPUT_MODE_NESTED = "nested"
552
-
553
-
554
-def file_tree(
555
- relative_path: str,
556
- *,
557
- max_depth: int = 0,
558
- max_lines: int = 0,
559
- folders_first: bool = True,
560
- max_folders: int | None = None,
561
- max_files: int | None = None,
562
- sort: tuple[str, str] = (SORT_BY_MODIFIED, SORT_DESC),
563
- ignore: str | None = None,
564
- output_mode: str = OUTPUT_MODE_STRING,
565
-) -> str | list[dict]:
566
- """Render a directory tree relative to the repository base path.
567
-
568
- Parameters:
569
- relative_path: Base directory (relative to project root) to scan with :func:`get_abs_path`.
570
- max_depth: Maximum depth of traversal (0 = unlimited). Depth starts at 1 for root entries.
571
- max_lines: Global limit for rendered lines (0 = unlimited). When exceeded, the current depth
572
- finishes rendering before deeper levels are skipped.
573
- folders_first: When True, folders render before files within each directory.
574
- max_folders: Optional per-directory cap (0 = unlimited) on rendered folder entries before adding a
575
- ``# N more folders`` comment. When only a single folder exceeds the limit and ``max_folders`` is greater than zero, that folder is rendered
576
- directly instead of emitting a summary comment.
577
- max_files: Optional per-directory cap (0 = unlimited) on rendered file entries before adding a ``# N more files`` comment.
578
- As with folders, a single excess file is rendered when ``max_files`` is greater than zero.
579
- sort: Tuple of ``(key, direction)`` where key is one of :data:`SORT_BY_NAME`,
580
- :data:`SORT_BY_CREATED`, or :data:`SORT_BY_MODIFIED`; direction is :data:`SORT_ASC`
581
- or :data:`SORT_DESC`.
582
- ignore: Inline ``.gitignore`` content or ``file:`` reference. Examples::
583
-
584
- ignore=\"\"\"\\n*.pyc\\n__pycache__/\\n!important.py\\n\"\"\"
585
- ignore=\"file:.gitignore\" # relative to scan root
586
- ignore=\"file://.gitignore\" # URI-style relative path
587
- ignore=\"file:/abs/path/.gitignore\"
588
- ignore=\"file:///abs/path/.gitignore\"
589
-
590
- output_mode: One of :data:`OUTPUT_MODE_STRING`, :data:`OUTPUT_MODE_FLAT`, or
591
- :data:`OUTPUT_MODE_NESTED`.
592
-
593
- Returns:
594
- ``OUTPUT_MODE_STRING`` → ``str``: multi-line ASCII tree.
595
- ``OUTPUT_MODE_FLAT`` → ``list[dict]``: flattened sequence of TreeItem dictionaries.
596
- ``OUTPUT_MODE_NESTED`` → ``list[dict]``: nested TreeItem dictionaries where folders
597
- include ``items`` arrays.
598
-
599
- Notes:
600
- * The utility is synchronous; avoid calling from latency-sensitive async loops.
601
- * The ASCII renderer walks the established tree depth-first so connectors reflect parent/child structure,
602
- while traversal and limit calculations remain breadth-first by depth.
603
- * ``created`` and ``modified`` values in structured outputs are timezone-aware UTC
604
- :class:`datetime.datetime` objects::
605
-
606
- item = flat_items[0]
607
- iso = item[\"created\"].isoformat()
608
- epoch = item[\"created\"].timestamp()
609
-
610
- """
611
- abs_root = get_abs_path(relative_path)
612
-
613
- if not os.path.exists(abs_root):
614
- raise FileNotFoundError(f"Path does not exist: {relative_path!r}")
615
- if not os.path.isdir(abs_root):
616
- raise NotADirectoryError(f"Expected a directory, received: {relative_path!r}")
617
-
618
- sort_key, sort_direction = sort
619
- if sort_key not in {SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED}:
620
- raise ValueError(f"Unsupported sort key: {sort_key!r}")
621
- if sort_direction not in {SORT_ASC, SORT_DESC}:
622
- raise ValueError(f"Unsupported sort direction: {sort_direction!r}")
623
- if output_mode not in {OUTPUT_MODE_STRING, OUTPUT_MODE_FLAT, OUTPUT_MODE_NESTED}:
624
- raise ValueError(f"Unsupported output mode: {output_mode!r}")
625
- if max_depth < 0:
626
- raise ValueError("max_depth must be >= 0")
627
- if max_lines < 0:
628
- raise ValueError("max_lines must be >= 0")
629
-
630
- ignore_spec = _resolve_ignore_patterns(ignore, abs_root)
631
-
632
- root_stat = os.stat(abs_root, follow_symlinks=False)
633
- root_name = os.path.basename(os.path.normpath(abs_root)) or os.path.basename(abs_root)
634
- root_node = _TreeEntry(
635
- name=root_name,
636
- level=0,
637
- item_type="folder",
638
- created=datetime.fromtimestamp(root_stat.st_ctime, tz=timezone.utc),
639
- modified=datetime.fromtimestamp(root_stat.st_mtime, tz=timezone.utc),
640
- parent=None,
641
- items=[],
642
- rel_path="",
643
- )
644
-
645
- queue: deque[tuple[_TreeEntry, str, int]] = deque([(root_node, abs_root, 1)])
646
- nodes_in_order: list[_TreeEntry] = []
647
- limit_level: Optional[int] = None
648
- visibility_cache: dict[str, bool] = {}
649
-
650
- def make_entry(entry: os.DirEntry, parent: _TreeEntry, level: int, item_type: Literal["file", "folder"]) -> _TreeEntry:
651
- stat = entry.stat(follow_symlinks=False)
652
- rel_path = os.path.relpath(entry.path, abs_root)
653
- rel_posix = _normalize_relative_path(rel_path)
654
- return _TreeEntry(
655
- name=entry.name,
656
- level=level,
657
- item_type=item_type,
658
- created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
659
- modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
660
- parent=parent,
661
- items=[] if item_type == "folder" else None,
662
- rel_path=rel_posix,
663
- )
664
-
665
- while queue:
666
- parent_node, current_dir, level = queue.popleft()
667
-
668
- if max_depth and level > max_depth:
669
- continue
670
-
671
- remaining_depth = max_depth - level if max_depth else -1
672
- folders, files = _list_directory_children(
673
- current_dir,
674
- abs_root,
675
- ignore_spec,
676
- max_depth_remaining=remaining_depth,
677
- cache=visibility_cache,
678
- )
679
-
680
- folder_entries = [make_entry(folder, parent_node, level, "folder") for folder in folders]
681
- file_entries = [make_entry(file_entry, parent_node, level, "file") for file_entry in files]
682
-
683
- children = _apply_sorting_and_limits(
684
- folder_entries,
685
- file_entries,
686
- folders_first=folders_first,
687
- sort=sort,
688
- max_folders=max_folders,
689
- max_files=max_files,
690
- directory_node=parent_node,
691
- )
692
-
693
- parent_node.items = children
694
- nodes_in_order.extend(children)
695
-
696
- if max_lines and limit_level is None and len(nodes_in_order) >= max_lines:
697
- limit_level = level
698
-
699
- for child in children:
700
- if child.item_type != "folder":
701
- continue
702
- if max_depth and level >= max_depth:
703
- continue
704
- if limit_level is not None and level >= limit_level:
705
- continue
706
- child_abs = os.path.join(current_dir, child.name)
707
- queue.append((child, child_abs, level + 1))
708
-
709
- pruned_nodes: list[_TreeEntry] = nodes_in_order
710
- if max_lines and limit_level is not None:
711
- _prune_nested_children(
712
- root_node,
713
- lambda entry: entry.level <= limit_level,
714
- )
715
- pruned_nodes = [node for node in nodes_in_order if node.level <= limit_level]
716
-
717
- visible_nodes: list[_TreeEntry]
718
- if max_lines and limit_level is None:
719
- visible_nodes = pruned_nodes[:max_lines]
720
- else:
721
- visible_nodes = pruned_nodes
722
-
723
- visible_ids = {id(node) for node in visible_nodes}
724
- if visible_ids:
725
- _prune_to_visible(root_node, visible_ids)
726
-
727
- _mark_last_flags(root_node)
728
- _refresh_render_metadata(root_node)
729
-
730
- def iter_visible() -> Iterable[_TreeEntry]:
731
- for node in _iter_depth_first(root_node.items or []):
732
- if not visible_ids or id(node) in visible_ids:
733
- yield node
734
-
735
- if output_mode == OUTPUT_MODE_STRING:
736
- display_name = relative_path.strip() or root_name
737
- root_line = f"{display_name.rstrip(os.sep)}/"
738
- lines = [root_line]
739
- for node in iter_visible():
740
- lines.append(node.text)
741
- return "\n".join(lines)
742
-
743
- if output_mode == OUTPUT_MODE_FLAT:
744
- return _build_tree_items_flat(list(iter_visible()))
745
-
746
- return _to_nested_structure(root_node.items or [])
747
-
748
-
749
-@dataclass(slots=True)
750
-class _TreeEntry:
751
- name: str
752
- level: int
753
- item_type: Literal["file", "folder", "comment"]
754
- created: datetime
755
- modified: datetime
756
- parent: Optional["_TreeEntry"] = None
757
- items: Optional[list["_TreeEntry"]] = None
758
- is_last: bool = False
759
- rel_path: str = ""
760
- text: str = ""
761
-
762
- def as_dict(self) -> dict[str, Any]:
763
- return {
764
- "name": self.name,
765
- "level": self.level,
766
- "type": self.item_type,
767
- "created": self.created,
768
- "modified": self.modified,
769
- "text": self.text,
770
- "items": [child.as_dict() for child in self.items] if self.items is not None else None,
771
- }
772
-
773
-
774
-def _normalize_relative_path(path: str) -> str:
775
- normalized = path.replace(os.sep, "/")
776
- if normalized in {".", ""}:
777
- return ""
778
- while normalized.startswith("./"):
779
- normalized = normalized[2:]
780
- return normalized
781
-
782
-
783
-def _directory_has_visible_entries(
784
- directory: str,
785
- root_abs_path: str,
786
- ignore_spec: PathSpec,
787
- cache: dict[str, bool],
788
- max_depth_remaining: int,
789
-) -> bool:
790
- if max_depth_remaining == 0:
791
- return False
792
-
793
- cached = cache.get(directory)
794
- if cached is not None:
795
- return cached
796
-
797
- try:
798
- with os.scandir(directory) as iterator:
799
- for entry in iterator:
800
- rel_path = os.path.relpath(entry.path, root_abs_path)
801
- rel_posix = _normalize_relative_path(rel_path)
802
- is_dir = entry.is_dir(follow_symlinks=False)
803
-
804
- if is_dir:
805
- ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
806
- if ignored:
807
- next_depth = max_depth_remaining - 1 if max_depth_remaining > 0 else -1
808
- if next_depth == 0:
809
- continue
810
- if _directory_has_visible_entries(
811
- entry.path,
812
- root_abs_path,
813
- ignore_spec,
814
- cache,
815
- next_depth,
816
- ):
817
- cache[directory] = True
818
- return True
819
- continue
820
- else:
821
- if ignore_spec.match_file(rel_posix):
822
- continue
823
-
824
- cache[directory] = True
825
- return True
826
- except FileNotFoundError:
827
- cache[directory] = False
828
- return False
829
-
830
- cache[directory] = False
831
- return False
832
-
833
-
834
-def _create_summary_comment(parent: _TreeEntry, noun: str, count: int) -> _TreeEntry:
835
- label = noun
836
- if count == 1 and noun.endswith("s"):
837
- label = noun[:-1]
838
- elif count > 1 and not noun.endswith("s"):
839
- label = f"{noun}s"
840
- return _TreeEntry(
841
- name=f"{count} more {label}",
842
- level=parent.level + 1,
843
- item_type="comment",
844
- created=parent.created,
845
- modified=parent.modified,
846
- parent=parent,
847
- items=None,
848
- rel_path=f"{parent.rel_path}#summary:{noun}:{count}",
849
- )
850
-
851
-
852
-def _prune_nested_children(node: _TreeEntry, predicate: Callable[[_TreeEntry], bool]) -> None:
853
- if node.items is None:
854
- return
855
- pruned: list[_TreeEntry] = []
856
- for child in node.items:
857
- if predicate(child):
858
- _prune_nested_children(child, predicate)
859
- pruned.append(child)
860
- node.items = pruned
861
-
862
-
863
-def _prune_to_visible(node: _TreeEntry, visible_ids: set[int]) -> None:
864
- if node.items is None:
865
- return
866
- filtered: list[_TreeEntry] = []
867
- for child in node.items:
868
- if not visible_ids or id(child) in visible_ids:
869
- _prune_to_visible(child, visible_ids)
870
- filtered.append(child)
871
- node.items = filtered
872
-
873
-
874
-def _mark_last_flags(node: _TreeEntry) -> None:
875
- if node.items is None:
876
- return
877
- total = len(node.items)
878
- for index, child in enumerate(node.items):
879
- child.is_last = index == total - 1
880
- _mark_last_flags(child)
881
-
882
-
883
-def _refresh_render_metadata(node: _TreeEntry) -> None:
884
- if node.items is None:
885
- return
886
- for child in node.items:
887
- child.text = _format_line(child)
888
- _refresh_render_metadata(child)
889
-
890
-
891
-def _resolve_ignore_patterns(ignore: str | None, root_abs_path: str) -> Optional[PathSpec]:
892
- if ignore is None:
893
- return None
894
-
895
- content: str
896
- if ignore.startswith("file:"):
897
- reference = ignore[5:]
898
- if reference.startswith("///"):
899
- reference_path = reference[2:]
900
- elif reference.startswith("//"):
901
- reference_path = os.path.join(root_abs_path, reference[2:])
902
- elif reference.startswith("/"):
903
- reference_path = reference
904
- else:
905
- reference_path = os.path.join(root_abs_path, reference)
906
-
907
- try:
908
- with open(reference_path, "r", encoding="utf-8") as handle:
909
- content = handle.read()
910
- except FileNotFoundError as exc:
911
- raise FileNotFoundError(f"Ignore file not found: {reference_path}") from exc
912
- else:
913
- content = ignore
914
-
915
- lines = [
916
- line.strip()
917
- for line in content.splitlines()
918
- if line.strip() and not line.strip().startswith("#")
919
- ]
920
-
921
- if not lines:
922
- return None
923
-
924
- return PathSpec.from_lines("gitwildmatch", lines)
925
-
926
-
927
-def _list_directory_children(
928
- directory: str,
929
- root_abs_path: str,
930
- ignore_spec: Optional[PathSpec],
931
- *,
932
- max_depth_remaining: int,
933
- cache: dict[str, bool],
934
-) -> tuple[list[os.DirEntry], list[os.DirEntry]]:
935
- folders: list[os.DirEntry] = []
936
- files: list[os.DirEntry] = []
937
-
938
- try:
939
- with os.scandir(directory) as iterator:
940
- for entry in iterator:
941
- if entry.name in (".", ".."):
942
- continue
943
- rel_path = os.path.relpath(entry.path, root_abs_path)
944
- rel_posix = _normalize_relative_path(rel_path)
945
- is_directory = entry.is_dir(follow_symlinks=False)
946
-
947
- if ignore_spec:
948
- if is_directory:
949
- ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
950
- if ignored:
951
- if _directory_has_visible_entries(
952
- entry.path,
953
- root_abs_path,
954
- ignore_spec,
955
- cache,
956
- max_depth_remaining - 1,
957
- ):
958
- folders.append(entry)
959
- continue
960
- else:
961
- if ignore_spec.match_file(rel_posix):
962
- continue
963
-
964
- if is_directory:
965
- folders.append(entry)
966
- else:
967
- files.append(entry)
968
- except FileNotFoundError:
969
- return ([], [])
970
-
971
- return (folders, files)
972
-
973
-
974
-def _apply_sorting_and_limits(
975
- folders: list[_TreeEntry],
976
- files: list[_TreeEntry],
977
- *,
978
- folders_first: bool,
979
- sort: tuple[str, str],
980
- max_folders: int | None,
981
- max_files: int | None,
982
- directory_node: _TreeEntry,
983
-) -> list[_TreeEntry]:
984
- sort_key, sort_dir = sort
985
- reverse = sort_dir == SORT_DESC
986
-
987
- def key_fn(node: _TreeEntry):
988
- if sort_key == SORT_BY_NAME:
989
- return node.name.casefold()
990
- if sort_key == SORT_BY_CREATED:
991
- return node.created
992
- return node.modified
993
-
994
- folders_sorted = sorted(folders, key=key_fn, reverse=reverse)
995
- files_sorted = sorted(files, key=key_fn, reverse=reverse)
996
- combined: list[_TreeEntry] = []
997
-
998
- def append_group(group: list[_TreeEntry], limit: int | None, noun: str) -> None:
999
- if limit == 0:
1000
- limit = None
1001
- if not group:
1002
- return
1003
- if limit is None:
1004
- combined.extend(group)
1005
- return
1006
-
1007
- limit = max(limit, 0)
1008
- visible = group[:limit]
1009
- combined.extend(visible)
1010
-
1011
- overflow = group[limit:]
1012
- if not overflow:
1013
- return
1014
-
1015
- if len(overflow) == 1 and limit > 0:
1016
- combined.append(overflow[0])
1017
- return
1018
-
1019
- combined.append(
1020
- _create_summary_comment(
1021
- directory_node,
1022
- noun,
1023
- len(overflow),
1024
- )
1025
- )
1026
-
1027
- if folders_first:
1028
- append_group(folders_sorted, max_folders, "folder")
1029
- append_group(files_sorted, max_files, "file")
1030
- else:
1031
- append_group(files_sorted, max_files, "file")
1032
- append_group(folders_sorted, max_folders, "folder")
1033
-
1034
- return combined
1035
-
1036
-
1037
-def _format_line(node: _TreeEntry) -> str:
1038
- segments: list[str] = []
1039
- ancestor = node.parent
1040
- while ancestor and ancestor.parent is not None:
1041
- segments.append(" " if ancestor.is_last else "│ ")
1042
- ancestor = ancestor.parent
1043
- segments.reverse()
1044
-
1045
- connector = "└── " if node.is_last else "├── "
1046
- if node.item_type == "folder":
1047
- label = f"{node.name}/"
1048
- elif node.item_type == "comment":
1049
- label = f"# {node.name}"
1050
- else:
1051
- label = node.name
1052
-
1053
- return "".join(segments) + connector + label
1054
-
1055
-
1056
-def _build_tree_items_flat(items: Sequence[_TreeEntry]) -> list[dict]:
1057
- return [
1058
- {
1059
- "name": node.name,
1060
- "level": node.level,
1061
- "type": node.item_type,
1062
- "created": node.created,
1063
- "modified": node.modified,
1064
- "text": node.text,
1065
- "items": None,
1066
- }
1067
- for node in items
1068
- ]
1069
-
1070
-
1071
-def _to_nested_structure(items: Sequence[_TreeEntry]) -> list[dict]:
1072
- def convert(node: _TreeEntry) -> dict:
1073
- children = None
1074
- if node.items is not None:
1075
- children = [convert(child) for child in node.items]
1076
- return {
1077
- "name": node.name,
1078
- "level": node.level,
1079
- "type": node.item_type,
1080
- "created": node.created,
1081
- "modified": node.modified,
1082
- "text": node.text,
1083
- "items": children,
1084
- }
1085
-
1086
- return [convert(item) for item in items]
1087
-
1088
-
1089
-def _iter_depth_first(items: Sequence[_TreeEntry]) -> Iterable[_TreeEntry]:
1090
- for node in items:
1091
- yield node
1092
- if node.items:
1093
- yield from _iter_depth_first(node.items)
tests/test_file_tree_visualize.py
+2
-5
@@ -23,7 +23,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
23
if str(REPO_ROOT) not in sys.path:
24
sys.path.insert(0, str(REPO_ROOT))
25
26
-from python.helpers.files import (
26
+from python.helpers.file_tree import (
27
OUTPUT_MODE_FLAT,
28
OUTPUT_MODE_NESTED,
29
OUTPUT_MODE_STRING,
@@ -32,12 +32,9 @@ from python.helpers.files import (
32
SORT_BY_MODIFIED,
33
SORT_BY_NAME,
34
SORT_DESC,
35
- create_dir,
36
- delete_dir,
35
file_tree,
38
- get_abs_path,
39
- write_file,
36
)
37
+from python.helpers.files import create_dir, delete_dir, get_abs_path, write_file
38
39
40
BASE_TEMP_ROOT = "tmp/tests/file_tree/visualize"