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"