"""
OS Helper — optional "Tree Radar" treemap GUI.
Module summary
--------------
This module is an *optional* surface on top of the pure ``os_helper``
library. It serves a small, self-contained web dashboard — the "Tree
Radar" from ``GUI.md`` — that turns "what is on disk" into a single,
legible treemap:
- Each rectangle is a file or folder; its **area** is the file/subtree
**size**, and its **color** encodes one of three modes:
- **age** (recent vs. stale, from each file's mtime),
- **hash-dedupe** (files that share a content hash form a cluster,
colored so duplicates stand out), or
- **type family** (code / data / media / archive / other).
- Hovering a rectangle reveals its absolute path, human-readable size
(via :func:`os_helper.format_size`), last-modified timestamp and — on
demand — its RIPEMD-160 / BLAKE2b content hash (via
:func:`os_helper.hashfile`).
Why it exists
-------------
The CLI handles "one path, one predicate at a time" well. A GUI must go
further, so this one shows the *whole* tree at a glance. It is a thin
client: **all** the read/compute work is delegated to the existing
``os_helper`` helpers (``os.walk`` size collection, ``format_size``,
``hashfile``), never reinvented here.
What it consumes / produces
---------------------------
- Consumes: a local directory root (a path on this machine).
- Produces: a locally-served HTML page (``GET /gui``) and a JSON tree
(``GET /api/tree``). Nothing is uploaded; no telemetry; no account.
Lean-import contract
--------------------
``import os_helper as osh`` must stay free of FastAPI/uvicorn. FastAPI is
therefore imported *inside* :func:`create_app` / :func:`run`, never at
module top level, and the whole GUI lives behind the optional
``os-helper[gui]`` extra. Importing *this* module is cheap; it only pulls
FastAPI when you actually build or run the app.
Usage example
-------------
>>> # Requires the optional extra: pip install "os-helper[gui]"
>>> from os_helper.gui import run
>>> run(root="~/Downloads", port=8017) # doctest: +SKIP
Author
------
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
"""
# Postpone annotation evaluation so FastAPI types can be referenced in
# signatures without importing FastAPI at module import time.
from __future__ import annotations
import json
import os
from typing import TYPE_CHECKING, Any, TypedDict
# Reuse the library's own helpers — do NOT reinvent size/hash/format logic.
# These are all pure-Python and dependency-light, so importing them keeps the
# GUI module cheap to import (the FastAPI cost is deferred to create_app/run).
from . import error, format_size, hashfile, info, warning
# FastAPI is an *optional* dependency (the ``gui`` extra). We only import it
# for type-checkers here; the runtime import happens lazily in create_app().
if TYPE_CHECKING: # pragma: no cover — typing only, never runs
from fastapi import FastAPI
# ---------------------------------------------------------------------------
# Type-family classification — used for the "type family" color mode.
# ---------------------------------------------------------------------------
# Map lower-cased file extensions to a coarse "family" the treemap colors by.
# Kept deliberately small and opinionated: the goal is a glanceable palette,
# not an exhaustive MIME table. Anything unlisted falls into "other".
_TYPE_FAMILIES: dict[str, str] = {
# Source code and scripts.
".py": "code",
".js": "code",
".ts": "code",
".c": "code",
".h": "code",
".cpp": "code",
".rs": "code",
".go": "code",
".java": "code",
".sh": "code",
".rb": "code",
# Structured / tabular data and text.
".json": "data",
".yaml": "data",
".yml": "data",
".csv": "data",
".tsv": "data",
".txt": "data",
".md": "data",
".xml": "data",
".parquet": "data",
# Media: images, audio, video.
".png": "media",
".jpg": "media",
".jpeg": "media",
".gif": "media",
".svg": "media",
".mp3": "media",
".wav": "media",
".flac": "media",
".mp4": "media",
".mov": "media",
".mkv": "media",
# Archives / compressed bundles.
".zip": "archive",
".tar": "archive",
".gz": "archive",
".bz2": "archive",
".xz": "archive",
".7z": "archive",
".rar": "archive",
}
def _type_family(name: str) -> str:
"""Classify a filename into a coarse type family for treemap coloring.
Parameters
----------
name : str
A file's base name (extension is read from it, case-insensitively).
Returns
-------
str
One of ``"code"``, ``"data"``, ``"media"``, ``"archive"`` or
``"other"`` — the family used to pick a color in "type" mode.
Examples
--------
>>> _type_family("main.PY")
'code'
>>> _type_family("archive.tar.gz")
'archive'
>>> _type_family("noext")
'other'
"""
# os.path.splitext keeps only the final extension (".gz" for "x.tar.gz"),
# which is the right granularity for a coarse family bucket.
_, ext = os.path.splitext(name)
# Look up the lower-cased extension; unknown/extensionless files are "other".
return _TYPE_FAMILIES.get(ext.lower(), "other")
# ---------------------------------------------------------------------------
# The JSON shape returned to the browser. Declaring it as a TypedDict keeps
# the contract explicit (CODING.md rule 3) and documents the tree schema.
# ---------------------------------------------------------------------------
[docs]
class TreeNode(TypedDict, total=False):
"""One node of the directory tree sent to the browser as JSON.
A node is either a file (no ``children``) or a directory (with a
``children`` list). Sizes are in bytes; ``size_h`` is the pre-formatted
human-readable string so the front-end never re-implements
:func:`os_helper.format_size`.
"""
name: str # base name (leaf display label)
path: str # absolute path on this machine
size: int # size in bytes (own size for files; subtree total for dirs)
size_h: str # human-readable size via os_helper.format_size
mtime: float # last-modified time, seconds since the epoch
is_dir: bool # True for directories, False for files
family: str # type family ("code" / "data" / ... ) — files only
children: list[TreeNode] # present on directories only
def _scan_tree(
root: str,
max_depth: int,
max_entries: int,
_depth: int = 0,
_budget: list[int] | None = None,
) -> TreeNode:
"""Walk a directory into a nested :class:`TreeNode` tree of sizes.
This is the treemap's data source. It mirrors the ``os.walk``-based
size collection used elsewhere in :mod:`os_helper.misc_utils`, but
returns a *nested* structure (rather than a flat mapping) because the
treemap needs the hierarchy. File sizes come from ``os.stat`` and are
rendered with :func:`os_helper.format_size` so the human-readable
string is computed exactly once, server-side.
Parameters
----------
root : str
Absolute path of the directory (or file) to scan.
max_depth : int
Maximum recursion depth. Directories deeper than this are summarized
by their own on-disk entry only (their children are omitted) to keep
the payload bounded on very deep trees.
max_entries : int
Global cap on how many filesystem entries are visited across the whole
scan. Protects the server from pathological (400k-file) trees.
_depth : int, optional
Internal recursion depth counter. Do not pass this yourself.
_budget : list of int, optional
Internal single-element mutable counter tracking the remaining entry
budget across the recursion. Do not pass this yourself.
Returns
-------
TreeNode
The root node, with nested ``children`` for directories.
Notes
-----
Hidden entries (names starting with ``"."``) are skipped, matching the
convention in :func:`os_helper.folder_description`. Unreadable entries
(permission errors, broken symlinks) are logged via ``osh.warning`` and
skipped rather than aborting the whole scan.
"""
# A single-element list acts as a shared, mutable counter across the
# recursion so the entry cap is truly global, not per-directory.
if _budget is None:
_budget = [max_entries]
# Base name for display; fall back to the full path for filesystem roots
# like "/" whose basename is empty.
name = os.path.basename(root.rstrip(os.sep)) or root
# Leaf case: a file. Stat it once for size + mtime; classify its family.
if not os.path.isdir(root):
try:
st = os.stat(root)
except OSError as exc:
# Broken symlink or vanished file: report it, size it as zero.
warning(f"Tree Radar: cannot stat file '{root}': {exc}")
return {
"name": name,
"path": root,
"size": 0,
"size_h": format_size(0),
"mtime": 0.0,
"is_dir": False,
"family": _type_family(name),
}
size = int(st.st_size)
return {
"name": name,
"path": root,
"size": size,
"size_h": format_size(size),
"mtime": float(st.st_mtime),
"is_dir": False,
"family": _type_family(name),
}
# Directory case: accumulate children and roll their sizes up.
children: list[TreeNode] = []
total = 0
# Directory mtime is still useful for "age" coloring of folder rectangles.
try:
dir_mtime = float(os.stat(root).st_mtime)
except OSError:
dir_mtime = 0.0
# Beyond max_depth we stop descending: return the directory node without
# children so deep trees don't explode the JSON payload.
if _depth >= max_depth:
return {
"name": name,
"path": root,
"size": 0,
"size_h": format_size(0),
"mtime": dir_mtime,
"is_dir": True,
"children": [],
}
# List the directory once; permission errors are logged and skipped so a
# single unreadable folder never aborts the whole scan.
try:
entries = sorted(os.scandir(root), key=lambda e: e.name)
except OSError as exc:
warning(f"Tree Radar: cannot list directory '{root}': {exc}")
entries = []
for entry in entries:
# Stop entirely once the global entry budget is exhausted.
if _budget[0] <= 0:
break
# Skip dotfiles/dotdirs: editor/OS metadata, not user content.
if entry.name.startswith("."):
continue
# Charge one unit of the global budget per entry visited.
_budget[0] -= 1
# Recurse: directories descend, files return a leaf node. We pass the
# shared depth + budget so limits are enforced across the whole tree.
child = _scan_tree(
entry.path,
max_depth=max_depth,
max_entries=max_entries,
_depth=_depth + 1,
_budget=_budget,
)
children.append(child)
total += child["size"]
return {
"name": name,
"path": root,
"size": total,
"size_h": format_size(total),
"mtime": dir_mtime,
"is_dir": True,
"children": children,
}
def _dedupe_groups(
node: TreeNode, groups: dict[str, list[str]] | None = None
) -> dict[str, list[str]]:
"""Group file paths by shared content hash (the Dedupe Lens data).
Walks an already-scanned :class:`TreeNode` tree, hashes every file with
:func:`os_helper.hashfile`, and returns a mapping ``hash -> [paths]``
limited to hashes that occur more than once (i.e. actual duplicates).
This feeds the treemap's "hash-dedupe" color mode and the Dedupe Lens.
Parameters
----------
node : TreeNode
A node produced by :func:`_scan_tree` (usually the root).
groups : dict, optional
Internal accumulator mapping content hash to the list of paths seen
with that hash. Do not pass this yourself.
Returns
-------
dict of str to list of str
Mapping from content hash to the ≥2 paths sharing it. Unique files
are omitted, so an empty dict means "no duplicates found".
Notes
-----
Hashing reads file content, so this is the expensive path. It is only
invoked when the caller asks for dedupe coloring (``?dedupe=1``), never on
the default tree fetch.
"""
# First (top-level) call seeds the accumulator; recursion reuses it so all
# files across the tree land in the same hash buckets.
if groups is None:
groups = {}
# Directory: recurse into children, nothing to hash for the folder itself.
if node.get("is_dir"):
for child in node.get("children", []):
_dedupe_groups(child, groups)
return groups
# File: hash its content and bucket the path under that digest. hashfile
# already handles the RIPEMD-160 / BLAKE2b fallback for us.
path = node["path"]
try:
digest = hashfile(path, hash_content=True)
except OSError as exc:
# Unreadable file: skip it rather than aborting the whole dedupe pass.
warning(f"Tree Radar: cannot hash '{path}' for dedupe: {exc}")
return groups
groups.setdefault(digest, []).append(path)
return groups
[docs]
def build_tree_payload(
root: str,
max_depth: int = 12,
max_entries: int = 20000,
dedupe: bool = False,
) -> dict[str, Any]:
"""Build the full JSON payload served by ``GET /api/tree``.
This is the backend entry point behind the data endpoint. It scans the
tree (sizes + mtimes + families) and, when ``dedupe`` is set, also
computes content-hash duplicate groups.
Parameters
----------
root : str
Directory to scan. ``~`` is expanded and the path is made absolute.
max_depth : int, optional
Maximum recursion depth (default 12).
max_entries : int, optional
Global cap on entries visited (default 20000).
dedupe : bool, optional
If True, also hash every file and include a ``dedupe`` mapping of
duplicate clusters. Defaults to False (cheap, no content reads).
Returns
-------
dict
``{"root": <abs path>, "tree": <TreeNode>, "dedupe": {hash: [paths]}}``.
Raises
------
NotADirectoryError
If ``root`` does not resolve to an existing directory.
"""
# Normalize the requested root: expand ~ and resolve to an absolute path so
# the payload always carries unambiguous, machine-local paths.
abs_root = os.path.abspath(os.path.expanduser(root))
# Fail loudly (the route turns this into an HTTP 400) if the path is not a
# real directory — the treemap only makes sense on a folder.
if not os.path.isdir(abs_root):
raise NotADirectoryError(f"Not a directory: {abs_root}")
info(f"Tree Radar: scanning '{abs_root}' (max_depth={max_depth}, dedupe={dedupe})")
# Walk the tree into a nested node structure of sizes/mtimes/families.
tree = _scan_tree(abs_root, max_depth=max_depth, max_entries=max_entries)
# Only pay for content hashing when the caller actually asked for it.
dedupe_map: dict[str, list[str]] = {}
if dedupe:
all_groups = _dedupe_groups(tree)
# Keep only real duplicate clusters (a hash seen on 2+ distinct paths).
dedupe_map = {h: paths for h, paths in all_groups.items() if len(paths) > 1}
return {"root": abs_root, "tree": tree, "dedupe": dedupe_map}
# ---------------------------------------------------------------------------
# The single-page front-end. Self-contained HTML + vanilla JS; the only
# client dependency is D3 loaded from a CDN (no build step, per GUI.md).
# ---------------------------------------------------------------------------
# The page is a plain module-level constant so serving it is a zero-cost
# string response. It renders a squarified treemap via d3.treemap, wires the
# three color modes (age / dedupe / type), and shows a hover tooltip. All
# data comes from GET /api/tree; nothing here phones home.
_INDEX_HTML: str = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OS Helper — Tree Radar</title>
<!-- D3 is the only client dependency, loaded from a CDN (no build step). -->
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
:root { color-scheme: light dark; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
header { padding: 10px 14px; border-bottom: 1px solid #8884; display: flex;
gap: 10px; align-items: center; flex-wrap: wrap; }
header h1 { font-size: 16px; margin: 0 12px 0 0; }
input[type=text] { padding: 6px 8px; min-width: 320px; }
button, select { padding: 6px 10px; }
#status { font-size: 12px; opacity: 0.7; margin-left: auto; }
#chart { width: 100vw; height: calc(100vh - 52px); }
.node rect { stroke: #0004; stroke-width: 0.5px; cursor: pointer; }
.node text { font-size: 10px; pointer-events: none; fill: #000; }
#tip { position: fixed; pointer-events: none; background: #000d; color: #fff;
padding: 6px 8px; border-radius: 4px; font-size: 12px; display: none;
max-width: 480px; z-index: 10; }
</style>
</head>
<body>
<header>
<h1>Tree Radar</h1>
<!-- Root folder to scan. Defaults to whatever the server was launched with. -->
<input type="text" id="root" placeholder="/absolute/path/to/folder" />
<button id="scan">Scan</button>
<!-- Color mode: which signal the rectangle color encodes. -->
<label>Color:
<select id="mode">
<option value="age">age</option>
<option value="dedupe">hash-dedupe</option>
<option value="type">type family</option>
</select>
</label>
<span id="status">idle</span>
</header>
<div id="chart"></div>
<div id="tip"></div>
<script>
// ---- Tree Radar front-end (vanilla JS + D3) ----------------------------
// All state is local to the browser. We fetch JSON from /api/tree and lay it
// out as a squarified treemap; color encodes age / dedupe / type per the
// selected mode. Your paths and hashes never leave this machine.
const statusEl = document.getElementById("status"); // small status line
const rootEl = document.getElementById("root"); // root-path input
const modeEl = document.getElementById("mode"); // color-mode selector
const tip = document.getElementById("tip"); // hover tooltip
let lastData = null; // last payload from /api/tree, cached for re-color
// Pre-fill the root input with the server's launch root (injected below).
rootEl.value = window.__OSH_ROOT__ || "";
// Coarse type-family palette. Colorblind-ish distinct hues; shapes are all
// rectangles so this is the one place color carries meaning (see GUI.md note
// on colorblind-safety — a pattern fallback is a future milestone).
const FAMILY_COLORS = {
code: "#4e79a7", data: "#59a14f", media: "#e15759",
archive: "#f28e2b", other: "#9c9c9c",
};
// Fetch the tree JSON for a root path. `dedupe` triggers server-side hashing.
async function fetchTree(root, dedupe) {
statusEl.textContent = "scanning...";
const url = "/api/tree?root=" + encodeURIComponent(root) +
(dedupe ? "&dedupe=1" : "");
const resp = await fetch(url);
if (!resp.ok) {
// Surface backend errors (e.g. "not a directory") in the status line.
const msg = await resp.text();
statusEl.textContent = "error: " + msg;
throw new Error(msg);
}
return await resp.json();
}
// Map a file's mtime to a blue(recent)->red(stale) color across the range of
// mtimes actually present in the current tree, so the scale auto-fits.
function ageColorScale(root) {
const times = [];
// Collect every leaf mtime to establish min/max for the scale.
(function walk(n) {
if (n.children) n.children.forEach(walk);
else if (n.data && n.data.mtime) times.push(n.data.mtime);
})(root);
const min = d3.min(times) || 0, max = d3.max(times) || 1;
// d3 interpolateRdYlBu: 0 -> red (old), 1 -> blue (recent). We invert the
// domain so recent files (large mtime) map to blue.
const scale = d3.scaleLinear().domain([min, max]).range([0, 1]);
return (mtime) => d3.interpolateRdYlBu(scale(mtime || min));
}
// Decide a rectangle's fill for the current color mode.
function colorFor(leaf, mode, ageScale, dupSet) {
const d = leaf.data;
if (mode === "type") return FAMILY_COLORS[d.family || "other"];
if (mode === "dedupe") {
// Gold = part of a duplicate cluster; grey = unique. dupSet holds every
// path that shares its content hash with at least one other file.
return dupSet.has(d.path) ? "#d4af37" : "#9c9c9c";
}
// Default: age coloring.
return ageScale(d.mtime);
}
// Render the treemap for the cached payload + current color mode.
function render() {
if (!lastData) return;
const chart = document.getElementById("chart");
const w = chart.clientWidth, h = chart.clientHeight;
// Build the D3 hierarchy; sum leaf sizes so parent areas roll up correctly.
const root = d3.hierarchy(lastData.tree)
.sum((d) => (d.children ? 0 : d.size))
.sort((a, b) => b.value - a.value);
// Squarified treemap layout with a little padding for legibility.
d3.treemap().size([w, h]).paddingInner(1)(root);
const mode = modeEl.value;
const ageScale = ageColorScale(root);
// Flatten the server's dedupe map (hash -> [paths]) into a fast lookup set.
const dupSet = new Set();
Object.values(lastData.dedupe || {}).forEach((paths) =>
paths.forEach((p) => dupSet.add(p)));
// Clear and redraw the SVG from scratch — simplest correct approach for a
// full re-layout on every scan / mode change.
const svg = d3.select("#chart").html("")
.append("svg").attr("width", w).attr("height", h);
// One <g> per leaf rectangle.
const leaves = root.leaves();
const g = svg.selectAll("g").data(leaves).enter()
.append("g").attr("class", "node")
.attr("transform", (d) => `translate(${d.x0},${d.y0})`);
g.append("rect")
.attr("width", (d) => Math.max(0, d.x1 - d.x0))
.attr("height", (d) => Math.max(0, d.y1 - d.y0))
.attr("fill", (d) => colorFor(d, mode, ageScale, dupSet))
// Hover: show the full detail tooltip (path, size, mtime, family).
.on("mousemove", (event, d) => {
const dt = new Date((d.data.mtime || 0) * 1000);
tip.style.display = "block";
tip.style.left = (event.clientX + 12) + "px";
tip.style.top = (event.clientY + 12) + "px";
tip.innerHTML =
"<b>" + d.data.path + "</b><br>" +
d.data.size_h + " · " + (d.data.family || "dir") + "<br>" +
"modified " + dt.toLocaleString();
})
.on("mouseout", () => { tip.style.display = "none"; });
// Label only rectangles large enough to fit readable text.
g.filter((d) => (d.x1 - d.x0) > 40 && (d.y1 - d.y0) > 14)
.append("text").attr("x", 3).attr("y", 12)
.text((d) => d.data.name);
statusEl.textContent =
"root: " + lastData.root + " (" + leaves.length + " files shown)";
}
// Kick off a scan for the current root + mode, then render.
async function scan() {
const root = rootEl.value.trim();
if (!root) { statusEl.textContent = "enter a folder path"; return; }
// Dedupe coloring needs the (expensive) server-side hash pass.
const wantDedupe = modeEl.value === "dedupe";
try {
lastData = await fetchTree(root, wantDedupe);
render();
} catch (e) { /* status line already shows the error */ }
}
// Wire the controls: Scan button, Enter in the input, and mode changes.
document.getElementById("scan").addEventListener("click", scan);
rootEl.addEventListener("keydown", (e) => { if (e.key === "Enter") scan(); });
modeEl.addEventListener("change", () => {
// Switching to dedupe requires a fresh fetch (needs hashes); other modes
// just recolor the cached tree instantly.
if (modeEl.value === "dedupe") scan(); else render();
});
window.addEventListener("resize", render);
// Auto-scan the launch root on first load so the page is useful immediately.
if (rootEl.value) scan();
</script>
</body>
</html>
"""
[docs]
def create_app(default_root: str | None = None) -> FastAPI:
"""Build the FastAPI application serving the Tree Radar GUI.
FastAPI is imported *here*, not at module top level, so that merely
importing :mod:`os_helper` (or even this module) never pulls the web
stack. The web dependencies live behind the ``os-helper[gui]`` extra.
Parameters
----------
default_root : str, optional
Folder to pre-fill in the page and auto-scan on load. Defaults to the
current working directory when not given.
Returns
-------
fastapi.FastAPI
The configured application, exposing ``GET /gui`` (the page) and
``GET /api/tree`` (the JSON data endpoint).
Raises
------
ImportError
If FastAPI is not installed (i.e. the ``gui`` extra is missing),
with a message pointing at ``pip install "os-helper[gui]"``.
"""
# Lazy, guarded import: this is the single place the optional web stack is
# required. A clear ImportError beats an opaque ModuleNotFoundError.
try:
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
except ImportError as exc: # pragma: no cover — exercised only without extra
error("Tree Radar GUI requires the optional 'gui' extra (FastAPI/uvicorn).")
raise ImportError(
"The Tree Radar GUI needs FastAPI/uvicorn. Install the extra:\n"
' pip install "os-helper[gui]"'
) from exc
# Resolve the launch root once so both the page and default scans agree.
launch_root = os.path.abspath(os.path.expanduser(default_root or os.getcwd()))
app = FastAPI(
title="OS Helper — Tree Radar",
description="Local-first treemap disk dashboard. Nothing leaves your machine.",
)
@app.get("/gui", response_class=HTMLResponse)
def gui_page() -> HTMLResponse:
"""Serve the self-contained Tree Radar HTML page.
Returns
-------
fastapi.responses.HTMLResponse
The single-page app, with the launch root injected so the page
auto-scans a sensible folder on first load.
"""
# Inject the server's launch root into the page as a JS global so the
# input is pre-filled and the first auto-scan targets a real folder.
# Use json.dumps to produce a *valid JS string literal*: it escapes
# backslashes and quotes correctly, so Windows paths like
# ``C:\Users\...`` embed safely (``C:\\Users\\...``) rather than
# breaking the script.
root_literal = json.dumps(launch_root)
page = _INDEX_HTML.replace(
'window.__OSH_ROOT__ || ""',
f'{root_literal} || ""',
)
return HTMLResponse(content=page)
@app.get("/api/tree", response_class=JSONResponse)
def api_tree(
root: str = Query(default=None, description="Folder to scan."),
depth: int = Query(default=12, ge=1, le=64),
dedupe: int = Query(default=0, ge=0, le=1),
) -> JSONResponse:
"""Return the directory tree as JSON for the treemap.
Parameters
----------
root : str, optional
Folder to scan; defaults to the server's launch root.
depth : int, optional
Maximum recursion depth (1–64, default 12).
dedupe : int, optional
When ``1``, also hash files and include duplicate clusters.
Returns
-------
fastapi.responses.JSONResponse
The payload from :func:`build_tree_payload`.
Raises
------
fastapi.HTTPException
400 when the requested root is not an existing directory.
"""
# Fall back to the launch root when the query omits ?root=.
target = root or launch_root
try:
payload = build_tree_payload(target, max_depth=depth, dedupe=bool(dedupe))
except NotADirectoryError as exc:
# Turn the domain error into a clean 400 the front-end can display.
raise HTTPException(status_code=400, detail=str(exc)) from exc
return JSONResponse(content=payload)
return app
[docs]
def run(
root: str | None = None,
host: str = "127.0.0.1",
port: int = 8017,
) -> None:
"""Launch the Tree Radar GUI with uvicorn (blocking).
Binds to loopback by default so the dashboard is reachable only from this
machine — consistent with the local-first promise.
Parameters
----------
root : str, optional
Folder to pre-fill and auto-scan. Defaults to the current directory.
host : str, optional
Interface to bind. Defaults to ``"127.0.0.1"`` (localhost only).
port : int, optional
TCP port to serve on. Defaults to ``8017``.
Raises
------
ImportError
If the ``gui`` extra (FastAPI/uvicorn) is not installed.
Notes
-----
This call blocks until the server is stopped (Ctrl-C).
"""
# uvicorn is part of the optional extra; import it lazily with a friendly
# error so the lean core import is never burdened with the web server.
try:
import uvicorn
except ImportError as exc: # pragma: no cover — exercised only without extra
error("Tree Radar GUI requires uvicorn (install the 'gui' extra).")
raise ImportError(
'The Tree Radar GUI needs uvicorn. Install the extra:\n pip install "os-helper[gui]"'
) from exc
# Build the app bound to the chosen launch root, then serve it.
app = create_app(default_root=root)
info(f"Tree Radar: serving http://{host}:{port}/gui (root: {root or os.getcwd()})")
uvicorn.run(app, host=host, port=port)
def _cli_main(argv: list[str] | None = None) -> int:
"""Console-script entry point for the ``os-helper-gui`` command.
A tiny argparse wrapper around :func:`run` so the GUI can be launched
directly from the shell once the ``gui`` extra is installed.
Parameters
----------
argv : list of str, optional
Arguments to parse; defaults to ``sys.argv[1:]`` when None.
Returns
-------
int
Process exit code (0 on success).
"""
# argparse lives in the stdlib, so this stays importable without the extra;
# the FastAPI/uvicorn requirement only bites inside run().
import argparse
parser = argparse.ArgumentParser(
prog="os-helper-gui",
description="Launch the local Tree Radar treemap disk dashboard.",
)
parser.add_argument(
"--root", default=None, help="Folder to pre-fill and scan (default: current directory)."
)
parser.add_argument(
"--host", default="127.0.0.1", help="Bind interface (default: 127.0.0.1, localhost only)."
)
parser.add_argument("--port", type=int, default=8017, help="Port to serve on (default: 8017).")
ns = parser.parse_args(argv)
# Delegate to run(); it raises a friendly ImportError if the extra is absent.
run(root=ns.root, host=ns.host, port=ns.port)
return 0
if __name__ == "__main__": # pragma: no cover — module run directly
raise SystemExit(_cli_main())