#!/usr/bin/env python3
# Timestamp: 2026-03-15
# File: scitex_app/sdk/_tree.py
"""Tree builder utility for FilesBackend.
Converts flat file listings into nested directory tree structures.
Reusable by any app that needs a file browser UI.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
[docs]
def build_tree(
backend: Any,
directory: str = "",
*,
extensions: Optional[List[str]] = None,
skip_hidden: bool = True,
max_depth: int = 10,
) -> List[Dict[str, Any]]:
"""Build a nested tree structure from a FilesBackend.
Parameters
----------
backend : FilesBackend
A file storage backend implementing the FilesBackend protocol.
directory : str
Starting directory (relative to backend root). Default: root.
extensions : list of str, optional
Filter files by extension (e.g., [".yaml", ".png"]).
Directories are always included for traversal.
skip_hidden : bool
Skip files/directories starting with ".". Default: True.
max_depth : int
Maximum recursion depth to prevent runaway traversal. Default: 10.
Returns
-------
list of dict
Nested tree structure::
[
{"path": "subdir", "name": "subdir", "type": "directory",
"children": [...]},
{"path": "file.yaml", "name": "file.yaml", "type": "file"},
]
"""
if max_depth <= 0:
return []
# Get all entries in this directory
entries = _list_entries(backend, directory)
items = []
for entry in entries:
name = Path(entry["path"]).name
if skip_hidden and name.startswith("."):
continue
if entry["type"] == "directory":
try:
children = build_tree(
backend,
entry["path"],
extensions=extensions,
skip_hidden=skip_hidden,
max_depth=max_depth - 1,
)
except PermissionError:
continue # skip directories we can't read
if children: # only include non-empty directories
items.append(
{
"path": entry["path"],
"name": name,
"type": "directory",
"children": children,
}
)
else:
# Apply extension filter to files
if extensions:
suffix = Path(name).suffix.lower()
if suffix not in extensions:
continue
items.append(
{
"path": entry["path"],
"name": name,
"type": "file",
}
)
# Sort: pure alphabetical (case-insensitive), dirs and files interleaved
items.sort(key=lambda x: x["name"].lower())
return items
def _list_entries(
backend: Any,
directory: str = "",
) -> List[Dict[str, str]]:
"""List directory entries with type information.
Tries backend.list_entries() first (if available, returns files + dirs).
Falls back to backend.list() for files and attempts directory discovery.
"""
# Preferred: backend supports list_entries() returning typed entries
if hasattr(backend, "list_entries"):
return backend.list_entries(directory)
# Fallback: use list() for files, try to discover directories
# via the backend's internal structure
if hasattr(backend, "_root"):
# FileSystemBackend — access pathlib directly for directory info
root = backend._root
target = (root / directory) if directory else root
if not target.is_dir():
return []
entries = []
try:
children = sorted(target.iterdir(), key=lambda x: x.name.lower())
except PermissionError:
return []
for item in children:
try:
rel = str(item.relative_to(root))
if item.is_dir():
entries.append({"path": rel, "type": "directory"})
elif item.is_file():
entries.append({"path": rel, "type": "file"})
except PermissionError:
continue # skip files/dirs we can't access
return entries
# Last resort: list() returns only files, no directory info
files = backend.list(directory)
return [{"path": f, "type": "file"} for f in files]
# EOF