├── tests
├── __init__.py
├── test_progress.py
├── test_parser.py
├── test_error_handling.py
├── test_utils.py
├── test_core_modules.py
├── test_core.py
├── test_main.py
└── test_config.py
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── questions.md
│ ├── feature_request.md
│ └── bug_report.md
├── workflows
│ ├── greetings.yml
│ ├── codeql.yml
│ └── tox.yml
├── dependabot.yml
└── pull_request_template.md
├── mac_cleanup
├── __version__.py
├── __main__.py
├── __init__.py
├── console.py
├── parser.py
├── utils.py
├── main.py
├── progress.py
├── error_handling.py
├── core_modules.py
├── core.py
├── config.py
└── default_modules.py
├── SECURITY.md
├── bumpVersion
├── .gitignore
├── tox.ini
├── .pre-commit-config.yaml
├── module_template.py
├── pyproject.toml
├── README.md
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
└── LICENSE
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [efa2d19, fwartner]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/questions.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Questions
3 | about: Ask your questions here
4 | title: ''
5 | labels: question
6 | assignees: efa2d19
7 |
8 | ---
9 |
10 | Question
11 | ---
12 |
13 | _Describe details of your question here._
14 |
--------------------------------------------------------------------------------
/mac_cleanup/__version__.py:
--------------------------------------------------------------------------------
1 | try: # For gh-actions
2 | from importlib.metadata import version
3 |
4 | __version__ = version(__package__) # pyright: ignore [reportArgumentType]
5 | except ModuleNotFoundError: # pragma: no cover
6 | __version__ = "source"
7 |
--------------------------------------------------------------------------------
/mac_cleanup/__main__.py:
--------------------------------------------------------------------------------
1 | """Mocking HomeBrew entrypoint."""
2 |
3 | # -*- coding: utf-8 -*-
4 | import re
5 | import sys
6 |
7 | from . import main
8 |
9 | if __name__ == "__main__":
10 | sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
11 | sys.exit(main())
12 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Any version with :x: are no longer supported and may face issues in it.
6 |
7 | | Version | Supported |
8 | |-----------|--------------------|
9 | | \>= 3.1.0 | :white_check_mark: |
10 | | < 3.1.0 | :x: |
11 |
12 | ## Reporting a Vulnerability
13 |
14 | To report a security issue, please use [GitHub Issues](https://github.com/mac-cleanup/mac-cleanup-py/issues/new/choose).
15 |
--------------------------------------------------------------------------------
/mac_cleanup/__init__.py:
--------------------------------------------------------------------------------
1 | from mac_cleanup.parser import args # isort: skip_file
2 | from mac_cleanup.core import ProxyCollector as Collector
3 | from mac_cleanup.core_modules import Command, Path
4 | from mac_cleanup.main import EntryPoint
5 |
6 | try:
7 | from mac_cleanup.__version__ import __version__
8 | except ImportError: # pragma: no cover
9 | __version__ = "source"
10 |
11 | main = EntryPoint().start
12 |
13 | __title__ = "mac-cleanup-py"
14 | __all__ = ["Collector", "Path", "Command", "args", "main"]
15 |
--------------------------------------------------------------------------------
/.github/workflows/greetings.yml:
--------------------------------------------------------------------------------
1 | name: Greetings
2 |
3 | on: [ pull_request, issues ]
4 |
5 | jobs:
6 | greeting:
7 | runs-on: ubuntu-latest
8 | permissions:
9 | issues: write
10 | pull-requests: write
11 | steps:
12 | - uses: actions/first-interaction@v1
13 | with:
14 | repo-token: ${{ secrets.GITHUB_TOKEN }}
15 | issue-message: "👋🏻 Thank you for your feedback, gonna watch it soon"
16 | pr-message: "👋🏻 Thank you for your first contribution, appreciate it, gonna watch it soon"
17 |
--------------------------------------------------------------------------------
/bumpVersion:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env zsh
2 |
3 | # Check no version
4 | if [[ -z "$1" ]]; then echo "No version supplied"; exit 1; fi
5 |
6 | # Check incorrect version
7 | if ! echo "$1" | grep -Eq '^[0-9]+\.[0-9]\.[0-9]+$'; then echo "Incorrect version"; exit 1; fi
8 |
9 | # Get current version
10 | currentVersion="$(grep -oE '(?:^version = \")(\d|\.)+(?:\")' pyproject.toml | sed -r 's/^version = "(([0-9]|\.)+)"/\1/g')"
11 |
12 | # Replace in pyproject
13 | sed -ri "" "s/^version = \"$currentVersion\"/version = \"$1\"/g" ./pyproject.toml
14 |
15 | # Replace in README
16 | sed -ri "" "s/$currentVersion/$1/g" ./README.md
17 |
18 | exit 0
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # pyenv
7 | .python-version
8 |
9 |
10 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
11 | __pypackages__/
12 |
13 | # Environments
14 | .env
15 | .venv
16 | env/
17 | venv/
18 | ENV/
19 | env.bak/
20 | venv.bak/
21 |
22 | # JetBrains staff
23 | .idea/
24 |
25 | # Ignore configuration
26 | mac_cleanup/modules.toml
27 |
28 | # mypy stuff
29 | .mypy_cache/*
30 |
31 | # pytest stuff
32 | .pytest_cache/*
33 |
34 | # coverage stuff
35 | .coverage*
36 |
37 | # tox
38 | .tox/*
39 |
40 | # ruff
41 | .ruff_cache/
42 |
43 | # macOS stuff
44 | .DS_Store
45 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Please see the documentation for all configuration options:
2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3 |
4 | version: 2
5 | updates:
6 | - package-ecosystem: "pip"
7 | directory: "/"
8 | schedule:
9 | interval: "weekly"
10 | time: "12:00"
11 | day: "friday"
12 | assignees:
13 | - "efa2d19"
14 | reviewers:
15 | - "efa2d19"
16 | commit-message:
17 | prefix: "poetry"
18 | target-branch: "develop"
19 |
20 | - package-ecosystem: "github-actions"
21 | directory: "/"
22 | schedule:
23 | interval: "weekly"
24 | time: "12:00"
25 | day: "friday"
26 |
--------------------------------------------------------------------------------
/mac_cleanup/console.py:
--------------------------------------------------------------------------------
1 | """Configuration of Rich console."""
2 |
3 | from typing import Optional
4 |
5 | from rich.console import Console
6 | from rich.theme import Theme
7 |
8 | console = Console(
9 | theme=Theme({"info": "cyan", "warning": "magenta", "danger": "bold red", "success": "bold green"}), record=True
10 | )
11 |
12 |
13 | def print_panel(text: str, title: Optional[str] = None) -> None:
14 | """
15 | Prints a rich panel with the given text.
16 |
17 | Args:
18 | text: Text to print in the panel
19 | title: Title of the panel
20 | """
21 | from rich.panel import Panel
22 | from rich.text import Text
23 |
24 | console.print(Panel(Text.from_markup(text, justify="center"), subtitle=title, subtitle_align="right"))
25 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: efa2d19
7 |
8 | ---
9 |
10 | Is your feature request related to a problem?
11 | ---
12 |
13 | _A clear and concise description of what the problem is._
14 |
15 | #### Example
16 | > I'm always frustrated when [...]
17 |
18 | Describe the solution you'd like
19 | ---
20 |
21 | _A clear and concise description of what you want to happen._
22 |
23 | Describe alternatives you've considered
24 | ---
25 |
26 | _A clear and concise description of any alternative solutions or features you've considered._
27 |
28 | Additional Context
29 | ---
30 |
31 | _Add any other context or screenshots about the feature request here._
32 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: efa2d19
7 |
8 | ---
9 |
10 | Describe The Bug
11 | ---
12 |
13 | _A clear and concise description of what the bug is._
14 |
15 | Version
16 | ---
17 |
18 | _Specify the version where the bug appeared_
19 |
20 | How To Reproduce
21 | ---
22 |
23 | _Steps to reproduce the behavior_
24 |
25 | 1. Go to '...'
26 | 2. Click on '....'
27 | 3. Scroll down to '....'
28 | 4. See something
29 |
30 | Expected Behavior
31 | ---
32 |
33 | _A clear and concise description of what you expect to happen._
34 |
35 | Screenshots
36 | ---
37 |
38 | _If applicable, add screenshots to help explain your problem._
39 |
40 | Additional Context
41 | ---
42 |
43 | _Add any other context about the problem here._
44 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - develop
8 |
9 | pull_request:
10 | branches:
11 | - main
12 | - develop
13 |
14 | jobs:
15 | analyze:
16 | name: Analyze
17 | runs-on: ubuntu-latest
18 | permissions:
19 | actions: read
20 | contents: read
21 | security-events: write
22 |
23 | strategy:
24 | fail-fast: false
25 | matrix:
26 | language: [ 'python' ]
27 |
28 | steps:
29 | - name: Checkout repository
30 | uses: actions/checkout@v4
31 |
32 | # Initializes the CodeQL tools for scanning.
33 | - name: Initialize CodeQL
34 | uses: github/codeql-action/init@v3
35 | with:
36 | languages: ${{ matrix.language }}
37 |
38 | - name: Perform CodeQL Analysis
39 | uses: github/codeql-action/analyze@v3
40 |
--------------------------------------------------------------------------------
/.github/workflows/tox.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - develop
8 |
9 | pull_request:
10 | branches:
11 | - main
12 | - develop
13 |
14 | jobs:
15 | tests:
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | os: [ macos-latest ]
20 | python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
21 | include:
22 | - os: macos-26
23 | python-version: 3.x
24 | runs-on: ${{ matrix.os }}
25 | steps:
26 | - uses: actions/checkout@v5
27 | - name: Set up Python v${{ matrix.python-version }}
28 | uses: actions/setup-python@v6
29 | with:
30 | python-version: ${{ matrix.python-version }}
31 | allow-prereleases: true
32 | - name: Install tox-gh-actions
33 | run: |
34 | python -m pip install --upgrade pip
35 | pip install tox tox-gh-actions
36 | - name: Tests with Tox
37 | run: tox
38 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | minversion = 4.24.2
3 | envlist = py310, py311, py312, py313, py314, pyright, ruff, black, isort, docformatter
4 | isolated_build = True
5 |
6 | [gh-actions]
7 | python =
8 | 3.10: py310
9 | 3.11: py311
10 | 3.12: py312
11 | 3.13: py313, pyright, ruff, black, isort, docformatter
12 | 3.14: py314
13 |
14 | [testenv]
15 | basepython =
16 | py310: python3.10
17 | py311: python3.11
18 | py312: python3.12
19 | py313, pyright, ruff, black, isort, docformatter: python3.13
20 | py314: python3.14
21 | setenv =
22 | PYTHONPATH = {toxinidir}
23 | py310, py311, py312, py313, py314: GROUP = main,test
24 | pyright, ruff: GROUP = main,test
25 | black, isort, docformatter: GROUP = lint
26 | skip_install = true
27 | allowlist_externals = poetry
28 | deps =
29 | poetry
30 | commands_pre =
31 | poetry install --only "{env:GROUP}" --no-root -v
32 | commands =
33 | py310, py311, py312, py313, py314: poetry run pytest
34 | pyright: poetry run pyright
35 | ruff: poetry run ruff check .
36 | black: poetry run black . --check
37 | isort: poetry run isort . -c
38 | docformatter: poetry run docformatter -cr . --config ./pyproject.toml
39 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | default_stages:
2 | - push
3 |
4 | repos:
5 | - repo: https://github.com/commitizen-tools/commitizen
6 | rev: v3.29.1
7 | hooks:
8 | - id: commitizen
9 | name: Check Convenient Commits
10 | stages: [ commit-msg ]
11 |
12 | - repo: https://github.com/psf/black
13 | rev: 24.10.0
14 | hooks:
15 | - id: black
16 | name: Run black
17 | types: [ python ]
18 |
19 | - repo: https://github.com/pycqa/isort
20 | rev: 5.13.2
21 | hooks:
22 | - id: isort
23 | name: Run isort
24 | types: [ python ]
25 |
26 | - repo: local
27 | hooks:
28 | - id: pytest
29 | name: Run pytest
30 | language: system
31 | pass_filenames: false
32 | types: [ python ]
33 | entry: poetry run pytest
34 |
35 | - id: pyright
36 | name: Run pyright
37 | language: system
38 | pass_filenames: false
39 | types: [ python ]
40 | entry: poetry run pyright
41 |
42 | - id: ruff
43 | name: Run ruff
44 | language: system
45 | pass_filenames: false
46 | types: [ python ]
47 | entry: poetry run ruff check .
48 |
49 | - id: docformatter
50 | name: Run docformatter
51 | language: system
52 | pass_filenames: false
53 | types: [ python ]
54 | entry: poetry run docformatter -cr . --config ./pyproject.toml
55 |
--------------------------------------------------------------------------------
/tests/test_progress.py:
--------------------------------------------------------------------------------
1 | """All tests for mac_cleanup_py.progress."""
2 |
3 | from typing import Callable
4 |
5 | import pytest
6 | from _pytest.capture import CaptureFixture
7 | from _pytest.monkeypatch import MonkeyPatch
8 |
9 | from mac_cleanup.progress import ProgressBar
10 |
11 |
12 | @pytest.mark.parametrize("user_continue", [True, False])
13 | def test_prompt(user_continue: bool, capsys: CaptureFixture[str], monkeypatch: MonkeyPatch):
14 | """Test ProgressBar prompt call."""
15 |
16 | # Dummy user input
17 | user_input_str: Callable[..., str] = lambda: "y" if user_continue else "n"
18 |
19 | # Check prompt output
20 | monkeypatch.setattr("builtins.input", user_input_str)
21 | assert ProgressBar.prompt("Prompt Text", "Prompt Title") == user_continue
22 |
23 | # Check stdout
24 | captured = capsys.readouterr().out
25 | assert "Prompt Text" in captured
26 | assert "Prompt Title" in captured
27 |
28 |
29 | def test_wrap_iter(capsys: CaptureFixture[str], monkeypatch: MonkeyPatch):
30 | """Test ProgressBar wrap_iter call."""
31 |
32 | seq = list(range(5))
33 |
34 | for _ in ProgressBar.wrap_iter(seq, total=len(seq), description="test_wrap_iter"):
35 | # Change transient attribute to be able to capture stdout
36 | monkeypatch.setattr(ProgressBar.current_progress.live, "transient", False)
37 |
38 | # Check stdout
39 | captured = capsys.readouterr().out
40 |
41 | # Check percents in output
42 | assert "100%" in captured
43 |
44 | # Check description in output
45 | assert "test_wrap_iter" in captured
46 |
--------------------------------------------------------------------------------
/mac_cleanup/parser.py:
--------------------------------------------------------------------------------
1 | """Console argument parser configuration."""
2 |
3 | from argparse import ArgumentParser, RawTextHelpFormatter
4 | from typing import final
5 |
6 | import attr
7 |
8 | from mac_cleanup.__version__ import __version__
9 |
10 |
11 | @final
12 | @attr.s(slots=True)
13 | class Args:
14 | dry_run: bool = attr.ib(default=False)
15 | update: bool = attr.ib(default=False)
16 | configure: bool = attr.ib(default=False)
17 | custom_path: bool = attr.ib(default=False)
18 | force: bool = attr.ib(default=False)
19 | verbose: bool = attr.ib(default=False)
20 |
21 |
22 | parser = ArgumentParser(
23 | description=f"""\
24 | Python cleanup script for macOS
25 | Version: {__version__}
26 | https://github.com/mac-cleanup/mac-cleanup-py\
27 | """,
28 | formatter_class=RawTextHelpFormatter,
29 | )
30 |
31 | parser.add_argument("-n", "--dry-run", help="Run without deleting stuff", action="store_true")
32 |
33 | parser.add_argument("-u", "--update", help="Update Homebrew on cleanup", action="store_true")
34 |
35 | parser.add_argument("-c", "--configure", help="Open module configuration screen", action="store_true")
36 |
37 | parser.add_argument("-p", "--custom-path", help="Specify path for custom modules", action="store_true")
38 |
39 | parser.add_argument("-f", "--force", help="Accept all warnings", action="store_true")
40 |
41 | parser.add_argument("-v", "--verbose", help="Print folders to be deleted", action="store_true")
42 |
43 | args = Args()
44 | parser.parse_args(namespace=args)
45 |
46 | # args.dry_run = True # debug
47 | # args.configure = True # debug
48 | # args.custom_path = True # debug
49 | # args.force = True # debug
50 | # args.verbose = True # debug
51 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | Title of Pull Request
2 | ---
3 |
4 | #### Example
5 | > Fixes #[Issue Number]
6 |
7 | Description
8 | ---
9 |
10 | _Briefly describe the changes in this pull request._
11 |
12 | - [Summary of major changes]
13 | - [Summary of minor changes]
14 | - [Any other relevant information]
15 |
16 | Type of Change
17 | ---
18 |
19 | Please delete options that are not relevant.
20 |
21 | - [ ] Bug fix (non-breaking change which fixes an issue)
22 | - [ ] New feature (non-breaking change which adds functionality)
23 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
24 | - [ ] This change requires a documentation update
25 |
26 | How Has This Been Tested?
27 | ---
28 |
29 | Please describe the tests that you ran to verify your changes.
30 | Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
31 |
32 | - [ ] Test A
33 | - [ ] Test B
34 |
35 | Checklist
36 | ---
37 |
38 | - [ ] My code follows the style guidelines of this project
39 | - [ ] I have performed a self-review of my own code
40 | - [ ] I have commented on my code, particularly in hard-to-understand areas
41 | - [ ] I have made corresponding changes to the documentation
42 | - [ ] My changes generate no new warnings
43 | - [ ] I have used Conventional Commits for my commit messages
44 | - [ ] I have added tests that prove my fix is effective or that my feature works
45 | - [ ] New and existing unit tests pass locally with my changes
46 | - [ ] Any dependent changes have been merged and published in downstream modules
47 |
48 | Screenshots
49 | ---
50 |
51 | _Attach screenshots or screen recordings of the changes, if any._
52 |
53 | Additional Context
54 | ---
55 |
56 | _Add any other context or information about the pull request here._
57 |
--------------------------------------------------------------------------------
/module_template.py:
--------------------------------------------------------------------------------
1 | """Custom module template example."""
2 |
3 | from mac_cleanup import *
4 |
5 | # Do not import any functions at the top level
6 |
7 | # Get an instance of Collector
8 | clc = Collector()
9 |
10 |
11 | # Module's name in configuration screen == function name
12 | def module_example_1():
13 | # Import anything you need from utils here
14 | from mac_cleanup.utils import check_exists, cmd
15 |
16 | # check_exists - checks if specified path exists
17 | if check_exists("~/example/path"):
18 | # Open context manager of Collector
19 | with clc as unit:
20 | # message() - sets message to be displayed in the progress bar
21 | unit.message("Message you want to see in progress bar")
22 |
23 | # add() - adds desired module to modules list
24 | unit.add(
25 | # Path - used for deleting paths
26 | Path("~/example/path")
27 | # with_prompt - calls for user prompt to approve "risky" action
28 | # You can specify question in prompt with optional attr "message_"
29 | .with_prompt()
30 | )
31 |
32 | unit.add(
33 | Path("~/example/dry_run/file.webm")
34 | # dry_run_only - specified path will be counted in dry run, but won't be deleted
35 | .dry_run_only()
36 | )
37 |
38 | # cmd() - executes specified command and return stdout only
39 | # stderr can be returned with attr "ignore_errors" set to False
40 | if cmd("echo 1") == "1":
41 | unit.add(
42 | # Command - used for executing any command with :func:`mac_cleanup.utils.cmd`
43 | Command("whoami").with_prompt("You will see your username. Proceed?")
44 | # with_errors - adds stderr to return of command execution
45 | .with_errors()
46 | )
47 |
48 |
49 | # You can create lots of modules in one file
50 | def module_example_2():
51 | pass
52 |
--------------------------------------------------------------------------------
/tests/test_parser.py:
--------------------------------------------------------------------------------
1 | """All tests for mac_cleanup_py.parser."""
2 |
3 | from argparse import Action
4 |
5 | import pytest
6 |
7 | from mac_cleanup.parser import Args, parser
8 |
9 |
10 | @pytest.fixture
11 | def get_namespace() -> Args:
12 | """Get empty args."""
13 |
14 | return Args()
15 |
16 |
17 | @pytest.fixture(scope="session")
18 | def get_parser_actions() -> list[Action]:
19 | """Get parser actions."""
20 |
21 | action_list = parser._actions # noqa
22 |
23 | # Remove help action from the list
24 | return action_list[1:]
25 |
26 |
27 | class TestParser:
28 | @staticmethod
29 | def get_all_args_from_namespace(namespace: Args) -> list[str]:
30 | """Get filtered attribute list (without dunder methods)"""
31 |
32 | return [attr for attr in dir(namespace) if not attr.startswith("__")]
33 |
34 | def test_description(self):
35 | """Test parser description."""
36 |
37 | from mac_cleanup.__version__ import __version__
38 |
39 | # Check current version in description
40 | assert parser.description is not None
41 | assert f"Version: {__version__}" in parser.description
42 |
43 | def test_actions_empty(self, get_namespace: Args):
44 | """Test parser without args."""
45 |
46 | # Set empty args to parser
47 | parser.parse_args(namespace=get_namespace)
48 |
49 | # Check there is no attrs
50 | assert not any(getattr(get_namespace, attr) for attr in self.get_all_args_from_namespace(get_namespace))
51 |
52 | @pytest.mark.parametrize(
53 | "is_short_name",
54 | # test invoking actions by short and long names
55 | [True, False],
56 | )
57 | def test_actions(self, is_short_name: bool, get_namespace: Args, get_parser_actions: list[Action]):
58 | """Test parser actions."""
59 |
60 | # Select actions name (short or long)
61 | action_index = 0 if is_short_name else 1
62 |
63 | # Get action list
64 | action_list = [action.option_strings[action_index] for action in get_parser_actions]
65 |
66 | # Add actions to parser
67 | parser.parse_args(args=action_list, namespace=get_namespace)
68 |
69 | # Check all attrs are set
70 | assert all(getattr(get_namespace, attr) for attr in self.get_all_args_from_namespace(get_namespace))
71 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "mac_cleanup"
3 | version = "3.3.0"
4 | description = "Python cleanup script for macOS"
5 | license = "Apache-2.0"
6 | authors = [ "efa2d19" ]
7 | readme = "README.md"
8 | homepage = "https://github.com/mac-cleanup/mac-cleanup-py"
9 | repository = "https://github.com/mac-cleanup/mac-cleanup-py"
10 | keywords = [
11 | "macos",
12 | "script",
13 | "cleanup",
14 | "cleaner"
15 | ]
16 | classifiers = [
17 | "Environment :: Console",
18 | "Operating System :: MacOS",
19 | "Programming Language :: Python :: 3.10",
20 | "Programming Language :: Python :: 3.11",
21 | "Programming Language :: Python :: 3.12",
22 | "Programming Language :: Python :: 3.13"
23 | ]
24 |
25 | include = [ "LICENSE" ]
26 | exclude = [ "mac_cleanup/__main__.py", "mac_cleanup/py.typed" ]
27 |
28 | [tool.poetry.urls]
29 | "Documentation" = "https://github.com/mac-cleanup/mac-cleanup-py#install-automatically"
30 | "Issue Tracker" = "https://github.com/mac-cleanup/mac-cleanup-py/issues"
31 |
32 |
33 | [tool.poetry.dependencies]
34 | python = ">=3.10,<3.15"
35 | rich = "^13.9.4"
36 | attrs = "~25.3.0"
37 | inquirer = "^3.4.0"
38 | toml = "~0.10.2"
39 | beartype = "~0.22.2"
40 | xattr = "~1.1.4"
41 |
42 | [tool.poetry.scripts]
43 | mac-cleanup = "mac_cleanup:main"
44 |
45 | [tool.poetry.group.test.dependencies]
46 | tox = "~4.24.2"
47 | pytest = "^8.4.2"
48 | pytest-cov = "~7.0.0"
49 | pyright = "^1.1.397"
50 | ruff = "^0.11.0"
51 |
52 | [tool.poetry.group.dev.dependencies]
53 | commitizen = "~4.4.1"
54 | jinja2 = "^3.1.6"
55 | pre-commit = "~4.2.0"
56 |
57 | [tool.poetry.group.lint.dependencies]
58 | black = "^25.1.0"
59 | isort = "^6.0.1"
60 | docformatter = "^1.7.5"
61 |
62 | [build-system]
63 | requires = [ "poetry-core>=2.1.0,<3.0.0" ]
64 | build-backend = "poetry.core.masonry.api"
65 |
66 | [tool.black]
67 | line-length = 120
68 | skip-magic-trailing-comma = true
69 | target-version = [ "py313" ]
70 |
71 | [tool.isort]
72 | py_version=313
73 | profile = "black"
74 | line_length = 120
75 | multi_line_output = 3
76 | include_trailing_comma = true
77 | force_grid_wrap = 0
78 | use_parentheses = true
79 | ensure_newline_before_comments = false
80 |
81 | [tool.docformatter]
82 | wrap-summaries = 100
83 | wrap-descriptions = 100
84 | tab-width = 4
85 | pre-summary-newline = true
86 | close-quotes-on-newline = true
87 |
88 | [tool.commitizen]
89 | name = "cz_conventional_commits"
90 | version_files = [ "pyproject.toml:version" ]
91 | tag_format = "v$version"
92 |
93 | [tool.coverage.run]
94 | omit = [
95 | "mac_cleanup/__main__.py",
96 | "mac_cleanup/default_modules.py"
97 | ]
98 |
99 | [tool.coverage.report]
100 | show_missing = true
101 |
102 | [tool.pyright]
103 | pythonVersion = "3.13"
104 | pythonPlatform = "Darwin"
105 | typeCheckingMode = "strict"
106 | reportPrivateUsage = false
107 | reportIncompatibleMethodOverride = false
108 | reportImportCycles = false
109 |
110 | [tool.ruff]
111 | target-version = "py313"
112 | line-length = 120
113 | ignore = [
114 | "F403", # star imports
115 | "F405", # probably undefined stuff from star imports
116 | "E731", # lambda expressions
117 | "C408" # dict/list as calls
118 | ]
119 | select = [
120 | "E", # default
121 | "F", # default
122 | "B", # bugbear, blind-except
123 | "C", # comprehensions
124 | "T", # print
125 | "Q", # quotes
126 | "A", # builtins
127 | "PT", # pytest-style
128 | "INP", # pep420
129 | "SIM", # simplify
130 | ]
131 |
132 | [tool.ruff.flake8-quotes]
133 | inline-quotes = "double"
134 | multiline-quotes = "double"
135 |
136 | [tool.pytest.ini_options]
137 | minversion = "8.3.5"
138 | addopts = "--cov=mac_cleanup"
139 | testpaths = [ "tests" ]
140 |
--------------------------------------------------------------------------------
/mac_cleanup/utils.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import Optional, cast
3 |
4 | from beartype import beartype # pyright: ignore [reportUnknownVariableType]
5 | from xattr import xattr # pyright: ignore [reportMissingTypeStubs]
6 |
7 |
8 | @beartype
9 | def cmd(command: str, *, ignore_errors: bool = True) -> str:
10 | """
11 | Executes command in Popen.
12 |
13 | :param command: Bash command
14 | :param ignore_errors: If True, no stderr in return
15 | :return: stdout of executed command
16 | """
17 |
18 | from subprocess import DEVNULL, PIPE, Popen
19 |
20 | # Get stdout and stderr from PIPE
21 | out_tuple = Popen(command, shell=True, stdout=PIPE, stderr=(DEVNULL if ignore_errors else PIPE)).communicate()
22 |
23 | # Cast correct type on out_tuple
24 | out_tuple = cast(tuple[Optional[bytes], Optional[bytes]], out_tuple)
25 |
26 | # Filter NoneType output and decode it
27 | filtered_out = [out.decode("utf-8", errors="replace").strip() for out in out_tuple if out is not None]
28 |
29 | return "".join(filtered_out)
30 |
31 |
32 | @beartype
33 | def expanduser(str_path: str) -> str:
34 | """
35 | Expands user.
36 |
37 | :param str_path: Path to be expanded
38 | :return: Path with extended user path as a posix
39 | """
40 |
41 | from pathlib import Path
42 |
43 | return Path(str_path).expanduser().as_posix()
44 |
45 |
46 | @beartype
47 | def check_exists(path: Path | str, *, expand_user: bool = True) -> bool:
48 | """
49 | Checks if path exists.
50 |
51 | :param path: Path to be checked
52 | :param expand_user: True if path needs to be expanded
53 | :return: True if specified path exists
54 | """
55 |
56 | if not isinstance(path, Path):
57 | path = Path(path)
58 |
59 | if expand_user:
60 | path = path.expanduser()
61 |
62 | # If glob return True (it'll delete nothing at the end, hard to handle otherwise)
63 | if "*" in path.as_posix():
64 | return True
65 |
66 | return path.exists()
67 |
68 |
69 | @beartype
70 | def check_deletable(path: Path | str) -> bool:
71 | """
72 | Checks if path is deletable.
73 |
74 | :param path: Path to be deleted
75 | :return: True if specified path is deletable
76 | """
77 |
78 | # Convert path to correct type
79 | if not isinstance(path, Path):
80 | path_: Path = Path(path)
81 | else:
82 | path_ = path
83 |
84 | sip_list = ["/System", "/usr", "/sbin", "/Applications", "/Library", "/usr/local"]
85 |
86 | user_list = ["~/Documents", "~/Downloads", "~/Desktop", "~/Movies", "~/Pictures"]
87 |
88 | # Returns False if empty
89 | if (path_posix := path_.as_posix()) == ".":
90 | return False
91 |
92 | # If glob return True (it'll delete nothing at the end, hard to handle otherwise)
93 | if "*" in path_posix:
94 | return True
95 |
96 | # Returns False if path startswith anything from SIP list or in custom list
97 | if any(path_posix.startswith(protected_path) for protected_path in list(map(expanduser, sip_list + user_list))):
98 | return False
99 |
100 | return "com.apple.rootless" not in xattr(path_posix)
101 |
102 |
103 | @beartype
104 | def bytes_to_human(size_bytes: int | float) -> str:
105 | """
106 | Converts bytes to human-readable format.
107 |
108 | :param size_bytes: Bytes
109 | :return: Human readable size
110 | """
111 |
112 | from math import floor, log
113 |
114 | if size_bytes <= 0:
115 | return "0B"
116 |
117 | size_name = ("B", "KB", "MB", "GB", "TB")
118 | i = int(floor(log(size_bytes, 1024)))
119 | p = pow(1024, i)
120 | s = round(size_bytes / p, 2)
121 |
122 | return f"{s} {size_name[i]}"
123 |
--------------------------------------------------------------------------------
/mac_cleanup/main.py:
--------------------------------------------------------------------------------
1 | from os import environ, statvfs
2 | from pathlib import Path
3 |
4 | from mac_cleanup.config import Config
5 | from mac_cleanup.console import console, print_panel
6 | from mac_cleanup.core import _Collector
7 | from mac_cleanup.error_handling import catch_exception
8 | from mac_cleanup.parser import args
9 | from mac_cleanup.utils import bytes_to_human
10 |
11 |
12 | class EntryPoint:
13 | config_path: Path
14 | base_collector: _Collector
15 |
16 | def __init__(self):
17 | if (config_home := environ.get("XDG_CONFIG_HOME")) is not None:
18 | self.config_path = Path(config_home).expanduser().joinpath("mac_cleanup_py").joinpath("config.toml")
19 | else:
20 | self.config_path = Path.home().joinpath(".mac_cleanup_py")
21 |
22 | self.base_collector = _Collector()
23 |
24 | @staticmethod
25 | def count_free_space() -> float:
26 | """Get current free space."""
27 |
28 | stat = statvfs("/")
29 | return float(stat.f_bavail * stat.f_frsize)
30 |
31 | def cleanup(self) -> None:
32 | """Launch cleanup and print results."""
33 |
34 | from mac_cleanup.progress import ProgressBar
35 |
36 | # Free space before the run
37 | free_space_before = self.count_free_space()
38 |
39 | for unit in self.base_collector._execute_list: # noqa
40 | for module in ProgressBar.wrap_iter(unit.modules, description=unit.message, total=len(unit.modules)):
41 | # Call for module execution
42 | module._execute() # noqa
43 |
44 | # Free space after the run
45 | free_space_after = self.count_free_space()
46 |
47 | # Print results
48 | print_panel(
49 | text=f"Removed - [success]{bytes_to_human(free_space_after - free_space_before)}", title="[info]Success"
50 | )
51 |
52 | @catch_exception
53 | def start(self) -> None:
54 | """Start mac_cleanup_py by cleaning console, loading config and parsing argument."""
55 |
56 | # Clear console at the start
57 | console.clear()
58 |
59 | # Get config
60 | config = Config(config_path_=self.config_path)
61 |
62 | # Sets custom modules' path if user prompted to and exits
63 | if args.custom_path:
64 | # Set custom path and exit
65 | config.set_custom_path()
66 |
67 | # Check config
68 | config(configuration_prompted=args.configure)
69 |
70 | # Handle dry runs
71 | if args.dry_run:
72 | from rich.prompt import Confirm
73 |
74 | estimate_size: float = 0
75 |
76 | for path, size in self.base_collector._extract_paths():
77 | if args.verbose and size:
78 | console.print(bytes_to_human(size), path, no_wrap=True)
79 | estimate_size += size
80 |
81 | freed_space = bytes_to_human(estimate_size) # noqa
82 |
83 | print_panel(text=f"Approx [success]{freed_space}[/success] will be cleaned", title="[info]Dry run results")
84 |
85 | try:
86 | continue_cleanup = Confirm.ask("Continue?", show_default=False, default="y")
87 | # Cyrillic symbols may crash rich.Confirm
88 | except UnicodeDecodeError:
89 | console.clear()
90 | console.print("Do not enter symbols that can't be decoded to UTF-8", style="danger")
91 | console.print("Exiting...")
92 | return
93 |
94 | console.clear()
95 |
96 | # Exit if user doesn't want to continue
97 | if not continue_cleanup:
98 | console.print("Exiting...")
99 | return
100 |
101 | # Clean stuff up
102 | self.cleanup()
103 |
104 |
105 | if __name__ == "__main__":
106 | EntryPoint().start() # pragma: no cover (coverage marks line as untested)
107 |
--------------------------------------------------------------------------------
/mac_cleanup/progress.py:
--------------------------------------------------------------------------------
1 | """Modified rich progress bar."""
2 |
3 | from typing import Iterable, Optional, Sequence
4 |
5 | from rich.progress import (
6 | BarColumn,
7 | Progress,
8 | ProgressType,
9 | SpinnerColumn,
10 | TaskProgressColumn,
11 | TextColumn,
12 | TimeElapsedColumn,
13 | TimeRemainingColumn,
14 | )
15 | from rich.prompt import Confirm
16 |
17 | from mac_cleanup.console import console, print_panel
18 |
19 |
20 | class _ProgressBar:
21 | """Proxy rich progress bar with blocking prompt."""
22 |
23 | def __init__(self):
24 | # Call parent init w/ default stuff
25 | self.current_progress = Progress(
26 | SpinnerColumn(),
27 | TextColumn("[progress.description]{task.description}"),
28 | BarColumn(),
29 | TaskProgressColumn(show_speed=True),
30 | TimeRemainingColumn(elapsed_when_finished=True),
31 | TimeElapsedColumn(),
32 | console=console,
33 | transient=True,
34 | )
35 |
36 | def prompt(
37 | self,
38 | prompt_text: str,
39 | prompt_title: str,
40 | password: bool = False,
41 | choices: Optional[list[str]] = None,
42 | show_default: bool = True,
43 | show_choices: bool = True,
44 | default: bool = True,
45 | ) -> bool:
46 | """
47 | Stops progress bar to show prompt to user.
48 |
49 | :param prompt_text: Text to be shown in panel
50 | :param prompt_title: Title of panel to be shown
51 | :param password: Enable password input. Defaults to False.
52 | :param choices: A list of valid choices. Defaults to None.
53 | :param show_default: Show default in prompt. Defaults to True.
54 | :param show_choices: Show choices in prompt. Defaults to True.
55 | :param default: Default value in prompt.
56 | :return: True on successful prompt
57 | """
58 |
59 | # Stop refreshing progress bar
60 | self.current_progress.stop()
61 |
62 | # Print prompt to user
63 | print_panel(text=prompt_text, title=prompt_title)
64 |
65 | # Get user input
66 | answer = Confirm.ask(
67 | prompt="Do you want to continue?",
68 | console=self.current_progress.console,
69 | password=password,
70 | choices=choices,
71 | show_default=show_default,
72 | show_choices=show_choices,
73 | default=default,
74 | )
75 |
76 | # Clear printed stuff
77 | self.current_progress.console.clear()
78 | self.current_progress.console.clear_live()
79 |
80 | # Resume refreshing progress bar
81 | self.current_progress.start()
82 |
83 | # Return user answer
84 | return answer
85 |
86 | def wrap_iter(
87 | self,
88 | sequence: Iterable[ProgressType] | Sequence[ProgressType],
89 | total: Optional[float] = None,
90 | description: str = "Working...",
91 | ) -> Iterable[ProgressType]:
92 | """
93 | Wrapper other :func:`rich.progress.track`
94 |
95 | :param sequence: Sequence (must support "len") you wish to iterate over.
96 | :param total: Total number of steps. Default is len(sequence).
97 | :param description: Description of task show next to progress bar. Defaults to "Working".
98 | :return: An iterable of the values in the sequence
99 | """
100 |
101 | # Clear previous Live instance
102 | self.current_progress.console.clear_live()
103 |
104 | # Get new progress instance with default stuff
105 | self.__init__()
106 |
107 | # Call context manager and yield from it
108 | with self.current_progress:
109 | yield from self.current_progress.track(sequence, total=total, description=description)
110 |
111 |
112 | # ProgressBar instance for all project
113 | ProgressBar = _ProgressBar()
114 |
--------------------------------------------------------------------------------
/mac_cleanup/error_handling.py:
--------------------------------------------------------------------------------
1 | """Wrapper for handling all errors in entry point."""
2 |
3 | from functools import wraps
4 | from typing import Any, Callable, Iterable, Optional, Type, TypeVar, overload
5 |
6 | T = TypeVar("T", bound=Callable[..., Any])
7 |
8 | _iterable_exception = tuple[Type[BaseException]] | list[Type[BaseException]]
9 | _exception = Type[BaseException] | _iterable_exception
10 |
11 |
12 | class ErrorHandler:
13 | """Decorator for catching exceptions and printing logs."""
14 |
15 | def __init__(self, exception: Optional[_exception] = None, exit_on_exception: bool = False):
16 | # Sets default exception (empty tuple) if none was provided
17 | if exception is None:
18 | self.exception: _iterable_exception = tuple[Type[BaseException]]()
19 | # Changes exception class to tuple if it's class
20 | elif not isinstance(exception, Iterable):
21 | self.exception = (exception,)
22 | else:
23 | self.exception = exception
24 |
25 | # Sets exit_on_exception
26 | self.exit_on_exception = exit_on_exception
27 |
28 | def __call__(self, func: T) -> Callable[..., Optional[T]]:
29 | @wraps(func)
30 | def wrapper(*args: Any, **kwargs: Any) -> Optional[T]:
31 | try:
32 | return func(*args, **kwargs)
33 | except KeyboardInterrupt:
34 | from mac_cleanup.console import console
35 |
36 | # Print exit message
37 | console.print("\n[warning]Exiting...")
38 |
39 | # Exit if prompted
40 | if self.exit_on_exception:
41 | exit(0)
42 | # Exclude SystemExit from BaseException clause
43 | except SystemExit as err:
44 | raise err
45 | # Catch all other errors
46 | except BaseException as err:
47 | # Clause for not expected error
48 | if type(err) not in self.exception:
49 | from logging import basicConfig, getLogger
50 |
51 | from rich.logging import RichHandler
52 |
53 | from mac_cleanup.console import console
54 |
55 | # Set logger config
56 | basicConfig(
57 | level="ERROR",
58 | format="%(message)s",
59 | datefmt="[%X]",
60 | handlers=[RichHandler(rich_tracebacks=True)],
61 | )
62 |
63 | # Get logger
64 | log = getLogger("EntryPoint")
65 |
66 | # Log error
67 | log.exception("Unexpected error occurred")
68 |
69 | # Print exit message
70 | console.print("\n[danger]Exiting...")
71 |
72 | # Exit if prompted
73 | if self.exit_on_exception:
74 | exit(1)
75 |
76 | return wrapper
77 |
78 |
79 | @overload
80 | def catch_exception(
81 | func: T, exception: Optional[_exception] = ..., exit_on_exception: bool = ...
82 | ) -> T: ... # pragma: no cover (coverage marks line as untested)
83 |
84 |
85 | @overload
86 | def catch_exception(
87 | func: None = ..., exception: Optional[_exception] = ..., exit_on_exception: bool = ...
88 | ) -> ErrorHandler: ... # pragma: no cover (coverage marks line as untested)
89 |
90 |
91 | def catch_exception(
92 | func: Optional[T] = None, exception: Optional[_exception] = None, exit_on_exception: bool = True
93 | ) -> ErrorHandler | Callable[..., Optional[T]]:
94 | """
95 | Decorator for catching exceptions and printing logs.
96 |
97 | :param func: Function to be decorated
98 | :param exception: Expected exception(s)
99 | :param exit_on_exception: If True, exit after unexpected exception was handled
100 | :return: Decorated function
101 | """
102 |
103 | err_handler_instance: ErrorHandler = ErrorHandler(exception=exception, exit_on_exception=exit_on_exception)
104 |
105 | if func:
106 | err_handler_call: Callable[..., Optional[T]] = err_handler_instance(func)
107 | return err_handler_call
108 |
109 | return err_handler_instance
110 |
--------------------------------------------------------------------------------
/mac_cleanup/core_modules.py:
--------------------------------------------------------------------------------
1 | """All core modules."""
2 |
3 | from abc import ABC, abstractmethod
4 | from pathlib import Path as Path_
5 | from typing import Final, Optional, TypeVar, final
6 |
7 | from beartype import beartype # pyright: ignore [reportUnknownVariableType]
8 |
9 | from mac_cleanup import args
10 | from mac_cleanup.progress import ProgressBar
11 | from mac_cleanup.utils import check_deletable, check_exists, cmd
12 |
13 | T = TypeVar("T")
14 |
15 |
16 | class BaseModule(ABC):
17 | """Base abstract module."""
18 |
19 | __prompt: bool = False
20 | __prompt_message: str = "Do you want to proceed?"
21 |
22 | @beartype
23 | def with_prompt(self: T, message_: Optional[str] = None) -> T:
24 | """
25 | Execute command with user prompt.
26 |
27 | :param message_: Message to be shown on prompt
28 | :return: Instance of self from
29 | :class: `BaseModule`
30 | """
31 |
32 | if args.force:
33 | return self
34 |
35 | # Can't be solved without typing.Self
36 | self.__prompt = True # pyright: ignore [reportAttributeAccessIssue]
37 |
38 | if message_:
39 | # Can't be solved without typing.Self
40 | self.__prompt_message = message_ # pyright: ignore [reportAttributeAccessIssue]
41 |
42 | return self
43 |
44 | @abstractmethod
45 | def _execute(self) -> bool:
46 | """Base exec with check for prompt :return: True on successful prompt."""
47 |
48 | # Call prompt if needed
49 | if self.__prompt:
50 | # Skip on negative prompt
51 | return ProgressBar.prompt(prompt_text=self.__prompt_message, prompt_title="Module requires attention")
52 |
53 | return True
54 |
55 |
56 | class _BaseCommand(BaseModule):
57 | """Base Command with basic command methods."""
58 |
59 | @beartype
60 | def __init__(self, command_: Optional[str]):
61 | self.__command: Final[Optional[str]] = command_
62 |
63 | @property
64 | def get_command(self) -> Optional[str]:
65 | """Get command specified to the module."""
66 |
67 | return self.__command
68 |
69 | @abstractmethod
70 | def _execute(self, ignore_errors: bool = True) -> Optional[str]:
71 | """
72 | Execute the command specified.
73 |
74 | :param ignore_errors: Ignore errors during execution
75 | :return: Command execution results based on specified parameters
76 | """
77 |
78 | # Skip if there is no command
79 | if not self.__command:
80 | return
81 |
82 | # Skip on negative prompt
83 | if not super()._execute():
84 | return
85 |
86 | # Execute command
87 | return cmd(command=self.__command, ignore_errors=ignore_errors)
88 |
89 |
90 | @final
91 | class Command(_BaseCommand):
92 | """Collector list unit for command execution."""
93 |
94 | __ignore_errors: bool = True
95 |
96 | def with_errors(self) -> "Command":
97 | """Return errors in exec output :return: :class:`Command`"""
98 |
99 | self.__ignore_errors = False
100 |
101 | return self
102 |
103 | def _execute(self, ignore_errors: Optional[bool] = None) -> Optional[str]:
104 | """
105 | Execute the command specified.
106 |
107 | :param ignore_errors: Overrides flag `ignore_errors` in class
108 | :return: Command execution results based on specified parameters
109 | """
110 |
111 | return super()._execute(ignore_errors=self.__ignore_errors if ignore_errors is None else ignore_errors)
112 |
113 |
114 | @final
115 | class Path(_BaseCommand):
116 | """Collector list unit for cleaning paths."""
117 |
118 | __dry_run_only: bool = False
119 |
120 | @beartype
121 | def __init__(self, path: str):
122 | self.__path: Final[Path_] = Path_(path).expanduser()
123 |
124 | tmp_command = "rm -rf '{path}'".format(path=self.__path.as_posix())
125 |
126 | super().__init__(command_=tmp_command)
127 |
128 | @property
129 | def get_path(self) -> Path_:
130 | """Get path specified to the module."""
131 |
132 | return self.__path
133 |
134 | def dry_run_only(self) -> "Path":
135 | """Set module to only count size in dry runs :return: :class:`Path`"""
136 |
137 | self.__dry_run_only = True
138 |
139 | return self
140 |
141 | def _execute(self, ignore_errors: bool = True) -> Optional[str]:
142 | """Delete specified path :return: Command execution results based on specified
143 | parameters.
144 | """
145 |
146 | if self.__dry_run_only:
147 | return
148 |
149 | # Skip if path is not deletable or undefined
150 | if not all([check_deletable(path=self.__path), check_exists(path=self.__path, expand_user=False)]):
151 | return
152 |
153 | return super()._execute(ignore_errors=ignore_errors)
154 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # mac-cleanup-py
2 |
3 | [](https://pypi.org/project/mac-cleanup/)
4 | [](https://github.com/mac-cleanup/mac-cleanup-py/actions/workflows/tox.yml)
5 | [](https://github.com/mac-cleanup/mac-cleanup-py/actions/workflows/codeql.yml)
6 | [](https://www.jetbrains.com)
7 |
8 | ## 🧹 Python cleanup script for macOS
9 |
10 | **mac-cleanup-py** is a powerful cleanup script for macOS.\
11 | This project is a rewrite of the original [mac-cleanup-sh](https://github.com/mac-cleanup/mac-cleanup-sh) rewritten in Python.
12 |
13 | ## 🚀 Features
14 |
15 | **mac-cleanup-py** helps you:
16 |
17 | 1. Empty Trash
18 | 2. Delete unnecessary logs & files
19 | 3. Clear cache
20 |
21 | 
22 |
23 |
24 |
25 | 📦 Default Modules
26 |
27 |
28 |
29 |
30 | - `adobe` - Clears **Adobe** cache files
31 | - `android` - Clears **Android** caches
32 | - `arc` - Clears **Arc Browser** caches
33 | - `brew` - Clears **Homebrew** cache
34 | - `bun` - Clears **Bun** cache
35 | - `cacher` - Clears **Cacher** logs
36 | - `chrome` - Clears **Google Chrome** cache
37 | - `chromium` - Clears **Chromium** cache files
38 | - `composer` - Clears **composer** cache
39 | - `conan` - Clears **Conan** cache
40 | - `docker` - Cleanup dangling **Docker** Images and stopped containers
41 | - `dns_cache` - Clears **DNS** cache
42 | - `dropbox` - Clears **Dropbox** cache
43 | - `ea` - Clears **EA App** cache files
44 | - `gem` - Cleanup any old versions of **Gems**
45 | - `go` - Clears **Go** cache
46 | - `google_drive` - Clears **Google Drive** caches
47 | - `gradle` - Clears **Gradle** caches
48 | - `inactive_memory` - Purge **Inactive Memory**
49 | - `ios_apps` - Cleanup **iOS Applications**
50 | - `ios_backups` - Removes **iOS Device Backups**
51 | - `java_cache` - Removes **Java head dumps** from home directory
52 | - `jetbrains` - Removes logs from **PhpStorm**, **PyCharm** etc
53 | - `kite` - Deletes **Kite** logs
54 | - `lunarclient` - Removes **Lunar Client** logs and cache
55 | - `minecraft` - Remove **Minecraft** logs and cache
56 | - `microsoft_teams` - Remove **Microsoft Teams** logs and cache
57 | - `npm` - Cleanup **npm** Cache
58 | - `obsidian` - Clears **Obsidian** cache files
59 | - `nuget` - Clears **.nuget** package files
60 | - `pnpm` - Cleanup **pnpm** Cache
61 | - `pod` - Cleanup **CocoaPods** Cache Files
62 | - `poetry` - Clears **Poetry** cache
63 | - `pyenv` - Cleanup **Pyenv-VirtualEnv** Cache
64 | - `steam` - Remove **Steam** logs and cache
65 | - `system_caches` - Clear **System cache**
66 | - `system_log` - Clear **System Log** Files
67 | - `telegram` - Clear old **Telegram** Cache
68 | - `trash` - Empty the **Trash** on All Mounted Volumes and the Main HDD
69 | - `wget_logs` - Remove **Wget** logs and hosts
70 | - `xcode` - Cleanup **Xcode Derived Data** and **Archives**
71 | - `xcode_simulators` - Reset **iOS simulators**
72 | - `yarn` - Cleanup **yarn** Cache
73 |
74 |
75 |
76 | ## 📥 Installation
77 |
78 | ### Using Homebrew
79 |
80 | ```bash
81 | brew install mac-cleanup-py
82 | ```
83 |
84 | ### Using pip
85 |
86 | ```bash
87 | pip3 install mac-cleanup
88 | ```
89 |
90 | ## 🗑️ Uninstallation
91 |
92 | ### Using Homebrew
93 |
94 | ```bash
95 | brew uninstall mac-cleanup-py
96 | ```
97 |
98 | ### Using pip
99 |
100 | ```bash
101 | pip3 uninstall mac-cleanup
102 | ```
103 |
104 | ## 💡 Usage Options
105 |
106 | Help menu:
107 |
108 | ```
109 | $ mac-cleanup -h
110 | usage: mac-cleanup [-h] [-n] [-u] [-c] [-p] [-f]
111 |
112 | Python cleanup script for macOS
113 | Version: 3.3.0
114 | https://github.com/mac-cleanup/mac-cleanup-py
115 |
116 | options:
117 | -h, --help show this help message and exit
118 | -n, --dry-run Run without deleting stuff
119 | -u, --update Update Homebrew on cleanup
120 | -c, --configure Open module configuration screen
121 | -p, --custom-path Specify path for custom modules
122 | -f, --force Accept all warnings
123 | -v, --verbose Print folders to be deleted
124 |
125 | ```
126 |
127 | ## 🌟 Contributing
128 |
129 | Contributions are always welcome!\
130 | If you have any ideas, suggestions, or bug reports, feel free to submit an issue or open a pull request.
131 |
132 | ## 📝 License
133 |
134 | This project is licensed under the [Apache-2.0 License](https://github.com/mac-cleanup/mac-cleanup-py/blob/main/LICENSE).
135 |
136 | ## 👏 Acknowledgements
137 |
138 | This project is developed using tools provided by the _JetBrains OSS Development Program_.
139 |
140 | > Find out more about their program and how they support open source [here](https://jb.gg/OpenSourceSupport).
141 |
142 |
143 |
144 |
145 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Welcome to contributing guide
2 |
3 | Thank you for investing your time in contributing to the project!
4 |
5 | Read repo's [Code of Conduct](./CODE_OF_CONDUCT.md) to keep community respectable.
6 |
7 | ## TL;DR
8 |
9 | > #### 1. [Fork](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) the repo
10 | > #### 2. [Checkout](https://www.atlassian.com/git/tutorials/using-branches/git-checkout) from `develop` branch
11 | > #### 3. Install dependencies: `pip install poetry && poetry install`
12 | > #### 3. Setup `pre-commit` hooks: `pre-commit install --hook-type pre-commit --hook-type pre-push`
13 | > #### 4. [Commit](https://www.atlassian.com/git/tutorials/saving-changes/git-commit) your changes with [Conventional Commits](https://www.conventionalcommits.org)
14 | > #### 5. Run tests: `poetry run tox`
15 | > #### 6. [Push the changes](https://docs.github.com/en/get-started/using-git/pushing-commits-to-a-remote-repository#about-git-push) to your fork
16 | > #### 7. [Open PR](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) to 'develop' branch
17 | > #### Congrats :tada::sparkles:. CYA in your PRs 😉
18 |
19 |
20 | ## New contributor guide
21 |
22 | To get an overview of the project, read the [README](README.md).
23 | Here are some resources to help you get started with open source contributions:
24 |
25 | - [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
26 | - [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git)
27 | - [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow)
28 | - [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
29 |
30 | ### Issues
31 |
32 | #### Create a new issue
33 |
34 | If you spot ant problems [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments).
35 |
36 | If an issue doesn't exist, you can open a new issue using a relevant [issue form](https://github.com/mac-cleanup/mac-cleanup-py/issues/new/choose).
37 |
38 | #### Solve an issue
39 |
40 | View the [existing issues](https://github.com/mac-cleanup/mac-cleanup-py/issues) to find something interesting to you.
41 |
42 | In general, new issues will be assigned on [Sasha](https://github.com/efa2d19).
43 |
44 | If you find an issue to work on, just post a comment on the issue's page and you are welcome to open a PR with a fix.
45 |
46 | ### Make Changes
47 |
48 | 1. [Fork the repository](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
49 | 2. Create a working branch out of 'develop' and start with your changes
50 | 3. Install dependencies
51 | ```bash
52 | pip install poetry && poetry install
53 | ```
54 | 4. Setup pre-commit hooks
55 | ```bash
56 | pre-commit install --hook-type pre-commit --hook-type pre-push
57 | ```
58 | 5. Run tests before pushing
59 | ```bash
60 | poetry run tox
61 | ```
62 |
63 | ### Commit your update
64 |
65 | [Commit the changes](https://www.atlassian.com/git/tutorials/saving-changes/git-commit) once you are happy with them and [push](https://docs.github.com/en/get-started/using-git/pushing-commits-to-a-remote-repository#about-git-push) them to your fork.
66 |
67 | Once your changes are ready, don't forget to self-review to speed up the review process:zap:.
68 |
69 | ### Pull Request
70 |
71 | When you're finished with the changes, create a pull request, also known as a [PR](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request).
72 | - Fill the "Ready for review" template so that we can review your PR. This template helps reviewers understand your changes as well as the purpose of your pull request.
73 | - Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one.
74 | - Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge.
75 | Once you submit your PR, someone of collaborators will come and see your PR.
76 | - You may be asked for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments.\
77 | - You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch.
78 | - As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations).
79 |
80 | ### Your PR is merged!
81 |
82 | Congratulations :tada::sparkles:
83 |
84 | Once your PR is merged, your contributions will be publicly visible on the [mac-cleanup-py](https://github.com/mac-cleanup/mac-cleanup-py).
85 |
--------------------------------------------------------------------------------
/tests/test_error_handling.py:
--------------------------------------------------------------------------------
1 | """Test global error handler."""
2 |
3 | from typing import Iterable, Type
4 |
5 | import pytest
6 | from _pytest.capture import CaptureFixture
7 | from _pytest.logging import LogCaptureFixture
8 |
9 | from mac_cleanup.error_handling import _exception # noqa
10 | from mac_cleanup.error_handling import catch_exception
11 |
12 |
13 | class TestErrorHandler:
14 | @pytest.mark.parametrize("raised_exception", [KeyboardInterrupt, BaseException])
15 | def test_with_func(
16 | self, raised_exception: Type[BaseException], capsys: CaptureFixture[str], caplog: LogCaptureFixture
17 | ):
18 | """Test wrapping functions without calling wrapper."""
19 |
20 | # Dummy callable wrapped in handler raised exception
21 | @catch_exception
22 | def dummy_callable() -> None:
23 | raise raised_exception
24 |
25 | # Call dummy callable and expect exit
26 | with pytest.raises(SystemExit):
27 | dummy_callable()
28 |
29 | # Get stdout
30 | captured_stdout = capsys.readouterr().out
31 |
32 | # Check correct stdout
33 | assert "\nExiting...\n" in captured_stdout
34 |
35 | # Get logger output
36 | captured_log = caplog.text
37 |
38 | # No logger output on KeyboardInterrupt
39 | expected_log = "Unexpected error occurred" if raised_exception is BaseException else ""
40 |
41 | # Check correct logger output
42 | assert expected_log in captured_log
43 |
44 | @pytest.mark.parametrize("raised_exception", [KeyboardInterrupt, BaseException])
45 | def test_no_func(
46 | self, raised_exception: Type[BaseException], capsys: CaptureFixture[str], caplog: LogCaptureFixture
47 | ):
48 | """Test wrapping functions with calling wrapper."""
49 |
50 | # Dummy callable wrapped in handler raised exception
51 | @catch_exception()
52 | def dummy_callable() -> None:
53 | raise raised_exception
54 |
55 | # Call dummy callable and expect exit
56 | with pytest.raises(SystemExit):
57 | dummy_callable()
58 |
59 | # Get stdout
60 | captured_stdout = capsys.readouterr().out
61 |
62 | # Check correct stdout
63 | assert "\nExiting...\n" in captured_stdout
64 |
65 | # Get logger output
66 | captured_log = caplog.text
67 |
68 | # No logger output on KeyboardInterrupt
69 | expected_log = "Unexpected error occurred" if raised_exception is BaseException else ""
70 |
71 | # Check correct logger output
72 | assert expected_log in captured_log
73 |
74 | @pytest.mark.parametrize("raised_exception", [KeyboardInterrupt, BaseException])
75 | def test_no_exit_on_exception(
76 | self, raised_exception: Type[BaseException], capsys: CaptureFixture[str], caplog: LogCaptureFixture
77 | ):
78 | """Test wrapping functions without exiting on caught stuff."""
79 |
80 | # Dummy callable wrapped in handler raised exception
81 | @catch_exception(exit_on_exception=False)
82 | def dummy_callable() -> None:
83 | raise raised_exception
84 |
85 | # Call dummy callable and expect no errors
86 | dummy_callable()
87 |
88 | # Get stdout
89 | captured_stdout = capsys.readouterr().out
90 |
91 | # Check correct stdout
92 | assert "\nExiting...\n" in captured_stdout
93 |
94 | # Get logger output
95 | captured_log = caplog.text
96 |
97 | # No logger output on KeyboardInterrupt
98 | expected_log = "Unexpected error occurred" if raised_exception is BaseException else ""
99 |
100 | # Check correct logger output
101 | assert expected_log in captured_log
102 |
103 | @pytest.mark.parametrize("custom_exception", [(ValueError, KeyError), [ValueError, KeyError], BaseException])
104 | def test_custom_exceptions(
105 | self, custom_exception: _exception, capsys: CaptureFixture[str], caplog: LogCaptureFixture
106 | ):
107 | """Test wrapping functions with providing expected exceptions."""
108 |
109 | # Dummy callable wrapped in handler raised exception
110 | @catch_exception(exception=custom_exception)
111 | def dummy_callable() -> None:
112 | raise (custom_exception if not isinstance(custom_exception, Iterable) else custom_exception[0])
113 |
114 | # Call dummy callable and expect no errors
115 | dummy_callable()
116 |
117 | # Get stdout
118 | captured_stdout = capsys.readouterr().out
119 |
120 | # Check correct stdout
121 | assert captured_stdout == ""
122 |
123 | # Get logger output
124 | captured_log = caplog.text
125 |
126 | # Check correct logger output
127 | assert captured_log == ""
128 |
129 | @pytest.mark.parametrize("exit_code", [0, 1])
130 | def test_exit_raised(self, exit_code: int, capsys: CaptureFixture[str], caplog: LogCaptureFixture):
131 | """Test wrapping functions raising SystemExit."""
132 |
133 | # Dummy callable wrapped in handler raised SystemExit
134 | @catch_exception
135 | def dummy_callable() -> None:
136 | exit(exit_code)
137 |
138 | # Call dummy callable and expect exit
139 | with pytest.raises(SystemExit):
140 | dummy_callable()
141 |
142 | # Get stdout
143 | captured_stdout = capsys.readouterr().out
144 |
145 | # Check correct stdout
146 | assert captured_stdout == ""
147 |
148 | # Get logger output
149 | captured_log = caplog.text
150 |
151 | # Check correct logger output
152 | assert captured_log == ""
153 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | """All tests for mac_cleanup_py.utils."""
2 |
3 | from pathlib import Path
4 | from typing import Optional
5 |
6 | import pytest
7 | from beartype.roar import BeartypeCallHintParamViolation
8 |
9 |
10 | @pytest.mark.parametrize(
11 | ("command", "ignore_errors", "output"),
12 | [
13 | # test empty output
14 | ("echo", True, ""),
15 | # test stdout
16 | ("echo 'test'", True, "test"),
17 | # test no errors
18 | ("echo 'test' >&2", True, ""),
19 | # test redirect stderr
20 | ("echo 'test' >&2", False, "test"),
21 | # test beartype
22 | (123, True, ""),
23 | ],
24 | )
25 | def test_cmd(command: str | int, ignore_errors: bool, output: str):
26 | """Test :class:`subprocess.Popen` command execution in :meth:`mac_cleanup.utils.cmd`"""
27 |
28 | from mac_cleanup.utils import cmd
29 |
30 | if isinstance(command, int):
31 | with pytest.raises(BeartypeCallHintParamViolation):
32 | cmd(command=command, ignore_errors=ignore_errors) # pyright: ignore [reportArgumentType] # noqa
33 | return
34 |
35 | assert cmd(command=command, ignore_errors=ignore_errors) == output
36 |
37 |
38 | @pytest.mark.parametrize(
39 | ("str_path", "output"),
40 | [
41 | # test empty
42 | ("", ""),
43 | # test expand home
44 | ("~/", None),
45 | # test beartype
46 | (123, None),
47 | ],
48 | )
49 | def test_expanduser(str_path: str | int, output: Optional[str]):
50 | """Test wrapper of :meth:`pathlib.Path.expanduser` in :meth:`mac_cleanup.utils.expanduser`"""
51 |
52 | from mac_cleanup.utils import expanduser
53 |
54 | if isinstance(str_path, int):
55 | with pytest.raises(BeartypeCallHintParamViolation):
56 | expanduser(str_path=str_path) # pyright: ignore [reportArgumentType] # noqa
57 | return
58 |
59 | if output is None:
60 | assert expanduser(str_path=str_path).startswith("/Users/")
61 | return
62 |
63 | assert expanduser(str_path=str_path) == "."
64 |
65 |
66 | @pytest.mark.parametrize(
67 | ("path", "output", "expand_path"),
68 | [
69 | # test existing str path
70 | ("/", True, True),
71 | # test expand user
72 | ("~/Documents", True, True),
73 | ("~/Documents", False, False),
74 | # test Glob
75 | ("*", True, True),
76 | # test non-existing str path
77 | ("/aboba", False, True),
78 | # test existing Path
79 | (Path("/"), True, True),
80 | # test expand user Path
81 | (Path("~/Documents"), True, True),
82 | (Path("~/Documents"), False, False),
83 | # test Glob in Path
84 | (Path("*"), True, True),
85 | # test non-existing Path
86 | (Path("/aboba"), False, True),
87 | # test beartype
88 | (123, False, True),
89 | ],
90 | )
91 | def test_check_exists(path: Path | str | int, output: bool, expand_path: bool):
92 | """Test wrapper of :meth:`pathlib.Path.exists` in :meth:`mac_cleanup.utils.check_exists`"""
93 |
94 | from mac_cleanup.utils import check_exists
95 |
96 | if isinstance(path, int):
97 | with pytest.raises(BeartypeCallHintParamViolation):
98 | check_exists(path=path, expand_user=expand_path) # pyright: ignore [reportArgumentType] # noqa
99 | return
100 |
101 | assert check_exists(path=path, expand_user=expand_path) is output
102 |
103 |
104 | @pytest.mark.parametrize(
105 | ("path", "output"),
106 | [
107 | # test deletable str path
108 | ("/", True),
109 | # test empty str path
110 | ("", False),
111 | # test Glob in str path
112 | ("*", True),
113 | # test deletable Path
114 | (Path("/"), True),
115 | # test empty Path
116 | (Path(""), False),
117 | # test Glob in Path
118 | (Path("*"), True),
119 | # test SIP
120 | ("/System", False),
121 | (Path("/System"), False),
122 | # test no expand user
123 | ("~/Documents", True),
124 | (Path("~/Documents").expanduser(), False),
125 | # test custom rules
126 | (Path("~/Documents"), True),
127 | # test beartype
128 | (123, False),
129 | ],
130 | )
131 | def test_check_deletable(path: Path | str | int, output: bool):
132 | """Test :meth:`mac_cleanup.utils.check_deletable` with SIP and custom restriction list."""
133 |
134 | from mac_cleanup.utils import check_deletable
135 |
136 | if isinstance(path, int):
137 | with pytest.raises(BeartypeCallHintParamViolation):
138 | check_deletable(path=path) # pyright: ignore [reportArgumentType] # noqa
139 | return
140 |
141 | assert check_deletable(path=path) is output
142 |
143 |
144 | @pytest.mark.parametrize(
145 | ("byte", "in_power", "output"),
146 | [
147 | # test empty
148 | (0, 1, "0B"),
149 | # test B
150 | (0, 0, "1.0 B"),
151 | # test KB
152 | (1024, 1, "1.0 KB"),
153 | # test MB
154 | (1024, 2, "1.0 MB"),
155 | # test GB
156 | (1024, 3, "1.0 GB"),
157 | # test TB
158 | (1024, 4, "1.0 TB"),
159 | # test beartype
160 | ("", 0, ""),
161 | ],
162 | )
163 | def test_bytes_to_human(byte: int | str, in_power: int, output: str):
164 | """Test bytes to human conversion in :meth:`mac_cleanup.utils.bytes_to_human`"""
165 |
166 | from mac_cleanup.utils import bytes_to_human
167 |
168 | if isinstance(byte, str):
169 | with pytest.raises(BeartypeCallHintParamViolation):
170 | bytes_to_human(size_bytes=byte) # pyright: ignore [reportArgumentType] # noqa
171 | return
172 |
173 | assert bytes_to_human(size_bytes=byte**in_power) == output
174 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | # TL;DR
4 |
5 | > ### Be nice to each other, or you'll face the consequences
6 |
7 | ## Our Pledge
8 |
9 | We as members, contributors, and leaders pledge to make participation in our
10 | community a harassment-free experience for everyone, regardless of age, body
11 | size, visible or invisible disability, ethnicity, sex characteristics, gender
12 | identity and expression, level of experience, education, socio-economic status,
13 | nationality, personal appearance, race, religion, or sexual identity
14 | and orientation.
15 |
16 | We pledge to act and interact in ways that contribute to an open, welcoming,
17 | diverse, inclusive, and healthy community.
18 |
19 | ## Our Standards
20 |
21 | Examples of behavior that contributes to a positive environment for our
22 | community include:
23 |
24 | * Demonstrating empathy and kindness toward other people
25 | * Being respectful of differing opinions, viewpoints, and experiences
26 | * Giving and gracefully accepting constructive feedback
27 | * Accepting responsibility and apologizing to those affected by our mistakes,
28 | and learning from the experience
29 | * Focusing on what is best not just for us as individuals, but for the
30 | overall community
31 |
32 | Examples of unacceptable behavior include:
33 |
34 | * The use of sexualized language or imagery, and sexual attention or
35 | advances of any kind
36 | * Trolling, insulting or derogatory comments, and personal or political attacks
37 | * Public or private harassment
38 | * Publishing others' private information, such as a physical or email
39 | address, without their explicit permission
40 | * Other conduct which could reasonably be considered inappropriate in a
41 | professional setting
42 |
43 | ## Enforcement Responsibilities
44 |
45 | Community leaders are responsible for clarifying and enforcing our standards of
46 | acceptable behavior and will take appropriate and fair corrective action in
47 | response to any behavior that they deem inappropriate, threatening, offensive,
48 | or harmful.
49 |
50 | Community leaders have the right and responsibility to remove, edit, or reject
51 | comments, commits, code, wiki edits, issues, and other contributions that are
52 | not aligned to this Code of Conduct, and will communicate reasons for moderation
53 | decisions when appropriate.
54 |
55 | ## Scope
56 |
57 | This Code of Conduct applies within all community spaces, and also applies when
58 | an individual is officially representing the community in public spaces.
59 | Examples of representing our community include using an official e-mail address,
60 | posting via an official social media account, or acting as an appointed
61 | representative at an online or offline event.
62 |
63 | ## Enforcement
64 |
65 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
66 | reported to the community leaders responsible for enforcement at
67 | [Author's contacts](https://github.com/efa2d19#connect-with-me).
68 | All complaints will be reviewed and investigated promptly and fairly.
69 |
70 | All community leaders are obligated to respect the privacy and security of the
71 | reporter of any incident.
72 |
73 | ## Enforcement Guidelines
74 |
75 | Community leaders will follow these Community Impact Guidelines in determining
76 | the consequences for any action they deem in violation of this Code of Conduct:
77 |
78 | ### 1. Correction
79 |
80 | **Community Impact**: Use of inappropriate language or other behavior deemed
81 | unprofessional or unwelcome in the community.
82 |
83 | **Consequence**: A private, written warning from community leaders, providing
84 | clarity around the nature of the violation and an explanation of why the
85 | behavior was inappropriate. A public apology may be requested.
86 |
87 | ### 2. Warning
88 |
89 | **Community Impact**: A violation through a single incident or series
90 | of actions.
91 |
92 | **Consequence**: A warning with consequences for continued behavior. No
93 | interaction with the people involved, including unsolicited interaction with
94 | those enforcing the Code of Conduct, for a specified period of time. This
95 | includes avoiding interactions in community spaces as well as external channels
96 | like social media. Violating these terms may lead to a temporary or
97 | permanent ban.
98 |
99 | ### 3. Temporary Ban
100 |
101 | **Community Impact**: A serious violation of community standards, including
102 | sustained inappropriate behavior.
103 |
104 | **Consequence**: A temporary ban from any sort of interaction or public
105 | communication with the community for a specified period of time. No public or
106 | private interaction with the people involved, including unsolicited interaction
107 | with those enforcing the Code of Conduct, is allowed during this period.
108 | Violating these terms may lead to a permanent ban.
109 |
110 | ### 4. Permanent Ban
111 |
112 | **Community Impact**: Demonstrating a pattern of violation of community
113 | standards, including sustained inappropriate behavior, harassment of an
114 | individual, or aggression toward or disparagement of classes of individuals.
115 |
116 | **Consequence**: A permanent ban from any sort of public interaction within
117 | the community.
118 |
119 | ## Attribution
120 |
121 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
122 | version 2.0, available at
123 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
124 |
125 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
126 | enforcement ladder](https://github.com/mozilla/diversity).
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 |
130 | For answers to common questions about this code of conduct, see the FAQ at
131 | https://www.contributor-covenant.org/faq. Translations are available at
132 | https://www.contributor-covenant.org/translations.
133 |
--------------------------------------------------------------------------------
/mac_cleanup/core.py:
--------------------------------------------------------------------------------
1 | """Core for collecting all unit modules."""
2 |
3 | from functools import partial
4 | from itertools import chain
5 | from pathlib import Path as Path_
6 | from types import TracebackType
7 | from typing import Any, Final, Generator, Optional, Type, TypeGuard, TypeVar, final
8 |
9 | import attr
10 | from beartype import beartype # pyright: ignore [reportUnknownVariableType]
11 |
12 | from mac_cleanup.core_modules import BaseModule, Path
13 |
14 | T = TypeVar("T")
15 |
16 |
17 | @final
18 | @attr.s(slots=True)
19 | class Unit:
20 | """Unit containing message and the modules list."""
21 |
22 | message: str = attr.ib()
23 | modules: list[BaseModule] = attr.ib(
24 | factory=list,
25 | validator=attr.validators.deep_iterable(
26 | member_validator=attr.validators.instance_of(BaseModule),
27 | iterable_validator=attr.validators.instance_of(list), # pyright: ignore [reportUnknownArgumentType]
28 | ),
29 | )
30 |
31 |
32 | @final
33 | class _Collector:
34 | """Class for collecting all modules."""
35 |
36 | _shared_instance: dict[str, Any] = dict()
37 |
38 | # Init temp stuff
39 | __temp_message: str
40 | __temp_modules_list: list[BaseModule]
41 |
42 | def __init__(self):
43 | # Borg implementation
44 | self.__dict__ = self._shared_instance
45 |
46 | # Add execute_list if none found in shared_instance
47 | if not hasattr(self, "_execute_list"):
48 | self._execute_list: Final[list[Unit]] = list()
49 |
50 | @property
51 | def get_temp_message(self) -> Optional[str]:
52 | """Getter of private potentially empty attr temp_message."""
53 |
54 | return getattr(self, "_Collector__temp_message", None)
55 |
56 | @property
57 | def get_temp_modules_list(self) -> Optional[list[BaseModule]]:
58 | """Getter of private potentially empty attr temp_modules_list."""
59 |
60 | return getattr(self, "_Collector__temp_modules_list", None)
61 |
62 | def __enter__(self) -> "_Collector":
63 | # Set temp stuff
64 | self.__temp_message = "Working..."
65 | self.__temp_modules_list = list()
66 |
67 | # Return self
68 | return self
69 |
70 | def __exit__(
71 | self,
72 | exc_type: Optional[Type[BaseException]],
73 | exc_value: Optional[BaseException],
74 | traceback: Optional[TracebackType],
75 | ) -> None:
76 | # Raise error if once occurred
77 | if exc_type:
78 | raise exc_type(exc_value)
79 |
80 | # Add Unit to list if modules list exists
81 | if self.__temp_modules_list:
82 | self._execute_list.append(Unit(message=self.__temp_message, modules=self.__temp_modules_list))
83 |
84 | # Unset temp stuff
85 | del self.__temp_message
86 | del self.__temp_modules_list
87 |
88 | @beartype
89 | def message(self, message_: str) -> None:
90 | """
91 | Add message to instance of :class:`Unit`
92 |
93 | :param message_: Message to be printed in progress bar
94 | """
95 |
96 | self.__temp_message = message_
97 |
98 | @beartype
99 | def add(self, module_: BaseModule) -> None:
100 | """
101 | Add module to the list of modules to instance of :class:`Unit`
102 |
103 | :param module_: Module based on
104 | :class: `BaseModule`
105 | """
106 |
107 | self.__temp_modules_list.append(module_)
108 |
109 | @staticmethod
110 | def _get_size(path_: Path_) -> float:
111 | """
112 | Counts size of directory.
113 |
114 | :param path_: Path to the directory
115 | :return: Size of specified directory
116 | """
117 |
118 | # Get path posix
119 | path_posix = path_.as_posix()
120 |
121 | # Set list of globs
122 | globs = ["*", "[", "]"]
123 |
124 | # Set glob list
125 | glob_constructor: list[str] = list()
126 |
127 | # Check if there is glob in path
128 | if any(glob in path_posix for glob in globs):
129 | # Set glob index list
130 | globs_index_list: list[int] = list()
131 |
132 | # Find glob indexes
133 | for glob in globs:
134 | try:
135 | globs_index_list.append(path_posix.index(glob))
136 | except ValueError:
137 | continue
138 |
139 | # Find first glob
140 | first_wildcard_position = min(globs_index_list)
141 |
142 | # Remove glob from path
143 | path_ = Path_(path_posix[:first_wildcard_position])
144 |
145 | # Update glob
146 | glob_constructor.append(path_posix[first_wildcard_position:])
147 |
148 | # Set is_file flag to False (globs can't be files)
149 | is_file = False
150 | else:
151 | # Check if path is a file
152 | is_file = path_.is_file()
153 |
154 | # Return size if path is a file
155 | if is_file:
156 | # Except SIP, symlinks, and not non-existent path
157 | try:
158 | return path_.stat(follow_symlinks=False).st_size
159 | except (PermissionError, FileNotFoundError):
160 | return 0
161 |
162 | # Add recursive glob
163 | glob_constructor.append("**/*")
164 |
165 | temp_size: float = 0
166 |
167 | glob_list: list[str] = list()
168 |
169 | # Construct glob_list
170 | if len(glob_constructor) == 1:
171 | # Add recursive glob on no glob in path
172 | glob_list.extend(glob_constructor)
173 | else:
174 | # Add specified glob
175 | glob_list.append(glob_constructor[0])
176 | # Add recursive glob
177 | glob_list.append("/".join(glob_constructor))
178 |
179 | # Find every file and count size
180 | for glob in glob_list:
181 | for file in path_.glob(glob):
182 | try:
183 | temp_size += file.stat(follow_symlinks=False).st_size
184 | # Except SIP, symlinks, and not non-existent path
185 | except (PermissionError, FileNotFoundError):
186 | continue
187 | return temp_size
188 |
189 | @staticmethod
190 | def __filter_modules(module_: BaseModule, filter_type: Type[T]) -> TypeGuard[T]:
191 | """Filter instances of specified class based on :class:`BaseModule`"""
192 |
193 | return isinstance(module_, filter_type)
194 |
195 | def _extract_paths(self) -> Generator[tuple[Path_, float], None, None]:
196 | """Extracts all paths from the collector :return: Yields paths with size."""
197 |
198 | from concurrent.futures import ThreadPoolExecutor, as_completed
199 |
200 | from mac_cleanup.progress import ProgressBar
201 |
202 | # Extract all modules
203 | all_modules = list(chain.from_iterable([unit.modules for unit in self._execute_list]))
204 |
205 | # Filter modules based on Path
206 | path_modules: list[Path] = list(filter(partial(self.__filter_modules, filter_type=Path), all_modules))
207 |
208 | # Extracts paths from path_modules list
209 | path_list: list[Path_] = [path.get_path for path in path_modules]
210 |
211 | # Get thread executor
212 | executor = ThreadPoolExecutor()
213 |
214 | try:
215 | # Add tasks to executor
216 | tasks = [executor.submit(self._get_size, path) for path in path_list]
217 |
218 | # Store paths and their corresponding futures
219 | path_future_zip = list(zip(path_list, tasks, strict=True))
220 |
221 | # Wait for task completion and add ProgressBar
222 | for future in ProgressBar.wrap_iter(
223 | as_completed(tasks), description="Collecting dry run", total=len(path_list)
224 | ):
225 | path = next(p for p, f in path_future_zip if f == future)
226 | size = future.result(timeout=10)
227 | yield path, size
228 | except KeyboardInterrupt:
229 | # Shutdown executor without waiting for tasks
230 | executor.shutdown(wait=False)
231 | else:
232 | # Cleanup executor
233 | executor.shutdown(wait=True)
234 |
235 |
236 | class ProxyCollector:
237 | """Proxy for accessing :class:`Collector` in a context manager."""
238 |
239 | def __init__(self):
240 | # Build a Collector object
241 | self.__base = _Collector()
242 |
243 | def __enter__(self) -> _Collector:
244 | # Return a Collector object
245 | return self.__base.__enter__()
246 |
247 | def __exit__(
248 | self,
249 | exc_type: Optional[Type[BaseException]],
250 | exc_value: Optional[BaseException],
251 | traceback: Optional[TracebackType],
252 | ) -> None:
253 | return self.__base.__exit__(exc_type, exc_value, traceback)
254 |
--------------------------------------------------------------------------------
/tests/test_core_modules.py:
--------------------------------------------------------------------------------
1 | """All tests for mac_cleanup_py.config."""
2 |
3 | import tempfile
4 | from pathlib import Path as Pathlib
5 | from typing import IO, Callable, Optional, cast
6 |
7 | import pytest
8 | from _pytest.capture import CaptureFixture
9 | from _pytest.monkeypatch import MonkeyPatch
10 |
11 | from mac_cleanup import args
12 | from mac_cleanup.core_modules import Command, Path
13 |
14 |
15 | class TestCommand:
16 | @pytest.mark.parametrize(
17 | ("prompt_succeeded", "prompt", "force_flag"),
18 | [
19 | (True, "prompt", False),
20 | (True, "prompt", True),
21 | (True, None, False),
22 | (True, None, True),
23 | (False, "prompt", False),
24 | (False, "prompt", True),
25 | (False, None, False),
26 | (False, None, True),
27 | ],
28 | )
29 | def test_base_module_execute(
30 | self,
31 | prompt_succeeded: bool,
32 | prompt: Optional[str],
33 | force_flag: bool,
34 | capsys: CaptureFixture[str],
35 | monkeypatch: MonkeyPatch,
36 | ):
37 | """Test prompt functionality in :class:`mac_cleanup.core_modules.BaseModule`"""
38 |
39 | args.force = force_flag
40 |
41 | # Dummy user input in prompt
42 | dummy_input: Callable[..., str] = lambda *_, **__: "" if force_flag else "y" if prompt_succeeded else "n"
43 |
44 | # Simulate user input in prompt
45 | monkeypatch.setattr("rich.prompt.PromptBase.get_input", dummy_input)
46 |
47 | # Get command with prompt
48 | command = Command("echo 'test'").with_prompt(message_=prompt)
49 |
50 | # Get command execution output
51 | captured_execute = command._execute()
52 |
53 | # Check command execution based on prompt success
54 | if prompt_succeeded or force_flag:
55 | assert captured_execute is not None
56 | assert "test" in captured_execute
57 | else:
58 | assert captured_execute is None
59 |
60 | if force_flag:
61 | return
62 |
63 | # Get stdout
64 | captured_stdout = capsys.readouterr().out
65 |
66 | # Change prompt message to default message if prompt was empty
67 | if prompt is None:
68 | prompt = "Do you want to proceed?"
69 |
70 | # Check prompt text
71 | assert prompt in captured_stdout
72 |
73 | # Check prompt title
74 | assert "Module requires attention" in captured_stdout
75 |
76 | @pytest.mark.parametrize(
77 | "executed_command",
78 | # Empty command or None
79 | ["", None],
80 | )
81 | def test_base_command_execute(self, executed_command: Optional[str]):
82 | """Test no command being passed to :class:`mac_cleanup.core_modules._BaseCommand`"""
83 |
84 | # Get command instance without command
85 | command = Command(executed_command)
86 |
87 | # Get stdout
88 | captured_stdout = command._execute()
89 |
90 | # Check there is no output and no errors
91 | assert captured_stdout is None
92 |
93 | @pytest.mark.parametrize("redirect_errors", [True, False])
94 | def test_with_errors(self, redirect_errors: bool):
95 | # Get command with stderr
96 | command = Command("echo 'test' >&2")
97 |
98 | # Specify redirecting errors in Command instance
99 | if redirect_errors:
100 | command = command.with_errors()
101 |
102 | # Get command execution output
103 | captured_execute = command._execute()
104 |
105 | # Check command execution output is not empty
106 | assert captured_execute is not None
107 |
108 | # Check if stderr was captured
109 | if redirect_errors:
110 | assert "test" in captured_execute
111 | return
112 |
113 | # Check if stderr wasn't captured
114 | assert "test" not in captured_execute
115 |
116 |
117 | class TestPath:
118 | @pytest.mark.parametrize("is_file", [True, False])
119 | def test_init(self, is_file: bool):
120 | """Tests path and command in init of :class:`mac_cleanup.core_modules.Path`"""
121 |
122 | # Get tmp file
123 | if is_file:
124 | tmp_file_object = tempfile.NamedTemporaryFile(mode="w+", delete=False) # noqa: SIM115
125 | else:
126 | tmp_file_object = tempfile.TemporaryDirectory()
127 |
128 | with tmp_file_object as f:
129 | # Get name from file
130 | if is_file:
131 | f = cast(IO[str], f)
132 | f_name: str = f.name
133 | else:
134 | f = cast(str, f)
135 | f_name: str = f
136 |
137 | # Get tmp path posix
138 | tmp_path_posix = Pathlib(f_name).as_posix()
139 |
140 | # Get Path instance
141 | path = Path(tmp_path_posix)
142 |
143 | # Check path
144 | assert path.get_path.as_posix() == tmp_path_posix
145 |
146 | # Check command
147 | assert path.get_command == f"rm -rf '{tmp_path_posix}'" # noqa
148 |
149 | def test_init_expanduser(self):
150 | """Test expand user in :class:`mac_cleanup.core_modules.Path`"""
151 |
152 | # Get dummy path posix
153 | tmp_path_posix = Pathlib("~/test")
154 |
155 | # Get Path instance
156 | path = Path(tmp_path_posix.as_posix())
157 |
158 | # Check path
159 | assert path.get_path.as_posix() == tmp_path_posix.expanduser().as_posix()
160 |
161 | # Check command
162 | assert path.get_command == f"rm -rf '{tmp_path_posix.expanduser().as_posix()}'" # noqa
163 |
164 | @pytest.mark.parametrize("is_file", [True, False])
165 | def test_dry_run_only(self, is_file: bool):
166 | """Test dry run only in :class:`mac_cleanup.core_modules.Path`"""
167 |
168 | # Get tmp file
169 | if is_file:
170 | tmp_file_object = tempfile.NamedTemporaryFile(mode="w+", delete=False) # noqa: SIM115
171 | else:
172 | tmp_file_object = tempfile.TemporaryDirectory()
173 |
174 | with tmp_file_object as f:
175 | # Get name from file
176 | if is_file:
177 | f = cast(IO[str], f)
178 | f_name: str = f.name
179 | else:
180 | f = cast(str, f)
181 | f_name: str = f
182 |
183 | # Get tmp path
184 | tmp_path = Pathlib(f_name)
185 |
186 | # Get Path instance with flag dry_run_only
187 | path = Path(tmp_path.as_posix()).dry_run_only()
188 |
189 | # Invoke path deletion
190 | path._execute()
191 |
192 | # Check path exists
193 | assert tmp_path.exists()
194 |
195 | @pytest.mark.parametrize("is_file", [True, False])
196 | def test_execute(self, is_file: bool):
197 | """Test for path/dir deletion in :class:`mac_cleanup.core_modules.Path`"""
198 |
199 | # Get tmp file
200 | if is_file:
201 | tmp_file_object = tempfile.NamedTemporaryFile(mode="w+", delete=False) # noqa: SIM115
202 | else:
203 | tmp_file_object = tempfile.TemporaryDirectory()
204 |
205 | with tmp_file_object as f:
206 | # Get name from file
207 | if is_file:
208 | f = cast(IO[str], f)
209 | f_name: str = f.name
210 | else:
211 | f = cast(str, f)
212 | f_name: str = f
213 |
214 | # Get tmp path
215 | tmp_path = Pathlib(f_name)
216 |
217 | # Get Path instance
218 | path = Path(tmp_path.as_posix())
219 |
220 | # Invoke path deletion
221 | path._execute()
222 |
223 | try:
224 | # Check file doesn't exist
225 | assert not tmp_path.exists()
226 | except AssertionError as err:
227 | # Remove temp file on error
228 | if is_file:
229 | from os import unlink
230 |
231 | unlink(f_name)
232 |
233 | # Raise error that file exists
234 | raise FileExistsError from err
235 |
236 | @pytest.mark.parametrize(("deletable", "exist"), [(False, True), (True, False), (False, False)])
237 | def test_negative_execute(self, deletable: bool, exist: bool, monkeypatch: MonkeyPatch):
238 | """Test for negative execution in :class:`mac_cleanup.core_modules.Path`"""
239 |
240 | # Dummy check_deletable utility
241 | dummy_deletable: Callable[[Pathlib | str], bool] = lambda path: deletable
242 |
243 | # Dummy check_exists utility
244 | dummy_exists: Callable[[Pathlib | str, bool], bool] = lambda path, expand_user: exist
245 |
246 | # Get tmp file
247 | with tempfile.NamedTemporaryFile(mode="w+") as f:
248 | # Get tmp path
249 | tmp_path = Pathlib(f.name)
250 |
251 | # Simulate check_deletable results
252 | monkeypatch.setattr("mac_cleanup.core_modules.check_deletable", dummy_deletable)
253 |
254 | # Simulate check_exists results
255 | monkeypatch.setattr("mac_cleanup.core_modules.check_exists", dummy_exists)
256 |
257 | # Invoke Path instance
258 | Path(tmp_path.as_posix())._execute()
259 |
260 | # Check file exists
261 | assert tmp_path.exists()
262 |
--------------------------------------------------------------------------------
/mac_cleanup/config.py:
--------------------------------------------------------------------------------
1 | """Config handler."""
2 |
3 | from inspect import getmembers, isfunction
4 | from pathlib import Path
5 | from typing import Callable, Final, Optional, TypedDict, final
6 |
7 | from mac_cleanup import default_modules
8 | from mac_cleanup.console import console
9 |
10 |
11 | @final
12 | class ConfigFile(TypedDict):
13 | """Config file structure."""
14 |
15 | enabled: list[str]
16 | custom_path: Optional[str]
17 |
18 |
19 | @final
20 | class Config:
21 | """
22 | Class for config initialization and validation.
23 |
24 | :param config_path_: Path to config location
25 | """
26 |
27 | def __init__(self, config_path_: Path):
28 | # Set config path
29 | self.__path: Final = config_path_
30 |
31 | # Set modules in the class
32 | self.__modules: dict[str, Callable[..., None]] = dict()
33 |
34 | # Load default modules
35 | self.__load_default()
36 |
37 | # Load config
38 | self.__config_data: Final[ConfigFile]
39 |
40 | try:
41 | self.__config_data = self.__read()
42 | except FileNotFoundError:
43 | console.print("[danger]Modules not configured, opening configuration screen...[/danger]")
44 |
45 | # Set config to empty dict
46 | # Why: self.configure will call dict.update
47 | self.__config_data = ConfigFile(enabled=list(), custom_path=None)
48 |
49 | # Launch configuration
50 | self.__configure(all_modules=list(self.__modules.keys()), enabled_modules=list())
51 |
52 | # Get custom modules path
53 | self.__custom_modules_path: Optional[str] = self.__config_data.get("custom_path")
54 |
55 | # Load custom modules
56 | self.__load_custom()
57 |
58 | def __call__(self, *, configuration_prompted: bool):
59 | """Checks config and launches additional configuration if needed."""
60 |
61 | # Configure and exit on prompt
62 | if configuration_prompted:
63 | # Configure modules
64 | self.__configure(
65 | all_modules=list(self.__modules.keys()), enabled_modules=self.__config_data.get("enabled", list[str]())
66 | )
67 |
68 | # Exit
69 | self.full_exit(failed=False)
70 |
71 | # If config doesn't have modules configuration - launch configuration
72 | if not self.__config_data.get("enabled"):
73 | # Notify user
74 | console.print("[danger]Modules not configured, opening configuration screen...[/danger]")
75 |
76 | # Configure modules
77 | self.__configure(all_modules=list(self.__modules.keys()), enabled_modules=list())
78 |
79 | # Create list with faulty modules
80 | remove_list: list[str] = list()
81 |
82 | # Invoke modules
83 | for module_name in self.__config_data["enabled"]:
84 | module = self.__modules.get(module_name)
85 |
86 | # Add faulty module to remove list - if modules wasn't found
87 | if not module:
88 | remove_list.append(module_name)
89 | continue
90 |
91 | # Call module
92 | module()
93 |
94 | # Pop faulty modules from module list
95 | for faulty_module in remove_list:
96 | self.__config_data["enabled"].remove(faulty_module)
97 |
98 | # Write updated config if faulty modules were found
99 | if remove_list:
100 | self.__write()
101 |
102 | def __read(self) -> ConfigFile:
103 | """Gets the config or creates it if it doesn't exist :return: Config as a dict."""
104 |
105 | from toml import TomlDecodeError, load
106 |
107 | # Creates config if it's not already created
108 | self.__path.parent.mkdir(exist_ok=True, parents=True)
109 | self.__path.touch(exist_ok=True)
110 |
111 | # Loads config
112 | # If something got wrong there is try -> except
113 | try:
114 | config = ConfigFile(**load(self.__path))
115 | except TomlDecodeError as err:
116 | raise FileNotFoundError from err
117 | return config
118 |
119 | def __write(self) -> None:
120 | """Updates and writes config as toml."""
121 |
122 | from toml import dump
123 |
124 | with open(self.__path, "w+") as f:
125 | dump(self.__config_data, f)
126 |
127 | @staticmethod
128 | def full_exit(failed: bool) -> None:
129 | """
130 | Gracefully exits from cleaner.
131 |
132 | :param failed: Status code of exit
133 | """
134 |
135 | console.print("Config saved, exiting...")
136 | exit(failed)
137 |
138 | def set_custom_path(self) -> None:
139 | """Sets path for custom modules in config."""
140 |
141 | from rich.prompt import Prompt
142 |
143 | # Ask for user input
144 | custom_path = Prompt.ask("Enter path to custom modules", default="~/Documents/mac-cleanup/", show_default=True)
145 |
146 | # Get temps path
147 | tmp_custom_path = Path(custom_path).expanduser()
148 |
149 | # Creates directory if it doesn't exist
150 | tmp_custom_path.mkdir(exist_ok=True, parents=True)
151 |
152 | # Changes custom_path in config
153 | self.__config_data["custom_path"] = tmp_custom_path.as_posix()
154 |
155 | # Update config
156 | self.__write()
157 |
158 | # Exit
159 | self.full_exit(failed=False)
160 |
161 | def __configure(self, *, all_modules: list[str], enabled_modules: list[str]) -> None:
162 | """
163 | Opens modules configuration screen.
164 |
165 | :param all_modules: List w/ all modules
166 | :param enabled_modules: List w/ all enabled modules
167 | :return: List w/ all modules user enabled
168 | """
169 |
170 | import inquirer # pyright: ignore [reportMissingTypeStubs]
171 |
172 | from mac_cleanup.console import print_panel
173 |
174 | # Prints the legend
175 | print_panel(
176 | text="[success]Enable: [yellow][warning][/warning] | [warning]<--[/warning] | [warning]-->[/warning]"
177 | "\t[success]Confirm: [warning][/warning]",
178 | title="[info]Controls",
179 | )
180 |
181 | questions = inquirer.Checkbox( # pyright: ignore [reportUnknownMemberType]
182 | "modules", message="Active modules", choices=all_modules, default=enabled_modules, carousel=True
183 | )
184 |
185 | # Get user answers
186 | answers = inquirer.prompt( # pyright: ignore [reportUnknownVariableType, reportUnknownMemberType]
187 | questions=[questions], raise_keyboard_interrupt=True
188 | )
189 |
190 | # Clear console after checkbox
191 | console.clear()
192 |
193 | if not answers or not answers["modules"]:
194 | console.print("Config cannot be empty. Enable some modules")
195 |
196 | return self.__configure(all_modules=all_modules, enabled_modules=enabled_modules)
197 |
198 | # Update config
199 | self.__config_data["enabled"] = answers["modules"]
200 |
201 | # Write new config
202 | self.__write()
203 |
204 | def __load_default(self) -> None:
205 | """Loads default modules."""
206 |
207 | self.__modules.update(dict(getmembers(object=default_modules, predicate=isfunction)))
208 |
209 | def __load_custom(self) -> None:
210 | """Loads custom modules and."""
211 |
212 | # Empty dict if no custom path
213 | if not self.__custom_modules_path:
214 | return
215 |
216 | from importlib.machinery import SourceFileLoader
217 | from importlib.util import module_from_spec, spec_from_loader
218 | from pathlib import Path
219 |
220 | tmp_modules: dict[str, Callable[..., None]] = dict()
221 |
222 | # Imports all modules from the given path
223 | for module in Path(self.__custom_modules_path).expanduser().rglob("*.py"):
224 | # Get filename
225 | filename = module.name.split(".py")[0]
226 |
227 | # Set module loader
228 | loader = SourceFileLoader(fullname=filename, path=module.as_posix())
229 |
230 | # Get module spec
231 | spec = spec_from_loader(loader.name, loader)
232 |
233 | # Check next file if spec is empty
234 | if spec is None:
235 | continue # pragma: no cover # TODO: add test later
236 |
237 | # Get all modules from file
238 | modules = module_from_spec(spec)
239 |
240 | # Execute module
241 | loader.exec_module(modules)
242 |
243 | # Add modules to the list
244 | # Duplicates will be overwritten
245 | tmp_modules.update(dict(getmembers(object=modules, predicate=isfunction)))
246 |
247 | self.__modules.update(tmp_modules)
248 |
249 | @property
250 | def get_modules(self) -> dict[str, Callable[..., None]]:
251 | """Getter for private attr modules."""
252 |
253 | return self.__modules
254 |
255 | @property
256 | def get_config_data(self) -> ConfigFile:
257 | """Getter for private attr config data."""
258 |
259 | return self.__config_data
260 |
261 | @property
262 | def get_custom_path(self) -> Optional[str]:
263 | """Getter for private attr custom modules path."""
264 |
265 | return self.__custom_modules_path
266 |
--------------------------------------------------------------------------------
/tests/test_core.py:
--------------------------------------------------------------------------------
1 | """All tests for mac_cleanup_py.core."""
2 |
3 | import os
4 | import tempfile
5 | from pathlib import Path as Pathlib
6 | from random import choice, randint
7 | from typing import Callable, Optional, Type
8 |
9 | import pytest
10 | from _pytest.monkeypatch import MonkeyPatch
11 |
12 | from mac_cleanup.core import ProxyCollector as Collector
13 | from mac_cleanup.core import _Collector # noqa
14 | from mac_cleanup.core import Unit
15 | from mac_cleanup.core_modules import BaseModule, Command, Path
16 |
17 |
18 | class TestUnit:
19 | def test_create_unit(self):
20 | """Check :class:`mac_cleanup.core.Unit` creation."""
21 |
22 | # message, module list, validators
23 | message = "Test message"
24 |
25 | modules: list[BaseModule] = [choice([Command, Path])("") for _ in range(randint(1, 5))]
26 |
27 | unit = Unit(message=message, modules=modules)
28 |
29 | # Check message
30 | assert unit.message == message
31 |
32 | # Check modules list
33 | assert unit.modules == modules
34 |
35 | # Check errors
36 | with pytest.raises(TypeError):
37 | Unit(message=message, modules=[123]) # pyright: ignore [reportArgumentType] # noqa
38 |
39 | with pytest.raises(TypeError):
40 | Unit(message=message, modules=123) # pyright: ignore [reportArgumentType] # noqa
41 |
42 |
43 | class TestCollector:
44 | def test_proxy_collector(self):
45 | """Test :class:`mac_cleanup.core.Collector` is proxy of
46 | :class:`mac_cleanup.core._Collector`
47 | """
48 |
49 | with Collector() as t:
50 | assert isinstance(t, _Collector)
51 |
52 | @pytest.mark.parametrize("raised_error", [IndexError, KeyError, ValueError])
53 | def test_errors_on_exit(self, raised_error: Type[BaseException]):
54 | """Test errors being raised from :class:`mac_cleanup.core._Collector` and it's proxy."""
55 |
56 | with pytest.raises(raised_error), Collector() as _: # noqa: PT012
57 | raise raised_error
58 |
59 | @staticmethod
60 | @pytest.fixture
61 | def base_collector() -> _Collector:
62 | """Get main collector instance - :class:`mac_cleanup.core._Collector`"""
63 |
64 | return _Collector()
65 |
66 | @pytest.mark.parametrize("message_text", ["Test message", None])
67 | def test_message_and_add(self, message_text: Optional[str], base_collector: _Collector):
68 | """Test messages (or default ones) and modules being added to
69 | :class:`mac_cleanup.core._Collector`
70 | """
71 |
72 | # Set modules list
73 | module_list = [Path(""), Command("")]
74 |
75 | with Collector() as t:
76 | if message_text:
77 | # Add message
78 | t.message(message_text)
79 | else:
80 | # Add default message if none was specified
81 | message_text = "Working..."
82 |
83 | # Add modules from list of modules
84 | for module in module_list:
85 | t.add(module)
86 |
87 | # Check temp stuff
88 | assert t.get_temp_message == message_text
89 | assert t.get_temp_modules_list == module_list
90 |
91 | # Check temp stuff is gone
92 | assert not hasattr(base_collector, "_Collector__temp_message")
93 | assert base_collector.get_temp_message is None
94 |
95 | assert not hasattr(base_collector, "_Collector__temp_modules_list")
96 | assert base_collector.get_temp_modules_list is None
97 |
98 | # Check message and module list were set and are correct
99 | assert base_collector._execute_list[-1].message == message_text
100 | assert base_collector._execute_list[-1].modules == module_list
101 |
102 | def test_add_no_module(self, base_collector: _Collector):
103 | """Test nothing being added without specifying modules in
104 | :class:`mac_cleanup.core._Collector`
105 | """
106 |
107 | with Collector() as t:
108 | t.message("test_add_no_module")
109 |
110 | # Check temp stuff
111 | assert t.get_temp_message == "test_add_no_module"
112 | assert t.get_temp_modules_list is not None
113 | assert len(t.get_temp_modules_list) == 0
114 |
115 | # Check no module with specified message
116 | assert not len([unit for unit in base_collector._execute_list if unit.message == "test_add_no_module"])
117 |
118 | @pytest.mark.parametrize("size_multiplier", [0, 1, 1024])
119 | def test_get_size(self, size_multiplier: int, base_collector: _Collector):
120 | """Test :meth:`mac_cleanup.core._Collector._get_size` works correctly."""
121 |
122 | # Get size in bytes
123 | size = 1024 * size_multiplier
124 |
125 | with (
126 | tempfile.TemporaryDirectory() as dir_name,
127 | tempfile.NamedTemporaryFile(mode="w+b", dir=dir_name, prefix="test_get_size", suffix=".test") as f,
128 | ):
129 | # Write random bytes with specified size
130 | f.write(os.urandom(size))
131 |
132 | # Flush from buffer
133 | f.flush()
134 | # Move pointer to start of file
135 | f.seek(0)
136 |
137 | assert (
138 | size
139 | # Check on a file
140 | == base_collector._get_size(Pathlib(f.name))
141 | # Check on dir
142 | == base_collector._get_size(Pathlib(dir_name))
143 | # Check in glob with star
144 | # == base_collector._get_size(Pathlib(dir_name + "/*"))
145 | # Check in glob with brackets
146 | # == base_collector._get_size(Pathlib(dir_name + "/[!mac]*"))
147 | )
148 |
149 | assert (
150 | 0
151 | # Check in glob with star
152 | == base_collector._get_size(Pathlib(f.name + "/*"))
153 | # Negative check in glob with brackets
154 | == base_collector._get_size(Pathlib(dir_name + "/[!test]*"))
155 | )
156 |
157 | @pytest.mark.parametrize("is_file", [True, False])
158 | def test_get_size_errors(self, is_file: bool, base_collector: _Collector, monkeypatch: MonkeyPatch):
159 | """Test errors in :meth:`mac_cleanup.core._Collector._get_size`"""
160 |
161 | # Check path doesn't exist in glob
162 | assert base_collector._get_size(Pathlib("~/Documents")) == 0
163 |
164 | error = PermissionError
165 |
166 | # Dummy path raising error
167 | def dummy_path_stat(console_self: Pathlib, follow_symlinks: bool) -> None: # noqa # noqa # noqa
168 | raise error
169 |
170 | # Dummy Pathlib.is_file
171 | dummy_is_file: Callable[[Pathlib], bool] = lambda path_self: is_file
172 |
173 | # Dummy Pathlib.exists (always True)
174 | dummy_exists: Callable[[Pathlib], bool] = lambda path_self: True
175 |
176 | # Dummy Pathlib.glob (always one empty path)
177 | dummy_glob: Callable[[Pathlib, str], list[Pathlib]] = lambda path_self, pattern: [Pathlib()]
178 |
179 | # Simulate path is file and exist
180 | monkeypatch.setattr("mac_cleanup.core.Path_.is_file", dummy_is_file)
181 | monkeypatch.setattr("mac_cleanup.core.Path_.exists", dummy_exists)
182 |
183 | # Simulate glob being opened
184 | monkeypatch.setattr("mac_cleanup.core.Path_.glob", dummy_glob)
185 |
186 | # Simulate error being raised
187 | monkeypatch.setattr("mac_cleanup.core.Path_.stat", dummy_path_stat)
188 |
189 | # Check PermissionError
190 | base_collector._get_size(Pathlib("/"))
191 |
192 | # Check FileNotFoundError
193 | error = FileNotFoundError
194 | base_collector._get_size(Pathlib("/"))
195 |
196 | @pytest.mark.parametrize("size_multiplier", [0, 1, 1024])
197 | def test_extract_paths(self, size_multiplier: int, base_collector: _Collector, monkeypatch: MonkeyPatch):
198 | """Test :meth:`mac_cleanup.core._Collector._extract_paths`"""
199 |
200 | # Get size in bytes
201 | size = 1024 * size_multiplier
202 |
203 | # Dummy get_size
204 | dummy_get_size: Callable[[_Collector, Pathlib], float] = lambda clc_self, path: size
205 |
206 | # Simulate get_size with specified size
207 | monkeypatch.setattr("mac_cleanup.core._Collector._get_size", dummy_get_size)
208 |
209 | # Simulate stuff in execute_list
210 | monkeypatch.setattr(base_collector, "_execute_list", [Unit(message="test", modules=[Path("~/test")])])
211 |
212 | # Call _extract_paths
213 | paths = list(base_collector._extract_paths())
214 |
215 | # Check results
216 | assert len(paths) == 1
217 | assert paths[0][0] == Path("~/test").get_path
218 | assert paths[0][1] == size
219 |
220 | def test_extract_paths_error(self, base_collector: _Collector, monkeypatch: MonkeyPatch):
221 | """Test errors in :meth:`mac_cleanup.core._Collector._extract_paths`"""
222 |
223 | # Dummy get size raising KeyboardInterrupt
224 | def dummy_get_size(clc_self: _Collector, path: Pathlib) -> float: # noqa # noqa
225 | raise KeyboardInterrupt
226 |
227 | # Simulate get_size with error
228 | monkeypatch.setattr("mac_cleanup.core._Collector._get_size", dummy_get_size)
229 |
230 | # Simulate stuff in execute_list
231 | monkeypatch.setattr(base_collector, "_execute_list", [Unit(message="test", modules=[Path("~/test")])])
232 |
233 | # Check prematurely exit return 0
234 | # Call _extract_paths
235 | paths = list(base_collector._extract_paths())
236 |
237 | # Check results
238 | assert len(paths) == 0
239 |
--------------------------------------------------------------------------------
/tests/test_main.py:
--------------------------------------------------------------------------------
1 | """Test main script in mac_cleanup_py.main."""
2 |
3 | from pathlib import Path as Pathlib
4 | from typing import Any, Callable
5 |
6 | import pytest
7 | from _pytest.capture import CaptureFixture
8 | from _pytest.monkeypatch import MonkeyPatch
9 |
10 | from mac_cleanup import Command, Path, main
11 | from mac_cleanup.config import Config
12 | from mac_cleanup.core import Unit
13 | from mac_cleanup.core_modules import BaseModule
14 | from mac_cleanup.main import EntryPoint
15 |
16 |
17 | class TestEntryPoint:
18 | def test_count_free_space(self):
19 | """Test :meth:`mac_cleanup.main.EntryPoint.count_free_space`"""
20 |
21 | # Get current free space
22 | res = EntryPoint.count_free_space()
23 |
24 | # Check result is float
25 | assert isinstance(res, float)
26 |
27 | # Check result is not empty
28 | assert res > 0
29 |
30 | def test_custom_path_set_prompt(self, monkeypatch: MonkeyPatch):
31 | """Test configuration for custom path prompted in :class:`mac_cleanup.main.EntryPoint`"""
32 |
33 | # Dummy custom path setter raising SystemExit
34 | dummy_set_custom_path: Callable[[Config], None] = lambda cfg_self: exit(0)
35 |
36 | # Simulate custom path prompted
37 | monkeypatch.setattr("mac_cleanup.parser.Args.custom_path", True)
38 |
39 | # Simulate custom path setter
40 | monkeypatch.setattr("mac_cleanup.config.Config.set_custom_path", dummy_set_custom_path)
41 |
42 | # Check custom path setter being called
43 | with pytest.raises(SystemExit):
44 | main()
45 |
46 | @pytest.mark.parametrize("size_multiplier", [3.0, 2.0])
47 | def test_cleanup(self, size_multiplier: float, capsys: CaptureFixture[str], monkeypatch: MonkeyPatch):
48 | """Test cleanup in :class:`mac_cleanup.main.EntryPoint`"""
49 |
50 | # Dummy Config with empty init
51 | def dummy_config_init(cfg_self: Config, config_path_: Pathlib) -> None: # noqa # noqa
52 | return
53 |
54 | # Dummy Config with empty call
55 | def dummy_config_call(config_path_: Pathlib, configuration_prompted: bool) -> None: # noqa # noqa
56 | return
57 |
58 | # Dummy count_free_space for simulating cleaned half of free space
59 | def dummy_count_free_space(entry_self: EntryPoint) -> float: # noqa
60 | if not hasattr(dummy_count_free_space, "called"):
61 | dummy_count_free_space.called = True # pyright: ignore [reportFunctionMemberAccess]
62 |
63 | return 1024**3 * size_multiplier / 2
64 | else:
65 | return 1024**3 * size_multiplier
66 |
67 | # Dummy module execution (empty one)
68 | dummy_module_execute: Callable[[BaseModule], None] = lambda md_self: None
69 |
70 | # Simulate Command/Path execution
71 | monkeypatch.setattr("mac_cleanup.core_modules.Command._execute", dummy_module_execute)
72 | monkeypatch.setattr("mac_cleanup.core_modules.Path._execute", dummy_module_execute)
73 |
74 | # Simulate Config with empty one
75 | monkeypatch.setattr("mac_cleanup.config.Config.__init__", dummy_config_init)
76 | monkeypatch.setattr("mac_cleanup.config.Config.__call__", dummy_config_call)
77 |
78 | # Create EntryPoint and mock it
79 | mock_entry_point = EntryPoint()
80 | monkeypatch.setattr(EntryPoint, "__new__", lambda: mock_entry_point)
81 |
82 | # Simulate count_free_space results
83 | monkeypatch.setattr(EntryPoint, "count_free_space", dummy_count_free_space)
84 |
85 | # Dummy execution list
86 | dummy_execute_list: list[Unit] = [
87 | Unit(message="test_1", modules=[Path("test"), Command("test")]),
88 | Unit(message="test_2", modules=[Path("test")]),
89 | Unit(message="test_3", modules=[Command("test")]),
90 | ]
91 |
92 | # Simulate execution list in BaseCollector
93 | monkeypatch.setattr(mock_entry_point.base_collector, "_execute_list", dummy_execute_list)
94 |
95 | # Call entrypoint
96 | main()
97 |
98 | # Get stdout
99 | captured_stdout = capsys.readouterr().out
100 |
101 | # Check status in title
102 | assert "Success" in captured_stdout
103 |
104 | # Check correct size in stdout
105 | assert f"Removed - {size_multiplier / 2} GB" in captured_stdout
106 |
107 | @pytest.mark.parametrize("cleanup_prompted", [True, False])
108 | @pytest.mark.parametrize("verbose", [True, False])
109 | def test_dry_run_prompt(
110 | self, cleanup_prompted: bool, verbose: bool, capsys: CaptureFixture[str], monkeypatch: MonkeyPatch
111 | ):
112 | """Test dry_run with verbose and optional cleanup in :class:`mac_cleanup.main.EntryPoint`"""
113 |
114 | # Dummy _extract_paths returning [Pathlib("test") and 1 GB]
115 | dummy_extract_paths: Callable[..., list[tuple[Pathlib, float]]] = lambda: [(Pathlib("test"), float(1024**3))]
116 |
117 | # Dummy Config with empty init
118 | def dummy_config_init(cfg_self: Config, config_path_: Pathlib) -> None: # noqa # noqa
119 | return
120 |
121 | # Dummy Config with empty call
122 | def dummy_config_call(config_path_: Pathlib, configuration_prompted: bool) -> None: # noqa # noqa
123 | return
124 |
125 | # Dummy user input in prompt for optional cleanup
126 | dummy_input: Callable[..., str] = lambda *_, **__: "y" if cleanup_prompted else "n"
127 |
128 | # Dummy cleanup (empty one)
129 | dummy_cleanup: Callable[[EntryPoint], None] = lambda entry_self: None
130 |
131 | # Simulate user input in prompt for optional cleanup
132 | monkeypatch.setattr("rich.prompt.PromptBase.get_input", dummy_input)
133 |
134 | # Simulate Config with empty one
135 | monkeypatch.setattr("mac_cleanup.config.Config.__init__", dummy_config_init)
136 | monkeypatch.setattr("mac_cleanup.config.Config.__call__", dummy_config_call)
137 |
138 | # Create EntryPoint and mock it
139 | mock_entry_point = EntryPoint()
140 | monkeypatch.setattr(EntryPoint, "__new__", lambda: mock_entry_point)
141 |
142 | # Simulate _extract_paths with predefined result
143 | monkeypatch.setattr(mock_entry_point.base_collector, "_extract_paths", dummy_extract_paths)
144 |
145 | # Simulate empty cleanup
146 | monkeypatch.setattr(EntryPoint, "cleanup", dummy_cleanup)
147 |
148 | # Simulate dry run was prompted
149 | monkeypatch.setattr("mac_cleanup.parser.Args.dry_run", True)
150 |
151 | # Simulate verbose was set
152 | monkeypatch.setattr("mac_cleanup.parser.Args.verbose", verbose)
153 |
154 | # Call entrypoint
155 | main()
156 |
157 | # Get stdout
158 | captured_stdout = capsys.readouterr().out
159 |
160 | # Check title and body with estimated size
161 | assert "Dry run results" in captured_stdout
162 | assert "Approx 1.0 GB will be cleaned" in captured_stdout
163 |
164 | # Check verbose message
165 | if verbose:
166 | assert "1.0 GB test" in captured_stdout
167 |
168 | # Check no verbose message
169 | if not verbose:
170 | assert "1.0 GB test" not in captured_stdout
171 |
172 | # Check exit message
173 | if not cleanup_prompted:
174 | assert "Exiting..." in captured_stdout
175 |
176 | def test_dry_run_prompt_error(self, capsys: CaptureFixture[str], monkeypatch: MonkeyPatch):
177 | """Test errors in dry_run in :class:`mac_cleanup.main.EntryPoint`"""
178 |
179 | # Dummy _extract_paths returning [Pathlib("test") and 1 GB]
180 | dummy_extract_paths: Callable[..., list[tuple[Pathlib, float]]] = lambda: [(Pathlib("test"), float(1024**3))]
181 |
182 | # Dummy Config with no init and empty call
183 | # Dummy Config with empty init
184 | def dummy_config_init(cfg_self: Config, config_path_: Pathlib) -> None: # noqa # noqa
185 | return
186 |
187 | # Dummy Config with empty call
188 | def dummy_config_call(config_path_: Pathlib, configuration_prompted: bool) -> None: # noqa # noqa
189 | return
190 |
191 | # Dummy user input in prompt raising random decode error
192 | def dummy_input(*args: Any, **kwargs: Any) -> None: # noqa
193 | raise UnicodeDecodeError("test", bytes(), 0, 1, "test")
194 |
195 | # Simulate user input in prompt with decode error
196 | monkeypatch.setattr("rich.prompt.PromptBase.get_input", dummy_input)
197 |
198 | # Simulate Config with empty one
199 | monkeypatch.setattr("mac_cleanup.config.Config.__init__", dummy_config_init)
200 | monkeypatch.setattr("mac_cleanup.config.Config.__call__", dummy_config_call)
201 |
202 | # Create EntryPoint and mock it
203 | mock_entry_point = EntryPoint()
204 | monkeypatch.setattr(EntryPoint, "__new__", lambda: mock_entry_point)
205 |
206 | # Simulate _extract_paths with predefined result
207 | monkeypatch.setattr(mock_entry_point.base_collector, "_extract_paths", dummy_extract_paths)
208 |
209 | # Simulate dry run was prompted
210 | monkeypatch.setattr("mac_cleanup.parser.Args.dry_run", True)
211 |
212 | # Call entrypoint
213 | main()
214 |
215 | # Get stdout
216 | captured_stdout = capsys.readouterr().out
217 |
218 | # Check title and body with estimated size
219 | assert "Dry run results" in captured_stdout
220 | assert "Approx 1.0 GB will be cleaned" in captured_stdout
221 |
222 | # Check error message and exit message
223 | assert "Do not enter symbols that can't be decoded to UTF-8" in captured_stdout
224 | assert "Exiting..." in captured_stdout
225 |
226 | @pytest.mark.parametrize("xdg_env_set", [True, False])
227 | def test_config_home(self, xdg_env_set: bool, monkeypatch: MonkeyPatch):
228 | """Test xdg and default config in :class:`mac_cleanup.main.EntryPoint`"""
229 |
230 | expected_path: str
231 |
232 | if xdg_env_set:
233 | monkeypatch.setenv("XDG_CONFIG_HOME", "config_home")
234 | expected_path = "config_home/mac_cleanup_py/config.toml"
235 | else:
236 | monkeypatch.setattr("pathlib.Path.home", lambda: Pathlib("home"))
237 | expected_path = "home/.mac_cleanup_py"
238 |
239 | assert str(EntryPoint().config_path) == expected_path
240 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | """All tests for mac_cleanup_py.config."""
2 |
3 | import tempfile
4 | from pathlib import Path
5 | from typing import IO, Callable, Optional
6 |
7 | import pytest
8 | import toml
9 | from _pytest.capture import CaptureFixture
10 | from _pytest.monkeypatch import MonkeyPatch
11 | from inquirer.errors import EndOfInput # pyright: ignore [reportMissingTypeStubs, reportUnknownVariableType]
12 | from readchar import key
13 |
14 | from mac_cleanup.config import Config, ConfigFile
15 |
16 |
17 | @pytest.fixture(scope="session")
18 | def user_output() -> list[str]:
19 | """Set dummy user output."""
20 |
21 | return [f"test{num}" for num in range(2)]
22 |
23 |
24 | @pytest.fixture(scope="session")
25 | def dummy_module() -> Callable[..., None]:
26 | """Dummy module for calling modules in __call__"""
27 |
28 | return lambda: None
29 |
30 |
31 | @pytest.fixture(scope="session")
32 | def dummy_prompt(user_output: list[str]) -> Callable[[list[str]], None]:
33 | """Dummy prompt for inquirer (args are needed for params being provided to inquirer)"""
34 |
35 | def inner(*args: list[str] | bool) -> None: # noqa
36 | raise EndOfInput(user_output)
37 |
38 | return inner
39 |
40 |
41 | @pytest.fixture(scope="session")
42 | def dummy_key() -> Callable[..., str]:
43 | """Dummy key press for inquirer."""
44 |
45 | return lambda: key.ENTER
46 |
47 |
48 | class TestConfig:
49 | @pytest.mark.parametrize("enabled", [1, 2])
50 | def test_init_enabled_modules(self, enabled: int):
51 | """Test loading list of enabled modules in :class:`mac_cleanup.config.Config`"""
52 |
53 | # Set list of dummy modules
54 | enabled_modules = [f"test{num}" for num in range(enabled)]
55 |
56 | # Create dummy ConfigFile
57 | test_config = ConfigFile(enabled=enabled_modules, custom_path=None)
58 |
59 | with tempfile.NamedTemporaryFile(mode="w+") as f:
60 | # Write dummy file
61 | toml.dump(test_config, f)
62 |
63 | # Flush from buffer
64 | f.flush()
65 | # Move pointer to start of file
66 | f.seek(0)
67 |
68 | # Get tmp file path
69 | config_path = Path(f.name)
70 | # Load config from dummy file
71 | config = Config(config_path_=config_path)
72 |
73 | # Assert that dummy modules loaded
74 | assert len(config.get_config_data.get("enabled")) == len(enabled_modules)
75 |
76 | @pytest.mark.parametrize("custom_path", [None, "/test"])
77 | def test_init_custom_path(self, custom_path: Optional[str]):
78 | """Test custom path being set in :class:`mac_cleanup.config.Config`"""
79 |
80 | # Create dummy ConfigFile
81 | test_config = ConfigFile(enabled=["test"], custom_path=custom_path)
82 |
83 | with tempfile.NamedTemporaryFile(mode="w+") as f:
84 | # Write dummy file
85 | toml.dump(test_config, f)
86 |
87 | # Flush from buffer
88 | f.flush()
89 | # Move pointer to start of file
90 | f.seek(0)
91 |
92 | # Get tmp file path
93 | config_path = Path(f.name)
94 | # Load config from dummy file
95 | config = Config(config_path_=config_path)
96 |
97 | # Assert that custom path is correct
98 | assert config.get_custom_path == custom_path
99 |
100 | @staticmethod
101 | def config_call_final_checks(
102 | config: Config,
103 | configuration_prompted: bool,
104 | file_context: IO[str],
105 | capsys: CaptureFixture[str],
106 | monkeypatch: MonkeyPatch,
107 | user_output: list[str],
108 | dummy_module: Callable[..., None],
109 | dummy_prompt: Callable[..., None],
110 | dummy_key: Callable[..., str],
111 | ):
112 | """Final tests for :class:`mac_cleanup.config.Config` launching configuration on being empty
113 | or user prompted configuration.
114 | """
115 |
116 | # Simulate dummy modules are legit
117 | for out in user_output:
118 | monkeypatch.setitem(config.get_modules, out, dummy_module)
119 |
120 | # Simulate user input to enable a module
121 | monkeypatch.setattr("inquirer.render.console._checkbox.Checkbox.process_input", dummy_prompt)
122 | monkeypatch.setattr("readchar.readkey", dummy_key)
123 |
124 | # Check error being raised on configuration prompt
125 | if configuration_prompted:
126 | with pytest.raises(SystemExit):
127 | config(configuration_prompted=configuration_prompted)
128 |
129 | # Get stdout
130 | captured_stdout = capsys.readouterr().out
131 | else:
132 | config(configuration_prompted=configuration_prompted)
133 |
134 | # Get stdout
135 | captured_stdout = capsys.readouterr().out
136 |
137 | # Check message on empty config
138 | assert "Modules not configured" in captured_stdout
139 |
140 | # Flush from buffer (new config)
141 | file_context.flush()
142 | # Move pointer to start of file
143 | file_context.seek(0)
144 |
145 | # Check legend being printed to user
146 | assert "Enable" in captured_stdout
147 | assert "Confirm" in captured_stdout
148 | assert "Controls" in captured_stdout
149 |
150 | # Check modules list being printed to user
151 | assert "Active modules" in captured_stdout
152 |
153 | # Check that the config file was written correctly
154 | config_data = ConfigFile(**toml.load(file_context))
155 |
156 | # Check new config is correct
157 | assert config.get_config_data.get("enabled") == config_data.get("enabled") == user_output
158 |
159 | def test_call_configuration_prompted(
160 | self,
161 | user_output: list[str],
162 | dummy_module: Callable[..., None],
163 | dummy_prompt: Callable[[list[str]], None],
164 | dummy_key: Callable[..., str],
165 | capsys: CaptureFixture[str],
166 | monkeypatch: MonkeyPatch,
167 | ):
168 | """Test for configuration being prompted by user in :class:`mac_cleanup.config.Config`"""
169 |
170 | # Create dummy ConfigFile
171 | test_config = ConfigFile(enabled=["test"], custom_path=None)
172 |
173 | with tempfile.NamedTemporaryFile(mode="w+") as f:
174 | # Write dummy config to tmp file
175 | toml.dump(test_config, f)
176 |
177 | # Flush from buffer
178 | f.flush()
179 | # Move pointer to start of file
180 | f.seek(0)
181 |
182 | # Get tmp file path
183 | config_path = Path(f.name)
184 | # Load config with tmp path
185 | config = Config(config_path_=config_path)
186 |
187 | # Check default state
188 | assert config.get_config_data.get("enabled") == ConfigFile(**toml.load(f)).get("enabled") == ["test"]
189 |
190 | # Launch final check
191 | self.config_call_final_checks(
192 | config=config,
193 | configuration_prompted=True,
194 | file_context=f,
195 | capsys=capsys,
196 | monkeypatch=monkeypatch,
197 | user_output=user_output,
198 | dummy_module=dummy_module,
199 | dummy_prompt=dummy_prompt,
200 | dummy_key=dummy_key,
201 | )
202 |
203 | def test_call_with_no_config(
204 | self,
205 | user_output: list[str],
206 | dummy_module: Callable[..., None],
207 | dummy_prompt: Callable[[list[str]], None],
208 | dummy_key: Callable[..., str],
209 | capsys: CaptureFixture[str],
210 | monkeypatch: MonkeyPatch,
211 | ):
212 | """Test :class:`mac_cleanup.config.Config` being called with an empty configuration."""
213 |
214 | # Create empty config
215 | test_config: dict[str, list[str] | Optional[str]] = dict()
216 |
217 | with tempfile.NamedTemporaryFile(mode="w+") as f:
218 | # Write dummy config to tmp file
219 | toml.dump(test_config, f)
220 |
221 | # Flush from buffer
222 | f.flush()
223 | # Move pointer to start of file
224 | f.seek(0)
225 |
226 | # Get tmp file path
227 | config_path = Path(f.name)
228 | # Load config with tmp path
229 | config = Config(config_path_=config_path)
230 |
231 | # Check default state
232 | assert config.get_config_data.get("enabled") is ConfigFile(**toml.load(f)).get("enabled") is None
233 |
234 | # Launch final check
235 | self.config_call_final_checks(
236 | config=config,
237 | configuration_prompted=False,
238 | file_context=f,
239 | capsys=capsys,
240 | monkeypatch=monkeypatch,
241 | user_output=user_output,
242 | dummy_module=dummy_module,
243 | dummy_prompt=dummy_prompt,
244 | dummy_key=dummy_key,
245 | )
246 |
247 | @pytest.mark.parametrize("custom_path", ["~/Documents/my-custom-modules", None])
248 | def test_configure_custom_path(self, custom_path: Optional[str], monkeypatch: MonkeyPatch):
249 | """Test custom path being set by user prompt in :class:`mac_cleanup.config.Config`"""
250 |
251 | # Set default custom modules path
252 | default_path = "~/Documents/mac-cleanup/"
253 |
254 | # Dummy user input
255 | dummy_input: Callable[..., str] = lambda: custom_path if custom_path else ""
256 |
257 | with tempfile.NamedTemporaryFile(mode="w+") as f:
258 | # Get tmp file path
259 | config_path = Path(f.name)
260 | # Load config from dummy file
261 | config = Config(config_path)
262 |
263 | # Simulate user input with custom path
264 | monkeypatch.setattr("builtins.input", dummy_input)
265 |
266 | # Check set_custom_path exits from cleaner
267 | with pytest.raises(SystemExit):
268 | # Call for custom path configuration
269 | config.set_custom_path()
270 |
271 | # Flush from buffer
272 | f.flush()
273 | # Move pointer to start of file
274 | f.seek(0)
275 |
276 | if not custom_path:
277 | custom_path = default_path
278 |
279 | custom_path = Path(custom_path).expanduser().as_posix()
280 |
281 | # Check that the custom path was written to the config file
282 | config_data = ConfigFile(**toml.load(f))
283 |
284 | assert config_data.get("custom_path") == custom_path
285 |
286 | def test_init_decode_error(
287 | self,
288 | user_output: list[str],
289 | dummy_prompt: Callable[[list[str]], None],
290 | dummy_key: Callable[..., str],
291 | capsys: CaptureFixture[str],
292 | monkeypatch: MonkeyPatch,
293 | ):
294 | """Test toml decode error on init of :class:`mac_cleanup.config.Config`"""
295 |
296 | # Simulate writing config without writing it
297 | dummy_write: Callable[[Config], None] = lambda cfg_self: None
298 |
299 | # Simulate decode error
300 | def dummy_load(f: Path): # noqa
301 | raise toml.TomlDecodeError("test", "test", 0)
302 |
303 | monkeypatch.setattr("toml.load", dummy_load)
304 |
305 | # Protect against writing config
306 | monkeypatch.setattr("mac_cleanup.config.Config._Config__write", dummy_write)
307 |
308 | # Simulate user input to enable a modules
309 | monkeypatch.setattr("inquirer.render.console._checkbox.Checkbox.process_input", dummy_prompt)
310 | monkeypatch.setattr("readchar.readkey", dummy_key)
311 |
312 | # Load empty config
313 | config = Config(Path(""))
314 |
315 | # Check new config is correct
316 | assert config.get_config_data.get("enabled") == user_output
317 |
318 | # Get stdout
319 | captured_stdout = capsys.readouterr().out
320 |
321 | # Check message on empty config or decode error
322 | assert "Modules not configured" in captured_stdout
323 |
324 | def test_call_with_custom_modules(self, capsys: CaptureFixture[str], monkeypatch: MonkeyPatch):
325 | """Test loading of custom modules in :class:`mac_cleanup.config.Config`"""
326 |
327 | from inspect import getsource
328 |
329 | # Clear default modules list
330 | dummy_load_default: Callable[[Config], None] = lambda cfg_self: None
331 |
332 | # Dummy module with output to stdout
333 | def dummy_module() -> None:
334 | print("dummy_module_output") # noqa: T201 # print in tests is ok
335 |
336 | # Get dummy module name
337 | dummy_module_name = dummy_module.__code__.co_name
338 |
339 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".py") as f:
340 | # Get tmp module path
341 | tmp_module_path = Path(f.name)
342 |
343 | # Simulate loading of default modules
344 | monkeypatch.setattr("mac_cleanup.config.Config._Config__load_default", dummy_load_default)
345 |
346 | # Simulate config read
347 | def dummy_read(self: Config) -> ConfigFile: # noqa
348 | return ConfigFile(enabled=[dummy_module_name], custom_path=tmp_module_path.parent.as_posix())
349 |
350 | # Simulate dummy module in enabled
351 | monkeypatch.setattr("mac_cleanup.config.Config._Config__read", dummy_read)
352 |
353 | # Write dummy_module to tmp path
354 | f.write(getsource(dummy_module).strip())
355 |
356 | # Flush from buffer
357 | f.flush()
358 | # Move pointer to start of file
359 | f.seek(0)
360 |
361 | # Load config from dummy file
362 | config = Config(Path(""))
363 |
364 | # Call config
365 | config(configuration_prompted=False)
366 |
367 | # Get stdout
368 | captured_stdout = capsys.readouterr().out
369 |
370 | # Check message on empty config or decode error
371 | assert "dummy_module_output" in captured_stdout
372 |
373 | # Check custom_path is correct
374 | assert config.get_config_data.get("custom_path") == config.get_custom_path == tmp_module_path.parent.as_posix()
375 |
376 | # Check enabled modules
377 | assert config.get_config_data.get("enabled") == [dummy_module_name]
378 |
379 | def test_call_faulty_modules(self, monkeypatch: MonkeyPatch):
380 | """Test faulty (deleted) modules in configuration of :class:`mac_cleanup.config.Config`"""
381 |
382 | # Create dummy modules list
383 | modules_list = {"test": lambda: None}
384 |
385 | # Simulate modules list
386 | def dummy_load_default(cfg_self: Config) -> None:
387 | cfg_self.get_modules.update(modules_list)
388 |
389 | # Simulate loading of default modules
390 | monkeypatch.setattr("mac_cleanup.config.Config._Config__load_default", dummy_load_default)
391 |
392 | # Create config
393 | test_config = ConfigFile(enabled=["test2"], custom_path=None)
394 |
395 | with tempfile.NamedTemporaryFile(mode="w+") as f:
396 | # Write config to tmp file
397 | toml.dump(test_config, f)
398 |
399 | # Flush from buffer
400 | f.flush()
401 | # Move pointer to start of file
402 | f.seek(0)
403 |
404 | # Get tmp file path
405 | config_path = Path(f.name)
406 | # Load config with tmp path
407 | config = Config(config_path_=config_path)
408 |
409 | # Call config
410 | config(configuration_prompted=False)
411 |
412 | # Check modules list
413 | assert config.get_modules == modules_list
414 |
415 | # Check enabled modules
416 | assert len(config.get_config_data.get("enabled")) == 0
417 |
418 | def test_none_modules_selected(
419 | self, dummy_key: Callable[..., str], capsys: CaptureFixture[str], monkeypatch: MonkeyPatch
420 | ):
421 | """Test modules configuration with none being selected in
422 | :class:`mac_cleanup.config.Config`
423 | """
424 |
425 | # Dummy Config read with error
426 | def dummy_read(self: Config): # noqa
427 | raise FileNotFoundError
428 |
429 | # Simulate error (to launch configuration)
430 | monkeypatch.setattr("mac_cleanup.config.Config._Config__read", dummy_read)
431 |
432 | # Set enabled modules (on 2-nd prompt call)
433 | enabled_modules = ["test"]
434 |
435 | # Dummy prompt for inquirer with different output on first call and later calls
436 | # (args are needed for params being provided to inquirer)
437 | def dummy_prompt(*args: list[str] | bool) -> None: # noqa
438 | selection: list[str]
439 |
440 | if not hasattr(dummy_prompt, "called"):
441 | dummy_prompt.called = True # pyright: ignore [reportFunctionMemberAccess]
442 | selection = list()
443 | else:
444 | selection = enabled_modules
445 | raise EndOfInput(selection)
446 |
447 | # Simulate user input to enable a module
448 | monkeypatch.setattr("inquirer.render.console._checkbox.Checkbox.process_input", dummy_prompt)
449 | monkeypatch.setattr("readchar.readkey", dummy_key)
450 |
451 | with tempfile.NamedTemporaryFile(mode="w+") as f:
452 | # Get tmp file path
453 | config_path = Path(f.name)
454 | # Load config with tmp path
455 | config = Config(config_path_=config_path)
456 |
457 | # Get stdout
458 | captured_stdout = capsys.readouterr().out
459 |
460 | # Check message on empty input
461 | assert "Config cannot be empty. Enable some modules" in captured_stdout
462 |
463 | # Check new config from second input
464 | assert config.get_config_data.get("enabled") == enabled_modules
465 |
--------------------------------------------------------------------------------
/mac_cleanup/default_modules.py:
--------------------------------------------------------------------------------
1 | from mac_cleanup.core import ProxyCollector as Collector
2 | from mac_cleanup.core_modules import Command, Path
3 | from mac_cleanup.parser import args
4 |
5 | clc = Collector()
6 |
7 |
8 | def trash():
9 | with clc as unit:
10 | unit.message("Emptying the Trash 🗑 on all mounted volumes and the main HDD")
11 | unit.add(Path("/Volumes/*/.Trashes/*"))
12 | unit.add(Path("~/.Trash/*"))
13 |
14 |
15 | def system_caches():
16 | with clc as unit:
17 | unit.message("Clearing System Cache Files")
18 | unit.add(
19 | Path("~/Library/Caches/*").with_prompt(
20 | "ALL USER CACHE will be DELETED, including Poetry, Jetbrains, Cocoa, yarn, Composer etc.\n" "Continue?"
21 | )
22 | )
23 | unit.add(Path("/private/var/folders/bh/*/*/*/*"))
24 |
25 |
26 | def system_log():
27 | with clc as unit:
28 | unit.message("Clearing System Log Files")
29 | unit.add(Path("/private/var/log/asl/*.asl"))
30 | unit.add(Path("/Library/Logs/DiagnosticReports/*"))
31 | unit.add(Path("/Library/Logs/CreativeCloud/*"))
32 | unit.add(Path("/Library/Logs/Adobe/*"))
33 | unit.add(Path("/Library/Logs/adobegc.log"))
34 | unit.add(Path("~/Library/Containers/com.apple.mail/Data/Library/Logs/Mail/*"))
35 | unit.add(Path("~/Library/Logs/CoreSimulator/*"))
36 |
37 |
38 | def jetbrains():
39 | from mac_cleanup.utils import check_exists
40 |
41 | if check_exists("~/Library/Logs/JetBrains/"):
42 | with clc as unit:
43 | unit.message("Clearing all application log files from JetBrains")
44 | unit.add(Path("~/Library/Logs/JetBrains/*/"))
45 |
46 |
47 | def adobe():
48 | from mac_cleanup.utils import check_exists
49 |
50 | if check_exists("~/Library/Application Support/Adobe/"):
51 | with clc as unit:
52 | unit.message("Clearing Adobe Cache Files")
53 | unit.add(Path("~/Library/Application Support/Adobe/Common/Media Cache Files/*"))
54 |
55 |
56 | def chrome():
57 | from mac_cleanup.utils import check_exists
58 |
59 | if check_exists("~/Library/Application Support/Google/Chrome/"):
60 | with clc as unit:
61 | unit.message("Clearing Google Chrome Cache Files")
62 | unit.add(Path("~/Library/Application Support/Google/Chrome/Default/Application Cache/*"))
63 |
64 |
65 | def ios_apps():
66 | with clc as unit:
67 | unit.message("Cleaning up iOS Applications")
68 | unit.add(Path("~/Music/iTunes/iTunes Media/Mobile Applications/*"))
69 |
70 |
71 | def ios_backups():
72 | with clc as unit:
73 | unit.message("Removing iOS Device Backups")
74 | unit.add(Path("~/Library/Application Support/MobileSync/Backup/*"))
75 |
76 |
77 | def xcode():
78 | with clc as unit:
79 | unit.message("Cleaning up XCode Derived Data and Archives")
80 | unit.add(Path("~/Library/Developer/Xcode/DerivedData/*"))
81 | unit.add(Path("~/Library/Developer/Xcode/Archives/*"))
82 | unit.add(Path("~/Library/Developer/Xcode/iOS Device Logs/*"))
83 |
84 |
85 | def xcode_simulators():
86 | from mac_cleanup.utils import cmd
87 |
88 | if cmd("type 'xcrun'"):
89 | with clc as unit:
90 | unit.message("Cleaning up iOS Simulators")
91 | unit.add(Command("osascript -e 'tell application 'com.apple.CoreSimulator.CoreSimulatorService' to quit'"))
92 | unit.add(Command("osascript -e 'tell application 'iOS Simulator' to quit'"))
93 | unit.add(Command("osascript -e 'tell application 'Simulator' to quit'"))
94 | unit.add(Command("xcrun simctl shutdown all"))
95 | unit.add(
96 | Command("xcrun simctl erase all").with_prompt("All Xcode simulators will be pruned.\n" "Continue?")
97 | )
98 |
99 | unit.add(Path("~/Library/Developer/CoreSimulator/Devices/*/data/[!Library|var|tmp|Media]*").dry_run_only())
100 | unit.add(
101 | Path(
102 | "~/Library/Developer/CoreSimulator/Devices/*/data/Library/"
103 | "[!PreferencesCaches|Caches|AddressBook|Trial]*"
104 | ).dry_run_only()
105 | )
106 | unit.add(Path("~/Library/Developer/CoreSimulator/Devices/*/data/Library/Caches/*").dry_run_only())
107 | unit.add(
108 | Path("~/Library/Developer/CoreSimulator/Devices/*/data/Library/AddressBook/AddressBook*").dry_run_only()
109 | )
110 |
111 |
112 | # Support deleting Dropbox Cache if they exist
113 | def dropbox():
114 | from mac_cleanup.utils import check_exists
115 |
116 | if check_exists("~/Dropbox"):
117 | with clc as unit:
118 | unit.message("Clearing Dropbox 📦 Cache Files")
119 | unit.add(Path("~/Dropbox/.dropbox.cache/*"))
120 |
121 |
122 | def google_drive():
123 | from mac_cleanup.utils import check_exists
124 |
125 | if check_exists("~/Library/Application Support/Google/DriveFS/"):
126 | with clc as unit:
127 | unit.message("Clearing Google Drive File Stream Cache Files")
128 | unit.add(Command("killall 'Google Drive File Stream'"))
129 | unit.add(Path("~/Library/Application Support/Google/DriveFS/[0-9a-zA-Z]*/content_cache"))
130 |
131 |
132 | def composer():
133 | from mac_cleanup.utils import cmd
134 |
135 | if cmd("type 'composer'"):
136 | with clc as unit:
137 | unit.message("Cleaning up composer")
138 | unit.add(Command("composer clearcache --no-interaction"))
139 | unit.add(Path("~/Library/Caches/composer").dry_run_only())
140 |
141 |
142 | # Deletes Steam caches, logs, and temp files
143 | def steam():
144 | from mac_cleanup.utils import check_exists
145 |
146 | if check_exists("~/Library/Application Support/Steam/"):
147 | with clc as unit:
148 | unit.message("Clearing Steam Cache, Log, and Temp Files")
149 | unit.add(Path("~/Library/Application Support/Steam/appcache"))
150 | unit.add(Path("~/Library/Application Support/Steam/depotcache"))
151 | unit.add(Path("~/Library/Application Support/Steam/logs"))
152 | unit.add(Path("~/Library/Application Support/Steam/steamapps/shadercache"))
153 | unit.add(Path("~/Library/Application Support/Steam/steamapps/temp"))
154 | unit.add(Path("~/Library/Application Support/Steam/steamapps/download"))
155 |
156 |
157 | # Deletes Minecraft logs
158 | def minecraft():
159 | from mac_cleanup.utils import check_exists
160 |
161 | if check_exists("~/Library/Application Support/minecraft"):
162 | with clc as unit:
163 | unit.message("Clearing Minecraft Cache and Log Files")
164 | unit.add(Path("~/Library/Application Support/minecraft/logs"))
165 | unit.add(Path("~/Library/Application Support/minecraft/crash-reports"))
166 | unit.add(Path("~/Library/Application Support/minecraft/webcache"))
167 | unit.add(Path("~/Library/Application Support/minecraft/webcache2"))
168 | unit.add(Path("~/Library/Application Support/minecraft/crash-reports"))
169 | unit.add(Path("~/Library/Application Support/minecraft/*.log"))
170 | unit.add(Path("~/Library/Application Support/minecraft/launcher_cef_log.txt"))
171 | unit.add(Path("~/Library/Application Support/minecraft/command_history.txt"))
172 |
173 | if check_exists("~/Library/Application Support/minecraft/.mixin.out"):
174 | unit.add(Path("~/Library/Application Support/minecraft/.mixin.out"))
175 |
176 |
177 | # Deletes Lunar Client logs (Minecraft alternate client)
178 | def lunarclient(): # noqa
179 | from mac_cleanup.utils import check_exists
180 |
181 | if check_exists("~/.lunarclient"):
182 | with clc as unit:
183 | unit.message("Deleting Lunar Client logs and caches")
184 | unit.add(Path("~/.lunarclient/game-cache"))
185 | unit.add(Path("~/.lunarclient/launcher-cache"))
186 | unit.add(Path("~/.lunarclient/logs"))
187 | unit.add(Path("~/.lunarclient/offline/*/logs"))
188 | unit.add(Path("~/.lunarclient/offline/files/*/logs"))
189 |
190 |
191 | # Deletes Wget logs
192 | def wget_logs():
193 | from mac_cleanup.utils import check_exists
194 |
195 | if check_exists("~/wget-log"):
196 | with clc as unit:
197 | unit.message("Deleting Wget log and hosts file")
198 | unit.add(Path("~/wget-log"))
199 | unit.add(Path("~/.wget-hsts"))
200 |
201 |
202 | # Deletes Cacher logs / I dunno either
203 | def cacher():
204 | from mac_cleanup.utils import check_exists
205 |
206 | if check_exists("~/.cacher"):
207 | with clc as unit:
208 | unit.message("Deleting Cacher logs")
209 | unit.add(Path("~/.cacher/logs"))
210 |
211 |
212 | # Deletes Android cache
213 | def android():
214 | from mac_cleanup.utils import check_exists
215 |
216 | if check_exists("~/.android"):
217 | with clc as unit:
218 | unit.message("Deleting Android cache")
219 | unit.add(Path("~/.android/cache"))
220 |
221 |
222 | # Clears Gradle caches
223 | def gradle():
224 | from mac_cleanup.utils import check_exists
225 |
226 | if check_exists("~/.gradle"):
227 | with clc as unit:
228 | unit.message("Clearing Gradle caches")
229 | unit.add(
230 | Path("~/.gradle/caches").with_prompt(
231 | "Gradle cache will be removed. It is chunky and kinda long to reinstall.\n" "Continue?"
232 | )
233 | )
234 |
235 |
236 | # Deletes Kite Autocomplete logs
237 | def kite():
238 | from mac_cleanup.utils import check_exists
239 |
240 | if check_exists("~/.kite"):
241 | with clc as unit:
242 | unit.message("Deleting Kite logs")
243 | unit.add(Path("~/.kite/logs"))
244 |
245 |
246 | def brew():
247 | from mac_cleanup.utils import cmd
248 |
249 | if cmd("type 'brew'"):
250 | with clc as unit:
251 | unit.message("Cleaning up Homebrew Cache")
252 |
253 | # Get brew path
254 | brew_cache_path = cmd("brew --cache")
255 |
256 | unit.add(Command("brew cleanup -s"))
257 | unit.add(Path(brew_cache_path))
258 | unit.add(Command("brew tap --repair"))
259 |
260 | if args.update:
261 | with clc as unit:
262 | unit.message("Updating Homebrew Recipes and upgrading")
263 | unit.add(Command("brew update && brew upgrade"))
264 |
265 |
266 | def gem():
267 | from mac_cleanup.utils import cmd
268 |
269 | if cmd("type 'gem'"): # TODO add count_dry
270 | with clc as unit:
271 | unit.message("Cleaning up any old versions of gems")
272 | unit.add(Command("gem cleanup"))
273 |
274 |
275 | def docker():
276 | from mac_cleanup.utils import cmd
277 |
278 | if cmd("type 'docker'"): # TODO add count_dry
279 | with clc as unit:
280 | unit.message("Cleaning up Docker")
281 |
282 | # Flag for turning Docker off
283 | close_docker = False
284 |
285 | if not cmd("docker ps >/dev/null 2>&1"):
286 | unit.add(Command("open -jga Docker"))
287 |
288 | close_docker = True
289 |
290 | unit.add(
291 | Command("docker system prune -af").with_prompt(
292 | "Stopped containers, dangling images, unused networks, volumes, and build cache will be deleted.\n"
293 | "Continue?"
294 | )
295 | )
296 |
297 | # Close Docker if it was opened by cleaner
298 | if close_docker:
299 | unit.add(Command("killall Docker"))
300 |
301 |
302 | def pyenv():
303 | from os import getenv
304 |
305 | if pyenv_path := getenv("PYENV_VIRTUALENV_CACHE_PATH"):
306 | with clc as unit:
307 | unit.message("Removing Pyenv-VirtualEnv Cache")
308 | unit.add(Path(pyenv_path))
309 |
310 |
311 | def npm():
312 | from mac_cleanup.utils import cmd
313 |
314 | if cmd("type 'npm'"):
315 | with clc as unit:
316 | unit.message("Cleaning up npm cache")
317 | unit.add(Command("npm cache clean --force"))
318 | unit.add(Path("~/.npm/*").dry_run_only())
319 |
320 |
321 | def pnpm():
322 | from mac_cleanup.utils import cmd
323 |
324 | if cmd("type 'pnpm'"):
325 | with clc as unit:
326 | unit.message("Cleaning up pnpm Cache...")
327 | unit.add(Command("pnpm store prune &>/dev/null"))
328 | unit.add(Path("~/.pnpm-store/*").dry_run_only())
329 |
330 |
331 | def yarn():
332 | from mac_cleanup.utils import cmd
333 |
334 | if cmd("type 'yarn'"):
335 | with clc as unit:
336 | unit.message("Cleaning up Yarn Cache")
337 | unit.add(Command("yarn cache clean --force"))
338 | unit.add(Path("~/Library/Caches/yarn").dry_run_only())
339 |
340 |
341 | def bun():
342 | from mac_cleanup.utils import cmd
343 |
344 | if cmd("type 'bun'"):
345 | with clc as unit:
346 | unit.message("Cleaning up Bun Cache")
347 | unit.add(Command("bun pm cache rm"))
348 | unit.add(Path("~/.bun/install/cache").dry_run_only())
349 |
350 |
351 | def pod():
352 | from mac_cleanup.utils import cmd
353 |
354 | if cmd("type 'pod'"):
355 | with clc as unit:
356 | unit.message("Cleaning up Pod Cache")
357 | unit.add(Command("pod cache clean --all"))
358 |
359 | unit.add(Path("~/Library/Caches/CocoaPods").dry_run_only())
360 |
361 |
362 | def go():
363 | from mac_cleanup.utils import cmd
364 |
365 | if cmd("type 'go'"):
366 | from os import getenv
367 |
368 | with clc as unit:
369 | unit.message("Clearing Go module cache")
370 | unit.add(Command("go clean -modcache"))
371 |
372 | if go_path := getenv("GOPATH"):
373 | unit.add(Path(go_path + "/pkg/mod").dry_run_only())
374 | else:
375 | unit.add(Path("~/go/pkg/mod").dry_run_only())
376 |
377 |
378 | # Deletes all Microsoft Teams Caches and resets it to default - can fix also some performance issues
379 | def microsoft_teams():
380 | from mac_cleanup.utils import check_exists
381 |
382 | if check_exists("~/Library/Application Support/Microsoft/Teams"):
383 | with clc as unit:
384 | unit.message("Deleting Microsoft Teams logs and caches")
385 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/IndexedDB"))
386 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/Cache"))
387 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/Application Cache"))
388 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/Code Cache"))
389 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/blob_storage"))
390 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/databases"))
391 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/gpucache"))
392 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/Local Storage"))
393 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/tmp"))
394 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/*logs*.txt"))
395 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/watchdog"))
396 | unit.add(Path("~/Library/Application Support/Microsoft/Teams/*watchdog*.json"))
397 |
398 |
399 | # Deletes Poetry cache
400 | def poetry():
401 | from mac_cleanup.utils import check_exists, cmd
402 |
403 | if cmd("type 'poetry'") or check_exists("~/Library/Caches/pypoetry"):
404 | with clc as unit:
405 | unit.message("Deleting Poetry cache")
406 | unit.add(
407 | Path("~/Library/Caches/pypoetry").with_prompt(
408 | "All non-local Poetry venvs will be deleted.\n" "Continue?"
409 | )
410 | )
411 |
412 |
413 | # Removes Java heap dumps
414 | def java_cache():
415 | with clc as unit:
416 | unit.message("Deleting Java heap dumps")
417 | unit.add(Path("~/*.hprof").with_prompt("All heap dumps (.hprof) in HOME dir will be deleted.\n" "Continue?"))
418 |
419 |
420 | def dns_cache():
421 | with clc as unit:
422 | unit.message("Cleaning up DNS cache")
423 | unit.add(Command("sudo dscacheutil -flushcache"))
424 | unit.add(Command("sudo killall -HUP mDNSResponder"))
425 |
426 |
427 | def inactive_memory():
428 | with clc as unit:
429 | unit.message("Purging inactive memory")
430 | unit.add(Command("sudo purge"))
431 |
432 |
433 | def telegram():
434 | from mac_cleanup.utils import cmd
435 |
436 | with clc as unit:
437 | unit.message("Clear old Telegram cache")
438 |
439 | reopen_telegram = False
440 |
441 | if cmd("ps aux | grep '[T]elegram'"):
442 | reopen_telegram = True
443 | unit.add(Command("killall -KILL Telegram"))
444 |
445 | unit.add(
446 | Path("~/Library/Group Containers/*.ru.keepcoder.Telegram/stable/account-*/postbox/db").with_prompt(
447 | "Telegram cache will be deleted. Once reopened, cache will be rebuild smaller. Continue?"
448 | )
449 | )
450 |
451 | if reopen_telegram:
452 | unit.add(Command("open /Applications/Telegram.app"))
453 |
454 |
455 | def conan():
456 | with clc as unit:
457 | unit.message("Clearing conan cache")
458 | unit.add(Command("""conan remove "*" -c"""))
459 | unit.add(Path("~/.conan2/p/"))
460 |
461 |
462 | def nuget_cache():
463 | with clc as unit:
464 | unit.message("Emptying the .nuget folder's content of the current user")
465 | unit.add(
466 | Path("~/.nuget/packages/").with_prompt(
467 | "Deleting nuget packages probably will cause a lot of files being redownloaded!\n" "Continue?"
468 | )
469 | )
470 |
471 |
472 | def obsidian_caches():
473 | with clc as unit:
474 | unit.message("Deleting all cache folders of Obsidian")
475 | unit.add(Path("~/Library/Application Support/obsidian/Cache/"))
476 | unit.add(Path("~/Library/Application Support/obsidian/Code Cache/"))
477 | unit.add(Path("~/Library/Application Support/obsidian/DawnGraphiteCache/"))
478 | unit.add(Path("~/Library/Application Support/obsidian/DawnWebGPUCache/"))
479 | unit.add(Path("~/Library/Application Support/obsidian/DawnWebGPUCache/"))
480 | unit.add(Path("~/Library/Application Support/obsidian/*.log"))
481 |
482 |
483 | def ea_caches():
484 | with clc as unit:
485 | unit.message("Deleting all cache folders of the EA App")
486 | unit.add(Path("~/Library/Application Support/Electronic Arts/EA app/IGOCache/"))
487 | unit.add(Path("~/Library/Application Support/Electronic Arts/EA app/Logs/"))
488 | unit.add(Path("~/Library/Application Support/Electronic Arts/EA app/OfflineCache/"))
489 | unit.add(Path("~/Library/Application Support/Electronic Arts/EA app/CEF/BrowserCache/EADesktop/Cache/"))
490 | unit.add(Path("~/Library/Application Support/Electronic Arts/EA app/CEF/BrowserCache/EADesktop/Code Cache/"))
491 | unit.add(Path("~/Library/Application Support/Electronic Arts/EA app/CEF/BrowserCache/EADesktop/DawnCache/"))
492 | unit.add(Path("~/Library/Application Support/Electronic Arts/EA app/CEF/BrowserCache/EADesktop/GPUCache/"))
493 |
494 |
495 | def chromium_caches():
496 | with clc as unit:
497 | unit.message("Deleting all cache folders of Chromium")
498 | unit.add(Path("~/Library/Application Support/Chromium/GraphiteDawnCache/"))
499 | unit.add(Path("~/Library/Application Support/Chromium/GrShaderCache/"))
500 | unit.add(Path("~/Library/Application Support/Chromium/ShaderCache/"))
501 | unit.add(Path("~/Library/Application Support/Chromium/Default/DawnCache/"))
502 | unit.add(Path("~/Library/Application Support/Chromium/Default/GPUCache/"))
503 |
504 |
505 | def arc():
506 | with clc as unit:
507 | unit.message("Deleting all cache, cookies, history, site data of Arc Browser")
508 | unit.add(Path("~/Library/Caches/Arc"))
509 | unit.add(Path("~/Library/Caches/CloudKit/company.thebrowser.Browser"))
510 | unit.add(Path("~/Library/Caches/company.thebrowser.Browser"))
511 | unit.add(Path("~/Library/Application Support/Arc/User Data/Default/History"))
512 | unit.add(Path("~/Library/Application Support/Arc/User Data/Default/History-journal"))
513 | unit.add(Path("~/Library/Application Support/Arc/User Data/Default/Cookies"))
514 | unit.add(Path("~/Library/Application Support/Arc/User Data/Default/Cookies-journal"))
515 | unit.add(Path("~/Library/Application Support/Arc/User Data/Default/Web Data"))
516 | unit.add(Path("~/Library/Application Support/Arc/User Data/Default/Web Data-journal"))
517 |
--------------------------------------------------------------------------------