diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish.yml similarity index 64% rename from .github/workflows/publish-pypi.yml rename to .github/workflows/publish.yml index 121932c..8f35319 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish.yml @@ -1,43 +1,37 @@ --- -name: Publish to PyPI +name: Publish on: push: tags: - - "v*" # Triggers on tags like v0.1.0 + - "v*" # Triggers on tags like v0.1.0 jobs: publish: - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: - id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Validate version matches tag run: | - PYPROJECT_VERSION=$(grep '^version = ' pyproject.toml | cut -d'"' -f2) - TAG_VERSION=${GITHUB_REF#refs/tags/v} - if [ "$PYPROJECT_VERSION" != "$TAG_VERSION" ]; then - echo "Version mismatch: pyproject.toml ($PYPROJECT_VERSION) != tag ($TAG_VERSION)" - exit 1 - fi + PYPROJECT_VERSION=$(grep '^version = ' pyproject.toml | cut -d'"' -f2) + TAG_VERSION=${GITHUB_REF#refs/tags/v} + if [ "$PYPROJECT_VERSION" != "$TAG_VERSION" ]; then + echo "Version mismatch: pyproject.toml ($PYPROJECT_VERSION) != tag ($TAG_VERSION)" + exit 1 + fi - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - - name: Install dependencies from uv.lock - run: uv sync --no-dev - - - name: Build package + - name: Build and publish to PyPI run: | - uv pip install --upgrade build - uv run python -m build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uv build + uv publish --trusted-publishing always - name: Update package managers run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/tests.yml similarity index 53% rename from .github/workflows/ci.yml rename to .github/workflows/tests.yml index d44cc41..3ff5855 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/tests.yml @@ -1,15 +1,15 @@ --- -name: CI +name: Tests on: workflow_dispatch: push: - branches: [ main ] + branches: [main] paths-ignore: - - '**.md' + - "**.md" pull_request: - branches: [ main ] + branches: [main] paths-ignore: - - '**.md' + - "**.md" jobs: test: @@ -20,36 +20,47 @@ jobs: fail-fast: false matrix: os: [ubuntu-slim, macos-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.9", "pypy3.10", "pypy3.11"] + python-version: + [ + "3.9", + "3.10", + "3.11", + "3.12", + "3.13", + "3.14", + "pypy3.9", + "pypy3.10", + "pypy3.11", + ] + + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true + - name: Checkout repository + uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - - name: Install dependencies - run: uv sync + - name: Install dependencies + run: uv sync --no-default-groups --group test - - name: Run tests - run: uv run pytest -v + - name: Run tests + run: uv run --no-sync pytest -v - - name: Run linter - run: uv run ruff check . + - name: Run linter + run: uv run --no-sync ruff check . - - name: Check formatting - run: uv run ruff format --check . + - name: Check formatting + run: uv run --no-sync ruff format --check . - - name: Test CLI - run: | - uv run python main.py --help - uv run python main.py . -n 5 -m 1000 + - name: Test CLI + run: | + uv run --no-sync python main.py --help + uv run --no-sync python main.py . -n 5 -m 1000 mypy: name: Type Check @@ -57,7 +68,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -65,15 +76,15 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --no-default-groups --group lint - name: Type check with mypy - run: uv run mypy . + run: uv run --no-sync mypy . post-dog: if: github.event_name == 'pull_request' name: Celebrate with a Dog! - needs: [test, mypy] # Only run if `test` and `mypy` succeed + needs: [test, mypy] # Only run if `test` and `mypy` succeed runs-on: ubuntu-slim steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d280f0..817c1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed + +- PyPy on Windows now works gracefully when `shutil.disk_usage` is unavailable (displays message instead of crashing) +- Fixed flaky user config tests by reloading `zpace.config` module inside pyfakefs context + +### Changed + +- Dropped Python 3.8 support (already EOL since October 2024) +- Removed `argparse` from dependencies (already in standard library) + +### CI/CD + +- Renamed workflows: `ci.yml` → `tests.yml`, `publish.yml` → `publish-pypi.yml` +- Simplified PyPI publish using `uv build`/`uv publish` instead of pypa action +- Switched to `ubuntu-slim` runners where Docker isn't needed +- Replaced explicit Python install with `UV_PYTHON` env var +- Updated `actions/checkout` to v6 +- Split dev dependencies into `test`, `lint`, and `dev` groups +- Excluded test files from mypy type checking +- Updated README badge URL + ## [0.4.5] - 2026-01-27 ### Features diff --git a/README.md b/README.md index 69db154..c452804 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Zpace [![PyPI version](https://img.shields.io/pypi/v/zpace?color=blue)](https://pypi.org/project/zpace/) -[![Tests](https://github.com/AzisK/Zpace/actions/workflows/ci.yml/badge.svg)](https://github.com/AzisK/Zpace/actions/workflows/ci.yml) +[![Tests](https://github.com/AzisK/Zpace/actions/workflows/tests.yml/badge.svg)](https://github.com/AzisK/Zpace/actions/workflows/tests.yml) A CLI tool to discover what's hogging your disk space! diff --git a/pyproject.toml b/pyproject.toml index 6a1b07d..85092ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ classifiers = [ ] dependencies = [ - "argparse>=1.4.0", "tqdm>=4.67.1", "tomli>=2.0.0; python_version < '3.11'", ] @@ -37,14 +36,20 @@ dependencies = [ zpace = "zpace.main:main" [dependency-groups] -dev = [ - "mypy>=1.18.2", - "pre-commit>=4.3.0", +test = [ "pyfakefs>=5.10.2", "pytest>=8.4.2", "ruff>=0.14.1", +] +lint = [ + "mypy>=1.18.2", "types-tqdm>=4.67.0.20250809", ] +dev = [ + {include-group = "test"}, + {include-group = "lint"}, + "pre-commit>=4.3.0", +] [tool.ruff] line-length = 100 @@ -58,5 +63,8 @@ indent-style = "space" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.mypy] +exclude = ["test_.*\\.py$"] + [tool.hatch.build.targets.wheel] include = ["zpace"] diff --git a/test_unit.py b/test_unit.py index 44b9696..c006ed7 100644 --- a/test_unit.py +++ b/test_unit.py @@ -2,6 +2,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock import sys +import importlib from zpace.core import ( calculate_dir_size, @@ -16,9 +17,6 @@ from zpace.config import ( MIN_FILE_SIZE, SKIP_DIRS, - DEFAULT_CATEGORIES, - load_user_categories_config, - USER_CONFIG_PATH, ) from io import StringIO import os @@ -766,93 +764,105 @@ def test_unicode_filenames(self, mock_tqdm, fs): assert "こんにちは.doc" in all_files +@pytest.fixture +def fs_with_config(fs): + """Fixture that reloads zpace.config after pyfakefs is active. + + This ensures USER_CONFIG_PATH is computed with the fake Path.home(). + """ + import zpace.config + + importlib.reload(zpace.config) + yield fs + + class TestLoadUserDirsConfig: """Test user directory configuration loading from ~/.zpace.toml.""" - def test_returns_defaults_when_no_config_file(self, fs): + def test_returns_defaults_when_no_config_file(self, fs_with_config): """When config file doesn't exist, return default special dirs.""" from zpace.config import load_user_dirs_config, DEFAULT_SPECIAL_DIRS result = load_user_dirs_config() assert result == DEFAULT_SPECIAL_DIRS - def test_returns_defaults_when_config_file_empty(self, fs): + def test_returns_defaults_when_config_file_empty(self, fs_with_config): """Empty config file should return defaults.""" - from zpace.config import load_user_dirs_config, DEFAULT_SPECIAL_DIRS + from zpace.config import load_user_dirs_config, DEFAULT_SPECIAL_DIRS, USER_CONFIG_PATH - fs.create_file(str(USER_CONFIG_PATH), contents="") + fs_with_config.create_file(str(USER_CONFIG_PATH), contents="") result = load_user_dirs_config() assert result == DEFAULT_SPECIAL_DIRS - def test_dirs_replaces_category(self, fs): + def test_dirs_replaces_category(self, fs_with_config): """Using 'dirs' should replace all dirs in a category.""" - from zpace.config import load_user_dirs_config + from zpace.config import load_user_dirs_config, USER_CONFIG_PATH config_content = """ [directories."Node Modules"] dirs = ["my_modules", "custom_modules"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_dirs_config() assert result["Node Modules"] == {"my_modules", "custom_modules"} - def test_add_extends_category(self, fs): + def test_add_extends_category(self, fs_with_config): """Using 'add' should add dirs to existing category.""" - from zpace.config import load_user_dirs_config + from zpace.config import load_user_dirs_config, USER_CONFIG_PATH config_content = """ [directories."Virtual Environments"] add = ["myenv", ".myenv"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_dirs_config() assert "myenv" in result["Virtual Environments"] assert ".myenv" in result["Virtual Environments"] assert ".venv" in result["Virtual Environments"] # Original still present - def test_remove_removes_from_category(self, fs): + def test_remove_removes_from_category(self, fs_with_config): """Using 'remove' should remove dirs from category.""" - from zpace.config import load_user_dirs_config + from zpace.config import load_user_dirs_config, USER_CONFIG_PATH config_content = """ [directories."Package Caches"] remove = ["vendor", ".cache"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_dirs_config() assert "vendor" not in result["Package Caches"] assert ".cache" not in result["Package Caches"] assert ".npm" in result["Package Caches"] # Other dirs still present - def test_creates_new_custom_category(self, fs): + def test_creates_new_custom_category(self, fs_with_config): """Should be able to create entirely new directory categories.""" - from zpace.config import load_user_dirs_config + from zpace.config import load_user_dirs_config, USER_CONFIG_PATH config_content = """ [directories."My Custom Dirs"] dirs = ["special_folder", "another_folder"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_dirs_config() assert "My Custom Dirs" in result assert result["My Custom Dirs"] == {"special_folder", "another_folder"} - def test_add_to_new_category_creates_it(self, fs): + def test_add_to_new_category_creates_it(self, fs_with_config): """Using 'add' on non-existent category should create it.""" - from zpace.config import load_user_dirs_config + from zpace.config import load_user_dirs_config, USER_CONFIG_PATH config_content = """ [directories.NewDirCategory] add = ["new_dir1", "new_dir2"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_dirs_config() assert "NewDirCategory" in result assert result["NewDirCategory"] == {"new_dir1", "new_dir2"} - def test_combined_operations(self, fs): + def test_combined_operations(self, fs_with_config): """Test dirs + add + remove in same category.""" - from zpace.config import load_user_dirs_config + from zpace.config import load_user_dirs_config, USER_CONFIG_PATH config_content = """ [directories.TestDirCat] @@ -860,29 +870,29 @@ def test_combined_operations(self, fs): add = ["d"] remove = ["b"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_dirs_config() assert result["TestDirCat"] == {"a", "c", "d"} - def test_invalid_toml_returns_defaults(self, fs): + def test_invalid_toml_returns_defaults(self, fs_with_config): """Malformed TOML should return defaults gracefully.""" - from zpace.config import load_user_dirs_config, DEFAULT_SPECIAL_DIRS + from zpace.config import load_user_dirs_config, DEFAULT_SPECIAL_DIRS, USER_CONFIG_PATH config_content = "this is not valid [ toml {" - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_dirs_config() assert result == DEFAULT_SPECIAL_DIRS - def test_does_not_mutate_default_special_dirs(self, fs): + def test_does_not_mutate_default_special_dirs(self, fs_with_config): """Ensure loading config doesn't mutate DEFAULT_SPECIAL_DIRS.""" - from zpace.config import load_user_dirs_config, DEFAULT_SPECIAL_DIRS + from zpace.config import load_user_dirs_config, DEFAULT_SPECIAL_DIRS, USER_CONFIG_PATH original_node_modules = DEFAULT_SPECIAL_DIRS["Node Modules"].copy() config_content = """ [directories."Node Modules"] dirs = ["custom"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) load_user_dirs_config() assert DEFAULT_SPECIAL_DIRS["Node Modules"] == original_node_modules @@ -890,103 +900,137 @@ def test_does_not_mutate_default_special_dirs(self, fs): class TestLoadUserConfig: """Test user configuration loading from ~/.zpace.toml.""" - def test_returns_defaults_when_no_config_file(self, fs): + def test_returns_defaults_when_no_config_file(self, fs_with_config): """When config file doesn't exist, return default categories.""" + from zpace.config import load_user_categories_config, DEFAULT_CATEGORIES + result = load_user_categories_config() assert result == DEFAULT_CATEGORIES - def test_returns_defaults_when_config_file_empty(self, fs): + def test_returns_defaults_when_config_file_empty(self, fs_with_config): """Empty config file should return defaults.""" - fs.create_file(str(USER_CONFIG_PATH), contents="") + from zpace.config import ( + load_user_categories_config, + DEFAULT_CATEGORIES, + USER_CONFIG_PATH, + ) + + fs_with_config.create_file(str(USER_CONFIG_PATH), contents="") result = load_user_categories_config() assert result == DEFAULT_CATEGORIES - def test_extensions_replaces_category(self, fs): + def test_extensions_replaces_category(self, fs_with_config): """Using 'extensions' should replace all extensions in a category.""" + from zpace.config import ( + load_user_categories_config, + DEFAULT_CATEGORIES, + USER_CONFIG_PATH, + ) + config_content = """ [categories.Pictures] extensions = [".custom1", ".custom2"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_categories_config() assert result["Pictures"] == {".custom1", ".custom2"} - # Other categories should remain unchanged assert result["Documents"] == DEFAULT_CATEGORIES["Documents"] - def test_add_extends_category(self, fs): + def test_add_extends_category(self, fs_with_config): """Using 'add' should add extensions to existing category.""" + from zpace.config import load_user_categories_config, USER_CONFIG_PATH + config_content = """ [categories.Code] add = [".sql", ".graphql"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_categories_config() assert ".sql" in result["Code"] assert ".graphql" in result["Code"] assert ".py" in result["Code"] # Original extension still present - def test_remove_removes_from_category(self, fs): + def test_remove_removes_from_category(self, fs_with_config): """Using 'remove' should remove extensions from category.""" + from zpace.config import load_user_categories_config, USER_CONFIG_PATH + config_content = """ [categories.Documents] remove = [".md", ".txt"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_categories_config() assert ".md" not in result["Documents"] assert ".txt" not in result["Documents"] assert ".pdf" in result["Documents"] # Other extensions still present - def test_creates_new_custom_category(self, fs): + def test_creates_new_custom_category(self, fs_with_config): """Should be able to create entirely new categories.""" + from zpace.config import load_user_categories_config, USER_CONFIG_PATH + config_content = """ [categories.Fonts] extensions = [".ttf", ".otf", ".woff"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_categories_config() assert "Fonts" in result assert result["Fonts"] == {".ttf", ".otf", ".woff"} - def test_add_to_new_category_creates_it(self, fs): + def test_add_to_new_category_creates_it(self, fs_with_config): """Using 'add' on non-existent category should create it.""" + from zpace.config import load_user_categories_config, USER_CONFIG_PATH + config_content = """ [categories.NewCategory] add = [".new1", ".new2"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_categories_config() assert "NewCategory" in result assert result["NewCategory"] == {".new1", ".new2"} - def test_combined_operations(self, fs): + def test_combined_operations(self, fs_with_config): """Test extensions + add + remove in same category.""" + from zpace.config import load_user_categories_config, USER_CONFIG_PATH + config_content = """ [categories.TestCat] extensions = [".a", ".b", ".c"] add = [".d"] remove = [".b"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_categories_config() - # extensions sets base, add adds, remove removes assert result["TestCat"] == {".a", ".c", ".d"} - def test_invalid_toml_returns_defaults(self, fs): + def test_invalid_toml_returns_defaults(self, fs_with_config): """Malformed TOML should return defaults gracefully.""" + from zpace.config import ( + load_user_categories_config, + DEFAULT_CATEGORIES, + USER_CONFIG_PATH, + ) + config_content = "this is not valid [ toml {" - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) result = load_user_categories_config() assert result == DEFAULT_CATEGORIES - def test_does_not_mutate_default_categories(self, fs): + def test_does_not_mutate_default_categories(self, fs_with_config): """Ensure loading config doesn't mutate DEFAULT_CATEGORIES.""" + from zpace.config import ( + load_user_categories_config, + DEFAULT_CATEGORIES, + USER_CONFIG_PATH, + ) + original_pictures = DEFAULT_CATEGORIES["Pictures"].copy() config_content = """ [categories.Pictures] extensions = [".custom"] """ - fs.create_file(str(USER_CONFIG_PATH), contents=config_content) + fs_with_config.create_file(str(USER_CONFIG_PATH), contents=config_content) load_user_categories_config() assert DEFAULT_CATEGORIES["Pictures"] == original_pictures diff --git a/uv.lock b/uv.lock index b0fc177..1ffcd32 100644 --- a/uv.lock +++ b/uv.lock @@ -6,15 +6,6 @@ resolution-markers = [ "python_full_version < '3.10'", ] -[[package]] -name = "argparse" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/argparse-1.4.0.tar.gz", hash = "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", size = 70508, upload-time = "2015-09-12T20:22:16.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/94/3af39d34be01a24a6e65433d19e107099374224905f1e0cc6bbe1fd22a2f/argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314", size = 23000, upload-time = "2015-09-14T16:03:16.137Z" }, -] - [[package]] name = "cfgv" version = "3.4.0" @@ -505,10 +496,9 @@ wheels = [ [[package]] name = "zpace" -version = "0.4.4" +version = "0.4.5" source = { editable = "." } dependencies = [ - { name = "argparse" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tqdm" }, ] @@ -522,10 +512,18 @@ dev = [ { name = "ruff" }, { name = "types-tqdm" }, ] +lint = [ + { name = "mypy" }, + { name = "types-tqdm" }, +] +test = [ + { name = "pyfakefs" }, + { name = "pytest" }, + { name = "ruff" }, +] [package.metadata] requires-dist = [ - { name = "argparse", specifier = ">=1.4.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "tqdm", specifier = ">=4.67.1" }, ] @@ -539,3 +537,12 @@ dev = [ { name = "ruff", specifier = ">=0.14.1" }, { name = "types-tqdm", specifier = ">=4.67.0.20250809" }, ] +lint = [ + { name = "mypy", specifier = ">=1.18.2" }, + { name = "types-tqdm", specifier = ">=4.67.0.20250809" }, +] +test = [ + { name = "pyfakefs", specifier = ">=5.10.2" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "ruff", specifier = ">=0.14.1" }, +] diff --git a/zpace/main.py b/zpace/main.py index feb54d5..4e69ee1 100644 --- a/zpace/main.py +++ b/zpace/main.py @@ -118,8 +118,11 @@ def main(): print("\nDISK USAGE") print("=" * terminal_width) - print(f" Free: {format_size(free)} / {format_size(total)}") - print(f" Used: {format_size(used)} ({used / total * 100:.1f}%)") + 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_path = get_trash_path() diff --git a/zpace/utils.py b/zpace/utils.py index 8969af2..0767cee 100644 --- a/zpace/utils.py +++ b/zpace/utils.py @@ -6,8 +6,11 @@ def get_disk_usage(path: str): - total, used, free = shutil.disk_usage(path) - return total, used, free + try: + total, used, free = shutil.disk_usage(path) + return total, used, free + except (AttributeError, OSError): + return 0, 0, 0 def format_size(size: float) -> str: