├── 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 | [![PyPI](https://img.shields.io/pypi/v/mac_cleanup)](https://pypi.org/project/mac-cleanup/) 4 | [![Tests](https://github.com/mac-cleanup/mac-cleanup-py/actions/workflows/tox.yml/badge.svg)](https://github.com/mac-cleanup/mac-cleanup-py/actions/workflows/tox.yml) 5 | [![CodeQL](https://github.com/mac-cleanup/mac-cleanup-py/actions/workflows/codeql.yml/badge.svg)](https://github.com/mac-cleanup/mac-cleanup-py/actions/workflows/codeql.yml) 6 | [![JetBrains](https://img.shields.io/badge/Thanks-JetBrains-green.svg)](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 | ![mac-cleanup-demo](https://user-images.githubusercontent.com/44712637/231780851-d2197255-e24e-46ba-8355-42bcf588376d.gif) 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 | JetBrains 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 | --------------------------------------------------------------------------------