From ea17a046a88cfb233cca5123bb5129ba6fb80f59 Mon Sep 17 00:00:00 2001 From: Azis Date: Fri, 6 Feb 2026 10:12:56 +0100 Subject: [PATCH 1/4] Add support for JSON output --- README.md | 50 ++++++++++++++++ zpace/core.py | 5 +- zpace/main.py | 155 ++++++++++++++++++++++++++++++++++++++++-------- zpace/output.py | 129 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 zpace/output.py diff --git a/README.md b/README.md index de07829..a6d5ecf 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,19 @@ zpace -m 1024 # Combine options zpace ~/Documents -n 15 -m 500 + +# (Unreleased) +# Output can be piped to a text file +zpace > zpace.txt + +# but it can also be saved to a file with an -o flag +zpace -o zpace.txt + +# Output can also be formatted to JSON +zpace --json + +# and save to a JSON file +zpace --json -o zpace.json ``` ### Example Output @@ -335,6 +348,42 @@ Videos (10 files) ``` +
+Open JSON example output (unreleased) + +```json +{ + "version": "1.0", + "scan_path": "/Users/azis", + "timestamp": "2026-01-28T14:30:00Z", + "disk_usage": { + "total_bytes": 926350000000, + "used_bytes": 393340000000, + "free_bytes": 533010000000, + "used_percent": 42.5, + "trash_bytes": 310410000 + }, + "scan_summary": { + "total_files": 593044, + "special_directories": 420, + "total_size_bytes": 208580000000 + }, + "special_directories": { + "Node Modules": [ + {"path": "/path/to/node_modules", "size_bytes": 1120000000} + ], + "Virtual Environments": [...] + }, + "files_by_category": { + "Videos": [ + {"path": "/path/to/video.mov", "size_bytes": 986900000} + ], + "Archives": [...] + } +} +``` +
+ ### Configuration Zpace can be customized via `~/.zpace.toml`. See [.zpace.toml.sample](.zpace.toml.sample) for a template: @@ -463,6 +512,7 @@ zpace/ │ ├── core.py │ ├── config.py │ └── utils.py +| └── output.py ├── main.py # Entry point ├── pyproject.toml # Project configuration ├── README.md # This file diff --git a/zpace/core.py b/zpace/core.py index ae600cc..99c4e05 100644 --- a/zpace/core.py +++ b/zpace/core.py @@ -83,9 +83,10 @@ def calculate_dir_size(dirpath: str) -> int: def scan_files_and_dirs( root_path: Path, - used_bytes: int, + used_bytes: float, min_size: int = MIN_FILE_SIZE, top_n: int = DEFAULT_TOP_N, + show_progress: bool = True, ) -> Tuple[Dict[str, List[Tuple[int, str]]], Dict[str, List[Tuple[int, str]]], int, int]: """ Scan directory tree for files and special directories using an iterative stack with os.scandir. @@ -105,7 +106,7 @@ def scan_files_and_dirs( # Pre-compute root level usage to skip logic if needed # We'll just check absolute paths for SKIP_DIRS - with tqdm(total=used_bytes, unit="B", unit_scale=True, desc="Scanning") as pbar: + with tqdm(total=used_bytes, unit="B", unit_scale=True, desc="Scanning", disable=not show_progress) as pbar: while stack: current_path, level = stack.pop() diff --git a/zpace/main.py b/zpace/main.py index 4e69ee1..f817f49 100644 --- a/zpace/main.py +++ b/zpace/main.py @@ -15,6 +15,7 @@ calculate_dir_size, scan_files_and_dirs, ) +from zpace.output import build_scan_result def print_results( @@ -97,6 +98,18 @@ def main(): default=MIN_FILE_SIZE // 1024, help=f"Minimum file/dir size in KB (default: {MIN_FILE_SIZE // 1024})", ) + parser.add_argument( + "-o", + "--output", + type=str, + metavar="FILE", + help="Write results to FILE instead of stdout", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output in JSON format", + ) args = parser.parse_args() # Do not resolve yet, check if it's a symlink first @@ -112,19 +125,24 @@ def main(): scan_path = raw_path.resolve() + # Determine output mode + quiet_mode = args.json or args.output + # Display disk usage total, used, free = map(float, get_disk_usage(str(scan_path))) terminal_width = shutil.get_terminal_size().columns - print("\nDISK USAGE") - print("=" * terminal_width) - if total > 0: - print(f" Free: {format_size(free)} / {format_size(total)}") - print(f" Used: {format_size(used)} ({used / total * 100:.1f}%)") - else: - print(" (disk usage unavailable on this platform)") + if not quiet_mode: + print("\nDISK USAGE") + print("=" * terminal_width) + if total > 0: + print(f" Free: {format_size(free)} / {format_size(total)}") + print(f" Used: {format_size(used)} ({used / total * 100:.1f}%)") + else: + print(" (disk usage unavailable on this platform)") # Check Trash size + trash_size = None trash_path = get_trash_path() if trash_path: if os.path.exists(trash_path): @@ -133,35 +151,47 @@ def main(): # Verify we can actually list it (os.access might lie on some systems/containers) next(os.scandir(trash_path), None) trash_size = calculate_dir_size(trash_path) - additional_message = "" - if trash_size > 1000 * 1024 * 1024: # 1000 MB - additional_message = " (Consider cleaning up your trash bin!)" - print(f" Trash: {format_size(trash_size)}{additional_message}") + if not quiet_mode: + additional_message = "" + if trash_size > 1000 * 1024 * 1024: # 1000 MB + additional_message = " (Consider cleaning up your trash bin!)" + print(f" Trash: {format_size(trash_size)}{additional_message}") except PermissionError: - print(" Trash: Access Denied") + if not quiet_mode: + print(" Trash: Access Denied") else: - print(" Trash: Access Denied") + if not quiet_mode: + print(" Trash: Access Denied") else: - print(" Trash: Not Found") + if not quiet_mode: + print(" Trash: Not Found") else: - print(" Trash: Unknown OS") + if not quiet_mode: + print(" Trash: Unknown OS") - print("=" * terminal_width) - print(f"\nSCANNING: {scan_path}") - print(f" Min size: {args.min_size} KB") - print() + if not quiet_mode: + print("=" * terminal_width) + print(f"\nSCANNING: {scan_path}") + print(f" Min size: {args.min_size} KB") + print() # Check for symlink explicitly if raw_path.is_symlink(): resolved = raw_path.resolve() - print(f"Attention - you provided a symlink: {raw_path}") - print(f"It points to this directory: {resolved}") - print(f"If you wish to analyse the symlinked directory, please pass its path: {resolved}") + msg = ( + f"Attention - you provided a symlink: {raw_path}\n" + f"It points to this directory: {resolved}\n" + f"If you wish to analyse the symlinked directory, please pass its path: {resolved}" + ) + if args.json: + print(f'{{"error": "{msg}"}}') + else: + print(msg) return try: top_files, top_dirs, total_files, total_size = scan_files_and_dirs( - scan_path, used, args.min_size * 1024, top_n=args.top + scan_path, used, args.min_size * 1024, top_n=args.top, show_progress=not quiet_mode ) except KeyboardInterrupt: print("\nScan interrupted by user") @@ -170,7 +200,84 @@ def main(): print(f"Error during scan: {e}") sys.exit(1) - # Display results + # JSON output mode + if args.json: + result = build_scan_result( + scan_path=str(scan_path), + total=total, + used=used, + free=free, + trash_size=trash_size, + file_categories=top_files, + dir_categories=top_dirs, + total_files=total_files, + total_size=total_size, + ) + json_output = result.to_json() + if args.output: + with open(args.output, "w") as f: + f.write(json_output) + f.write("\n") + else: + print(json_output) + return + + # Text file output mode (no colors, no progress - already handled) + if args.output: + import io + + buffer = io.StringIO() + buffer.write(f"DISK USAGE\n") + buffer.write("=" * 80 + "\n") + buffer.write(f" Free: {format_size(free)} / {format_size(total)}\n") + buffer.write(f" Used: {format_size(used)} ({used / total * 100:.1f}%)\n") + if trash_size is not None: + buffer.write(f" Trash: {format_size(trash_size)}\n") + buffer.write("=" * 80 + "\n\n") + buffer.write(f"SCAN PATH: {scan_path}\n") + buffer.write(f"Min size: {args.min_size} KB\n\n") + buffer.write("SCAN COMPLETE!\n") + buffer.write(f" Found {total_files:,} files\n") + buffer.write(f" Found {sum(len(e) for e in top_dirs.values())} special directories\n") + buffer.write(f" Total size: {format_size(total_size)}\n") + + # Write special directories + if top_dirs: + buffer.write("\n" + "=" * 80 + "\n") + buffer.write("SPECIAL DIRECTORIES\n") + buffer.write("=" * 80 + "\n") + for category in sorted(top_dirs.keys()): + entries = top_dirs[category] + if not entries: + continue + buffer.write("\n" + "-" * 80 + "\n") + buffer.write(f"{category} ({len(entries)} directories)\n") + buffer.write("-" * 80 + "\n") + for size, dirpath in entries: + buffer.write(f" {format_size(size):>12} {dirpath}\n") + + # Write file categories + if top_files: + buffer.write("\n" + "=" * 80 + "\n") + buffer.write("LARGEST FILES BY CATEGORY\n") + buffer.write("=" * 80 + "\n") + for category in sorted(top_files.keys()): + entries = top_files[category] + if not entries: + continue + buffer.write("\n" + "-" * 80 + "\n") + buffer.write(f"{category} ({len(entries)} files)\n") + buffer.write("-" * 80 + "\n") + for size, filepath in entries: + buffer.write(f" {format_size(size):>12} {filepath}\n") + + buffer.write("=" * 80 + "\n") + + with open(args.output, "w") as f: + f.write(buffer.getvalue()) + return + + # Display results (normal interactive mode) print("\nSCAN COMPLETE!") print(f" Found {total_files:,} files") print(f" Found {sum(len(e) for e in top_dirs.values())} special directories") diff --git a/zpace/output.py b/zpace/output.py new file mode 100644 index 0000000..490f611 --- /dev/null +++ b/zpace/output.py @@ -0,0 +1,129 @@ +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + + +@dataclass +class DiskUsage: + total_bytes: int + used_bytes: int + free_bytes: int + used_percent: float + trash_bytes: Optional[int] = None + + +@dataclass +class FileEntry: + path: str + size_bytes: int + + +@dataclass +class ScanSummary: + total_files: int + special_directories_count: int + total_size_bytes: int + + +@dataclass +class ScanResult: + version: str = "1.0" + scan_path: str = "" + timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + disk_usage: Optional[DiskUsage] = None + scan_summary: Optional[ScanSummary] = None + special_directories: Dict[str, List[FileEntry]] = field(default_factory=dict) + files_by_category: Dict[str, List[FileEntry]] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result: Dict[str, Any] = { + "version": self.version, + "scan_path": self.scan_path, + "timestamp": self.timestamp, + } + + if self.disk_usage: + result["disk_usage"] = { + "total_bytes": self.disk_usage.total_bytes, + "used_bytes": self.disk_usage.used_bytes, + "free_bytes": self.disk_usage.free_bytes, + "used_percent": self.disk_usage.used_percent, + } + if self.disk_usage.trash_bytes is not None: + result["disk_usage"]["trash_bytes"] = self.disk_usage.trash_bytes + + if self.scan_summary: + result["scan_summary"] = { + "total_files": self.scan_summary.total_files, + "special_directories_count": self.scan_summary.special_directories_count, + "total_size_bytes": self.scan_summary.total_size_bytes, + } + + result["special_directories"] = { + category: [{"path": e.path, "size_bytes": e.size_bytes} for e in entries] + for category, entries in sorted(self.special_directories.items()) + } + + result["files_by_category"] = { + category: [{"path": e.path, "size_bytes": e.size_bytes} for e in entries] + for category, entries in sorted(self.files_by_category.items()) + } + + return result + + def to_json(self, indent: int = 2) -> str: + """Serialize to JSON string.""" + return json.dumps(self.to_dict(), indent=indent) + + +def build_scan_result( + scan_path: str, + total: float, + used: float, + free: float, + trash_size: Optional[int], + file_categories: Dict[str, List[tuple]], + dir_categories: Dict[str, List[tuple]], + total_files: int, + total_size: int, +) -> ScanResult: + """Build a ScanResult from raw scan data.""" + used_percent = (used / total * 100) if total > 0 else 0.0 + + disk_usage = DiskUsage( + total_bytes=int(total), + used_bytes=int(used), + free_bytes=int(free), + used_percent=round(used_percent, 1), + trash_bytes=trash_size, + ) + + # Count special directories + special_dirs_count = sum(len(entries) for entries in dir_categories.values()) + + scan_summary = ScanSummary( + total_files=total_files, + special_directories_count=special_dirs_count, + total_size_bytes=total_size, + ) + + # Convert tuples to FileEntry objects + special_directories = { + category: [FileEntry(path=path, size_bytes=size) for size, path in entries] + for category, entries in dir_categories.items() + } + + files_by_category = { + category: [FileEntry(path=path, size_bytes=size) for size, path in entries] + for category, entries in file_categories.items() + } + + return ScanResult( + scan_path=scan_path, + disk_usage=disk_usage, + scan_summary=scan_summary, + special_directories=special_directories, + files_by_category=files_by_category, + ) From afbbdb85de9c8cfda83f5e558d048b4f6e644ceb Mon Sep 17 00:00:00 2001 From: AzisK Date: Fri, 6 Feb 2026 22:39:55 +0100 Subject: [PATCH 2/4] Add tests and bump the version to v0.5.0 --- CHANGELOG.md | 7 ++ README.md | 34 +++-- pyproject.toml | 2 +- test_unit.py | 336 ++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 359 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 817c1e9..83e0847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.5.0] - 2026-02-06 + +### Features + +- **JSON Output**: Added `--json` flag to output scan results in structured JSON format +- **File Output**: Added `-o` / `--output` flag to save results to a file (supports both text and JSON) + ### Fixed - PyPy on Windows now works gracefully when `shutil.disk_usage` is unavailable (displays message instead of crashing) diff --git a/README.md b/README.md index a6d5ecf..914edd5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A CLI tool to discover what's hogging your disk space! The tool shows the largest files in each category of files (videos, pictures, documents etc.) as well as the largest special directories as apps in MacOS, Python virtual environments, node_modules etc. -It's built to indentify the biggest chunks of data that could potentially free up the space for something else. +It's built to identify the biggest chunks of data that could potentially free up the space for something else. ## Features @@ -71,24 +71,20 @@ zpace . # Show top 20 items per category (default: 10) zpace -n 20 -# Set minimum file size to 1MB (default: 100KB) +# Set minimum file size to 1MB (default: 100KB, value is in KB) zpace -m 1024 # Combine options zpace ~/Documents -n 15 -m 500 -# (Unreleased) -# Output can be piped to a text file -zpace > zpace.txt +# Save results to a text file +zpace -o results.txt -# but it can also be saved to a file with an -o flag -zpace -o zpace.txt - -# Output can also be formatted to JSON +# Output in JSON format zpace --json -# and save to a JSON file -zpace --json -o zpace.json +# Save JSON results to a file +zpace --json -o results.json ``` ### Example Output @@ -349,7 +345,7 @@ Videos (10 files)
-Open JSON example output (unreleased) +Open JSON example output ```json { @@ -511,12 +507,14 @@ zpace/ │ ├── main.py │ ├── core.py │ ├── config.py -│ └── utils.py -| └── output.py -├── main.py # Entry point -├── pyproject.toml # Project configuration -├── README.md # This file -└── CHANGELOG.md # Version history +│ ├── utils.py +│ └── output.py +├── main.py # Entry point +├── test_unit.py # Unit tests +├── test_integration.py # Integration tests +├── pyproject.toml # Project configuration +├── README.md # This file +└── CHANGELOG.md # Version history ``` ### Contributing diff --git a/pyproject.toml b/pyproject.toml index 85092ea..106998d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zpace" -version = "0.4.5" +version = "0.5.0" description = "A CLI tool to discover what's consuming your disk space" readme = "README.md" requires-python = ">=3.9" diff --git a/test_unit.py b/test_unit.py index c006ed7..c84fa4c 100644 --- a/test_unit.py +++ b/test_unit.py @@ -733,7 +733,12 @@ def test_symlink_rejection_in_main(self): pass mock_exit.assert_not_called() - mock_print.assert_any_call(f"Attention - you provided a symlink: {Path('/tmp/link')}") + expected_msg = ( + f"Attention - you provided a symlink: {Path('/tmp/link')}\n" + f"It points to this directory: {Path('/real/path')}\n" + f"If you wish to analyse the symlinked directory, please pass its path: {Path('/real/path')}" + ) + mock_print.assert_any_call(expected_msg) class TestUnicodeHandling: @@ -1035,5 +1040,334 @@ def test_does_not_mutate_default_categories(self, fs_with_config): assert DEFAULT_CATEGORIES["Pictures"] == original_pictures +class TestScanResultToDict: + """Test ScanResult.to_dict() serialization.""" + + def test_minimal_scan_result(self): + from zpace.output import ScanResult + + result = ScanResult(scan_path="/test", timestamp="2026-01-01T00:00:00Z") + d = result.to_dict() + + assert d["version"] == "1.0" + assert d["scan_path"] == "/test" + assert d["timestamp"] == "2026-01-01T00:00:00Z" + assert d["special_directories"] == {} + assert d["files_by_category"] == {} + assert "disk_usage" not in d + assert "scan_summary" not in d + + def test_full_scan_result(self): + from zpace.output import ScanResult, DiskUsage, ScanSummary, FileEntry + + result = ScanResult( + scan_path="/home/user", + timestamp="2026-02-06T10:00:00Z", + disk_usage=DiskUsage( + total_bytes=1000000, + used_bytes=600000, + free_bytes=400000, + used_percent=60.0, + trash_bytes=5000, + ), + scan_summary=ScanSummary( + total_files=100, + special_directories_count=5, + total_size_bytes=500000, + ), + special_directories={ + "Node Modules": [FileEntry(path="/project/node_modules", size_bytes=200000)] + }, + files_by_category={"Videos": [FileEntry(path="/home/video.mp4", size_bytes=300000)]}, + ) + d = result.to_dict() + + assert d["disk_usage"]["total_bytes"] == 1000000 + assert d["disk_usage"]["used_percent"] == 60.0 + assert d["disk_usage"]["trash_bytes"] == 5000 + assert d["scan_summary"]["total_files"] == 100 + assert d["scan_summary"]["special_directories_count"] == 5 + assert len(d["special_directories"]["Node Modules"]) == 1 + assert d["special_directories"]["Node Modules"][0]["path"] == "/project/node_modules" + assert len(d["files_by_category"]["Videos"]) == 1 + assert d["files_by_category"]["Videos"][0]["size_bytes"] == 300000 + + def test_disk_usage_without_trash(self): + from zpace.output import ScanResult, DiskUsage + + result = ScanResult( + scan_path="/test", + timestamp="2026-01-01T00:00:00Z", + disk_usage=DiskUsage( + total_bytes=1000, used_bytes=500, free_bytes=500, used_percent=50.0 + ), + ) + d = result.to_dict() + + assert "trash_bytes" not in d["disk_usage"] + + def test_categories_sorted_alphabetically(self): + from zpace.output import ScanResult, FileEntry + + result = ScanResult( + scan_path="/test", + timestamp="2026-01-01T00:00:00Z", + special_directories={ + "Zebra": [FileEntry(path="/z", size_bytes=1)], + "Apple": [FileEntry(path="/a", size_bytes=2)], + }, + files_by_category={ + "Videos": [FileEntry(path="/v", size_bytes=3)], + "Archives": [FileEntry(path="/ar", size_bytes=4)], + }, + ) + d = result.to_dict() + + assert list(d["special_directories"].keys()) == ["Apple", "Zebra"] + assert list(d["files_by_category"].keys()) == ["Archives", "Videos"] + + +class TestScanResultToJson: + """Test ScanResult.to_json() produces valid JSON.""" + + def test_produces_valid_json(self): + import json + from zpace.output import ScanResult, DiskUsage, ScanSummary, FileEntry + + result = ScanResult( + scan_path="/home/user", + timestamp="2026-02-06T10:00:00Z", + disk_usage=DiskUsage( + total_bytes=1000000, + used_bytes=600000, + free_bytes=400000, + used_percent=60.0, + ), + scan_summary=ScanSummary( + total_files=50, + special_directories_count=3, + total_size_bytes=400000, + ), + files_by_category={"Documents": [FileEntry(path="/doc.pdf", size_bytes=10000)]}, + ) + json_str = result.to_json() + parsed = json.loads(json_str) + + assert parsed["version"] == "1.0" + assert parsed["scan_path"] == "/home/user" + assert parsed["disk_usage"]["used_percent"] == 60.0 + + def test_json_roundtrip_preserves_data(self): + import json + from zpace.output import ScanResult, FileEntry + + result = ScanResult( + scan_path="/test", + timestamp="2026-01-01T00:00:00Z", + files_by_category={ + "Code": [ + FileEntry(path="/a.py", size_bytes=100), + FileEntry(path="/b.js", size_bytes=200), + ] + }, + ) + parsed = json.loads(result.to_json()) + + assert len(parsed["files_by_category"]["Code"]) == 2 + assert parsed["files_by_category"]["Code"][0]["path"] == "/a.py" + assert parsed["files_by_category"]["Code"][1]["path"] == "/b.js" + + +class TestBuildScanResult: + """Test build_scan_result() factory function.""" + + def test_builds_from_raw_data(self): + from zpace.output import build_scan_result + + result = build_scan_result( + scan_path="/home/user", + total=1000000.0, + used=600000.0, + free=400000.0, + trash_size=5000, + file_categories={"Documents": [(10000, "/doc.pdf")]}, + dir_categories={"Node Modules": [(200000, "/project/node_modules")]}, + total_files=50, + total_size=400000, + ) + + assert result.scan_path == "/home/user" + assert result.disk_usage is not None + assert result.disk_usage.total_bytes == 1000000 + assert result.disk_usage.used_bytes == 600000 + assert result.disk_usage.free_bytes == 400000 + assert result.disk_usage.used_percent == 60.0 + assert result.disk_usage.trash_bytes == 5000 + assert result.scan_summary is not None + assert result.scan_summary.total_files == 50 + assert result.scan_summary.special_directories_count == 1 + assert result.scan_summary.total_size_bytes == 400000 + assert len(result.special_directories["Node Modules"]) == 1 + assert result.special_directories["Node Modules"][0].path == "/project/node_modules" + assert len(result.files_by_category["Documents"]) == 1 + assert result.files_by_category["Documents"][0].size_bytes == 10000 + + def test_builds_without_trash(self): + from zpace.output import build_scan_result + + result = build_scan_result( + scan_path="/test", + total=1000.0, + used=500.0, + free=500.0, + trash_size=None, + file_categories={}, + dir_categories={}, + total_files=0, + total_size=0, + ) + + assert result.disk_usage is not None + assert result.disk_usage.trash_bytes is None + + def test_used_percent_with_zero_total(self): + from zpace.output import build_scan_result + + result = build_scan_result( + scan_path="/test", + total=0.0, + used=0.0, + free=0.0, + trash_size=None, + file_categories={}, + dir_categories={}, + total_files=0, + total_size=0, + ) + + assert result.disk_usage is not None + assert result.disk_usage.used_percent == 0.0 + + def test_multiple_entries_per_category(self): + from zpace.output import build_scan_result + + result = build_scan_result( + scan_path="/test", + total=1000.0, + used=500.0, + free=500.0, + trash_size=None, + file_categories={ + "Videos": [(500000, "/v1.mp4"), (300000, "/v2.mp4")], + "Documents": [(100000, "/doc.pdf")], + }, + dir_categories={ + "Node Modules": [(200000, "/a/node_modules"), (100000, "/b/node_modules")], + }, + total_files=3, + total_size=900000, + ) + + assert len(result.files_by_category["Videos"]) == 2 + assert len(result.files_by_category["Documents"]) == 1 + assert len(result.special_directories["Node Modules"]) == 2 + + +class TestMainJsonOutput: + """Test --json flag in main().""" + + @patch("zpace.main.scan_files_and_dirs") + @patch("zpace.main.get_disk_usage") + def test_json_stdout(self, mock_disk, mock_scan): + import json + + mock_disk.return_value = (1000000, 600000, 400000) + mock_scan.return_value = ( + {"Documents": [(10000, "/doc.pdf")]}, + {"Node Modules": [(200000, "/nm")]}, + 50, + 400000, + ) + + with ( + patch("sys.argv", ["main.py", "--json"]), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.is_symlink", return_value=False), + patch("sys.stdout", new=StringIO()) as fake_out, + ): + main() + output = fake_out.getvalue() + parsed = json.loads(output) + + assert parsed["version"] == "1.0" + assert "disk_usage" in parsed + assert "scan_summary" in parsed + assert "Documents" in parsed["files_by_category"] + assert "Node Modules" in parsed["special_directories"] + + +class TestMainFileOutput: + """Test -o flag in main().""" + + @patch("zpace.main.scan_files_and_dirs") + @patch("zpace.main.get_disk_usage") + def test_text_file_output(self, mock_disk, mock_scan, tmp_path): + mock_disk.return_value = (1000000, 600000, 400000) + mock_scan.return_value = ( + {"Documents": [(10000, "/doc.pdf")]}, + {"Node Modules": [(200000, "/nm")]}, + 50, + 400000, + ) + + output_file = str(tmp_path / "output.txt") + + with ( + patch("sys.argv", ["main.py", "-o", output_file]), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.is_symlink", return_value=False), + ): + main() + + with open(output_file) as f: + content = f.read() + + assert "DISK USAGE" in content + assert "SCAN PATH" in content + assert "Documents" in content + assert "Node Modules" in content + + @patch("zpace.main.scan_files_and_dirs") + @patch("zpace.main.get_disk_usage") + def test_json_file_output(self, mock_disk, mock_scan, tmp_path): + import json + + mock_disk.return_value = (1000000, 600000, 400000) + mock_scan.return_value = ( + {"Documents": [(10000, "/doc.pdf")]}, + {}, + 50, + 400000, + ) + + output_file = str(tmp_path / "output.json") + + with ( + patch("sys.argv", ["main.py", "--json", "-o", output_file]), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.is_symlink", return_value=False), + ): + main() + + with open(output_file) as f: + parsed = json.loads(f.read()) + + assert parsed["version"] == "1.0" + assert "Documents" in parsed["files_by_category"] + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 691a2a6482a784b239d2f945a8ac6d638f169800 Mon Sep 17 00:00:00 2001 From: AzisK Date: Fri, 6 Feb 2026 22:43:21 +0100 Subject: [PATCH 3/4] Lint --- uv.lock | 2 +- zpace/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 1ffcd32..7f9813d 100644 --- a/uv.lock +++ b/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "zpace" -version = "0.4.5" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, diff --git a/zpace/main.py b/zpace/main.py index f817f49..cbd3359 100644 --- a/zpace/main.py +++ b/zpace/main.py @@ -227,7 +227,7 @@ def main(): import io buffer = io.StringIO() - buffer.write(f"DISK USAGE\n") + buffer.write("DISK USAGE\n") buffer.write("=" * 80 + "\n") buffer.write(f" Free: {format_size(free)} / {format_size(total)}\n") buffer.write(f" Used: {format_size(used)} ({used / total * 100:.1f}%)\n") From db2ff3a1a9db44d7e34d46b32922d434d907c580 Mon Sep 17 00:00:00 2001 From: AzisK Date: Fri, 6 Feb 2026 22:45:58 +0100 Subject: [PATCH 4/4] Update core.py --- zpace/core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zpace/core.py b/zpace/core.py index 99c4e05..2cf867b 100644 --- a/zpace/core.py +++ b/zpace/core.py @@ -106,7 +106,9 @@ def scan_files_and_dirs( # Pre-compute root level usage to skip logic if needed # We'll just check absolute paths for SKIP_DIRS - with tqdm(total=used_bytes, unit="B", unit_scale=True, desc="Scanning", disable=not show_progress) as pbar: + with tqdm( + total=used_bytes, unit="B", unit_scale=True, desc="Scanning", disable=not show_progress + ) as pbar: while stack: current_path, level = stack.pop()