├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── codeql-analysis.yml │ ├── create-release.yml │ ├── pre-commit-autoupdate.yml │ └── test-package.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── SECURITY.md ├── pyproject.toml ├── requirements-test.txt ├── requirements.txt ├── setup.py ├── src └── fsutil │ ├── __init__.py │ ├── archives.py │ ├── args.py │ ├── checks.py │ ├── converters.py │ ├── deps.py │ ├── info.py │ ├── io.py │ ├── metadata.py │ ├── operations.py │ ├── paths.py │ ├── perms.py │ ├── py.typed │ └── types.py ├── tests ├── __init__.py ├── conftest.py ├── test_archives.py ├── test_args.py ├── test_checks.py ├── test_converters.py ├── test_deps.py ├── test_info.py ├── test_io.py ├── test_metadata.py ├── test_operations.py ├── test_paths.py └── test_perms.py └── tox.ini /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [fabiocaccamo] 2 | polar: fabiocaccamo 3 | ko_fi: fabiocaccamo 4 | liberapay: fabiocaccamo 5 | tidelift: pypi/python-fsutil 6 | custom: ["https://www.buymeacoffee.com/fabiocaccamo", "https://www.paypal.me/fabiocaccamo"] 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug for this project 4 | title: '' 5 | labels: bug 6 | assignees: fabiocaccamo 7 | 8 | --- 9 | 10 | **Python version** 11 | ? 12 | 13 | **Package version** 14 | ? 15 | 16 | **Current behavior (bug description)** 17 | ? 18 | 19 | **Expected behavior** 20 | ? 21 | -------------------------------------------------------------------------------- /.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: fabiocaccamo 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | open-pull-requests-limit: 100 9 | 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | open-pull-requests-limit: 100 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request 3 | about: Submit a pull request for this project 4 | assignees: fabiocaccamo 5 | 6 | --- 7 | 8 | **Describe your changes** 9 | ? 10 | 11 | **Related issue** 12 | ? 13 | 14 | **Checklist before requesting a review** 15 | - [ ] I have performed a self-review of my code. 16 | - [ ] I have added tests for the proposed changes. 17 | - [ ] I have run the tests and there are not errors. 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '15 21 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | # environment: release 12 | permissions: 13 | id-token: write 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Extract release notes 20 | id: extract-release-notes 21 | uses: ffurrer2/extract-release-notes@v2 22 | 23 | - name: Create release 24 | uses: ncipollo/release-action@v1 25 | with: 26 | body: ${{ steps.extract-release-notes.outputs.release_notes }} 27 | token: ${{ secrets.WORKFLOWS_CREATE_RELEASE_TOKEN }} 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: '3.x' 33 | cache: 'pip' 34 | 35 | - name: Build Package 36 | run: | 37 | pip install pip --upgrade 38 | pip install build 39 | python -m build 40 | 41 | - name: Publish on PyPI 42 | uses: pypa/gh-action-pypi-publish@release/v1 43 | with: 44 | packages-dir: dist/ 45 | # password: ${{ secrets.WORKFLOWS_PUBLISH_TO_PYPI_TOKEN }} 46 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit-autoupdate.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit auto-update 2 | 3 | on: 4 | # every month 5 | schedule: 6 | - cron: "0 0 1 * *" 7 | # on demand 8 | workflow_dispatch: 9 | 10 | jobs: 11 | auto-update: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.x' 18 | - uses: browniebroke/pre-commit-autoupdate-action@main 19 | - uses: peter-evans/create-pull-request@v7 20 | with: 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | branch: update/pre-commit-hooks 23 | title: Update pre-commit hooks 24 | commit-message: "Update pre-commit hooks." 25 | body: Update versions of pre-commit hooks to latest version. 26 | -------------------------------------------------------------------------------- /.github/workflows/test-package.yml: -------------------------------------------------------------------------------- 1 | name: Test package 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | platform: [ubuntu-latest, macos-latest, windows-latest] 18 | python-version: ['3.10', '3.11', '3.12', '3.13', 'pypy-3.10'] 19 | 20 | steps: 21 | 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | cache: 'pip' 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install -r requirements.txt 34 | pip install -r requirements-test.txt 35 | 36 | - name: Run pre-commit 37 | run: | 38 | pre-commit run --all-files --show-diff-on-failure --verbose 39 | 40 | - name: Run mypy 41 | run: | 42 | mypy --install-types --non-interactive 43 | 44 | - name: Run tests 45 | run: | 46 | pytest tests --cov=fsutil --cov-report=term-missing --cov-fail-under=90 47 | 48 | - name: Upload coverage to Codecov 49 | uses: codecov/codecov-action@v5 50 | with: 51 | token: ${{ secrets.CODECOV_TOKEN }} 52 | fail_ci_if_error: false 53 | files: ./coverage.xml 54 | flags: unittests 55 | verbose: true 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | # custom 126 | TODO.txt 127 | test-coverage.sh 128 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | 5 | - repo: https://github.com/asottile/pyupgrade 6 | rev: v3.19.1 7 | hooks: 8 | - id: pyupgrade 9 | args: ["--py310-plus"] 10 | 11 | - repo: https://github.com/frostming/fix-future-annotations 12 | rev: 0.5.0 13 | hooks: 14 | - id: fix-future-annotations 15 | 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.11.2 18 | hooks: 19 | - id: ruff 20 | args: [--fix, --exit-non-zero-on-fix] 21 | - id: ruff-format 22 | 23 | - repo: https://github.com/pre-commit/mirrors-mypy 24 | rev: v1.15.0 25 | hooks: 26 | - id: mypy 27 | args: [--ignore-missing-imports, --strict] 28 | exclude: "tests" 29 | additional_dependencies: [ 30 | types-requests 31 | ] 32 | 33 | - repo: https://github.com/pre-commit/pre-commit-hooks 34 | rev: v5.0.0 35 | hooks: 36 | - id: trailing-whitespace 37 | - id: end-of-file-fixer 38 | - id: check-yaml 39 | - id: check-added-large-files 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.15.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.15.0) - 2025-02-06 8 | - Split codebase into modules. 9 | - Convert tests to `pytest`. 10 | - Bump requirements and `pre-commit` hooks. 11 | 12 | ## [0.14.1](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.14.1) - 2024-03-19 13 | - Add `mypy` to `pre-commit`. 14 | - Add `transform_filepath` method. #12 #13 15 | - Fix `join_filename` return value when `basename` or `extension` are empty. 16 | - Fix `pyproject` `Ruff` conf warnings. 17 | - Bump requirements and `pre-commit` hooks. 18 | 19 | ## [0.13.1](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.13.1) - 2024-01-24 20 | - Fix permissions inheritance from existing file when using `write_file` with `atomic=True`. #94 21 | - Bump requirements and `pre-commit` hooks. 22 | 23 | ## [0.13.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.13.0) - 2023-12-19 24 | - Add `get_permissions` and `set_permissions` methods. 25 | - Fix permissions lost when using `write_file` with `atomic=True`. #94 26 | - Improve `write_file` with `atomic=True` atomicity. #91 27 | - Remove tests duplicated code. 28 | 29 | ## [0.12.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.12.0) - 2023-12-11 30 | - Add possibility to write files atomically (`fsutil.write_file(path, content, atomic=True)`). #91 31 | 32 | ## [0.11.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.11.0) - 2023-11-01 33 | - Add `Python 3.12` support. (#84) 34 | - Add `tar` files operations support. #48 (#87) 35 | - Switch from `setup.cfg` to `pyproject.toml`. 36 | - Replace `flake8` with `Ruff`. 37 | - Fix `tox` test command. 38 | - Upgrade syntax for `Python >= 3.8`. 39 | - Reformat tests code. 40 | - Set `Black` pre-commit hook `line-length` option value. 41 | - Add `fix-future-annotations` `pre-commit` hook. 42 | - Bump requirements and `pre-commit` hooks. 43 | 44 | ## [0.10.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.10.0) - 2023-02-01 45 | - Rename default branch from `master` to `main`. 46 | - Move `flake8` config to `setup.cfg`. 47 | - Increase `flake8` checks. 48 | - Add `mypy` to CI (strict mode). 49 | - Add `pre-commit` to CI. 50 | - Force keyword arguments . 51 | - Remove unused import. 52 | - Add type hints. #18 53 | - Bump requirements and `pre-commit` hooks. 54 | 55 | ## [0.9.3](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.9.3) - 2023-01-12 56 | - Remove `tests/` from dist. 57 | 58 | ## [0.9.2](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.9.2) - 2023-01-11 59 | - Fix `FileNotFoundError` when calling `make_dirs_for_file` with filename only. 60 | - Pin test requirements. 61 | - Bump test requirements. 62 | 63 | ## [0.9.1](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.9.1) - 2023-01-02 64 | - Fix `OSError` when downloading multiple files to the same temp dir. 65 | 66 | ## [0.9.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.9.0) - 2023-01-02 67 | - Drop old code targeting `Python < 3.8`. 68 | - Add `get_unique_name` method. 69 | - Add `replace_file` method. 70 | - Add `replace_dir` method. 71 | - Add `get_dir_hash` method. #10 72 | - Add support to `pathlib.Path` path arguments. #14 73 | - Add default value for `pattern` argument in `search_dirs` and `search_files` methods. 74 | - Add more assertions on path args. 75 | - Increase tests coverage. 76 | - Add `setup.cfg` (`setuptools` declarative syntax) generated using `setuptools-py2cfg`. 77 | - Add `pyupgrade` to `pre-commit` config. 78 | - Fix duplicated test name. 79 | - Remove unused variable in tests. 80 | 81 | ## [0.8.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.8.0) - 2022-12-09 82 | - Add `Python 3.11` support. 83 | - Drop `Python < 3.8` support. #17 84 | - Add `pypy` to CI. 85 | - Add `pre-commit`. 86 | - Add default json encoder to `write_file_json` for encoding also `datetime` and `set` objects by default. 87 | - Replace `str.format` with `f-strings`. 88 | - Make `dirpath` argument optional in `download_file` method. 89 | - Fix `download_file` `NameError` when `requests` is not installed. 90 | - Increase tests coverage. 91 | - Bump requirements and GitHub actions versions. 92 | 93 | ## [0.7.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.7.0) - 2022-09-13 94 | - Add `read_file_lines_count` method. 95 | - Update `read_file_lines` method with two new arguments: `line_start` and `line_end` *(for specifying the lines-range to read)*. 96 | 97 | ## [0.6.1](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.6.1) - 2022-05-20 98 | - Fixed `create_zip_file` content directory structure. 99 | 100 | ## [0.6.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.6.0) - 2022-01-25 101 | - Added `read_file_json` and `write_file_json` methods. 102 | - Removed `requests` requirement *(it's optional now)*. 103 | 104 | ## [0.5.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.5.0) - 2021-05-03 105 | - Added `get_parent_dir` method. 106 | - Updated `join_path` to force concatenation even with absolute paths. 107 | - Updated `join_path` to return a normalized path. 108 | - Updated `join_filepath` method to use `join_path`. 109 | 110 | ## [0.4.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.4.0) - 2020-12-23 111 | - Added `delete_dir_content` method (alias for `remove_dir_content` method). 112 | - Added `download_file` method. 113 | - Added `read_file_from_url` method. 114 | - Added `remove_dir_content` and method. 115 | 116 | ## [0.3.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.3.0) - 2020-11-04 117 | - Added `create_zip_file` method. 118 | - Added `extract_zip_file` method. 119 | - Added `get_dir_creation_date` method. 120 | - Added `get_dir_creation_date_formatted` method. 121 | - Added `get_dir_last_modified_date` method. 122 | - Added `get_dir_last_modified_date_formatted` method. 123 | - Added `get_file_creation_date` method. 124 | - Added `get_file_creation_date_formatted` method. 125 | - Added `get_file_last_modified_date` method. 126 | - Added `get_file_last_modified_date_formatted` method. 127 | - Added `read_file_lines` method. 128 | - Refactored tests. 129 | 130 | ## [0.2.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.2.0) - 2020-10-29 131 | - Added `convert_size_bytes_to_string` method. 132 | - Added `convert_size_string_to_bytes` method. 133 | - Added `get_dir_size` method. 134 | - Added `get_dir_size_formatted` method. 135 | - Added `get_file_size` method. 136 | - Added `get_file_size_formatted`. 137 | - Renamed `get_path` to `join_path`. 138 | - Renamed `get_hash` to `get_file_hash`. 139 | - Fixed `clean_dir` method and added relative tests. 140 | - Improved code quality and tests coverage. 141 | 142 | ## [0.1.0](https://github.com/fabiocaccamo/python-fsutil/releases/tag/0.1.0) - 2020-10-27 143 | - Released `python-fsutil` 144 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | fabio.caccamo@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Fabio Caccamo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | recursive-include src * 4 | recursive-include tests * 5 | recursive-exclude * *.pyc __pycache__ .DS_Store 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/pypi/pyversions/python-fsutil.svg?color=blue&logo=python&logoColor=white)](https://www.python.org/) 2 | [![](https://img.shields.io/pypi/v/python-fsutil.svg?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/python-fsutil/) 3 | [![](https://static.pepy.tech/badge/python-fsutil/month)](https://pepy.tech/project/python-fsutil) 4 | [![](https://img.shields.io/github/stars/fabiocaccamo/python-fsutil?logo=github&style=flat)](https://github.com/fabiocaccamo/python-fsutil/stargazers) 5 | [![](https://img.shields.io/pypi/l/python-fsutil.svg?color=blue)](https://github.com/fabiocaccamo/python-fsutil/blob/main/LICENSE.txt) 6 | 7 | [![](https://results.pre-commit.ci/badge/github/fabiocaccamo/python-fsutil/main.svg)](https://results.pre-commit.ci/latest/github/fabiocaccamo/python-fsutil/main) 8 | [![](https://img.shields.io/github/actions/workflow/status/fabiocaccamo/python-fsutil/test-package.yml?branch=main&label=build&logo=github)](https://github.com/fabiocaccamo/python-fsutil) 9 | [![](https://img.shields.io/codecov/c/gh/fabiocaccamo/python-fsutil?logo=codecov)](https://codecov.io/gh/fabiocaccamo/python-fsutil) 10 | [![](https://img.shields.io/codacy/grade/fc40788ae7d74d1fb34a38934113c4e5?logo=codacy)](https://www.codacy.com/app/fabiocaccamo/python-fsutil) 11 | [![](https://img.shields.io/codeclimate/maintainability/fabiocaccamo/python-fsutil?logo=code-climate)](https://codeclimate.com/github/fabiocaccamo/python-fsutil/) 12 | [![](https://img.shields.io/badge/code%20style-black-000000.svg?logo=python&logoColor=black)](https://github.com/psf/black) 13 | [![](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 14 | 15 | # python-fsutil 16 | high-level file-system operations for lazy devs. 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pip install python-fsutil 22 | ``` 23 | 24 | ## Usage 25 | 26 | Just import the main module and call its methods. 27 | 28 | ```python 29 | import fsutil 30 | ``` 31 | 32 | ### Methods 33 | 34 | - [`assert_dir`](#assert_dir) 35 | - [`assert_exists`](#assert_exists) 36 | - [`assert_file`](#assert_file) 37 | - [`assert_not_dir`](#assert_not_dir) 38 | - [`assert_not_exists`](#assert_not_exists) 39 | - [`assert_not_file`](#assert_not_file) 40 | - [`clean_dir`](#clean_dir) 41 | - [`convert_size_bytes_to_string`](#convert_size_bytes_to_string) 42 | - [`convert_size_string_to_bytes`](#convert_size_string_to_bytes) 43 | - [`copy_dir`](#copy_dir) 44 | - [`copy_dir_content`](#copy_dir_content) 45 | - [`copy_file`](#copy_file) 46 | - [`create_dir`](#create_dir) 47 | - [`create_file`](#create_file) 48 | - [`create_tar_file`](#create_tar_file) 49 | - [`create_zip_file`](#create_zip_file) 50 | - [`delete_dir`](#delete_dir) 51 | - [`delete_dir_content`](#delete_dir_content) 52 | - [`delete_dirs`](#delete_dirs) 53 | - [`delete_file`](#delete_file) 54 | - [`delete_files`](#delete_files) 55 | - [`download_file`](#download_file) *(require `requests` to be installed)* 56 | - [`exists`](#exists) 57 | - [`extract_tar_file`](#extract_tar_file) 58 | - [`extract_zip_file`](#extract_zip_file) 59 | - [`get_dir_creation_date`](#get_dir_creation_date) 60 | - [`get_dir_creation_date_formatted`](#get_dir_creation_date_formatted) 61 | - [`get_dir_hash`](#get_dir_hash) 62 | - [`get_dir_last_modified_date`](#get_dir_last_modified_date) 63 | - [`get_dir_last_modified_date_formatted`](#get_dir_last_modified_date_formatted) 64 | - [`get_dir_size`](#get_dir_size) 65 | - [`get_dir_size_formatted`](#get_dir_size_formatted) 66 | - [`get_file_basename`](#get_file_basename) 67 | - [`get_file_creation_date`](#get_file_creation_date) 68 | - [`get_file_creation_date_formatted`](#get_file_creation_date_formatted) 69 | - [`get_file_extension`](#get_file_extension) 70 | - [`get_file_hash`](#get_file_hash) 71 | - [`get_file_last_modified_date`](#get_file_last_modified_date) 72 | - [`get_file_last_modified_date_formatted`](#get_file_last_modified_date_formatted) 73 | - [`get_file_size`](#get_file_size) 74 | - [`get_file_size_formatted`](#get_file_size_formatted) 75 | - [`get_filename`](#get_filename) 76 | - [`get_parent_dir`](#get_parent_dir) 77 | - [`get_permissions`](#get_permissions) 78 | - [`get_unique_name`](#get_unique_name) 79 | - [`is_dir`](#is_dir) 80 | - [`is_empty`](#is_empty) 81 | - [`is_empty_dir`](#is_empty_dir) 82 | - [`is_empty_file`](#is_empty_file) 83 | - [`is_file`](#is_file) 84 | - [`join_filename`](#join_filename) 85 | - [`join_filepath`](#join_filepath) 86 | - [`join_path`](#join_path) 87 | - [`list_dirs`](#list_dirs) 88 | - [`list_files`](#list_files) 89 | - [`make_dirs`](#make_dirs) 90 | - [`make_dirs_for_file`](#make_dirs_for_file) 91 | - [`move_dir`](#move_dir) 92 | - [`move_file`](#move_file) 93 | - [`read_file`](#read_file) 94 | - [`read_file_from_url`](#read_file_from_url) *(requires `requests` to be installed)* 95 | - [`read_file_json`](#read_file_json) 96 | - [`read_file_lines`](#read_file_lines) 97 | - [`read_file_lines_count`](#read_file_lines_count) 98 | - [`remove_dir`](#remove_dir) 99 | - [`remove_dir_content`](#remove_dir_content) 100 | - [`remove_dirs`](#remove_dirs) 101 | - [`remove_file`](#remove_file) 102 | - [`remove_files`](#remove_files) 103 | - [`rename_dir`](#rename_dir) 104 | - [`rename_file`](#rename_file) 105 | - [`rename_file_basename`](#rename_file_basename) 106 | - [`rename_file_extension`](#rename_file_extension) 107 | - [`replace_dir`](#replace_dir) 108 | - [`replace_file`](#replace_file) 109 | - [`search_dirs`](#search_dirs) 110 | - [`search_files`](#search_files) 111 | - [`set_permissions`](#set_permissions) 112 | - [`split_filename`](#split_filename) 113 | - [`split_filepath`](#split_filepath) 114 | - [`split_path`](#split_path) 115 | - [`transform_filepath`](#transform_filepath) 116 | - [`write_file`](#write_file) 117 | - [`write_file_json`](#write_file_json) 118 | 119 | 120 | #### `assert_dir` 121 | 122 | ```python 123 | # Raise an OSError if the given path doesn't exist or it is not a directory. 124 | fsutil.assert_dir(path) 125 | ``` 126 | 127 | #### `assert_exists` 128 | 129 | ```python 130 | # Raise an OSError if the given path doesn't exist. 131 | fsutil.assert_exists(path) 132 | ``` 133 | 134 | #### `assert_file` 135 | 136 | ```python 137 | # Raise an OSError if the given path doesn't exist or it is not a file. 138 | fsutil.assert_file(path) 139 | ``` 140 | 141 | #### `assert_not_dir` 142 | 143 | ```python 144 | # Raise an OSError if the given path is an existing directory. 145 | fsutil.assert_not_dir(path) 146 | ``` 147 | 148 | #### `assert_not_exists` 149 | 150 | ```python 151 | # Raise an OSError if the given path already exists. 152 | fsutil.assert_not_exists(path) 153 | ``` 154 | 155 | #### `assert_not_file` 156 | 157 | ```python 158 | # Raise an OSError if the given path is an existing file. 159 | fsutil.assert_not_file(path) 160 | ``` 161 | 162 | #### `clean_dir` 163 | 164 | ```python 165 | # Clean a directory by removing empty sub-directories and/or empty files. 166 | fsutil.clean_dir(path, dirs=True, files=True) 167 | ``` 168 | 169 | #### `convert_size_bytes_to_string` 170 | 171 | ```python 172 | # Convert the given size bytes to string using the right unit suffix. 173 | size_str = fsutil.convert_size_bytes_to_string(size) 174 | ``` 175 | 176 | #### `convert_size_string_to_bytes` 177 | 178 | ```python 179 | # Convert the given size string to bytes. 180 | size_bytes = fsutil.convert_size_string_to_bytes(size) 181 | ``` 182 | 183 | #### `copy_dir` 184 | 185 | ```python 186 | # Copy the directory at the given path and all its content to dest path. 187 | # If overwrite is not allowed and dest path exists, an OSError is raised. 188 | # More informations about kwargs supported options here: 189 | # https://docs.python.org/3/library/shutil.html#shutil.copytree 190 | fsutil.copy_dir(path, dest, overwrite=False, **kwargs) 191 | ``` 192 | 193 | #### `copy_dir_content` 194 | 195 | ```python 196 | # Copy the content of the directory at the given path to dest path. 197 | # More informations about kwargs supported options here: 198 | # https://docs.python.org/3/library/shutil.html#shutil.copytree 199 | fsutil.copy_dir_content(path, dest, **kwargs) 200 | ``` 201 | 202 | #### `copy_file` 203 | 204 | ```python 205 | # Copy the file at the given path and its metadata to dest path. 206 | # If overwrite is not allowed and dest path exists, an OSError is raised. 207 | # More informations about kwargs supported options here: 208 | # https://docs.python.org/3/library/shutil.html#shutil.copy2 209 | fsutil.copy_file(path, dest, overwrite=False, **kwargs) 210 | ``` 211 | 212 | #### `create_dir` 213 | 214 | ```python 215 | # Create directory at the given path. 216 | # If overwrite is not allowed and path exists, an OSError is raised. 217 | fsutil.create_dir(path, overwrite=False) 218 | ``` 219 | 220 | #### `create_file` 221 | 222 | ```python 223 | # Create file with the specified content at the given path. 224 | # If overwrite is not allowed and path exists, an OSError is raised. 225 | fsutil.create_file(path, content="", overwrite=False) 226 | ``` 227 | 228 | #### `create_tar_file` 229 | 230 | ```python 231 | # Create tar file at path compressing directories/files listed in content_paths. 232 | # If overwrite is allowed and dest tar already exists, it will be overwritten. 233 | fsutil.create_tar_file(path, content_paths, overwrite=True, compression="gzip") 234 | ``` 235 | 236 | #### `create_zip_file` 237 | 238 | ```python 239 | # Create zip file at path compressing directories/files listed in content_paths. 240 | # If overwrite is allowed and dest zip already exists, it will be overwritten. 241 | fsutil.create_zip_file(path, content_paths, overwrite=True, compression=zipfile.ZIP_DEFLATED) 242 | ``` 243 | 244 | #### `delete_dir` 245 | 246 | ```python 247 | # Alias for remove_dir. 248 | fsutil.delete_dir(path) 249 | ``` 250 | 251 | #### `delete_dir_content` 252 | 253 | ```python 254 | # Alias for remove_dir_content. 255 | fsutil.delete_dir_content(path) 256 | ``` 257 | 258 | #### `delete_dirs` 259 | 260 | ```python 261 | # Alias for remove_dirs. 262 | fsutil.delete_dirs(*paths) 263 | ``` 264 | 265 | #### `delete_file` 266 | 267 | ```python 268 | # Alias for remove_file. 269 | fsutil.delete_file(path) 270 | ``` 271 | 272 | #### `delete_files` 273 | 274 | ```python 275 | # Alias for remove_files. 276 | fsutil.delete_files(*paths) 277 | ``` 278 | 279 | #### `download_file` 280 | 281 | ```python 282 | # Download a file from url to the given dirpath and return the filepath. 283 | # If dirpath is not provided, the file will be downloaded to a temp directory. 284 | # If filename is provided, the file will be named using filename. 285 | # It is possible to pass extra request options (eg. for authentication) using **kwargs. 286 | filepath = fsutil.download_file(url, dirpath=None, filename="archive.zip", chunk_size=8192, **kwargs) 287 | ``` 288 | 289 | #### `exists` 290 | 291 | ```python 292 | # Check if a directory of a file exists at the given path. 293 | value = fsutil.exists(path) 294 | ``` 295 | 296 | #### `extract_tar_file` 297 | 298 | ```python 299 | # Extract tar file at path to dest path. 300 | # If autodelete, the archive will be deleted after extraction. 301 | # If content_paths list is defined, only listed items will be extracted, otherwise all. 302 | fsutil.extract_tar_file(path, dest, content_paths=None, autodelete=False) 303 | ``` 304 | 305 | #### `extract_zip_file` 306 | 307 | ```python 308 | # Extract zip file at path to dest path. 309 | # If autodelete, the archive will be deleted after extraction. 310 | # If content_paths list is defined, only listed items will be extracted, otherwise all. 311 | fsutil.extract_zip_file(path, dest, content_paths=None, autodelete=False) 312 | ``` 313 | 314 | #### `get_dir_creation_date` 315 | 316 | ```python 317 | # Get the directory creation date. 318 | date = fsutil.get_dir_creation_date(path) 319 | ``` 320 | 321 | #### `get_dir_creation_date_formatted` 322 | 323 | ```python 324 | # Get the directory creation date formatted using the given format. 325 | date_str = fsutil.get_dir_creation_date_formatted(path, format='%Y-%m-%d %H:%M:%S') 326 | ``` 327 | 328 | #### `get_dir_hash` 329 | 330 | ```python 331 | # Get the hash of the directory at the given path using 332 | # the specified algorithm function (md5 by default). 333 | hash = fsutil.get_dir_hash(path, func="md5") 334 | ``` 335 | 336 | #### `get_dir_last_modified_date` 337 | 338 | ```python 339 | # Get the directory last modification date. 340 | date = fsutil.get_dir_last_modified_date(path) 341 | ``` 342 | 343 | #### `get_dir_last_modified_date_formatted` 344 | 345 | ```python 346 | # Get the directory last modification date formatted using the given format. 347 | date_str = fsutil.get_dir_last_modified_date_formatted(path, format="%Y-%m-%d %H:%M:%S") 348 | ``` 349 | 350 | #### `get_dir_size` 351 | 352 | ```python 353 | # Get the directory size in bytes. 354 | size = fsutil.get_dir_size(path) 355 | ``` 356 | 357 | #### `get_dir_size_formatted` 358 | 359 | ```python 360 | # Get the directory size formatted using the right unit suffix. 361 | size_str = fsutil.get_dir_size_formatted(path) 362 | ``` 363 | 364 | #### `get_file_basename` 365 | 366 | ```python 367 | # Get the file basename from the given path/url. 368 | basename = fsutil.get_file_basename(path) 369 | ``` 370 | 371 | #### `get_file_creation_date` 372 | 373 | ```python 374 | # Get the file creation date. 375 | date = fsutil.get_file_creation_date(path) 376 | ``` 377 | 378 | #### `get_file_creation_date_formatted` 379 | 380 | ```python 381 | # Get the file creation date formatted using the given format. 382 | date_str = fsutil.get_file_creation_date_formatted(path, format="%Y-%m-%d %H:%M:%S") 383 | ``` 384 | 385 | #### `get_file_extension` 386 | 387 | ```python 388 | # Get the file extension from the given path/url. 389 | extension = fsutil.get_file_extension(path) 390 | ``` 391 | 392 | #### `get_file_hash` 393 | 394 | ```python 395 | # Get the hash of the file at the given path using 396 | # the specified algorithm function (md5 by default). 397 | filehash = fsutil.get_file_hash(path, func="md5") 398 | ``` 399 | 400 | #### `get_file_last_modified_date` 401 | 402 | ```python 403 | # Get the file last modification date. 404 | date = fsutil.get_file_last_modified_date(path) 405 | ``` 406 | 407 | #### `get_file_last_modified_date_formatted` 408 | 409 | ```python 410 | # Get the file last modification date formatted using the given format. 411 | date_str = fsutil.get_file_last_modified_date_formatted(path, format="%Y-%m-%d %H:%M:%S") 412 | ``` 413 | 414 | #### `get_file_size` 415 | 416 | ```python 417 | # Get the file size in bytes. 418 | size = fsutil.get_file_size(path) 419 | ``` 420 | 421 | #### `get_file_size_formatted` 422 | 423 | ```python 424 | # Get the file size formatted using the right unit suffix. 425 | size_str = fsutil.get_file_size_formatted(path) 426 | ``` 427 | 428 | #### `get_filename` 429 | 430 | ```python 431 | # Get the filename from the given path/url. 432 | filename = fsutil.get_filename(path) 433 | ``` 434 | 435 | #### `get_parent_dir` 436 | 437 | ```python 438 | # Get the parent directory for the given path going up N levels. 439 | parent_dir = fsutil.get_parent_dir(path, levels=1) 440 | ``` 441 | 442 | #### `get_permissions` 443 | 444 | ```python 445 | # Get the file/directory permissions. 446 | permissions = fsutil.get_permissions(path) 447 | ``` 448 | 449 | #### `get_unique_name` 450 | 451 | ```python 452 | # Get a unique name for a directory/file at the given directory path. 453 | unique_name = fsutil.get_unique_name(path, prefix="", suffix="", extension="", separator="-") 454 | ``` 455 | 456 | #### `is_dir` 457 | 458 | ```python 459 | # Determine whether the specified path represents an existing directory. 460 | value = fsutil.is_dir(path) 461 | ``` 462 | 463 | #### `is_empty` 464 | 465 | ```python 466 | # Determine whether the specified path represents an empty directory or an empty file. 467 | value = fsutil.is_empty(path) 468 | ``` 469 | 470 | #### `is_empty_dir` 471 | 472 | ```python 473 | # Determine whether the specified path represents an empty directory. 474 | value = fsutil.is_empty_dir(path) 475 | ``` 476 | 477 | #### `is_empty_file` 478 | 479 | ```python 480 | # Determine whether the specified path represents an empty file. 481 | value = fsutil.is_empty_file(path) 482 | ``` 483 | 484 | #### `is_file` 485 | 486 | ```python 487 | # Determine whether the specified path represents an existing file. 488 | value = fsutil.is_file(path) 489 | ``` 490 | 491 | #### `join_filename` 492 | 493 | ```python 494 | # Create a filename joining the file basename and the extension. 495 | filename = fsutil.join_filename(basename, extension) 496 | ``` 497 | 498 | #### `join_filepath` 499 | 500 | ```python 501 | # Create a filepath joining the directory path and the filename. 502 | filepath = fsutil.join_filepath(dirpath, filename) 503 | ``` 504 | 505 | #### `join_path` 506 | 507 | ```python 508 | # Create a path joining path and paths. 509 | # If path is __file__ (or a .py file), the resulting path will be relative 510 | # to the directory path of the module in which it's used. 511 | path = fsutil.join_path(path, *paths) 512 | ``` 513 | 514 | #### `list_dirs` 515 | 516 | ```python 517 | # List all directories contained at the given directory path. 518 | dirs = fsutil.list_dirs(path) 519 | ``` 520 | 521 | #### `list_files` 522 | 523 | ```python 524 | # List all files contained at the given directory path. 525 | files = fsutil.list_files(path) 526 | ``` 527 | 528 | #### `make_dirs` 529 | 530 | ```python 531 | # Create the directories needed to ensure that the given path exists. 532 | # If a file already exists at the given path an OSError is raised. 533 | fsutil.make_dirs(path) 534 | ``` 535 | 536 | #### `make_dirs_for_file` 537 | 538 | ```python 539 | # Create the directories needed to ensure that the given path exists. 540 | # If a directory already exists at the given path an OSError is raised. 541 | fsutil.make_dirs_for_file(path) 542 | ``` 543 | 544 | #### `move_dir` 545 | 546 | ```python 547 | # Move an existing dir from path to dest directory. 548 | # If overwrite is not allowed and dest path exists, an OSError is raised. 549 | # More informations about kwargs supported options here: 550 | # https://docs.python.org/3/library/shutil.html#shutil.move 551 | fsutil.move_dir(path, dest, overwrite=False, **kwargs) 552 | ``` 553 | 554 | #### `move_file` 555 | 556 | ```python 557 | # Move an existing file from path to dest directory. 558 | # If overwrite is not allowed and dest path exists, an OSError is raised. 559 | # More informations about kwargs supported options here: 560 | # https://docs.python.org/3/library/shutil.html#shutil.move 561 | fsutil.move_file(path, dest, overwrite=False, **kwargs) 562 | ``` 563 | 564 | #### `read_file` 565 | 566 | ```python 567 | # Read the content of the file at the given path using the specified encoding. 568 | content = fsutil.read_file(path, encoding="utf-8") 569 | ``` 570 | 571 | #### `read_file_from_url` 572 | 573 | ```python 574 | # Read the content of the file at the given url. 575 | content = fsutil.read_file_from_url(url, **kwargs) 576 | ``` 577 | 578 | #### `read_file_json` 579 | 580 | ```python 581 | # Read and decode a json encoded file at the given path. 582 | data = fsutil.read_file_json(path, cls=None, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, object_pairs_hook=None) 583 | ``` 584 | 585 | #### `read_file_lines` 586 | 587 | ```python 588 | # Read file content lines. 589 | # It is possible to specify the line indexes (negative indexes too), 590 | # very useful especially when reading large files. 591 | content = fsutil.read_file_lines(path, line_start=0, line_end=-1, strip_white=True, skip_empty=True, encoding="utf-8") 592 | ``` 593 | 594 | #### `read_file_lines_count` 595 | 596 | ```python 597 | # Read file lines count. 598 | lines_count = fsutil.read_file_lines_count(path) 599 | ``` 600 | 601 | #### `remove_dir` 602 | 603 | ```python 604 | # Remove a directory at the given path and all its content. 605 | # If the directory is removed with success returns True, otherwise False. 606 | # More informations about kwargs supported options here: 607 | # https://docs.python.org/3/library/shutil.html#shutil.rmtree 608 | fsutil.remove_dir(path, **kwargs) 609 | ``` 610 | 611 | #### `remove_dir_content` 612 | 613 | ```python 614 | # Removes all directory content (both sub-directories and files). 615 | fsutil.remove_dir_content(path) 616 | ``` 617 | 618 | #### `remove_dirs` 619 | 620 | ```python 621 | # Remove multiple directories at the given paths and all their content. 622 | fsutil.remove_dirs(*paths) 623 | ``` 624 | 625 | #### `remove_file` 626 | 627 | ```python 628 | # Remove a file at the given path. 629 | # If the file is removed with success returns True, otherwise False. 630 | fsutil.remove_file(path) 631 | ``` 632 | 633 | #### `remove_files` 634 | 635 | ```python 636 | # Remove multiple files at the given paths. 637 | fsutil.remove_files(*paths) 638 | ``` 639 | 640 | #### `rename_dir` 641 | 642 | ```python 643 | # Rename a directory with the given name. 644 | # If a directory or a file with the given name already exists, an OSError is raised. 645 | fsutil.rename_dir(path, name) 646 | ``` 647 | 648 | #### `rename_file` 649 | 650 | ```python 651 | # Rename a file with the given name. 652 | # If a directory or a file with the given name already exists, an OSError is raised. 653 | fsutil.rename_file(path, name) 654 | ``` 655 | 656 | #### `rename_file_basename` 657 | 658 | ```python 659 | # Rename a file basename with the given basename. 660 | fsutil.rename_file_basename(path, basename) 661 | ``` 662 | 663 | #### `rename_file_extension` 664 | 665 | ```python 666 | # Rename a file extension with the given extension. 667 | fsutil.rename_file_extension(path, extension) 668 | ``` 669 | 670 | #### `replace_dir` 671 | 672 | ```python 673 | # Replace directory at the specified path with the directory located at src. 674 | # If autodelete, the src directory will be removed at the end of the operation. 675 | # Optimized for large directories. 676 | fsutil.replace_dir(path, src, autodelete=False) 677 | ``` 678 | 679 | #### `replace_file` 680 | 681 | ```python 682 | # Replace file at the specified path with the file located at src. 683 | # If autodelete, the src file will be removed at the end of the operation. 684 | # Optimized for large files. 685 | fsutil.replace_file(path, src, autodelete=False) 686 | ``` 687 | 688 | #### `search_dirs` 689 | 690 | ```python 691 | # Search for directories at path matching the given pattern. 692 | dirs = fsutil.search_dirs(path, pattern="**/*") 693 | ``` 694 | 695 | #### `search_files` 696 | 697 | ```python 698 | # Search for files at path matching the given pattern. 699 | files = fsutil.search_files(path, pattern="**/*.*") 700 | ``` 701 | 702 | #### `set_permissions` 703 | 704 | ```python 705 | # Set the file/directory permissions. 706 | fsutil.set_permissions(path, 700) 707 | ``` 708 | 709 | #### `split_filename` 710 | 711 | ```python 712 | # Split a filename and returns its basename and extension. 713 | basename, extension = fsutil.split_filename(path) 714 | ``` 715 | 716 | #### `split_filepath` 717 | 718 | ```python 719 | # Split a filename and returns its directory-path and filename. 720 | dirpath, filename = fsutil.split_filepath(path) 721 | ``` 722 | 723 | #### `split_path` 724 | 725 | ```python 726 | # Split a path and returns its path-names. 727 | path_names = fsutil.split_path(path) 728 | ``` 729 | 730 | #### `transform_filepath` 731 | 732 | ```python 733 | # Trasform a filepath by applying the provided optional changes. 734 | filepath = fsutil.transform_filepath(path, dirpath=None, basename=lambda b: slugify(b), extension="webp") 735 | ``` 736 | 737 | #### `write_file` 738 | 739 | ```python 740 | # Write file with the specified content at the given path. 741 | fsutil.write_file(path, content, append=False, encoding="utf-8", atomic=False) 742 | ``` 743 | 744 | #### `write_file_json` 745 | 746 | ```python 747 | # Write a json file at the given path with the specified data encoded in json format. 748 | fsutil.write_file_json(path, data, encoding="utf-8", atomic=False, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None, indent=None, separators=None, default=None, sort_keys=False) 749 | ``` 750 | 751 | ## Testing 752 | ```bash 753 | # clone repository 754 | git clone https://github.com/fabiocaccamo/python-fsutil.git && cd python-fsutil 755 | 756 | # create virtualenv and activate it 757 | python -m venv venv && . venv/bin/activate 758 | 759 | # upgrade pip 760 | python -m pip install --upgrade pip 761 | 762 | # install requirements 763 | python -m pip install -r requirements.txt -r requirements-test.txt 764 | 765 | # install pre-commit to run formatters and linters 766 | pre-commit install --install-hooks 767 | 768 | # run tests using tox 769 | tox 770 | 771 | # or run tests using pytest 772 | pytest 773 | ``` 774 | 775 | ## License 776 | Released under [MIT License](LICENSE.txt). 777 | 778 | --- 779 | 780 | ## Supporting 781 | 782 | - :star: Star this project on [GitHub](https://github.com/fabiocaccamo/python-fsutil) 783 | - :octocat: Follow me on [GitHub](https://github.com/fabiocaccamo) 784 | - :blue_heart: Follow me on [Twitter](https://twitter.com/fabiocaccamo) 785 | - :moneybag: Sponsor me on [Github](https://github.com/sponsors/fabiocaccamo) 786 | 787 | ## See also 788 | 789 | - [`python-benedict`](https://github.com/fabiocaccamo/python-benedict) - dict subclass with keylist/keypath support, I/O shortcuts (base64, csv, json, pickle, plist, query-string, toml, xml, yaml) and many utilities. 📘 790 | 791 | - [`python-fontbro`](https://github.com/fabiocaccamo/python-fontbro) - friendly font operations on top of fontTools. 🧢 792 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Keep this library updated to the latest version. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | latest | :white_check_mark: | 10 | | oldest | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Open an issue. 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "python-fsutil" 7 | description = "high-level file-system operations for lazy devs." 8 | authors = [ 9 | { name = "Fabio Caccamo", email = "fabio.caccamo@gmail.com" }, 10 | ] 11 | keywords = [ 12 | "python", 13 | "file", 14 | "system", 15 | "util", 16 | "utils", 17 | "utility", 18 | "utilities", 19 | "dir", 20 | "directory", 21 | "path", 22 | "fs", 23 | "os", 24 | "shutil", 25 | "glob", 26 | ] 27 | classifiers = [ 28 | "Development Status :: 5 - Production/Stable", 29 | "Environment :: MacOS X", 30 | "Environment :: Web Environment", 31 | "Intended Audience :: Developers", 32 | "Intended Audience :: System Administrators", 33 | "License :: OSI Approved :: MIT License", 34 | "Natural Language :: English", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python :: 3", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: 3.12", 40 | "Programming Language :: Python :: 3.13", 41 | "Programming Language :: Python :: Implementation :: PyPy", 42 | "Topic :: Desktop Environment :: File Managers", 43 | "Topic :: Software Development :: Build Tools", 44 | "Topic :: Software Development :: Libraries :: Python Modules", 45 | "Topic :: System :: Filesystems", 46 | "Topic :: Utilities", 47 | ] 48 | dynamic = ["version"] 49 | maintainers = [ 50 | { name = "Fabio Caccamo", email = "fabio.caccamo@gmail.com" }, 51 | ] 52 | 53 | [project.readme] 54 | file = "README.md" 55 | content-type = "text/markdown" 56 | 57 | [project.license] 58 | file = "LICENSE.txt" 59 | content-type = "text/plain" 60 | 61 | [project.urls] 62 | Homepage = "https://github.com/fabiocaccamo/python-fsutil" 63 | Download = "https://github.com/fabiocaccamo/python-fsutil/releases" 64 | Documentation = "https://github.com/fabiocaccamo/python-fsutil#readme" 65 | Issues = "https://github.com/fabiocaccamo/python-fsutil/issues" 66 | Funding = "https://github.com/sponsors/fabiocaccamo/" 67 | Twitter = "https://twitter.com/fabiocaccamo" 68 | 69 | [tool.black] 70 | line-length = 88 71 | include = '\.pyi?$' 72 | exclude = ''' 73 | /( 74 | \.git 75 | | \.hg 76 | | \.mypy_cache 77 | | \.tox 78 | | \.venv 79 | | _build 80 | | buck-out 81 | | build 82 | | dist 83 | | venv 84 | )/ 85 | ''' 86 | 87 | [tool.mypy] 88 | files = ["src"] 89 | disable_error_code = "import-untyped" 90 | ignore_missing_imports = true 91 | install_types = true 92 | non_interactive = true 93 | strict = true 94 | 95 | [tool.pytest.ini_options] 96 | pythonpath = "src" 97 | addopts = "-v" 98 | testpaths = ["tests"] 99 | 100 | [tool.ruff] 101 | line-length = 88 102 | 103 | [tool.ruff.lint] 104 | ignore = [] 105 | select = ["B", "B9", "C", "E", "F", "I", "W"] 106 | 107 | [tool.ruff.lint.mccabe] 108 | max-complexity = 10 109 | 110 | [tool.setuptools] 111 | package-dir = {"" = "src"} 112 | packages = ["fsutil"] 113 | 114 | [tool.setuptools.dynamic.version] 115 | attr = "fsutil.metadata.__version__" 116 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | coverage == 7.6.* 2 | mypy == 1.15.* 3 | pre-commit == 4.1.* 4 | pytest == 8.3.* 5 | pytest-cov == 6.0.* 6 | requests == 2.32.* 7 | tox == 4.24.* 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabiocaccamo/python-fsutil/8a3a509c2d605d509f9eb32e7e2401078dac116c/requirements.txt -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /src/fsutil/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fsutil.archives import ( 4 | create_tar_file, 5 | create_zip_file, 6 | extract_tar_file, 7 | extract_zip_file, 8 | ) 9 | from fsutil.checks import ( 10 | assert_dir, 11 | assert_exists, 12 | assert_file, 13 | assert_not_dir, 14 | assert_not_exists, 15 | assert_not_file, 16 | exists, 17 | is_dir, 18 | is_empty, 19 | is_empty_dir, 20 | is_empty_file, 21 | is_file, 22 | ) 23 | from fsutil.converters import convert_size_bytes_to_string, convert_size_string_to_bytes 24 | from fsutil.info import ( 25 | get_dir_creation_date, 26 | get_dir_creation_date_formatted, 27 | get_dir_hash, 28 | get_dir_last_modified_date, 29 | get_dir_last_modified_date_formatted, 30 | get_dir_size, 31 | get_dir_size_formatted, 32 | get_file_creation_date, 33 | get_file_creation_date_formatted, 34 | get_file_hash, 35 | get_file_last_modified_date, 36 | get_file_last_modified_date_formatted, 37 | get_file_size, 38 | get_file_size_formatted, 39 | ) 40 | from fsutil.io import ( 41 | read_file, 42 | read_file_from_url, 43 | read_file_json, 44 | read_file_lines, 45 | read_file_lines_count, 46 | write_file, 47 | write_file_json, 48 | ) 49 | from fsutil.metadata import ( 50 | __author__, 51 | __copyright__, 52 | __description__, 53 | __email__, 54 | __license__, 55 | __title__, 56 | __version__, 57 | ) 58 | from fsutil.operations import ( 59 | clean_dir, 60 | copy_dir, 61 | copy_dir_content, 62 | copy_file, 63 | create_dir, 64 | create_file, 65 | delete_dir, 66 | delete_dir_content, 67 | delete_dirs, 68 | delete_file, 69 | delete_files, 70 | download_file, 71 | list_dirs, 72 | list_files, 73 | make_dirs, 74 | make_dirs_for_file, 75 | move_dir, 76 | move_file, 77 | remove_dir, 78 | remove_dir_content, 79 | remove_dirs, 80 | remove_file, 81 | remove_files, 82 | rename_dir, 83 | rename_file, 84 | rename_file_basename, 85 | rename_file_extension, 86 | replace_dir, 87 | replace_file, 88 | search_dirs, 89 | search_files, 90 | ) 91 | from fsutil.paths import ( 92 | get_file_basename, 93 | get_file_extension, 94 | get_filename, 95 | get_parent_dir, 96 | get_unique_name, 97 | join_filename, 98 | join_filepath, 99 | join_path, 100 | split_filename, 101 | split_filepath, 102 | split_path, 103 | transform_filepath, 104 | ) 105 | from fsutil.perms import get_permissions, set_permissions 106 | 107 | __all__ = [ 108 | "__author__", 109 | "__copyright__", 110 | "__description__", 111 | "__email__", 112 | "__license__", 113 | "__title__", 114 | "__version__", 115 | "assert_dir", 116 | "assert_exists", 117 | "assert_file", 118 | "assert_not_dir", 119 | "assert_not_exists", 120 | "assert_not_file", 121 | "clean_dir", 122 | "convert_size_bytes_to_string", 123 | "convert_size_string_to_bytes", 124 | "copy_dir", 125 | "copy_dir_content", 126 | "copy_file", 127 | "create_dir", 128 | "create_file", 129 | "create_tar_file", 130 | "create_zip_file", 131 | "delete_dir", 132 | "delete_dir_content", 133 | "delete_dirs", 134 | "delete_file", 135 | "delete_files", 136 | "download_file", 137 | "exists", 138 | "extract_tar_file", 139 | "extract_zip_file", 140 | "get_dir_creation_date", 141 | "get_dir_creation_date_formatted", 142 | "get_dir_hash", 143 | "get_dir_last_modified_date", 144 | "get_dir_last_modified_date_formatted", 145 | "get_dir_size", 146 | "get_dir_size_formatted", 147 | "get_file_basename", 148 | "get_file_creation_date", 149 | "get_file_creation_date_formatted", 150 | "get_file_extension", 151 | "get_file_hash", 152 | "get_file_last_modified_date", 153 | "get_file_last_modified_date_formatted", 154 | "get_file_size", 155 | "get_file_size_formatted", 156 | "get_filename", 157 | "get_parent_dir", 158 | "get_permissions", 159 | "get_unique_name", 160 | "is_dir", 161 | "is_empty", 162 | "is_empty_dir", 163 | "is_empty_file", 164 | "is_file", 165 | "join_filename", 166 | "join_filepath", 167 | "join_path", 168 | "list_dirs", 169 | "list_files", 170 | "make_dirs", 171 | "make_dirs_for_file", 172 | "move_dir", 173 | "move_file", 174 | "read_file", 175 | "read_file_from_url", 176 | "read_file_json", 177 | "read_file_lines", 178 | "read_file_lines_count", 179 | "remove_dir", 180 | "remove_dir_content", 181 | "remove_dirs", 182 | "remove_file", 183 | "remove_files", 184 | "rename_dir", 185 | "rename_file", 186 | "rename_file_basename", 187 | "rename_file_extension", 188 | "replace_dir", 189 | "replace_file", 190 | "search_dirs", 191 | "search_files", 192 | "set_permissions", 193 | "split_filename", 194 | "split_filepath", 195 | "split_path", 196 | "transform_filepath", 197 | "write_file", 198 | "write_file_json", 199 | ] 200 | -------------------------------------------------------------------------------- /src/fsutil/archives.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | import tarfile 6 | import zipfile 7 | from collections.abc import Callable, Iterable 8 | from typing import Literal 9 | 10 | from fsutil.args import get_path as _get_path 11 | from fsutil.checks import ( 12 | assert_exists, 13 | assert_file, 14 | assert_not_dir, 15 | assert_not_exists, 16 | assert_not_file, 17 | is_dir, 18 | is_file, 19 | ) 20 | from fsutil.operations import make_dirs, make_dirs_for_file, remove_file 21 | from fsutil.paths import get_filename, join_path 22 | from fsutil.types import PathIn 23 | 24 | 25 | def create_tar_file( 26 | path: PathIn, 27 | content_paths: list[PathIn], 28 | *, 29 | overwrite: bool = True, 30 | compression: str = "", # literal: gz, bz2, xz 31 | ) -> None: 32 | """ 33 | Create tar file at path compressing directories/files listed in content_paths. 34 | If overwrite is allowed and dest tar already exists, it will be overwritten. 35 | """ 36 | path = _get_path(path) 37 | assert_not_dir(path) 38 | if not overwrite: 39 | assert_not_exists(path) 40 | make_dirs_for_file(path) 41 | 42 | def _write_content_to_tar_file( 43 | file: tarfile.TarFile, content_path: PathIn, basedir: str = "" 44 | ) -> None: 45 | path = _get_path(content_path) 46 | assert_exists(path) 47 | if is_file(path): 48 | filename = get_filename(path) 49 | filepath = join_path(basedir, filename) 50 | file.add(path, filepath) 51 | elif is_dir(path): 52 | for item_name in os.listdir(path): 53 | item_path = join_path(path, item_name) 54 | item_basedir = ( 55 | join_path(basedir, item_name) if is_dir(item_path) else basedir 56 | ) 57 | _write_content_to_tar_file(file, item_path, item_basedir) 58 | 59 | mode = f"w:{compression}" if compression else "w" 60 | with tarfile.open(path, mode=mode) as file: # type: ignore 61 | for content_path in content_paths: 62 | _write_content_to_tar_file(file, content_path) 63 | 64 | 65 | def create_zip_file( 66 | path: PathIn, 67 | content_paths: list[PathIn], 68 | *, 69 | overwrite: bool = True, 70 | compression: int = zipfile.ZIP_DEFLATED, 71 | ) -> None: 72 | """ 73 | Create zip file at path compressing directories/files listed in content_paths. 74 | If overwrite is allowed and dest zip already exists, it will be overwritten. 75 | """ 76 | path = _get_path(path) 77 | assert_not_dir(path) 78 | if not overwrite: 79 | assert_not_exists(path) 80 | make_dirs_for_file(path) 81 | 82 | def _write_content_to_zip_file( 83 | file: zipfile.ZipFile, content_path: PathIn, basedir: str = "" 84 | ) -> None: 85 | path = _get_path(content_path) 86 | assert_exists(path) 87 | if is_file(path): 88 | filename = get_filename(path) 89 | filepath = join_path(basedir, filename) 90 | file.write(path, filepath) 91 | elif is_dir(path): 92 | for item_name in os.listdir(path): 93 | item_path = join_path(path, item_name) 94 | item_basedir = ( 95 | join_path(basedir, item_name) if is_dir(item_path) else basedir 96 | ) 97 | _write_content_to_zip_file(file, item_path, item_basedir) 98 | 99 | with zipfile.ZipFile(path, "w", compression) as file: 100 | for content_path in content_paths: 101 | _write_content_to_zip_file(file, content_path) 102 | 103 | 104 | def extract_tar_file( 105 | path: PathIn, 106 | dest: PathIn, 107 | *, 108 | autodelete: bool = False, 109 | content_paths: Iterable[tarfile.TarInfo] | None = None, 110 | filter: ( 111 | Callable[[tarfile.TarInfo, str], tarfile.TarInfo | None] 112 | | Literal["fully_trusted", "tar", "data"] 113 | ) 114 | | None = None, 115 | ) -> None: 116 | """ 117 | Extract tar file at path to dest path. 118 | If autodelete, the archive will be deleted after extraction. 119 | If content_paths list is defined, 120 | only listed items will be extracted, otherwise all. 121 | """ 122 | path = _get_path(path) 123 | dest = _get_path(dest) 124 | assert_file(path) 125 | assert_not_file(dest) 126 | make_dirs(dest) 127 | with tarfile.TarFile(path, "r") as file: 128 | if sys.version_info < (3, 12): 129 | file.extractall(dest, members=content_paths) 130 | else: 131 | # https://docs.python.org/3/library/tarfile.html#tarfile-extraction-filter 132 | file.extractall( 133 | dest, 134 | members=content_paths, 135 | numeric_owner=False, 136 | filter=(filter or "data"), 137 | ) 138 | if autodelete: 139 | remove_file(path) 140 | 141 | 142 | def extract_zip_file( 143 | path: PathIn, 144 | dest: PathIn, 145 | *, 146 | autodelete: bool = False, 147 | content_paths: Iterable[str | zipfile.ZipInfo] | None = None, 148 | ) -> None: 149 | """ 150 | Extract zip file at path to dest path. 151 | If autodelete, the archive will be deleted after extraction. 152 | If content_paths list is defined, 153 | only listed items will be extracted, otherwise all. 154 | """ 155 | path = _get_path(path) 156 | dest = _get_path(dest) 157 | assert_file(path) 158 | assert_not_file(dest) 159 | make_dirs(dest) 160 | with zipfile.ZipFile(path, "r") as file: 161 | file.extractall(dest, members=content_paths) 162 | if autodelete: 163 | remove_file(path) 164 | -------------------------------------------------------------------------------- /src/fsutil/args.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from fsutil.types import PathIn 6 | 7 | 8 | def get_path(path: PathIn) -> str: 9 | if path is None: 10 | return None 11 | if isinstance(path, str): 12 | return os.path.normpath(path) 13 | return str(path) 14 | -------------------------------------------------------------------------------- /src/fsutil/checks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from fsutil.args import get_path as _get_path 6 | from fsutil.types import PathIn 7 | 8 | 9 | def assert_dir(path: PathIn) -> None: 10 | """ 11 | Raise an OSError if the given path doesn't exist or it is not a directory. 12 | """ 13 | path = _get_path(path) 14 | if not is_dir(path): 15 | raise OSError(f"Invalid directory path: {path}") 16 | 17 | 18 | def assert_exists(path: PathIn) -> None: 19 | """ 20 | Raise an OSError if the given path doesn't exist. 21 | """ 22 | path = _get_path(path) 23 | if not exists(path): 24 | raise OSError(f"Invalid item path: {path}") 25 | 26 | 27 | def assert_file(path: PathIn) -> None: 28 | """ 29 | Raise an OSError if the given path doesn't exist or it is not a file. 30 | """ 31 | path = _get_path(path) 32 | if not is_file(path): 33 | raise OSError(f"Invalid file path: {path}") 34 | 35 | 36 | def assert_not_dir(path: PathIn) -> None: 37 | """ 38 | Raise an OSError if the given path is an existing directory. 39 | """ 40 | path = _get_path(path) 41 | if is_dir(path): 42 | raise OSError(f"Invalid path, directory already exists: {path}") 43 | 44 | 45 | def assert_not_exists(path: PathIn) -> None: 46 | """ 47 | Raise an OSError if the given path already exists. 48 | """ 49 | path = _get_path(path) 50 | if exists(path): 51 | raise OSError(f"Invalid path, item already exists: {path}") 52 | 53 | 54 | def assert_not_file(path: PathIn) -> None: 55 | """ 56 | Raise an OSError if the given path is an existing file. 57 | """ 58 | path = _get_path(path) 59 | if is_file(path): 60 | raise OSError(f"Invalid path, file already exists: {path}") 61 | 62 | 63 | def exists(path: PathIn) -> bool: 64 | """ 65 | Check if a directory of a file exists at the given path. 66 | """ 67 | path = _get_path(path) 68 | return os.path.exists(path) 69 | 70 | 71 | def is_dir(path: PathIn) -> bool: 72 | """ 73 | Determine whether the specified path represents an existing directory. 74 | """ 75 | path = _get_path(path) 76 | return os.path.isdir(path) 77 | 78 | 79 | def is_empty(path: PathIn) -> bool: 80 | """ 81 | Determine whether the specified path represents an empty directory or an empty file. 82 | """ 83 | path = _get_path(path) 84 | assert_exists(path) 85 | if is_dir(path): 86 | return is_empty_dir(path) 87 | return is_empty_file(path) 88 | 89 | 90 | def is_empty_dir(path: PathIn) -> bool: 91 | """ 92 | Determine whether the specified path represents an empty directory. 93 | """ 94 | path = _get_path(path) 95 | assert_dir(path) 96 | return len(os.listdir(path)) == 0 97 | 98 | 99 | def is_empty_file(path: PathIn) -> bool: 100 | """ 101 | Determine whether the specified path represents an empty file. 102 | """ 103 | from fsutil.info import get_file_size 104 | 105 | path = _get_path(path) 106 | return get_file_size(path) == 0 107 | 108 | 109 | def is_file(path: PathIn) -> bool: 110 | """ 111 | Determine whether the specified path represents an existing file. 112 | """ 113 | path = _get_path(path) 114 | return os.path.isfile(path) 115 | -------------------------------------------------------------------------------- /src/fsutil/converters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | SIZE_UNITS = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] 4 | 5 | 6 | def convert_size_bytes_to_string(size: int) -> str: 7 | """ 8 | Convert the given size bytes to string using the right unit suffix. 9 | """ 10 | size_num = float(size) 11 | units = SIZE_UNITS 12 | factor = 0 13 | factor_limit = len(units) - 1 14 | while (size_num >= 1024) and (factor <= factor_limit): 15 | size_num /= 1024 16 | factor += 1 17 | size_units = units[factor] 18 | size_str = f"{size_num:.2f}" if (factor > 1) else f"{size_num:.0f}" 19 | size_str = f"{size_str} {size_units}" 20 | return size_str 21 | 22 | 23 | def convert_size_string_to_bytes(size: str) -> float | int: 24 | """ 25 | Convert the given size string to bytes. 26 | """ 27 | units = [item.lower() for item in SIZE_UNITS] 28 | parts = size.strip().replace(" ", " ").split(" ") 29 | amount = float(parts[0]) 30 | unit = parts[1] 31 | factor = units.index(unit.lower()) 32 | if not factor: 33 | return amount 34 | return int((1024**factor) * amount) 35 | -------------------------------------------------------------------------------- /src/fsutil/deps.py: -------------------------------------------------------------------------------- 1 | from types import ModuleType 2 | 3 | 4 | def require_requests() -> ModuleType: 5 | try: 6 | import requests 7 | 8 | return requests 9 | except ImportError as error: 10 | raise ModuleNotFoundError( 11 | "'requests' module is not installed, " 12 | "it can be installed by running: 'pip install requests'" 13 | ) from error 14 | -------------------------------------------------------------------------------- /src/fsutil/info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import os 5 | from datetime import datetime 6 | 7 | from fsutil.args import get_path as _get_path 8 | from fsutil.checks import assert_dir, assert_file 9 | from fsutil.converters import convert_size_bytes_to_string 10 | from fsutil.operations import search_files 11 | from fsutil.types import PathIn 12 | 13 | 14 | def get_dir_creation_date(path: PathIn) -> datetime: 15 | """ 16 | Get the directory creation date. 17 | """ 18 | path = _get_path(path) 19 | assert_dir(path) 20 | creation_timestamp = os.path.getctime(path) 21 | creation_date = datetime.fromtimestamp(creation_timestamp) 22 | return creation_date 23 | 24 | 25 | def get_dir_creation_date_formatted( 26 | path: PathIn, *, format: str = "%Y-%m-%d %H:%M:%S" 27 | ) -> str: 28 | """ 29 | Get the directory creation date formatted using the given format. 30 | """ 31 | path = _get_path(path) 32 | date = get_dir_creation_date(path) 33 | return date.strftime(format) 34 | 35 | 36 | def get_dir_hash(path: PathIn, *, func: str = "md5") -> str: 37 | """ 38 | Get the hash of the directory at the given path using 39 | the specified algorithm function (md5 by default). 40 | """ 41 | path = _get_path(path) 42 | assert_dir(path) 43 | hash_ = hashlib.new(func) 44 | files = search_files(path) 45 | for file in sorted(files): 46 | file_hash = get_file_hash(file, func=func) 47 | file_hash_b = bytes(file_hash, "utf-8") 48 | hash_.update(file_hash_b) 49 | hash_hex = hash_.hexdigest() 50 | return hash_hex 51 | 52 | 53 | def get_dir_last_modified_date(path: PathIn) -> datetime: 54 | """ 55 | Get the directory last modification date. 56 | """ 57 | path = _get_path(path) 58 | assert_dir(path) 59 | last_modified_timestamp = os.path.getmtime(path) 60 | for basepath, dirnames, filenames in os.walk(path): 61 | for dirname in dirnames: 62 | dirpath = os.path.join(basepath, dirname) 63 | last_modified_timestamp = max( 64 | last_modified_timestamp, os.path.getmtime(dirpath) 65 | ) 66 | for filename in filenames: 67 | filepath = os.path.join(basepath, filename) 68 | last_modified_timestamp = max( 69 | last_modified_timestamp, os.path.getmtime(filepath) 70 | ) 71 | last_modified_date = datetime.fromtimestamp(last_modified_timestamp) 72 | return last_modified_date 73 | 74 | 75 | def get_dir_last_modified_date_formatted( 76 | path: PathIn, *, format: str = "%Y-%m-%d %H:%M:%S" 77 | ) -> str: 78 | """ 79 | Get the directory last modification date formatted using the given format. 80 | """ 81 | path = _get_path(path) 82 | date = get_dir_last_modified_date(path) 83 | return date.strftime(format) 84 | 85 | 86 | def get_dir_size(path: PathIn) -> int: 87 | """ 88 | Get the directory size in bytes. 89 | """ 90 | path = _get_path(path) 91 | assert_dir(path) 92 | size = 0 93 | for basepath, _, filenames in os.walk(path): 94 | for filename in filenames: 95 | filepath = os.path.join(basepath, filename) 96 | if not os.path.islink(filepath): 97 | size += get_file_size(filepath) 98 | return size 99 | 100 | 101 | def get_dir_size_formatted(path: PathIn) -> str: 102 | """ 103 | Get the directory size formatted using the right unit suffix. 104 | """ 105 | size = get_dir_size(path) 106 | size_formatted = convert_size_bytes_to_string(size) 107 | return size_formatted 108 | 109 | 110 | def get_file_creation_date(path: PathIn) -> datetime: 111 | """ 112 | Get the file creation date. 113 | """ 114 | path = _get_path(path) 115 | assert_file(path) 116 | creation_timestamp = os.path.getctime(path) 117 | creation_date = datetime.fromtimestamp(creation_timestamp) 118 | return creation_date 119 | 120 | 121 | def get_file_creation_date_formatted( 122 | path: PathIn, *, format: str = "%Y-%m-%d %H:%M:%S" 123 | ) -> str: 124 | """ 125 | Get the file creation date formatted using the given format. 126 | """ 127 | path = _get_path(path) 128 | date = get_file_creation_date(path) 129 | return date.strftime(format) 130 | 131 | 132 | def get_file_hash(path: PathIn, *, func: str = "md5") -> str: 133 | """ 134 | Get the hash of the file at the given path using 135 | the specified algorithm function (md5 by default). 136 | """ 137 | path = _get_path(path) 138 | assert_file(path) 139 | hash = hashlib.new(func) 140 | with open(path, "rb") as file: 141 | for chunk in iter(lambda: file.read(4096), b""): 142 | hash.update(chunk) 143 | hash_hex = hash.hexdigest() 144 | return hash_hex 145 | 146 | 147 | def get_file_last_modified_date(path: PathIn) -> datetime: 148 | """ 149 | Get the file last modification date. 150 | """ 151 | path = _get_path(path) 152 | assert_file(path) 153 | last_modified_timestamp = os.path.getmtime(path) 154 | last_modified_date = datetime.fromtimestamp(last_modified_timestamp) 155 | return last_modified_date 156 | 157 | 158 | def get_file_last_modified_date_formatted( 159 | path: PathIn, *, format: str = "%Y-%m-%d %H:%M:%S" 160 | ) -> str: 161 | """ 162 | Get the file last modification date formatted using the given format. 163 | """ 164 | path = _get_path(path) 165 | date = get_file_last_modified_date(path) 166 | return date.strftime(format) 167 | 168 | 169 | def get_file_size(path: PathIn) -> int: 170 | """ 171 | Get the directory size in bytes. 172 | """ 173 | path = _get_path(path) 174 | assert_file(path) 175 | # size = os.stat(path).st_size 176 | size = os.path.getsize(path) 177 | return size 178 | 179 | 180 | def get_file_size_formatted(path: PathIn) -> str: 181 | """ 182 | Get the directory size formatted using the right unit suffix. 183 | """ 184 | path = _get_path(path) 185 | size = get_file_size(path) 186 | size_formatted = convert_size_bytes_to_string(size) 187 | return size_formatted 188 | -------------------------------------------------------------------------------- /src/fsutil/io.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | import platform 6 | import tempfile 7 | from collections.abc import Generator 8 | from datetime import datetime 9 | from typing import Any 10 | 11 | from fsutil.args import get_path as _get_path 12 | from fsutil.checks import assert_file, assert_not_dir, exists 13 | from fsutil.deps import require_requests 14 | from fsutil.operations import make_dirs_for_file, remove_file 15 | from fsutil.paths import split_filepath 16 | from fsutil.perms import get_permissions, set_permissions 17 | from fsutil.types import PathIn 18 | 19 | 20 | def read_file(path: PathIn, *, encoding: str = "utf-8") -> str: 21 | """ 22 | Read the content of the file at the given path using the specified encoding. 23 | """ 24 | path = _get_path(path) 25 | assert_file(path) 26 | content = "" 27 | with open(path, encoding=encoding) as file: 28 | content = file.read() 29 | return content 30 | 31 | 32 | def read_file_from_url(url: str, **kwargs: Any) -> str: 33 | """ 34 | Read the content of the file at the given url. 35 | """ 36 | requests = require_requests() 37 | response = requests.get(url, **kwargs) 38 | response.raise_for_status() 39 | content = str(response.text) 40 | return content 41 | 42 | 43 | def read_file_json(path: PathIn, **kwargs: Any) -> Any: 44 | """ 45 | Read and decode a json encoded file at the given path. 46 | """ 47 | path = _get_path(path) 48 | content = read_file(path) 49 | data = json.loads(content, **kwargs) 50 | return data 51 | 52 | 53 | def _read_file_lines_in_range( 54 | path: PathIn, 55 | *, 56 | line_start: int = 0, 57 | line_end: int = -1, 58 | encoding: str = "utf-8", 59 | ) -> Generator[str]: 60 | path = _get_path(path) 61 | line_start_negative = line_start < 0 62 | line_end_negative = line_end < 0 63 | if line_start_negative or line_end_negative: 64 | # pre-calculate lines count only if using negative line indexes 65 | lines_count = read_file_lines_count(path) 66 | # normalize negative indexes 67 | if line_start_negative: 68 | line_start = max(0, line_start + lines_count) 69 | if line_end_negative: 70 | line_end = min(line_end + lines_count, lines_count - 1) 71 | with open(path, "rb") as file: 72 | file.seek(0) 73 | line_index = 0 74 | for line in file: 75 | if line_index >= line_start and line_index <= line_end: 76 | yield line.decode(encoding) 77 | line_index += 1 78 | 79 | 80 | def read_file_lines( 81 | path: PathIn, 82 | *, 83 | line_start: int = 0, 84 | line_end: int = -1, 85 | strip_white: bool = True, 86 | skip_empty: bool = True, 87 | encoding: str = "utf-8", 88 | ) -> list[str]: 89 | """ 90 | Read file content lines. 91 | It is possible to specify the line indexes (negative indexes too), 92 | very useful especially when reading large files. 93 | """ 94 | path = _get_path(path) 95 | assert_file(path) 96 | if line_start == 0 and line_end == -1: 97 | content = read_file(path, encoding=encoding) 98 | lines = content.splitlines() 99 | else: 100 | lines = list( 101 | _read_file_lines_in_range( 102 | path, 103 | line_start=line_start, 104 | line_end=line_end, 105 | encoding=encoding, 106 | ) 107 | ) 108 | if strip_white: 109 | lines = [line.strip() for line in lines] 110 | if skip_empty: 111 | lines = [line for line in lines if line] 112 | return lines 113 | 114 | 115 | def read_file_lines_count(path: PathIn) -> int: 116 | """ 117 | Read file lines count. 118 | """ 119 | path = _get_path(path) 120 | assert_file(path) 121 | lines_count = 0 122 | with open(path, "rb") as file: 123 | file.seek(0) 124 | lines_count = sum(1 for line in file) 125 | return lines_count 126 | 127 | 128 | def _write_file_atomic( 129 | path: PathIn, 130 | content: str, 131 | *, 132 | append: bool = False, 133 | encoding: str = "utf-8", 134 | ) -> None: 135 | path = _get_path(path) 136 | mode = "a" if append else "w" 137 | if append: 138 | content = read_file(path, encoding=encoding) + content 139 | dirpath, _ = split_filepath(path) 140 | auto_delete_temp_file = False if platform.system() == "Windows" else True 141 | try: 142 | with tempfile.NamedTemporaryFile( 143 | mode=mode, 144 | dir=dirpath, 145 | delete=auto_delete_temp_file, 146 | # delete_on_close=False, # supported since Python >= 3.12 147 | encoding=encoding, 148 | ) as file: 149 | file.write(content) 150 | file.flush() 151 | os.fsync(file.fileno()) 152 | temp_path = file.name 153 | permissions = get_permissions(path) if exists(path) else None 154 | os.replace(temp_path, path) 155 | if permissions: 156 | set_permissions(path, permissions) 157 | except FileNotFoundError: 158 | # success - the NamedTemporaryFile has not been able 159 | # to remove the temp file on __exit__ because the temp file 160 | # has replaced atomically the file at path. 161 | pass 162 | finally: 163 | # attempt for fixing #121 (on Windows destroys created file on exit) 164 | # manually delete the temporary file if still exists 165 | if temp_path and exists(temp_path): 166 | remove_file(temp_path) 167 | 168 | 169 | def _write_file_non_atomic( 170 | path: PathIn, 171 | content: str, 172 | *, 173 | append: bool = False, 174 | encoding: str = "utf-8", 175 | ) -> None: 176 | mode = "a" if append else "w" 177 | with open(path, mode, encoding=encoding) as file: 178 | file.write(content) 179 | 180 | 181 | def write_file( 182 | path: PathIn, 183 | content: str, 184 | *, 185 | append: bool = False, 186 | encoding: str = "utf-8", 187 | atomic: bool = False, 188 | ) -> None: 189 | """ 190 | Write file with the specified content at the given path. 191 | """ 192 | path = _get_path(path) 193 | assert_not_dir(path) 194 | make_dirs_for_file(path) 195 | write_file_func = _write_file_atomic if atomic else _write_file_non_atomic 196 | write_file_func( 197 | path, 198 | content, 199 | append=append, 200 | encoding=encoding, 201 | ) 202 | 203 | 204 | def write_file_json( 205 | path: PathIn, 206 | data: Any, 207 | encoding: str = "utf-8", 208 | atomic: bool = False, 209 | **kwargs: Any, 210 | ) -> None: 211 | """ 212 | Write a json file at the given path with the specified data encoded in json format. 213 | """ 214 | path = _get_path(path) 215 | 216 | def default_encoder(obj: Any) -> Any: 217 | if isinstance(obj, datetime): 218 | return obj.isoformat() 219 | elif isinstance(obj, set): 220 | return list(obj) 221 | return str(obj) 222 | 223 | kwargs.setdefault("default", default_encoder) 224 | content = json.dumps(data, **kwargs) 225 | write_file( 226 | path, 227 | content, 228 | append=False, 229 | encoding=encoding, 230 | atomic=atomic, 231 | ) 232 | -------------------------------------------------------------------------------- /src/fsutil/metadata.py: -------------------------------------------------------------------------------- 1 | __author__ = "Fabio Caccamo" 2 | __copyright__ = "Copyright (c) 2020-present Fabio Caccamo" 3 | __description__ = "high-level file-system operations for lazy devs." 4 | __email__ = "fabio.caccamo@gmail.com" 5 | __license__ = "MIT" 6 | __title__ = "python-fsutil" 7 | __version__ = "0.15.0" 8 | -------------------------------------------------------------------------------- /src/fsutil/operations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import glob 4 | import os 5 | import re 6 | import shutil 7 | import tempfile 8 | import uuid 9 | from collections.abc import Callable 10 | from typing import Any 11 | 12 | from fsutil.args import get_path as _get_path 13 | from fsutil.checks import ( 14 | assert_dir, 15 | assert_file, 16 | assert_not_dir, 17 | assert_not_exists, 18 | assert_not_file, 19 | exists, 20 | is_dir, 21 | is_empty_dir, 22 | is_empty_file, 23 | is_file, 24 | ) 25 | from fsutil.deps import require_requests 26 | from fsutil.paths import ( 27 | get_file_basename, 28 | get_file_extension, 29 | get_filename, 30 | get_unique_name, 31 | join_filename, 32 | join_filepath, 33 | join_path, 34 | split_filename, 35 | split_filepath, 36 | ) 37 | from fsutil.types import PathIn 38 | 39 | 40 | def _clean_dir_empty_dirs(path: PathIn) -> None: 41 | path = _get_path(path) 42 | for basepath, dirnames, _ in os.walk(path, topdown=False): 43 | for dirname in dirnames: 44 | dirpath = os.path.join(basepath, dirname) 45 | if is_empty_dir(dirpath): 46 | remove_dir(dirpath) 47 | 48 | 49 | def _clean_dir_empty_files(path: PathIn) -> None: 50 | path = _get_path(path) 51 | for basepath, _, filenames in os.walk(path, topdown=False): 52 | for filename in filenames: 53 | filepath = os.path.join(basepath, filename) 54 | if is_empty_file(filepath): 55 | remove_file(filepath) 56 | 57 | 58 | def clean_dir(path: PathIn, *, dirs: bool = True, files: bool = True) -> None: 59 | """ 60 | Clean a directory by removing empty directories and/or empty files. 61 | """ 62 | path = _get_path(path) 63 | assert_dir(path) 64 | if files: 65 | _clean_dir_empty_files(path) 66 | if dirs: 67 | _clean_dir_empty_dirs(path) 68 | 69 | 70 | def copy_dir( 71 | path: PathIn, dest: PathIn, *, overwrite: bool = False, **kwargs: Any 72 | ) -> None: 73 | """ 74 | Copy the directory at the given path and all its content to dest path. 75 | If overwrite is not allowed and dest path exists, an OSError is raised. 76 | More informations about kwargs supported options here: 77 | https://docs.python.org/3/library/shutil.html#shutil.copytree 78 | """ 79 | path = _get_path(path) 80 | dest = _get_path(dest) 81 | assert_dir(path) 82 | dirname = os.path.basename(os.path.normpath(path)) 83 | dest = os.path.join(dest, dirname) 84 | assert_not_file(dest) 85 | if not overwrite: 86 | assert_not_exists(dest) 87 | copy_dir_content(path, dest, **kwargs) 88 | 89 | 90 | def copy_dir_content(path: PathIn, dest: PathIn, **kwargs: Any) -> None: 91 | """ 92 | Copy the content of the directory at the given path to dest path. 93 | More informations about kwargs supported options here: 94 | https://docs.python.org/3/library/shutil.html#shutil.copytree 95 | """ 96 | path = _get_path(path) 97 | dest = _get_path(dest) 98 | assert_dir(path) 99 | assert_not_file(dest) 100 | make_dirs(dest) 101 | kwargs.setdefault("dirs_exist_ok", True) 102 | shutil.copytree(path, dest, **kwargs) 103 | 104 | 105 | def copy_file( 106 | path: PathIn, dest: PathIn, *, overwrite: bool = False, **kwargs: Any 107 | ) -> None: 108 | """ 109 | Copy the file at the given path and its metadata to dest path. 110 | If overwrite is not allowed and dest path exists, an OSError is raised. 111 | More informations about kwargs supported options here: 112 | https://docs.python.org/3/library/shutil.html#shutil.copy2 113 | """ 114 | path = _get_path(path) 115 | dest = _get_path(dest) 116 | assert_file(path) 117 | assert_not_dir(dest) 118 | if not overwrite: 119 | assert_not_exists(dest) 120 | make_dirs_for_file(dest) 121 | shutil.copy2(path, dest, **kwargs) 122 | 123 | 124 | def create_dir(path: PathIn, *, overwrite: bool = False) -> None: 125 | """ 126 | Create directory at the given path. 127 | If overwrite is not allowed and path exists, an OSError is raised. 128 | """ 129 | path = _get_path(path) 130 | assert_not_file(path) 131 | if not overwrite: 132 | assert_not_exists(path) 133 | make_dirs(path) 134 | 135 | 136 | def create_file(path: PathIn, content: str = "", *, overwrite: bool = False) -> None: 137 | """ 138 | Create file with the specified content at the given path. 139 | If overwrite is not allowed and path exists, an OSError is raised. 140 | """ 141 | from fsutil.io import write_file 142 | 143 | path = _get_path(path) 144 | assert_not_dir(path) 145 | if not overwrite: 146 | assert_not_exists(path) 147 | write_file(path, content) 148 | 149 | 150 | def delete_dir(path: PathIn) -> bool: 151 | """ 152 | Alias for remove_dir. 153 | """ 154 | removed = remove_dir(path) 155 | return removed 156 | 157 | 158 | def delete_dir_content(path: PathIn) -> None: 159 | """ 160 | Alias for remove_dir_content. 161 | """ 162 | remove_dir_content(path) 163 | 164 | 165 | def delete_dirs(*paths: PathIn) -> None: 166 | """ 167 | Alias for remove_dirs. 168 | """ 169 | remove_dirs(*paths) 170 | 171 | 172 | def delete_file(path: PathIn) -> bool: 173 | """ 174 | Alias for remove_file. 175 | """ 176 | removed = remove_file(path) 177 | return removed 178 | 179 | 180 | def delete_files(*paths: PathIn) -> None: 181 | """ 182 | Alias for remove_files. 183 | """ 184 | remove_files(*paths) 185 | 186 | 187 | def download_file( 188 | url: str, 189 | *, 190 | dirpath: PathIn | None = None, 191 | filename: str | None = None, 192 | chunk_size: int = 8192, 193 | **kwargs: Any, 194 | ) -> str: 195 | """ 196 | Download a file from url to dirpath. 197 | If dirpath is not provided, the file will be downloaded to a temp directory. 198 | If filename is provided, the file will be named using filename. 199 | It is possible to pass extra request options 200 | (eg. for authentication) using **kwargs. 201 | """ 202 | requests = require_requests() 203 | # https://stackoverflow.com/a/16696317/2096218 204 | 205 | kwargs["stream"] = True 206 | with requests.get(url, **kwargs) as response: 207 | response.raise_for_status() 208 | 209 | # build filename 210 | if not filename: 211 | # detect filename from headers 212 | content_disposition = response.headers.get("content-disposition", "") or "" 213 | filename_pattern = r'filename="(.*)"' 214 | filename_match = re.search(filename_pattern, content_disposition) 215 | if filename_match: 216 | filename = filename_match.group(1) 217 | # or detect filename from url 218 | if not filename: 219 | filename = get_filename(url) 220 | # or fallback to a unique name 221 | if not filename: 222 | filename_uuid = str(uuid.uuid4()) 223 | filename = f"download-{filename_uuid}" 224 | 225 | # build filepath 226 | dirpath = dirpath or tempfile.gettempdir() 227 | dirpath = _get_path(dirpath) 228 | filepath = join_path(dirpath, filename) 229 | make_dirs_for_file(filepath) 230 | 231 | # write file to disk 232 | with open(filepath, "wb") as file: 233 | for chunk in response.iter_content(chunk_size=chunk_size): 234 | if chunk: 235 | file.write(chunk) 236 | return filepath 237 | 238 | 239 | def _filter_paths( 240 | basepath: str, 241 | relpaths: list[str], 242 | *, 243 | predicate: Callable[[str], bool] | None = None, 244 | ) -> list[str]: 245 | """ 246 | Filter paths relative to basepath according to the optional predicate function. 247 | If predicate is defined, paths are filtered using it, 248 | otherwise all paths will be listed. 249 | """ 250 | paths = [] 251 | for relpath in relpaths: 252 | abspath = os.path.join(basepath, relpath) 253 | if predicate is None or predicate(abspath): 254 | paths.append(abspath) 255 | paths.sort() 256 | return paths 257 | 258 | 259 | def list_dirs(path: PathIn) -> list[str]: 260 | """ 261 | List all directories contained at the given directory path. 262 | """ 263 | path = _get_path(path) 264 | return _filter_paths(path, os.listdir(path), predicate=is_dir) 265 | 266 | 267 | def list_files(path: PathIn) -> list[str]: 268 | """ 269 | List all files contained at the given directory path. 270 | """ 271 | path = _get_path(path) 272 | return _filter_paths(path, os.listdir(path), predicate=is_file) 273 | 274 | 275 | def make_dirs(path: PathIn) -> None: 276 | """ 277 | Create the directories needed to ensure that the given path exists. 278 | If a file already exists at the given path an OSError is raised. 279 | """ 280 | path = _get_path(path) 281 | if is_dir(path): 282 | return 283 | assert_not_file(path) 284 | os.makedirs(path, exist_ok=True) 285 | 286 | 287 | def make_dirs_for_file(path: PathIn) -> None: 288 | """ 289 | Create the directories needed to ensure that the given path exists. 290 | If a directory already exists at the given path an OSError is raised. 291 | """ 292 | path = _get_path(path) 293 | if is_file(path): 294 | return 295 | assert_not_dir(path) 296 | dirpath, _ = split_filepath(path) 297 | if dirpath: 298 | make_dirs(dirpath) 299 | 300 | 301 | def move_dir( 302 | path: PathIn, dest: PathIn, *, overwrite: bool = False, **kwargs: Any 303 | ) -> None: 304 | """ 305 | Move an existing dir from path to dest directory. 306 | If overwrite is not allowed and dest path exists, an OSError is raised. 307 | More informations about kwargs supported options here: 308 | https://docs.python.org/3/library/shutil.html#shutil.move 309 | """ 310 | path = _get_path(path) 311 | dest = _get_path(dest) 312 | assert_dir(path) 313 | assert_not_file(dest) 314 | if not overwrite: 315 | assert_not_exists(dest) 316 | make_dirs(dest) 317 | shutil.move(path, dest, **kwargs) 318 | 319 | 320 | def move_file( 321 | path: PathIn, dest: PathIn, *, overwrite: bool = False, **kwargs: Any 322 | ) -> None: 323 | """ 324 | Move an existing file from path to dest directory. 325 | If overwrite is not allowed and dest path exists, an OSError is raised. 326 | More informations about kwargs supported options here: 327 | https://docs.python.org/3/library/shutil.html#shutil.move 328 | """ 329 | path = _get_path(path) 330 | dest = _get_path(dest) 331 | assert_file(path) 332 | assert_not_file(dest) 333 | dest = os.path.join(dest, get_filename(path)) 334 | assert_not_dir(dest) 335 | if not overwrite: 336 | assert_not_exists(dest) 337 | make_dirs_for_file(dest) 338 | shutil.move(path, dest, **kwargs) 339 | 340 | 341 | def remove_dir(path: PathIn, **kwargs: Any) -> bool: 342 | """ 343 | Remove a directory at the given path and all its content. 344 | If the directory is removed with success returns True, otherwise False. 345 | More informations about kwargs supported options here: 346 | https://docs.python.org/3/library/shutil.html#shutil.rmtree 347 | """ 348 | path = _get_path(path) 349 | if not exists(path): 350 | return False 351 | assert_dir(path) 352 | shutil.rmtree(path, **kwargs) 353 | return not exists(path) 354 | 355 | 356 | def remove_dir_content(path: PathIn) -> None: 357 | """ 358 | Removes all directory content (both sub-directories and files). 359 | """ 360 | path = _get_path(path) 361 | assert_dir(path) 362 | remove_dirs(*list_dirs(path)) 363 | remove_files(*list_files(path)) 364 | 365 | 366 | def remove_dirs(*paths: PathIn) -> None: 367 | """ 368 | Remove multiple directories at the given paths and all their content. 369 | """ 370 | for path in paths: 371 | remove_dir(path) 372 | 373 | 374 | def remove_file(path: PathIn) -> bool: 375 | """ 376 | Remove a file at the given path. 377 | If the file is removed with success returns True, otherwise False. 378 | """ 379 | path = _get_path(path) 380 | if not exists(path): 381 | return False 382 | assert_file(path) 383 | os.remove(path) 384 | return not exists(path) 385 | 386 | 387 | def remove_files(*paths: PathIn) -> None: 388 | """ 389 | Remove multiple files at the given paths. 390 | """ 391 | for path in paths: 392 | remove_file(path) 393 | 394 | 395 | def rename_dir(path: PathIn, name: str) -> None: 396 | """ 397 | Rename a directory with the given name. 398 | If a directory or a file with the given name already exists, an OSError is raised. 399 | """ 400 | path = _get_path(path) 401 | assert_dir(path) 402 | comps = list(os.path.split(path)) 403 | comps[-1] = name 404 | dest = os.path.join(*comps) 405 | assert_not_exists(dest) 406 | os.rename(path, dest) 407 | 408 | 409 | def rename_file(path: PathIn, name: str) -> None: 410 | """ 411 | Rename a file with the given name. 412 | If a directory or a file with the given name already exists, an OSError is raised. 413 | """ 414 | path = _get_path(path) 415 | assert_file(path) 416 | dirpath, _ = split_filepath(path) 417 | dest = join_filepath(dirpath, name) 418 | assert_not_exists(dest) 419 | os.rename(path, dest) 420 | 421 | 422 | def rename_file_basename(path: PathIn, basename: str) -> None: 423 | """ 424 | Rename a file basename with the given basename. 425 | """ 426 | path = _get_path(path) 427 | extension = get_file_extension(path) 428 | filename = join_filename(basename, extension) 429 | rename_file(path, filename) 430 | 431 | 432 | def rename_file_extension(path: PathIn, extension: str) -> None: 433 | """ 434 | Rename a file extension with the given extension. 435 | """ 436 | path = _get_path(path) 437 | basename = get_file_basename(path) 438 | filename = join_filename(basename, extension) 439 | rename_file(path, filename) 440 | 441 | 442 | def replace_dir(path: PathIn, src: PathIn, *, autodelete: bool = False) -> None: 443 | """ 444 | Replace directory at the specified path with the directory located at src. 445 | If autodelete, the src directory will be removed at the end of the operation. 446 | Optimized for large files. 447 | """ 448 | path = _get_path(path) 449 | src = _get_path(src) 450 | assert_not_file(path) 451 | assert_dir(src) 452 | 453 | if path == src: 454 | return 455 | 456 | make_dirs(path) 457 | 458 | dirpath, dirname = split_filepath(path) 459 | # safe temporary name to avoid clashes with existing files/directories 460 | temp_dirname = get_unique_name(dirpath) 461 | temp_dest = join_path(dirpath, temp_dirname) 462 | copy_dir_content(src, temp_dest) 463 | 464 | if exists(path): 465 | temp_dirname = get_unique_name(dirpath) 466 | temp_path = join_path(dirpath, temp_dirname) 467 | rename_dir(path=path, name=temp_dirname) 468 | rename_dir(path=temp_dest, name=dirname) 469 | remove_dir(path=temp_path) 470 | else: 471 | rename_dir(path=temp_dest, name=dirname) 472 | 473 | if autodelete: 474 | remove_dir(path=src) 475 | 476 | 477 | def replace_file(path: PathIn, src: PathIn, *, autodelete: bool = False) -> None: 478 | """ 479 | Replace file at the specified path with the file located at src. 480 | If autodelete, the src file will be removed at the end of the operation. 481 | Optimized for large files. 482 | """ 483 | path = _get_path(path) 484 | src = _get_path(src) 485 | assert_not_dir(path) 486 | assert_file(src) 487 | if path == src: 488 | return 489 | 490 | make_dirs_for_file(path) 491 | 492 | dirpath, filename = split_filepath(path) 493 | _, extension = split_filename(filename) 494 | # safe temporary name to avoid clashes with existing files/directories 495 | temp_filename = get_unique_name(dirpath, extension=extension) 496 | temp_dest = join_path(dirpath, temp_filename) 497 | copy_file(path=src, dest=temp_dest, overwrite=False) 498 | 499 | if exists(path): 500 | temp_filename = get_unique_name(dirpath, extension=extension) 501 | temp_path = join_path(dirpath, temp_filename) 502 | rename_file(path=path, name=temp_filename) 503 | rename_file(path=temp_dest, name=filename) 504 | remove_file(path=temp_path) 505 | else: 506 | rename_file(path=temp_dest, name=filename) 507 | 508 | if autodelete: 509 | remove_file(path=src) 510 | 511 | 512 | def _search_paths(path: PathIn, pattern: str) -> list[str]: 513 | """ 514 | Search all paths relative to path matching the given pattern. 515 | """ 516 | path = _get_path(path) 517 | assert_dir(path) 518 | pathname = os.path.join(path, pattern) 519 | paths = glob.glob(pathname, recursive=True) 520 | return paths 521 | 522 | 523 | def search_dirs(path: PathIn, pattern: str = "**/*") -> list[str]: 524 | """ 525 | Search for directories at path matching the given pattern. 526 | """ 527 | path = _get_path(path) 528 | return _filter_paths(path, _search_paths(path, pattern), predicate=is_dir) 529 | 530 | 531 | def search_files(path: PathIn, pattern: str = "**/*.*") -> list[str]: 532 | """ 533 | Search for files at path matching the given pattern. 534 | """ 535 | path = _get_path(path) 536 | return _filter_paths(path, _search_paths(path, pattern), predicate=is_file) 537 | -------------------------------------------------------------------------------- /src/fsutil/paths.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import uuid 5 | from collections.abc import Callable 6 | from urllib.parse import urlsplit 7 | 8 | from fsutil.args import get_path as _get_path 9 | from fsutil.checks import assert_dir, exists 10 | from fsutil.types import PathIn 11 | 12 | 13 | def get_filename(path: PathIn) -> str: 14 | """ 15 | Get the filename from the given path/url. 16 | """ 17 | path = _get_path(path) 18 | filepath = urlsplit(path).path 19 | filename = os.path.basename(filepath) 20 | return filename 21 | 22 | 23 | def get_file_basename(path: PathIn) -> str: 24 | """ 25 | Get the file basename from the given path/url. 26 | """ 27 | path = _get_path(path) 28 | basename, _ = split_filename(path) 29 | return basename 30 | 31 | 32 | def get_file_extension(path: PathIn) -> str: 33 | """ 34 | Get the file extension from the given path/url. 35 | """ 36 | path = _get_path(path) 37 | _, extension = split_filename(path) 38 | return extension 39 | 40 | 41 | def get_parent_dir(path: PathIn, *, levels: int = 1) -> str: 42 | """ 43 | Get the parent directory for the given path going up N levels. 44 | """ 45 | path = _get_path(path) 46 | return join_path(path, *([os.pardir] * max(1, levels))) 47 | 48 | 49 | def get_unique_name( 50 | path: PathIn, 51 | *, 52 | prefix: str = "", 53 | suffix: str = "", 54 | extension: str = "", 55 | separator: str = "-", 56 | ) -> str: 57 | """ 58 | Get a unique name for a directory/file at the given directory path. 59 | """ 60 | path = _get_path(path) 61 | assert_dir(path) 62 | name = "" 63 | while True: 64 | if prefix: 65 | name += f"{prefix}{separator}" 66 | uid = uuid.uuid4() 67 | name += f"{uid}" 68 | if suffix: 69 | name += f"{separator}{suffix}" 70 | if extension: 71 | extension = extension.lstrip(".").lower() 72 | name += f".{extension}" 73 | if exists(join_path(path, name)): 74 | continue 75 | break 76 | return name 77 | 78 | 79 | def join_filename(basename: str, extension: str) -> str: 80 | """ 81 | Create a filename joining the file basename and the extension. 82 | """ 83 | basename = basename.rstrip(".").strip() 84 | extension = extension.replace(".", "").strip() 85 | if basename and extension: 86 | filename = f"{basename}.{extension}" 87 | return filename 88 | return basename or extension 89 | 90 | 91 | def join_filepath(dirpath: PathIn, filename: str) -> str: 92 | """ 93 | Create a filepath joining the directory path and the filename. 94 | """ 95 | dirpath = _get_path(dirpath) 96 | return join_path(dirpath, filename) 97 | 98 | 99 | def join_path(path: PathIn, *paths: PathIn) -> str: 100 | """ 101 | Create a path joining path and paths. 102 | If path is __file__ (or a .py file), the resulting path will be relative 103 | to the directory path of the module in which it's used. 104 | """ 105 | path = _get_path(path) 106 | basepath = path 107 | if get_file_extension(path) in ["py", "pyc", "pyo"]: 108 | basepath = os.path.dirname(os.path.realpath(path)) 109 | paths_str = [_get_path(path).lstrip("/\\") for path in paths] 110 | return os.path.normpath(os.path.join(basepath, *paths_str)) 111 | 112 | 113 | def split_filename(path: PathIn) -> tuple[str, str]: 114 | """ 115 | Split a filename and returns its basename and extension. 116 | """ 117 | path = _get_path(path) 118 | filename = get_filename(path) 119 | basename, extension = os.path.splitext(filename) 120 | extension = extension.replace(".", "").strip() 121 | return (basename, extension) 122 | 123 | 124 | def split_filepath(path: PathIn) -> tuple[str, str]: 125 | """ 126 | Split a filepath and returns its directory-path and filename. 127 | """ 128 | path = _get_path(path) 129 | dirpath = os.path.dirname(path) 130 | filename = get_filename(path) 131 | return (dirpath, filename) 132 | 133 | 134 | def split_path(path: PathIn) -> list[str]: 135 | """ 136 | Split a path and returns its path-names. 137 | """ 138 | path = _get_path(path) 139 | head, tail = os.path.split(path) 140 | names = head.split(os.sep) + [tail] 141 | names = list(filter(None, names)) 142 | return names 143 | 144 | 145 | def transform_filepath( 146 | path: PathIn, 147 | *, 148 | dirpath: str | Callable[[str], str] | None = None, 149 | basename: str | Callable[[str], str] | None = None, 150 | extension: str | Callable[[str], str] | None = None, 151 | ) -> str: 152 | """ 153 | Trasform a filepath by applying the provided optional changes. 154 | 155 | :param path: The path. 156 | :type path: PathIn 157 | :param dirpath: The new dirpath or a callable. 158 | :type dirpath: str | Callable[[str], str] | None 159 | :param basename: The new basename or a callable. 160 | :type basename: str | Callable[[str], str] | None 161 | :param extension: The new extension or a callable. 162 | :type extension: str | Callable[[str], str] | None 163 | 164 | :returns: The filepath with the applied changes. 165 | :rtype: str 166 | """ 167 | 168 | def _get_value( 169 | new_value: str | Callable[[str], str] | None, 170 | old_value: str, 171 | ) -> str: 172 | value = old_value 173 | if new_value is not None: 174 | if callable(new_value): 175 | value = new_value(old_value) 176 | elif isinstance(new_value, str): 177 | value = new_value 178 | else: 179 | value = old_value 180 | return value 181 | 182 | if all([dirpath is None, basename is None, extension is None]): 183 | raise ValueError( 184 | "Invalid arguments: at least one of " 185 | "'dirpath', 'basename' or 'extension' is required." 186 | ) 187 | old_dirpath, old_filename = split_filepath(path) 188 | old_basename, old_extension = split_filename(old_filename) 189 | new_dirpath = _get_value(dirpath, old_dirpath) 190 | new_basename = _get_value(basename, old_basename) 191 | new_extension = _get_value(extension, old_extension) 192 | if not any([new_dirpath, new_basename, new_extension]): 193 | raise ValueError( 194 | "Invalid arguments: at least one of " 195 | "'dirpath', 'basename' or 'extension' is required." 196 | ) 197 | new_filename = join_filename(new_basename, new_extension) 198 | new_filepath = join_filepath(new_dirpath, new_filename) 199 | return new_filepath 200 | -------------------------------------------------------------------------------- /src/fsutil/perms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from fsutil.args import get_path as _get_path 6 | from fsutil.checks import assert_exists 7 | from fsutil.types import PathIn 8 | 9 | 10 | def get_permissions(path: PathIn) -> int: 11 | """ 12 | Get the file/directory permissions. 13 | """ 14 | path = _get_path(path) 15 | assert_exists(path) 16 | st_mode = os.stat(path).st_mode 17 | permissions = int(str(oct(st_mode & 0o777))[2:]) 18 | return permissions 19 | 20 | 21 | def set_permissions(path: PathIn, value: int) -> None: 22 | """ 23 | Set the file/directory permissions. 24 | """ 25 | path = _get_path(path) 26 | assert_exists(path) 27 | permissions = int(str(value), 8) & 0o777 28 | os.chmod(path, permissions) 29 | -------------------------------------------------------------------------------- /src/fsutil/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabiocaccamo/python-fsutil/8a3a509c2d605d509f9eb32e7e2401078dac116c/src/fsutil/py.typed -------------------------------------------------------------------------------- /src/fsutil/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | from typing import Union 5 | 6 | PathIn = Union[str, pathlib.Path] 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabiocaccamo/python-fsutil/8a3a509c2d605d509f9eb32e7e2401078dac116c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def temp_path(): 9 | with tempfile.TemporaryDirectory() as temp_dir: 10 | 11 | def _temp_path(filepath=""): 12 | return os.path.join(temp_dir, filepath) 13 | 14 | yield _temp_path 15 | -------------------------------------------------------------------------------- /tests/test_archives.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import fsutil 4 | 5 | 6 | def test_create_zip_file(temp_path): 7 | zip_path = temp_path("archive.zip") 8 | f1_path = temp_path("a/b/f1.txt") 9 | f2_path = temp_path("a/b/f2.txt") 10 | f3_path = temp_path("x/y/f3.txt") 11 | f4_path = temp_path("x/y/f4.txt") 12 | fsutil.create_file(f1_path, content="hello world 1") 13 | fsutil.create_file(f2_path, content="hello world 2") 14 | fsutil.create_file(f3_path, content="hello world 3") 15 | fsutil.create_file(f4_path, content="hello world 4") 16 | fsutil.create_zip_file(zip_path, [f1_path, f2_path, f3_path, f4_path]) 17 | with pytest.raises(OSError): 18 | fsutil.create_zip_file( 19 | zip_path, [f1_path, f2_path, f3_path, f4_path], overwrite=False 20 | ) 21 | assert fsutil.is_file(f1_path) 22 | assert fsutil.is_file(f2_path) 23 | assert fsutil.is_file(f3_path) 24 | assert fsutil.is_file(f4_path) 25 | assert fsutil.is_file(zip_path) 26 | assert fsutil.get_file_size(zip_path) > 0 27 | 28 | 29 | def test_create_tar_file(temp_path): 30 | tar_path = temp_path("archive.tar") 31 | f1_path = temp_path("a/b/f1.txt") 32 | f2_path = temp_path("a/b/f2.txt") 33 | f3_path = temp_path("x/y/f3.txt") 34 | f4_path = temp_path("x/y/f4.txt") 35 | fsutil.create_file(f1_path, content="hello world 1") 36 | fsutil.create_file(f2_path, content="hello world 2") 37 | fsutil.create_file(f3_path, content="hello world 3") 38 | fsutil.create_file(f4_path, content="hello world 4") 39 | fsutil.create_tar_file(tar_path, [f1_path, f2_path, f3_path, f4_path]) 40 | with pytest.raises(OSError): 41 | fsutil.create_tar_file( 42 | tar_path, [f1_path, f2_path, f3_path, f4_path], overwrite=False 43 | ) 44 | assert fsutil.is_file(f1_path) 45 | assert fsutil.is_file(f2_path) 46 | assert fsutil.is_file(f3_path) 47 | assert fsutil.is_file(f4_path) 48 | assert fsutil.is_file(tar_path) 49 | assert fsutil.get_file_size(tar_path) > 0 50 | 51 | 52 | def test_extract_zip_file(temp_path): 53 | zip_path = temp_path("archive.zip") 54 | unzip_path = temp_path("unarchive/") 55 | f1_path = temp_path("a/b/f1.txt") 56 | f2_path = temp_path("a/b/f2.txt") 57 | f3_path = temp_path("j/k/f3.txt") 58 | f4_path = temp_path("j/k/f4.txt") 59 | f5_path = temp_path("x/y/z/f5.txt") 60 | f6_path = temp_path("x/y/z/f6.txt") 61 | f5_f6_dir = temp_path("x") 62 | fsutil.create_file(f1_path, content="hello world 1") 63 | fsutil.create_file(f2_path, content="hello world 2") 64 | fsutil.create_file(f3_path, content="hello world 3") 65 | fsutil.create_file(f4_path, content="hello world 4") 66 | fsutil.create_file(f5_path, content="hello world 5") 67 | fsutil.create_file(f6_path, content="hello world 6") 68 | fsutil.create_zip_file(zip_path, [f1_path, f2_path, f3_path, f4_path, f5_f6_dir]) 69 | fsutil.extract_zip_file(zip_path, unzip_path) 70 | assert fsutil.is_dir(unzip_path) 71 | assert fsutil.is_file(temp_path("unarchive/f1.txt")) 72 | assert fsutil.is_file(temp_path("unarchive/f2.txt")) 73 | assert fsutil.is_file(temp_path("unarchive/f3.txt")) 74 | assert fsutil.is_file(temp_path("unarchive/f4.txt")) 75 | assert fsutil.is_file(temp_path("unarchive/y/z/f5.txt")) 76 | assert fsutil.is_file(temp_path("unarchive/y/z/f6.txt")) 77 | assert fsutil.is_file(zip_path) 78 | 79 | 80 | def test_extract_zip_file_with_autodelete(temp_path): 81 | zip_path = temp_path("archive.zip") 82 | unzip_path = temp_path("unarchive/") 83 | path = temp_path("f1.txt") 84 | fsutil.create_file(path, content="hello world 1") 85 | fsutil.create_zip_file(zip_path, [path]) 86 | fsutil.extract_zip_file(zip_path, unzip_path, autodelete=True) 87 | assert fsutil.is_dir(unzip_path) 88 | assert fsutil.is_file(temp_path("unarchive/f1.txt")) 89 | assert not fsutil.is_file(zip_path) 90 | 91 | 92 | def test_extract_tar_file(temp_path): 93 | tar_path = temp_path("archive.tar") 94 | untar_path = temp_path("unarchive/") 95 | f1_path = temp_path("a/b/f1.txt") 96 | f2_path = temp_path("a/b/f2.txt") 97 | f3_path = temp_path("j/k/f3.txt") 98 | f4_path = temp_path("j/k/f4.txt") 99 | f5_path = temp_path("x/y/z/f5.txt") 100 | f6_path = temp_path("x/y/z/f6.txt") 101 | f5_f6_dir = temp_path("x") 102 | fsutil.create_file(f1_path, content="hello world 1") 103 | fsutil.create_file(f2_path, content="hello world 2") 104 | fsutil.create_file(f3_path, content="hello world 3") 105 | fsutil.create_file(f4_path, content="hello world 4") 106 | fsutil.create_file(f5_path, content="hello world 5") 107 | fsutil.create_file(f6_path, content="hello world 6") 108 | fsutil.create_tar_file(tar_path, [f1_path, f2_path, f3_path, f4_path, f5_f6_dir]) 109 | fsutil.extract_tar_file(tar_path, untar_path) 110 | assert fsutil.is_dir(untar_path) 111 | assert fsutil.is_file(temp_path("unarchive/f1.txt")) 112 | assert fsutil.is_file(temp_path("unarchive/f2.txt")) 113 | assert fsutil.is_file(temp_path("unarchive/f3.txt")) 114 | assert fsutil.is_file(temp_path("unarchive/f4.txt")) 115 | assert fsutil.is_file(temp_path("unarchive/y/z/f5.txt")) 116 | assert fsutil.is_file(temp_path("unarchive/y/z/f6.txt")) 117 | assert fsutil.is_file(tar_path) 118 | 119 | 120 | def test_extract_tar_file_with_autodelete(temp_path): 121 | tar_path = temp_path("archive.tar") 122 | untar_path = temp_path("unarchive/") 123 | path = temp_path("f1.txt") 124 | fsutil.create_file(path, content="hello world 1") 125 | fsutil.create_tar_file(tar_path, [path]) 126 | fsutil.extract_tar_file(tar_path, untar_path, autodelete=True) 127 | assert fsutil.is_dir(untar_path) 128 | assert fsutil.is_file(temp_path("unarchive/f1.txt")) 129 | assert not fsutil.is_file(tar_path) 130 | 131 | 132 | if __name__ == "__main__": 133 | pytest.main() 134 | -------------------------------------------------------------------------------- /tests/test_args.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from fsutil.args import get_path 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "input_path, expected", 11 | [ 12 | (None, None), 13 | ("", "."), 14 | ("/home/user/docs", os.path.normpath("/home/user/docs")), 15 | ("C:\\Users\\test", os.path.normpath("C:\\Users\\test")), 16 | ("./relative/path", os.path.normpath("./relative/path")), 17 | ("..", os.path.normpath("..")), 18 | (Path("/home/user/docs"), os.path.normpath("/home/user/docs")), 19 | (Path("C:\\Users\\test"), os.path.normpath("C:\\Users\\test")), 20 | (Path("./relative/path"), os.path.normpath("./relative/path")), 21 | (Path(".."), os.path.normpath("..")), 22 | ], 23 | ) 24 | def test_get_path(input_path, expected): 25 | assert get_path(input_path) == expected 26 | 27 | 28 | if __name__ == "__main__": 29 | pytest.main() 30 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import fsutil 4 | 5 | 6 | def test_assert_dir(temp_path): 7 | path = temp_path("a/b/") 8 | with pytest.raises(OSError): 9 | fsutil.assert_dir(path) 10 | fsutil.create_dir(path) 11 | fsutil.assert_dir(path) 12 | 13 | 14 | def test_assert_dir_with_file(temp_path): 15 | path = temp_path("a/b/c.txt") 16 | fsutil.create_file(path) 17 | with pytest.raises(OSError): 18 | fsutil.assert_dir(path) 19 | 20 | 21 | def test_assert_exists_with_directory(temp_path): 22 | path = temp_path("a/b/") 23 | with pytest.raises(OSError): 24 | fsutil.assert_exists(path) 25 | fsutil.create_dir(path) 26 | fsutil.assert_exists(path) 27 | 28 | 29 | def test_assert_exists_with_file(temp_path): 30 | path = temp_path("a/b/c.txt") 31 | with pytest.raises(OSError): 32 | fsutil.assert_exists(path) 33 | fsutil.create_file(path) 34 | fsutil.assert_exists(path) 35 | 36 | 37 | def test_assert_file(temp_path): 38 | path = temp_path("a/b/c.txt") 39 | with pytest.raises(OSError): 40 | fsutil.assert_file(path) 41 | fsutil.create_file(path) 42 | fsutil.assert_file(path) 43 | 44 | 45 | def test_assert_file_with_directory(temp_path): 46 | path = temp_path("a/b/c.txt") 47 | fsutil.create_dir(path) 48 | with pytest.raises(OSError): 49 | fsutil.assert_file(path) 50 | 51 | 52 | def test_exists(temp_path): 53 | path = temp_path("a/b/") 54 | assert not fsutil.exists(path) 55 | fsutil.create_dir(path) 56 | assert fsutil.exists(path) 57 | path = temp_path("a/b/c.txt") 58 | assert not fsutil.exists(path) 59 | fsutil.create_file(path) 60 | assert fsutil.exists(path) 61 | 62 | 63 | def test_is_dir(temp_path): 64 | path = temp_path("a/b/") 65 | assert not fsutil.is_dir(path) 66 | fsutil.create_dir(path) 67 | assert fsutil.is_dir(path) 68 | path = temp_path("a/b/c.txt") 69 | assert not fsutil.is_dir(path) 70 | fsutil.create_file(path) 71 | assert not fsutil.is_dir(path) 72 | 73 | 74 | def test_is_empty(temp_path): 75 | fsutil.create_file(temp_path("a/b/c.txt")) 76 | fsutil.create_file(temp_path("a/b/d.txt"), content="1") 77 | fsutil.create_dir(temp_path("a/b/e")) 78 | assert fsutil.is_empty(temp_path("a/b/c.txt")) 79 | assert not fsutil.is_empty(temp_path("a/b/d.txt")) 80 | assert fsutil.is_empty(temp_path("a/b/e")) 81 | assert not fsutil.is_empty(temp_path("a/b")) 82 | 83 | 84 | def test_is_empty_dir(temp_path): 85 | path = temp_path("a/b/") 86 | fsutil.create_dir(path) 87 | assert fsutil.is_empty_dir(path) 88 | filepath = temp_path("a/b/c.txt") 89 | fsutil.create_file(filepath) 90 | assert fsutil.is_file(filepath) 91 | assert not fsutil.is_empty_dir(path) 92 | 93 | 94 | def test_is_empty_file(temp_path): 95 | path = temp_path("a/b/c.txt") 96 | fsutil.create_file(path) 97 | assert fsutil.is_empty_file(path) 98 | path = temp_path("a/b/d.txt") 99 | fsutil.create_file(path, content="hello world") 100 | assert not fsutil.is_empty_file(path) 101 | 102 | 103 | def test_is_file(temp_path): 104 | path = temp_path("a/b/c.txt") 105 | assert not fsutil.is_file(path) 106 | fsutil.create_file(path) 107 | assert fsutil.is_file(path) 108 | 109 | 110 | if __name__ == "__main__": 111 | pytest.main() 112 | -------------------------------------------------------------------------------- /tests/test_converters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import fsutil 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "size_bytes, expected_output", 8 | [ 9 | (1023, "1023 bytes"), 10 | (1024, "1 KB"), 11 | (1048576, "1.00 MB"), 12 | (1572864, "1.50 MB"), 13 | (1073741824, "1.00 GB"), 14 | (1879048192, "1.75 GB"), 15 | (1099511627776, "1.00 TB"), 16 | ], 17 | ) 18 | def test_convert_size_bytes_to_string(size_bytes, expected_output): 19 | assert fsutil.convert_size_bytes_to_string(size_bytes) == expected_output 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "size_string, expected_output", 24 | [ 25 | ("1023 bytes", "1023 bytes"), 26 | ("1 KB", "1 KB"), 27 | ("1.00 MB", "1.00 MB"), 28 | ("1.25 MB", "1.25 MB"), 29 | ("2.50 MB", "2.50 MB"), 30 | ("1.00 GB", "1.00 GB"), 31 | ("1.09 GB", "1.09 GB"), 32 | ("1.99 GB", "1.99 GB"), 33 | ("1.00 TB", "1.00 TB"), 34 | ], 35 | ) 36 | def test_convert_size_bytes_to_string_and_convert_size_string_to_bytes( 37 | size_string, expected_output 38 | ): 39 | assert ( 40 | fsutil.convert_size_bytes_to_string( 41 | fsutil.convert_size_string_to_bytes(size_string) 42 | ) 43 | == expected_output 44 | ) 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "size_string, expected_output", 49 | [ 50 | ("1 KB", 1024), 51 | ("1.00 MB", 1048576), 52 | ("1.00 GB", 1073741824), 53 | ("1.00 TB", 1099511627776), 54 | ], 55 | ) 56 | def test_convert_size_string_to_bytes(size_string, expected_output): 57 | assert fsutil.convert_size_string_to_bytes(size_string) == expected_output 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "size_bytes, expected_output", 62 | [ 63 | (1023, 1023), 64 | (1024, 1024), 65 | (1048576, 1048576), 66 | (1310720, 1310720), 67 | (2621440, 2621440), 68 | (1073741824, 1073741824), 69 | (1170378588, 1170378588), 70 | (2136746229, 2136746229), 71 | (1099511627776, 1099511627776), 72 | ], 73 | ) 74 | def test_convert_size_string_to_bytes_and_convert_size_bytes_to_string( 75 | size_bytes, expected_output 76 | ): 77 | assert ( 78 | fsutil.convert_size_string_to_bytes( 79 | fsutil.convert_size_bytes_to_string(size_bytes) 80 | ) 81 | == expected_output 82 | ) 83 | 84 | 85 | if __name__ == "__main__": 86 | pytest.main() 87 | -------------------------------------------------------------------------------- /tests/test_deps.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from types import ModuleType 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | from fsutil.deps import require_requests 8 | 9 | 10 | def test_require_requests_installed(): 11 | with mock.patch.dict(sys.modules, {"requests": mock.Mock(spec=ModuleType)}): 12 | requests_module = require_requests() 13 | assert isinstance(requests_module, ModuleType) 14 | 15 | 16 | def test_require_requests_not_installed(): 17 | with mock.patch.dict(sys.modules, {"requests": None}): 18 | with pytest.raises( 19 | ModuleNotFoundError, match="'requests' module is not installed" 20 | ): 21 | require_requests() 22 | 23 | 24 | if __name__ == "__main__": 25 | pytest.main() 26 | -------------------------------------------------------------------------------- /tests/test_info.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from datetime import datetime, timedelta 4 | 5 | import pytest 6 | 7 | import fsutil 8 | 9 | 10 | def create_file_of_size(path, size): 11 | fsutil.create_file(path) 12 | size_bytes = fsutil.convert_size_string_to_bytes(size) 13 | with open(path, "wb") as file: 14 | file.seek(size_bytes - 1) 15 | file.write(b"\0") 16 | 17 | 18 | def test_get_dir_creation_date(temp_path): 19 | path = temp_path("a/b/c.txt") 20 | fsutil.create_file(path, content="Hello World") 21 | creation_date = fsutil.get_dir_creation_date(temp_path("a/b")) 22 | now = datetime.now() 23 | assert (now - creation_date) < timedelta(seconds=0.1) 24 | time.sleep(0.2) 25 | creation_date = fsutil.get_dir_creation_date(temp_path("a/b")) 26 | now = datetime.now() 27 | assert not (now - creation_date) < timedelta(seconds=0.1) 28 | 29 | 30 | def test_get_dir_creation_date_formatted(temp_path): 31 | path = temp_path("a/b/c.txt") 32 | fsutil.create_file(path, content="Hello World") 33 | creation_date_str = fsutil.get_dir_creation_date_formatted( 34 | temp_path("a/b"), format="%Y/%m/%d" 35 | ) 36 | creation_date_re = re.compile(r"^[\d]{4}\/[\d]{2}\/[\d]{2}$") 37 | assert creation_date_re.match(creation_date_str) is not None 38 | 39 | 40 | def test_get_dir_hash(temp_path): 41 | f1_path = temp_path("x/a/b/f1.txt") 42 | f2_path = temp_path("x/a/b/f2.txt") 43 | f3_path = temp_path("x/j/k/f3.txt") 44 | f4_path = temp_path("x/j/k/f4.txt") 45 | f5_path = temp_path("x/y/z/f5.txt") 46 | f6_path = temp_path("x/y/z/f6.txt") 47 | fsutil.create_file(f1_path, content="hello world 1") 48 | fsutil.create_file(f2_path, content="hello world 2") 49 | fsutil.create_file(f3_path, content="hello world 3") 50 | fsutil.create_file(f4_path, content="hello world 4") 51 | fsutil.create_file(f5_path, content="hello world 5") 52 | fsutil.create_file(f6_path, content="hello world 6") 53 | dir_hash = fsutil.get_dir_hash(temp_path("x/")) 54 | assert dir_hash == "eabe619c41f0c4611b7b9746bededfcb" 55 | 56 | 57 | def test_get_dir_last_modified_date(temp_path): 58 | path = temp_path("a/b/c.txt") 59 | fsutil.create_file(path, content="Hello") 60 | creation_date = fsutil.get_dir_creation_date(temp_path("a")) 61 | time.sleep(0.2) 62 | fsutil.write_file(path, content="Goodbye", append=True) 63 | now = datetime.now() 64 | lastmod_date = fsutil.get_dir_last_modified_date(temp_path("a")) 65 | assert (now - lastmod_date) < timedelta(seconds=0.1) 66 | assert (lastmod_date - creation_date) > timedelta(seconds=0.15) 67 | 68 | 69 | def test_get_dir_last_modified_date_formatted(temp_path): 70 | path = temp_path("a/b/c.txt") 71 | fsutil.create_file(path, content="Hello World") 72 | lastmod_date_str = fsutil.get_dir_last_modified_date_formatted(temp_path("a")) 73 | lastmod_date_re = re.compile( 74 | r"^[\d]{4}\-[\d]{2}\-[\d]{2}[\s]{1}[\d]{2}\:[\d]{2}\:[\d]{2}$" 75 | ) 76 | assert lastmod_date_re.match(lastmod_date_str) is not None 77 | 78 | 79 | def test_get_dir_size(temp_path): 80 | create_file_of_size(temp_path("a/a-1.txt"), "1.05 MB") # 1101004 81 | create_file_of_size(temp_path("a/b/b-1.txt"), "2 MB") # 2097152 82 | create_file_of_size(temp_path("a/b/b-2.txt"), "2.25 MB") # 2359296 83 | create_file_of_size(temp_path("a/b/c/c-1.txt"), "3.75 MB") # 3932160 84 | create_file_of_size(temp_path("a/b/c/c-2.txt"), "500 KB") # 512000 85 | create_file_of_size(temp_path("a/b/c/c-3.txt"), "200 KB") # 204800 86 | assert fsutil.get_dir_size(temp_path("a")) == 10206412 87 | assert fsutil.get_dir_size(temp_path("a/b")) == 9105408 88 | assert fsutil.get_dir_size(temp_path("a/b/c")) == 4648960 89 | 90 | 91 | def test_get_dir_size_formatted(temp_path): 92 | create_file_of_size(temp_path("a/a-1.txt"), "1.05 MB") # 1101004 93 | create_file_of_size(temp_path("a/b/b-1.txt"), "2 MB") # 2097152 94 | create_file_of_size(temp_path("a/b/b-2.txt"), "2.25 MB") # 2359296 95 | create_file_of_size(temp_path("a/b/c/c-1.txt"), "3.75 MB") # 3932160 96 | create_file_of_size(temp_path("a/b/c/c-2.txt"), "500 KB") # 512000 97 | create_file_of_size(temp_path("a/b/c/c-3.txt"), "200 KB") # 204800 98 | assert fsutil.get_dir_size_formatted(temp_path("a")) == "9.73 MB" 99 | assert fsutil.get_dir_size_formatted(temp_path("a/b")) == "8.68 MB" 100 | assert fsutil.get_dir_size_formatted(temp_path("a/b/c")) == "4.43 MB" 101 | 102 | 103 | def test_get_file_creation_date(temp_path): 104 | path = temp_path("a/b/c.txt") 105 | fsutil.create_file(path, content="Hello World") 106 | creation_date = fsutil.get_file_creation_date(path) 107 | now = datetime.now() 108 | assert (now - creation_date) < timedelta(seconds=0.1) 109 | time.sleep(0.2) 110 | creation_date = fsutil.get_file_creation_date(path) 111 | now = datetime.now() 112 | assert not (now - creation_date) < timedelta(seconds=0.1) 113 | 114 | 115 | def test_get_file_creation_date_formatted(temp_path): 116 | path = temp_path("a/b/c.txt") 117 | fsutil.create_file(path, content="Hello World") 118 | creation_date_str = fsutil.get_file_creation_date_formatted(path, format="%Y/%m/%d") 119 | creation_date_re = re.compile(r"^[\d]{4}\/[\d]{2}\/[\d]{2}$") 120 | assert creation_date_re.match(creation_date_str) is not None 121 | 122 | 123 | def test_get_file_hash(temp_path): 124 | path = temp_path("a/b/c.txt") 125 | fsutil.create_file(path, content="Hello World") 126 | file_hash = fsutil.get_file_hash(path) 127 | assert file_hash == "b10a8db164e0754105b7a99be72e3fe5" 128 | 129 | 130 | def test_get_file_last_modified_date(temp_path): 131 | path = temp_path("a/b/c.txt") 132 | fsutil.create_file(path, content="Hello") 133 | creation_date = fsutil.get_file_creation_date(path) 134 | time.sleep(0.2) 135 | fsutil.write_file(path, content="Goodbye", append=True) 136 | now = datetime.now() 137 | lastmod_date = fsutil.get_file_last_modified_date(path) 138 | assert (now - lastmod_date) < timedelta(seconds=0.1) 139 | assert (lastmod_date - creation_date) > timedelta(seconds=0.15) 140 | 141 | 142 | def test_get_file_last_modified_date_formatted(temp_path): 143 | path = temp_path("a/b/c.txt") 144 | fsutil.create_file(path, content="Hello World") 145 | lastmod_date_str = fsutil.get_file_last_modified_date_formatted(path) 146 | lastmod_date_re = re.compile( 147 | r"^[\d]{4}\-[\d]{2}\-[\d]{2}[\s]{1}[\d]{2}\:[\d]{2}\:[\d]{2}$" 148 | ) 149 | assert lastmod_date_re.match(lastmod_date_str) is not None 150 | 151 | 152 | def test_get_file_size(temp_path): 153 | path = temp_path("a/b/c.txt") 154 | create_file_of_size(path, "1.75 MB") 155 | size = fsutil.get_file_size(path) 156 | assert size == fsutil.convert_size_string_to_bytes("1.75 MB") 157 | 158 | 159 | def test_get_file_size_formatted(temp_path): 160 | path = temp_path("a/b/c.txt") 161 | create_file_of_size(path, "1.75 MB") 162 | size = fsutil.get_file_size_formatted(path) 163 | assert size == "1.75 MB" 164 | 165 | 166 | if __name__ == "__main__": 167 | pytest.main() 168 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import datetime 3 | from decimal import Decimal 4 | 5 | import pytest 6 | 7 | import fsutil 8 | 9 | 10 | def test_read_file(temp_path): 11 | path = temp_path("a/b/c.txt") 12 | fsutil.write_file(path, content="Hello World") 13 | assert fsutil.read_file(path) == "Hello World" 14 | 15 | 16 | def test_read_file_from_url(): 17 | url = "https://raw.githubusercontent.com/fabiocaccamo/python-fsutil/main/README.md" 18 | content = fsutil.read_file_from_url(url) 19 | assert "python-fsutil" in content 20 | 21 | 22 | def test_read_file_json(temp_path): 23 | path = temp_path("a/b/c.json") 24 | now = datetime.now() 25 | data = { 26 | "test": "Hello World", 27 | "test_datetime": now, 28 | "test_set": {1, 2, 3}, 29 | } 30 | fsutil.write_file_json(path, data=data) 31 | expected_data = data.copy() 32 | expected_data["test_datetime"] = now.isoformat() 33 | expected_data["test_set"] = list(expected_data["test_set"]) 34 | assert fsutil.read_file_json(path) == expected_data 35 | 36 | 37 | def test_read_file_lines(temp_path): 38 | path = temp_path("a/b/c.txt") 39 | lines = ["", "1 ", " 2", "", "", " 3 ", " 4 ", "", "", "5"] 40 | fsutil.write_file(path, content="\n".join(lines)) 41 | 42 | expected_lines = list(lines) 43 | lines = fsutil.read_file_lines(path, strip_white=False, skip_empty=False) 44 | assert lines == expected_lines 45 | 46 | expected_lines = ["", "1", "2", "", "", "3", "4", "", "", "5"] 47 | lines = fsutil.read_file_lines(path, strip_white=True, skip_empty=False) 48 | assert lines == expected_lines 49 | 50 | expected_lines = ["1 ", " 2", " 3 ", " 4 ", "5"] 51 | lines = fsutil.read_file_lines(path, strip_white=False, skip_empty=True) 52 | assert lines == expected_lines 53 | 54 | expected_lines = ["1", "2", "3", "4", "5"] 55 | lines = fsutil.read_file_lines(path, strip_white=True, skip_empty=True) 56 | assert lines == expected_lines 57 | 58 | 59 | def test_read_file_lines_with_lines_range(temp_path): 60 | path = temp_path("a/b/c.txt") 61 | lines = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] 62 | fsutil.write_file(path, content="\n".join(lines)) 63 | 64 | # single line 65 | expected_lines = ["1"] 66 | lines = fsutil.read_file_lines(path, line_start=1, line_end=1) 67 | assert lines == expected_lines 68 | 69 | # multiple lines 70 | expected_lines = ["1", "2", "3"] 71 | lines = fsutil.read_file_lines(path, line_start=1, line_end=3) 72 | assert lines == expected_lines 73 | 74 | # multiple lines not stripped 75 | newline = "\r\n" if sys.platform == "win32" else "\n" 76 | expected_lines = [f"1{newline}", f"2{newline}", f"3{newline}"] 77 | lines = fsutil.read_file_lines( 78 | path, line_start=1, line_end=3, strip_white=False, skip_empty=False 79 | ) 80 | assert lines == expected_lines 81 | 82 | # last line 83 | expected_lines = ["9"] 84 | lines = fsutil.read_file_lines(path, line_start=-1) 85 | assert lines == expected_lines 86 | 87 | # last 3 lines 88 | expected_lines = ["7", "8", "9"] 89 | lines = fsutil.read_file_lines(path, line_start=-3) 90 | assert lines == expected_lines 91 | 92 | # empty file 93 | fsutil.write_file(path, content="") 94 | expected_lines = [] 95 | lines = fsutil.read_file_lines(path, line_start=-2) 96 | assert lines == expected_lines 97 | 98 | 99 | def test_read_file_lines_count(temp_path): 100 | path = temp_path("a/b/c.txt") 101 | lines = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] 102 | fsutil.write_file(path, content="\n".join(lines)) 103 | 104 | lines_count = fsutil.read_file_lines_count(path) 105 | assert lines_count == 10 106 | 107 | 108 | def test_write_file(temp_path): 109 | path = temp_path("a/b/c.txt") 110 | fsutil.write_file(path, content="Hello World") 111 | assert fsutil.read_file(path) == "Hello World" 112 | fsutil.write_file(path, content="Hello Jupiter") 113 | assert fsutil.read_file(path) == "Hello Jupiter" 114 | 115 | 116 | def test_write_file_atomic(temp_path): 117 | path = temp_path("a/b/c.txt") 118 | fsutil.write_file(path, content="Hello World", atomic=True) 119 | assert fsutil.read_file(path) == "Hello World" 120 | fsutil.write_file(path, content="Hello Jupiter", atomic=True) 121 | assert fsutil.read_file(path) == "Hello Jupiter" 122 | 123 | 124 | def test_write_file_atomic_no_temp_files_left(temp_path): 125 | path = temp_path("a/b/c.txt") 126 | fsutil.write_file(path, content="Hello World", atomic=True) 127 | fsutil.write_file(path, content="Hello Jupiter", atomic=True) 128 | assert fsutil.list_files(temp_path("a/b/")) == [path] 129 | 130 | 131 | @pytest.mark.skipif(sys.platform.startswith("win"), reason="Test skipped on Windows") 132 | def test_write_file_atomic_permissions_inheritance(temp_path): 133 | path = temp_path("a/b/c.txt") 134 | fsutil.write_file(path, content="Hello World", atomic=False) 135 | assert fsutil.get_permissions(path) == 644 136 | fsutil.set_permissions(path, 777) 137 | fsutil.write_file(path, content="Hello Jupiter", atomic=True) 138 | assert fsutil.get_permissions(path) == 777 139 | 140 | 141 | def test_write_file_with_filename_only(): 142 | path = "document.txt" 143 | fsutil.write_file(path, content="Hello World") 144 | assert fsutil.is_file(path) 145 | # cleanup 146 | fsutil.remove_file(path) 147 | 148 | 149 | def test_write_file_json(temp_path): 150 | path = temp_path("a/b/c.json") 151 | now = datetime.now() 152 | dec = Decimal("3.33") 153 | data = { 154 | "test": "Hello World", 155 | "test_datetime": now, 156 | "test_decimal": dec, 157 | } 158 | fsutil.write_file_json(path, data=data) 159 | assert fsutil.read_file(path) == ( 160 | "{" 161 | f'"test": "Hello World", ' 162 | f'"test_datetime": "{now.isoformat()}", ' 163 | f'"test_decimal": "{dec}"' 164 | "}" 165 | ) 166 | 167 | 168 | def test_write_file_json_atomic(temp_path): 169 | path = temp_path("a/b/c.json") 170 | now = datetime.now() 171 | dec = Decimal("3.33") 172 | data = { 173 | "test": "Hello World", 174 | "test_datetime": now, 175 | "test_decimal": dec, 176 | } 177 | fsutil.write_file_json(path, data=data, atomic=True) 178 | assert fsutil.read_file(path) == ( 179 | "{" 180 | f'"test": "Hello World", ' 181 | f'"test_datetime": "{now.isoformat()}", ' 182 | f'"test_decimal": "{dec}"' 183 | "}" 184 | ) 185 | 186 | 187 | def test_write_file_with_append(temp_path): 188 | path = temp_path("a/b/c.txt") 189 | fsutil.write_file(path, content="Hello World") 190 | assert fsutil.read_file(path) == "Hello World" 191 | fsutil.write_file(path, content=" - Hello Sun", append=True) 192 | assert fsutil.read_file(path) == "Hello World - Hello Sun" 193 | 194 | 195 | def test_write_file_with_append_atomic(temp_path): 196 | path = temp_path("a/b/c.txt") 197 | fsutil.write_file(path, content="Hello World", atomic=True) 198 | assert fsutil.read_file(path) == "Hello World" 199 | fsutil.write_file(path, content=" - Hello Sun", append=True, atomic=True) 200 | assert fsutil.read_file(path) == "Hello World - Hello Sun" 201 | 202 | 203 | if __name__ == "__main__": 204 | pytest.main() 205 | -------------------------------------------------------------------------------- /tests/test_metadata.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fsutil.metadata import ( 4 | __author__, 5 | __copyright__, 6 | __description__, 7 | __email__, 8 | __license__, 9 | __title__, 10 | __version__, 11 | ) 12 | 13 | 14 | def test_metadata_variables(): 15 | assert bool(__author__) and isinstance(__author__, str) 16 | assert bool(__copyright__) and isinstance(__copyright__, str) 17 | assert bool(__description__) and isinstance(__description__, str) 18 | assert bool(__email__) and isinstance(__email__, str) 19 | assert bool(__license__) and isinstance(__license__, str) 20 | assert bool(__title__) and isinstance(__title__, str) 21 | assert bool(__version__) and isinstance(__version__, str) 22 | 23 | 24 | if __name__ == "__main__": 25 | pytest.main() 26 | -------------------------------------------------------------------------------- /tests/test_operations.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | import fsutil 7 | 8 | 9 | def test_clean_dir_only_dirs(temp_path): 10 | fsutil.create_dir(temp_path("x/y/z/a")) 11 | fsutil.create_dir(temp_path("x/y/z/b")) 12 | fsutil.create_dir(temp_path("x/y/z/c")) 13 | fsutil.create_dir(temp_path("x/y/z/d")) 14 | fsutil.create_dir(temp_path("x/y/z/e")) 15 | fsutil.create_file(temp_path("x/y/z/b/f.txt"), content="hello world") 16 | fsutil.create_file(temp_path("x/y/z/d/f.txt"), content="hello world") 17 | fsutil.clean_dir(temp_path("x/y"), dirs=False, files=True) 18 | assert fsutil.exists(temp_path("x/y/z/a")) 19 | assert fsutil.exists(temp_path("x/y/z/b")) 20 | assert fsutil.exists(temp_path("x/y/z/c")) 21 | assert fsutil.exists(temp_path("x/y/z/d")) 22 | assert fsutil.exists(temp_path("x/y/z/e")) 23 | fsutil.clean_dir(temp_path("x/y"), dirs=True, files=True) 24 | assert not fsutil.exists(temp_path("x/y/z/a")) 25 | assert fsutil.exists(temp_path("x/y/z/b")) 26 | assert not fsutil.exists(temp_path("x/y/z/c")) 27 | assert fsutil.exists(temp_path("x/y/z/d")) 28 | assert not fsutil.exists(temp_path("x/y/z/e")) 29 | 30 | 31 | def test_clean_dir_only_files(temp_path): 32 | fsutil.create_file(temp_path("a/b/c/f1.txt"), content="hello world") 33 | fsutil.create_file(temp_path("a/b/c/f2.txt")) 34 | fsutil.create_file(temp_path("a/b/c/f3.txt"), content="hello world") 35 | fsutil.create_file(temp_path("a/b/c/f4.txt")) 36 | fsutil.create_file(temp_path("a/b/c/f5.txt"), content="hello world") 37 | fsutil.clean_dir(temp_path("a"), dirs=False, files=False) 38 | assert fsutil.exists(temp_path("a/b/c/f1.txt")) 39 | assert fsutil.exists(temp_path("a/b/c/f2.txt")) 40 | assert fsutil.exists(temp_path("a/b/c/f3.txt")) 41 | assert fsutil.exists(temp_path("a/b/c/f4.txt")) 42 | assert fsutil.exists(temp_path("a/b/c/f5.txt")) 43 | fsutil.clean_dir(temp_path("a"), dirs=False, files=True) 44 | assert fsutil.exists(temp_path("a/b/c/f1.txt")) 45 | assert not fsutil.exists(temp_path("a/b/c/f2.txt")) 46 | assert fsutil.exists(temp_path("a/b/c/f3.txt")) 47 | assert not fsutil.exists(temp_path("a/b/c/f4.txt")) 48 | assert fsutil.exists(temp_path("a/b/c/f5.txt")) 49 | 50 | 51 | def test_clean_dir_dirs_and_files(temp_path): 52 | fsutil.create_file(temp_path("a/b/c/f1.txt")) 53 | fsutil.create_file(temp_path("a/b/c/f2.txt")) 54 | fsutil.create_file(temp_path("a/b/c/f3.txt")) 55 | fsutil.create_file(temp_path("a/b/c/d/f4.txt")) 56 | fsutil.create_file(temp_path("a/b/c/d/f5.txt")) 57 | fsutil.clean_dir(temp_path("a"), dirs=True, files=True) 58 | assert not fsutil.exists(temp_path("a/b/c/d/f5.txt")) 59 | assert not fsutil.exists(temp_path("a/b/c/d/f4.txt")) 60 | assert not fsutil.exists(temp_path("a/b/c/f3.txt")) 61 | assert not fsutil.exists(temp_path("a/b/c/f2.txt")) 62 | assert not fsutil.exists(temp_path("a/b/c/f1.txt")) 63 | assert not fsutil.exists(temp_path("a/b/c")) 64 | assert not fsutil.exists(temp_path("a/b")) 65 | assert fsutil.exists(temp_path("a")) 66 | 67 | 68 | def test_copy_file(temp_path): 69 | path = temp_path("a/b/c.txt") 70 | fsutil.create_file(path, content="hello world") 71 | dest = temp_path("x/y/z.txt") 72 | fsutil.copy_file(path, dest) 73 | assert fsutil.is_file(path) 74 | assert fsutil.is_file(dest) 75 | assert fsutil.get_file_hash(path) == fsutil.get_file_hash(dest) 76 | 77 | 78 | def test_copy_dir(temp_path): 79 | fsutil.create_file(temp_path("a/b/f-1.txt")) 80 | fsutil.create_file(temp_path("a/b/f-2.txt")) 81 | fsutil.create_file(temp_path("a/b/f-3.txt")) 82 | fsutil.copy_dir(temp_path("a/b"), temp_path("x/y/z")) 83 | filepaths = fsutil.list_files(temp_path("a/b")) 84 | filenames = [fsutil.get_filename(filepath) for filepath in filepaths] 85 | assert len(filepaths) == 3 86 | assert filenames == ["f-1.txt", "f-2.txt", "f-3.txt"] 87 | filepaths = fsutil.list_files(temp_path("x/y/z/b/")) 88 | filenames = [fsutil.get_filename(filepath) for filepath in filepaths] 89 | assert len(filepaths) == 3 90 | assert filenames == ["f-1.txt", "f-2.txt", "f-3.txt"] 91 | 92 | 93 | def test_copy_dir_with_overwrite(temp_path): 94 | fsutil.create_file(temp_path("a/b/f-1.txt")) 95 | fsutil.create_file(temp_path("a/b/f-2.txt")) 96 | fsutil.create_file(temp_path("a/b/f-3.txt")) 97 | fsutil.create_file(temp_path("x/y/z/f-0.txt")) 98 | fsutil.copy_dir(temp_path("a/b"), temp_path("x/y/z"), overwrite=False) 99 | with pytest.raises(OSError): 100 | fsutil.copy_dir(temp_path("a/b"), temp_path("x/y/z"), overwrite=False) 101 | fsutil.copy_dir(temp_path("a/b"), temp_path("x/y/z"), overwrite=True) 102 | 103 | 104 | def test_copy_dir_content(temp_path): 105 | fsutil.create_file(temp_path("a/b/f-1.txt")) 106 | fsutil.create_file(temp_path("a/b/f-2.txt")) 107 | fsutil.create_file(temp_path("a/b/f-3.txt")) 108 | fsutil.copy_dir_content(temp_path("a/b"), temp_path("z")) 109 | filepaths = fsutil.list_files(temp_path("z")) 110 | filenames = [fsutil.get_filename(filepath) for filepath in filepaths] 111 | assert len(filepaths) == 3 112 | assert filenames == ["f-1.txt", "f-2.txt", "f-3.txt"] 113 | 114 | 115 | def test_create_file(temp_path): 116 | path = temp_path("a/b/c.txt") 117 | assert not fsutil.exists(path) 118 | fsutil.create_file(path, content="hello world") 119 | assert fsutil.exists(path) 120 | assert fsutil.is_file(path) 121 | assert fsutil.read_file(path) == "hello world" 122 | 123 | 124 | def test_create_file_with_overwrite(temp_path): 125 | path = temp_path("a/b/c.txt") 126 | fsutil.create_file(path, content="hello world") 127 | with pytest.raises(OSError): 128 | fsutil.create_file(path, content="hello world") 129 | fsutil.create_file(path, content="hello moon", overwrite=True) 130 | assert fsutil.read_file(path) == "hello moon" 131 | 132 | 133 | def test_delete_dir(temp_path): 134 | fsutil.create_file(temp_path("a/b/c/d.txt")) 135 | fsutil.create_file(temp_path("a/b/c/e.txt")) 136 | fsutil.create_file(temp_path("a/b/c/f.txt")) 137 | deleted = fsutil.delete_dir(temp_path("a/c/")) 138 | assert not deleted 139 | deleted = fsutil.delete_dir(temp_path("a/b/")) 140 | assert deleted 141 | assert fsutil.exists(temp_path("a")) 142 | assert not fsutil.exists(temp_path("a/b")) 143 | 144 | 145 | def test_delete_dir_content(temp_path): 146 | fsutil.create_file(temp_path("a/b/c/d.txt")) 147 | fsutil.create_file(temp_path("a/b/e.txt")) 148 | fsutil.create_file(temp_path("a/b/f.txt")) 149 | path = temp_path("a/b/") 150 | fsutil.delete_dir_content(path) 151 | assert fsutil.is_empty_dir(path) 152 | 153 | 154 | def test_delete_dirs(temp_path): 155 | fsutil.create_file(temp_path("a/b/c/document.txt")) 156 | fsutil.create_file(temp_path("a/b/d/document.txt")) 157 | fsutil.create_file(temp_path("a/b/e/document.txt")) 158 | fsutil.create_file(temp_path("a/b/f/document.txt")) 159 | path1 = temp_path("a/b/c/") 160 | path2 = temp_path("a/b/d/") 161 | path3 = temp_path("a/b/e/") 162 | path4 = temp_path("a/b/f/") 163 | assert fsutil.exists(path1) 164 | assert fsutil.exists(path2) 165 | assert fsutil.exists(path3) 166 | assert fsutil.exists(path4) 167 | fsutil.delete_dirs(path1, path2, path3, path4) 168 | assert not fsutil.exists(path1) 169 | assert not fsutil.exists(path2) 170 | assert not fsutil.exists(path3) 171 | assert not fsutil.exists(path4) 172 | 173 | 174 | def test_delete_file(temp_path): 175 | path = temp_path("a/b/c.txt") 176 | fsutil.create_file(path) 177 | assert fsutil.exists(path) 178 | deleted = fsutil.delete_file(temp_path("a/b/d.txt")) 179 | assert not deleted 180 | deleted = fsutil.delete_file(path) 181 | assert deleted 182 | assert not fsutil.exists(path) 183 | 184 | 185 | def test_delete_files(temp_path): 186 | path1 = temp_path("a/b/c/document.txt") 187 | path2 = temp_path("a/b/d/document.txt") 188 | path3 = temp_path("a/b/e/document.txt") 189 | path4 = temp_path("a/b/f/document.txt") 190 | fsutil.create_file(path1) 191 | fsutil.create_file(path2) 192 | fsutil.create_file(path3) 193 | fsutil.create_file(path4) 194 | assert fsutil.exists(path1) 195 | assert fsutil.exists(path2) 196 | assert fsutil.exists(path3) 197 | assert fsutil.exists(path4) 198 | fsutil.delete_files(path1, path2, path3, path4) 199 | assert not fsutil.exists(path1) 200 | assert not fsutil.exists(path2) 201 | assert not fsutil.exists(path3) 202 | assert not fsutil.exists(path4) 203 | 204 | 205 | def test_download_file(temp_path): 206 | url = "https://raw.githubusercontent.com/fabiocaccamo/python-fsutil/main/README.md" 207 | path = fsutil.download_file(url, dirpath=temp_path()) 208 | assert fsutil.exists(path) 209 | lines = fsutil.read_file_lines(path, skip_empty=False) 210 | lines_count = len(lines) 211 | assert 500 < lines_count < 1000 212 | fsutil.remove_file(path) 213 | assert not fsutil.exists(path) 214 | 215 | 216 | def test_download_file_multiple_to_temp_dir(temp_path): 217 | for _ in range(3): 218 | url = "https://raw.githubusercontent.com/fabiocaccamo/python-fsutil/main/README.md" 219 | path = fsutil.download_file(url) 220 | assert fsutil.exists(path) 221 | lines = fsutil.read_file_lines(path, skip_empty=False) 222 | lines_count = len(lines) 223 | assert 500 < lines_count < 1000 224 | fsutil.remove_file(path) 225 | assert not fsutil.exists(path) 226 | 227 | 228 | def test_download_file_without_requests_installed(temp_path): 229 | url = "https://raw.githubusercontent.com/fabiocaccamo/python-fsutil/main/README.md" 230 | with patch("fsutil.operations.require_requests", side_effect=ModuleNotFoundError()): 231 | with pytest.raises(ModuleNotFoundError): 232 | fsutil.download_file(url, dirpath=temp_path()) 233 | 234 | 235 | def test_list_dirs(temp_path): 236 | for i in range(0, 5): 237 | fsutil.create_dir(temp_path(f"a/b/c/d-{i}")) 238 | fsutil.create_file(temp_path(f"a/b/c/f-{i}"), content=f"{i}") 239 | dirpaths = fsutil.list_dirs(temp_path("a/b/c")) 240 | dirnames = [fsutil.split_path(dirpath)[-1] for dirpath in dirpaths] 241 | assert len(dirpaths) == 5 242 | assert dirnames == ["d-0", "d-1", "d-2", "d-3", "d-4"] 243 | 244 | 245 | def test_list_files(temp_path): 246 | for i in range(0, 5): 247 | fsutil.create_dir(temp_path(f"a/b/c/d-{i}")) 248 | fsutil.create_file(temp_path(f"a/b/c/f-{i}.txt"), content=f"{i}") 249 | filepaths = fsutil.list_files(temp_path("a/b/c")) 250 | filenames = [fsutil.get_filename(filepath) for filepath in filepaths] 251 | assert len(filepaths) == 5 252 | assert filenames == ["f-0.txt", "f-1.txt", "f-2.txt", "f-3.txt", "f-4.txt"] 253 | 254 | 255 | def test_make_dirs(temp_path): 256 | path = temp_path("a/b/c/") 257 | fsutil.make_dirs(path) 258 | assert fsutil.is_dir(path) 259 | 260 | 261 | def test_make_dirs_race_condition(temp_path): 262 | path = temp_path("a/b/c/") 263 | for _ in range(0, 20): 264 | t = threading.Thread(target=fsutil.make_dirs, args=[path], kwargs={}) 265 | t.start() 266 | t.join() 267 | assert fsutil.is_dir(path) 268 | 269 | 270 | def test_make_dirs_with_existing_dir(temp_path): 271 | path = temp_path("a/b/c/") 272 | fsutil.create_dir(path) 273 | fsutil.make_dirs(path) 274 | assert fsutil.is_dir(path) 275 | 276 | 277 | def test_make_dirs_with_existing_file(temp_path): 278 | path = temp_path("a/b/c.txt") 279 | fsutil.create_file(path) 280 | with pytest.raises(OSError): 281 | fsutil.make_dirs(path) 282 | 283 | 284 | def test_make_dirs_for_file(temp_path): 285 | path = temp_path("a/b/c.txt") 286 | fsutil.make_dirs_for_file(path) 287 | assert fsutil.is_dir(temp_path("a/b/")) 288 | assert not fsutil.is_dir(path) 289 | assert not fsutil.is_file(path) 290 | 291 | 292 | def test_make_dirs_for_file_with_existing_file(temp_path): 293 | path = temp_path("a/b/c.txt") 294 | fsutil.create_file(path) 295 | fsutil.make_dirs_for_file(path) 296 | assert fsutil.is_dir(temp_path("a/b/")) 297 | assert not fsutil.is_dir(path) 298 | assert fsutil.is_file(path) 299 | 300 | 301 | def test_make_dirs_for_file_with_existing_dir(temp_path): 302 | path = temp_path("a/b/c.txt") 303 | fsutil.create_dir(path) 304 | with pytest.raises(OSError): 305 | fsutil.make_dirs_for_file(path) 306 | 307 | 308 | def test_make_dirs_for_file_with_filename_only(temp_path): 309 | path = "document.txt" 310 | fsutil.make_dirs_for_file(path) 311 | assert not fsutil.is_file(path) 312 | 313 | 314 | def test_move_dir(temp_path): 315 | path = temp_path("a/b/c.txt") 316 | fsutil.create_file(path, content="Hello World") 317 | fsutil.move_dir(temp_path("a/b"), temp_path("x/y")) 318 | assert not fsutil.exists(path) 319 | assert fsutil.is_file(temp_path("x/y/b/c.txt")) 320 | 321 | 322 | def test_move_file(temp_path): 323 | path = temp_path("a/b/c.txt") 324 | fsutil.create_file(path, content="Hello World") 325 | dest = temp_path("a") 326 | fsutil.move_file(path, dest) 327 | assert not fsutil.exists(path) 328 | assert fsutil.is_file(temp_path("a/c.txt")) 329 | 330 | 331 | def test_rename_dir(temp_path): 332 | path = temp_path("a/b/c") 333 | fsutil.make_dirs(path) 334 | fsutil.rename_dir(path, "d") 335 | assert not fsutil.exists(path) 336 | path = temp_path("a/b/d") 337 | assert fsutil.exists(path) 338 | 339 | 340 | def test_rename_dir_with_existing_name(temp_path): 341 | path = temp_path("a/b/c") 342 | fsutil.make_dirs(path) 343 | fsutil.make_dirs(temp_path("a/b/d")) 344 | with pytest.raises(OSError): 345 | fsutil.rename_dir(path, "d") 346 | 347 | 348 | def test_rename_file(temp_path): 349 | path = temp_path("a/b/c.txt") 350 | fsutil.create_file(path) 351 | fsutil.rename_file(path, "d.txt.backup") 352 | assert not fsutil.exists(path) 353 | path = temp_path("a/b/d.txt.backup") 354 | assert fsutil.exists(path) 355 | 356 | 357 | def test_rename_file_with_existing_name(temp_path): 358 | path = temp_path("a/b/c") 359 | fsutil.create_file(path) 360 | path = temp_path("a/b/d") 361 | fsutil.create_file(path) 362 | with pytest.raises(OSError): 363 | fsutil.rename_file(path, "c") 364 | 365 | 366 | def test_rename_file_basename(temp_path): 367 | path = temp_path("a/b/c.txt") 368 | fsutil.create_file(path) 369 | fsutil.rename_file_basename(path, "d") 370 | assert not fsutil.exists(path) 371 | path = temp_path("a/b/d.txt") 372 | assert fsutil.exists(path) 373 | 374 | 375 | def test_rename_file_extension(temp_path): 376 | path = temp_path("a/b/c.txt") 377 | fsutil.create_file(path) 378 | fsutil.rename_file_extension(path, "json") 379 | assert not fsutil.exists(path) 380 | path = temp_path("a/b/c.json") 381 | assert fsutil.exists(path) 382 | 383 | 384 | def test_remove_dir(temp_path): 385 | fsutil.create_file(temp_path("a/b/c/d.txt")) 386 | fsutil.create_file(temp_path("a/b/c/e.txt")) 387 | fsutil.create_file(temp_path("a/b/c/f.txt")) 388 | removed = fsutil.remove_dir(temp_path("a/c/")) 389 | assert not removed 390 | removed = fsutil.remove_dir(temp_path("a/b/")) 391 | assert removed 392 | assert fsutil.exists(temp_path("a")) 393 | assert not fsutil.exists(temp_path("a/b")) 394 | 395 | 396 | def test_remove_dir_content(temp_path): 397 | fsutil.create_file(temp_path("a/b/c/d.txt")) 398 | fsutil.create_file(temp_path("a/b/e.txt")) 399 | fsutil.create_file(temp_path("a/b/f.txt")) 400 | path = temp_path("a/b/") 401 | fsutil.remove_dir_content(path) 402 | assert fsutil.is_empty_dir(path) 403 | 404 | 405 | def test_remove_dirs(temp_path): 406 | fsutil.create_file(temp_path("a/b/c/document.txt")) 407 | fsutil.create_file(temp_path("a/b/d/document.txt")) 408 | fsutil.create_file(temp_path("a/b/e/document.txt")) 409 | fsutil.create_file(temp_path("a/b/f/document.txt")) 410 | path1 = temp_path("a/b/c/") 411 | path2 = temp_path("a/b/d/") 412 | path3 = temp_path("a/b/e/") 413 | path4 = temp_path("a/b/f/") 414 | assert fsutil.exists(path1) 415 | assert fsutil.exists(path2) 416 | assert fsutil.exists(path3) 417 | assert fsutil.exists(path4) 418 | fsutil.remove_dirs(path1, path2, path3, path4) 419 | assert not fsutil.exists(path1) 420 | assert not fsutil.exists(path2) 421 | assert not fsutil.exists(path3) 422 | assert not fsutil.exists(path4) 423 | 424 | 425 | def test_remove_file(temp_path): 426 | path = temp_path("a/b/c.txt") 427 | fsutil.create_file(path) 428 | assert fsutil.exists(path) 429 | removed = fsutil.remove_file(temp_path("a/b/d.txt")) 430 | assert not removed 431 | removed = fsutil.remove_file(path) 432 | assert removed 433 | assert not fsutil.exists(path) 434 | 435 | 436 | def test_remove_files(temp_path): 437 | path1 = temp_path("a/b/c/document.txt") 438 | path2 = temp_path("a/b/d/document.txt") 439 | path3 = temp_path("a/b/e/document.txt") 440 | path4 = temp_path("a/b/f/document.txt") 441 | fsutil.create_file(path1) 442 | fsutil.create_file(path2) 443 | fsutil.create_file(path3) 444 | fsutil.create_file(path4) 445 | assert fsutil.exists(path1) 446 | assert fsutil.exists(path2) 447 | assert fsutil.exists(path3) 448 | assert fsutil.exists(path4) 449 | fsutil.remove_files(path1, path2, path3, path4) 450 | assert not fsutil.exists(path1) 451 | assert not fsutil.exists(path2) 452 | assert not fsutil.exists(path3) 453 | assert not fsutil.exists(path4) 454 | 455 | 456 | def test_replace_file(temp_path): 457 | dest = temp_path("a/b/c.txt") 458 | src = temp_path("d/e/f.txt") 459 | fsutil.create_file(dest, "old") 460 | fsutil.create_file(src, "new") 461 | fsutil.replace_file(dest, src) 462 | content = fsutil.read_file(dest) 463 | assert content == "new" 464 | assert fsutil.exists(src) 465 | 466 | 467 | def test_replace_file_with_autodelete(temp_path): 468 | dest_file = temp_path("a/b/c.txt") 469 | src_file = temp_path("d/e/f.txt") 470 | fsutil.create_file(dest_file, "old") 471 | fsutil.create_file(src_file, "new") 472 | fsutil.replace_file(dest_file, src_file, autodelete=True) 473 | content = fsutil.read_file(dest_file) 474 | assert content == "new" 475 | assert not fsutil.exists(src_file) 476 | 477 | 478 | def test_replace_dir(temp_path): 479 | dest_dir = temp_path("a/b/") 480 | dest_file = temp_path("a/b/c.txt") 481 | src_dir = temp_path("d/e/") 482 | src_file = temp_path("d/e/f.txt") 483 | fsutil.create_file(dest_file, "old") 484 | fsutil.create_file(src_file, "new") 485 | fsutil.replace_dir(dest_dir, src_dir) 486 | content = fsutil.read_file(temp_path("a/b/f.txt")) 487 | assert content == "new" 488 | assert fsutil.exists(src_dir) 489 | 490 | 491 | def test_replace_dir_with_autodelete(temp_path): 492 | dest_dir = temp_path("a/b/") 493 | dest_file = temp_path("a/b/c.txt") 494 | src_dir = temp_path("d/e/") 495 | src_file = temp_path("d/e/f.txt") 496 | fsutil.create_file(dest_file, "old") 497 | fsutil.create_file(src_file, "new") 498 | fsutil.replace_dir(dest_dir, src_dir, autodelete=True) 499 | content = fsutil.read_file(temp_path("a/b/f.txt")) 500 | assert content == "new" 501 | assert not fsutil.exists(src_dir) 502 | 503 | 504 | def test_search_files(temp_path): 505 | fsutil.create_file(temp_path("a/b/c/IMG_1000.jpg")) 506 | fsutil.create_file(temp_path("a/b/c/IMG_1001.jpg")) 507 | fsutil.create_file(temp_path("a/b/c/IMG_1002.png")) 508 | fsutil.create_file(temp_path("a/b/c/IMG_1003.jpg")) 509 | fsutil.create_file(temp_path("a/b/c/IMG_1004.jpg")) 510 | fsutil.create_file(temp_path("a/x/c/IMG_1005.png")) 511 | fsutil.create_file(temp_path("x/b/c/IMG_1006.png")) 512 | fsutil.create_file(temp_path("a/b/c/DOC_1007.png")) 513 | results = fsutil.search_files(temp_path("a/"), "**/c/IMG_*.png") 514 | expected_results = [ 515 | temp_path("a/b/c/IMG_1002.png"), 516 | temp_path("a/x/c/IMG_1005.png"), 517 | ] 518 | assert results == expected_results 519 | 520 | 521 | def test_search_dirs(temp_path): 522 | fsutil.create_file(temp_path("a/b/c/IMG_1000.jpg")) 523 | fsutil.create_file(temp_path("x/y/z/c/IMG_1001.jpg")) 524 | fsutil.create_file(temp_path("a/c/IMG_1002.png")) 525 | fsutil.create_file(temp_path("c/b/c/IMG_1003.jpg")) 526 | results = fsutil.search_dirs(temp_path(""), "**/c") 527 | expected_results = [ 528 | temp_path("a/b/c"), 529 | temp_path("a/c"), 530 | temp_path("c"), 531 | temp_path("c/b/c"), 532 | temp_path("x/y/z/c"), 533 | ] 534 | assert results == expected_results 535 | 536 | 537 | if __name__ == "__main__": 538 | pytest.main() 539 | -------------------------------------------------------------------------------- /tests/test_paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | import fsutil 7 | 8 | 9 | def test_get_file_basename(): 10 | assert fsutil.get_file_basename("Document") == "Document" 11 | assert fsutil.get_file_basename("Document.txt") == "Document" 12 | assert fsutil.get_file_basename(".Document.txt") == ".Document" 13 | assert fsutil.get_file_basename("/root/a/b/c/Document.txt") == "Document" 14 | assert ( 15 | fsutil.get_file_basename("https://domain-name.com/Document.txt?p=1") 16 | == "Document" 17 | ) 18 | 19 | 20 | def test_get_file_extension(): 21 | assert fsutil.get_file_extension("Document") == "" 22 | assert fsutil.get_file_extension("Document.txt") == "txt" 23 | assert fsutil.get_file_extension(".Document.txt") == "txt" 24 | assert fsutil.get_file_extension("/root/a/b/c/Document.txt") == "txt" 25 | assert ( 26 | fsutil.get_file_extension("https://domain-name.com/Document.txt?p=1") == "txt" 27 | ) 28 | 29 | 30 | def test_get_filename(): 31 | assert fsutil.get_filename("Document") == "Document" 32 | assert fsutil.get_filename("Document.txt") == "Document.txt" 33 | assert fsutil.get_filename(".Document.txt") == ".Document.txt" 34 | assert fsutil.get_filename("/root/a/b/c/Document.txt") == "Document.txt" 35 | assert ( 36 | fsutil.get_filename("https://domain-name.com/Document.txt?p=1") 37 | == "Document.txt" 38 | ) 39 | 40 | 41 | def test_get_parent_dir(): 42 | s = "/root/a/b/c/Document.txt" 43 | assert fsutil.get_parent_dir(s) == os.path.normpath("/root/a/b/c") 44 | assert fsutil.get_parent_dir(s, levels=0) == os.path.normpath("/root/a/b/c") 45 | assert fsutil.get_parent_dir(s, levels=1) == os.path.normpath("/root/a/b/c") 46 | assert fsutil.get_parent_dir(s, levels=2) == os.path.normpath("/root/a/b") 47 | assert fsutil.get_parent_dir(s, levels=3) == os.path.normpath("/root/a") 48 | assert fsutil.get_parent_dir(s, levels=4) == os.path.normpath("/root") 49 | assert fsutil.get_parent_dir(s, levels=5) == os.path.normpath("/") 50 | assert fsutil.get_parent_dir(s, levels=6) == os.path.normpath("/") 51 | 52 | 53 | def test_get_unique_name(temp_path): 54 | path = temp_path("a/b/c") 55 | fsutil.create_dir(path) 56 | name = fsutil.get_unique_name( 57 | path, 58 | prefix="custom-prefix", 59 | suffix="custom-suffix", 60 | extension="txt", 61 | separator="_", 62 | ) 63 | basename, extension = fsutil.split_filename(name) 64 | assert basename.startswith("custom-prefix_") 65 | assert basename.endswith("_custom-suffix") 66 | assert extension == "txt" 67 | 68 | 69 | def test_join_filename(): 70 | assert fsutil.join_filename("Document", "txt") == "Document.txt" 71 | assert fsutil.join_filename("Document", ".txt") == "Document.txt" 72 | assert fsutil.join_filename(" Document ", " txt ") == "Document.txt" 73 | assert fsutil.join_filename("Document", " .txt ") == "Document.txt" 74 | assert fsutil.join_filename("Document", "") == "Document" 75 | assert fsutil.join_filename("", "txt") == "txt" 76 | 77 | 78 | def test_join_filepath(): 79 | assert fsutil.join_filepath("a/b/c", "Document.txt") == os.path.normpath( 80 | "a/b/c/Document.txt" 81 | ) 82 | 83 | 84 | def test_join_path_with_absolute_path(): 85 | assert fsutil.join_path("/a/b/c/", "/document.txt") == os.path.normpath( 86 | "/a/b/c/document.txt" 87 | ) 88 | 89 | 90 | @patch("os.sep", "\\") 91 | def test_join_path_with_absolute_path_on_windows(): 92 | assert fsutil.join_path("/a/b/c/", "/document.txt") == os.path.normpath( 93 | "/a/b/c/document.txt" 94 | ) 95 | 96 | 97 | def test_join_path_with_parent_dirs(): 98 | assert fsutil.join_path("/a/b/c/", "../../document.txt") == os.path.normpath( 99 | "/a/document.txt" 100 | ) 101 | 102 | 103 | def test_split_filename(): 104 | assert fsutil.split_filename("Document") == ("Document", "") 105 | assert fsutil.split_filename(".Document") == (".Document", "") 106 | assert fsutil.split_filename("Document.txt") == ("Document", "txt") 107 | assert fsutil.split_filename(".Document.txt") == (".Document", "txt") 108 | assert fsutil.split_filename("/root/a/b/c/Document.txt") == ("Document", "txt") 109 | assert fsutil.split_filename("https://domain-name.com/Document.txt?p=1") == ( 110 | "Document", 111 | "txt", 112 | ) 113 | 114 | 115 | def test_split_filepath(): 116 | s = os.path.normpath("/root/a/b/c/Document.txt") 117 | assert fsutil.split_filepath(s) == (os.path.normpath("/root/a/b/c"), "Document.txt") 118 | 119 | 120 | def test_split_filepath_with_filename_only(): 121 | s = os.path.normpath("Document.txt") 122 | assert fsutil.split_filepath(s) == ("", "Document.txt") 123 | 124 | 125 | def test_split_path(): 126 | s = os.path.normpath("/root/a/b/c/Document.txt") 127 | assert fsutil.split_path(s) == ["root", "a", "b", "c", "Document.txt"] 128 | 129 | 130 | def test_transform_filepath_without_args(): 131 | s = "/root/a/b/c/Document.txt" 132 | with pytest.raises(ValueError): 133 | fsutil.transform_filepath(s) 134 | 135 | 136 | def test_transform_filepath_with_empty_str_args(): 137 | s = "/root/a/b/c/Document.txt" 138 | assert fsutil.transform_filepath(s, dirpath="") == os.path.normpath("Document.txt") 139 | assert fsutil.transform_filepath(s, basename="") == os.path.normpath( 140 | "/root/a/b/c/txt" 141 | ) 142 | assert fsutil.transform_filepath(s, extension="") == os.path.normpath( 143 | "/root/a/b/c/Document" 144 | ) 145 | assert fsutil.transform_filepath( 146 | s, dirpath="/root/x/y/z/", basename="NewDocument", extension="xls" 147 | ) == os.path.normpath("/root/x/y/z/NewDocument.xls") 148 | with pytest.raises(ValueError): 149 | fsutil.transform_filepath(s, dirpath="", basename="", extension="") 150 | 151 | 152 | def test_transform_filepath_with_str_args(): 153 | s = "/root/a/b/c/Document.txt" 154 | assert fsutil.transform_filepath(s, dirpath="/root/x/y/z/") == os.path.normpath( 155 | "/root/x/y/z/Document.txt" 156 | ) 157 | assert fsutil.transform_filepath(s, basename="NewDocument") == os.path.normpath( 158 | "/root/a/b/c/NewDocument.txt" 159 | ) 160 | assert fsutil.transform_filepath(s, extension="xls") == os.path.normpath( 161 | "/root/a/b/c/Document.xls" 162 | ) 163 | assert fsutil.transform_filepath(s, extension=".xls") == os.path.normpath( 164 | "/root/a/b/c/Document.xls" 165 | ) 166 | assert fsutil.transform_filepath( 167 | s, dirpath="/root/x/y/z/", basename="NewDocument", extension="xls" 168 | ) == os.path.normpath("/root/x/y/z/NewDocument.xls") 169 | 170 | 171 | def test_transform_filepath_with_callable_args(): 172 | s = "/root/a/b/c/Document.txt" 173 | assert fsutil.transform_filepath( 174 | s, dirpath=lambda d: f"{d}/x/y/z/" 175 | ) == os.path.normpath("/root/a/b/c/x/y/z/Document.txt") 176 | assert fsutil.transform_filepath( 177 | s, basename=lambda b: b.lower() 178 | ) == os.path.normpath("/root/a/b/c/document.txt") 179 | assert fsutil.transform_filepath(s, extension=lambda e: "xls") == os.path.normpath( 180 | "/root/a/b/c/Document.xls" 181 | ) 182 | assert fsutil.transform_filepath( 183 | s, 184 | dirpath=lambda d: f"{d}/x/y/z/", 185 | basename=lambda b: b.lower(), 186 | extension=lambda e: "xls", 187 | ) == os.path.normpath("/root/a/b/c/x/y/z/document.xls") 188 | 189 | 190 | if __name__ == "__main__": 191 | pytest.main() 192 | -------------------------------------------------------------------------------- /tests/test_perms.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | import fsutil 6 | 7 | 8 | @pytest.mark.skipif(sys.platform.startswith("win"), reason="Test skipped on Windows") 9 | def test_get_permissions(temp_path): 10 | path = temp_path("a/b/c.txt") 11 | fsutil.write_file(path, content="Hello World") 12 | permissions = fsutil.get_permissions(path) 13 | assert permissions == 644 14 | 15 | 16 | @pytest.mark.skipif(sys.platform.startswith("win"), reason="Test skipped on Windows") 17 | def test_set_permissions(temp_path): 18 | path = temp_path("a/b/c.txt") 19 | fsutil.write_file(path, content="Hello World") 20 | fsutil.set_permissions(path, 777) 21 | permissions = fsutil.get_permissions(path) 22 | assert permissions == 777 23 | 24 | 25 | if __name__ == "__main__": 26 | pytest.main() 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{310,311,312,313}, 4 | 5 | [testenv] 6 | basepython = 7 | py310: python3.10 8 | py311: python3.11 9 | py312: python3.12 10 | py313: python3.13 11 | 12 | passenv = CI,GITHUB_WORKFLOW 13 | 14 | deps = 15 | -r requirements.txt 16 | -r requirements-test.txt 17 | 18 | commands = 19 | pre-commit run --all-files 20 | mypy --install-types --non-interactive 21 | pytest tests --cov=fsutil --cov-report=term-missing --cov-fail-under=90 22 | --------------------------------------------------------------------------------