├── path ├── py.typed ├── compat │ └── py38.py ├── classes.py ├── matchers.py ├── masks.py └── __init__.py ├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── towncrier.toml ├── docs ├── history.rst ├── api.rst ├── index.rst ├── conf.py └── Makefile ├── .gitignore ├── tea.yaml ├── .pre-commit-config.yaml ├── SECURITY.md ├── tests ├── conftest.py └── test_path.py ├── .editorconfig ├── mypy.ini ├── .readthedocs.yaml ├── .coveragerc ├── pytest.ini ├── ruff.toml ├── tox.ini ├── pyproject.toml ├── README.rst └── NEWS.rst /path/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .tox 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/path 2 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 1 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.egg-info 4 | *.egg 5 | .eggs/ 6 | MANIFEST 7 | build 8 | dist 9 | .tox 10 | docs/_build 11 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0x32392EaEA1FDE87733bEEc3b184C9006501c4A82' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | === 2 | API 3 | === 4 | 5 | .. important:: 6 | The documented methods' signatures are not always correct. 7 | See :class:`path.Path`. 8 | 9 | .. automodule:: path 10 | :members: 11 | :undoc-members: 12 | 13 | .. automodule:: path.masks 14 | :members: 15 | :undoc-members: 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | def pytest_configure(config): 5 | disable_broken_doctests(config) 6 | 7 | 8 | def disable_broken_doctests(config): 9 | """ 10 | Workaround for python/cpython#117692. 11 | """ 12 | if (3, 11, 9) <= sys.version_info < (3, 12): 13 | config.option.doctestmodules = False 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = False 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | */pep517-build-env-* 6 | path/py37compat.py 7 | disable_warnings = 8 | couldnt-parse 9 | 10 | [report] 11 | show_missing = True 12 | exclude_also = 13 | # Exclude common false positives per 14 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 15 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 16 | class .*\bProtocol\): 17 | if TYPE_CHECKING: 18 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | api 12 | history 13 | 14 | .. tidelift-referral-banner:: 15 | 16 | Thanks to Mahan Marwat for transferring the ``path`` name on 17 | Read The Docs from `path `_ 18 | to this project. 19 | 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /path/compat/py38.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info < (3, 9): 4 | 5 | def removesuffix(self, suffix): 6 | # suffix='' should not call self[:-0]. 7 | if suffix and self.endswith(suffix): 8 | return self[: -len(suffix)] 9 | else: 10 | return self[:] 11 | 12 | def removeprefix(self, prefix): 13 | if self.startswith(prefix): 14 | return self[len(prefix) :] 15 | else: 16 | return self[:] 17 | 18 | else: 19 | 20 | def removesuffix(self, suffix): 21 | return self.removesuffix(suffix) 22 | 23 | def removeprefix(self, prefix): 24 | return self.removeprefix(prefix) 25 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | -------------------------------------------------------------------------------- /path/classes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from typing import Any, Callable, Generic, TypeVar 5 | 6 | 7 | class ClassProperty(property): 8 | def __get__(self, cls: Any, owner: type | None = None) -> Any: 9 | assert self.fget is not None 10 | return self.fget.__get__(None, owner)() 11 | 12 | 13 | _T = TypeVar("_T") 14 | 15 | 16 | class multimethod(Generic[_T]): 17 | """ 18 | Acts like a classmethod when invoked from the class and like an 19 | instancemethod when invoked from the instance. 20 | """ 21 | 22 | func: Callable[..., _T] 23 | 24 | def __init__(self, func: Callable[..., _T]): 25 | self.func = func 26 | 27 | def __get__(self, instance: _T | None, owner: type[_T] | None) -> Callable[..., _T]: 28 | """ 29 | If called on an instance, pass the instance as the first 30 | argument. 31 | """ 32 | return ( 33 | functools.partial(self.func, owner) 34 | if instance is None 35 | else functools.partial(self.func, owner, instance) 36 | ) 37 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | ] 45 | 46 | [format] 47 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 48 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 49 | preview = true 50 | # https://docs.astral.sh/ruff/settings/#format_quote-style 51 | quote-style = "preserve" 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | usedevelop = True 9 | extras = 10 | test 11 | check 12 | cover 13 | enabler 14 | type 15 | 16 | [testenv:diffcov] 17 | description = run tests and check that diff from main is covered 18 | deps = 19 | {[testenv]deps} 20 | diff-cover 21 | commands = 22 | pytest {posargs} --cov-report xml 23 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 24 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 25 | 26 | [testenv:docs] 27 | description = build the documentation 28 | extras = 29 | doc 30 | test 31 | changedir = docs 32 | commands = 33 | python -m sphinx -W --keep-going . {toxinidir}/build/html 34 | python -m sphinxlint 35 | 36 | [testenv:finalize] 37 | description = assemble changelog and tag a release 38 | skip_install = True 39 | deps = 40 | towncrier 41 | jaraco.develop >= 7.23 42 | pass_env = * 43 | commands = 44 | python -m jaraco.develop.finalize 45 | 46 | 47 | [testenv:release] 48 | description = publish the package to PyPI and GitHub 49 | skip_install = True 50 | deps = 51 | build 52 | twine>=3 53 | jaraco.develop>=7.1 54 | pass_env = 55 | TWINE_PASSWORD 56 | GITHUB_TOKEN 57 | setenv = 58 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 59 | commands = 60 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 61 | python -m build 62 | python -m twine upload dist/* 63 | python -m jaraco.develop.create-github-release 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "path" 12 | authors = [ 13 | { name = "Jason Orendorff", email = "jason.orendorff@gmail.com" }, 14 | ] 15 | maintainers = [ 16 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 17 | ] 18 | description = "A module wrapper for os.path" 19 | readme = "README.rst" 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Intended Audience :: Developers", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Operating System :: OS Independent", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ] 28 | requires-python = ">=3.9" 29 | license = "MIT" 30 | dependencies = [ 31 | ] 32 | dynamic = ["version"] 33 | 34 | [project.urls] 35 | Source = "https://github.com/jaraco/path" 36 | 37 | [project.optional-dependencies] 38 | test = [ 39 | # upstream 40 | "pytest >= 6, != 8.1.*", 41 | 42 | # local 43 | "appdirs", 44 | "packaging", 45 | 'pywin32; platform_system == "Windows" and python_version < "3.12"', 46 | "more_itertools", 47 | # required for checkdocs on README.rst 48 | "pygments", 49 | "types-pywin32", 50 | ] 51 | 52 | doc = [ 53 | # upstream 54 | "sphinx >= 3.5", 55 | "jaraco.packaging >= 9.3", 56 | "rst.linker >= 1.9", 57 | "furo", 58 | "sphinx-lint", 59 | 60 | # tidelift 61 | "jaraco.tidelift >= 1.4", 62 | 63 | # local 64 | ] 65 | 66 | check = [ 67 | "pytest-checkdocs >= 2.4", 68 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 69 | ] 70 | 71 | cover = [ 72 | "pytest-cov", 73 | ] 74 | 75 | enabler = [ 76 | "pytest-enabler >= 2.2", 77 | ] 78 | 79 | type = [ 80 | # upstream 81 | "pytest-mypy", 82 | 83 | # local 84 | ] 85 | 86 | 87 | [tool.setuptools_scm] 88 | -------------------------------------------------------------------------------- /path/matchers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import fnmatch 4 | import ntpath 5 | from typing import TYPE_CHECKING, Any, Callable, overload 6 | 7 | if TYPE_CHECKING: 8 | from typing_extensions import Literal 9 | 10 | 11 | @overload 12 | def load(param: None) -> Null: ... 13 | 14 | 15 | @overload 16 | def load(param: str) -> Pattern: ... 17 | 18 | 19 | @overload 20 | def load(param: Any) -> Any: ... 21 | 22 | 23 | def load(param): 24 | """ 25 | If the supplied parameter is a string, assume it's a simple 26 | pattern. 27 | """ 28 | return ( 29 | Pattern(param) 30 | if isinstance(param, str) 31 | else param 32 | if param is not None 33 | else Null() 34 | ) 35 | 36 | 37 | class Base: 38 | pass 39 | 40 | 41 | class Null(Base): 42 | def __call__(self, path: str) -> Literal[True]: 43 | return True 44 | 45 | 46 | class Pattern(Base): 47 | pattern: str 48 | _pattern: str 49 | 50 | def __init__(self, pattern: str): 51 | self.pattern = pattern 52 | 53 | def get_pattern(self, normcase: Callable[[str], str]) -> str: 54 | try: 55 | return self._pattern 56 | except AttributeError: 57 | pass 58 | self._pattern = normcase(self.pattern) 59 | return self._pattern 60 | 61 | # NOTE: 'path' should be annotated with Path, but cannot due to circular imports. 62 | def __call__(self, path) -> bool: 63 | normcase = getattr(self, 'normcase', path.module.normcase) 64 | pattern = self.get_pattern(normcase) 65 | return fnmatch.fnmatchcase(normcase(path.name), pattern) 66 | 67 | 68 | class CaseInsensitive(Pattern): 69 | """ 70 | A Pattern with a ``'normcase'`` property, suitable for passing to 71 | :meth:`iterdir`, :meth:`dirs`, :meth:`files`, :meth:`walk`, 72 | :meth:`walkdirs`, or :meth:`walkfiles` to match case-insensitive. 73 | 74 | For example, to get all files ending in .py, .Py, .pY, or .PY in the 75 | current directory:: 76 | 77 | from path import Path, matchers 78 | Path('.').files(matchers.CaseInsensitive('*.py')) 79 | """ 80 | 81 | normcase = staticmethod(ntpath.normcase) 82 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | pygments_style = "sphinx" 12 | 13 | # Link dates and other references in the changelog 14 | extensions += ['rst.linker'] 15 | link_files = { 16 | '../NEWS.rst': dict( 17 | using=dict(GH='https://github.com'), 18 | replace=[ 19 | dict( 20 | pattern=r'(Issue #|\B#)(?P\d+)', 21 | url='{package_url}/issues/{issue}', 22 | ), 23 | dict( 24 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 25 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 26 | ), 27 | dict( 28 | pattern=r'PEP[- ](?P\d+)', 29 | url='https://peps.python.org/pep-{pep_number:0>4}/', 30 | ), 31 | ], 32 | ) 33 | } 34 | 35 | # Be strict about any broken references 36 | nitpicky = True 37 | nitpick_ignore: list[tuple[str, str]] = [] 38 | 39 | 40 | nitpick_ignore = [ 41 | ('py:class', '_io.BufferedRandom'), 42 | ('py:class', '_io.BufferedReader'), 43 | ('py:class', '_io.BufferedWriter'), 44 | ('py:class', '_io.FileIO'), 45 | ('py:class', '_io.TextIOWrapper'), 46 | ('py:class', 'Literal[-1, 1]'), 47 | ('py:class', 'OpenBinaryMode'), 48 | ('py:class', 'OpenBinaryModeReading'), 49 | ('py:class', 'OpenBinaryModeUpdating'), 50 | ('py:class', 'OpenBinaryModeWriting'), 51 | ('py:class', 'OpenTextMode'), 52 | ('py:class', '_IgnoreFn'), 53 | ('py:class', '_CopyFn'), 54 | ('py:class', '_Match'), 55 | ('py:class', '_OnErrorCallback'), 56 | ('py:class', '_OnExcCallback'), 57 | ('py:class', 'os.statvfs_result'), 58 | ('py:class', 'ModuleType'), 59 | ] 60 | 61 | # Include Python intersphinx mapping to prevent failures 62 | # jaraco/skeleton#51 63 | extensions += ['sphinx.ext.intersphinx'] 64 | intersphinx_mapping = { 65 | 'python': ('https://docs.python.org/3', None), 66 | } 67 | 68 | # Preserve authored syntax for defaults 69 | autodoc_preserve_defaults = True 70 | 71 | # Add support for linking usernames, PyPI projects, Wikipedia pages 72 | github_url = 'https://github.com/' 73 | extlinks = { 74 | 'user': (f'{github_url}%s', '@%s'), 75 | 'pypi': ('https://pypi.org/project/%s', '%s'), 76 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 77 | } 78 | extensions += ['sphinx.ext.extlinks'] 79 | 80 | # local 81 | 82 | extensions += ['jaraco.tidelift'] 83 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/path.svg 2 | :target: https://pypi.org/project/path 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/path.svg 5 | 6 | .. image:: https://github.com/jaraco/path/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/jaraco/path/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/path/badge/?version=latest 15 | :target: https://path.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://tidelift.com/badges/package/pypi/path 21 | :target: https://tidelift.com/subscription/pkg/pypi-path?utm_source=pypi-path&utm_medium=readme 22 | 23 | 24 | ``path`` (aka path pie, formerly ``path.py``) implements path 25 | objects as first-class entities, allowing common operations on 26 | files to be invoked on those path objects directly. For example: 27 | 28 | .. code-block:: python 29 | 30 | from path import Path 31 | 32 | d = Path("/home/guido/bin") 33 | for f in d.files("*.py"): 34 | f.chmod(0o755) 35 | 36 | # Globbing 37 | for f in d.files("*.py"): 38 | f.chmod("u+rwx") 39 | 40 | # Changing the working directory: 41 | with Path("somewhere"): 42 | # cwd in now `somewhere` 43 | ... 44 | 45 | # Concatenate paths with / 46 | foo_txt = Path("bar") / "foo.txt" 47 | 48 | Path pie is `hosted at Github `_. 49 | 50 | Find `the documentation here `_. 51 | 52 | Guides and Testimonials 53 | ======================= 54 | 55 | Yasoob wrote the Python 101 `Writing a Cleanup Script 56 | `_ 57 | based on ``path``. 58 | 59 | Advantages 60 | ========== 61 | 62 | Path pie provides a superior experience to similar offerings. 63 | 64 | Python 3.4 introduced 65 | `pathlib `_, 66 | which shares many characteristics with ``path``. In particular, 67 | it provides an object encapsulation for representing filesystem paths. 68 | One may have imagined ``pathlib`` would supersede ``path``. 69 | 70 | But the implementation and the usage quickly diverge, and ``path`` 71 | has several advantages over ``pathlib``: 72 | 73 | - ``path`` implements ``Path`` objects as a subclass of ``str``, and as a 74 | result these ``Path`` objects may be passed directly to other APIs that 75 | expect simple text representations of paths, whereas with ``pathlib``, one 76 | must first cast values to strings before passing them to APIs that do 77 | not honor `PEP 519 `_ 78 | ``PathLike`` interface. 79 | - ``path`` give quality of life features beyond exposing basic functionality 80 | of a path. ``path`` provides methods like ``rmtree`` (from shlib) and 81 | ``remove_p`` (remove a file if it exists), properties like ``.permissions``, 82 | and sophisticated ``walk``, ``TempDir``, and ``chmod`` behaviors. 83 | - As a PyPI-hosted package, ``path`` is free to iterate 84 | faster than a stdlib package. Contributions are welcome 85 | and encouraged. 86 | - ``path`` provides superior portability using a uniform abstraction 87 | over its single Path object, 88 | freeing the implementer to subclass it readily. One cannot 89 | subclass a ``pathlib.Path`` to add functionality, but must 90 | subclass ``Path``, ``PosixPath``, and ``WindowsPath``, even 91 | to do something as simple as to add a ``__dict__`` to the subclass 92 | instances. ``path`` instead allows the ``Path.module`` 93 | object to be overridden by subclasses, defaulting to the 94 | ``os.path``. Even advanced uses of ``path.Path`` that 95 | subclass the model do not need to be concerned with 96 | OS-specific nuances. ``path.Path`` objects are inherently "pure", 97 | not requiring the author to distinguish between pure and non-pure 98 | variants. 99 | 100 | This path project has the explicit aim to provide compatibility 101 | with ``pathlib`` objects where possible, such that a ``path.Path`` 102 | object is a drop-in replacement for ``pathlib.Path*`` objects. 103 | This project welcomes contributions to improve that compatibility 104 | where it's lacking. 105 | 106 | 107 | Origins 108 | ======= 109 | 110 | The ``path.py`` project was initially released in 2003 by Jason Orendorff 111 | and has been continuously developed and supported by several maintainers 112 | over the years. 113 | 114 | 115 | For Enterprise 116 | ============== 117 | 118 | Available as part of the Tidelift Subscription. 119 | 120 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 121 | 122 | `Learn more `_. 123 | -------------------------------------------------------------------------------- /path/masks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import itertools 5 | import operator 6 | import re 7 | from collections.abc import Iterable, Iterator 8 | from typing import Any, Callable 9 | 10 | 11 | # from jaraco.functools 12 | def compose(*funcs: Callable[..., Any]) -> Callable[..., Any]: 13 | compose_two = lambda f1, f2: lambda *args, **kwargs: f1(f2(*args, **kwargs)) # noqa 14 | return functools.reduce(compose_two, funcs) 15 | 16 | 17 | # from jaraco.structures.binary 18 | def gen_bit_values(number: int) -> Iterator[int]: 19 | """ 20 | Return a zero or one for each bit of a numeric value up to the most 21 | significant 1 bit, beginning with the least significant bit. 22 | 23 | >>> list(gen_bit_values(16)) 24 | [0, 0, 0, 0, 1] 25 | """ 26 | digits = bin(number)[2:] 27 | return map(int, reversed(digits)) 28 | 29 | 30 | # from more_itertools 31 | def padded( 32 | iterable: Iterable[Any], 33 | fillvalue: Any | None = None, 34 | n: int | None = None, 35 | next_multiple: bool = False, 36 | ) -> Iterator[Any]: 37 | """Yield the elements from *iterable*, followed by *fillvalue*, such that 38 | at least *n* items are emitted. 39 | 40 | >>> list(padded([1, 2, 3], '?', 5)) 41 | [1, 2, 3, '?', '?'] 42 | 43 | If *next_multiple* is ``True``, *fillvalue* will be emitted until the 44 | number of items emitted is a multiple of *n*:: 45 | 46 | >>> list(padded([1, 2, 3, 4], n=3, next_multiple=True)) 47 | [1, 2, 3, 4, None, None] 48 | 49 | If *n* is ``None``, *fillvalue* will be emitted indefinitely. 50 | 51 | """ 52 | it = iter(iterable) 53 | if n is None: 54 | yield from itertools.chain(it, itertools.repeat(fillvalue)) 55 | elif n < 1: 56 | raise ValueError('n must be at least 1') 57 | else: 58 | item_count = 0 59 | for item in it: 60 | yield item 61 | item_count += 1 62 | 63 | remaining = (n - item_count) % n if next_multiple else n - item_count 64 | for _ in range(remaining): 65 | yield fillvalue 66 | 67 | 68 | def compound(mode: str) -> Callable[[int], int]: 69 | """ 70 | Support multiple, comma-separated Unix chmod symbolic modes. 71 | 72 | >>> oct(compound('a=r,u+w')(0)) 73 | '0o644' 74 | """ 75 | return compose(*map(simple, reversed(mode.split(',')))) 76 | 77 | 78 | def simple(mode: str) -> Callable[[int], int]: 79 | """ 80 | Convert a Unix chmod symbolic mode like ``'ugo+rwx'`` to a function 81 | suitable for applying to a mask to affect that change. 82 | 83 | >>> mask = simple('ugo+rwx') 84 | >>> mask(0o554) == 0o777 85 | True 86 | 87 | >>> simple('go-x')(0o777) == 0o766 88 | True 89 | 90 | >>> simple('o-x')(0o445) == 0o444 91 | True 92 | 93 | >>> simple('a+x')(0) == 0o111 94 | True 95 | 96 | >>> simple('a=rw')(0o057) == 0o666 97 | True 98 | 99 | >>> simple('u=x')(0o666) == 0o166 100 | True 101 | 102 | >>> simple('g=')(0o157) == 0o107 103 | True 104 | 105 | >>> simple('gobbledeegook') 106 | Traceback (most recent call last): 107 | ValueError: ('Unrecognized symbolic mode', 'gobbledeegook') 108 | """ 109 | # parse the symbolic mode 110 | parsed = re.match('(?P[ugoa]+)(?P[-+=])(?P[rwx]*)$', mode) 111 | if not parsed: 112 | raise ValueError("Unrecognized symbolic mode", mode) 113 | 114 | # generate a mask representing the specified permission 115 | spec_map = dict(r=4, w=2, x=1) 116 | specs = (spec_map[perm] for perm in parsed.group('what')) 117 | spec = functools.reduce(operator.or_, specs, 0) 118 | 119 | # now apply spec to each subject in who 120 | shift_map = dict(u=6, g=3, o=0) 121 | who = parsed.group('who').replace('a', 'ugo') 122 | masks = (spec << shift_map[subj] for subj in who) 123 | mask = functools.reduce(operator.or_, masks) 124 | 125 | op = parsed.group('op') 126 | 127 | # if op is -, invert the mask 128 | if op == '-': 129 | mask ^= 0o777 130 | 131 | # if op is =, retain extant values for unreferenced subjects 132 | if op == '=': 133 | masks = (0o7 << shift_map[subj] for subj in who) 134 | retain = functools.reduce(operator.or_, masks) ^ 0o777 135 | 136 | op_map = { 137 | '+': operator.or_, 138 | '-': operator.and_, 139 | '=': lambda mask, target: target & retain ^ mask, 140 | } 141 | return functools.partial(op_map[op], mask) 142 | 143 | 144 | class Permissions(int): 145 | """ 146 | >>> perms = Permissions(0o764) 147 | >>> oct(perms) 148 | '0o764' 149 | >>> perms.symbolic 150 | 'rwxrw-r--' 151 | >>> str(perms) 152 | 'rwxrw-r--' 153 | >>> str(Permissions(0o222)) 154 | '-w--w--w-' 155 | """ 156 | 157 | @property 158 | def symbolic(self) -> str: 159 | return ''.join( 160 | ['-', val][bit] for val, bit in zip(itertools.cycle('rwx'), self.bits) 161 | ) 162 | 163 | @property 164 | def bits(self) -> Iterator[int]: 165 | return reversed(tuple(padded(gen_bit_values(self), 0, n=9))) 166 | 167 | def __str__(self) -> str: 168 | return self.symbolic 169 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -W 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pathpy.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pathpy.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pathpy" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pathpy" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v17.1.1 2 | ======= 3 | 4 | Bugfixes 5 | -------- 6 | 7 | - Fixed TempDir constructor arguments. (#236) 8 | 9 | 10 | v17.1.0 11 | ======= 12 | 13 | Features 14 | -------- 15 | 16 | - Fully inlined the type annotations. Big thanks to SethMMorton. (#235) 17 | 18 | 19 | v17.0.0 20 | ======= 21 | 22 | Deprecations and Removals 23 | ------------------------- 24 | 25 | - Removed deprecated methods ``getcwd``, ``abspath``, ``ext``, ``listdir``, ``isdir``, ``isfile``, and ``text``. 26 | - Removed deprecated support for passing ``bytes`` to ``write_text`` and ``write_lines(linesep=)`` parameter. 27 | 28 | 29 | v16.16.0 30 | ======== 31 | 32 | Features 33 | -------- 34 | 35 | - Implement .replace. (#214) 36 | - Add .home classmethod. (#214) 37 | 38 | 39 | v16.15.0 40 | ======== 41 | 42 | Features 43 | -------- 44 | 45 | - Replaced 'open' overloads with 'functools.wraps(open)' for simple re-use. (#225) 46 | 47 | 48 | Bugfixes 49 | -------- 50 | 51 | - Add type hints for .with_name, .suffix, .with_stem. (#227) 52 | - Add type hint for .absolute. (#228) 53 | 54 | 55 | v16.14.0 56 | ======== 57 | 58 | Features 59 | -------- 60 | 61 | - Add .symlink_to and .hardlink_to. (#214) 62 | - Add .cwd method and deprecated .getcwd. (#214) 63 | 64 | 65 | v16.13.0 66 | ======== 67 | 68 | Features 69 | -------- 70 | 71 | - Create 'absolute' method and deprecate 'abspath'. (#214) 72 | - In readlink, prefer the display path to the substitute path. (#222) 73 | 74 | 75 | v16.12.1 76 | ======== 77 | 78 | Bugfixes 79 | -------- 80 | 81 | - Restore functionality in .isdir and .isfile. 82 | 83 | 84 | v16.12.0 85 | ======== 86 | 87 | Features 88 | -------- 89 | 90 | - Added .is_dir and .is_file for parity with pathlib. Deprecates .isdir and .isfile. (#214) 91 | 92 | 93 | v16.11.0 94 | ======== 95 | 96 | Features 97 | -------- 98 | 99 | - Inlined some types. (#215) 100 | 101 | 102 | v16.10.2 103 | ======== 104 | 105 | Bugfixes 106 | -------- 107 | 108 | - Fix iterdir - it also accepts match. Ref #220. (#220) 109 | 110 | 111 | v16.10.1 112 | ======== 113 | 114 | Bugfixes 115 | -------- 116 | 117 | - Add type annotation for iterdir. (#220) 118 | 119 | 120 | v16.10.0 121 | ======== 122 | 123 | Features 124 | -------- 125 | 126 | - Added .with_name and .with_stem. 127 | - Prefer .suffix to .ext and deprecate .ext. 128 | 129 | 130 | v16.9.0 131 | ======= 132 | 133 | Features 134 | -------- 135 | 136 | - Added ``.iterdir()`` and deprecated ``.listdir()``. (#214) 137 | 138 | 139 | v16.8.0 140 | ======= 141 | 142 | Features 143 | -------- 144 | 145 | - Use '.' as the default path. (#216) 146 | 147 | 148 | v16.7.1 149 | ======= 150 | 151 | Bugfixes 152 | -------- 153 | 154 | - Set ``stacklevel=2`` in deprecation warning for ``.text``. (#210) 155 | 156 | 157 | v16.7.0 158 | ======= 159 | 160 | Features 161 | -------- 162 | 163 | - Added ``.permissions`` attribute. (#211) 164 | - Require Python 3.8 or later. 165 | 166 | 167 | v16.6.0 168 | ------- 169 | 170 | - ``.mtime`` and ``.atime`` are now settable. 171 | 172 | v16.5.0 173 | ------- 174 | 175 | - Refreshed packaging. 176 | - #197: Fixed default argument rendering in docs. 177 | - #209: Refactored ``write_lines`` to re-use open semantics. 178 | Deprecated the ``linesep`` parameter. 179 | 180 | v16.4.0 181 | ------- 182 | 183 | - #207: Added type hints and declare the library as typed. 184 | 185 | v16.3.0 186 | ------- 187 | 188 | - Require Python 3.7 or later. 189 | - #205: test_listdir_other_encoding now automatically skips 190 | itself on file systems where it's not appropriate. 191 | 192 | v16.2.0 193 | ------- 194 | 195 | - Deprecated passing bytes to ``write_text``. Instead, users 196 | should call ``write_bytes``. 197 | 198 | v16.1.0 199 | ------- 200 | 201 | - #204: Improved test coverage across the package to 99%, fixing 202 | bugs in uncovered code along the way. 203 | 204 | v16.0.0 205 | ------- 206 | 207 | - #200: ``TempDir`` context now cleans up unconditionally, 208 | even if an exception occurs. 209 | 210 | v15.1.2 211 | ------- 212 | 213 | - #199: Fixed broken link in README. 214 | 215 | v15.1.1 216 | ------- 217 | 218 | - Refreshed package metadata. 219 | 220 | v15.1.0 221 | ------- 222 | 223 | - Added ``ExtantPath`` and ``ExtantFile`` objects that raise 224 | errors when they reference a non-existent path or file. 225 | 226 | v15.0.1 227 | ------- 228 | 229 | - Refreshed package metadata. 230 | 231 | v15.0.0 232 | ------- 233 | 234 | - Removed ``__version__`` property. To determine the version, 235 | use ``importlib.metadata.version('path')``. 236 | 237 | v14.0.1 238 | ------- 239 | 240 | - Fixed regression on Python 3.7 and earlier where ``lru_cache`` 241 | did not support a user function. 242 | 243 | v14.0.0 244 | ------- 245 | 246 | - Removed ``namebase`` property. Use ``stem`` instead. 247 | - Removed ``update`` parameter on method to 248 | ``Path.merge_tree``. Instead, to only copy newer files, 249 | provide a wrapped ``copy`` function, as described in the 250 | doc string. 251 | - Removed ``FastPath``. Just use ``Path``. 252 | - Removed ``path.CaseInsensitivePattern``. Instead 253 | use ``path.matchers.CaseInsensitive``. 254 | - Removed ``path.tempdir``. Use ``path.TempDir``. 255 | - #154: Added ``Traversal`` class and support for customizing 256 | the behavior of a ``Path.walk``. 257 | 258 | v13.3.0 259 | ------- 260 | 261 | - #186: Fix test failures on Python 3.8 on Windows by relying on 262 | ``realpath()`` instead of ``readlink()``. 263 | - #189: ``realpath()`` now honors symlinks on Python 3.7 and 264 | earlier, approximating the behavior found on Python 3.8. 265 | - #187: ``lines()`` no longer relies on the deprecated ``.text()``. 266 | 267 | v13.2.0 268 | ------- 269 | 270 | - Require Python 3.6 or later. 271 | 272 | v13.1.0 273 | ------- 274 | 275 | - #170: Added ``read_text`` and ``read_bytes`` methods to 276 | align with ``pathlib`` behavior. Deprecated ``text`` method. 277 | If you require newline normalization of ``text``, use 278 | ``jaraco.text.normalize_newlines(Path.read_text())``. 279 | 280 | v13.0.0 281 | ------- 282 | 283 | - #169: Renamed package from ``path.py`` to ``path``. The docs 284 | make reference to a pet name "path pie" for easier discovery. 285 | 286 | v12.5.0 287 | ------- 288 | 289 | - #195: Project now depends on ``path``. 290 | 291 | v12.4.0 292 | ------- 293 | 294 | - #169: Project now depends on ``path < 13.2``. 295 | - Fixed typo in README. 296 | 297 | v12.3.0 298 | ------- 299 | 300 | - #169: Project is renamed to simply ``path``. This release of 301 | ``path.py`` simply depends on ``path < 13.1``. 302 | 303 | v12.2.0 304 | ------- 305 | 306 | - #169: Moved project at GitHub from ``jaraco/path.py`` to 307 | ``jaraco/path``. 308 | 309 | v12.1.0 310 | ------- 311 | 312 | - #171: Fixed exception in ``rmdir_p`` when target is not empty. 313 | - #174: Rely on ``importlib.metadata`` on Python 3.8. 314 | 315 | v12.0.2 316 | ------- 317 | 318 | - Refreshed package metadata. 319 | 320 | 12.0.1 321 | ------ 322 | 323 | - #166: Removed 'universal' wheel support. 324 | 325 | 12.0 326 | --- 327 | 328 | - #148: Dropped support for Python 2.7 and 3.4. 329 | - Moved 'path' into a package. 330 | 331 | 11.5.2 332 | ------ 333 | 334 | - #163: Corrected 'pymodules' typo in package declaration. 335 | 336 | 11.5.1 337 | ------ 338 | 339 | - Minor packaging refresh. 340 | 341 | 11.5.0 342 | ------ 343 | 344 | - #156: Re-wrote the handling of pattern matches for 345 | ``listdir``, ``walk``, and related methods, allowing 346 | the pattern to be a more complex object. This approach 347 | drastically simplifies the code and obviates the 348 | ``CaseInsensitivePattern`` and ``FastPath`` classes. 349 | Now the main ``Path`` class should be as performant 350 | as ``FastPath`` and case-insensitive matches can be 351 | readily constructed using the new 352 | ``path.matchers.CaseInsensitive`` class. 353 | 354 | 11.4.1 355 | ------ 356 | 357 | - #153: Skip intermittently failing performance test on 358 | Python 2. 359 | 360 | 11.4.0 361 | ------ 362 | 363 | - #130: Path.py now supports non-decodable filenames on 364 | Linux and Python 2, leveraging the 365 | `backports.os `_ 366 | package (as an optional dependency). Currently, only 367 | ``listdir`` is patched, but other ``os`` primitives may 368 | be patched similarly in the ``patch_for_linux_python2`` 369 | function. 370 | 371 | - #141: For merge_tree, instead of relying on the deprecated 372 | distutils module, implement merge_tree explicitly. The 373 | ``update`` parameter is deprecated, instead superseded 374 | by a ``copy_function`` parameter and an ``only_newer`` 375 | wrapper for any copy function. 376 | 377 | 11.3.0 378 | ------ 379 | 380 | - #151: No longer use two techniques for splitting lines. 381 | Instead, unconditionally rely on io.open for universal 382 | newlines support and always use splitlines. 383 | 384 | 11.2.0 385 | ------ 386 | 387 | - #146: Rely on `importlib_metadata 388 | `_ instead of 389 | setuptools/pkg_resources to load the version of the module. 390 | Added tests ensuring a <100ms import time for the ``path`` 391 | module. This change adds an explicit dependency on the 392 | importlib_metadata package, but the project still supports 393 | copying of the ``path.py`` module without any dependencies. 394 | 395 | 11.1.0 396 | ------ 397 | 398 | - #143, #144: Add iglob method. 399 | - #142, #145: Rename ``tempdir`` to ``TempDir`` and declare 400 | it as part of ``__all__``. Retain ``tempdir`` for compatibility 401 | for now. 402 | - #145: ``TempDir.__enter__`` no longer returns the ``TempDir`` 403 | instance, but instead returns a ``Path`` instance, suitable for 404 | entering to change the current working directory. 405 | 406 | 11.0.1 407 | ------ 408 | 409 | - #136: Fixed test failures on BSD. 410 | 411 | - Refreshed package metadata. 412 | 413 | 11.0 414 | ---- 415 | 416 | - Drop support for Python 3.3. 417 | 418 | 10.6 419 | ---- 420 | 421 | - Renamed ``namebase`` to ``stem`` to match API of pathlib. 422 | Kept ``namebase`` as a deprecated alias for compatibility. 423 | 424 | - Added new ``with_suffix`` method, useful for renaming the 425 | extension on a Path:: 426 | 427 | orig = Path('mydir/mypath.bat') 428 | renamed = orig.rename(orig.with_suffix('.cmd')) 429 | 430 | 10.5 431 | ---- 432 | 433 | - Packaging refresh and readme updates. 434 | 435 | 10.4 436 | ---- 437 | 438 | - #130: Removed surrogate_escape handler as it's no longer 439 | used. 440 | 441 | 10.3.1 442 | ------ 443 | 444 | - #124: Fixed ``rmdir_p`` raising ``FileNotFoundError`` when 445 | directory does not exist on Windows. 446 | 447 | 10.3 448 | ---- 449 | 450 | - #115: Added a new performance-optimized implementation 451 | for listdir operations, optimizing ``listdir``, ``walk``, 452 | ``walkfiles``, ``walkdirs``, and ``fnmatch``, presented 453 | as the ``FastPath`` class. 454 | 455 | Please direct feedback on this implementation to the ticket, 456 | especially if the performance benefits justify it replacing 457 | the default ``Path`` class. 458 | 459 | 10.2 460 | ---- 461 | 462 | - Symlink no longer requires the ``newlink`` parameter 463 | and will default to the basename of the target in the 464 | current working directory. 465 | 466 | 10.1 467 | ---- 468 | 469 | - #123: Implement ``Path.__fspath__`` per PEP 519. 470 | 471 | 10.0 472 | ---- 473 | 474 | - Once again as in 8.0 remove deprecated ``path.path``. 475 | 476 | 9.1 477 | --- 478 | 479 | - #121: Removed workaround for #61 added in 5.2. ``path.py`` 480 | now only supports file system paths that can be effectively 481 | decoded to text. It is the responsibility of the system 482 | implementer to ensure that filenames on the system are 483 | decodeable by ``sys.getfilesystemencoding()``. 484 | 485 | 9.0 486 | --- 487 | 488 | - Drop support for Python 2.6 and 3.2 as integration 489 | dependencies (pip) no longer support these versions. 490 | 491 | 8.3 492 | --- 493 | 494 | - Merge with latest skeleton, adding badges and test runs by 495 | default under tox instead of pytest-runner. 496 | - Documentation is no longer hosted with PyPI. 497 | 498 | 8.2.1 499 | ----- 500 | 501 | - #112: Update Travis CI usage to only deploy on Python 3.5. 502 | 503 | 8.2 504 | --- 505 | 506 | - Refreshed project metadata based on `jaraco's project 507 | skeleton `_. 508 | 509 | - Releases are now automatically published via Travis-CI. 510 | - #111: More aggressively trap errors when importing 511 | ``pkg_resources``. 512 | 513 | 8.1.2 514 | ----- 515 | 516 | - #105: By using unicode literals, avoid errors rendering the 517 | backslash in __get_owner_windows. 518 | 519 | 8.1.1 520 | ----- 521 | 522 | - #102: Reluctantly restored reference to path.path in ``__all__``. 523 | 524 | 8.1 525 | --- 526 | 527 | - #102: Restored ``path.path`` with a DeprecationWarning. 528 | 529 | 8.0 530 | --- 531 | 532 | Removed ``path.path``. Clients must now refer to the canonical 533 | name, ``path.Path`` as introduced in 6.2. 534 | 535 | 7.7 536 | --- 537 | 538 | - #88: Added support for resolving certain directories on a 539 | system to platform-friendly locations using the `appdirs 540 | `_ library. The 541 | ``Path.special`` method returns an ``SpecialResolver`` instance 542 | that will resolve a path in a scope 543 | (i.e. 'site' or 'user') and class (i.e. 'config', 'cache', 544 | 'data'). For 545 | example, to create a config directory for "My App":: 546 | 547 | config_dir = Path.special("My App").user.config.makedirs_p() 548 | 549 | ``config_dir`` will exist in a user context and will be in a 550 | suitable platform-friendly location. 551 | 552 | As ``path.py`` does not currently have any dependencies, and 553 | to retain that expectation for a compatible upgrade path, 554 | ``appdirs`` must be installed to avoid an ImportError when 555 | invoking ``special``. 556 | 557 | 558 | - #88: In order to support "multipath" results, where multiple 559 | paths are returned in a single, ``os.pathsep``-separated 560 | string, a new class MultiPath now represents those special 561 | results. This functionality is experimental and may change. 562 | Feedback is invited. 563 | 564 | 7.6.2 565 | ----- 566 | 567 | - Re-release of 7.6.1 without unintended feature. 568 | 569 | 7.6.1 570 | ----- 571 | 572 | - #101: Supress error when `path.py` is not present as a distribution. 573 | 574 | 7.6 575 | --- 576 | 577 | - #100: Add ``merge_tree`` method for merging 578 | two existing directory trees. 579 | - Uses `setuptools_scm `_ 580 | for version management. 581 | 582 | 7.5 583 | --- 584 | 585 | - #97: ``__rdiv__`` and ``__rtruediv__`` are now defined. 586 | 587 | 7.4 588 | --- 589 | 590 | - #93: chown now appears in docs and raises NotImplementedError if 591 | ``os.chown`` isn't present. 592 | - #92: Added compatibility support for ``.samefile`` on platforms without 593 | ``os.samefile``. 594 | 595 | 7.3 596 | --- 597 | 598 | - #91: Releases now include a universal wheel. 599 | 600 | 7.2 601 | --- 602 | 603 | - In chmod, added support for multiple symbolic masks (separated by commas). 604 | - In chmod, fixed issue in setting of symbolic mask with '=' where 605 | unreferenced permissions were cleared. 606 | 607 | 7.1 608 | --- 609 | 610 | - #23: Added support for symbolic masks to ``.chmod``. 611 | 612 | 7.0 613 | --- 614 | 615 | - The ``open`` method now uses ``io.open`` and supports all of the 616 | parameters to that function. ``open`` will always raise an ``OSError`` 617 | on failure, even on Python 2. 618 | - Updated ``write_text`` to support additional newline patterns. 619 | - The ``text`` method now always returns text (never bytes), and thus 620 | requires an encoding parameter be supplied if the default encoding is not 621 | sufficient to decode the content of the file. 622 | 623 | 6.2 624 | --- 625 | 626 | - ``path`` class renamed to ``Path``. The ``path`` name remains as an alias 627 | for compatibility. 628 | 629 | 6.1 630 | --- 631 | 632 | - ``chown`` now accepts names in addition to numeric IDs. 633 | 634 | 6.0 635 | --- 636 | 637 | - Drop support for Python 2.5. Python 2.6 or later required. 638 | - Installation now requires setuptools. 639 | 640 | 5.3 641 | --- 642 | 643 | - Allow arbitrary callables to be passed to path.walk ``errors`` parameter. 644 | Enables workaround for issues such as #73 and #56. 645 | 646 | 5.2 647 | --- 648 | 649 | - #61: path.listdir now decodes filenames from os.listdir when loading 650 | characters from a file. On Python 3, the behavior is unchanged. On Python 651 | 2, the behavior will now mimick that of Python 3, attempting to decode 652 | all filenames and paths using the encoding indicated by 653 | ``sys.getfilesystemencoding()``, and escaping any undecodable characters 654 | using the 'surrogateescape' handler. 655 | 656 | 5.1 657 | --- 658 | 659 | - #53: Added ``path.in_place`` for editing files in place. 660 | 661 | 5.0 662 | --- 663 | 664 | - ``path.fnmatch`` now takes an optional parameter ``normcase`` and this 665 | parameter defaults to self.module.normcase (using case normalization most 666 | pertinent to the path object itself). Note that this change means that 667 | any paths using a custom ntpath module on non-Windows systems will have 668 | different fnmatch behavior. Before:: 669 | 670 | # on Unix 671 | >>> p = path('Foo') 672 | >>> p.module = ntpath 673 | >>> p.fnmatch('foo') 674 | False 675 | 676 | After:: 677 | 678 | # on any OS 679 | >>> p = path('Foo') 680 | >>> p.module = ntpath 681 | >>> p.fnmatch('foo') 682 | True 683 | 684 | To maintain the original behavior, either don't define the 'module' for the 685 | path or supply explicit normcase function:: 686 | 687 | >>> p.fnmatch('foo', normcase=os.path.normcase) 688 | # result always varies based on OS, same as fnmatch.fnmatch 689 | 690 | For most use-cases, the default behavior should remain the same. 691 | 692 | - Issue #50: Methods that accept patterns (``listdir``, ``files``, ``dirs``, 693 | ``walk``, ``walkdirs``, ``walkfiles``, and ``fnmatch``) will now use a 694 | ``normcase`` attribute if it is present on the ``pattern`` parameter. The 695 | path module now provides a ``CaseInsensitivePattern`` wrapper for strings 696 | suitable for creating case-insensitive patterns for those methods. 697 | 698 | 4.4 699 | --- 700 | 701 | - Issue #44: _hash method would open files in text mode, producing 702 | invalid results on Windows. Now files are opened in binary mode, producing 703 | consistent results. 704 | - Issue #47: Documentation is dramatically improved with Intersphinx links 705 | to the Python os.path functions and documentation for all methods and 706 | properties. 707 | 708 | 4.3 709 | --- 710 | 711 | - Issue #32: Add ``chdir`` and ``cd`` methods. 712 | 713 | 4.2 714 | --- 715 | 716 | - ``open()`` now passes all positional and keyword arguments through to the 717 | underlying ``builtins.open`` call. 718 | 719 | 4.1 720 | --- 721 | 722 | - Native Python 2 and Python 3 support without using 2to3 during the build 723 | process. 724 | 725 | 4.0 726 | --- 727 | 728 | - Added a ``chunks()`` method to a allow quick iteration over pieces of a 729 | file at a given path. 730 | - Issue #28: Fix missing argument to ``samefile``. 731 | - Initializer no longer enforces `isinstance basestring` for the source 732 | object. Now any object that supplies ``__unicode__`` can be used by a 733 | ``path`` (except None). Clients that depend on a ValueError being raised 734 | for ``int`` and other non-string objects should trap these types 735 | internally. 736 | - Issue #30: ``chown`` no longer requires both uid and gid to be provided 737 | and will not mutate the ownership if nothing is provided. 738 | 739 | 3.2 740 | --- 741 | 742 | - Issue #22: ``__enter__`` now returns self. 743 | 744 | 3.1 745 | --- 746 | 747 | - Issue #20: `relpath` now supports a "start" parameter to match the 748 | signature of `os.path.relpath`. 749 | 750 | 3.0 751 | --- 752 | 753 | - Minimum Python version is now 2.5. 754 | 755 | 2.6 756 | --- 757 | 758 | - Issue #5: Implemented `path.tempdir`, which returns a path object which is 759 | a temporary directory and context manager for cleaning up the directory. 760 | - Issue #12: One can now construct path objects from a list of strings by 761 | simply using path.joinpath. For example:: 762 | 763 | path.joinpath('a', 'b', 'c') # or 764 | path.joinpath(*path_elements) 765 | 766 | 2.5 767 | --- 768 | 769 | - Issue #7: Add the ability to do chaining of operations that formerly only 770 | returned None. 771 | - Issue #4: Raise a TypeError when constructed from None. 772 | -------------------------------------------------------------------------------- /tests/test_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the path module. 3 | 4 | This suite runs on Linux, macOS, and Windows. To extend the 5 | platform support, just add appropriate pathnames for your 6 | platform (os.name) in each place where the p() function is called. 7 | Then report the result. If you can't get the test to run at all on 8 | your platform, there's probably a bug -- please report the issue 9 | in the issue tracker. 10 | 11 | TestScratchDir.test_touch() takes a while to run. It sleeps a few 12 | seconds to allow some time to pass between calls to check the modify 13 | time on files. 14 | """ 15 | 16 | import contextlib 17 | import datetime 18 | import importlib 19 | import ntpath 20 | import os 21 | import platform 22 | import posixpath 23 | import re 24 | import shutil 25 | import stat 26 | import subprocess 27 | import sys 28 | import textwrap 29 | import time 30 | import types 31 | 32 | import pytest 33 | from more_itertools import ilen 34 | 35 | import path 36 | from path import Multi, Path, SpecialResolver, TempDir, matchers 37 | 38 | 39 | def os_choose(**choices): 40 | """Choose a value from several possible values, based on os.name""" 41 | return choices[os.name] 42 | 43 | 44 | class TestBasics: 45 | def test_relpath(self): 46 | root = Path(os_choose(nt='C:\\', posix='/')) 47 | foo = root / 'foo' 48 | quux = foo / 'quux' 49 | bar = foo / 'bar' 50 | boz = bar / 'Baz' / 'Boz' 51 | up = Path(os.pardir) 52 | 53 | # basics 54 | assert root.relpathto(boz) == Path('foo') / 'bar' / 'Baz' / 'Boz' 55 | assert bar.relpathto(boz) == Path('Baz') / 'Boz' 56 | assert quux.relpathto(boz) == up / 'bar' / 'Baz' / 'Boz' 57 | assert boz.relpathto(quux) == up / up / up / 'quux' 58 | assert boz.relpathto(bar) == up / up 59 | 60 | # Path is not the first element in concatenation 61 | assert root.relpathto(boz) == 'foo' / Path('bar') / 'Baz' / 'Boz' 62 | 63 | # x.relpathto(x) == curdir 64 | assert root.relpathto(root) == os.curdir 65 | assert boz.relpathto(boz) == os.curdir 66 | # Make sure case is properly noted (or ignored) 67 | assert boz.relpathto(boz.normcase()) == os.curdir 68 | 69 | # relpath() 70 | cwd = Path(os.getcwd()) 71 | assert boz.relpath() == cwd.relpathto(boz) 72 | 73 | if os.name == 'nt': # pragma: nocover 74 | # Check relpath across drives. 75 | d = Path('D:\\') 76 | assert d.relpathto(boz) == boz 77 | 78 | def test_construction_without_args(self): 79 | """ 80 | Path class will construct a path to current directory when called with no arguments. 81 | """ 82 | assert Path() == '.' 83 | 84 | def test_construction_from_none(self): 85 | """ """ 86 | with pytest.raises(TypeError): 87 | Path(None) 88 | 89 | def test_construction_from_int(self): 90 | """ 91 | Path class will construct a path as a string of the number 92 | """ 93 | assert Path(1) == '1' 94 | 95 | def test_string_compatibility(self): 96 | """Test compatibility with ordinary strings.""" 97 | x = Path('xyzzy') 98 | assert x == 'xyzzy' 99 | assert x == 'xyzzy' 100 | 101 | # sorting 102 | items = [Path('fhj'), Path('fgh'), 'E', Path('d'), 'A', Path('B'), 'c'] 103 | items.sort() 104 | assert items == ['A', 'B', 'E', 'c', 'd', 'fgh', 'fhj'] 105 | 106 | # Test p1/p1. 107 | p1 = Path("foo") 108 | p2 = Path("bar") 109 | assert p1 / p2 == os_choose(nt='foo\\bar', posix='foo/bar') 110 | 111 | def test_properties(self): 112 | # Create sample path object. 113 | f = Path( 114 | os_choose( 115 | nt='C:\\Program Files\\Python\\Lib\\xyzzy.py', 116 | posix='/usr/local/python/lib/xyzzy.py', 117 | ) 118 | ) 119 | 120 | # .parent 121 | nt_lib = 'C:\\Program Files\\Python\\Lib' 122 | posix_lib = '/usr/local/python/lib' 123 | expected = os_choose(nt=nt_lib, posix=posix_lib) 124 | assert f.parent == expected 125 | 126 | # .name 127 | assert f.name == 'xyzzy.py' 128 | assert f.parent.name == os_choose(nt='Lib', posix='lib') 129 | 130 | # .suffix 131 | assert f.suffix == '.py' 132 | assert f.parent.suffix == '' 133 | 134 | # .drive 135 | assert f.drive == os_choose(nt='C:', posix='') 136 | 137 | def test_absolute(self): 138 | assert Path(os.curdir).absolute() == os.getcwd() 139 | 140 | def test_cwd(self): 141 | cwd = Path.cwd() 142 | assert isinstance(cwd, Path) 143 | assert cwd == os.getcwd() 144 | 145 | def test_home(self): 146 | home = Path.home() 147 | assert isinstance(home, Path) 148 | assert home == os.path.expanduser('~') 149 | 150 | def test_explicit_module(self): 151 | """ 152 | The user may specify an explicit path module to use. 153 | """ 154 | nt_ok = Path.using_module(ntpath)(r'foo\bar\baz') 155 | posix_ok = Path.using_module(posixpath)(r'foo/bar/baz') 156 | posix_wrong = Path.using_module(posixpath)(r'foo\bar\baz') 157 | 158 | assert nt_ok.dirname() == r'foo\bar' 159 | assert posix_ok.dirname() == r'foo/bar' 160 | assert posix_wrong.dirname() == '' 161 | 162 | assert nt_ok / 'quux' == r'foo\bar\baz\quux' 163 | assert posix_ok / 'quux' == r'foo/bar/baz/quux' 164 | 165 | def test_explicit_module_classes(self): 166 | """ 167 | Multiple calls to path.using_module should produce the same class. 168 | """ 169 | nt_path = Path.using_module(ntpath) 170 | assert nt_path is Path.using_module(ntpath) 171 | assert nt_path.__name__ == 'Path_ntpath' 172 | 173 | def test_joinpath_on_instance(self): 174 | res = Path('foo') 175 | foo_bar = res.joinpath('bar') 176 | assert foo_bar == os_choose(nt='foo\\bar', posix='foo/bar') 177 | 178 | def test_joinpath_to_nothing(self): 179 | res = Path('foo') 180 | assert res.joinpath() == res 181 | 182 | def test_joinpath_on_class(self): 183 | "Construct a path from a series of strings" 184 | foo_bar = Path.joinpath('foo', 'bar') 185 | assert foo_bar == os_choose(nt='foo\\bar', posix='foo/bar') 186 | 187 | def test_joinpath_fails_on_empty(self): 188 | "It doesn't make sense to join nothing at all" 189 | with pytest.raises(TypeError): 190 | Path.joinpath() 191 | 192 | def test_joinpath_returns_same_type(self): 193 | path_posix = Path.using_module(posixpath) 194 | res = path_posix.joinpath('foo') 195 | assert isinstance(res, path_posix) 196 | res2 = res.joinpath('bar') 197 | assert isinstance(res2, path_posix) 198 | assert res2 == 'foo/bar' 199 | 200 | def test_radd_string(self): 201 | res = 'foo' + Path('bar') 202 | assert res == Path('foobar') 203 | 204 | def test_fspath(self): 205 | os.fspath(Path('foobar')) 206 | 207 | def test_normpath(self): 208 | assert Path('foo//bar').normpath() == os.path.normpath('foo//bar') 209 | 210 | def test_expandvars(self, monkeypatch): 211 | monkeypatch.setitem(os.environ, 'sub', 'value') 212 | val = '$sub/$(sub)' 213 | assert Path(val).expandvars() == os.path.expandvars(val) 214 | assert 'value' in Path(val).expandvars() 215 | 216 | def test_expand(self): 217 | val = 'foobar' 218 | expected = os.path.normpath(os.path.expanduser(os.path.expandvars(val))) 219 | assert Path(val).expand() == expected 220 | 221 | def test_splitdrive(self): 222 | val = Path.using_module(ntpath)(r'C:\bar') 223 | drive, rest = val.splitdrive() 224 | assert drive == 'C:' 225 | assert rest == r'\bar' 226 | assert isinstance(rest, Path) 227 | 228 | def test_relpathto(self): 229 | source = Path.using_module(ntpath)(r'C:\foo') 230 | dest = Path.using_module(ntpath)(r'D:\bar') 231 | assert source.relpathto(dest) == dest 232 | 233 | def test_walk_errors(self): 234 | start = Path('/does-not-exist') 235 | items = list(start.walk(errors='ignore')) 236 | assert not items 237 | 238 | def test_walk_child_error(self, tmpdir): 239 | def simulate_access_denied(item): 240 | if item.name == 'sub1': 241 | raise OSError("Access denied") 242 | 243 | p = Path(tmpdir) 244 | (p / 'sub1').makedirs_p() 245 | items = path.Traversal(simulate_access_denied)(p.walk(errors='ignore')) 246 | assert list(items) == [p / 'sub1'] 247 | 248 | def test_read_md5(self, tmpdir): 249 | target = Path(tmpdir) / 'some file' 250 | target.write_text('quick brown fox and lazy dog') 251 | assert target.read_md5() == b's\x15\rPOW\x7fYk\xa8\x8e\x00\x0b\xd7G\xf9' 252 | 253 | def test_read_hexhash(self, tmpdir): 254 | target = Path(tmpdir) / 'some file' 255 | target.write_text('quick brown fox and lazy dog') 256 | assert target.read_hexhash('md5') == '73150d504f577f596ba88e000bd747f9' 257 | 258 | @pytest.mark.skipif("not hasattr(os, 'statvfs')") 259 | def test_statvfs(self): 260 | Path('.').statvfs() 261 | 262 | @pytest.mark.skipif("not hasattr(os, 'pathconf')") 263 | def test_pathconf(self): 264 | assert isinstance(Path('.').pathconf(1), int) 265 | 266 | def test_utime(self, tmpdir): 267 | tmpfile = Path(tmpdir) / 'file' 268 | tmpfile.touch() 269 | new_time = (time.time() - 600,) * 2 270 | assert Path(tmpfile).utime(new_time).stat().st_atime == new_time[0] 271 | 272 | def test_chmod_str(self, tmpdir): 273 | tmpfile = Path(tmpdir) / 'file' 274 | tmpfile.touch() 275 | tmpfile.chmod('o-r') 276 | is_windows = platform.system() == 'Windows' 277 | assert is_windows or not (tmpfile.stat().st_mode & stat.S_IROTH) 278 | 279 | @pytest.mark.skipif("not hasattr(Path, 'chown')") 280 | def test_chown(self, tmpdir): 281 | tmpfile = Path(tmpdir) / 'file' 282 | tmpfile.touch() 283 | tmpfile.chown(os.getuid(), os.getgid()) 284 | import pwd 285 | 286 | name = pwd.getpwuid(os.getuid()).pw_name 287 | tmpfile.chown(name) 288 | 289 | def test_renames(self, tmpdir): 290 | tmpfile = Path(tmpdir) / 'file' 291 | tmpfile.touch() 292 | tmpfile.renames(Path(tmpdir) / 'foo' / 'alt') 293 | 294 | def test_mkdir_p(self, tmpdir): 295 | Path(tmpdir).mkdir_p() 296 | 297 | def test_removedirs_p(self, tmpdir): 298 | dir = Path(tmpdir) / 'somedir' 299 | dir.mkdir() 300 | (dir / 'file').touch() 301 | (dir / 'sub').mkdir() 302 | dir.removedirs_p() 303 | assert dir.is_dir() 304 | assert (dir / 'file').is_file() 305 | # TODO: shouldn't sub get removed? 306 | # assert not (dir / 'sub').is_dir() 307 | 308 | @pytest.mark.skipif("not hasattr(Path, 'group')") 309 | def test_group(self, tmpdir): 310 | file = Path(tmpdir).joinpath('file').touch() 311 | assert isinstance(file.group(), str) 312 | 313 | 314 | class TestReadWriteText: 315 | def test_read_write(self, tmpdir): 316 | file = path.Path(tmpdir) / 'filename' 317 | file.write_text('hello world', encoding='utf-8') 318 | assert file.read_text(encoding='utf-8') == 'hello world' 319 | assert file.read_bytes() == b'hello world' 320 | 321 | 322 | class TestPerformance: 323 | @staticmethod 324 | def get_command_time(cmd): 325 | args = [sys.executable, '-m', 'timeit', '-n', '1', '-r', '1', '-u', 'usec'] + [ 326 | cmd 327 | ] 328 | res = subprocess.check_output(args, text=True, encoding='utf-8') 329 | dur = re.search(r'(\d+) usec per loop', res).group(1) 330 | return datetime.timedelta(microseconds=int(dur)) 331 | 332 | def test_import_time(self, monkeypatch): 333 | """ 334 | Import should take less than some limit. 335 | 336 | Run tests in a subprocess to isolate from test suite overhead. 337 | """ 338 | limit = datetime.timedelta(milliseconds=20) 339 | baseline = self.get_command_time('pass') 340 | measure = self.get_command_time('import path') 341 | duration = measure - baseline 342 | assert duration < limit 343 | 344 | 345 | class TestOwnership: 346 | @pytest.mark.skipif('platform.system() == "Windows" and sys.version_info > (3, 12)') 347 | def test_get_owner(self): 348 | Path('/').get_owner() 349 | 350 | 351 | class TestLinks: 352 | def test_hardlink_to(self, tmpdir): 353 | target = Path(tmpdir) / 'target' 354 | target.write_text('hello', encoding='utf-8') 355 | link = Path(tmpdir).joinpath('link') 356 | link.hardlink_to(target) 357 | assert link.read_text(encoding='utf-8') == 'hello' 358 | 359 | def test_link(self, tmpdir): 360 | target = Path(tmpdir) / 'target' 361 | target.write_text('hello', encoding='utf-8') 362 | link = target.link(Path(tmpdir) / 'link') 363 | assert link.read_text(encoding='utf-8') == 'hello' 364 | 365 | def test_symlink_to(self, tmpdir): 366 | target = Path(tmpdir) / 'target' 367 | target.write_text('hello', encoding='utf-8') 368 | link = Path(tmpdir).joinpath('link') 369 | link.symlink_to(target) 370 | assert link.read_text(encoding='utf-8') == 'hello' 371 | 372 | def test_symlink_none(self, tmpdir): 373 | root = Path(tmpdir) 374 | with root: 375 | file = (Path('dir').mkdir() / 'file').touch() 376 | file.symlink() 377 | assert Path('file').is_file() 378 | 379 | def test_readlinkabs_passthrough(self, tmpdir): 380 | link = Path(tmpdir) / 'link' 381 | Path('foo').absolute().symlink(link) 382 | assert link.readlinkabs() == Path('foo').absolute() 383 | 384 | def test_readlinkabs_rendered(self, tmpdir): 385 | link = Path(tmpdir) / 'link' 386 | Path('foo').symlink(link) 387 | assert link.readlinkabs() == Path(tmpdir) / 'foo' 388 | 389 | 390 | class TestSymbolicLinksWalk: 391 | def test_skip_symlinks(self, tmpdir): 392 | root = Path(tmpdir) 393 | sub = root / 'subdir' 394 | sub.mkdir() 395 | sub.symlink(root / 'link') 396 | (sub / 'file').touch() 397 | assert len(list(root.walk())) == 4 398 | 399 | skip_links = path.Traversal( 400 | lambda item: item.is_dir() and not item.islink(), 401 | ) 402 | assert len(list(skip_links(root.walk()))) == 3 403 | 404 | 405 | class TestSelfReturn: 406 | """ 407 | Some methods don't necessarily return any value (e.g. makedirs, 408 | makedirs_p, rename, mkdir, touch, chroot). These methods should return 409 | self anyhow to allow methods to be chained. 410 | """ 411 | 412 | def test_makedirs_p(self, tmpdir): 413 | """ 414 | Path('foo').makedirs_p() == Path('foo') 415 | """ 416 | p = Path(tmpdir) / "newpath" 417 | ret = p.makedirs_p() 418 | assert p == ret 419 | 420 | def test_makedirs_p_extant(self, tmpdir): 421 | p = Path(tmpdir) 422 | ret = p.makedirs_p() 423 | assert p == ret 424 | 425 | def test_rename(self, tmpdir): 426 | p = Path(tmpdir) / "somefile" 427 | p.touch() 428 | target = Path(tmpdir) / "otherfile" 429 | ret = p.rename(target) 430 | assert target == ret 431 | 432 | def test_mkdir(self, tmpdir): 433 | p = Path(tmpdir) / "newdir" 434 | ret = p.mkdir() 435 | assert p == ret 436 | 437 | def test_touch(self, tmpdir): 438 | p = Path(tmpdir) / "empty file" 439 | ret = p.touch() 440 | assert p == ret 441 | 442 | 443 | @pytest.mark.skipif("not hasattr(Path, 'chroot')") 444 | def test_chroot(monkeypatch): 445 | results = [] 446 | monkeypatch.setattr(os, 'chroot', results.append) 447 | Path().chroot() 448 | assert results == [Path()] 449 | 450 | 451 | @pytest.mark.skipif("not hasattr(Path, 'startfile')") 452 | def test_startfile(monkeypatch): 453 | results = [] 454 | monkeypatch.setattr(os, 'startfile', results.append) 455 | Path().startfile() 456 | assert results == [Path()] 457 | 458 | 459 | class TestScratchDir: 460 | """ 461 | Tests that run in a temporary directory (does not test TempDir class) 462 | """ 463 | 464 | def test_context_manager(self, tmpdir): 465 | """Can be used as context manager for chdir.""" 466 | d = Path(tmpdir) 467 | subdir = d / 'subdir' 468 | subdir.makedirs() 469 | old_dir = os.getcwd() 470 | with subdir: 471 | assert os.getcwd() == os.path.realpath(subdir) 472 | assert os.getcwd() == old_dir 473 | 474 | def test_touch(self, tmpdir): 475 | # NOTE: This test takes a long time to run (~10 seconds). 476 | # It sleeps several seconds because on Windows, the resolution 477 | # of a file's mtime and ctime is about 2 seconds. 478 | # 479 | # atime isn't tested because on Windows the resolution of atime 480 | # is something like 24 hours. 481 | 482 | threshold = 1 483 | 484 | d = Path(tmpdir) 485 | f = d / 'test.txt' 486 | t0 = time.time() - threshold 487 | f.touch() 488 | t1 = time.time() + threshold 489 | 490 | assert f.exists() 491 | assert f.is_file() 492 | assert f.size == 0 493 | assert t0 <= f.mtime <= t1 494 | if hasattr(os.path, 'getctime'): 495 | ct = f.ctime 496 | assert t0 <= ct <= t1 497 | 498 | time.sleep(threshold * 2) 499 | with open(f, 'ab') as fobj: 500 | fobj.write(b'some bytes') 501 | 502 | time.sleep(threshold * 2) 503 | t2 = time.time() - threshold 504 | f.touch() 505 | t3 = time.time() + threshold 506 | 507 | assert t0 <= t1 < t2 <= t3 # sanity check 508 | 509 | assert f.exists() 510 | assert f.is_file() 511 | assert f.size == 10 512 | assert t2 <= f.mtime <= t3 513 | if hasattr(os.path, 'getctime'): 514 | ct2 = f.ctime 515 | if platform.system() == 'Windows': # pragma: nocover 516 | # On Windows, "ctime" is CREATION time 517 | assert ct == ct2 518 | assert ct2 < t2 519 | else: 520 | assert ( 521 | # ctime is unchanged 522 | ct == ct2 523 | or 524 | # ctime is approximately the mtime 525 | ct2 == pytest.approx(f.mtime, 0.001) 526 | ) 527 | 528 | def test_listing(self, tmpdir): 529 | d = Path(tmpdir) 530 | assert list(d.iterdir()) == [] 531 | 532 | f = 'testfile.txt' 533 | af = d / f 534 | assert af == os.path.join(d, f) 535 | af.touch() 536 | try: 537 | assert af.exists() 538 | 539 | assert list(d.iterdir()) == [af] 540 | 541 | # .glob() 542 | assert d.glob('testfile.txt') == [af] 543 | assert d.glob('test*.txt') == [af] 544 | assert d.glob('*.txt') == [af] 545 | assert d.glob('*txt') == [af] 546 | assert d.glob('*') == [af] 547 | assert d.glob('*.html') == [] 548 | assert d.glob('testfile') == [] 549 | 550 | # .iglob matches .glob but as an iterator. 551 | assert list(d.iglob('*')) == d.glob('*') 552 | assert isinstance(d.iglob('*'), types.GeneratorType) 553 | 554 | finally: 555 | af.remove() 556 | 557 | # Try a test with 20 files 558 | files = [d / ('%d.txt' % i) for i in range(20)] 559 | for f in files: 560 | with open(f, 'w', encoding='utf-8') as fobj: 561 | fobj.write('some text\n') 562 | try: 563 | files2 = list(d.iterdir()) 564 | files.sort() 565 | files2.sort() 566 | assert files == files2 567 | finally: 568 | for f in files: 569 | with contextlib.suppress(Exception): 570 | f.remove() 571 | 572 | @pytest.fixture 573 | def bytes_filename(self, tmpdir): 574 | name = rb'r\xe9\xf1emi' 575 | base = str(tmpdir).encode('ascii') 576 | try: 577 | with open(os.path.join(base, name), 'wb'): 578 | pass 579 | except Exception as exc: 580 | raise pytest.skip(f"Invalid encodings disallowed {exc}") from exc 581 | return name 582 | 583 | def test_iterdir_other_encoding(self, tmpdir, bytes_filename): # pragma: nocover 584 | """ 585 | Some filesystems allow non-character sequences in path names. 586 | ``.iterdir`` should still function in this case. 587 | See issue #61 for details. 588 | """ 589 | # first demonstrate that os.listdir works 590 | assert os.listdir(str(tmpdir).encode('ascii')) 591 | 592 | # now try with path 593 | results = Path(tmpdir).iterdir() 594 | (res,) = results 595 | assert isinstance(res, Path) 596 | assert len(res.basename()) == len(bytes_filename) 597 | 598 | def test_makedirs(self, tmpdir): 599 | d = Path(tmpdir) 600 | 601 | # Placeholder file so that when removedirs() is called, 602 | # it doesn't remove the temporary directory itself. 603 | tempf = d / 'temp.txt' 604 | tempf.touch() 605 | try: 606 | foo = d / 'foo' 607 | boz = foo / 'bar' / 'baz' / 'boz' 608 | boz.makedirs() 609 | try: 610 | assert boz.is_dir() 611 | finally: 612 | boz.removedirs() 613 | assert not foo.exists() 614 | assert d.exists() 615 | 616 | foo.mkdir(0o750) 617 | boz.makedirs(0o700) 618 | try: 619 | assert boz.is_dir() 620 | finally: 621 | boz.removedirs() 622 | assert not foo.exists() 623 | assert d.exists() 624 | finally: 625 | os.remove(tempf) 626 | 627 | def assertSetsEqual(self, a, b): 628 | ad = {} 629 | 630 | for i in a: 631 | ad[i] = None 632 | 633 | bd = {} 634 | 635 | for i in b: 636 | bd[i] = None 637 | 638 | assert ad == bd 639 | 640 | def test_shutil(self, tmpdir): 641 | # Note: This only tests the methods exist and do roughly what 642 | # they should, neglecting the details as they are shutil's 643 | # responsibility. 644 | 645 | d = Path(tmpdir) 646 | testDir = d / 'testdir' 647 | testFile = testDir / 'testfile.txt' 648 | testA = testDir / 'A' 649 | testCopy = testA / 'testcopy.txt' 650 | testLink = testA / 'testlink.txt' 651 | testB = testDir / 'B' 652 | testC = testB / 'C' 653 | testCopyOfLink = testC / testA.relpathto(testLink) 654 | 655 | # Create test dirs and a file 656 | testDir.mkdir() 657 | testA.mkdir() 658 | testB.mkdir() 659 | 660 | with open(testFile, 'w', encoding='utf-8') as f: 661 | f.write('x' * 10000) 662 | 663 | # Test simple file copying. 664 | testFile.copyfile(testCopy) 665 | assert testCopy.is_file() 666 | assert testFile.bytes() == testCopy.bytes() 667 | 668 | # Test copying into a directory. 669 | testCopy2 = testA / testFile.name 670 | testFile.copy(testA) 671 | assert testCopy2.is_file() 672 | assert testFile.bytes() == testCopy2.bytes() 673 | 674 | # Make a link for the next test to use. 675 | testFile.symlink(testLink) 676 | 677 | # Test copying directory tree. 678 | testA.copytree(testC) 679 | assert testC.is_dir() 680 | self.assertSetsEqual( 681 | testC.iterdir(), 682 | [testC / testCopy.name, testC / testFile.name, testCopyOfLink], 683 | ) 684 | assert not testCopyOfLink.islink() 685 | 686 | # Clean up for another try. 687 | testC.rmtree() 688 | assert not testC.exists() 689 | 690 | # Copy again, preserving symlinks. 691 | testA.copytree(testC, True) 692 | assert testC.is_dir() 693 | self.assertSetsEqual( 694 | testC.iterdir(), 695 | [testC / testCopy.name, testC / testFile.name, testCopyOfLink], 696 | ) 697 | if hasattr(os, 'symlink'): 698 | assert testCopyOfLink.islink() 699 | assert testCopyOfLink.realpath() == testFile 700 | 701 | # Clean up. 702 | testDir.rmtree() 703 | assert not testDir.exists() 704 | self.assertList(d.iterdir(), []) 705 | 706 | def assertList(self, listing, expected): 707 | assert sorted(listing) == sorted(expected) 708 | 709 | def test_patterns(self, tmpdir): 710 | d = Path(tmpdir) 711 | names = ['x.tmp', 'x.xtmp', 'x2g', 'x22', 'x.txt'] 712 | dirs = [d, d / 'xdir', d / 'xdir.tmp', d / 'xdir.tmp' / 'xsubdir'] 713 | 714 | for e in dirs: 715 | if not e.is_dir(): 716 | e.makedirs() 717 | 718 | for name in names: 719 | (e / name).touch() 720 | self.assertList(d.iterdir('*.tmp'), [d / 'x.tmp', d / 'xdir.tmp']) 721 | self.assertList(d.files('*.tmp'), [d / 'x.tmp']) 722 | self.assertList(d.dirs('*.tmp'), [d / 'xdir.tmp']) 723 | self.assertList( 724 | d.walk(), [e for e in dirs if e != d] + [e / n for e in dirs for n in names] 725 | ) 726 | self.assertList(d.walk('*.tmp'), [e / 'x.tmp' for e in dirs] + [d / 'xdir.tmp']) 727 | self.assertList(d.walkfiles('*.tmp'), [e / 'x.tmp' for e in dirs]) 728 | self.assertList(d.walkdirs('*.tmp'), [d / 'xdir.tmp']) 729 | 730 | encodings = 'UTF-8', 'UTF-16BE', 'UTF-16LE', 'UTF-16' 731 | 732 | @pytest.mark.parametrize("encoding", encodings) 733 | def test_unicode(self, tmpdir, encoding): 734 | """Test that path works with the specified encoding, 735 | which must be capable of representing the entire range of 736 | Unicode codepoints. 737 | """ 738 | d = Path(tmpdir) 739 | p = d / 'unicode.txt' 740 | 741 | givenLines = [ 742 | 'Hello world\n', 743 | '\u0d0a\u0a0d\u0d15\u0a15\r\n', 744 | '\u0d0a\u0a0d\u0d15\u0a15\x85', 745 | '\u0d0a\u0a0d\u0d15\u0a15\u2028', 746 | '\r', 747 | 'hanging', 748 | ] 749 | given = ''.join(givenLines) 750 | expectedLines = [ 751 | 'Hello world\n', 752 | '\u0d0a\u0a0d\u0d15\u0a15\n', 753 | '\u0d0a\u0a0d\u0d15\u0a15\n', 754 | '\u0d0a\u0a0d\u0d15\u0a15\n', 755 | '\n', 756 | 'hanging', 757 | ] 758 | clean = ''.join(expectedLines) 759 | stripped = [line.replace('\n', '') for line in expectedLines] 760 | 761 | # write bytes manually to file 762 | with open(p, 'wb') as strm: 763 | strm.write(given.encode(encoding)) 764 | 765 | # test read-fully functions, including 766 | # path.lines() in unicode mode. 767 | assert p.read_bytes() == given.encode(encoding) 768 | assert p.lines(encoding) == expectedLines 769 | assert p.lines(encoding, retain=False) == stripped 770 | 771 | # If this is UTF-16, that's enough. 772 | # The rest of these will unfortunately fail because append=True 773 | # mode causes an extra BOM to be written in the middle of the file. 774 | # UTF-16 is the only encoding that has this problem. 775 | if encoding == 'UTF-16': 776 | return 777 | 778 | # Write Unicode to file using path.write_text(). 779 | # This test doesn't work with a hanging line. 780 | cleanNoHanging = clean + '\n' 781 | 782 | p.write_text(cleanNoHanging, encoding) 783 | p.write_text(cleanNoHanging, encoding, append=True) 784 | # Check the result. 785 | expectedBytes = 2 * cleanNoHanging.replace('\n', os.linesep).encode(encoding) 786 | expectedLinesNoHanging = expectedLines[:] 787 | expectedLinesNoHanging[-1] += '\n' 788 | assert p.bytes() == expectedBytes 789 | assert p.read_text(encoding) == 2 * cleanNoHanging 790 | assert p.lines(encoding) == 2 * expectedLinesNoHanging 791 | assert p.lines(encoding, retain=False) == 2 * stripped 792 | 793 | # Write Unicode to file using path.write_lines(). 794 | # The output in the file should be exactly the same as last time. 795 | p.write_lines(expectedLines, encoding) 796 | p.write_lines(stripped, encoding, append=True) 797 | # Check the result. 798 | assert p.bytes() == expectedBytes 799 | 800 | # Now: same test, but using various newline sequences. 801 | # If linesep is being properly applied, these will be converted 802 | # to the platform standard newline sequence. 803 | p.write_lines(givenLines, encoding) 804 | p.write_lines(givenLines, encoding, append=True) 805 | # Check the result. 806 | assert p.bytes() == expectedBytes 807 | 808 | def test_chunks(self, tmpdir): 809 | p = (TempDir() / 'test.txt').touch() 810 | txt = "0123456789" 811 | size = 5 812 | p.write_text(txt, encoding='utf-8') 813 | for i, chunk in enumerate(p.chunks(size, encoding='utf-8')): 814 | assert chunk == txt[i * size : i * size + size] 815 | 816 | assert i == len(txt) / size - 1 817 | 818 | def test_samefile(self, tmpdir): 819 | f1 = (TempDir() / '1.txt').touch() 820 | f1.write_text('foo') 821 | f2 = (TempDir() / '2.txt').touch() 822 | f1.write_text('foo') 823 | f3 = (TempDir() / '3.txt').touch() 824 | f1.write_text('bar') 825 | f4 = TempDir() / '4.txt' 826 | f1.copyfile(f4) 827 | 828 | assert os.path.samefile(f1, f2) == f1.samefile(f2) 829 | assert os.path.samefile(f1, f3) == f1.samefile(f3) 830 | assert os.path.samefile(f1, f4) == f1.samefile(f4) 831 | assert os.path.samefile(f1, f1) == f1.samefile(f1) 832 | 833 | def test_rmtree_p(self, tmpdir): 834 | d = Path(tmpdir) 835 | sub = d / 'subfolder' 836 | sub.mkdir() 837 | (sub / 'afile').write_text('something') 838 | sub.rmtree_p() 839 | assert not sub.exists() 840 | 841 | def test_rmtree_p_nonexistent(self, tmpdir): 842 | d = Path(tmpdir) 843 | sub = d / 'subfolder' 844 | assert not sub.exists() 845 | sub.rmtree_p() 846 | 847 | def test_rmdir_p_exists(self, tmpdir): 848 | """ 849 | Invocation of rmdir_p on an existant directory should 850 | remove the directory. 851 | """ 852 | d = Path(tmpdir) 853 | sub = d / 'subfolder' 854 | sub.mkdir() 855 | sub.rmdir_p() 856 | assert not sub.exists() 857 | 858 | def test_rmdir_p_nonexistent(self, tmpdir): 859 | """ 860 | A non-existent file should not raise an exception. 861 | """ 862 | d = Path(tmpdir) 863 | sub = d / 'subfolder' 864 | assert not sub.exists() 865 | sub.rmdir_p() 866 | 867 | def test_rmdir_p_sub_sub_dir(self, tmpdir): 868 | """ 869 | A non-empty folder should not raise an exception. 870 | """ 871 | d = Path(tmpdir) 872 | sub = d / 'subfolder' 873 | sub.mkdir() 874 | subsub = sub / 'subfolder' 875 | subsub.mkdir() 876 | 877 | sub.rmdir_p() 878 | 879 | 880 | class TestMergeTree: 881 | @pytest.fixture(autouse=True) 882 | def testing_structure(self, tmpdir): 883 | self.test_dir = Path(tmpdir) 884 | self.subdir_a = self.test_dir / 'A' 885 | self.test_file = self.subdir_a / 'testfile.txt' 886 | self.test_link = self.subdir_a / 'testlink.txt' 887 | self.subdir_b = self.test_dir / 'B' 888 | 889 | self.subdir_a.mkdir() 890 | self.subdir_b.mkdir() 891 | 892 | with open(self.test_file, 'w', encoding='utf-8') as f: 893 | f.write('x' * 10000) 894 | 895 | self.test_file.symlink(self.test_link) 896 | 897 | def check_link(self): 898 | target = Path(self.subdir_b / self.test_link.name) 899 | check = target.islink if hasattr(os, 'symlink') else target.is_file 900 | assert check() 901 | 902 | def test_with_nonexisting_dst_kwargs(self): 903 | self.subdir_a.merge_tree(self.subdir_b, symlinks=True) 904 | assert self.subdir_b.is_dir() 905 | expected = { 906 | self.subdir_b / self.test_file.name, 907 | self.subdir_b / self.test_link.name, 908 | } 909 | assert set(self.subdir_b.iterdir()) == expected 910 | self.check_link() 911 | 912 | def test_with_nonexisting_dst_args(self): 913 | self.subdir_a.merge_tree(self.subdir_b, True) 914 | assert self.subdir_b.is_dir() 915 | expected = { 916 | self.subdir_b / self.test_file.name, 917 | self.subdir_b / self.test_link.name, 918 | } 919 | assert set(self.subdir_b.iterdir()) == expected 920 | self.check_link() 921 | 922 | def test_with_existing_dst(self): 923 | self.subdir_b.rmtree() 924 | self.subdir_a.copytree(self.subdir_b, True) 925 | 926 | self.test_link.remove() 927 | test_new = self.subdir_a / 'newfile.txt' 928 | test_new.touch() 929 | with open(self.test_file, 'w', encoding='utf-8') as f: 930 | f.write('x' * 5000) 931 | 932 | self.subdir_a.merge_tree(self.subdir_b, True) 933 | 934 | assert self.subdir_b.is_dir() 935 | expected = { 936 | self.subdir_b / self.test_file.name, 937 | self.subdir_b / self.test_link.name, 938 | self.subdir_b / test_new.name, 939 | } 940 | assert set(self.subdir_b.iterdir()) == expected 941 | self.check_link() 942 | assert len(Path(self.subdir_b / self.test_file.name).bytes()) == 5000 943 | 944 | def test_copytree_parameters(self): 945 | """ 946 | merge_tree should accept parameters to copytree, such as 'ignore' 947 | """ 948 | ignore = shutil.ignore_patterns('testlink*') 949 | self.subdir_a.merge_tree(self.subdir_b, ignore=ignore) 950 | 951 | assert self.subdir_b.is_dir() 952 | assert list(self.subdir_b.iterdir()) == [self.subdir_b / self.test_file.name] 953 | 954 | def test_only_newer(self): 955 | """ 956 | merge_tree should accept a copy_function in which only 957 | newer files are copied and older files do not overwrite 958 | newer copies in the dest. 959 | """ 960 | target = self.subdir_b / 'testfile.txt' 961 | target.write_text('this is newer', encoding='utf-8') 962 | self.subdir_a.merge_tree( 963 | self.subdir_b, copy_function=path.only_newer(shutil.copy2) 964 | ) 965 | assert target.read_text(encoding='utf-8') == 'this is newer' 966 | 967 | def test_nested(self): 968 | self.subdir_a.joinpath('subsub').mkdir() 969 | self.subdir_a.merge_tree(self.subdir_b) 970 | assert self.subdir_b.joinpath('subsub').is_dir() 971 | 972 | 973 | class TestChdir: 974 | def test_chdir_or_cd(self, tmpdir): 975 | """tests the chdir or cd method""" 976 | d = Path(str(tmpdir)) 977 | cwd = d.cwd() 978 | 979 | # ensure the cwd isn't our tempdir 980 | assert str(d) != str(cwd) 981 | # now, we're going to chdir to tempdir 982 | d.chdir() 983 | 984 | # we now ensure that our cwd is the tempdir 985 | assert str(d.cwd()) == str(tmpdir) 986 | # we're resetting our path 987 | d = Path(cwd) 988 | 989 | # we ensure that our cwd is still set to tempdir 990 | assert str(d.cwd()) == str(tmpdir) 991 | 992 | # we're calling the alias cd method 993 | d.cd() 994 | # now, we ensure cwd isn'r tempdir 995 | assert str(d.cwd()) == str(cwd) 996 | assert str(d.cwd()) != str(tmpdir) 997 | 998 | 999 | class TestSubclass: 1000 | def test_subclass_produces_same_class(self): 1001 | """ 1002 | When operations are invoked on a subclass, they should produce another 1003 | instance of that subclass. 1004 | """ 1005 | 1006 | class PathSubclass(Path): 1007 | pass 1008 | 1009 | p = PathSubclass('/foo') 1010 | subdir = p / 'bar' 1011 | assert isinstance(subdir, PathSubclass) 1012 | 1013 | 1014 | class TestTempDir: 1015 | def test_constructor(self): 1016 | """ 1017 | One should be able to readily construct a temporary directory 1018 | """ 1019 | d = TempDir() 1020 | assert isinstance(d, path.Path) 1021 | assert d.exists() 1022 | assert d.is_dir() 1023 | d.rmdir() 1024 | assert not d.exists() 1025 | 1026 | def test_next_class(self): 1027 | """ 1028 | It should be possible to invoke operations on a TempDir and get 1029 | Path classes. 1030 | """ 1031 | d = TempDir() 1032 | sub = d / 'subdir' 1033 | assert isinstance(sub, path.Path) 1034 | d.rmdir() 1035 | 1036 | def test_context_manager(self): 1037 | """ 1038 | One should be able to use a TempDir object as a context, which will 1039 | clean up the contents after. 1040 | """ 1041 | d = TempDir() 1042 | res = d.__enter__() 1043 | assert res == path.Path(d) 1044 | (d / 'somefile.txt').touch() 1045 | assert not isinstance(d / 'somefile.txt', TempDir) 1046 | d.__exit__(None, None, None) 1047 | assert not d.exists() 1048 | 1049 | def test_context_manager_using_with(self): 1050 | """ 1051 | The context manager will allow using the with keyword and 1052 | provide a temporary directory that will be deleted after that. 1053 | """ 1054 | 1055 | with TempDir() as d: 1056 | assert d.is_dir() 1057 | assert not d.is_dir() 1058 | 1059 | def test_cleaned_up_on_interrupt(self): 1060 | with contextlib.suppress(KeyboardInterrupt): 1061 | with TempDir() as d: 1062 | raise KeyboardInterrupt() 1063 | 1064 | assert not d.exists() 1065 | 1066 | def test_constructor_dir_argument(self, tmpdir): 1067 | """ 1068 | It should be possible to provide a dir argument to the constructor 1069 | """ 1070 | with TempDir(dir=tmpdir) as d: 1071 | assert str(d).startswith(str(tmpdir)) 1072 | 1073 | def test_constructor_prefix_argument(self): 1074 | """ 1075 | It should be possible to provide a prefix argument to the constructor 1076 | """ 1077 | prefix = 'test_prefix' 1078 | with TempDir(prefix=prefix) as d: 1079 | assert d.name.startswith(prefix) 1080 | 1081 | def test_constructor_suffix_argument(self): 1082 | """ 1083 | It should be possible to provide a suffix argument to the constructor 1084 | """ 1085 | suffix = 'test_suffix' 1086 | with TempDir(suffix=suffix) as d: 1087 | assert str(d).endswith(suffix) 1088 | 1089 | 1090 | class TestUnicode: 1091 | @pytest.fixture(autouse=True) 1092 | def unicode_name_in_tmpdir(self, tmpdir): 1093 | # build a snowman (dir) in the temporary directory 1094 | Path(tmpdir).joinpath('☃').mkdir() 1095 | 1096 | def test_walkdirs_with_unicode_name(self, tmpdir): 1097 | for _res in Path(tmpdir).walkdirs(): 1098 | pass 1099 | 1100 | 1101 | class TestPatternMatching: 1102 | def test_fnmatch_simple(self): 1103 | p = Path('FooBar') 1104 | assert p.fnmatch('Foo*') 1105 | assert p.fnmatch('Foo[ABC]ar') 1106 | 1107 | def test_fnmatch_custom_mod(self): 1108 | p = Path('FooBar') 1109 | p.module = ntpath 1110 | assert p.fnmatch('foobar') 1111 | assert p.fnmatch('FOO[ABC]AR') 1112 | 1113 | def test_fnmatch_custom_normcase(self): 1114 | def normcase(path): 1115 | return path.upper() 1116 | 1117 | p = Path('FooBar') 1118 | assert p.fnmatch('foobar', normcase=normcase) 1119 | assert p.fnmatch('FOO[ABC]AR', normcase=normcase) 1120 | 1121 | def test_iterdir_simple(self): 1122 | p = Path('.') 1123 | assert ilen(p.iterdir()) == len(os.listdir('.')) 1124 | 1125 | def test_iterdir_empty_pattern(self): 1126 | p = Path('.') 1127 | assert list(p.iterdir('')) == [] 1128 | 1129 | def test_iterdir_patterns(self, tmpdir): 1130 | p = Path(tmpdir) 1131 | (p / 'sub').mkdir() 1132 | (p / 'File').touch() 1133 | assert list(p.iterdir('s*')) == [p / 'sub'] 1134 | assert ilen(p.iterdir('*')) == 2 1135 | 1136 | def test_iterdir_custom_module(self, tmpdir): 1137 | """ 1138 | Listdir patterns should honor the case sensitivity of the path module 1139 | used by that Path class. 1140 | """ 1141 | always_unix = Path.using_module(posixpath) 1142 | p = always_unix(tmpdir) 1143 | (p / 'sub').mkdir() 1144 | (p / 'File').touch() 1145 | assert list(p.iterdir('S*')) == [] 1146 | 1147 | always_win = Path.using_module(ntpath) 1148 | p = always_win(tmpdir) 1149 | assert list(p.iterdir('S*')) == [p / 'sub'] 1150 | assert list(p.iterdir('f*')) == [p / 'File'] 1151 | 1152 | def test_iterdir_case_insensitive(self, tmpdir): 1153 | """ 1154 | Listdir patterns should honor the case sensitivity of the path module 1155 | used by that Path class. 1156 | """ 1157 | p = Path(tmpdir) 1158 | (p / 'sub').mkdir() 1159 | (p / 'File').touch() 1160 | assert list(p.iterdir(matchers.CaseInsensitive('S*'))) == [p / 'sub'] 1161 | assert list(p.iterdir(matchers.CaseInsensitive('f*'))) == [p / 'File'] 1162 | assert p.files(matchers.CaseInsensitive('S*')) == [] 1163 | assert p.dirs(matchers.CaseInsensitive('f*')) == [] 1164 | 1165 | def test_walk_case_insensitive(self, tmpdir): 1166 | p = Path(tmpdir) 1167 | (p / 'sub1' / 'foo').makedirs_p() 1168 | (p / 'sub2' / 'foo').makedirs_p() 1169 | (p / 'sub1' / 'foo' / 'bar.Txt').touch() 1170 | (p / 'sub2' / 'foo' / 'bar.TXT').touch() 1171 | (p / 'sub2' / 'foo' / 'bar.txt.bz2').touch() 1172 | files = list(p.walkfiles(matchers.CaseInsensitive('*.txt'))) 1173 | assert len(files) == 2 1174 | assert p / 'sub2' / 'foo' / 'bar.TXT' in files 1175 | assert p / 'sub1' / 'foo' / 'bar.Txt' in files 1176 | 1177 | 1178 | class TestInPlace: 1179 | reference_content = textwrap.dedent( 1180 | """ 1181 | The quick brown fox jumped over the lazy dog. 1182 | """.lstrip() 1183 | ) 1184 | reversed_content = textwrap.dedent( 1185 | """ 1186 | .god yzal eht revo depmuj xof nworb kciuq ehT 1187 | """.lstrip() 1188 | ) 1189 | alternate_content = textwrap.dedent( 1190 | """ 1191 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, 1192 | sed do eiusmod tempor incididunt ut labore et dolore magna 1193 | aliqua. Ut enim ad minim veniam, quis nostrud exercitation 1194 | ullamco laboris nisi ut aliquip ex ea commodo consequat. 1195 | Duis aute irure dolor in reprehenderit in voluptate velit 1196 | esse cillum dolore eu fugiat nulla pariatur. Excepteur 1197 | sint occaecat cupidatat non proident, sunt in culpa qui 1198 | officia deserunt mollit anim id est laborum. 1199 | """.lstrip() 1200 | ) 1201 | 1202 | @classmethod 1203 | def create_reference(cls, tmpdir): 1204 | p = Path(tmpdir) / 'document' 1205 | with p.open('w', encoding='utf-8') as stream: 1206 | stream.write(cls.reference_content) 1207 | return p 1208 | 1209 | def test_line_by_line_rewrite(self, tmpdir): 1210 | doc = self.create_reference(tmpdir) 1211 | # reverse all the text in the document, line by line 1212 | with doc.in_place(encoding='utf-8') as (reader, writer): 1213 | for line in reader: 1214 | r_line = ''.join(reversed(line.strip())) + '\n' 1215 | writer.write(r_line) 1216 | with doc.open(encoding='utf-8') as stream: 1217 | data = stream.read() 1218 | assert data == self.reversed_content 1219 | 1220 | def test_exception_in_context(self, tmpdir): 1221 | doc = self.create_reference(tmpdir) 1222 | with pytest.raises(RuntimeError) as exc: 1223 | with doc.in_place(encoding='utf-8') as (reader, writer): 1224 | writer.write(self.alternate_content) 1225 | raise RuntimeError("some error") 1226 | assert "some error" in str(exc.value) 1227 | with doc.open(encoding='utf-8') as stream: 1228 | data = stream.read() 1229 | assert 'Lorem' not in data 1230 | assert 'lazy dog' in data 1231 | 1232 | def test_write_mode_invalid(self, tmpdir): 1233 | with pytest.raises(ValueError): 1234 | with (Path(tmpdir) / 'document').in_place(mode='w'): 1235 | pass 1236 | 1237 | 1238 | class TestSpecialPaths: 1239 | @pytest.fixture(autouse=True, scope='class') 1240 | def appdirs_installed(cls): 1241 | pytest.importorskip('appdirs') 1242 | 1243 | @pytest.fixture 1244 | def feign_linux(self, monkeypatch): 1245 | monkeypatch.setattr("platform.system", lambda: "Linux") 1246 | monkeypatch.setattr("sys.platform", "linux") 1247 | monkeypatch.setattr("os.pathsep", ":") 1248 | # remove any existing import of appdirs, as it sets up some 1249 | # state during import. 1250 | sys.modules.pop('appdirs') 1251 | 1252 | def test_basic_paths(self): 1253 | appdirs = importlib.import_module('appdirs') 1254 | 1255 | expected = appdirs.user_config_dir() 1256 | assert SpecialResolver(Path).user.config == expected 1257 | 1258 | expected = appdirs.site_config_dir() 1259 | assert SpecialResolver(Path).site.config == expected 1260 | 1261 | expected = appdirs.user_config_dir('My App', 'Me') 1262 | assert SpecialResolver(Path, 'My App', 'Me').user.config == expected 1263 | 1264 | def test_unix_paths(self, tmpdir, monkeypatch, feign_linux): 1265 | fake_config = tmpdir / '_config' 1266 | monkeypatch.setitem(os.environ, 'XDG_CONFIG_HOME', str(fake_config)) 1267 | expected = str(tmpdir / '_config') 1268 | assert SpecialResolver(Path).user.config == expected 1269 | 1270 | def test_unix_paths_fallback(self, tmpdir, monkeypatch, feign_linux): 1271 | "Without XDG_CONFIG_HOME set, ~/.config should be used." 1272 | fake_home = tmpdir / '_home' 1273 | monkeypatch.delitem(os.environ, 'XDG_CONFIG_HOME', raising=False) 1274 | monkeypatch.setitem(os.environ, 'HOME', str(fake_home)) 1275 | expected = Path('~/.config').expanduser() 1276 | assert SpecialResolver(Path).user.config == expected 1277 | 1278 | def test_property(self): 1279 | assert isinstance(Path.special().user.config, Path) 1280 | assert isinstance(Path.special().user.data, Path) 1281 | assert isinstance(Path.special().user.cache, Path) 1282 | 1283 | def test_other_parameters(self): 1284 | """ 1285 | Other parameters should be passed through to appdirs function. 1286 | """ 1287 | res = Path.special(version="1.0", multipath=True).site.config 1288 | assert isinstance(res, Path) 1289 | 1290 | def test_multipath(self, feign_linux, monkeypatch, tmpdir): 1291 | """ 1292 | If multipath is provided, on Linux return the XDG_CONFIG_DIRS 1293 | """ 1294 | fake_config_1 = str(tmpdir / '_config1') 1295 | fake_config_2 = str(tmpdir / '_config2') 1296 | config_dirs = os.pathsep.join([fake_config_1, fake_config_2]) 1297 | monkeypatch.setitem(os.environ, 'XDG_CONFIG_DIRS', config_dirs) 1298 | res = Path.special(multipath=True).site.config 1299 | assert isinstance(res, Multi) 1300 | assert fake_config_1 in res 1301 | assert fake_config_2 in res 1302 | assert '_config1' in str(res) 1303 | 1304 | def test_reused_SpecialResolver(self): 1305 | """ 1306 | Passing additional args and kwargs to SpecialResolver should be 1307 | passed through to each invocation of the function in appdirs. 1308 | """ 1309 | appdirs = importlib.import_module('appdirs') 1310 | 1311 | adp = SpecialResolver(Path, version="1.0") 1312 | res = adp.user.config 1313 | 1314 | expected = appdirs.user_config_dir(version="1.0") 1315 | assert res == expected 1316 | 1317 | 1318 | class TestMultiPath: 1319 | def test_for_class(self): 1320 | """ 1321 | Multi.for_class should return a subclass of the Path class provided. 1322 | """ 1323 | cls = Multi.for_class(Path) 1324 | assert issubclass(cls, Path) 1325 | assert issubclass(cls, Multi) 1326 | expected_name = 'Multi' + Path.__name__ 1327 | assert cls.__name__ == expected_name 1328 | 1329 | def test_detect_no_pathsep(self): 1330 | """ 1331 | If no pathsep is provided, multipath detect should return an instance 1332 | of the parent class with no Multi mix-in. 1333 | """ 1334 | path = Multi.for_class(Path).detect('/foo/bar') 1335 | assert isinstance(path, Path) 1336 | assert not isinstance(path, Multi) 1337 | 1338 | def test_detect_with_pathsep(self): 1339 | """ 1340 | If a pathsep appears in the input, detect should return an instance 1341 | of a Path with the Multi mix-in. 1342 | """ 1343 | inputs = '/foo/bar', '/baz/bing' 1344 | input = os.pathsep.join(inputs) 1345 | path = Multi.for_class(Path).detect(input) 1346 | 1347 | assert isinstance(path, Multi) 1348 | 1349 | def test_iteration(self): 1350 | """ 1351 | Iterating over a MultiPath should yield instances of the 1352 | parent class. 1353 | """ 1354 | inputs = '/foo/bar', '/baz/bing' 1355 | input = os.pathsep.join(inputs) 1356 | path = Multi.for_class(Path).detect(input) 1357 | 1358 | items = iter(path) 1359 | first = next(items) 1360 | assert first == '/foo/bar' 1361 | assert isinstance(first, Path) 1362 | assert not isinstance(first, Multi) 1363 | assert next(items) == '/baz/bing' 1364 | assert path == input 1365 | 1366 | 1367 | def test_no_dependencies(): 1368 | """ 1369 | Path pie guarantees that the path module can be 1370 | transplanted into an environment without any dependencies. 1371 | """ 1372 | cmd = [sys.executable, '-S', '-c', 'import path'] 1373 | subprocess.check_call(cmd) 1374 | 1375 | 1376 | class TestHandlers: 1377 | @staticmethod 1378 | def run_with_handler(handler): 1379 | try: 1380 | raise ValueError() 1381 | except Exception: 1382 | handler("Something unexpected happened") 1383 | 1384 | def test_raise(self): 1385 | handler = path.Handlers._resolve('strict') 1386 | with pytest.raises(ValueError): 1387 | self.run_with_handler(handler) 1388 | 1389 | def test_warn(self): 1390 | handler = path.Handlers._resolve('warn') 1391 | with pytest.warns(path.TreeWalkWarning): 1392 | self.run_with_handler(handler) 1393 | 1394 | def test_ignore(self): 1395 | handler = path.Handlers._resolve('ignore') 1396 | self.run_with_handler(handler) 1397 | 1398 | def test_invalid_handler(self): 1399 | with pytest.raises(ValueError): 1400 | path.Handlers._resolve('raise') 1401 | -------------------------------------------------------------------------------- /path/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Path Pie 3 | 4 | Implements ``path.Path`` - An object representing a 5 | path to a file or directory. 6 | 7 | Example:: 8 | 9 | from path import Path 10 | d = Path('/home/guido/bin') 11 | 12 | # Globbing 13 | for f in d.files('*.py'): 14 | f.chmod(0o755) 15 | 16 | # Changing the working directory: 17 | with Path("somewhere"): 18 | # cwd in now `somewhere` 19 | ... 20 | 21 | # Concatenate paths with / 22 | foo_txt = Path("bar") / "foo.txt" 23 | """ 24 | 25 | from __future__ import annotations 26 | 27 | import builtins 28 | import contextlib 29 | import datetime 30 | import errno 31 | import fnmatch 32 | import functools 33 | import glob 34 | import hashlib 35 | import importlib 36 | import itertools 37 | import os 38 | import pathlib 39 | import re 40 | import shutil 41 | import sys 42 | import tempfile 43 | import warnings 44 | from io import ( 45 | BufferedRandom, 46 | BufferedReader, 47 | BufferedWriter, 48 | FileIO, 49 | TextIOWrapper, 50 | ) 51 | from types import ModuleType 52 | 53 | with contextlib.suppress(ImportError): 54 | import win32security 55 | 56 | with contextlib.suppress(ImportError): 57 | import pwd 58 | 59 | with contextlib.suppress(ImportError): 60 | import grp 61 | 62 | from collections.abc import Generator, Iterable, Iterator 63 | from typing import ( 64 | IO, 65 | TYPE_CHECKING, 66 | Any, 67 | BinaryIO, 68 | Callable, 69 | overload, 70 | ) 71 | 72 | if TYPE_CHECKING: 73 | from typing import Union 74 | 75 | from _typeshed import ( 76 | ExcInfo, 77 | OpenBinaryMode, 78 | OpenBinaryModeReading, 79 | OpenBinaryModeUpdating, 80 | OpenBinaryModeWriting, 81 | OpenTextMode, 82 | ) 83 | from typing_extensions import Literal, Never, Self 84 | 85 | _Match = Union[str, Callable[[str], bool], None] 86 | _CopyFn = Callable[[str, str], object] 87 | _IgnoreFn = Callable[[str, list[str]], Iterable[str]] 88 | _OnErrorCallback = Callable[[Callable[..., Any], str, ExcInfo], object] 89 | _OnExcCallback = Callable[[Callable[..., Any], str, BaseException], object] 90 | 91 | 92 | from . import classes, masks, matchers 93 | from .compat.py38 import removeprefix, removesuffix 94 | 95 | __all__ = ['Path', 'TempDir', 'Traversal'] 96 | 97 | LINESEPS = ['\r\n', '\r', '\n'] 98 | U_LINESEPS = LINESEPS + ['\u0085', '\u2028', '\u2029'] 99 | B_NEWLINE = re.compile('|'.join(LINESEPS).encode()) 100 | U_NEWLINE = re.compile('|'.join(U_LINESEPS)) 101 | B_NL_END = re.compile(B_NEWLINE.pattern + b'$') 102 | U_NL_END = re.compile(U_NEWLINE.pattern + '$') 103 | 104 | _default_linesep = object() 105 | 106 | 107 | def _make_timestamp_ns(value: float | datetime.datetime) -> int: 108 | timestamp_s = value if isinstance(value, (float, int)) else value.timestamp() 109 | return int(timestamp_s * 10**9) 110 | 111 | 112 | class TreeWalkWarning(Warning): 113 | pass 114 | 115 | 116 | class Traversal: 117 | """ 118 | Wrap a walk result to customize the traversal. 119 | 120 | `follow` is a function that takes an item and returns 121 | True if that item should be followed and False otherwise. 122 | 123 | For example, to avoid traversing into directories that 124 | begin with `.`: 125 | 126 | >>> traverse = Traversal(lambda dir: not dir.startswith('.')) 127 | >>> items = list(traverse(Path('.').walk())) 128 | 129 | Directories beginning with `.` will appear in the results, but 130 | their children will not. 131 | 132 | >>> dot_dir = next(item for item in items if item.is_dir() and item.startswith('.')) 133 | >>> any(item.parent == dot_dir for item in items) 134 | False 135 | """ 136 | 137 | def __init__(self, follow: Callable[[Path], bool]): 138 | self.follow = follow 139 | 140 | def __call__( 141 | self, walker: Generator[Path, Callable[[], bool] | None, None] 142 | ) -> Iterator[Path]: 143 | traverse = None 144 | while True: 145 | try: 146 | item = walker.send(traverse) 147 | except StopIteration: 148 | return 149 | yield item 150 | 151 | traverse = functools.partial(self.follow, item) 152 | 153 | 154 | def _strip_newlines(lines: Iterable[str]) -> Iterator[str]: 155 | r""" 156 | >>> list(_strip_newlines(['Hello World\r\n', 'foo'])) 157 | ['Hello World', 'foo'] 158 | """ 159 | return (U_NL_END.sub('', line) for line in lines) 160 | 161 | 162 | class Path(str): 163 | """ 164 | Represents a filesystem path. 165 | 166 | For documentation on individual methods, consult their 167 | counterparts in :mod:`os.path`. 168 | 169 | Some methods are additionally included from :mod:`shutil`. 170 | The functions are linked directly into the class namespace 171 | such that they will be bound to the Path instance. For example, 172 | ``Path(src).copy(target)`` is equivalent to 173 | ``shutil.copy(src, target)``. Therefore, when referencing 174 | the docs for these methods, assume `src` references `self`, 175 | the Path instance. 176 | """ 177 | 178 | module: ModuleType = os.path 179 | """ The path module to use for path operations. 180 | 181 | .. seealso:: :mod:`os.path` 182 | """ 183 | 184 | def __new__(cls, other: Any = '.') -> Self: 185 | return super().__new__(cls, other) 186 | 187 | def __init__(self, other: Any = '.') -> None: 188 | if other is None: 189 | raise TypeError("Invalid initial value for path: None") 190 | self._validate() 191 | 192 | def _validate(self) -> None: 193 | pass 194 | 195 | @classmethod 196 | @functools.lru_cache 197 | def using_module(cls, module: ModuleType) -> type[Self]: 198 | subclass_name = cls.__name__ + '_' + module.__name__ 199 | bases = (cls,) 200 | ns = {'module': module} 201 | return type(subclass_name, bases, ns) 202 | 203 | @classes.ClassProperty 204 | @classmethod 205 | def _next_class(cls) -> type[Self]: 206 | """ 207 | What class should be used to construct new instances from this class 208 | """ 209 | return cls 210 | 211 | # --- Special Python methods. 212 | 213 | def __repr__(self) -> str: 214 | return f'{type(self).__name__}({super().__repr__()})' 215 | 216 | # Adding a Path and a string yields a Path. 217 | def __add__(self, more: str) -> Self: 218 | return self._next_class(super().__add__(more)) 219 | 220 | def __radd__(self, other: str) -> Self: 221 | return self._next_class(other.__add__(self)) 222 | 223 | # The / operator joins Paths. 224 | def __truediv__(self, rel: str) -> Self: 225 | """fp.__truediv__(rel) == fp / rel == fp.joinpath(rel) 226 | 227 | Join two path components, adding a separator character if 228 | needed. 229 | 230 | .. seealso:: :func:`os.path.join` 231 | """ 232 | return self._next_class(self.module.join(self, rel)) 233 | 234 | # The / operator joins Paths the other way around 235 | def __rtruediv__(self, rel: str) -> Self: 236 | """fp.__rtruediv__(rel) == rel / fp 237 | 238 | Join two path components, adding a separator character if 239 | needed. 240 | 241 | .. seealso:: :func:`os.path.join` 242 | """ 243 | return self._next_class(self.module.join(rel, self)) 244 | 245 | def __enter__(self) -> Self: 246 | self._old_dir = self.cwd() 247 | os.chdir(self) 248 | return self 249 | 250 | def __exit__(self, *_) -> None: 251 | os.chdir(self._old_dir) 252 | 253 | @classmethod 254 | def cwd(cls): 255 | """Return the current working directory as a path object. 256 | 257 | .. seealso:: :func:`os.getcwd` 258 | """ 259 | return cls(os.getcwd()) 260 | 261 | @classmethod 262 | def home(cls) -> Path: 263 | return cls(os.path.expanduser('~')) 264 | 265 | # 266 | # --- Operations on Path strings. 267 | 268 | def absolute(self) -> Self: 269 | """.. seealso:: :func:`os.path.abspath`""" 270 | return self._next_class(self.module.abspath(self)) 271 | 272 | def normcase(self) -> Self: 273 | """.. seealso:: :func:`os.path.normcase`""" 274 | return self._next_class(self.module.normcase(self)) 275 | 276 | def normpath(self) -> Self: 277 | """.. seealso:: :func:`os.path.normpath`""" 278 | return self._next_class(self.module.normpath(self)) 279 | 280 | def realpath(self) -> Self: 281 | """.. seealso:: :func:`os.path.realpath`""" 282 | return self._next_class(self.module.realpath(self)) 283 | 284 | def expanduser(self) -> Self: 285 | """.. seealso:: :func:`os.path.expanduser`""" 286 | return self._next_class(self.module.expanduser(self)) 287 | 288 | def expandvars(self) -> Self: 289 | """.. seealso:: :func:`os.path.expandvars`""" 290 | return self._next_class(self.module.expandvars(self)) 291 | 292 | def dirname(self) -> Self: 293 | """.. seealso:: :attr:`parent`, :func:`os.path.dirname`""" 294 | return self._next_class(self.module.dirname(self)) 295 | 296 | def basename(self) -> Self: 297 | """.. seealso:: :attr:`name`, :func:`os.path.basename`""" 298 | return self._next_class(self.module.basename(self)) 299 | 300 | def expand(self) -> Self: 301 | """Clean up a filename by calling :meth:`expandvars()`, 302 | :meth:`expanduser()`, and :meth:`normpath()` on it. 303 | 304 | This is commonly everything needed to clean up a filename 305 | read from a configuration file, for example. 306 | """ 307 | return self.expandvars().expanduser().normpath() 308 | 309 | @property 310 | def stem(self) -> str: 311 | """The same as :meth:`name`, but with one file extension stripped off. 312 | 313 | >>> Path('/home/guido/python.tar.gz').stem 314 | 'python.tar' 315 | """ 316 | base, _ = self.module.splitext(self.name) 317 | return base 318 | 319 | def with_stem(self, stem: str) -> Self: 320 | """Return a new path with the stem changed. 321 | 322 | >>> Path('/home/guido/python.tar.gz').with_stem("foo") 323 | Path('/home/guido/foo.gz') 324 | """ 325 | return self.with_name(stem + self.suffix) 326 | 327 | @property 328 | def suffix(self) -> Self: 329 | """The file extension, for example ``'.py'``.""" 330 | _, suffix = self.module.splitext(self) 331 | return suffix 332 | 333 | def with_suffix(self, suffix: str) -> Self: 334 | """Return a new path with the file suffix changed (or added, if none) 335 | 336 | >>> Path('/home/guido/python.tar.gz').with_suffix(".foo") 337 | Path('/home/guido/python.tar.foo') 338 | 339 | >>> Path('python').with_suffix('.zip') 340 | Path('python.zip') 341 | 342 | >>> Path('filename.ext').with_suffix('zip') 343 | Traceback (most recent call last): 344 | ... 345 | ValueError: Invalid suffix 'zip' 346 | """ 347 | if not suffix.startswith('.'): 348 | raise ValueError(f"Invalid suffix {suffix!r}") 349 | return self.stripext() + suffix 350 | 351 | @property 352 | def drive(self) -> Self: 353 | """The drive specifier, for example ``'C:'``. 354 | 355 | This is always empty on systems that don't use drive specifiers. 356 | """ 357 | drive, _ = self.module.splitdrive(self) 358 | return self._next_class(drive) 359 | 360 | @property 361 | def parent(self) -> Self: 362 | """This path's parent directory, as a new Path object. 363 | 364 | For example, 365 | ``Path('/usr/local/lib/libpython.so').parent == 366 | Path('/usr/local/lib')`` 367 | 368 | .. seealso:: :meth:`dirname`, :func:`os.path.dirname` 369 | """ 370 | return self.dirname() 371 | 372 | @property 373 | def name(self) -> Self: 374 | """The name of this file or directory without the full path. 375 | 376 | For example, 377 | ``Path('/usr/local/lib/libpython.so').name == 'libpython.so'`` 378 | 379 | .. seealso:: :meth:`basename`, :func:`os.path.basename` 380 | """ 381 | return self.basename() 382 | 383 | def with_name(self, name: str) -> Self: 384 | """Return a new path with the name changed. 385 | 386 | >>> Path('/home/guido/python.tar.gz').with_name("foo.zip") 387 | Path('/home/guido/foo.zip') 388 | """ 389 | return self._next_class(removesuffix(self, self.name) + name) 390 | 391 | def splitpath(self) -> tuple[Self, str]: 392 | """Return two-tuple of ``.parent``, ``.name``. 393 | 394 | .. seealso:: :attr:`parent`, :attr:`name`, :func:`os.path.split` 395 | """ 396 | parent, child = self.module.split(self) 397 | return self._next_class(parent), child 398 | 399 | def splitdrive(self) -> tuple[Self, Self]: 400 | """Return two-tuple of ``.drive`` and rest without drive. 401 | 402 | Split the drive specifier from this path. If there is 403 | no drive specifier, :samp:`{p.drive}` is empty, so the return value 404 | is simply ``(Path(''), p)``. This is always the case on Unix. 405 | 406 | .. seealso:: :func:`os.path.splitdrive` 407 | """ 408 | drive, rel = self.module.splitdrive(self) 409 | return self._next_class(drive), self._next_class(rel) 410 | 411 | def splitext(self) -> tuple[Self, str]: 412 | """Return two-tuple of ``.stripext()`` and ``.ext``. 413 | 414 | Split the filename extension from this path and return 415 | the two parts. Either part may be empty. 416 | 417 | The extension is everything from ``'.'`` to the end of the 418 | last path segment. This has the property that if 419 | ``(a, b) == p.splitext()``, then ``a + b == p``. 420 | 421 | .. seealso:: :func:`os.path.splitext` 422 | """ 423 | filename, ext = self.module.splitext(self) 424 | return self._next_class(filename), ext 425 | 426 | def stripext(self) -> Self: 427 | """Remove one file extension from the path. 428 | 429 | For example, ``Path('/home/guido/python.tar.gz').stripext()`` 430 | returns ``Path('/home/guido/python.tar')``. 431 | """ 432 | return self.splitext()[0] 433 | 434 | @classes.multimethod 435 | def joinpath(cls, first: str, *others: str) -> Self: 436 | """ 437 | Join first to zero or more :class:`Path` components, 438 | adding a separator character (:samp:`{first}.module.sep`) 439 | if needed. Returns a new instance of 440 | :samp:`{first}._next_class`. 441 | 442 | .. seealso:: :func:`os.path.join` 443 | """ 444 | return cls._next_class(cls.module.join(first, *others)) 445 | 446 | def splitall(self) -> list[Self | str]: 447 | r"""Return a list of the path components in this path. 448 | 449 | The first item in the list will be a Path. Its value will be 450 | either :data:`os.curdir`, :data:`os.pardir`, empty, or the root 451 | directory of this path (for example, ``'/'`` or ``'C:\\'``). The 452 | other items in the list will be strings. 453 | 454 | ``Path.joinpath(*result)`` will yield the original path. 455 | 456 | >>> Path('/foo/bar/baz').splitall() 457 | [Path('/'), 'foo', 'bar', 'baz'] 458 | """ 459 | return list(self._parts()) 460 | 461 | def parts(self) -> tuple[Self | str, ...]: 462 | """ 463 | >>> Path('/foo/bar/baz').parts() 464 | (Path('/'), 'foo', 'bar', 'baz') 465 | """ 466 | return tuple(self._parts()) 467 | 468 | def _parts(self) -> Iterator[Self | str]: 469 | return reversed(tuple(self._parts_iter())) 470 | 471 | def _parts_iter(self) -> Iterator[Self | str]: 472 | loc = self 473 | while loc != os.curdir and loc != os.pardir: 474 | prev = loc 475 | loc, child = prev.splitpath() 476 | if loc == prev: 477 | break 478 | yield child 479 | yield loc 480 | 481 | def relpath(self, start: str = '.') -> Self: 482 | """Return this path as a relative path, 483 | based from `start`, which defaults to the current working directory. 484 | """ 485 | cwd = self._next_class(start) 486 | return cwd.relpathto(self) 487 | 488 | def relpathto(self, dest: str) -> Self: 489 | """Return a relative path from `self` to `dest`. 490 | 491 | If there is no relative path from `self` to `dest`, for example if 492 | they reside on different drives in Windows, then this returns 493 | ``dest.absolute()``. 494 | """ 495 | origin = self.absolute() 496 | dest_path = self._next_class(dest).absolute() 497 | 498 | orig_list = origin.normcase().splitall() 499 | # Don't normcase dest! We want to preserve the case. 500 | dest_list = dest_path.splitall() 501 | 502 | if orig_list[0] != self.module.normcase(dest_list[0]): 503 | # Can't get here from there. 504 | return dest_path 505 | 506 | # Find the location where the two paths start to differ. 507 | i = 0 508 | for start_seg, dest_seg in zip(orig_list, dest_list): 509 | if start_seg != self.module.normcase(dest_seg): 510 | break 511 | i += 1 512 | 513 | # Now i is the point where the two paths diverge. 514 | # Need a certain number of "os.pardir"s to work up 515 | # from the origin to the point of divergence. 516 | segments = [os.pardir] * (len(orig_list) - i) 517 | # Need to add the diverging part of dest_list. 518 | segments += dest_list[i:] 519 | if len(segments) == 0: 520 | # If they happen to be identical, use os.curdir. 521 | relpath = os.curdir 522 | else: 523 | relpath = self.module.join(*segments) 524 | return self._next_class(relpath) 525 | 526 | # --- Listing, searching, walking, and matching 527 | 528 | def iterdir(self, match: _Match = None) -> Iterator[Self]: 529 | """Yields items in this directory. 530 | 531 | Use :meth:`files` or :meth:`dirs` instead if you want a listing 532 | of just files or just subdirectories. 533 | 534 | The elements of the list are Path objects. 535 | 536 | With the optional `match` argument, a callable, 537 | only return items whose names match the given pattern. 538 | 539 | .. seealso:: :meth:`files`, :meth:`dirs` 540 | """ 541 | match = matchers.load(match) 542 | return filter(match, (self / child for child in os.listdir(self))) 543 | 544 | def dirs(self, match: _Match = None) -> list[Self]: 545 | """List of this directory's subdirectories. 546 | 547 | The elements of the list are Path objects. 548 | This does not walk recursively into subdirectories 549 | (but see :meth:`walkdirs`). 550 | 551 | Accepts parameters to :meth:`iterdir`. 552 | """ 553 | return [p for p in self.iterdir(match) if p.is_dir()] 554 | 555 | def files(self, match: _Match = None) -> list[Self]: 556 | """List of the files in self. 557 | 558 | The elements of the list are Path objects. 559 | This does not walk into subdirectories (see :meth:`walkfiles`). 560 | 561 | Accepts parameters to :meth:`iterdir`. 562 | """ 563 | 564 | return [p for p in self.iterdir(match) if p.is_file()] 565 | 566 | def walk( 567 | self, match: _Match = None, errors: str = 'strict' 568 | ) -> Generator[Self, Callable[[], bool] | None, None]: 569 | """Iterator over files and subdirs, recursively. 570 | 571 | The iterator yields Path objects naming each child item of 572 | this directory and its descendants. This requires that 573 | ``D.is_dir()``. 574 | 575 | This performs a depth-first traversal of the directory tree. 576 | Each directory is returned just before all its children. 577 | 578 | The `errors=` keyword argument controls behavior when an 579 | error occurs. The default is ``'strict'``, which causes an 580 | exception. Other allowed values are ``'warn'`` (which 581 | reports the error via :func:`warnings.warn()`), and ``'ignore'``. 582 | `errors` may also be an arbitrary callable taking a msg parameter. 583 | """ 584 | 585 | error_fn = Handlers._resolve(errors) 586 | match = matchers.load(match) 587 | 588 | try: 589 | childList = self.iterdir() 590 | except Exception as exc: 591 | error_fn(f"Unable to list directory '{self}': {exc}") 592 | return 593 | 594 | for child in childList: 595 | traverse = None 596 | if match(child): 597 | traverse = yield child 598 | traverse = traverse or child.is_dir 599 | try: 600 | do_traverse = traverse() 601 | except Exception as exc: 602 | error_fn(f"Unable to access '{child}': {exc}") 603 | continue 604 | 605 | if do_traverse: 606 | yield from child.walk(errors=error_fn, match=match) # type: ignore[arg-type] 607 | 608 | def walkdirs(self, match: _Match = None, errors: str = 'strict') -> Iterator[Self]: 609 | """Iterator over subdirs, recursively.""" 610 | return (item for item in self.walk(match, errors) if item.is_dir()) 611 | 612 | def walkfiles(self, match: _Match = None, errors: str = 'strict') -> Iterator[Self]: 613 | """Iterator over files, recursively.""" 614 | return (item for item in self.walk(match, errors) if item.is_file()) 615 | 616 | def fnmatch( 617 | self, pattern: str, normcase: Callable[[str], str] | None = None 618 | ) -> bool: 619 | """Return ``True`` if `self.name` matches the given `pattern`. 620 | 621 | `pattern` - A filename pattern with wildcards, 622 | for example ``'*.py'``. If the pattern contains a `normcase` 623 | attribute, it is applied to the name and path prior to comparison. 624 | 625 | `normcase` - (optional) A function used to normalize the pattern and 626 | filename before matching. Defaults to normcase from 627 | ``self.module``, :func:`os.path.normcase`. 628 | 629 | .. seealso:: :func:`fnmatch.fnmatch` 630 | """ 631 | default_normcase = getattr(pattern, 'normcase', self.module.normcase) 632 | normcase = normcase or default_normcase 633 | name = normcase(self.name) 634 | pattern = normcase(pattern) 635 | return fnmatch.fnmatchcase(name, pattern) 636 | 637 | def glob(self, pattern: str) -> list[Self]: 638 | """Return a list of Path objects that match the pattern. 639 | 640 | `pattern` - a path relative to this directory, with wildcards. 641 | 642 | For example, ``Path('/users').glob('*/bin/*')`` returns a list 643 | of all the files users have in their :file:`bin` directories. 644 | 645 | .. seealso:: :func:`glob.glob` 646 | 647 | .. note:: Glob is **not** recursive, even when using ``**``. 648 | To do recursive globbing see :func:`walk`, 649 | :func:`walkdirs` or :func:`walkfiles`. 650 | """ 651 | cls = self._next_class 652 | return [cls(s) for s in glob.glob(self / pattern)] 653 | 654 | def iglob(self, pattern: str) -> Iterator[Self]: 655 | """Return an iterator of Path objects that match the pattern. 656 | 657 | `pattern` - a path relative to this directory, with wildcards. 658 | 659 | For example, ``Path('/users').iglob('*/bin/*')`` returns an 660 | iterator of all the files users have in their :file:`bin` 661 | directories. 662 | 663 | .. seealso:: :func:`glob.iglob` 664 | 665 | .. note:: Glob is **not** recursive, even when using ``**``. 666 | To do recursive globbing see :func:`walk`, 667 | :func:`walkdirs` or :func:`walkfiles`. 668 | """ 669 | cls = self._next_class 670 | return (cls(s) for s in glob.iglob(self / pattern)) 671 | 672 | # 673 | # --- Reading or writing an entire file at once. 674 | 675 | @overload 676 | def open( 677 | self, 678 | mode: OpenTextMode = ..., 679 | buffering: int = ..., 680 | encoding: str | None = ..., 681 | errors: str | None = ..., 682 | newline: str | None = ..., 683 | closefd: bool = True, 684 | opener: Callable[[str, int], int] | None = ..., 685 | ) -> TextIOWrapper: ... 686 | @overload 687 | def open( 688 | self, 689 | mode: OpenBinaryMode, 690 | buffering: Literal[0], 691 | encoding: None = ..., 692 | errors: None = ..., 693 | newline: None = ..., 694 | closefd: bool = True, 695 | opener: Callable[[str, int], int] | None = ..., 696 | ) -> FileIO: ... 697 | @overload 698 | def open( 699 | self, 700 | mode: OpenBinaryModeUpdating, 701 | buffering: Literal[-1, 1] = ..., 702 | encoding: None = ..., 703 | errors: None = ..., 704 | newline: None = ..., 705 | closefd: bool = True, 706 | opener: Callable[[str, int], int] | None = ..., 707 | ) -> BufferedRandom: ... 708 | @overload 709 | def open( 710 | self, 711 | mode: OpenBinaryModeWriting, 712 | buffering: Literal[-1, 1] = ..., 713 | encoding: None = ..., 714 | errors: None = ..., 715 | newline: None = ..., 716 | closefd: bool = True, 717 | opener: Callable[[str, int], int] | None = ..., 718 | ) -> BufferedWriter: ... 719 | @overload 720 | def open( 721 | self, 722 | mode: OpenBinaryModeReading, 723 | buffering: Literal[-1, 1] = ..., 724 | encoding: None = ..., 725 | errors: None = ..., 726 | newline: None = ..., 727 | closefd: bool = True, 728 | opener: Callable[[str, int], int] | None = ..., 729 | ) -> BufferedReader: ... 730 | @overload 731 | def open( 732 | self, 733 | mode: OpenBinaryMode, 734 | buffering: int = ..., 735 | encoding: None = ..., 736 | errors: None = ..., 737 | newline: None = ..., 738 | closefd: bool = True, 739 | opener: Callable[[str, int], int] | None = ..., 740 | ) -> BinaryIO: ... 741 | @overload 742 | def open( 743 | self, 744 | mode: str, 745 | buffering: int = ..., 746 | encoding: str | None = ..., 747 | errors: str | None = ..., 748 | newline: str | None = ..., 749 | closefd: bool = True, 750 | opener: Callable[[str, int], int] | None = ..., 751 | ) -> IO[Any]: ... 752 | def open(self, *args, **kwargs): 753 | """Open this file and return a corresponding file object. 754 | 755 | Keyword arguments work as in :func:`io.open`. If the file cannot be 756 | opened, an :class:`OSError` is raised. 757 | """ 758 | return open(self, *args, **kwargs) 759 | 760 | def bytes(self) -> builtins.bytes: 761 | """Open this file, read all bytes, return them as a string.""" 762 | with self.open('rb') as f: 763 | return f.read() 764 | 765 | @overload 766 | def chunks( 767 | self, 768 | size: int, 769 | mode: OpenTextMode = ..., 770 | buffering: int = ..., 771 | encoding: str | None = ..., 772 | errors: str | None = ..., 773 | newline: str | None = ..., 774 | closefd: bool = ..., 775 | opener: Callable[[str, int], int] | None = ..., 776 | ) -> Iterator[str]: ... 777 | @overload 778 | def chunks( 779 | self, 780 | size: int, 781 | mode: OpenBinaryMode, 782 | buffering: int = ..., 783 | encoding: str | None = ..., 784 | errors: str | None = ..., 785 | newline: str | None = ..., 786 | closefd: bool = ..., 787 | opener: Callable[[str, int], int] | None = ..., 788 | ) -> Iterator[builtins.bytes]: ... 789 | @overload 790 | def chunks( 791 | self, 792 | size: int, 793 | mode: str, 794 | buffering: int = ..., 795 | encoding: str | None = ..., 796 | errors: str | None = ..., 797 | newline: str | None = ..., 798 | closefd: bool = ..., 799 | opener: Callable[[str, int], int] | None = ..., 800 | ) -> Iterator[str | builtins.bytes]: ... 801 | def chunks(self, size, *args, **kwargs): 802 | """Returns a generator yielding chunks of the file, so it can 803 | be read piece by piece with a simple for loop. 804 | 805 | Any argument you pass after `size` will be passed to :meth:`open`. 806 | 807 | :example: 808 | 809 | >>> hash = hashlib.md5() 810 | >>> for chunk in Path("NEWS.rst").chunks(8192, mode='rb'): 811 | ... hash.update(chunk) 812 | 813 | This will read the file by chunks of 8192 bytes. 814 | """ 815 | with self.open(*args, **kwargs) as f: 816 | yield from iter(lambda: f.read(size) or None, None) 817 | 818 | def write_bytes(self, bytes: builtins.bytes, append: bool = False) -> None: 819 | """Open this file and write the given bytes to it. 820 | 821 | Default behavior is to overwrite any existing file. 822 | Call ``p.write_bytes(bytes, append=True)`` to append instead. 823 | """ 824 | with self.open('ab' if append else 'wb') as f: 825 | f.write(bytes) 826 | 827 | def read_text(self, encoding: str | None = None, errors: str | None = None) -> str: 828 | r"""Open this file, read it in, return the content as a string. 829 | 830 | Optional parameters are passed to :meth:`open`. 831 | 832 | .. seealso:: :meth:`lines` 833 | """ 834 | with self.open(encoding=encoding, errors=errors) as f: 835 | return f.read() 836 | 837 | def read_bytes(self) -> builtins.bytes: 838 | r"""Return the contents of this file as bytes.""" 839 | with self.open(mode='rb') as f: 840 | return f.read() 841 | 842 | def write_text( 843 | self, 844 | text: str, 845 | encoding: str | None = None, 846 | errors: str = 'strict', 847 | linesep: str | None = os.linesep, 848 | append: bool = False, 849 | ) -> None: 850 | r"""Write the given text to this file. 851 | 852 | The default behavior is to overwrite any existing file; 853 | to append instead, use the `append=True` keyword argument. 854 | 855 | There are two differences between :meth:`write_text` and 856 | :meth:`write_bytes`: newline handling and Unicode handling. 857 | See below. 858 | 859 | Parameters: 860 | 861 | `text` - str - The text to be written. 862 | 863 | `encoding` - str - The text encoding used. 864 | 865 | `errors` - str - How to handle Unicode encoding errors. 866 | Default is ``'strict'``. See ``help(unicode.encode)`` for the 867 | options. Ignored if `text` isn't a Unicode string. 868 | 869 | `linesep` - keyword argument - str/unicode - The sequence of 870 | characters to be used to mark end-of-line. The default is 871 | :data:`os.linesep`. Specify ``None`` to 872 | use newlines unmodified. 873 | 874 | `append` - keyword argument - bool - Specifies what to do if 875 | the file already exists (``True``: append to the end of it; 876 | ``False``: overwrite it). The default is ``False``. 877 | 878 | 879 | --- Newline handling. 880 | 881 | ``write_text()`` converts all standard end-of-line sequences 882 | (``'\n'``, ``'\r'``, and ``'\r\n'``) to your platform's default 883 | end-of-line sequence (see :data:`os.linesep`; on Windows, for example, 884 | the end-of-line marker is ``'\r\n'``). 885 | 886 | To override the platform's default, pass the `linesep=` 887 | keyword argument. To preserve the newlines as-is, pass 888 | ``linesep=None``. 889 | 890 | This handling applies to Unicode text and bytes, except 891 | with Unicode, additional non-ASCII newlines are recognized: 892 | ``\x85``, ``\r\x85``, and ``\u2028``. 893 | 894 | --- Unicode 895 | 896 | `text` is written using the 897 | specified `encoding` (or the default encoding if `encoding` 898 | isn't specified). The `errors` argument applies only to this 899 | conversion. 900 | """ 901 | if linesep is not None: 902 | text = U_NEWLINE.sub(linesep, text) 903 | bytes = text.encode(encoding or sys.getdefaultencoding(), errors) 904 | self.write_bytes(bytes, append=append) 905 | 906 | def lines( 907 | self, 908 | encoding: str | None = None, 909 | errors: str | None = None, 910 | retain: bool = True, 911 | ) -> list[str]: 912 | r"""Open this file, read all lines, return them in a list. 913 | 914 | Optional arguments: 915 | `encoding` - The Unicode encoding (or character set) of 916 | the file. The default is ``None``, meaning use 917 | ``locale.getpreferredencoding()``. 918 | `errors` - How to handle Unicode errors; see 919 | `open `_ 920 | for the options. Default is ``None`` meaning "strict". 921 | `retain` - If ``True`` (default), retain newline characters, 922 | but translate all newline 923 | characters to ``\n``. If ``False``, newline characters are 924 | omitted. 925 | """ 926 | text = U_NEWLINE.sub('\n', self.read_text(encoding, errors)) 927 | return text.splitlines(retain) 928 | 929 | def write_lines( 930 | self, 931 | lines: list[str], 932 | encoding: str | None = None, 933 | errors: str = 'strict', 934 | *, 935 | append: bool = False, 936 | ): 937 | r"""Write the given lines of text to this file. 938 | 939 | By default this overwrites any existing file at this path. 940 | 941 | Puts a platform-specific newline sequence on every line. 942 | 943 | `lines` - A list of strings. 944 | 945 | `encoding` - A Unicode encoding to use. This applies only if 946 | `lines` contains any Unicode strings. 947 | 948 | `errors` - How to handle errors in Unicode encoding. This 949 | also applies only to Unicode strings. 950 | 951 | Use the keyword argument ``append=True`` to append lines to the 952 | file. The default is to overwrite the file. 953 | """ 954 | mode = 'a' if append else 'w' 955 | with self.open(mode, encoding=encoding, errors=errors, newline='') as f: 956 | f.writelines(self._replace_linesep(lines)) 957 | 958 | @staticmethod 959 | def _replace_linesep(lines: Iterable[str]) -> Iterator[str]: 960 | return (line + os.linesep for line in _strip_newlines(lines)) 961 | 962 | def read_md5(self) -> builtins.bytes: 963 | """Calculate the md5 hash for this file. 964 | 965 | This reads through the entire file. 966 | 967 | .. seealso:: :meth:`read_hash` 968 | """ 969 | return self.read_hash('md5') 970 | 971 | def _hash(self, hash_name: str) -> hashlib._Hash: 972 | """Returns a hash object for the file at the current path. 973 | 974 | `hash_name` should be a hash algo name (such as ``'md5'`` 975 | or ``'sha1'``) that's available in the :mod:`hashlib` module. 976 | """ 977 | m = hashlib.new(hash_name) 978 | for chunk in self.chunks(8192, mode="rb"): 979 | m.update(chunk) 980 | return m 981 | 982 | def read_hash(self, hash_name) -> builtins.bytes: 983 | """Calculate given hash for this file. 984 | 985 | List of supported hashes can be obtained from :mod:`hashlib` package. 986 | This reads the entire file. 987 | 988 | .. seealso:: :meth:`hashlib.hash.digest` 989 | """ 990 | return self._hash(hash_name).digest() 991 | 992 | def read_hexhash(self, hash_name) -> str: 993 | """Calculate given hash for this file, returning hexdigest. 994 | 995 | List of supported hashes can be obtained from :mod:`hashlib` package. 996 | This reads the entire file. 997 | 998 | .. seealso:: :meth:`hashlib.hash.hexdigest` 999 | """ 1000 | return self._hash(hash_name).hexdigest() 1001 | 1002 | # --- Methods for querying the filesystem. 1003 | # N.B. On some platforms, the os.path functions may be implemented in C 1004 | # (e.g. isdir on Windows, Python 3.2.2), and compiled functions don't get 1005 | # bound. Playing it safe and wrapping them all in method calls. 1006 | 1007 | def isabs(self) -> bool: 1008 | """ 1009 | >>> Path('.').isabs() 1010 | False 1011 | 1012 | .. seealso:: :func:`os.path.isabs` 1013 | """ 1014 | return self.module.isabs(self) 1015 | 1016 | def exists(self) -> bool: 1017 | """.. seealso:: :func:`os.path.exists`""" 1018 | return self.module.exists(self) 1019 | 1020 | def is_dir(self) -> bool: 1021 | """.. seealso:: :func:`os.path.isdir`""" 1022 | return self.module.isdir(self) 1023 | 1024 | def is_file(self) -> bool: 1025 | """.. seealso:: :func:`os.path.isfile`""" 1026 | return self.module.isfile(self) 1027 | 1028 | def islink(self) -> bool: 1029 | """.. seealso:: :func:`os.path.islink`""" 1030 | return self.module.islink(self) 1031 | 1032 | def ismount(self) -> bool: 1033 | """ 1034 | >>> Path('.').ismount() 1035 | False 1036 | 1037 | .. seealso:: :func:`os.path.ismount` 1038 | """ 1039 | return self.module.ismount(self) 1040 | 1041 | def samefile(self, other: str) -> bool: 1042 | """.. seealso:: :func:`os.path.samefile`""" 1043 | return self.module.samefile(self, other) 1044 | 1045 | def getatime(self) -> float: 1046 | """.. seealso:: :attr:`atime`, :func:`os.path.getatime`""" 1047 | return self.module.getatime(self) 1048 | 1049 | def set_atime(self, value: float | datetime.datetime) -> None: 1050 | mtime_ns = self.stat().st_atime_ns 1051 | self.utime(ns=(_make_timestamp_ns(value), mtime_ns)) 1052 | 1053 | @property 1054 | def atime(self) -> float: 1055 | """ 1056 | Last access time of the file. 1057 | 1058 | >>> Path('.').atime > 0 1059 | True 1060 | 1061 | Allows setting: 1062 | 1063 | >>> some_file = Path(getfixture('tmp_path')).joinpath('file.txt').touch() 1064 | >>> MST = datetime.timezone(datetime.timedelta(hours=-7)) 1065 | >>> some_file.atime = datetime.datetime(1976, 5, 7, 10, tzinfo=MST) 1066 | >>> some_file.atime 1067 | 200336400.0 1068 | 1069 | .. seealso:: :meth:`getatime`, :func:`os.path.getatime` 1070 | """ 1071 | return self.getatime() 1072 | 1073 | @atime.setter 1074 | def atime(self, value: float | datetime.datetime) -> None: 1075 | self.set_atime(value) 1076 | 1077 | def getmtime(self) -> float: 1078 | """.. seealso:: :attr:`mtime`, :func:`os.path.getmtime`""" 1079 | return self.module.getmtime(self) 1080 | 1081 | def set_mtime(self, value: float | datetime.datetime) -> None: 1082 | atime_ns = self.stat().st_atime_ns 1083 | self.utime(ns=(atime_ns, _make_timestamp_ns(value))) 1084 | 1085 | @property 1086 | def mtime(self) -> float: 1087 | """ 1088 | Last modified time of the file. 1089 | 1090 | Allows setting: 1091 | 1092 | >>> some_file = Path(getfixture('tmp_path')).joinpath('file.txt').touch() 1093 | >>> MST = datetime.timezone(datetime.timedelta(hours=-7)) 1094 | >>> some_file.mtime = datetime.datetime(1976, 5, 7, 10, tzinfo=MST) 1095 | >>> some_file.mtime 1096 | 200336400.0 1097 | 1098 | .. seealso:: :meth:`getmtime`, :func:`os.path.getmtime` 1099 | """ 1100 | return self.getmtime() 1101 | 1102 | @mtime.setter 1103 | def mtime(self, value: float | datetime.datetime) -> None: 1104 | self.set_mtime(value) 1105 | 1106 | def getctime(self) -> float: 1107 | """.. seealso:: :attr:`ctime`, :func:`os.path.getctime`""" 1108 | return self.module.getctime(self) 1109 | 1110 | @property 1111 | def ctime(self) -> float: 1112 | """Creation time of the file. 1113 | 1114 | .. seealso:: :meth:`getctime`, :func:`os.path.getctime` 1115 | """ 1116 | return self.getctime() 1117 | 1118 | def getsize(self) -> int: 1119 | """.. seealso:: :attr:`size`, :func:`os.path.getsize`""" 1120 | return self.module.getsize(self) 1121 | 1122 | @property 1123 | def size(self) -> int: 1124 | """Size of the file, in bytes. 1125 | 1126 | .. seealso:: :meth:`getsize`, :func:`os.path.getsize` 1127 | """ 1128 | return self.getsize() 1129 | 1130 | @property 1131 | def permissions(self) -> masks.Permissions: 1132 | """ 1133 | Permissions. 1134 | 1135 | >>> perms = Path('.').permissions 1136 | >>> isinstance(perms, int) 1137 | True 1138 | >>> set(perms.symbolic) <= set('rwx-') 1139 | True 1140 | >>> perms.symbolic 1141 | 'r...' 1142 | """ 1143 | return masks.Permissions(self.stat().st_mode) 1144 | 1145 | def access( 1146 | self, 1147 | mode: int, 1148 | *, 1149 | dir_fd: int | None = None, 1150 | effective_ids: bool = False, 1151 | follow_symlinks: bool = True, 1152 | ) -> bool: 1153 | """ 1154 | Return does the real user have access to this path. 1155 | 1156 | >>> Path('.').access(os.F_OK) 1157 | True 1158 | 1159 | .. seealso:: :func:`os.access` 1160 | """ 1161 | return os.access( 1162 | self, 1163 | mode, 1164 | dir_fd=dir_fd, 1165 | effective_ids=effective_ids, 1166 | follow_symlinks=follow_symlinks, 1167 | ) 1168 | 1169 | def stat(self, *, follow_symlinks: bool = True) -> os.stat_result: 1170 | """ 1171 | Perform a ``stat()`` system call on this path. 1172 | 1173 | >>> Path('.').stat() 1174 | os.stat_result(...) 1175 | 1176 | .. seealso:: :meth:`lstat`, :func:`os.stat` 1177 | """ 1178 | return os.stat(self, follow_symlinks=follow_symlinks) 1179 | 1180 | def lstat(self) -> os.stat_result: 1181 | """ 1182 | Like :meth:`stat`, but do not follow symbolic links. 1183 | 1184 | >>> Path('.').lstat() == Path('.').stat() 1185 | True 1186 | 1187 | .. seealso:: :meth:`stat`, :func:`os.lstat` 1188 | """ 1189 | return os.lstat(self) 1190 | 1191 | if sys.platform == "win32": 1192 | 1193 | def get_owner(self) -> str: # pragma: nocover 1194 | r""" 1195 | Return the name of the owner of this file or directory. Follow 1196 | symbolic links. 1197 | 1198 | Return a name of the form ``DOMAIN\User Name``; may be a group. 1199 | 1200 | .. seealso:: :attr:`owner` 1201 | """ 1202 | if "win32security" not in globals(): 1203 | raise NotImplementedError("Ownership not available on this platform.") 1204 | 1205 | desc = win32security.GetFileSecurity( 1206 | self, win32security.OWNER_SECURITY_INFORMATION 1207 | ) 1208 | sid = desc.GetSecurityDescriptorOwner() 1209 | account, domain, typecode = win32security.LookupAccountSid(None, sid) # type: ignore[arg-type] 1210 | return domain + '\\' + account 1211 | 1212 | else: 1213 | 1214 | def get_owner(self) -> str: # pragma: nocover 1215 | """ 1216 | Return the name of the owner of this file or directory. Follow 1217 | symbolic links. 1218 | 1219 | .. seealso:: :attr:`owner` 1220 | """ 1221 | st = self.stat() 1222 | return pwd.getpwuid(st.st_uid).pw_name 1223 | 1224 | @property 1225 | def owner(self) -> str: 1226 | """Name of the owner of this file or directory. 1227 | 1228 | .. seealso:: :meth:`get_owner`""" 1229 | return self.get_owner() 1230 | 1231 | if sys.platform != "win32": # pragma: no cover 1232 | 1233 | def group(self, *, follow_symlinks: bool = True) -> str: 1234 | """ 1235 | Return the group name of the file gid. 1236 | """ 1237 | gid = self.stat(follow_symlinks=follow_symlinks).st_gid 1238 | return grp.getgrgid(gid).gr_name 1239 | 1240 | def statvfs(self) -> os.statvfs_result: 1241 | """Perform a ``statvfs()`` system call on this path. 1242 | 1243 | .. seealso:: :func:`os.statvfs` 1244 | """ 1245 | return os.statvfs(self) 1246 | 1247 | def pathconf(self, name: str | int) -> int: 1248 | """.. seealso:: :func:`os.pathconf`""" 1249 | return os.pathconf(self, name) 1250 | 1251 | # 1252 | # --- Modifying operations on files and directories 1253 | 1254 | @overload 1255 | def utime( 1256 | self, 1257 | times: tuple[int, int] | tuple[float, float] | None = None, 1258 | *, 1259 | dir_fd: int | None = None, 1260 | follow_symlinks: bool = True, 1261 | ) -> Self: ... 1262 | @overload 1263 | def utime( 1264 | self, 1265 | times: tuple[int, int] | tuple[float, float] | None = None, 1266 | *, 1267 | ns: tuple[int, int], 1268 | dir_fd: int | None = None, 1269 | follow_symlinks: bool = True, 1270 | ) -> Self: ... 1271 | 1272 | def utime(self, *args, **kwargs) -> Self: 1273 | """Set the access and modified times of this file. 1274 | 1275 | .. seealso:: :func:`os.utime` 1276 | """ 1277 | os.utime(self, *args, **kwargs) 1278 | return self 1279 | 1280 | def chmod(self, mode: str | int) -> Self: 1281 | """ 1282 | Set the mode. May be the new mode (os.chmod behavior) or a `symbolic 1283 | mode `_. 1284 | 1285 | >>> a_file = Path(getfixture('tmp_path')).joinpath('afile.txt').touch() 1286 | >>> a_file.chmod(0o700) 1287 | Path(... 1288 | >>> a_file.chmod('u+x') 1289 | Path(... 1290 | 1291 | .. seealso:: :func:`os.chmod` 1292 | """ 1293 | if isinstance(mode, str): 1294 | mask = masks.compound(mode) 1295 | mode = mask(self.stat().st_mode) 1296 | os.chmod(self, mode) 1297 | return self 1298 | 1299 | if sys.platform != "win32": 1300 | 1301 | def chown(self, uid: str | int = -1, gid: str | int = -1) -> Self: 1302 | """ 1303 | Change the owner and group by names or numbers. 1304 | 1305 | .. seealso:: :func:`os.chown` 1306 | """ 1307 | 1308 | def resolve_uid(uid: str | int) -> int: 1309 | return uid if isinstance(uid, int) else pwd.getpwnam(uid).pw_uid 1310 | 1311 | def resolve_gid(gid: str | int) -> int: 1312 | return gid if isinstance(gid, int) else grp.getgrnam(gid).gr_gid 1313 | 1314 | os.chown(self, resolve_uid(uid), resolve_gid(gid)) 1315 | return self 1316 | 1317 | def rename(self, new: str) -> Self: 1318 | """.. seealso:: :func:`os.rename`""" 1319 | os.rename(self, new) 1320 | return self._next_class(new) 1321 | 1322 | def renames(self, new: str) -> Self: 1323 | """.. seealso:: :func:`os.renames`""" 1324 | os.renames(self, new) 1325 | return self._next_class(new) 1326 | 1327 | def replace(self, target_or_old: str, *args) -> Self: 1328 | """ 1329 | Replace a path or substitute substrings. 1330 | 1331 | Implements both pathlib.Path.replace and str.replace. 1332 | 1333 | If only a target is supplied, rename this path to the target path, 1334 | overwriting if that path exists. 1335 | 1336 | >>> dest = Path(getfixture('tmp_path')) 1337 | >>> orig = dest.joinpath('foo').touch() 1338 | >>> new = orig.replace(dest.joinpath('fee')) 1339 | >>> orig.exists() 1340 | False 1341 | >>> new.exists() 1342 | True 1343 | 1344 | ..seealso:: :meth:`pathlib.Path.replace` 1345 | 1346 | If a second parameter is supplied, perform a textual replacement. 1347 | 1348 | >>> Path('foo').replace('o', 'e') 1349 | Path('fee') 1350 | >>> Path('foo').replace('o', 'l', 1) 1351 | Path('flo') 1352 | 1353 | ..seealso:: :meth:`str.replace` 1354 | """ 1355 | return self._next_class( 1356 | super().replace(target_or_old, *args) 1357 | if args 1358 | else pathlib.Path(self).replace(target_or_old) 1359 | ) 1360 | 1361 | # 1362 | # --- Create/delete operations on directories 1363 | 1364 | def mkdir(self, mode: int = 0o777) -> Self: 1365 | """.. seealso:: :func:`os.mkdir`""" 1366 | os.mkdir(self, mode) 1367 | return self 1368 | 1369 | def mkdir_p(self, mode: int = 0o777) -> Self: 1370 | """Like :meth:`mkdir`, but does not raise an exception if the 1371 | directory already exists.""" 1372 | with contextlib.suppress(FileExistsError): 1373 | self.mkdir(mode) 1374 | return self 1375 | 1376 | def makedirs(self, mode: int = 0o777) -> Self: 1377 | """.. seealso:: :func:`os.makedirs`""" 1378 | os.makedirs(self, mode) 1379 | return self 1380 | 1381 | def makedirs_p(self, mode: int = 0o777) -> Self: 1382 | """Like :meth:`makedirs`, but does not raise an exception if the 1383 | directory already exists.""" 1384 | with contextlib.suppress(FileExistsError): 1385 | self.makedirs(mode) 1386 | return self 1387 | 1388 | def rmdir(self) -> Self: 1389 | """.. seealso:: :func:`os.rmdir`""" 1390 | os.rmdir(self) 1391 | return self 1392 | 1393 | def rmdir_p(self) -> Self: 1394 | """Like :meth:`rmdir`, but does not raise an exception if the 1395 | directory is not empty or does not exist.""" 1396 | suppressed = FileNotFoundError, FileExistsError, DirectoryNotEmpty 1397 | with contextlib.suppress(*suppressed): 1398 | with DirectoryNotEmpty.translate(): 1399 | self.rmdir() 1400 | return self 1401 | 1402 | def removedirs(self) -> Self: 1403 | """.. seealso:: :func:`os.removedirs`""" 1404 | os.removedirs(self) 1405 | return self 1406 | 1407 | def removedirs_p(self) -> Self: 1408 | """Like :meth:`removedirs`, but does not raise an exception if the 1409 | directory is not empty or does not exist.""" 1410 | with contextlib.suppress(FileExistsError, DirectoryNotEmpty): 1411 | with DirectoryNotEmpty.translate(): 1412 | self.removedirs() 1413 | return self 1414 | 1415 | # --- Modifying operations on files 1416 | 1417 | def touch(self) -> Self: 1418 | """Set the access/modified times of this file to the current time. 1419 | Create the file if it does not exist. 1420 | """ 1421 | os.close(os.open(self, os.O_WRONLY | os.O_CREAT, 0o666)) 1422 | os.utime(self, None) 1423 | return self 1424 | 1425 | def remove(self) -> Self: 1426 | """.. seealso:: :func:`os.remove`""" 1427 | os.remove(self) 1428 | return self 1429 | 1430 | def remove_p(self) -> Self: 1431 | """Like :meth:`remove`, but does not raise an exception if the 1432 | file does not exist.""" 1433 | with contextlib.suppress(FileNotFoundError): 1434 | self.unlink() 1435 | return self 1436 | 1437 | unlink = remove 1438 | unlink_p = remove_p 1439 | 1440 | # --- Links 1441 | 1442 | def hardlink_to(self, target: str) -> None: 1443 | """ 1444 | Create a hard link at self, pointing to target. 1445 | 1446 | .. seealso:: :func:`os.link` 1447 | """ 1448 | os.link(target, self) 1449 | 1450 | def link(self, newpath: str) -> Self: 1451 | """Create a hard link at `newpath`, pointing to this file. 1452 | 1453 | .. seealso:: :func:`os.link` 1454 | """ 1455 | os.link(self, newpath) 1456 | return self._next_class(newpath) 1457 | 1458 | def symlink_to(self, target: str, target_is_directory: bool = False) -> None: 1459 | """ 1460 | Create a symbolic link at self, pointing to target. 1461 | 1462 | .. seealso:: :func:`os.symlink` 1463 | """ 1464 | os.symlink(target, self, target_is_directory) 1465 | 1466 | def symlink(self, newlink: str | None = None) -> Self: 1467 | """Create a symbolic link at `newlink`, pointing here. 1468 | 1469 | If newlink is not supplied, the symbolic link will assume 1470 | the name self.basename(), creating the link in the cwd. 1471 | 1472 | .. seealso:: :func:`os.symlink` 1473 | """ 1474 | if newlink is None: 1475 | newlink = self.basename() 1476 | os.symlink(self, newlink) 1477 | return self._next_class(newlink) 1478 | 1479 | def readlink(self) -> Self: 1480 | """Return the path to which this symbolic link points. 1481 | 1482 | The result may be an absolute or a relative path. 1483 | 1484 | .. seealso:: :meth:`readlinkabs`, :func:`os.readlink` 1485 | """ 1486 | return self._next_class(removeprefix(os.readlink(self), '\\\\?\\')) 1487 | 1488 | def readlinkabs(self) -> Self: 1489 | """Return the path to which this symbolic link points. 1490 | 1491 | The result is always an absolute path. 1492 | 1493 | .. seealso:: :meth:`readlink`, :func:`os.readlink` 1494 | """ 1495 | p = self.readlink() 1496 | return p if p.isabs() else (self.parent / p).absolute() 1497 | 1498 | # High-level functions from shutil. 1499 | 1500 | def copyfile(self, dst: str, *, follow_symlinks: bool = True) -> Self: 1501 | return self._next_class( 1502 | shutil.copyfile(self, dst, follow_symlinks=follow_symlinks) 1503 | ) 1504 | 1505 | def copymode(self, dst: str, *, follow_symlinks: bool = True) -> None: 1506 | shutil.copymode(self, dst, follow_symlinks=follow_symlinks) 1507 | 1508 | def copystat(self, dst: str, *, follow_symlinks: bool = True) -> None: 1509 | shutil.copystat(self, dst, follow_symlinks=follow_symlinks) 1510 | 1511 | def copy(self, dst: str, *, follow_symlinks: bool = True) -> Self: 1512 | return self._next_class(shutil.copy(self, dst, follow_symlinks=follow_symlinks)) 1513 | 1514 | def copy2(self, dst: str, *, follow_symlinks: bool = True) -> Self: 1515 | return self._next_class( 1516 | shutil.copy2(self, dst, follow_symlinks=follow_symlinks) 1517 | ) 1518 | 1519 | def copytree( 1520 | self, 1521 | dst: str, 1522 | symlinks: bool = False, 1523 | ignore: _IgnoreFn | None = None, 1524 | copy_function: _CopyFn = shutil.copy2, 1525 | ignore_dangling_symlinks: bool = False, 1526 | dirs_exist_ok: bool = False, 1527 | ) -> Self: 1528 | return self._next_class( 1529 | shutil.copytree( 1530 | self, 1531 | dst, 1532 | symlinks=symlinks, 1533 | ignore=ignore, 1534 | copy_function=copy_function, 1535 | ignore_dangling_symlinks=ignore_dangling_symlinks, 1536 | dirs_exist_ok=dirs_exist_ok, 1537 | ) 1538 | ) 1539 | 1540 | def move(self, dst: str, copy_function: _CopyFn = shutil.copy2) -> Self: 1541 | retval = shutil.move(self, dst, copy_function=copy_function) 1542 | # shutil.move may return None if the src and dst are the same 1543 | return self._next_class(retval or dst) 1544 | 1545 | if sys.version_info >= (3, 12): 1546 | 1547 | @overload 1548 | def rmtree( 1549 | self, 1550 | ignore_errors: bool, 1551 | onerror: _OnErrorCallback, 1552 | *, 1553 | onexc: None = ..., 1554 | dir_fd: int | None = ..., 1555 | ) -> None: ... 1556 | @overload 1557 | def rmtree( 1558 | self, 1559 | ignore_errors: bool = ..., 1560 | *, 1561 | onerror: _OnErrorCallback, 1562 | onexc: None = ..., 1563 | dir_fd: int | None = ..., 1564 | ) -> None: ... 1565 | @overload 1566 | def rmtree( 1567 | self, 1568 | ignore_errors: bool = ..., 1569 | *, 1570 | onexc: _OnExcCallback | None = ..., 1571 | dir_fd: int | None = ..., 1572 | ) -> None: ... 1573 | 1574 | elif sys.version_info >= (3, 11): 1575 | # NOTE: Strictly speaking, an overload is not needed - this could 1576 | # be expressed in a single annotation. However, if overloads 1577 | # are used there must be a minimum of two, so this was split 1578 | # into two so that the body of rmtree need not be re-defined 1579 | # for each version. 1580 | @overload 1581 | def rmtree( 1582 | self, 1583 | onerror: _OnErrorCallback | None = None, 1584 | *, 1585 | dir_fd: int | None = None, 1586 | ) -> None: ... 1587 | @overload 1588 | def rmtree( 1589 | self, 1590 | ignore_errors: bool, 1591 | onerror: _OnErrorCallback | None = ..., 1592 | *, 1593 | dir_fd: int | None = ..., 1594 | ) -> None: ... 1595 | 1596 | else: 1597 | # NOTE: See note about overloads above. 1598 | @overload 1599 | def rmtree(self, onerror: _OnErrorCallback | None = ...) -> None: ... 1600 | @overload 1601 | def rmtree( 1602 | self, ignore_errors: bool, onerror: _OnErrorCallback | None = ... 1603 | ) -> None: ... 1604 | 1605 | def rmtree(self, *args, **kwargs): 1606 | shutil.rmtree(self, *args, **kwargs) 1607 | 1608 | # Copy the docstrings from shutil to these methods. 1609 | 1610 | copyfile.__doc__ = shutil.copyfile.__doc__ 1611 | copymode.__doc__ = shutil.copymode.__doc__ 1612 | copystat.__doc__ = shutil.copystat.__doc__ 1613 | copy.__doc__ = shutil.copy.__doc__ 1614 | copy2.__doc__ = shutil.copy2.__doc__ 1615 | copytree.__doc__ = shutil.copytree.__doc__ 1616 | move.__doc__ = shutil.move.__doc__ 1617 | rmtree.__doc__ = shutil.rmtree.__doc__ 1618 | 1619 | def rmtree_p(self) -> Self: 1620 | """Like :meth:`rmtree`, but does not raise an exception if the 1621 | directory does not exist.""" 1622 | with contextlib.suppress(FileNotFoundError): 1623 | self.rmtree() 1624 | return self 1625 | 1626 | def chdir(self) -> None: 1627 | """.. seealso:: :func:`os.chdir`""" 1628 | os.chdir(self) 1629 | 1630 | cd = chdir 1631 | 1632 | def merge_tree( 1633 | self, 1634 | dst: str, 1635 | symlinks: bool = False, 1636 | *, 1637 | copy_function: _CopyFn = shutil.copy2, 1638 | ignore: _IgnoreFn = lambda dir, contents: [], 1639 | ): 1640 | """ 1641 | Copy entire contents of self to dst, overwriting existing 1642 | contents in dst with those in self. 1643 | 1644 | Pass ``symlinks=True`` to copy symbolic links as links. 1645 | 1646 | Accepts a ``copy_function``, similar to copytree. 1647 | 1648 | To avoid overwriting newer files, supply a copy function 1649 | wrapped in ``only_newer``. For example:: 1650 | 1651 | src.merge_tree(dst, copy_function=only_newer(shutil.copy2)) 1652 | """ 1653 | dst_path = self._next_class(dst) 1654 | dst_path.makedirs_p() 1655 | 1656 | sources = list(self.iterdir()) 1657 | _ignored = set(ignore(self, [item.name for item in sources])) 1658 | 1659 | def ignored(item: Self) -> bool: 1660 | return item.name in _ignored 1661 | 1662 | for source in itertools.filterfalse(ignored, sources): 1663 | dest = dst_path / source.name 1664 | if symlinks and source.islink(): 1665 | target = source.readlink() 1666 | target.symlink(dest) 1667 | elif source.is_dir(): 1668 | source.merge_tree( 1669 | dest, 1670 | symlinks=symlinks, 1671 | copy_function=copy_function, 1672 | ignore=ignore, 1673 | ) 1674 | else: 1675 | copy_function(source, dest) 1676 | 1677 | self.copystat(dst_path) 1678 | 1679 | # 1680 | # --- Special stuff from os 1681 | 1682 | if sys.platform != "win32": 1683 | 1684 | def chroot(self) -> None: # pragma: nocover 1685 | """.. seealso:: :func:`os.chroot`""" 1686 | os.chroot(self) 1687 | 1688 | if sys.platform == "win32": 1689 | if sys.version_info >= (3, 10): 1690 | 1691 | @overload 1692 | def startfile( 1693 | self, 1694 | arguments: str = ..., 1695 | cwd: str | None = ..., 1696 | show_cmd: int = ..., 1697 | ) -> Self: ... 1698 | @overload 1699 | def startfile( 1700 | self, 1701 | operation: str, 1702 | arguments: str = ..., 1703 | cwd: str | None = ..., 1704 | show_cmd: int = ..., 1705 | ) -> Self: ... 1706 | 1707 | else: 1708 | 1709 | @overload 1710 | def startfile(self) -> Self: ... 1711 | @overload 1712 | def startfile(self, operation: str) -> Self: ... 1713 | 1714 | def startfile(self, *args, **kwargs) -> Self: # pragma: nocover 1715 | """.. seealso:: :func:`os.startfile`""" 1716 | os.startfile(self, *args, **kwargs) 1717 | return self 1718 | 1719 | # in-place re-writing, courtesy of Martijn Pieters 1720 | # http://www.zopatista.com/python/2013/11/26/inplace-file-rewriting/ 1721 | @contextlib.contextmanager 1722 | def in_place( 1723 | self, 1724 | mode: str = 'r', 1725 | buffering: int = -1, 1726 | encoding: str | None = None, 1727 | errors: str | None = None, 1728 | newline: str | None = None, 1729 | backup_extension: str | None = None, 1730 | ) -> Iterator[tuple[IO[Any], IO[Any]]]: 1731 | """ 1732 | A context in which a file may be re-written in-place with 1733 | new content. 1734 | 1735 | Yields a tuple of :samp:`({readable}, {writable})` file 1736 | objects, where `writable` replaces `readable`. 1737 | 1738 | If an exception occurs, the old file is restored, removing the 1739 | written data. 1740 | 1741 | Mode *must not* use ``'w'``, ``'a'``, or ``'+'``; only 1742 | read-only-modes are allowed. A :exc:`ValueError` is raised 1743 | on invalid modes. 1744 | 1745 | For example, to add line numbers to a file:: 1746 | 1747 | p = Path(filename) 1748 | assert p.is_file() 1749 | with p.in_place() as (reader, writer): 1750 | for number, line in enumerate(reader, 1): 1751 | writer.write('{0:3}: '.format(number))) 1752 | writer.write(line) 1753 | 1754 | Thereafter, the file at `filename` will have line numbers in it. 1755 | """ 1756 | if set(mode).intersection('wa+'): 1757 | raise ValueError('Only read-only file modes can be used') 1758 | 1759 | # move existing file to backup, create new file with same permissions 1760 | # borrowed extensively from the fileinput module 1761 | backup_fn = self + (backup_extension or os.extsep + 'bak') 1762 | backup_fn.remove_p() 1763 | self.rename(backup_fn) 1764 | readable = open( 1765 | backup_fn, 1766 | mode, 1767 | buffering=buffering, 1768 | encoding=encoding, 1769 | errors=errors, 1770 | newline=newline, 1771 | ) 1772 | 1773 | perm = os.stat(readable.fileno()).st_mode 1774 | os_mode = os.O_CREAT | os.O_WRONLY | os.O_TRUNC 1775 | os_mode |= getattr(os, 'O_BINARY', 0) 1776 | fd = os.open(self, os_mode, perm) 1777 | writable = open( 1778 | fd, 1779 | "w" + mode.replace('r', ''), 1780 | buffering=buffering, 1781 | encoding=encoding, 1782 | errors=errors, 1783 | newline=newline, 1784 | ) 1785 | with contextlib.suppress(OSError, AttributeError): 1786 | self.chmod(perm) 1787 | 1788 | try: 1789 | yield readable, writable 1790 | except Exception: 1791 | # move backup back 1792 | readable.close() 1793 | writable.close() 1794 | self.remove_p() 1795 | backup_fn.rename(self) 1796 | raise 1797 | else: 1798 | readable.close() 1799 | writable.close() 1800 | finally: 1801 | backup_fn.remove_p() 1802 | 1803 | @classes.ClassProperty 1804 | @classmethod 1805 | def special(cls) -> Callable[[str | None], SpecialResolver]: 1806 | """ 1807 | Return a SpecialResolver object suitable referencing a suitable 1808 | directory for the relevant platform for the given 1809 | type of content. 1810 | 1811 | For example, to get a user config directory, invoke: 1812 | 1813 | dir = Path.special().user.config 1814 | 1815 | Uses the `appdirs 1816 | `_ to resolve 1817 | the paths in a platform-friendly way. 1818 | 1819 | To create a config directory for 'My App', consider: 1820 | 1821 | dir = Path.special("My App").user.config.makedirs_p() 1822 | 1823 | If the ``appdirs`` module is not installed, invocation 1824 | of special will raise an ImportError. 1825 | """ 1826 | return functools.partial(SpecialResolver, cls) 1827 | 1828 | 1829 | class DirectoryNotEmpty(OSError): 1830 | @staticmethod 1831 | @contextlib.contextmanager 1832 | def translate() -> Iterator[None]: 1833 | try: 1834 | yield 1835 | except OSError as exc: 1836 | if exc.errno == errno.ENOTEMPTY: 1837 | raise DirectoryNotEmpty(*exc.args) from exc 1838 | raise 1839 | 1840 | 1841 | def only_newer(copy_func: _CopyFn) -> _CopyFn: 1842 | """ 1843 | Wrap a copy function (like shutil.copy2) to return 1844 | the dst if it's newer than the source. 1845 | """ 1846 | 1847 | @functools.wraps(copy_func) 1848 | def wrapper(src: str, dst: str): 1849 | src_p = Path(src) 1850 | dst_p = Path(dst) 1851 | is_newer_dst = dst_p.exists() and dst_p.getmtime() >= src_p.getmtime() 1852 | if is_newer_dst: 1853 | return dst 1854 | return copy_func(src, dst) 1855 | 1856 | return wrapper 1857 | 1858 | 1859 | class ExtantPath(Path): 1860 | """ 1861 | >>> ExtantPath('.') 1862 | ExtantPath('.') 1863 | >>> ExtantPath('does-not-exist') 1864 | Traceback (most recent call last): 1865 | OSError: does-not-exist does not exist. 1866 | """ 1867 | 1868 | def _validate(self) -> None: 1869 | if not self.exists(): 1870 | raise OSError(f"{self} does not exist.") 1871 | 1872 | 1873 | class ExtantFile(Path): 1874 | """ 1875 | >>> ExtantFile('.') 1876 | Traceback (most recent call last): 1877 | FileNotFoundError: . does not exist as a file. 1878 | >>> ExtantFile('does-not-exist') 1879 | Traceback (most recent call last): 1880 | FileNotFoundError: does-not-exist does not exist as a file. 1881 | """ 1882 | 1883 | def _validate(self) -> None: 1884 | if not self.is_file(): 1885 | raise FileNotFoundError(f"{self} does not exist as a file.") 1886 | 1887 | 1888 | class SpecialResolver: 1889 | path_class: type 1890 | wrapper: ModuleType 1891 | 1892 | class ResolverScope: 1893 | paths: SpecialResolver 1894 | scope: str 1895 | 1896 | def __init__(self, paths: SpecialResolver, scope: str) -> None: 1897 | self.paths = paths 1898 | self.scope = scope 1899 | 1900 | def __getattr__(self, class_: str) -> _MultiPathType: 1901 | return self.paths.get_dir(self.scope, class_) 1902 | 1903 | def __init__( 1904 | self, 1905 | path_class: type, 1906 | appname: str | None = None, 1907 | appauthor: str | None = None, 1908 | version: str | None = None, 1909 | roaming: bool = False, 1910 | multipath: bool = False, 1911 | ): 1912 | appdirs = importlib.import_module('appdirs') 1913 | self.path_class = path_class 1914 | self.wrapper = appdirs.AppDirs( 1915 | appname=appname, 1916 | appauthor=appauthor, 1917 | version=version, 1918 | roaming=roaming, 1919 | multipath=multipath, 1920 | ) 1921 | 1922 | def __getattr__(self, scope: str) -> ResolverScope: 1923 | return self.ResolverScope(self, scope) 1924 | 1925 | def get_dir(self, scope: str, class_: str) -> _MultiPathType: 1926 | """ 1927 | Return the callable function from appdirs, but with the 1928 | result wrapped in self.path_class 1929 | """ 1930 | prop_name = f'{scope}_{class_}_dir' 1931 | value = getattr(self.wrapper, prop_name) 1932 | MultiPath = Multi.for_class(self.path_class) 1933 | return MultiPath.detect(value) 1934 | 1935 | 1936 | class Multi: 1937 | """ 1938 | A mix-in for a Path which may contain multiple Path separated by pathsep. 1939 | """ 1940 | 1941 | @classmethod 1942 | def for_class(cls, path_cls: type) -> type[_MultiPathType]: 1943 | name = 'Multi' + path_cls.__name__ 1944 | return type(name, (cls, path_cls), {}) 1945 | 1946 | @classmethod 1947 | def detect(cls, input: str) -> _MultiPathType: 1948 | if os.pathsep not in input: 1949 | cls = cls._next_class 1950 | return cls(input) # type: ignore[return-value, call-arg] 1951 | 1952 | def __iter__(self) -> Iterator[Path]: 1953 | return iter(map(self._next_class, self.split(os.pathsep))) # type: ignore[attr-defined] 1954 | 1955 | @classes.ClassProperty 1956 | @classmethod 1957 | def _next_class(cls) -> type[Path]: 1958 | """ 1959 | Multi-subclasses should use the parent class 1960 | """ 1961 | return next(class_ for class_ in cls.__mro__ if not issubclass(class_, Multi)) # type: ignore[return-value] 1962 | 1963 | 1964 | class _MultiPathType(Multi, Path): 1965 | pass 1966 | 1967 | 1968 | class TempDir(Path): 1969 | """ 1970 | A temporary directory via :func:`tempfile.mkdtemp`, and 1971 | constructed with the same parameters that you can use 1972 | as a context manager. 1973 | 1974 | For example: 1975 | 1976 | >>> with TempDir() as d: 1977 | ... d.is_dir() and isinstance(d, Path) 1978 | True 1979 | 1980 | The directory is deleted automatically. 1981 | 1982 | >>> d.is_dir() 1983 | False 1984 | 1985 | .. seealso:: :func:`tempfile.mkdtemp` 1986 | """ 1987 | 1988 | @classes.ClassProperty 1989 | @classmethod 1990 | def _next_class(cls) -> type[Path]: 1991 | return Path 1992 | 1993 | def __new__(cls, *args, **kwargs) -> Self: 1994 | dirname = tempfile.mkdtemp(*args, **kwargs) 1995 | return super().__new__(cls, dirname) 1996 | 1997 | def __init__(self, *args, **kwargs) -> None: 1998 | pass 1999 | 2000 | def __enter__(self) -> Self: 2001 | # TempDir should return a Path version of itself and not itself 2002 | # so that a second context manager does not create a second 2003 | # temporary directory, but rather changes CWD to the location 2004 | # of the temporary directory. 2005 | return self._next_class(self) 2006 | 2007 | def __exit__(self, *_) -> None: 2008 | self.rmtree() 2009 | 2010 | 2011 | class Handlers: 2012 | @staticmethod 2013 | def strict(msg: str) -> Never: 2014 | raise 2015 | 2016 | @staticmethod 2017 | def warn(msg: str) -> None: 2018 | warnings.warn(msg, TreeWalkWarning, stacklevel=2) 2019 | 2020 | @staticmethod 2021 | def ignore(msg: str) -> None: 2022 | pass 2023 | 2024 | @classmethod 2025 | def _resolve(cls, param: str | Callable[[str], None]) -> Callable[[str], None]: 2026 | msg = "invalid errors parameter" 2027 | if isinstance(param, str): 2028 | if param not in vars(cls): 2029 | raise ValueError(msg) 2030 | return {"strict": cls.strict, "warn": cls.warn, "ignore": cls.ignore}[param] 2031 | else: 2032 | if not callable(param): 2033 | raise ValueError(msg) 2034 | return param 2035 | --------------------------------------------------------------------------------