Files
WinDirStat-Export/wdsexport.py
T
Lucas WintherandClaude Opus 5.5 e28fbeb0b3 Add wdsexport: Linux directory scan to WinDirStat 2.x saved-results CSV
Single-file, stdlib-only CLI that writes the CSV format WinDirStat's
CsvLoader accepts (parent-before-child rows, subtree totals, localized
headers for all 25 UI languages). Includes install.sh, pyproject entry
point, unit tests and a generator for the language table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:01:02 +01:00

289 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""wdsexport - scan a Linux/macOS directory tree and write a WinDirStat 2.x
"saved results" CSV, to be opened on Windows via File > Open saved results.
Format notes (from windirstat/CsvLoader.cpp):
* Header names must match WinDirStat's UI language exactly.
* Rows must be ordered parent-before-child; parents are found by the path
prefix before the last backslash.
* Container rows carry subtree totals (the loader does not sum children).
Rows are written in post-order (when subtree totals are known) to a temp file,
which is then copied out in reverse: reversed post-order is parent-first.
"""
import argparse
import os
import socket
import stat
import sys
import tempfile
import time
import zipfile
__version__ = "1.0.0"
IT_DIRECTORY = 1 << 2
IT_FILE = 1 << 3
ITF_SCANERROR = 1 << 14
ITF_ROOTITEM = 1 << 28
# Localized column names from windirstat/res/langs/lang_*.txt, in the order:
# Name, Files, Folders, Logical Size, Physical Size, Attributes, Last Change, Index.
# Regenerate with tools/gen_langs.py.
# @@LANGS-BEGIN@@
LANGS = {
'ar': ['الاسم', 'الملفات', 'المجلدات الفرعية', 'الحجم المنطقي', 'الحجم الفعلي على القرص', 'السمات', 'آخر تعديل', 'الفهرس'],
'cs': ['Název', 'Soubory', 'Složky', 'Logická velikost', 'Fyzická velikost', 'Atributy', 'Poslední změna', 'Index'],
'da': ['Navn', 'Filer', 'Mapper', 'Logisk størrelse', 'Fysisk størrelse', 'Attributter', 'Sidste ændring', 'Indeks'],
'de': ['Name', 'Dateien', 'Verzeichnisse', 'Logische Größe', 'Physische Größe', 'Attribute', 'Letzte Änderung', 'Index'],
'en': ['Name', 'Files', 'Folders', 'Logical Size', 'Physical Size', 'Attributes', 'Last Change', 'Index'],
'es': ['Nombre', 'Archivos', 'Subdirectorios', 'Tamaño Lógico', 'Tamaño Físico', 'Atributos', 'Último Cambio', 'Índice'],
'et': ['Nimi', 'Failid', 'Alamkataloogid', 'Loogiline suurus', 'Füüsiline suurus', 'Atribuudid', 'Viimane muudatus', 'Indeks'],
'fi': ['Nimi', 'Tiedostoja', 'Alikansioita', 'Looginen koko', 'Fyysinen koko', 'Määritteet', 'Edellinen muutos', 'Indeksi'],
'fr': ['Nom', 'Fichiers', 'Sous-dossiers', 'Taille Logique', 'Taille Physique', 'Attributs', 'Dernier changement', 'Index'],
'hi': ['नाम', 'फ़ाइलें', 'फ़ोल्डर', 'तार्किक आकार', 'भौतिक आकार', 'विशेषताएँ', 'अंतिम बदलाव', 'सूचकांक'],
'hu': ['Név', 'Fájlok', 'Alkönyvtárak', 'Logikai méret', 'Fizikai méret', 'Attribútumok', 'Utoljára módosítva', 'Index'],
'it': ['Nome', 'File', 'Sottocartella', 'Dimensione logica', 'Dimensione fisica', 'Attributi', 'Ultima modifica', 'Indice'],
'ja': ['名前', 'ファイル', 'フォルダー', '論理サイズ', '物理サイズ', '属性', '最終更新日時', 'インデックス'],
'ko': ['이름', '파일', '폴더', '논리적 크기', '물리적 크기', '속성', '최근 변경', '인덱스'],
'nb': ['Navn', 'Filer', 'Mapper', 'Størrelse', 'Diskbruk', 'Attributter', 'Endringsdato', 'Indeks'],
'nl': ['Naam', 'Bestanden', 'Onderliggende mappen', 'Logische Grootte', 'Fysieke Grootte', 'Kenmerken', 'Laatste wijziging', 'Index'],
'pl': ['Nazwa', 'Plików', 'Podkatalogi', 'Rozmiar logiczny', 'Rozmiar fizyczny', 'Atrybuty', 'Ostatnia modyfikacja', 'Indeks'],
'pt': ['Nome', 'Arquivos', 'Subdiretórios', 'Tamanho Lógico', 'Tamanho Físico', 'Atributos', 'Última Alteração', 'Índice'],
'ru': ['Имя', 'Файлы', 'Подкаталоги', 'Логический размер', 'Физический размер', 'Атрибуты', 'Дата изменения', 'Индекс'],
'sl': ['Ime', 'Datoteke', 'Mape', 'Logična velikost', 'Fizična velikost', 'Atributi', 'Zadnja sprememba', 'Indeks'],
'sv': ['Namn', 'Filer', 'Mappar', 'Logisk storlek', 'Fysisk storlek', 'Attribut', 'Senast ändrad', 'Index'],
'tr': ['Ad', 'Dosyalar', 'Dizinler', 'Mantıksal Boyut', 'Fiziksel Boyut', 'Özellikler', 'Son Değişiklik', 'Dizin'],
'uk': ['Ім’я', 'Файли', 'Підкаталоги', 'Логічний розмір', 'Фізичний розмір', 'Атрибути', 'Дата зміни', 'Індекс'],
'zh-HK': ['名稱', '檔案數目', '資料夾數目', '邏輯大小', '實際大小', '檔案屬性', '最後修改時間', '索引'],
'zh': ['名称', '文件数', '文件夹数', '逻辑大小', '物理大小', '属性', '修改时间', '索引'],
}
# @@LANGS-END@@
def header(lang):
name, files, folders, logical, physical, attrs, last_change, index = LANGS[lang]
return [name, files, folders, logical, physical, attrs, last_change,
"WinDirStat " + attrs, index, "ScanOutcome", "ScanError"]
def clean(name):
"""Make a path component safe for WinDirStat's minimal CSV parser."""
s = os.fsdecode(name).encode("utf-8", "replace").decode("utf-8")
for bad, good in (("\\", "_"), ('"', "'"), ("\n", " "), ("\r", " ")):
s = s.replace(bad, good)
return s
def timestamp(mtime):
try:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(mtime))
except (OverflowError, OSError, ValueError):
return ""
class Scanner:
def __init__(self, out, cross_fs=False, exclude=(), skip_inodes=(), progress=None):
self.out = out
self.cross_fs = cross_fs
self.exclude = {os.path.realpath(p) for p in exclude}
self.skip_inodes = set(skip_inodes)
self.seen_links = set()
self.progress = progress
self.entries = 0
self.errors = 0
self.root_dev = None
def row(self, path, files, folders, logical, physical, attrs, mtime, itype,
outcome="complete", err=""):
self.out.write(
f'"{path}",{files},{folders},{logical},{physical},"{attrs}",'
f'{timestamp(mtime)},0x{itype:08X},0x{0:016X},"{outcome}","{err}"\n')
def scan(self, root, root_name):
st = os.stat(root)
self.root_dev = st.st_dev
return self._walk(root, clean(root_name), st.st_mtime, "", True)
def _walk(self, dirpath, wpath, mtime, attrs, is_root=False):
# One stack frame per directory level; main() raises the recursion limit.
files = folders = logical = physical = 0
outcome, err = "complete", ""
try:
it = os.scandir(dirpath)
except OSError as e:
it, outcome, err = None, "partial", clean(e.strerror or type(e).__name__)
self.errors += 1
if it is not None:
with it:
for entry in it:
self.entries += 1
if self.progress and self.entries % 100000 == 0:
self.progress(self.entries)
try:
st = entry.stat(follow_symlinks=False)
except OSError:
self.errors += 1
continue
name = clean(entry.name)
child = wpath + "\\" + name
a = "H" if name.startswith(".") else ""
if stat.S_ISDIR(st.st_mode):
full = os.path.join(dirpath, entry.name)
if (not self.cross_fs and st.st_dev != self.root_dev) or \
os.path.realpath(full) in self.exclude:
continue
r = self._walk(full, child, st.st_mtime, a)
files += r[0]
folders += r[1] + 1
logical += r[2]
physical += r[3]
continue
key = (st.st_dev, st.st_ino)
if key in self.skip_inodes:
continue # our own output/temp files
if st.st_nlink > 1:
if key in self.seen_links:
continue # count each hard-linked inode once
self.seen_links.add(key)
lsz, psz = st.st_size, st.st_blocks * 512
self.row(child, 0, 0, lsz, psz, a, st.st_mtime, IT_FILE)
files += 1
logical += lsz
physical += psz
itype = IT_DIRECTORY | (ITF_ROOTITEM if is_root else 0) | \
(ITF_SCANERROR if outcome != "complete" else 0)
self.row(wpath, files, folders, logical, physical, attrs, mtime, itype, outcome, err)
return files, folders, logical, physical
def copy_reversed_lines(src, dst, block=1 << 20):
"""Write the lines of binary file `src` to `dst` in reverse order."""
src.seek(0, os.SEEK_END)
pos = src.tell()
tail = b""
while pos > 0:
step = min(block, pos)
pos -= step
src.seek(pos)
buf = src.read(step) + tail
lines = buf.split(b"\n")
tail = lines[0] # may be incomplete; joined with the previous block
for line in reversed(lines[1:]):
if line:
dst.write(line + b"\n")
if tail:
dst.write(tail + b"\n")
def default_name(path):
host = socket.gethostname().split(".")[0] or "host"
real = os.path.realpath(path)
base = os.path.basename(real.rstrip(os.sep)) or "root"
return f"{host}-{base}"
def human(n):
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if n < 1024 or unit == "TiB":
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
n /= 1024
def parse_args(argv):
p = argparse.ArgumentParser(
prog="wdsexport",
description="Scan a directory tree and write a WinDirStat 2.x saved-results CSV "
"(open it in WinDirStat via File > Open saved results).")
p.add_argument("path", nargs="?", default="/", help="directory to scan (default: /)")
p.add_argument("-o", "--output",
help="output CSV path, or - for stdout (default: ./<name>.csv)")
p.add_argument("--name", help="root folder name shown in WinDirStat "
"(default: <hostname>-<dirname>, e.g. myhost-root)")
p.add_argument("--exclude", action="append", default=[], metavar="DIR",
help="skip this directory (repeatable)")
p.add_argument("--zip", action="store_true",
help="also write <output>.zip (CSVs compress ~15x)")
p.add_argument("--lang", default="en", choices=sorted(LANGS), metavar="CODE",
help="column-name language; must match WinDirStat's UI language "
f"(default: en; choices: {', '.join(sorted(LANGS))})")
p.add_argument("--cross-fs", action="store_true",
help="descend into other mounted filesystems (default: stay on one, like du -x)")
p.add_argument("-q", "--quiet", action="store_true", help="no progress output")
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
args = p.parse_args(argv)
if not os.path.isdir(args.path):
p.error(f"not a directory: {args.path}")
if args.zip and args.output == "-":
p.error("--zip cannot be used with --output -")
return args
def main(argv=None):
args = parse_args(argv)
sys.setrecursionlimit(max(sys.getrecursionlimit(), 100000))
name = args.name or default_name(args.path)
to_stdout = args.output == "-"
output = None if to_stdout else os.path.abspath(args.output or f"{clean(name)}.csv")
log = (lambda *a: None) if args.quiet else (lambda msg: print(msg, file=sys.stderr, flush=True))
tty = sys.stderr.isatty() and not args.quiet
t0 = time.monotonic()
def progress(n):
if tty:
print(f"\r{n:,} entries, {time.monotonic() - t0:.0f}s", end="", file=sys.stderr, flush=True)
tmp_dir = tempfile.gettempdir() if to_stdout else os.path.dirname(output)
tmp = tempfile.NamedTemporaryFile("w+b", dir=tmp_dir, prefix=".wdsexport-", suffix=".tmp", delete=False)
skip = set()
for f in [tmp.name] + ([output] if output and os.path.exists(output) else []):
st = os.stat(f)
skip.add((st.st_dev, st.st_ino))
try:
text = open(tmp.fileno(), "w", encoding="utf-8", newline="\n", buffering=1 << 20, closefd=False)
scanner = Scanner(text, cross_fs=args.cross_fs, exclude=args.exclude,
skip_inodes=skip, progress=progress)
files, folders, logical, physical = scanner.scan(args.path, name)
text.flush()
if tty:
print(file=sys.stderr)
log(f"Scanned {scanner.entries:,} entries in {time.monotonic() - t0:.0f}s: "
f"{files:,} files, {folders:,} folders, {human(physical)} on disk")
if scanner.errors:
log(f"{scanner.errors:,} entries could not be read (marked as partial); "
"run with sudo to include them")
head = ("" + ",".join(f'"{h}"' for h in header(args.lang)) + "\n").encode("utf-8")
if to_stdout:
dst = sys.stdout.buffer
dst.write(head)
copy_reversed_lines(tmp, dst)
dst.flush()
else:
# Write next to the target, then rename, so a failed run never
# leaves a truncated CSV behind under the final name.
part = output + ".part"
with open(part, "wb", buffering=1 << 20) as dst:
dst.write(head)
copy_reversed_lines(tmp, dst)
os.replace(part, output)
log(f"Wrote {output} ({human(os.path.getsize(output))})")
if args.zip:
zpath = os.path.splitext(output)[0] + ".zip"
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
z.write(output, os.path.basename(output))
log(f"Wrote {zpath} ({human(os.path.getsize(zpath))})")
except KeyboardInterrupt:
print("\ninterrupted", file=sys.stderr)
return 130
finally:
tmp.close()
os.unlink(tmp.name)
if output and os.path.exists(output + ".part"):
os.unlink(output + ".part")
return 0
if __name__ == "__main__":
sys.exit(main())