diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..5dd08896 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,17 @@ +[run] +omit = + # leading `*/` for pytest-dev/pytest-cov#456 + */.tox/* + */pep517-build-env-* + path/py37compat.py +disable_warnings = + couldnt-parse + +[report] +show_missing = True +exclude_also = + # Exclude common false positives per + # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion + # Ref jaraco/skeleton#97 and jaraco/skeleton#135 + class .*\bProtocol\): + if TYPE_CHECKING: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..172bf578 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.tox diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..304196f8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 4 +insert_final_newline = true +end_of_line = lf + +[*.py] +indent_style = space +max_line_length = 88 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.rst] +indent_style = space diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..eb6c7653 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: pypi/path diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..d40c74ac --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,131 @@ +name: tests + +on: + merge_group: + push: + branches-ignore: + # temporary GH branches relating to merge queues (jaraco/skeleton#93) + - gh-readonly-queue/** + tags: + # required if branches-ignore is supplied (jaraco/skeleton#103) + - '**' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + # Environment variable to support color support (jaraco/skeleton#66) + FORCE_COLOR: 1 + + # Suppress noisy pip warnings + PIP_DISABLE_PIP_VERSION_CHECK: 'true' + PIP_NO_WARN_SCRIPT_LOCATION: 'true' + + # Ensure tests can sense settings about the environment + TOX_OVERRIDE: >- + testenv.pass_env+=GITHUB_*,FORCE_COLOR + + +jobs: + test: + strategy: + # https://blog.jaraco.com/efficient-use-of-ci-resources/ + matrix: + python: + - "3.10" + - "3.13" + platform: + - ubuntu-latest + - macos-latest + - windows-latest + include: + - python: "3.11" + platform: ubuntu-latest + - python: "3.12" + platform: ubuntu-latest + - python: "3.14" + platform: ubuntu-latest + - python: "3.15" + platform: ubuntu-latest + - python: pypy3.10 + platform: ubuntu-latest + runs-on: ${{ matrix.platform }} + continue-on-error: ${{ matrix.python == '3.15' }} + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + # Install dependencies for building packages on pre-release Pythons + # jaraco/skeleton#161 + if: matrix.python == '3.15' && matrix.platform == 'ubuntu-latest' + run: | + sudo apt update + sudo apt install -y libxml2-dev libxslt-dev + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + - name: Install tox + run: python -m pip install tox + - name: Run + run: tox + + collateral: + strategy: + fail-fast: false + matrix: + job: + - diffcov + - docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install tox + run: python -m pip install tox + - name: Eval ${{ matrix.job }} + run: tox -e ${{ matrix.job }} + + check: # This job does nothing and is only used for the branch protection + if: always() + + needs: + - test + - collateral + + runs-on: ubuntu-latest + + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + + release: + permissions: + contents: write + needs: + - check + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install tox + run: python -m pip install tox + - name: Run + run: tox -e release + env: + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.hgtags b/.hgtags deleted file mode 100644 index 05ad6c4f..00000000 --- a/.hgtags +++ /dev/null @@ -1,38 +0,0 @@ -9401857d62dd5d4784dc06656ab13992847b5e76 2.4 -69851aaf75f1c6ff63c72daf8d1a82692e04b301 2.4.1 -6ba869f2260f54ac6d66ffc08b9ce546846c6c9c 2.5 -5a008a763604cd3237ce83bb402fca41e4456001 2.6 -be871d26bce9185c8a4b2c20314f541508e99c1f 2.6.1 -5b78897bb40297548a7a01b35ce842eaa30d19de 3.0 -a5539b0050d4377bd1bdeb74eed06e0e4a775a99 3.0.1 -137d1b8b6d4ca80382efeda217d9236d1570ab70 3.1 -13f2731fa9d98314aedb2fdd55cce97b381b564f 3.2 -97899c3ef1421a247b0864f5486dd480ad31b41f 4.0 -9652fb4e2b486d2ce32f7ed3806304d1c0d653f7 4.1 -f922c0423dbf87f11643724ce3b7c2bc78303a8d 4.2 -38282c28271f66ed07d3c9b52bfa55e26280833d 4.3 -2ed42cef0b080079ad26de6f082fa6d722813087 4.4 -47206e8c59c858e074f6ea1ba80cefb3a37045fd 5.0 -0382bd2f0348cfd73e4e09cd55799a428e75a131 5.1 -7bf7fe39edc888b2f14faf76a70c75b5b17502e6 5.2 -83dcd6b47278aedcbf52f1a6798bd333c515b818 5.3 -2d5f08983462a86cdd41963fca6626ff87dc3ba1 6.0 -55c924ecc419dbf1038254436be512c5cb18bd15 6.1 -be72beb60a11f64344cd9e4c6029b330d49cd830 6.2 -698ac3acb84034fb17487b5009153c8956c5d596 7.0 -793192a17ea0e78640a6d6b133a2948f6cecf43f 7.1 -abeebd8675eaa4626619cb3083c8e1af48a2f2ce 7.2 -581040c32e0c2154cb7aa455734edf1c41021607 7.3 -b96177b0522706277881ddc3f1fce41077eb9993 7.4 -76fab4bb56d10e83f2af64ce7a7e39d56e88eb70 7.5 -a96c4d592397e79e2fa16b632fcc89b48ed63d04 7.6 -06d6fdfddc0a69a56d4b378816d7e5b59ae4a018 7.6.1 -cf0bb158f8d370f5195b95c917201f51aa53c0a9 7.6.2 -cfc54169af8f0fdc98fff6f06b98d325a81dfaeb 7.7 -baa58fde6e3b8820bbd77d5721760ec3f8f08716 7.7.1 -936d47e8d38319be37b06f0e05b1356eeaf7390e 8.0 -06219e4168be20471a7ceedf617f7464c0e63f6b 8.1 -095ad9f1f5db19d1dfc69f67d66e63b38f50270b 8.1.1 -66874c317d1692313127a0c7310e19f64fdc7bd0 8.1.2 -487d9a027220c215b1ddd04823aa5289ebc1120a 8.2 -5f1e61425fa995d3ac71b620a267f3d3a3447f25 8.2.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..54cc8303 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.0 + hooks: + - id: ruff-check + args: [--fix, --unsafe-fixes] + - id: ruff-format diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..72437063 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,19 @@ +version: 2 +python: + install: + - path: . + extra_requirements: + - doc + +sphinx: + configuration: docs/conf.py + +# required boilerplate readthedocs/readthedocs.org#10401 +build: + os: ubuntu-lts-latest + tools: + python: latest + # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 + jobs: + post_checkout: + - git fetch --unshallow || true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 25eb7008..00000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -sudo: false -language: python -python: -- 2.6 -- 2.7 -- 3.2 -- 3.3 -- 3.4 -- 3.5 -script: -- pip install -U pytest -- python setup.py test -deploy: - provider: pypi - on: - tags: true - all_branches: true - python: 3.5 - user: jaraco - distributions: release - password: - secure: fggUs33qP6DB+j/q7KGScfohgGq7OwsW5BMW6ZZvSlq+9pnNDZxSVrfCw0wb9vdq/Hb9nH4Of+wDoyh+Ul6GN28GRX7qj1HTjbc65nhRp9aA1Ib9Y3KJwGR8k5gPJZmx/zKP0r7COSXsOdXDkVSJ/UjCfuKhcsSHpi0lAYG6BSA= diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index 5a62897e..00000000 --- a/CHANGES.rst +++ /dev/null @@ -1,273 +0,0 @@ -8.2.1 ------ - -- #112: Update Travis CI usage to only deploy on Python 3.5. - -8.2 ---- - -- Refreshed project metadata based on `jaraco's project - skeleton _. -- Releases are now automatically published via Travis-CI. -- #111: More aggressively trap errors when importing - ``pkg_resources``. - -8.1.2 ------ - -- #105: By using unicode literals, avoid errors rendering the - backslash in __get_owner_windows. - -8.1.1 ------ - -Reluctantly restored reference to path.path in ``__all__``. - -8.1 ---- - -Restored ``path.path`` with a DeprecationWarning. - -8.0 ---- - -Removed ``path.path``. Clients must now refer to the canonical -name, ``path.Path`` as introduced in 6.2. - -7.7 ---- - -- #88: Added support for resolving certain directories on a - system to platform-friendly locations using the `appdirs - `_ library. The - ``Path.special`` method returns an ``SpecialResolver`` instance - that will resolve a path in a scope - (i.e. 'site' or 'user') and class (i.e. 'config', 'cache', - 'data'). For - example, to create a config directory for "My App":: - - config_dir = Path.special("My App").user.config.makedirs_p() - - ``config_dir`` will exist in a user context and will be in a - suitable platform-friendly location. - - As ``path.py`` does not currently have any dependencies, and - to retain that expectation for a compatible upgrade path, - ``appdirs`` must be installed to avoid an ImportError when - invoking ``special``. - - -- #88: In order to support "multipath" results, where multiple - paths are returned in a single, ``os.pathsep``-separated - string, a new class MultiPath now represents those special - results. This functionality is experimental and may change. - Feedback is invited. - -7.6.2 ------ - -- Re-release of 7.6.1 without unintended feature. - -7.6.1 ------ - -- #101: Supress error when `path.py` is not present as a distribution. - -7.6 ---- - -- Pull Request #100: Add ``merge_tree`` method for merging - two existing directory trees. -- Uses `setuptools_scm `_ - for version management. - -7.5 ---- - -- #97: ``__rdiv__`` and ``__rtruediv__`` are now defined. - -7.4 ---- - -- #93: chown now appears in docs and raises NotImplementedError if - ``os.chown`` isn't present. -- #92: Added compatibility support for ``.samefile`` on platforms without - ``os.samefile``. - -7.3 ---- - - - #91: Releases now include a universal wheel. - -7.2 ---- - - - In chmod, added support for multiple symbolic masks (separated by commas). - - In chmod, fixed issue in setting of symbolic mask with '=' where - unreferenced permissions were cleared. - -7.1 ---- - - - #23: Added support for symbolic masks to ``.chmod``. - -7.0 ---- - - - The ``open`` method now uses ``io.open`` and supports all of the - parameters to that function. ``open`` will always raise an ``OSError`` - on failure, even on Python 2. - - Updated ``write_text`` to support additional newline patterns. - - The ``text`` method now always returns text (never bytes), and thus - requires an encoding parameter be supplied if the default encoding is not - sufficient to decode the content of the file. - -6.2 ---- - - - ``path`` class renamed to ``Path``. The ``path`` name remains as an alias - for compatibility. - -6.1 ---- - - - ``chown`` now accepts names in addition to numeric IDs. - -6.0 ---- - - - Drop support for Python 2.5. Python 2.6 or later required. - - Installation now requires setuptools. - -5.3 ---- - - - Allow arbitrary callables to be passed to path.walk ``errors`` parameter. - Enables workaround for issues such as #73 and #56. - -5.2 ---- - - - #61: path.listdir now decodes filenames from os.listdir when loading - characters from a file. On Python 3, the behavior is unchanged. On Python - 2, the behavior will now mimick that of Python 3, attempting to decode - all filenames and paths using the encoding indicated by - ``sys.getfilesystemencoding()``, and escaping any undecodable characters - using the 'surrogateescape' handler. - -5.1 ---- - - - #53: Added ``path.in_place`` for editing files in place. - -5.0 ---- - - - ``path.fnmatch`` now takes an optional parameter ``normcase`` and this - parameter defaults to self.module.normcase (using case normalization most - pertinent to the path object itself). Note that this change means that - any paths using a custom ntpath module on non-Windows systems will have - different fnmatch behavior. Before:: - - # on Unix - >>> p = path('Foo') - >>> p.module = ntpath - >>> p.fnmatch('foo') - False - - After:: - - # on any OS - >>> p = path('Foo') - >>> p.module = ntpath - >>> p.fnmatch('foo') - True - - To maintain the original behavior, either don't define the 'module' for the - path or supply explicit normcase function:: - - >>> p.fnmatch('foo', normcase=os.path.normcase) - # result always varies based on OS, same as fnmatch.fnmatch - - For most use-cases, the default behavior should remain the same. - - - Issue #50: Methods that accept patterns (``listdir``, ``files``, ``dirs``, - ``walk``, ``walkdirs``, ``walkfiles``, and ``fnmatch``) will now use a - ``normcase`` attribute if it is present on the ``pattern`` parameter. The - path module now provides a ``CaseInsensitivePattern`` wrapper for strings - suitable for creating case-insensitive patterns for those methods. - -4.4 ---- - - - Issue #44: _hash method would open files in text mode, producing - invalid results on Windows. Now files are opened in binary mode, producing - consistent results. - - Issue #47: Documentation is dramatically improved with Intersphinx links - to the Python os.path functions and documentation for all methods and - properties. - -4.3 ---- - - - Issue #32: Add ``chdir`` and ``cd`` methods. - -4.2 ---- - - - ``open()`` now passes all positional and keyword arguments through to the - underlying ``builtins.open`` call. - -4.1 ---- - - - Native Python 2 and Python 3 support without using 2to3 during the build - process. - -4.0 ---- - - - Added a ``chunks()`` method to a allow quick iteration over pieces of a - file at a given path. - - Issue #28: Fix missing argument to ``samefile``. - - Initializer no longer enforces `isinstance basestring` for the source - object. Now any object that supplies ``__unicode__`` can be used by a - ``path`` (except None). Clients that depend on a ValueError being raised - for ``int`` and other non-string objects should trap these types - internally. - - Issue #30: ``chown`` no longer requires both uid and gid to be provided - and will not mutate the ownership if nothing is provided. - -3.2 ---- - - - Issue #22: ``__enter__`` now returns self. - -3.1 ---- - - - Issue #20: `relpath` now supports a "start" parameter to match the - signature of `os.path.relpath`. - -3.0 ---- - - - Minimum Python version is now 2.5. - -2.6 ---- - - - Issue #5: Implemented `path.tempdir`, which returns a path object which is - a temporary directory and context manager for cleaning up the directory. - - Issue #12: One can now construct path objects from a list of strings by - simply using path.joinpath. For example:: - - path.joinpath('a', 'b', 'c') # or - path.joinpath(*path_elements) - -2.5 ---- - - - Issue #7: Add the ability to do chaining of operations that formerly only - returned None. - - Issue #4: Raise a TypeError when constructed from None. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index bb37a272..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include *.rst diff --git a/NEWS.rst b/NEWS.rst new file mode 100644 index 00000000..93e57e9a --- /dev/null +++ b/NEWS.rst @@ -0,0 +1,771 @@ +v17.1.1 +======= + +Bugfixes +-------- + +- Fixed TempDir constructor arguments. (#236) + + +v17.1.0 +======= + +Features +-------- + +- Fully inlined the type annotations. Big thanks to SethMMorton. (#235) + + +v17.0.0 +======= + +Deprecations and Removals +------------------------- + +- Removed deprecated methods ``getcwd``, ``abspath``, ``ext``, ``listdir``, ``isdir``, ``isfile``, and ``text``. +- Removed deprecated support for passing ``bytes`` to ``write_text`` and ``write_lines(linesep=)`` parameter. + + +v16.16.0 +======== + +Features +-------- + +- Implement .replace. (#214) +- Add .home classmethod. (#214) + + +v16.15.0 +======== + +Features +-------- + +- Replaced 'open' overloads with 'functools.wraps(open)' for simple re-use. (#225) + + +Bugfixes +-------- + +- Add type hints for .with_name, .suffix, .with_stem. (#227) +- Add type hint for .absolute. (#228) + + +v16.14.0 +======== + +Features +-------- + +- Add .symlink_to and .hardlink_to. (#214) +- Add .cwd method and deprecated .getcwd. (#214) + + +v16.13.0 +======== + +Features +-------- + +- Create 'absolute' method and deprecate 'abspath'. (#214) +- In readlink, prefer the display path to the substitute path. (#222) + + +v16.12.1 +======== + +Bugfixes +-------- + +- Restore functionality in .isdir and .isfile. + + +v16.12.0 +======== + +Features +-------- + +- Added .is_dir and .is_file for parity with pathlib. Deprecates .isdir and .isfile. (#214) + + +v16.11.0 +======== + +Features +-------- + +- Inlined some types. (#215) + + +v16.10.2 +======== + +Bugfixes +-------- + +- Fix iterdir - it also accepts match. Ref #220. (#220) + + +v16.10.1 +======== + +Bugfixes +-------- + +- Add type annotation for iterdir. (#220) + + +v16.10.0 +======== + +Features +-------- + +- Added .with_name and .with_stem. +- Prefer .suffix to .ext and deprecate .ext. + + +v16.9.0 +======= + +Features +-------- + +- Added ``.iterdir()`` and deprecated ``.listdir()``. (#214) + + +v16.8.0 +======= + +Features +-------- + +- Use '.' as the default path. (#216) + + +v16.7.1 +======= + +Bugfixes +-------- + +- Set ``stacklevel=2`` in deprecation warning for ``.text``. (#210) + + +v16.7.0 +======= + +Features +-------- + +- Added ``.permissions`` attribute. (#211) +- Require Python 3.8 or later. + + +v16.6.0 +------- + +- ``.mtime`` and ``.atime`` are now settable. + +v16.5.0 +------- + +- Refreshed packaging. +- #197: Fixed default argument rendering in docs. +- #209: Refactored ``write_lines`` to re-use open semantics. + Deprecated the ``linesep`` parameter. + +v16.4.0 +------- + +- #207: Added type hints and declare the library as typed. + +v16.3.0 +------- + +- Require Python 3.7 or later. +- #205: test_listdir_other_encoding now automatically skips + itself on file systems where it's not appropriate. + +v16.2.0 +------- + +- Deprecated passing bytes to ``write_text``. Instead, users + should call ``write_bytes``. + +v16.1.0 +------- + +- #204: Improved test coverage across the package to 99%, fixing + bugs in uncovered code along the way. + +v16.0.0 +------- + +- #200: ``TempDir`` context now cleans up unconditionally, + even if an exception occurs. + +v15.1.2 +------- + +- #199: Fixed broken link in README. + +v15.1.1 +------- + +- Refreshed package metadata. + +v15.1.0 +------- + +- Added ``ExtantPath`` and ``ExtantFile`` objects that raise + errors when they reference a non-existent path or file. + +v15.0.1 +------- + +- Refreshed package metadata. + +v15.0.0 +------- + +- Removed ``__version__`` property. To determine the version, + use ``importlib.metadata.version('path')``. + +v14.0.1 +------- + +- Fixed regression on Python 3.7 and earlier where ``lru_cache`` + did not support a user function. + +v14.0.0 +------- + +- Removed ``namebase`` property. Use ``stem`` instead. +- Removed ``update`` parameter on method to + ``Path.merge_tree``. Instead, to only copy newer files, + provide a wrapped ``copy`` function, as described in the + doc string. +- Removed ``FastPath``. Just use ``Path``. +- Removed ``path.CaseInsensitivePattern``. Instead + use ``path.matchers.CaseInsensitive``. +- Removed ``path.tempdir``. Use ``path.TempDir``. +- #154: Added ``Traversal`` class and support for customizing + the behavior of a ``Path.walk``. + +v13.3.0 +------- + +- #186: Fix test failures on Python 3.8 on Windows by relying on + ``realpath()`` instead of ``readlink()``. +- #189: ``realpath()`` now honors symlinks on Python 3.7 and + earlier, approximating the behavior found on Python 3.8. +- #187: ``lines()`` no longer relies on the deprecated ``.text()``. + +v13.2.0 +------- + +- Require Python 3.6 or later. + +v13.1.0 +------- + +- #170: Added ``read_text`` and ``read_bytes`` methods to + align with ``pathlib`` behavior. Deprecated ``text`` method. + If you require newline normalization of ``text``, use + ``jaraco.text.normalize_newlines(Path.read_text())``. + +v13.0.0 +------- + +- #169: Renamed package from ``path.py`` to ``path``. The docs + make reference to a pet name "path pie" for easier discovery. + +v12.5.0 +------- + +- #195: Project now depends on ``path``. + +v12.4.0 +------- + +- #169: Project now depends on ``path < 13.2``. +- Fixed typo in README. + +v12.3.0 +------- + +- #169: Project is renamed to simply ``path``. This release of + ``path.py`` simply depends on ``path < 13.1``. + +v12.2.0 +------- + +- #169: Moved project at GitHub from ``jaraco/path.py`` to + ``jaraco/path``. + +v12.1.0 +------- + +- #171: Fixed exception in ``rmdir_p`` when target is not empty. +- #174: Rely on ``importlib.metadata`` on Python 3.8. + +v12.0.2 +------- + +- Refreshed package metadata. + +12.0.1 +------ + +- #166: Removed 'universal' wheel support. + +12.0 +--- + +- #148: Dropped support for Python 2.7 and 3.4. +- Moved 'path' into a package. + +11.5.2 +------ + +- #163: Corrected 'pymodules' typo in package declaration. + +11.5.1 +------ + +- Minor packaging refresh. + +11.5.0 +------ + +- #156: Re-wrote the handling of pattern matches for + ``listdir``, ``walk``, and related methods, allowing + the pattern to be a more complex object. This approach + drastically simplifies the code and obviates the + ``CaseInsensitivePattern`` and ``FastPath`` classes. + Now the main ``Path`` class should be as performant + as ``FastPath`` and case-insensitive matches can be + readily constructed using the new + ``path.matchers.CaseInsensitive`` class. + +11.4.1 +------ + +- #153: Skip intermittently failing performance test on + Python 2. + +11.4.0 +------ + +- #130: Path.py now supports non-decodable filenames on + Linux and Python 2, leveraging the + `backports.os `_ + package (as an optional dependency). Currently, only + ``listdir`` is patched, but other ``os`` primitives may + be patched similarly in the ``patch_for_linux_python2`` + function. + +- #141: For merge_tree, instead of relying on the deprecated + distutils module, implement merge_tree explicitly. The + ``update`` parameter is deprecated, instead superseded + by a ``copy_function`` parameter and an ``only_newer`` + wrapper for any copy function. + +11.3.0 +------ + +- #151: No longer use two techniques for splitting lines. + Instead, unconditionally rely on io.open for universal + newlines support and always use splitlines. + +11.2.0 +------ + +- #146: Rely on `importlib_metadata + `_ instead of + setuptools/pkg_resources to load the version of the module. + Added tests ensuring a <100ms import time for the ``path`` + module. This change adds an explicit dependency on the + importlib_metadata package, but the project still supports + copying of the ``path.py`` module without any dependencies. + +11.1.0 +------ + +- #143, #144: Add iglob method. +- #142, #145: Rename ``tempdir`` to ``TempDir`` and declare + it as part of ``__all__``. Retain ``tempdir`` for compatibility + for now. +- #145: ``TempDir.__enter__`` no longer returns the ``TempDir`` + instance, but instead returns a ``Path`` instance, suitable for + entering to change the current working directory. + +11.0.1 +------ + +- #136: Fixed test failures on BSD. + +- Refreshed package metadata. + +11.0 +---- + +- Drop support for Python 3.3. + +10.6 +---- + +- Renamed ``namebase`` to ``stem`` to match API of pathlib. + Kept ``namebase`` as a deprecated alias for compatibility. + +- Added new ``with_suffix`` method, useful for renaming the + extension on a Path:: + + orig = Path('mydir/mypath.bat') + renamed = orig.rename(orig.with_suffix('.cmd')) + +10.5 +---- + +- Packaging refresh and readme updates. + +10.4 +---- + +- #130: Removed surrogate_escape handler as it's no longer + used. + +10.3.1 +------ + +- #124: Fixed ``rmdir_p`` raising ``FileNotFoundError`` when + directory does not exist on Windows. + +10.3 +---- + +- #115: Added a new performance-optimized implementation + for listdir operations, optimizing ``listdir``, ``walk``, + ``walkfiles``, ``walkdirs``, and ``fnmatch``, presented + as the ``FastPath`` class. + + Please direct feedback on this implementation to the ticket, + especially if the performance benefits justify it replacing + the default ``Path`` class. + +10.2 +---- + +- Symlink no longer requires the ``newlink`` parameter + and will default to the basename of the target in the + current working directory. + +10.1 +---- + +- #123: Implement ``Path.__fspath__`` per PEP 519. + +10.0 +---- + +- Once again as in 8.0 remove deprecated ``path.path``. + +9.1 +--- + +- #121: Removed workaround for #61 added in 5.2. ``path.py`` + now only supports file system paths that can be effectively + decoded to text. It is the responsibility of the system + implementer to ensure that filenames on the system are + decodeable by ``sys.getfilesystemencoding()``. + +9.0 +--- + +- Drop support for Python 2.6 and 3.2 as integration + dependencies (pip) no longer support these versions. + +8.3 +--- + +- Merge with latest skeleton, adding badges and test runs by + default under tox instead of pytest-runner. +- Documentation is no longer hosted with PyPI. + +8.2.1 +----- + +- #112: Update Travis CI usage to only deploy on Python 3.5. + +8.2 +--- + +- Refreshed project metadata based on `jaraco's project + skeleton `_. + +- Releases are now automatically published via Travis-CI. +- #111: More aggressively trap errors when importing + ``pkg_resources``. + +8.1.2 +----- + +- #105: By using unicode literals, avoid errors rendering the + backslash in __get_owner_windows. + +8.1.1 +----- + +- #102: Reluctantly restored reference to path.path in ``__all__``. + +8.1 +--- + +- #102: Restored ``path.path`` with a DeprecationWarning. + +8.0 +--- + +Removed ``path.path``. Clients must now refer to the canonical +name, ``path.Path`` as introduced in 6.2. + +7.7 +--- + +- #88: Added support for resolving certain directories on a + system to platform-friendly locations using the `appdirs + `_ library. The + ``Path.special`` method returns an ``SpecialResolver`` instance + that will resolve a path in a scope + (i.e. 'site' or 'user') and class (i.e. 'config', 'cache', + 'data'). For + example, to create a config directory for "My App":: + + config_dir = Path.special("My App").user.config.makedirs_p() + + ``config_dir`` will exist in a user context and will be in a + suitable platform-friendly location. + + As ``path.py`` does not currently have any dependencies, and + to retain that expectation for a compatible upgrade path, + ``appdirs`` must be installed to avoid an ImportError when + invoking ``special``. + + +- #88: In order to support "multipath" results, where multiple + paths are returned in a single, ``os.pathsep``-separated + string, a new class MultiPath now represents those special + results. This functionality is experimental and may change. + Feedback is invited. + +7.6.2 +----- + +- Re-release of 7.6.1 without unintended feature. + +7.6.1 +----- + +- #101: Supress error when `path.py` is not present as a distribution. + +7.6 +--- + +- #100: Add ``merge_tree`` method for merging + two existing directory trees. +- Uses `setuptools_scm `_ + for version management. + +7.5 +--- + +- #97: ``__rdiv__`` and ``__rtruediv__`` are now defined. + +7.4 +--- + +- #93: chown now appears in docs and raises NotImplementedError if + ``os.chown`` isn't present. +- #92: Added compatibility support for ``.samefile`` on platforms without + ``os.samefile``. + +7.3 +--- + + - #91: Releases now include a universal wheel. + +7.2 +--- + + - In chmod, added support for multiple symbolic masks (separated by commas). + - In chmod, fixed issue in setting of symbolic mask with '=' where + unreferenced permissions were cleared. + +7.1 +--- + + - #23: Added support for symbolic masks to ``.chmod``. + +7.0 +--- + + - The ``open`` method now uses ``io.open`` and supports all of the + parameters to that function. ``open`` will always raise an ``OSError`` + on failure, even on Python 2. + - Updated ``write_text`` to support additional newline patterns. + - The ``text`` method now always returns text (never bytes), and thus + requires an encoding parameter be supplied if the default encoding is not + sufficient to decode the content of the file. + +6.2 +--- + + - ``path`` class renamed to ``Path``. The ``path`` name remains as an alias + for compatibility. + +6.1 +--- + + - ``chown`` now accepts names in addition to numeric IDs. + +6.0 +--- + + - Drop support for Python 2.5. Python 2.6 or later required. + - Installation now requires setuptools. + +5.3 +--- + + - Allow arbitrary callables to be passed to path.walk ``errors`` parameter. + Enables workaround for issues such as #73 and #56. + +5.2 +--- + + - #61: path.listdir now decodes filenames from os.listdir when loading + characters from a file. On Python 3, the behavior is unchanged. On Python + 2, the behavior will now mimick that of Python 3, attempting to decode + all filenames and paths using the encoding indicated by + ``sys.getfilesystemencoding()``, and escaping any undecodable characters + using the 'surrogateescape' handler. + +5.1 +--- + + - #53: Added ``path.in_place`` for editing files in place. + +5.0 +--- + + - ``path.fnmatch`` now takes an optional parameter ``normcase`` and this + parameter defaults to self.module.normcase (using case normalization most + pertinent to the path object itself). Note that this change means that + any paths using a custom ntpath module on non-Windows systems will have + different fnmatch behavior. Before:: + + # on Unix + >>> p = path('Foo') + >>> p.module = ntpath + >>> p.fnmatch('foo') + False + + After:: + + # on any OS + >>> p = path('Foo') + >>> p.module = ntpath + >>> p.fnmatch('foo') + True + + To maintain the original behavior, either don't define the 'module' for the + path or supply explicit normcase function:: + + >>> p.fnmatch('foo', normcase=os.path.normcase) + # result always varies based on OS, same as fnmatch.fnmatch + + For most use-cases, the default behavior should remain the same. + + - Issue #50: Methods that accept patterns (``listdir``, ``files``, ``dirs``, + ``walk``, ``walkdirs``, ``walkfiles``, and ``fnmatch``) will now use a + ``normcase`` attribute if it is present on the ``pattern`` parameter. The + path module now provides a ``CaseInsensitivePattern`` wrapper for strings + suitable for creating case-insensitive patterns for those methods. + +4.4 +--- + + - Issue #44: _hash method would open files in text mode, producing + invalid results on Windows. Now files are opened in binary mode, producing + consistent results. + - Issue #47: Documentation is dramatically improved with Intersphinx links + to the Python os.path functions and documentation for all methods and + properties. + +4.3 +--- + + - Issue #32: Add ``chdir`` and ``cd`` methods. + +4.2 +--- + + - ``open()`` now passes all positional and keyword arguments through to the + underlying ``builtins.open`` call. + +4.1 +--- + + - Native Python 2 and Python 3 support without using 2to3 during the build + process. + +4.0 +--- + + - Added a ``chunks()`` method to a allow quick iteration over pieces of a + file at a given path. + - Issue #28: Fix missing argument to ``samefile``. + - Initializer no longer enforces `isinstance basestring` for the source + object. Now any object that supplies ``__unicode__`` can be used by a + ``path`` (except None). Clients that depend on a ValueError being raised + for ``int`` and other non-string objects should trap these types + internally. + - Issue #30: ``chown`` no longer requires both uid and gid to be provided + and will not mutate the ownership if nothing is provided. + +3.2 +--- + + - Issue #22: ``__enter__`` now returns self. + +3.1 +--- + + - Issue #20: `relpath` now supports a "start" parameter to match the + signature of `os.path.relpath`. + +3.0 +--- + + - Minimum Python version is now 2.5. + +2.6 +--- + + - Issue #5: Implemented `path.tempdir`, which returns a path object which is + a temporary directory and context manager for cleaning up the directory. + - Issue #12: One can now construct path objects from a list of strings by + simply using path.joinpath. For example:: + + path.joinpath('a', 'b', 'c') # or + path.joinpath(*path_elements) + +2.5 +--- + + - Issue #7: Add the ability to do chaining of operations that formerly only + returned None. + - Issue #4: Raise a TypeError when constructed from None. diff --git a/README.rst b/README.rst index 099837a0..fdea1552 100644 --- a/README.rst +++ b/README.rst @@ -1,64 +1,122 @@ -path.py -======= +.. image:: https://img.shields.io/pypi/v/path.svg + :target: https://pypi.org/project/path -``path.py`` implements a path objects as first-class entities, allowing -common operations on files to be invoked on those path objects directly. For -example: +.. image:: https://img.shields.io/pypi/pyversions/path.svg -.. code-block:: python +.. image:: https://github.com/jaraco/path/actions/workflows/main.yml/badge.svg + :target: https://github.com/jaraco/path/actions?query=workflow%3A%22tests%22 + :alt: tests - from path import Path - d = Path('/home/guido/bin') - for f in d.files('*.py'): - f.chmod(0755) +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Ruff -``path.py`` is `hosted at Github `_. +.. image:: https://readthedocs.org/projects/path/badge/?version=latest + :target: https://path.readthedocs.io/en/latest/?badge=latest -Documentation is `hosted with PyPI `_. +.. image:: https://img.shields.io/badge/skeleton-2026-informational + :target: https://blog.jaraco.com/skeleton -Guides and Testimonials -======================= +.. image:: https://tidelift.com/badges/package/pypi/path + :target: https://tidelift.com/subscription/pkg/pypi-path?utm_source=pypi-path&utm_medium=readme -Yasoob has written the Python 101 `Writing a Cleanup Script -`_ -based on ``path.py``. -Installing -========== +``path`` (aka path pie, formerly ``path.py``) implements path +objects as first-class entities, allowing common operations on +files to be invoked on those path objects directly. For example: + +.. code-block:: python -Path.py may be installed using ``setuptools``, ``distribute``, or ``pip``:: + from path import Path - pip install path.py + d = Path("/home/guido/bin") + for f in d.files("*.py"): + f.chmod(0o755) -The latest release is always updated to the `Python Package Index -`_. + # Globbing + for f in d.files("*.py"): + f.chmod("u+rwx") -You may also always download the source distribution (zip/tarball), extract -it, and run ``python setup.py`` to install it. + # Changing the working directory: + with Path("somewhere"): + # cwd in now `somewhere` + ... -Development -=========== + # Concatenate paths with / + foo_txt = Path("bar") / "foo.txt" -To install an in-development version, use the Github links to clone or -download a snapshot of the latest code. Alternatively, if you have git -installed, you may be able to use ``pip`` or ``easy_install`` to install directly from -the repository:: +Path pie is `hosted at Github `_. - pip install git+https://github.com/jaraco/path.py.git +Find `the documentation here `_. -Testing +Guides and Testimonials +======================= + +Yasoob wrote the Python 101 `Writing a Cleanup Script +`_ +based on ``path``. + +Advantages +========== + +Path pie provides a superior experience to similar offerings. + +Python 3.4 introduced +`pathlib `_, +which shares many characteristics with ``path``. In particular, +it provides an object encapsulation for representing filesystem paths. +One may have imagined ``pathlib`` would supersede ``path``. + +But the implementation and the usage quickly diverge, and ``path`` +has several advantages over ``pathlib``: + +- ``path`` implements ``Path`` objects as a subclass of ``str``, and as a + result these ``Path`` objects may be passed directly to other APIs that + expect simple text representations of paths, whereas with ``pathlib``, one + must first cast values to strings before passing them to APIs that do + not honor `PEP 519 `_ + ``PathLike`` interface. +- ``path`` give quality of life features beyond exposing basic functionality + of a path. ``path`` provides methods like ``rmtree`` (from shlib) and + ``remove_p`` (remove a file if it exists), properties like ``.permissions``, + and sophisticated ``walk``, ``TempDir``, and ``chmod`` behaviors. +- As a PyPI-hosted package, ``path`` is free to iterate + faster than a stdlib package. Contributions are welcome + and encouraged. +- ``path`` provides superior portability using a uniform abstraction + over its single Path object, + freeing the implementer to subclass it readily. One cannot + subclass a ``pathlib.Path`` to add functionality, but must + subclass ``Path``, ``PosixPath``, and ``WindowsPath``, even + to do something as simple as to add a ``__dict__`` to the subclass + instances. ``path`` instead allows the ``Path.module`` + object to be overridden by subclasses, defaulting to the + ``os.path``. Even advanced uses of ``path.Path`` that + subclass the model do not need to be concerned with + OS-specific nuances. ``path.Path`` objects are inherently "pure", + not requiring the author to distinguish between pure and non-pure + variants. + +This path project has the explicit aim to provide compatibility +with ``pathlib`` objects where possible, such that a ``path.Path`` +object is a drop-in replacement for ``pathlib.Path*`` objects. +This project welcomes contributions to improve that compatibility +where it's lacking. + + +Origins ======= -Tests are continuously run by Travis-CI: |BuildStatus|_ +The ``path.py`` project was initially released in 2003 by Jason Orendorff +and has been continuously developed and supported by several maintainers +over the years. + -.. |BuildStatus| image:: https://secure.travis-ci.org/jaraco/path.py.png -.. _BuildStatus: http://travis-ci.org/jaraco/path.py +For Enterprise +============== -To run the tests, refer to the ``.travis.yml`` file for the steps run on the -Travis-CI hosts. +Available as part of the Tidelift Subscription. -Releasing -========= +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. -Tagged releases are automatically published to PyPI by Travis-CI, assuming -the Python 3 build passes. +`Learn more `_. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..54f99acb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Security Contact + +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. diff --git a/docs/api.rst b/docs/api.rst index cc77f4fa..150d9358 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,3 +9,7 @@ API .. automodule:: path :members: :undoc-members: + +.. automodule:: path.masks + :members: + :undoc-members: diff --git a/docs/conf.py b/docs/conf.py index f57a46e4..fe85d77b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,54 +1,82 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import setuptools_scm +from __future__ import annotations extensions = [ 'sphinx.ext.autodoc', - 'rst.linker', + 'jaraco.packaging.sphinx', ] -# General information about the project. -project = 'path.py' -copyright = '2013-2016 Mikhail Gusarov, Jason R. Coombs' +master_doc = "index" +html_theme = "furo" + +pygments_style = "sphinx" -# The short X.Y version. -version = setuptools_scm.get_version(root='..', relative_to=__file__) -# The full version, including alpha/beta/rc tags. -release = version +# Link dates and other references in the changelog +extensions += ['rst.linker'] +link_files = { + '../NEWS.rst': dict( + using=dict(GH='https://github.com'), + replace=[ + dict( + pattern=r'(Issue #|\B#)(?P\d+)', + url='{package_url}/issues/{issue}', + ), + dict( + pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', + with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', + ), + dict( + pattern=r'PEP[- ](?P\d+)', + url='https://peps.python.org/pep-{pep_number:0>4}/', + ), + ], + ) +} -pygments_style = 'sphinx' -html_theme = 'alabaster' -html_static_path = ['_static'] -htmlhelp_basename = 'pathpydoc' -templates_path = ['_templates'] -exclude_patterns = ['_build'] -source_suffix = '.rst' -master_doc = 'index' +# Be strict about any broken references +nitpicky = True +nitpick_ignore: list[tuple[str, str]] = [] -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'rst.linker'] -intersphinx_mapping = {'python': ('http://docs.python.org/', None)} +nitpick_ignore = [ + ('py:class', '_io.BufferedRandom'), + ('py:class', '_io.BufferedReader'), + ('py:class', '_io.BufferedWriter'), + ('py:class', '_io.FileIO'), + ('py:class', '_io.TextIOWrapper'), + ('py:class', 'Literal[-1, 1]'), + ('py:class', 'OpenBinaryMode'), + ('py:class', 'OpenBinaryModeReading'), + ('py:class', 'OpenBinaryModeUpdating'), + ('py:class', 'OpenBinaryModeWriting'), + ('py:class', 'OpenTextMode'), + ('py:class', '_IgnoreFn'), + ('py:class', '_CopyFn'), + ('py:class', '_Match'), + ('py:class', '_OnErrorCallback'), + ('py:class', '_OnExcCallback'), + ('py:class', 'os.statvfs_result'), + ('py:class', 'ModuleType'), +] -link_files = { - 'CHANGES.rst': dict( - using=dict( - GH='https://github.com', - project=project, - ), - replace=[ - dict( - pattern=r"(Issue )?#(?P\d+)", - url='{GH}/jaraco/{project}/issues/{issue}', - ), - dict( - pattern=r"Pull Request ?#(?P\d+)", - url='{GH}/jaraco/{project}/pull/{pull_request}', - ), - dict( - pattern=r"^(?m)((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n", - with_scm="{text}\n{rev[timestamp]:%d %b %Y}\n", - ), - ], - ), +# Include Python intersphinx mapping to prevent failures +# jaraco/skeleton#51 +extensions += ['sphinx.ext.intersphinx'] +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), } + +# Preserve authored syntax for defaults +autodoc_preserve_defaults = True + +# Add support for linking usernames, PyPI projects, Wikipedia pages +github_url = 'https://github.com/' +extlinks = { + 'user': (f'{github_url}%s', '@%s'), + 'pypi': ('https://pypi.org/project/%s', '%s'), + 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), +} +extensions += ['sphinx.ext.extlinks'] + +# local + +extensions += ['jaraco.tidelift'] diff --git a/docs/history.rst b/docs/history.rst index 877faf79..0a5a245f 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -5,4 +5,4 @@ History ******* -.. include:: ../CHANGES (links).rst +.. include:: ../NEWS (links).rst diff --git a/docs/index.rst b/docs/index.rst index 1ed9774b..a6ab591f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,9 @@ -Welcome to path.py's documentation! +Welcome to |project| documentation! =================================== -Contents: +.. sidebar-links:: + :home: + :pypi: .. toctree:: :maxdepth: 1 @@ -9,6 +11,12 @@ Contents: api history +.. tidelift-referral-banner:: + +Thanks to Mahan Marwat for transferring the ``path`` name on +Read The Docs from `path `_ +to this project. + Indices and tables ================== @@ -16,4 +24,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..efcb8cbc --- /dev/null +++ b/mypy.ini @@ -0,0 +1,15 @@ +[mypy] +# Is the project well-typed? +strict = False + +# Early opt-in even when strict = False +warn_unused_ignores = True +warn_redundant_casts = True +enable_error_code = ignore-without-code + +# Support namespace packages per https://github.com/python/mypy/issues/14057 +explicit_package_bases = True + +disable_error_code = + # Disable due to many false positives + overload-overlap, diff --git a/path.py b/path.py deleted file mode 100644 index 1e92a490..00000000 --- a/path.py +++ /dev/null @@ -1,1722 +0,0 @@ -# -# Copyright (c) 2010 Mikhail Gusarov -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# - -""" -path.py - An object representing a path to a file or directory. - -https://github.com/jaraco/path.py - -Example:: - - from path import Path - d = Path('/home/guido/bin') - for f in d.files('*.py'): - f.chmod(0o755) -""" - -from __future__ import unicode_literals - -import sys -import warnings -import os -import fnmatch -import glob -import shutil -import codecs -import hashlib -import errno -import tempfile -import functools -import operator -import re -import contextlib -import io -from distutils import dir_util -import importlib - -try: - import win32security -except ImportError: - pass - -try: - import pwd -except ImportError: - pass - -try: - import grp -except ImportError: - pass - -############################################################################## -# Python 2/3 support -PY3 = sys.version_info >= (3,) -PY2 = not PY3 - -string_types = str, -text_type = str -getcwdu = os.getcwd - -def surrogate_escape(error): - """ - Simulate the Python 3 ``surrogateescape`` handler, but for Python 2 only. - """ - chars = error.object[error.start:error.end] - assert len(chars) == 1 - val = ord(chars) - val += 0xdc00 - return __builtin__.unichr(val), error.end - -if PY2: - import __builtin__ - string_types = __builtin__.basestring, - text_type = __builtin__.unicode - getcwdu = os.getcwdu - codecs.register_error('surrogateescape', surrogate_escape) - -@contextlib.contextmanager -def io_error_compat(): - try: - yield - except IOError as io_err: - # On Python 2, io.open raises IOError; transform to OSError for - # future compatibility. - os_err = OSError(*io_err.args) - os_err.filename = getattr(io_err, 'filename', None) - raise os_err - -############################################################################## - -__all__ = ['Path', 'CaseInsensitivePattern'] - - -LINESEPS = ['\r\n', '\r', '\n'] -U_LINESEPS = LINESEPS + ['\u0085', '\u2028', '\u2029'] -NEWLINE = re.compile('|'.join(LINESEPS)) -U_NEWLINE = re.compile('|'.join(U_LINESEPS)) -NL_END = re.compile(r'(?:{0})$'.format(NEWLINE.pattern)) -U_NL_END = re.compile(r'(?:{0})$'.format(U_NEWLINE.pattern)) - - -try: - import pkg_resources - __version__ = pkg_resources.require('path.py')[0].version -except Exception: - __version__ = 'unknown' - - -class TreeWalkWarning(Warning): - pass - - -# from jaraco.functools -def compose(*funcs): - compose_two = lambda f1, f2: lambda *args, **kwargs: f1(f2(*args, **kwargs)) - return functools.reduce(compose_two, funcs) - - -def simple_cache(func): - """ - Save results for the :meth:'path.using_module' classmethod. - When Python 3.2 is available, use functools.lru_cache instead. - """ - saved_results = {} - - def wrapper(cls, module): - if module in saved_results: - return saved_results[module] - saved_results[module] = func(cls, module) - return saved_results[module] - return wrapper - - -class ClassProperty(property): - def __get__(self, cls, owner): - return self.fget.__get__(None, owner)() - - -class multimethod(object): - """ - Acts like a classmethod when invoked from the class and like an - instancemethod when invoked from the instance. - """ - def __init__(self, func): - self.func = func - - def __get__(self, instance, owner): - return ( - functools.partial(self.func, owner) if instance is None - else functools.partial(self.func, owner, instance) - ) - - -class Path(text_type): - """ - Represents a filesystem path. - - For documentation on individual methods, consult their - counterparts in :mod:`os.path`. - - Some methods are additionally included from :mod:`shutil`. - The functions are linked directly into the class namespace - such that they will be bound to the Path instance. For example, - ``Path(src).copy(target)`` is equivalent to - ``shutil.copy(src, target)``. Therefore, when referencing - the docs for these methods, assume `src` references `self`, - the Path instance. - """ - - module = os.path - """ The path module to use for path operations. - - .. seealso:: :mod:`os.path` - """ - - def __init__(self, other=''): - if other is None: - raise TypeError("Invalid initial value for path: None") - - @classmethod - @simple_cache - def using_module(cls, module): - subclass_name = cls.__name__ + '_' + module.__name__ - if PY2: - subclass_name = str(subclass_name) - bases = (cls,) - ns = {'module': module} - return type(subclass_name, bases, ns) - - @ClassProperty - @classmethod - def _next_class(cls): - """ - What class should be used to construct new instances from this class - """ - return cls - - @classmethod - def _always_unicode(cls, path): - """ - Ensure the path as retrieved from a Python API, such as :func:`os.listdir`, - is a proper Unicode string. - """ - if PY3 or isinstance(path, text_type): - return path - return path.decode(sys.getfilesystemencoding(), 'surrogateescape') - - # --- Special Python methods. - - def __repr__(self): - return '%s(%s)' % (type(self).__name__, super(Path, self).__repr__()) - - # Adding a Path and a string yields a Path. - def __add__(self, more): - try: - return self._next_class(super(Path, self).__add__(more)) - except TypeError: # Python bug - return NotImplemented - - def __radd__(self, other): - if not isinstance(other, string_types): - return NotImplemented - return self._next_class(other.__add__(self)) - - # The / operator joins Paths. - def __div__(self, rel): - """ fp.__div__(rel) == fp / rel == fp.joinpath(rel) - - Join two path components, adding a separator character if - needed. - - .. seealso:: :func:`os.path.join` - """ - return self._next_class(self.module.join(self, rel)) - - # Make the / operator work even when true division is enabled. - __truediv__ = __div__ - - # The / operator joins Paths the other way around - def __rdiv__(self, rel): - """ fp.__rdiv__(rel) == rel / fp - - Join two path components, adding a separator character if - needed. - - .. seealso:: :func:`os.path.join` - """ - return self._next_class(self.module.join(rel, self)) - - # Make the / operator work even when true division is enabled. - __rtruediv__ = __rdiv__ - - def __enter__(self): - self._old_dir = self.getcwd() - os.chdir(self) - return self - - def __exit__(self, *_): - os.chdir(self._old_dir) - - @classmethod - def getcwd(cls): - """ Return the current working directory as a path object. - - .. seealso:: :func:`os.getcwdu` - """ - return cls(getcwdu()) - - # - # --- Operations on Path strings. - - def abspath(self): - """ .. seealso:: :func:`os.path.abspath` """ - return self._next_class(self.module.abspath(self)) - - def normcase(self): - """ .. seealso:: :func:`os.path.normcase` """ - return self._next_class(self.module.normcase(self)) - - def normpath(self): - """ .. seealso:: :func:`os.path.normpath` """ - return self._next_class(self.module.normpath(self)) - - def realpath(self): - """ .. seealso:: :func:`os.path.realpath` """ - return self._next_class(self.module.realpath(self)) - - def expanduser(self): - """ .. seealso:: :func:`os.path.expanduser` """ - return self._next_class(self.module.expanduser(self)) - - def expandvars(self): - """ .. seealso:: :func:`os.path.expandvars` """ - return self._next_class(self.module.expandvars(self)) - - def dirname(self): - """ .. seealso:: :attr:`parent`, :func:`os.path.dirname` """ - return self._next_class(self.module.dirname(self)) - - def basename(self): - """ .. seealso:: :attr:`name`, :func:`os.path.basename` """ - return self._next_class(self.module.basename(self)) - - def expand(self): - """ Clean up a filename by calling :meth:`expandvars()`, - :meth:`expanduser()`, and :meth:`normpath()` on it. - - This is commonly everything needed to clean up a filename - read from a configuration file, for example. - """ - return self.expandvars().expanduser().normpath() - - @property - def namebase(self): - """ The same as :meth:`name`, but with one file extension stripped off. - - For example, - ``Path('/home/guido/python.tar.gz').name == 'python.tar.gz'``, - but - ``Path('/home/guido/python.tar.gz').namebase == 'python.tar'``. - """ - base, ext = self.module.splitext(self.name) - return base - - @property - def ext(self): - """ The file extension, for example ``'.py'``. """ - f, ext = self.module.splitext(self) - return ext - - @property - def drive(self): - """ The drive specifier, for example ``'C:'``. - - This is always empty on systems that don't use drive specifiers. - """ - drive, r = self.module.splitdrive(self) - return self._next_class(drive) - - parent = property( - dirname, None, None, - """ This path's parent directory, as a new Path object. - - For example, - ``Path('/usr/local/lib/libpython.so').parent == - Path('/usr/local/lib')`` - - .. seealso:: :meth:`dirname`, :func:`os.path.dirname` - """) - - name = property( - basename, None, None, - """ The name of this file or directory without the full path. - - For example, - ``Path('/usr/local/lib/libpython.so').name == 'libpython.so'`` - - .. seealso:: :meth:`basename`, :func:`os.path.basename` - """) - - def splitpath(self): - """ p.splitpath() -> Return ``(p.parent, p.name)``. - - .. seealso:: :attr:`parent`, :attr:`name`, :func:`os.path.split` - """ - parent, child = self.module.split(self) - return self._next_class(parent), child - - def splitdrive(self): - """ p.splitdrive() -> Return ``(p.drive, )``. - - Split the drive specifier from this path. If there is - no drive specifier, :samp:`{p.drive}` is empty, so the return value - is simply ``(Path(''), p)``. This is always the case on Unix. - - .. seealso:: :func:`os.path.splitdrive` - """ - drive, rel = self.module.splitdrive(self) - return self._next_class(drive), rel - - def splitext(self): - """ p.splitext() -> Return ``(p.stripext(), p.ext)``. - - Split the filename extension from this path and return - the two parts. Either part may be empty. - - The extension is everything from ``'.'`` to the end of the - last path segment. This has the property that if - ``(a, b) == p.splitext()``, then ``a + b == p``. - - .. seealso:: :func:`os.path.splitext` - """ - filename, ext = self.module.splitext(self) - return self._next_class(filename), ext - - def stripext(self): - """ p.stripext() -> Remove one file extension from the path. - - For example, ``Path('/home/guido/python.tar.gz').stripext()`` - returns ``Path('/home/guido/python.tar')``. - """ - return self.splitext()[0] - - def splitunc(self): - """ .. seealso:: :func:`os.path.splitunc` """ - unc, rest = self.module.splitunc(self) - return self._next_class(unc), rest - - @property - def uncshare(self): - """ - The UNC mount point for this path. - This is empty for paths on local drives. - """ - unc, r = self.module.splitunc(self) - return self._next_class(unc) - - @multimethod - def joinpath(cls, first, *others): - """ - Join first to zero or more :class:`Path` components, adding a separator - character (:samp:`{first}.module.sep`) if needed. Returns a new instance of - :samp:`{first}._next_class`. - - .. seealso:: :func:`os.path.join` - """ - if not isinstance(first, cls): - first = cls(first) - return first._next_class(first.module.join(first, *others)) - - def splitall(self): - r""" Return a list of the path components in this path. - - The first item in the list will be a Path. Its value will be - either :data:`os.curdir`, :data:`os.pardir`, empty, or the root - directory of this path (for example, ``'/'`` or ``'C:\\'``). The - other items in the list will be strings. - - ``path.Path.joinpath(*result)`` will yield the original path. - """ - parts = [] - loc = self - while loc != os.curdir and loc != os.pardir: - prev = loc - loc, child = prev.splitpath() - if loc == prev: - break - parts.append(child) - parts.append(loc) - parts.reverse() - return parts - - def relpath(self, start='.'): - """ Return this path as a relative path, - based from `start`, which defaults to the current working directory. - """ - cwd = self._next_class(start) - return cwd.relpathto(self) - - def relpathto(self, dest): - """ Return a relative path from `self` to `dest`. - - If there is no relative path from `self` to `dest`, for example if - they reside on different drives in Windows, then this returns - ``dest.abspath()``. - """ - origin = self.abspath() - dest = self._next_class(dest).abspath() - - orig_list = origin.normcase().splitall() - # Don't normcase dest! We want to preserve the case. - dest_list = dest.splitall() - - if orig_list[0] != self.module.normcase(dest_list[0]): - # Can't get here from there. - return dest - - # Find the location where the two paths start to differ. - i = 0 - for start_seg, dest_seg in zip(orig_list, dest_list): - if start_seg != self.module.normcase(dest_seg): - break - i += 1 - - # Now i is the point where the two paths diverge. - # Need a certain number of "os.pardir"s to work up - # from the origin to the point of divergence. - segments = [os.pardir] * (len(orig_list) - i) - # Need to add the diverging part of dest_list. - segments += dest_list[i:] - if len(segments) == 0: - # If they happen to be identical, use os.curdir. - relpath = os.curdir - else: - relpath = self.module.join(*segments) - return self._next_class(relpath) - - # --- Listing, searching, walking, and matching - - def listdir(self, pattern=None): - """ D.listdir() -> List of items in this directory. - - Use :meth:`files` or :meth:`dirs` instead if you want a listing - of just files or just subdirectories. - - The elements of the list are Path objects. - - With the optional `pattern` argument, this only lists - items whose names match the given pattern. - - .. seealso:: :meth:`files`, :meth:`dirs` - """ - if pattern is None: - pattern = '*' - return [ - self / child - for child in map(self._always_unicode, os.listdir(self)) - if self._next_class(child).fnmatch(pattern) - ] - - def dirs(self, pattern=None): - """ D.dirs() -> List of this directory's subdirectories. - - The elements of the list are Path objects. - This does not walk recursively into subdirectories - (but see :meth:`walkdirs`). - - With the optional `pattern` argument, this only lists - directories whose names match the given pattern. For - example, ``d.dirs('build-*')``. - """ - return [p for p in self.listdir(pattern) if p.isdir()] - - def files(self, pattern=None): - """ D.files() -> List of the files in this directory. - - The elements of the list are Path objects. - This does not walk into subdirectories (see :meth:`walkfiles`). - - With the optional `pattern` argument, this only lists files - whose names match the given pattern. For example, - ``d.files('*.pyc')``. - """ - - return [p for p in self.listdir(pattern) if p.isfile()] - - def walk(self, pattern=None, errors='strict'): - """ D.walk() -> iterator over files and subdirs, recursively. - - The iterator yields Path objects naming each child item of - this directory and its descendants. This requires that - ``D.isdir()``. - - This performs a depth-first traversal of the directory tree. - Each directory is returned just before all its children. - - The `errors=` keyword argument controls behavior when an - error occurs. The default is ``'strict'``, which causes an - exception. Other allowed values are ``'warn'`` (which - reports the error via :func:`warnings.warn()`), and ``'ignore'``. - `errors` may also be an arbitrary callable taking a msg parameter. - """ - class Handlers: - def strict(msg): - raise - - def warn(msg): - warnings.warn(msg, TreeWalkWarning) - - def ignore(msg): - pass - - if not callable(errors) and errors not in vars(Handlers): - raise ValueError("invalid errors parameter") - errors = vars(Handlers).get(errors, errors) - - try: - childList = self.listdir() - except Exception: - exc = sys.exc_info()[1] - tmpl = "Unable to list directory '%(self)s': %(exc)s" - msg = tmpl % locals() - errors(msg) - return - - for child in childList: - if pattern is None or child.fnmatch(pattern): - yield child - try: - isdir = child.isdir() - except Exception: - exc = sys.exc_info()[1] - tmpl = "Unable to access '%(child)s': %(exc)s" - msg = tmpl % locals() - errors(msg) - isdir = False - - if isdir: - for item in child.walk(pattern, errors): - yield item - - def walkdirs(self, pattern=None, errors='strict'): - """ D.walkdirs() -> iterator over subdirs, recursively. - - With the optional `pattern` argument, this yields only - directories whose names match the given pattern. For - example, ``mydir.walkdirs('*test')`` yields only directories - with names ending in ``'test'``. - - The `errors=` keyword argument controls behavior when an - error occurs. The default is ``'strict'``, which causes an - exception. The other allowed values are ``'warn'`` (which - reports the error via :func:`warnings.warn()`), and ``'ignore'``. - """ - if errors not in ('strict', 'warn', 'ignore'): - raise ValueError("invalid errors parameter") - - try: - dirs = self.dirs() - except Exception: - if errors == 'ignore': - return - elif errors == 'warn': - warnings.warn( - "Unable to list directory '%s': %s" - % (self, sys.exc_info()[1]), - TreeWalkWarning) - return - else: - raise - - for child in dirs: - if pattern is None or child.fnmatch(pattern): - yield child - for subsubdir in child.walkdirs(pattern, errors): - yield subsubdir - - def walkfiles(self, pattern=None, errors='strict'): - """ D.walkfiles() -> iterator over files in D, recursively. - - The optional argument `pattern` limits the results to files - with names that match the pattern. For example, - ``mydir.walkfiles('*.tmp')`` yields only files with the ``.tmp`` - extension. - """ - if errors not in ('strict', 'warn', 'ignore'): - raise ValueError("invalid errors parameter") - - try: - childList = self.listdir() - except Exception: - if errors == 'ignore': - return - elif errors == 'warn': - warnings.warn( - "Unable to list directory '%s': %s" - % (self, sys.exc_info()[1]), - TreeWalkWarning) - return - else: - raise - - for child in childList: - try: - isfile = child.isfile() - isdir = not isfile and child.isdir() - except: - if errors == 'ignore': - continue - elif errors == 'warn': - warnings.warn( - "Unable to access '%s': %s" - % (self, sys.exc_info()[1]), - TreeWalkWarning) - continue - else: - raise - - if isfile: - if pattern is None or child.fnmatch(pattern): - yield child - elif isdir: - for f in child.walkfiles(pattern, errors): - yield f - - def fnmatch(self, pattern, normcase=None): - """ Return ``True`` if `self.name` matches the given `pattern`. - - `pattern` - A filename pattern with wildcards, - for example ``'*.py'``. If the pattern contains a `normcase` - attribute, it is applied to the name and path prior to comparison. - - `normcase` - (optional) A function used to normalize the pattern and - filename before matching. Defaults to :meth:`self.module`, which defaults - to :meth:`os.path.normcase`. - - .. seealso:: :func:`fnmatch.fnmatch` - """ - default_normcase = getattr(pattern, 'normcase', self.module.normcase) - normcase = normcase or default_normcase - name = normcase(self.name) - pattern = normcase(pattern) - return fnmatch.fnmatchcase(name, pattern) - - def glob(self, pattern): - """ Return a list of Path objects that match the pattern. - - `pattern` - a path relative to this directory, with wildcards. - - For example, ``Path('/users').glob('*/bin/*')`` returns a list - of all the files users have in their :file:`bin` directories. - - .. seealso:: :func:`glob.glob` - """ - cls = self._next_class - return [cls(s) for s in glob.glob(self / pattern)] - - # - # --- Reading or writing an entire file at once. - - def open(self, *args, **kwargs): - """ Open this file and return a corresponding :class:`file` object. - - Keyword arguments work as in :func:`io.open`. If the file cannot be - opened, an :class:`~exceptions.OSError` is raised. - """ - with io_error_compat(): - return io.open(self, *args, **kwargs) - - def bytes(self): - """ Open this file, read all bytes, return them as a string. """ - with self.open('rb') as f: - return f.read() - - def chunks(self, size, *args, **kwargs): - """ Returns a generator yielding chunks of the file, so it can - be read piece by piece with a simple for loop. - - Any argument you pass after `size` will be passed to :meth:`open`. - - :example: - - >>> hash = hashlib.md5() - >>> for chunk in Path("path.py").chunks(8192, mode='rb'): - ... hash.update(chunk) - - This will read the file by chunks of 8192 bytes. - """ - with self.open(*args, **kwargs) as f: - for chunk in iter(lambda: f.read(size) or None, None): - yield chunk - - def write_bytes(self, bytes, append=False): - """ Open this file and write the given bytes to it. - - Default behavior is to overwrite any existing file. - Call ``p.write_bytes(bytes, append=True)`` to append instead. - """ - if append: - mode = 'ab' - else: - mode = 'wb' - with self.open(mode) as f: - f.write(bytes) - - def text(self, encoding=None, errors='strict'): - r""" Open this file, read it in, return the content as a string. - - All newline sequences are converted to ``'\n'``. Keyword arguments - will be passed to :meth:`open`. - - .. seealso:: :meth:`lines` - """ - with self.open(mode='r', encoding=encoding, errors=errors) as f: - return U_NEWLINE.sub('\n', f.read()) - - def write_text(self, text, encoding=None, errors='strict', - linesep=os.linesep, append=False): - r""" Write the given text to this file. - - The default behavior is to overwrite any existing file; - to append instead, use the `append=True` keyword argument. - - There are two differences between :meth:`write_text` and - :meth:`write_bytes`: newline handling and Unicode handling. - See below. - - Parameters: - - `text` - str/unicode - The text to be written. - - `encoding` - str - The Unicode encoding that will be used. - This is ignored if `text` isn't a Unicode string. - - `errors` - str - How to handle Unicode encoding errors. - Default is ``'strict'``. See ``help(unicode.encode)`` for the - options. This is ignored if `text` isn't a Unicode - string. - - `linesep` - keyword argument - str/unicode - The sequence of - characters to be used to mark end-of-line. The default is - :data:`os.linesep`. You can also specify ``None`` to - leave all newlines as they are in `text`. - - `append` - keyword argument - bool - Specifies what to do if - the file already exists (``True``: append to the end of it; - ``False``: overwrite it.) The default is ``False``. - - - --- Newline handling. - - ``write_text()`` converts all standard end-of-line sequences - (``'\n'``, ``'\r'``, and ``'\r\n'``) to your platform's default - end-of-line sequence (see :data:`os.linesep`; on Windows, for example, - the end-of-line marker is ``'\r\n'``). - - If you don't like your platform's default, you can override it - using the `linesep=` keyword argument. If you specifically want - ``write_text()`` to preserve the newlines as-is, use ``linesep=None``. - - This applies to Unicode text the same as to 8-bit text, except - there are three additional standard Unicode end-of-line sequences: - ``u'\x85'``, ``u'\r\x85'``, and ``u'\u2028'``. - - (This is slightly different from when you open a file for - writing with ``fopen(filename, "w")`` in C or ``open(filename, 'w')`` - in Python.) - - - --- Unicode - - If `text` isn't Unicode, then apart from newline handling, the - bytes are written verbatim to the file. The `encoding` and - `errors` arguments are not used and must be omitted. - - If `text` is Unicode, it is first converted to :func:`bytes` using the - specified `encoding` (or the default encoding if `encoding` - isn't specified). The `errors` argument applies only to this - conversion. - - """ - if isinstance(text, text_type): - if linesep is not None: - text = U_NEWLINE.sub(linesep, text) - text = text.encode(encoding or sys.getdefaultencoding(), errors) - else: - assert encoding is None - text = NEWLINE.sub(linesep, text) - self.write_bytes(text, append=append) - - def lines(self, encoding=None, errors='strict', retain=True): - r""" Open this file, read all lines, return them in a list. - - Optional arguments: - `encoding` - The Unicode encoding (or character set) of - the file. The default is ``None``, meaning the content - of the file is read as 8-bit characters and returned - as a list of (non-Unicode) str objects. - `errors` - How to handle Unicode errors; see help(str.decode) - for the options. Default is ``'strict'``. - `retain` - If ``True``, retain newline characters; but all newline - character combinations (``'\r'``, ``'\n'``, ``'\r\n'``) are - translated to ``'\n'``. If ``False``, newline characters are - stripped off. Default is ``True``. - - This uses ``'U'`` mode. - - .. seealso:: :meth:`text` - """ - if encoding is None and retain: - with self.open('U') as f: - return f.readlines() - else: - return self.text(encoding, errors).splitlines(retain) - - def write_lines(self, lines, encoding=None, errors='strict', - linesep=os.linesep, append=False): - r""" Write the given lines of text to this file. - - By default this overwrites any existing file at this path. - - This puts a platform-specific newline sequence on every line. - See `linesep` below. - - `lines` - A list of strings. - - `encoding` - A Unicode encoding to use. This applies only if - `lines` contains any Unicode strings. - - `errors` - How to handle errors in Unicode encoding. This - also applies only to Unicode strings. - - linesep - The desired line-ending. This line-ending is - applied to every line. If a line already has any - standard line ending (``'\r'``, ``'\n'``, ``'\r\n'``, - ``u'\x85'``, ``u'\r\x85'``, ``u'\u2028'``), that will - be stripped off and this will be used instead. The - default is os.linesep, which is platform-dependent - (``'\r\n'`` on Windows, ``'\n'`` on Unix, etc.). - Specify ``None`` to write the lines as-is, like - :meth:`file.writelines`. - - Use the keyword argument ``append=True`` to append lines to the - file. The default is to overwrite the file. - - .. warning :: - - When you use this with Unicode data, if the encoding of the - existing data in the file is different from the encoding - you specify with the `encoding=` parameter, the result is - mixed-encoding data, which can really confuse someone trying - to read the file later. - """ - with self.open('ab' if append else 'wb') as f: - for l in lines: - isUnicode = isinstance(l, text_type) - if linesep is not None: - pattern = U_NL_END if isUnicode else NL_END - l = pattern.sub('', l) + linesep - if isUnicode: - l = l.encode(encoding or sys.getdefaultencoding(), errors) - f.write(l) - - def read_md5(self): - """ Calculate the md5 hash for this file. - - This reads through the entire file. - - .. seealso:: :meth:`read_hash` - """ - return self.read_hash('md5') - - def _hash(self, hash_name): - """ Returns a hash object for the file at the current path. - - `hash_name` should be a hash algo name (such as ``'md5'`` or ``'sha1'``) - that's available in the :mod:`hashlib` module. - """ - m = hashlib.new(hash_name) - for chunk in self.chunks(8192, mode="rb"): - m.update(chunk) - return m - - def read_hash(self, hash_name): - """ Calculate given hash for this file. - - List of supported hashes can be obtained from :mod:`hashlib` package. - This reads the entire file. - - .. seealso:: :meth:`hashlib.hash.digest` - """ - return self._hash(hash_name).digest() - - def read_hexhash(self, hash_name): - """ Calculate given hash for this file, returning hexdigest. - - List of supported hashes can be obtained from :mod:`hashlib` package. - This reads the entire file. - - .. seealso:: :meth:`hashlib.hash.hexdigest` - """ - return self._hash(hash_name).hexdigest() - - # --- Methods for querying the filesystem. - # N.B. On some platforms, the os.path functions may be implemented in C - # (e.g. isdir on Windows, Python 3.2.2), and compiled functions don't get - # bound. Playing it safe and wrapping them all in method calls. - - def isabs(self): - """ .. seealso:: :func:`os.path.isabs` """ - return self.module.isabs(self) - - def exists(self): - """ .. seealso:: :func:`os.path.exists` """ - return self.module.exists(self) - - def isdir(self): - """ .. seealso:: :func:`os.path.isdir` """ - return self.module.isdir(self) - - def isfile(self): - """ .. seealso:: :func:`os.path.isfile` """ - return self.module.isfile(self) - - def islink(self): - """ .. seealso:: :func:`os.path.islink` """ - return self.module.islink(self) - - def ismount(self): - """ .. seealso:: :func:`os.path.ismount` """ - return self.module.ismount(self) - - def samefile(self, other): - """ .. seealso:: :func:`os.path.samefile` """ - if not hasattr(self.module, 'samefile'): - other = Path(other).realpath().normpath().normcase() - return self.realpath().normpath().normcase() == other - return self.module.samefile(self, other) - - def getatime(self): - """ .. seealso:: :attr:`atime`, :func:`os.path.getatime` """ - return self.module.getatime(self) - - atime = property( - getatime, None, None, - """ Last access time of the file. - - .. seealso:: :meth:`getatime`, :func:`os.path.getatime` - """) - - def getmtime(self): - """ .. seealso:: :attr:`mtime`, :func:`os.path.getmtime` """ - return self.module.getmtime(self) - - mtime = property( - getmtime, None, None, - """ Last-modified time of the file. - - .. seealso:: :meth:`getmtime`, :func:`os.path.getmtime` - """) - - def getctime(self): - """ .. seealso:: :attr:`ctime`, :func:`os.path.getctime` """ - return self.module.getctime(self) - - ctime = property( - getctime, None, None, - """ Creation time of the file. - - .. seealso:: :meth:`getctime`, :func:`os.path.getctime` - """) - - def getsize(self): - """ .. seealso:: :attr:`size`, :func:`os.path.getsize` """ - return self.module.getsize(self) - - size = property( - getsize, None, None, - """ Size of the file, in bytes. - - .. seealso:: :meth:`getsize`, :func:`os.path.getsize` - """) - - if hasattr(os, 'access'): - def access(self, mode): - """ Return ``True`` if current user has access to this path. - - mode - One of the constants :data:`os.F_OK`, :data:`os.R_OK`, - :data:`os.W_OK`, :data:`os.X_OK` - - .. seealso:: :func:`os.access` - """ - return os.access(self, mode) - - def stat(self): - """ Perform a ``stat()`` system call on this path. - - .. seealso:: :meth:`lstat`, :func:`os.stat` - """ - return os.stat(self) - - def lstat(self): - """ Like :meth:`stat`, but do not follow symbolic links. - - .. seealso:: :meth:`stat`, :func:`os.lstat` - """ - return os.lstat(self) - - def __get_owner_windows(self): - """ - Return the name of the owner of this file or directory. Follow - symbolic links. - - Return a name of the form ``r'DOMAIN\\User Name'``; may be a group. - - .. seealso:: :attr:`owner` - """ - desc = win32security.GetFileSecurity( - self, win32security.OWNER_SECURITY_INFORMATION) - sid = desc.GetSecurityDescriptorOwner() - account, domain, typecode = win32security.LookupAccountSid(None, sid) - return domain + '\\' + account - - def __get_owner_unix(self): - """ - Return the name of the owner of this file or directory. Follow - symbolic links. - - .. seealso:: :attr:`owner` - """ - st = self.stat() - return pwd.getpwuid(st.st_uid).pw_name - - def __get_owner_not_implemented(self): - raise NotImplementedError("Ownership not available on this platform.") - - if 'win32security' in globals(): - get_owner = __get_owner_windows - elif 'pwd' in globals(): - get_owner = __get_owner_unix - else: - get_owner = __get_owner_not_implemented - - owner = property( - get_owner, None, None, - """ Name of the owner of this file or directory. - - .. seealso:: :meth:`get_owner`""") - - if hasattr(os, 'statvfs'): - def statvfs(self): - """ Perform a ``statvfs()`` system call on this path. - - .. seealso:: :func:`os.statvfs` - """ - return os.statvfs(self) - - if hasattr(os, 'pathconf'): - def pathconf(self, name): - """ .. seealso:: :func:`os.pathconf` """ - return os.pathconf(self, name) - - # - # --- Modifying operations on files and directories - - def utime(self, times): - """ Set the access and modified times of this file. - - .. seealso:: :func:`os.utime` - """ - os.utime(self, times) - return self - - def chmod(self, mode): - """ - Set the mode. May be the new mode (os.chmod behavior) or a `symbolic - mode `_. - - .. seealso:: :func:`os.chmod` - """ - if isinstance(mode, string_types): - mask = _multi_permission_mask(mode) - mode = mask(self.stat().st_mode) - os.chmod(self, mode) - return self - - def chown(self, uid=-1, gid=-1): - """ - Change the owner and group by names rather than the uid or gid numbers. - - .. seealso:: :func:`os.chown` - """ - if hasattr(os, 'chown'): - if 'pwd' in globals() and isinstance(uid, string_types): - uid = pwd.getpwnam(uid).pw_uid - if 'grp' in globals() and isinstance(gid, string_types): - gid = grp.getgrnam(gid).gr_gid - os.chown(self, uid, gid) - else: - raise NotImplementedError("Ownership not available on this platform.") - return self - - def rename(self, new): - """ .. seealso:: :func:`os.rename` """ - os.rename(self, new) - return self._next_class(new) - - def renames(self, new): - """ .. seealso:: :func:`os.renames` """ - os.renames(self, new) - return self._next_class(new) - - # - # --- Create/delete operations on directories - - def mkdir(self, mode=0o777): - """ .. seealso:: :func:`os.mkdir` """ - os.mkdir(self, mode) - return self - - def mkdir_p(self, mode=0o777): - """ Like :meth:`mkdir`, but does not raise an exception if the - directory already exists. """ - try: - self.mkdir(mode) - except OSError: - _, e, _ = sys.exc_info() - if e.errno != errno.EEXIST: - raise - return self - - def makedirs(self, mode=0o777): - """ .. seealso:: :func:`os.makedirs` """ - os.makedirs(self, mode) - return self - - def makedirs_p(self, mode=0o777): - """ Like :meth:`makedirs`, but does not raise an exception if the - directory already exists. """ - try: - self.makedirs(mode) - except OSError: - _, e, _ = sys.exc_info() - if e.errno != errno.EEXIST: - raise - return self - - def rmdir(self): - """ .. seealso:: :func:`os.rmdir` """ - os.rmdir(self) - return self - - def rmdir_p(self): - """ Like :meth:`rmdir`, but does not raise an exception if the - directory is not empty or does not exist. """ - try: - self.rmdir() - except OSError: - _, e, _ = sys.exc_info() - if e.errno != errno.ENOTEMPTY and e.errno != errno.EEXIST: - raise - return self - - def removedirs(self): - """ .. seealso:: :func:`os.removedirs` """ - os.removedirs(self) - return self - - def removedirs_p(self): - """ Like :meth:`removedirs`, but does not raise an exception if the - directory is not empty or does not exist. """ - try: - self.removedirs() - except OSError: - _, e, _ = sys.exc_info() - if e.errno != errno.ENOTEMPTY and e.errno != errno.EEXIST: - raise - return self - - # --- Modifying operations on files - - def touch(self): - """ Set the access/modified times of this file to the current time. - Create the file if it does not exist. - """ - fd = os.open(self, os.O_WRONLY | os.O_CREAT, 0o666) - os.close(fd) - os.utime(self, None) - return self - - def remove(self): - """ .. seealso:: :func:`os.remove` """ - os.remove(self) - return self - - def remove_p(self): - """ Like :meth:`remove`, but does not raise an exception if the - file does not exist. """ - try: - self.unlink() - except OSError: - _, e, _ = sys.exc_info() - if e.errno != errno.ENOENT: - raise - return self - - def unlink(self): - """ .. seealso:: :func:`os.unlink` """ - os.unlink(self) - return self - - def unlink_p(self): - """ Like :meth:`unlink`, but does not raise an exception if the - file does not exist. """ - self.remove_p() - return self - - # --- Links - - if hasattr(os, 'link'): - def link(self, newpath): - """ Create a hard link at `newpath`, pointing to this file. - - .. seealso:: :func:`os.link` - """ - os.link(self, newpath) - return self._next_class(newpath) - - if hasattr(os, 'symlink'): - def symlink(self, newlink): - """ Create a symbolic link at `newlink`, pointing here. - - .. seealso:: :func:`os.symlink` - """ - os.symlink(self, newlink) - return self._next_class(newlink) - - if hasattr(os, 'readlink'): - def readlink(self): - """ Return the path to which this symbolic link points. - - The result may be an absolute or a relative path. - - .. seealso:: :meth:`readlinkabs`, :func:`os.readlink` - """ - return self._next_class(os.readlink(self)) - - def readlinkabs(self): - """ Return the path to which this symbolic link points. - - The result is always an absolute path. - - .. seealso:: :meth:`readlink`, :func:`os.readlink` - """ - p = self.readlink() - if p.isabs(): - return p - else: - return (self.parent / p).abspath() - - # High-level functions from shutil - # These functions will be bound to the instance such that - # Path(name).copy(target) will invoke shutil.copy(name, target) - - copyfile = shutil.copyfile - copymode = shutil.copymode - copystat = shutil.copystat - copy = shutil.copy - copy2 = shutil.copy2 - copytree = shutil.copytree - if hasattr(shutil, 'move'): - move = shutil.move - rmtree = shutil.rmtree - - def rmtree_p(self): - """ Like :meth:`rmtree`, but does not raise an exception if the - directory does not exist. """ - try: - self.rmtree() - except OSError: - _, e, _ = sys.exc_info() - if e.errno != errno.ENOENT: - raise - return self - - def chdir(self): - """ .. seealso:: :func:`os.chdir` """ - os.chdir(self) - - cd = chdir - - def merge_tree(self, dst, symlinks=False, *args, **kwargs): - """ - Copy entire contents of self to dst, overwriting existing - contents in dst with those in self. - - If the additional keyword `update` is True, each - `src` will only be copied if `dst` does not exist, - or `src` is newer than `dst`. - - Note that the technique employed stages the files in a temporary - directory first, so this function is not suitable for merging - trees with large files, especially if the temporary directory - is not capable of storing a copy of the entire source tree. - """ - update = kwargs.pop('update', False) - with tempdir() as _temp_dir: - # first copy the tree to a stage directory to support - # the parameters and behavior of copytree. - stage = _temp_dir / str(hash(self)) - self.copytree(stage, symlinks, *args, **kwargs) - # now copy everything from the stage directory using - # the semantics of dir_util.copy_tree - dir_util.copy_tree(stage, dst, preserve_symlinks=symlinks, - update=update) - - # - # --- Special stuff from os - - if hasattr(os, 'chroot'): - def chroot(self): - """ .. seealso:: :func:`os.chroot` """ - os.chroot(self) - - if hasattr(os, 'startfile'): - def startfile(self): - """ .. seealso:: :func:`os.startfile` """ - os.startfile(self) - return self - - # in-place re-writing, courtesy of Martijn Pieters - # http://www.zopatista.com/python/2013/11/26/inplace-file-rewriting/ - @contextlib.contextmanager - def in_place(self, mode='r', buffering=-1, encoding=None, errors=None, - newline=None, backup_extension=None): - """ - A context in which a file may be re-written in-place with new content. - - Yields a tuple of :samp:`({readable}, {writable})` file objects, where `writable` - replaces `readable`. - - If an exception occurs, the old file is restored, removing the - written data. - - Mode *must not* use ``'w'``, ``'a'``, or ``'+'``; only read-only-modes are - allowed. A :exc:`ValueError` is raised on invalid modes. - - For example, to add line numbers to a file:: - - p = Path(filename) - assert p.isfile() - with p.in_place() as (reader, writer): - for number, line in enumerate(reader, 1): - writer.write('{0:3}: '.format(number))) - writer.write(line) - - Thereafter, the file at `filename` will have line numbers in it. - """ - import io - - if set(mode).intersection('wa+'): - raise ValueError('Only read-only file modes can be used') - - # move existing file to backup, create new file with same permissions - # borrowed extensively from the fileinput module - backup_fn = self + (backup_extension or os.extsep + 'bak') - try: - os.unlink(backup_fn) - except os.error: - pass - os.rename(self, backup_fn) - readable = io.open(backup_fn, mode, buffering=buffering, - encoding=encoding, errors=errors, newline=newline) - try: - perm = os.fstat(readable.fileno()).st_mode - except OSError: - writable = open(self, 'w' + mode.replace('r', ''), - buffering=buffering, encoding=encoding, errors=errors, - newline=newline) - else: - os_mode = os.O_CREAT | os.O_WRONLY | os.O_TRUNC - if hasattr(os, 'O_BINARY'): - os_mode |= os.O_BINARY - fd = os.open(self, os_mode, perm) - writable = io.open(fd, "w" + mode.replace('r', ''), - buffering=buffering, encoding=encoding, errors=errors, - newline=newline) - try: - if hasattr(os, 'chmod'): - os.chmod(self, perm) - except OSError: - pass - try: - yield readable, writable - except Exception: - # move backup back - readable.close() - writable.close() - try: - os.unlink(self) - except os.error: - pass - os.rename(backup_fn, self) - raise - else: - readable.close() - writable.close() - finally: - try: - os.unlink(backup_fn) - except os.error: - pass - - @ClassProperty - @classmethod - def special(cls): - """ - Return a SpecialResolver object suitable referencing a suitable - directory for the relevant platform for the given - type of content. - - For example, to get a user config directory, invoke: - - dir = Path.special().user.config - - Uses the `appdirs - `_ to resolve - the paths in a platform-friendly way. - - To create a config directory for 'My App', consider: - - dir = Path.special("My App").user.config.makedirs_p() - - If the ``appdirs`` module is not installed, invocation - of special will raise an ImportError. - """ - return functools.partial(SpecialResolver, cls) - - -class SpecialResolver(object): - class ResolverScope: - def __init__(self, paths, scope): - self.paths = paths - self.scope = scope - - def __getattr__(self, class_): - return self.paths.get_dir(self.scope, class_) - - def __init__(self, path_class, *args, **kwargs): - appdirs = importlib.import_module('appdirs') - - # let appname default to None until - # https://github.com/ActiveState/appdirs/issues/55 is solved. - not args and kwargs.setdefault('appname', None) - - vars(self).update( - path_class=path_class, - wrapper=appdirs.AppDirs(*args, **kwargs), - ) - - def __getattr__(self, scope): - return self.ResolverScope(self, scope) - - def get_dir(self, scope, class_): - """ - Return the callable function from appdirs, but with the - result wrapped in self.path_class - """ - prop_name = '{scope}_{class_}_dir'.format(**locals()) - value = getattr(self.wrapper, prop_name) - MultiPath = Multi.for_class(self.path_class) - return MultiPath.detect(value) - - -class Multi: - """ - A mix-in for a Path which may contain multiple Path separated by pathsep. - """ - @classmethod - def for_class(cls, path_cls): - name = 'Multi' + path_cls.__name__ - if PY2: - name = str(name) - return type(name, (cls, path_cls), {}) - - @classmethod - def detect(cls, input): - if os.pathsep not in input: - cls = cls._next_class - return cls(input) - - def __iter__(self): - return iter(map(self._next_class, self.split(os.pathsep))) - - @ClassProperty - @classmethod - def _next_class(cls): - """ - Multi-subclasses should use the parent class - """ - return next( - class_ - for class_ in cls.__mro__ - if not issubclass(class_, Multi) - ) - - -class tempdir(Path): - """ - A temporary directory via :func:`tempfile.mkdtemp`, and constructed with the - same parameters that you can use as a context manager. - - Example: - - with tempdir() as d: - # do stuff with the Path object "d" - - # here the directory is deleted automatically - - .. seealso:: :func:`tempfile.mkdtemp` - """ - - @ClassProperty - @classmethod - def _next_class(cls): - return Path - - def __new__(cls, *args, **kwargs): - dirname = tempfile.mkdtemp(*args, **kwargs) - return super(tempdir, cls).__new__(cls, dirname) - - def __init__(self, *args, **kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - if not exc_value: - self.rmtree() - - -def _multi_permission_mask(mode): - """ - Support multiple, comma-separated Unix chmod symbolic modes. - - >>> _multi_permission_mask('a=r,u+w')(0) == 0o644 - True - """ - compose = lambda f, g: lambda *args, **kwargs: g(f(*args, **kwargs)) - return functools.reduce(compose, map(_permission_mask, mode.split(','))) - - -def _permission_mask(mode): - """ - Convert a Unix chmod symbolic mode like ``'ugo+rwx'`` to a function - suitable for applying to a mask to affect that change. - - >>> mask = _permission_mask('ugo+rwx') - >>> mask(0o554) == 0o777 - True - - >>> _permission_mask('go-x')(0o777) == 0o766 - True - - >>> _permission_mask('o-x')(0o445) == 0o444 - True - - >>> _permission_mask('a+x')(0) == 0o111 - True - - >>> _permission_mask('a=rw')(0o057) == 0o666 - True - - >>> _permission_mask('u=x')(0o666) == 0o166 - True - - >>> _permission_mask('g=')(0o157) == 0o107 - True - """ - # parse the symbolic mode - parsed = re.match('(?P[ugoa]+)(?P[-+=])(?P[rwx]*)$', mode) - if not parsed: - raise ValueError("Unrecognized symbolic mode", mode) - - # generate a mask representing the specified permission - spec_map = dict(r=4, w=2, x=1) - specs = (spec_map[perm] for perm in parsed.group('what')) - spec = functools.reduce(operator.or_, specs, 0) - - # now apply spec to each subject in who - shift_map = dict(u=6, g=3, o=0) - who = parsed.group('who').replace('a', 'ugo') - masks = (spec << shift_map[subj] for subj in who) - mask = functools.reduce(operator.or_, masks) - - op = parsed.group('op') - - # if op is -, invert the mask - if op == '-': - mask ^= 0o777 - - # if op is =, retain extant values for unreferenced subjects - if op == '=': - masks = (0o7 << shift_map[subj] for subj in who) - retain = functools.reduce(operator.or_, masks) ^ 0o777 - - op_map = { - '+': operator.or_, - '-': operator.and_, - '=': lambda mask, target: target & retain ^ mask, - } - return functools.partial(op_map[op], mask) - - -class CaseInsensitivePattern(text_type): - """ - A string with a ``'normcase'`` property, suitable for passing to - :meth:`listdir`, :meth:`dirs`, :meth:`files`, :meth:`walk`, - :meth:`walkdirs`, or :meth:`walkfiles` to match case-insensitive. - - For example, to get all files ending in .py, .Py, .pY, or .PY in the - current directory:: - - from path import Path, CaseInsensitivePattern as ci - Path('.').files(ci('*.py')) - """ - - @property - def normcase(self): - return __import__('ntpath').normcase - -######################## -# Backward-compatibility -class path(Path): - def __new__(cls, *args, **kwargs): - msg = "path is deprecated. Use Path instead." - warnings.warn(msg, DeprecationWarning) - return Path.__new__(cls, *args, **kwargs) - - -__all__ += ['path'] -######################## diff --git a/path/__init__.py b/path/__init__.py new file mode 100644 index 00000000..33dd979c --- /dev/null +++ b/path/__init__.py @@ -0,0 +1,2034 @@ +""" +Path Pie + +Implements ``path.Path`` - An object representing a +path to a file or directory. + +Example:: + + from path import Path + d = Path('/home/guido/bin') + + # Globbing + for f in d.files('*.py'): + f.chmod(0o755) + + # Changing the working directory: + with Path("somewhere"): + # cwd in now `somewhere` + ... + + # Concatenate paths with / + foo_txt = Path("bar") / "foo.txt" +""" + +from __future__ import annotations + +import builtins +import contextlib +import datetime +import errno +import fnmatch +import functools +import glob +import hashlib +import importlib +import itertools +import os +import pathlib +import re +import shutil +import sys +import tempfile +import warnings +from io import ( + BufferedRandom, + BufferedReader, + BufferedWriter, + FileIO, + TextIOWrapper, +) +from types import ModuleType + +with contextlib.suppress(ImportError): + import win32security + +with contextlib.suppress(ImportError): + import pwd + +with contextlib.suppress(ImportError): + import grp + +from collections.abc import Generator, Iterable, Iterator +from typing import ( + IO, + TYPE_CHECKING, + Any, + BinaryIO, + Callable, + overload, +) + +if TYPE_CHECKING: + from typing import Union + + from _typeshed import ( + ExcInfo, + OpenBinaryMode, + OpenBinaryModeReading, + OpenBinaryModeUpdating, + OpenBinaryModeWriting, + OpenTextMode, + ) + from typing_extensions import Literal, Never, Self + + _Match = Union[str, Callable[[str], bool], None] + _CopyFn = Callable[[str, str], object] + _IgnoreFn = Callable[[str, list[str]], Iterable[str]] + _OnErrorCallback = Callable[[Callable[..., Any], str, ExcInfo], object] + _OnExcCallback = Callable[[Callable[..., Any], str, BaseException], object] + + +from . import classes, masks, matchers +from .compat.py38 import removeprefix, removesuffix + +__all__ = ['Path', 'TempDir', 'Traversal'] + +LINESEPS = ['\r\n', '\r', '\n'] +U_LINESEPS = LINESEPS + ['\u0085', '\u2028', '\u2029'] +B_NEWLINE = re.compile('|'.join(LINESEPS).encode()) +U_NEWLINE = re.compile('|'.join(U_LINESEPS)) +B_NL_END = re.compile(B_NEWLINE.pattern + b'$') +U_NL_END = re.compile(U_NEWLINE.pattern + '$') + +_default_linesep = object() + + +def _make_timestamp_ns(value: float | datetime.datetime) -> int: + timestamp_s = value if isinstance(value, (float, int)) else value.timestamp() + return int(timestamp_s * 10**9) + + +class TreeWalkWarning(Warning): + pass + + +class Traversal: + """ + Wrap a walk result to customize the traversal. + + `follow` is a function that takes an item and returns + True if that item should be followed and False otherwise. + + For example, to avoid traversing into directories that + begin with `.`: + + >>> traverse = Traversal(lambda dir: not dir.startswith('.')) + >>> items = list(traverse(Path('.').walk())) + + Directories beginning with `.` will appear in the results, but + their children will not. + + >>> dot_dir = next(item for item in items if item.is_dir() and item.startswith('.')) + >>> any(item.parent == dot_dir for item in items) + False + """ + + def __init__(self, follow: Callable[[Path], bool]): + self.follow = follow + + def __call__( + self, walker: Generator[Path, Callable[[], bool] | None, None] + ) -> Iterator[Path]: + traverse = None + while True: + try: + item = walker.send(traverse) + except StopIteration: + return + yield item + + traverse = functools.partial(self.follow, item) + + +def _strip_newlines(lines: Iterable[str]) -> Iterator[str]: + r""" + >>> list(_strip_newlines(['Hello World\r\n', 'foo'])) + ['Hello World', 'foo'] + """ + return (U_NL_END.sub('', line) for line in lines) + + +class Path(str): + """ + Represents a filesystem path. + + For documentation on individual methods, consult their + counterparts in :mod:`os.path`. + + Some methods are additionally included from :mod:`shutil`. + The functions are linked directly into the class namespace + such that they will be bound to the Path instance. For example, + ``Path(src).copy(target)`` is equivalent to + ``shutil.copy(src, target)``. Therefore, when referencing + the docs for these methods, assume `src` references `self`, + the Path instance. + """ + + module: ModuleType = os.path + """ The path module to use for path operations. + + .. seealso:: :mod:`os.path` + """ + + def __new__(cls, other: Any = '.') -> Self: + return super().__new__(cls, other) + + def __init__(self, other: Any = '.') -> None: + if other is None: + raise TypeError("Invalid initial value for path: None") + self._validate() + + def _validate(self) -> None: + pass + + @classmethod + @functools.lru_cache + def using_module(cls, module: ModuleType) -> type[Self]: + subclass_name = cls.__name__ + '_' + module.__name__ + bases = (cls,) + ns = {'module': module} + return type(subclass_name, bases, ns) + + @classes.ClassProperty + @classmethod + def _next_class(cls) -> type[Self]: + """ + What class should be used to construct new instances from this class + """ + return cls + + # --- Special Python methods. + + def __repr__(self) -> str: + return f'{type(self).__name__}({super().__repr__()})' + + # Adding a Path and a string yields a Path. + def __add__(self, more: str) -> Self: + return self._next_class(super().__add__(more)) + + def __radd__(self, other: str) -> Self: + return self._next_class(other.__add__(self)) + + # The / operator joins Paths. + def __truediv__(self, rel: str) -> Self: + """fp.__truediv__(rel) == fp / rel == fp.joinpath(rel) + + Join two path components, adding a separator character if + needed. + + .. seealso:: :func:`os.path.join` + """ + return self._next_class(self.module.join(self, rel)) + + # The / operator joins Paths the other way around + def __rtruediv__(self, rel: str) -> Self: + """fp.__rtruediv__(rel) == rel / fp + + Join two path components, adding a separator character if + needed. + + .. seealso:: :func:`os.path.join` + """ + return self._next_class(self.module.join(rel, self)) + + def __enter__(self) -> Self: + self._old_dir = self.cwd() + os.chdir(self) + return self + + def __exit__(self, *_) -> None: + os.chdir(self._old_dir) + + @classmethod + def cwd(cls): + """Return the current working directory as a path object. + + .. seealso:: :func:`os.getcwd` + """ + return cls(os.getcwd()) + + @classmethod + def home(cls) -> Path: + return cls(os.path.expanduser('~')) + + # + # --- Operations on Path strings. + + def absolute(self) -> Self: + """.. seealso:: :func:`os.path.abspath`""" + return self._next_class(self.module.abspath(self)) + + def normcase(self) -> Self: + """.. seealso:: :func:`os.path.normcase`""" + return self._next_class(self.module.normcase(self)) + + def normpath(self) -> Self: + """.. seealso:: :func:`os.path.normpath`""" + return self._next_class(self.module.normpath(self)) + + def realpath(self) -> Self: + """.. seealso:: :func:`os.path.realpath`""" + return self._next_class(self.module.realpath(self)) + + def expanduser(self) -> Self: + """.. seealso:: :func:`os.path.expanduser`""" + return self._next_class(self.module.expanduser(self)) + + def expandvars(self) -> Self: + """.. seealso:: :func:`os.path.expandvars`""" + return self._next_class(self.module.expandvars(self)) + + def dirname(self) -> Self: + """.. seealso:: :attr:`parent`, :func:`os.path.dirname`""" + return self._next_class(self.module.dirname(self)) + + def basename(self) -> Self: + """.. seealso:: :attr:`name`, :func:`os.path.basename`""" + return self._next_class(self.module.basename(self)) + + def expand(self) -> Self: + """Clean up a filename by calling :meth:`expandvars()`, + :meth:`expanduser()`, and :meth:`normpath()` on it. + + This is commonly everything needed to clean up a filename + read from a configuration file, for example. + """ + return self.expandvars().expanduser().normpath() + + @property + def stem(self) -> str: + """The same as :meth:`name`, but with one file extension stripped off. + + >>> Path('/home/guido/python.tar.gz').stem + 'python.tar' + """ + base, _ = self.module.splitext(self.name) + return base + + def with_stem(self, stem: str) -> Self: + """Return a new path with the stem changed. + + >>> Path('/home/guido/python.tar.gz').with_stem("foo") + Path('/home/guido/foo.gz') + """ + return self.with_name(stem + self.suffix) + + @property + def suffix(self) -> Self: + """The file extension, for example ``'.py'``.""" + _, suffix = self.module.splitext(self) + return suffix + + def with_suffix(self, suffix: str) -> Self: + """Return a new path with the file suffix changed (or added, if none) + + >>> Path('/home/guido/python.tar.gz').with_suffix(".foo") + Path('/home/guido/python.tar.foo') + + >>> Path('python').with_suffix('.zip') + Path('python.zip') + + >>> Path('filename.ext').with_suffix('zip') + Traceback (most recent call last): + ... + ValueError: Invalid suffix 'zip' + """ + if not suffix.startswith('.'): + raise ValueError(f"Invalid suffix {suffix!r}") + return self.stripext() + suffix + + @property + def drive(self) -> Self: + """The drive specifier, for example ``'C:'``. + + This is always empty on systems that don't use drive specifiers. + """ + drive, _ = self.module.splitdrive(self) + return self._next_class(drive) + + @property + def parent(self) -> Self: + """This path's parent directory, as a new Path object. + + For example, + ``Path('/usr/local/lib/libpython.so').parent == + Path('/usr/local/lib')`` + + .. seealso:: :meth:`dirname`, :func:`os.path.dirname` + """ + return self.dirname() + + @property + def name(self) -> Self: + """The name of this file or directory without the full path. + + For example, + ``Path('/usr/local/lib/libpython.so').name == 'libpython.so'`` + + .. seealso:: :meth:`basename`, :func:`os.path.basename` + """ + return self.basename() + + def with_name(self, name: str) -> Self: + """Return a new path with the name changed. + + >>> Path('/home/guido/python.tar.gz').with_name("foo.zip") + Path('/home/guido/foo.zip') + """ + return self._next_class(removesuffix(self, self.name) + name) + + def splitpath(self) -> tuple[Self, str]: + """Return two-tuple of ``.parent``, ``.name``. + + .. seealso:: :attr:`parent`, :attr:`name`, :func:`os.path.split` + """ + parent, child = self.module.split(self) + return self._next_class(parent), child + + def splitdrive(self) -> tuple[Self, Self]: + """Return two-tuple of ``.drive`` and rest without drive. + + Split the drive specifier from this path. If there is + no drive specifier, :samp:`{p.drive}` is empty, so the return value + is simply ``(Path(''), p)``. This is always the case on Unix. + + .. seealso:: :func:`os.path.splitdrive` + """ + drive, rel = self.module.splitdrive(self) + return self._next_class(drive), self._next_class(rel) + + def splitext(self) -> tuple[Self, str]: + """Return two-tuple of ``.stripext()`` and ``.ext``. + + Split the filename extension from this path and return + the two parts. Either part may be empty. + + The extension is everything from ``'.'`` to the end of the + last path segment. This has the property that if + ``(a, b) == p.splitext()``, then ``a + b == p``. + + .. seealso:: :func:`os.path.splitext` + """ + filename, ext = self.module.splitext(self) + return self._next_class(filename), ext + + def stripext(self) -> Self: + """Remove one file extension from the path. + + For example, ``Path('/home/guido/python.tar.gz').stripext()`` + returns ``Path('/home/guido/python.tar')``. + """ + return self.splitext()[0] + + @classes.multimethod + def joinpath(cls, first: str, *others: str) -> Self: + """ + Join first to zero or more :class:`Path` components, + adding a separator character (:samp:`{first}.module.sep`) + if needed. Returns a new instance of + :samp:`{first}._next_class`. + + .. seealso:: :func:`os.path.join` + """ + return cls._next_class(cls.module.join(first, *others)) + + def splitall(self) -> list[Self | str]: + r"""Return a list of the path components in this path. + + The first item in the list will be a Path. Its value will be + either :data:`os.curdir`, :data:`os.pardir`, empty, or the root + directory of this path (for example, ``'/'`` or ``'C:\\'``). The + other items in the list will be strings. + + ``Path.joinpath(*result)`` will yield the original path. + + >>> Path('/foo/bar/baz').splitall() + [Path('/'), 'foo', 'bar', 'baz'] + """ + return list(self._parts()) + + def parts(self) -> tuple[Self | str, ...]: + """ + >>> Path('/foo/bar/baz').parts() + (Path('/'), 'foo', 'bar', 'baz') + """ + return tuple(self._parts()) + + def _parts(self) -> Iterator[Self | str]: + return reversed(tuple(self._parts_iter())) + + def _parts_iter(self) -> Iterator[Self | str]: + loc = self + while loc != os.curdir and loc != os.pardir: + prev = loc + loc, child = prev.splitpath() + if loc == prev: + break + yield child + yield loc + + def relpath(self, start: str = '.') -> Self: + """Return this path as a relative path, + based from `start`, which defaults to the current working directory. + """ + cwd = self._next_class(start) + return cwd.relpathto(self) + + def relpathto(self, dest: str) -> Self: + """Return a relative path from `self` to `dest`. + + If there is no relative path from `self` to `dest`, for example if + they reside on different drives in Windows, then this returns + ``dest.absolute()``. + """ + origin = self.absolute() + dest_path = self._next_class(dest).absolute() + + orig_list = origin.normcase().splitall() + # Don't normcase dest! We want to preserve the case. + dest_list = dest_path.splitall() + + if orig_list[0] != self.module.normcase(dest_list[0]): + # Can't get here from there. + return dest_path + + # Find the location where the two paths start to differ. + i = 0 + for start_seg, dest_seg in zip(orig_list, dest_list): + if start_seg != self.module.normcase(dest_seg): + break + i += 1 + + # Now i is the point where the two paths diverge. + # Need a certain number of "os.pardir"s to work up + # from the origin to the point of divergence. + segments = [os.pardir] * (len(orig_list) - i) + # Need to add the diverging part of dest_list. + segments += dest_list[i:] + if len(segments) == 0: + # If they happen to be identical, use os.curdir. + relpath = os.curdir + else: + relpath = self.module.join(*segments) + return self._next_class(relpath) + + # --- Listing, searching, walking, and matching + + def iterdir(self, match: _Match = None) -> Iterator[Self]: + """Yields items in this directory. + + Use :meth:`files` or :meth:`dirs` instead if you want a listing + of just files or just subdirectories. + + The elements of the list are Path objects. + + With the optional `match` argument, a callable, + only return items whose names match the given pattern. + + .. seealso:: :meth:`files`, :meth:`dirs` + """ + match = matchers.load(match) + return filter(match, (self / child for child in os.listdir(self))) + + def dirs(self, match: _Match = None) -> list[Self]: + """List of this directory's subdirectories. + + The elements of the list are Path objects. + This does not walk recursively into subdirectories + (but see :meth:`walkdirs`). + + Accepts parameters to :meth:`iterdir`. + """ + return [p for p in self.iterdir(match) if p.is_dir()] + + def files(self, match: _Match = None) -> list[Self]: + """List of the files in self. + + The elements of the list are Path objects. + This does not walk into subdirectories (see :meth:`walkfiles`). + + Accepts parameters to :meth:`iterdir`. + """ + + return [p for p in self.iterdir(match) if p.is_file()] + + def walk( + self, match: _Match = None, errors: str = 'strict' + ) -> Generator[Self, Callable[[], bool] | None, None]: + """Iterator over files and subdirs, recursively. + + The iterator yields Path objects naming each child item of + this directory and its descendants. This requires that + ``D.is_dir()``. + + This performs a depth-first traversal of the directory tree. + Each directory is returned just before all its children. + + The `errors=` keyword argument controls behavior when an + error occurs. The default is ``'strict'``, which causes an + exception. Other allowed values are ``'warn'`` (which + reports the error via :func:`warnings.warn()`), and ``'ignore'``. + `errors` may also be an arbitrary callable taking a msg parameter. + """ + + error_fn = Handlers._resolve(errors) + match = matchers.load(match) + + try: + childList = self.iterdir() + except Exception as exc: + error_fn(f"Unable to list directory '{self}': {exc}") + return + + for child in childList: + traverse = None + if match(child): + traverse = yield child + traverse = traverse or child.is_dir + try: + do_traverse = traverse() + except Exception as exc: + error_fn(f"Unable to access '{child}': {exc}") + continue + + if do_traverse: + yield from child.walk(errors=error_fn, match=match) # type: ignore[arg-type] + + def walkdirs(self, match: _Match = None, errors: str = 'strict') -> Iterator[Self]: + """Iterator over subdirs, recursively.""" + return (item for item in self.walk(match, errors) if item.is_dir()) + + def walkfiles(self, match: _Match = None, errors: str = 'strict') -> Iterator[Self]: + """Iterator over files, recursively.""" + return (item for item in self.walk(match, errors) if item.is_file()) + + def fnmatch( + self, pattern: str, normcase: Callable[[str], str] | None = None + ) -> bool: + """Return ``True`` if `self.name` matches the given `pattern`. + + `pattern` - A filename pattern with wildcards, + for example ``'*.py'``. If the pattern contains a `normcase` + attribute, it is applied to the name and path prior to comparison. + + `normcase` - (optional) A function used to normalize the pattern and + filename before matching. Defaults to normcase from + ``self.module``, :func:`os.path.normcase`. + + .. seealso:: :func:`fnmatch.fnmatch` + """ + default_normcase = getattr(pattern, 'normcase', self.module.normcase) + normcase = normcase or default_normcase + name = normcase(self.name) + pattern = normcase(pattern) + return fnmatch.fnmatchcase(name, pattern) + + def glob(self, pattern: str) -> list[Self]: + """Return a list of Path objects that match the pattern. + + `pattern` - a path relative to this directory, with wildcards. + + For example, ``Path('/users').glob('*/bin/*')`` returns a list + of all the files users have in their :file:`bin` directories. + + .. seealso:: :func:`glob.glob` + + .. note:: Glob is **not** recursive, even when using ``**``. + To do recursive globbing see :func:`walk`, + :func:`walkdirs` or :func:`walkfiles`. + """ + cls = self._next_class + return [cls(s) for s in glob.glob(self / pattern)] + + def iglob(self, pattern: str) -> Iterator[Self]: + """Return an iterator of Path objects that match the pattern. + + `pattern` - a path relative to this directory, with wildcards. + + For example, ``Path('/users').iglob('*/bin/*')`` returns an + iterator of all the files users have in their :file:`bin` + directories. + + .. seealso:: :func:`glob.iglob` + + .. note:: Glob is **not** recursive, even when using ``**``. + To do recursive globbing see :func:`walk`, + :func:`walkdirs` or :func:`walkfiles`. + """ + cls = self._next_class + return (cls(s) for s in glob.iglob(self / pattern)) + + # + # --- Reading or writing an entire file at once. + + @overload + def open( + self, + mode: OpenTextMode = ..., + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> TextIOWrapper: ... + @overload + def open( + self, + mode: OpenBinaryMode, + buffering: Literal[0], + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> FileIO: ... + @overload + def open( + self, + mode: OpenBinaryModeUpdating, + buffering: Literal[-1, 1] = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BufferedRandom: ... + @overload + def open( + self, + mode: OpenBinaryModeWriting, + buffering: Literal[-1, 1] = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BufferedWriter: ... + @overload + def open( + self, + mode: OpenBinaryModeReading, + buffering: Literal[-1, 1] = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BufferedReader: ... + @overload + def open( + self, + mode: OpenBinaryMode, + buffering: int = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BinaryIO: ... + @overload + def open( + self, + mode: str, + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> IO[Any]: ... + def open(self, *args, **kwargs): + """Open this file and return a corresponding file object. + + Keyword arguments work as in :func:`io.open`. If the file cannot be + opened, an :class:`OSError` is raised. + """ + return open(self, *args, **kwargs) + + def bytes(self) -> builtins.bytes: + """Open this file, read all bytes, return them as a string.""" + with self.open('rb') as f: + return f.read() + + @overload + def chunks( + self, + size: int, + mode: OpenTextMode = ..., + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = ..., + opener: Callable[[str, int], int] | None = ..., + ) -> Iterator[str]: ... + @overload + def chunks( + self, + size: int, + mode: OpenBinaryMode, + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = ..., + opener: Callable[[str, int], int] | None = ..., + ) -> Iterator[builtins.bytes]: ... + @overload + def chunks( + self, + size: int, + mode: str, + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = ..., + opener: Callable[[str, int], int] | None = ..., + ) -> Iterator[str | builtins.bytes]: ... + def chunks(self, size, *args, **kwargs): + """Returns a generator yielding chunks of the file, so it can + be read piece by piece with a simple for loop. + + Any argument you pass after `size` will be passed to :meth:`open`. + + :example: + + >>> hash = hashlib.md5() + >>> for chunk in Path("NEWS.rst").chunks(8192, mode='rb'): + ... hash.update(chunk) + + This will read the file by chunks of 8192 bytes. + """ + with self.open(*args, **kwargs) as f: + yield from iter(lambda: f.read(size) or None, None) + + def write_bytes(self, bytes: builtins.bytes, append: bool = False) -> None: + """Open this file and write the given bytes to it. + + Default behavior is to overwrite any existing file. + Call ``p.write_bytes(bytes, append=True)`` to append instead. + """ + with self.open('ab' if append else 'wb') as f: + f.write(bytes) + + def read_text(self, encoding: str | None = None, errors: str | None = None) -> str: + r"""Open this file, read it in, return the content as a string. + + Optional parameters are passed to :meth:`open`. + + .. seealso:: :meth:`lines` + """ + with self.open(encoding=encoding, errors=errors) as f: + return f.read() + + def read_bytes(self) -> builtins.bytes: + r"""Return the contents of this file as bytes.""" + with self.open(mode='rb') as f: + return f.read() + + def write_text( + self, + text: str, + encoding: str | None = None, + errors: str = 'strict', + linesep: str | None = os.linesep, + append: bool = False, + ) -> None: + r"""Write the given text to this file. + + The default behavior is to overwrite any existing file; + to append instead, use the `append=True` keyword argument. + + There are two differences between :meth:`write_text` and + :meth:`write_bytes`: newline handling and Unicode handling. + See below. + + Parameters: + + `text` - str - The text to be written. + + `encoding` - str - The text encoding used. + + `errors` - str - How to handle Unicode encoding errors. + Default is ``'strict'``. See ``help(unicode.encode)`` for the + options. Ignored if `text` isn't a Unicode string. + + `linesep` - keyword argument - str/unicode - The sequence of + characters to be used to mark end-of-line. The default is + :data:`os.linesep`. Specify ``None`` to + use newlines unmodified. + + `append` - keyword argument - bool - Specifies what to do if + the file already exists (``True``: append to the end of it; + ``False``: overwrite it). The default is ``False``. + + + --- Newline handling. + + ``write_text()`` converts all standard end-of-line sequences + (``'\n'``, ``'\r'``, and ``'\r\n'``) to your platform's default + end-of-line sequence (see :data:`os.linesep`; on Windows, for example, + the end-of-line marker is ``'\r\n'``). + + To override the platform's default, pass the `linesep=` + keyword argument. To preserve the newlines as-is, pass + ``linesep=None``. + + This handling applies to Unicode text and bytes, except + with Unicode, additional non-ASCII newlines are recognized: + ``\x85``, ``\r\x85``, and ``\u2028``. + + --- Unicode + + `text` is written using the + specified `encoding` (or the default encoding if `encoding` + isn't specified). The `errors` argument applies only to this + conversion. + """ + if linesep is not None: + text = U_NEWLINE.sub(linesep, text) + bytes = text.encode(encoding or sys.getdefaultencoding(), errors) + self.write_bytes(bytes, append=append) + + def lines( + self, + encoding: str | None = None, + errors: str | None = None, + retain: bool = True, + ) -> list[str]: + r"""Open this file, read all lines, return them in a list. + + Optional arguments: + `encoding` - The Unicode encoding (or character set) of + the file. The default is ``None``, meaning use + ``locale.getpreferredencoding()``. + `errors` - How to handle Unicode errors; see + `open `_ + for the options. Default is ``None`` meaning "strict". + `retain` - If ``True`` (default), retain newline characters, + but translate all newline + characters to ``\n``. If ``False``, newline characters are + omitted. + """ + text = U_NEWLINE.sub('\n', self.read_text(encoding, errors)) + return text.splitlines(retain) + + def write_lines( + self, + lines: list[str], + encoding: str | None = None, + errors: str = 'strict', + *, + append: bool = False, + ): + r"""Write the given lines of text to this file. + + By default this overwrites any existing file at this path. + + Puts a platform-specific newline sequence on every line. + + `lines` - A list of strings. + + `encoding` - A Unicode encoding to use. This applies only if + `lines` contains any Unicode strings. + + `errors` - How to handle errors in Unicode encoding. This + also applies only to Unicode strings. + + Use the keyword argument ``append=True`` to append lines to the + file. The default is to overwrite the file. + """ + mode = 'a' if append else 'w' + with self.open(mode, encoding=encoding, errors=errors, newline='') as f: + f.writelines(self._replace_linesep(lines)) + + @staticmethod + def _replace_linesep(lines: Iterable[str]) -> Iterator[str]: + return (line + os.linesep for line in _strip_newlines(lines)) + + def read_md5(self) -> builtins.bytes: + """Calculate the md5 hash for this file. + + This reads through the entire file. + + .. seealso:: :meth:`read_hash` + """ + return self.read_hash('md5') + + def _hash(self, hash_name: str) -> hashlib._Hash: + """Returns a hash object for the file at the current path. + + `hash_name` should be a hash algo name (such as ``'md5'`` + or ``'sha1'``) that's available in the :mod:`hashlib` module. + """ + m = hashlib.new(hash_name) + for chunk in self.chunks(8192, mode="rb"): + m.update(chunk) + return m + + def read_hash(self, hash_name) -> builtins.bytes: + """Calculate given hash for this file. + + List of supported hashes can be obtained from :mod:`hashlib` package. + This reads the entire file. + + .. seealso:: :meth:`hashlib.hash.digest` + """ + return self._hash(hash_name).digest() + + def read_hexhash(self, hash_name) -> str: + """Calculate given hash for this file, returning hexdigest. + + List of supported hashes can be obtained from :mod:`hashlib` package. + This reads the entire file. + + .. seealso:: :meth:`hashlib.hash.hexdigest` + """ + return self._hash(hash_name).hexdigest() + + # --- Methods for querying the filesystem. + # N.B. On some platforms, the os.path functions may be implemented in C + # (e.g. isdir on Windows, Python 3.2.2), and compiled functions don't get + # bound. Playing it safe and wrapping them all in method calls. + + def isabs(self) -> bool: + """ + >>> Path('.').isabs() + False + + .. seealso:: :func:`os.path.isabs` + """ + return self.module.isabs(self) + + def exists(self) -> bool: + """.. seealso:: :func:`os.path.exists`""" + return self.module.exists(self) + + def is_dir(self) -> bool: + """.. seealso:: :func:`os.path.isdir`""" + return self.module.isdir(self) + + def is_file(self) -> bool: + """.. seealso:: :func:`os.path.isfile`""" + return self.module.isfile(self) + + def islink(self) -> bool: + """.. seealso:: :func:`os.path.islink`""" + return self.module.islink(self) + + def ismount(self) -> bool: + """ + >>> Path('.').ismount() + False + + .. seealso:: :func:`os.path.ismount` + """ + return self.module.ismount(self) + + def samefile(self, other: str) -> bool: + """.. seealso:: :func:`os.path.samefile`""" + return self.module.samefile(self, other) + + def getatime(self) -> float: + """.. seealso:: :attr:`atime`, :func:`os.path.getatime`""" + return self.module.getatime(self) + + def set_atime(self, value: float | datetime.datetime) -> None: + mtime_ns = self.stat().st_atime_ns + self.utime(ns=(_make_timestamp_ns(value), mtime_ns)) + + @property + def atime(self) -> float: + """ + Last access time of the file. + + >>> Path('.').atime > 0 + True + + Allows setting: + + >>> some_file = Path(getfixture('tmp_path')).joinpath('file.txt').touch() + >>> MST = datetime.timezone(datetime.timedelta(hours=-7)) + >>> some_file.atime = datetime.datetime(1976, 5, 7, 10, tzinfo=MST) + >>> some_file.atime + 200336400.0 + + .. seealso:: :meth:`getatime`, :func:`os.path.getatime` + """ + return self.getatime() + + @atime.setter + def atime(self, value: float | datetime.datetime) -> None: + self.set_atime(value) + + def getmtime(self) -> float: + """.. seealso:: :attr:`mtime`, :func:`os.path.getmtime`""" + return self.module.getmtime(self) + + def set_mtime(self, value: float | datetime.datetime) -> None: + atime_ns = self.stat().st_atime_ns + self.utime(ns=(atime_ns, _make_timestamp_ns(value))) + + @property + def mtime(self) -> float: + """ + Last modified time of the file. + + Allows setting: + + >>> some_file = Path(getfixture('tmp_path')).joinpath('file.txt').touch() + >>> MST = datetime.timezone(datetime.timedelta(hours=-7)) + >>> some_file.mtime = datetime.datetime(1976, 5, 7, 10, tzinfo=MST) + >>> some_file.mtime + 200336400.0 + + .. seealso:: :meth:`getmtime`, :func:`os.path.getmtime` + """ + return self.getmtime() + + @mtime.setter + def mtime(self, value: float | datetime.datetime) -> None: + self.set_mtime(value) + + def getctime(self) -> float: + """.. seealso:: :attr:`ctime`, :func:`os.path.getctime`""" + return self.module.getctime(self) + + @property + def ctime(self) -> float: + """Creation time of the file. + + .. seealso:: :meth:`getctime`, :func:`os.path.getctime` + """ + return self.getctime() + + def getsize(self) -> int: + """.. seealso:: :attr:`size`, :func:`os.path.getsize`""" + return self.module.getsize(self) + + @property + def size(self) -> int: + """Size of the file, in bytes. + + .. seealso:: :meth:`getsize`, :func:`os.path.getsize` + """ + return self.getsize() + + @property + def permissions(self) -> masks.Permissions: + """ + Permissions. + + >>> perms = Path('.').permissions + >>> isinstance(perms, int) + True + >>> set(perms.symbolic) <= set('rwx-') + True + >>> perms.symbolic + 'r...' + """ + return masks.Permissions(self.stat().st_mode) + + def access( + self, + mode: int, + *, + dir_fd: int | None = None, + effective_ids: bool = False, + follow_symlinks: bool = True, + ) -> bool: + """ + Return does the real user have access to this path. + + >>> Path('.').access(os.F_OK) + True + + .. seealso:: :func:`os.access` + """ + return os.access( + self, + mode, + dir_fd=dir_fd, + effective_ids=effective_ids, + follow_symlinks=follow_symlinks, + ) + + def stat(self, *, follow_symlinks: bool = True) -> os.stat_result: + """ + Perform a ``stat()`` system call on this path. + + >>> Path('.').stat() + os.stat_result(...) + + .. seealso:: :meth:`lstat`, :func:`os.stat` + """ + return os.stat(self, follow_symlinks=follow_symlinks) + + def lstat(self) -> os.stat_result: + """ + Like :meth:`stat`, but do not follow symbolic links. + + >>> Path('.').lstat() == Path('.').stat() + True + + .. seealso:: :meth:`stat`, :func:`os.lstat` + """ + return os.lstat(self) + + if sys.platform == "win32": + + def get_owner(self) -> str: # pragma: nocover + r""" + Return the name of the owner of this file or directory. Follow + symbolic links. + + Return a name of the form ``DOMAIN\User Name``; may be a group. + + .. seealso:: :attr:`owner` + """ + if "win32security" not in globals(): + raise NotImplementedError("Ownership not available on this platform.") + + desc = win32security.GetFileSecurity( + self, win32security.OWNER_SECURITY_INFORMATION + ) + sid = desc.GetSecurityDescriptorOwner() + account, domain, typecode = win32security.LookupAccountSid(None, sid) # type: ignore[arg-type] + return domain + '\\' + account + + else: + + def get_owner(self) -> str: # pragma: nocover + """ + Return the name of the owner of this file or directory. Follow + symbolic links. + + .. seealso:: :attr:`owner` + """ + st = self.stat() + return pwd.getpwuid(st.st_uid).pw_name + + @property + def owner(self) -> str: + """Name of the owner of this file or directory. + + .. seealso:: :meth:`get_owner`""" + return self.get_owner() + + if sys.platform != "win32": # pragma: no cover + + def group(self, *, follow_symlinks: bool = True) -> str: + """ + Return the group name of the file gid. + """ + gid = self.stat(follow_symlinks=follow_symlinks).st_gid + return grp.getgrgid(gid).gr_name + + def statvfs(self) -> os.statvfs_result: + """Perform a ``statvfs()`` system call on this path. + + .. seealso:: :func:`os.statvfs` + """ + return os.statvfs(self) + + def pathconf(self, name: str | int) -> int: + """.. seealso:: :func:`os.pathconf`""" + return os.pathconf(self, name) + + # + # --- Modifying operations on files and directories + + @overload + def utime( + self, + times: tuple[int, int] | tuple[float, float] | None = None, + *, + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> Self: ... + @overload + def utime( + self, + times: tuple[int, int] | tuple[float, float] | None = None, + *, + ns: tuple[int, int], + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> Self: ... + + def utime(self, *args, **kwargs) -> Self: + """Set the access and modified times of this file. + + .. seealso:: :func:`os.utime` + """ + os.utime(self, *args, **kwargs) + return self + + def chmod(self, mode: str | int) -> Self: + """ + Set the mode. May be the new mode (os.chmod behavior) or a `symbolic + mode `_. + + >>> a_file = Path(getfixture('tmp_path')).joinpath('afile.txt').touch() + >>> a_file.chmod(0o700) + Path(... + >>> a_file.chmod('u+x') + Path(... + + .. seealso:: :func:`os.chmod` + """ + if isinstance(mode, str): + mask = masks.compound(mode) + mode = mask(self.stat().st_mode) + os.chmod(self, mode) + return self + + if sys.platform != "win32": + + def chown(self, uid: str | int = -1, gid: str | int = -1) -> Self: + """ + Change the owner and group by names or numbers. + + .. seealso:: :func:`os.chown` + """ + + def resolve_uid(uid: str | int) -> int: + return uid if isinstance(uid, int) else pwd.getpwnam(uid).pw_uid + + def resolve_gid(gid: str | int) -> int: + return gid if isinstance(gid, int) else grp.getgrnam(gid).gr_gid + + os.chown(self, resolve_uid(uid), resolve_gid(gid)) + return self + + def rename(self, new: str) -> Self: + """.. seealso:: :func:`os.rename`""" + os.rename(self, new) + return self._next_class(new) + + def renames(self, new: str) -> Self: + """.. seealso:: :func:`os.renames`""" + os.renames(self, new) + return self._next_class(new) + + def replace(self, target_or_old: str, *args) -> Self: + """ + Replace a path or substitute substrings. + + Implements both pathlib.Path.replace and str.replace. + + If only a target is supplied, rename this path to the target path, + overwriting if that path exists. + + >>> dest = Path(getfixture('tmp_path')) + >>> orig = dest.joinpath('foo').touch() + >>> new = orig.replace(dest.joinpath('fee')) + >>> orig.exists() + False + >>> new.exists() + True + + ..seealso:: :meth:`pathlib.Path.replace` + + If a second parameter is supplied, perform a textual replacement. + + >>> Path('foo').replace('o', 'e') + Path('fee') + >>> Path('foo').replace('o', 'l', 1) + Path('flo') + + ..seealso:: :meth:`str.replace` + """ + return self._next_class( + super().replace(target_or_old, *args) + if args + else pathlib.Path(self).replace(target_or_old) + ) + + # + # --- Create/delete operations on directories + + def mkdir(self, mode: int = 0o777) -> Self: + """.. seealso:: :func:`os.mkdir`""" + os.mkdir(self, mode) + return self + + def mkdir_p(self, mode: int = 0o777) -> Self: + """Like :meth:`mkdir`, but does not raise an exception if the + directory already exists.""" + with contextlib.suppress(FileExistsError): + self.mkdir(mode) + return self + + def makedirs(self, mode: int = 0o777) -> Self: + """.. seealso:: :func:`os.makedirs`""" + os.makedirs(self, mode) + return self + + def makedirs_p(self, mode: int = 0o777) -> Self: + """Like :meth:`makedirs`, but does not raise an exception if the + directory already exists.""" + with contextlib.suppress(FileExistsError): + self.makedirs(mode) + return self + + def rmdir(self) -> Self: + """.. seealso:: :func:`os.rmdir`""" + os.rmdir(self) + return self + + def rmdir_p(self) -> Self: + """Like :meth:`rmdir`, but does not raise an exception if the + directory is not empty or does not exist.""" + suppressed = FileNotFoundError, FileExistsError, DirectoryNotEmpty + with contextlib.suppress(*suppressed): + with DirectoryNotEmpty.translate(): + self.rmdir() + return self + + def removedirs(self) -> Self: + """.. seealso:: :func:`os.removedirs`""" + os.removedirs(self) + return self + + def removedirs_p(self) -> Self: + """Like :meth:`removedirs`, but does not raise an exception if the + directory is not empty or does not exist.""" + with contextlib.suppress(FileExistsError, DirectoryNotEmpty): + with DirectoryNotEmpty.translate(): + self.removedirs() + return self + + # --- Modifying operations on files + + def touch(self) -> Self: + """Set the access/modified times of this file to the current time. + Create the file if it does not exist. + """ + os.close(os.open(self, os.O_WRONLY | os.O_CREAT, 0o666)) + os.utime(self, None) + return self + + def remove(self) -> Self: + """.. seealso:: :func:`os.remove`""" + os.remove(self) + return self + + def remove_p(self) -> Self: + """Like :meth:`remove`, but does not raise an exception if the + file does not exist.""" + with contextlib.suppress(FileNotFoundError): + self.unlink() + return self + + unlink = remove + unlink_p = remove_p + + # --- Links + + def hardlink_to(self, target: str) -> None: + """ + Create a hard link at self, pointing to target. + + .. seealso:: :func:`os.link` + """ + os.link(target, self) + + def link(self, newpath: str) -> Self: + """Create a hard link at `newpath`, pointing to this file. + + .. seealso:: :func:`os.link` + """ + os.link(self, newpath) + return self._next_class(newpath) + + def symlink_to(self, target: str, target_is_directory: bool = False) -> None: + """ + Create a symbolic link at self, pointing to target. + + .. seealso:: :func:`os.symlink` + """ + os.symlink(target, self, target_is_directory) + + def symlink(self, newlink: str | None = None) -> Self: + """Create a symbolic link at `newlink`, pointing here. + + If newlink is not supplied, the symbolic link will assume + the name self.basename(), creating the link in the cwd. + + .. seealso:: :func:`os.symlink` + """ + if newlink is None: + newlink = self.basename() + os.symlink(self, newlink) + return self._next_class(newlink) + + def readlink(self) -> Self: + """Return the path to which this symbolic link points. + + The result may be an absolute or a relative path. + + .. seealso:: :meth:`readlinkabs`, :func:`os.readlink` + """ + return self._next_class(removeprefix(os.readlink(self), '\\\\?\\')) + + def readlinkabs(self) -> Self: + """Return the path to which this symbolic link points. + + The result is always an absolute path. + + .. seealso:: :meth:`readlink`, :func:`os.readlink` + """ + p = self.readlink() + return p if p.isabs() else (self.parent / p).absolute() + + # High-level functions from shutil. + + def copyfile(self, dst: str, *, follow_symlinks: bool = True) -> Self: + return self._next_class( + shutil.copyfile(self, dst, follow_symlinks=follow_symlinks) + ) + + def copymode(self, dst: str, *, follow_symlinks: bool = True) -> None: + shutil.copymode(self, dst, follow_symlinks=follow_symlinks) + + def copystat(self, dst: str, *, follow_symlinks: bool = True) -> None: + shutil.copystat(self, dst, follow_symlinks=follow_symlinks) + + def copy(self, dst: str, *, follow_symlinks: bool = True) -> Self: + return self._next_class(shutil.copy(self, dst, follow_symlinks=follow_symlinks)) + + def copy2(self, dst: str, *, follow_symlinks: bool = True) -> Self: + return self._next_class( + shutil.copy2(self, dst, follow_symlinks=follow_symlinks) + ) + + def copytree( + self, + dst: str, + symlinks: bool = False, + ignore: _IgnoreFn | None = None, + copy_function: _CopyFn = shutil.copy2, + ignore_dangling_symlinks: bool = False, + dirs_exist_ok: bool = False, + ) -> Self: + return self._next_class( + shutil.copytree( + self, + dst, + symlinks=symlinks, + ignore=ignore, + copy_function=copy_function, + ignore_dangling_symlinks=ignore_dangling_symlinks, + dirs_exist_ok=dirs_exist_ok, + ) + ) + + def move(self, dst: str, copy_function: _CopyFn = shutil.copy2) -> Self: + retval = shutil.move(self, dst, copy_function=copy_function) + # shutil.move may return None if the src and dst are the same + return self._next_class(retval or dst) + + if sys.version_info >= (3, 12): + + @overload + def rmtree( + self, + ignore_errors: bool, + onerror: _OnErrorCallback, + *, + onexc: None = ..., + dir_fd: int | None = ..., + ) -> None: ... + @overload + def rmtree( + self, + ignore_errors: bool = ..., + *, + onerror: _OnErrorCallback, + onexc: None = ..., + dir_fd: int | None = ..., + ) -> None: ... + @overload + def rmtree( + self, + ignore_errors: bool = ..., + *, + onexc: _OnExcCallback | None = ..., + dir_fd: int | None = ..., + ) -> None: ... + + elif sys.version_info >= (3, 11): + # NOTE: Strictly speaking, an overload is not needed - this could + # be expressed in a single annotation. However, if overloads + # are used there must be a minimum of two, so this was split + # into two so that the body of rmtree need not be re-defined + # for each version. + @overload + def rmtree( + self, + onerror: _OnErrorCallback | None = None, + *, + dir_fd: int | None = None, + ) -> None: ... + @overload + def rmtree( + self, + ignore_errors: bool, + onerror: _OnErrorCallback | None = ..., + *, + dir_fd: int | None = ..., + ) -> None: ... + + else: + # NOTE: See note about overloads above. + @overload + def rmtree(self, onerror: _OnErrorCallback | None = ...) -> None: ... + @overload + def rmtree( + self, ignore_errors: bool, onerror: _OnErrorCallback | None = ... + ) -> None: ... + + def rmtree(self, *args, **kwargs): + shutil.rmtree(self, *args, **kwargs) + + # Copy the docstrings from shutil to these methods. + + copyfile.__doc__ = shutil.copyfile.__doc__ + copymode.__doc__ = shutil.copymode.__doc__ + copystat.__doc__ = shutil.copystat.__doc__ + copy.__doc__ = shutil.copy.__doc__ + copy2.__doc__ = shutil.copy2.__doc__ + copytree.__doc__ = shutil.copytree.__doc__ + move.__doc__ = shutil.move.__doc__ + rmtree.__doc__ = shutil.rmtree.__doc__ + + def rmtree_p(self) -> Self: + """Like :meth:`rmtree`, but does not raise an exception if the + directory does not exist.""" + with contextlib.suppress(FileNotFoundError): + self.rmtree() + return self + + def chdir(self) -> None: + """.. seealso:: :func:`os.chdir`""" + os.chdir(self) + + cd = chdir + + def merge_tree( + self, + dst: str, + symlinks: bool = False, + *, + copy_function: _CopyFn = shutil.copy2, + ignore: _IgnoreFn = lambda dir, contents: [], + ): + """ + Copy entire contents of self to dst, overwriting existing + contents in dst with those in self. + + Pass ``symlinks=True`` to copy symbolic links as links. + + Accepts a ``copy_function``, similar to copytree. + + To avoid overwriting newer files, supply a copy function + wrapped in ``only_newer``. For example:: + + src.merge_tree(dst, copy_function=only_newer(shutil.copy2)) + """ + dst_path = self._next_class(dst) + dst_path.makedirs_p() + + sources = list(self.iterdir()) + _ignored = set(ignore(self, [item.name for item in sources])) + + def ignored(item: Self) -> bool: + return item.name in _ignored + + for source in itertools.filterfalse(ignored, sources): + dest = dst_path / source.name + if symlinks and source.islink(): + target = source.readlink() + target.symlink(dest) + elif source.is_dir(): + source.merge_tree( + dest, + symlinks=symlinks, + copy_function=copy_function, + ignore=ignore, + ) + else: + copy_function(source, dest) + + self.copystat(dst_path) + + # + # --- Special stuff from os + + if sys.platform != "win32": + + def chroot(self) -> None: # pragma: nocover + """.. seealso:: :func:`os.chroot`""" + os.chroot(self) + + if sys.platform == "win32": + if sys.version_info >= (3, 10): + + @overload + def startfile( + self, + arguments: str = ..., + cwd: str | None = ..., + show_cmd: int = ..., + ) -> Self: ... + @overload + def startfile( + self, + operation: str, + arguments: str = ..., + cwd: str | None = ..., + show_cmd: int = ..., + ) -> Self: ... + + else: + + @overload + def startfile(self) -> Self: ... + @overload + def startfile(self, operation: str) -> Self: ... + + def startfile(self, *args, **kwargs) -> Self: # pragma: nocover + """.. seealso:: :func:`os.startfile`""" + os.startfile(self, *args, **kwargs) + return self + + # in-place re-writing, courtesy of Martijn Pieters + # http://www.zopatista.com/python/2013/11/26/inplace-file-rewriting/ + @contextlib.contextmanager + def in_place( + self, + mode: str = 'r', + buffering: int = -1, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + backup_extension: str | None = None, + ) -> Iterator[tuple[IO[Any], IO[Any]]]: + """ + A context in which a file may be re-written in-place with + new content. + + Yields a tuple of :samp:`({readable}, {writable})` file + objects, where `writable` replaces `readable`. + + If an exception occurs, the old file is restored, removing the + written data. + + Mode *must not* use ``'w'``, ``'a'``, or ``'+'``; only + read-only-modes are allowed. A :exc:`ValueError` is raised + on invalid modes. + + For example, to add line numbers to a file:: + + p = Path(filename) + assert p.is_file() + with p.in_place() as (reader, writer): + for number, line in enumerate(reader, 1): + writer.write('{0:3}: '.format(number))) + writer.write(line) + + Thereafter, the file at `filename` will have line numbers in it. + """ + if set(mode).intersection('wa+'): + raise ValueError('Only read-only file modes can be used') + + # move existing file to backup, create new file with same permissions + # borrowed extensively from the fileinput module + backup_fn = self + (backup_extension or os.extsep + 'bak') + backup_fn.remove_p() + self.rename(backup_fn) + readable = open( + backup_fn, + mode, + buffering=buffering, + encoding=encoding, + errors=errors, + newline=newline, + ) + + perm = os.stat(readable.fileno()).st_mode + os_mode = os.O_CREAT | os.O_WRONLY | os.O_TRUNC + os_mode |= getattr(os, 'O_BINARY', 0) + fd = os.open(self, os_mode, perm) + writable = open( + fd, + "w" + mode.replace('r', ''), + buffering=buffering, + encoding=encoding, + errors=errors, + newline=newline, + ) + with contextlib.suppress(OSError, AttributeError): + self.chmod(perm) + + try: + yield readable, writable + except Exception: + # move backup back + readable.close() + writable.close() + self.remove_p() + backup_fn.rename(self) + raise + else: + readable.close() + writable.close() + finally: + backup_fn.remove_p() + + @classes.ClassProperty + @classmethod + def special(cls) -> Callable[[str | None], SpecialResolver]: + """ + Return a SpecialResolver object suitable referencing a suitable + directory for the relevant platform for the given + type of content. + + For example, to get a user config directory, invoke: + + dir = Path.special().user.config + + Uses the `appdirs + `_ to resolve + the paths in a platform-friendly way. + + To create a config directory for 'My App', consider: + + dir = Path.special("My App").user.config.makedirs_p() + + If the ``appdirs`` module is not installed, invocation + of special will raise an ImportError. + """ + return functools.partial(SpecialResolver, cls) + + +class DirectoryNotEmpty(OSError): + @staticmethod + @contextlib.contextmanager + def translate() -> Iterator[None]: + try: + yield + except OSError as exc: + if exc.errno == errno.ENOTEMPTY: + raise DirectoryNotEmpty(*exc.args) from exc + raise + + +def only_newer(copy_func: _CopyFn) -> _CopyFn: + """ + Wrap a copy function (like shutil.copy2) to return + the dst if it's newer than the source. + """ + + @functools.wraps(copy_func) + def wrapper(src: str, dst: str): + src_p = Path(src) + dst_p = Path(dst) + is_newer_dst = dst_p.exists() and dst_p.getmtime() >= src_p.getmtime() + if is_newer_dst: + return dst + return copy_func(src, dst) + + return wrapper + + +class ExtantPath(Path): + """ + >>> ExtantPath('.') + ExtantPath('.') + >>> ExtantPath('does-not-exist') + Traceback (most recent call last): + OSError: does-not-exist does not exist. + """ + + def _validate(self) -> None: + if not self.exists(): + raise OSError(f"{self} does not exist.") + + +class ExtantFile(Path): + """ + >>> ExtantFile('.') + Traceback (most recent call last): + FileNotFoundError: . does not exist as a file. + >>> ExtantFile('does-not-exist') + Traceback (most recent call last): + FileNotFoundError: does-not-exist does not exist as a file. + """ + + def _validate(self) -> None: + if not self.is_file(): + raise FileNotFoundError(f"{self} does not exist as a file.") + + +class SpecialResolver: + path_class: type + wrapper: ModuleType + + class ResolverScope: + paths: SpecialResolver + scope: str + + def __init__(self, paths: SpecialResolver, scope: str) -> None: + self.paths = paths + self.scope = scope + + def __getattr__(self, class_: str) -> _MultiPathType: + return self.paths.get_dir(self.scope, class_) + + def __init__( + self, + path_class: type, + appname: str | None = None, + appauthor: str | None = None, + version: str | None = None, + roaming: bool = False, + multipath: bool = False, + ): + appdirs = importlib.import_module('appdirs') + self.path_class = path_class + self.wrapper = appdirs.AppDirs( + appname=appname, + appauthor=appauthor, + version=version, + roaming=roaming, + multipath=multipath, + ) + + def __getattr__(self, scope: str) -> ResolverScope: + return self.ResolverScope(self, scope) + + def get_dir(self, scope: str, class_: str) -> _MultiPathType: + """ + Return the callable function from appdirs, but with the + result wrapped in self.path_class + """ + prop_name = f'{scope}_{class_}_dir' + value = getattr(self.wrapper, prop_name) + MultiPath = Multi.for_class(self.path_class) + return MultiPath.detect(value) + + +class Multi: + """ + A mix-in for a Path which may contain multiple Path separated by pathsep. + """ + + @classmethod + def for_class(cls, path_cls: type) -> type[_MultiPathType]: + name = 'Multi' + path_cls.__name__ + return type(name, (cls, path_cls), {}) + + @classmethod + def detect(cls, input: str) -> _MultiPathType: + if os.pathsep not in input: + cls = cls._next_class + return cls(input) # type: ignore[return-value, call-arg] + + def __iter__(self) -> Iterator[Path]: + return iter(map(self._next_class, self.split(os.pathsep))) # type: ignore[attr-defined] + + @classes.ClassProperty + @classmethod + def _next_class(cls) -> type[Path]: + """ + Multi-subclasses should use the parent class + """ + return next(class_ for class_ in cls.__mro__ if not issubclass(class_, Multi)) # type: ignore[return-value] + + +class _MultiPathType(Multi, Path): + pass + + +class TempDir(Path): + """ + A temporary directory via :func:`tempfile.mkdtemp`, and + constructed with the same parameters that you can use + as a context manager. + + For example: + + >>> with TempDir() as d: + ... d.is_dir() and isinstance(d, Path) + True + + The directory is deleted automatically. + + >>> d.is_dir() + False + + .. seealso:: :func:`tempfile.mkdtemp` + """ + + @classes.ClassProperty + @classmethod + def _next_class(cls) -> type[Path]: + return Path + + def __new__(cls, *args, **kwargs) -> Self: + dirname = tempfile.mkdtemp(*args, **kwargs) + return super().__new__(cls, dirname) + + def __init__(self, *args, **kwargs) -> None: + pass + + def __enter__(self) -> Self: + # TempDir should return a Path version of itself and not itself + # so that a second context manager does not create a second + # temporary directory, but rather changes CWD to the location + # of the temporary directory. + return self._next_class(self) + + def __exit__(self, *_) -> None: + self.rmtree() + + +class Handlers: + @staticmethod + def strict(msg: str) -> Never: + raise + + @staticmethod + def warn(msg: str) -> None: + warnings.warn(msg, TreeWalkWarning, stacklevel=2) + + @staticmethod + def ignore(msg: str) -> None: + pass + + @classmethod + def _resolve(cls, param: str | Callable[[str], None]) -> Callable[[str], None]: + msg = "invalid errors parameter" + if isinstance(param, str): + if param not in vars(cls): + raise ValueError(msg) + return {"strict": cls.strict, "warn": cls.warn, "ignore": cls.ignore}[param] + else: + if not callable(param): + raise ValueError(msg) + return param diff --git a/path/classes.py b/path/classes.py new file mode 100644 index 00000000..7aeb7f2f --- /dev/null +++ b/path/classes.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import functools +from typing import Any, Callable, Generic, TypeVar + + +class ClassProperty(property): + def __get__(self, cls: Any, owner: type | None = None) -> Any: + assert self.fget is not None + return self.fget.__get__(None, owner)() + + +_T = TypeVar("_T") + + +class multimethod(Generic[_T]): + """ + Acts like a classmethod when invoked from the class and like an + instancemethod when invoked from the instance. + """ + + func: Callable[..., _T] + + def __init__(self, func: Callable[..., _T]): + self.func = func + + def __get__(self, instance: _T | None, owner: type[_T] | None) -> Callable[..., _T]: + """ + If called on an instance, pass the instance as the first + argument. + """ + return ( + functools.partial(self.func, owner) + if instance is None + else functools.partial(self.func, owner, instance) + ) diff --git a/path/compat/py38.py b/path/compat/py38.py new file mode 100644 index 00000000..79ddc015 --- /dev/null +++ b/path/compat/py38.py @@ -0,0 +1,24 @@ +import sys + +if sys.version_info < (3, 9): + + def removesuffix(self, suffix): + # suffix='' should not call self[:-0]. + if suffix and self.endswith(suffix): + return self[: -len(suffix)] + else: + return self[:] + + def removeprefix(self, prefix): + if self.startswith(prefix): + return self[len(prefix) :] + else: + return self[:] + +else: + + def removesuffix(self, suffix): + return self.removesuffix(suffix) + + def removeprefix(self, prefix): + return self.removeprefix(prefix) diff --git a/path/masks.py b/path/masks.py new file mode 100644 index 00000000..6c551d71 --- /dev/null +++ b/path/masks.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import functools +import itertools +import operator +import re +from collections.abc import Iterable, Iterator +from typing import Any, Callable + + +# from jaraco.functools +def compose(*funcs: Callable[..., Any]) -> Callable[..., Any]: + compose_two = lambda f1, f2: lambda *args, **kwargs: f1(f2(*args, **kwargs)) # noqa + return functools.reduce(compose_two, funcs) + + +# from jaraco.structures.binary +def gen_bit_values(number: int) -> Iterator[int]: + """ + Return a zero or one for each bit of a numeric value up to the most + significant 1 bit, beginning with the least significant bit. + + >>> list(gen_bit_values(16)) + [0, 0, 0, 0, 1] + """ + digits = bin(number)[2:] + return map(int, reversed(digits)) + + +# from more_itertools +def padded( + iterable: Iterable[Any], + fillvalue: Any | None = None, + n: int | None = None, + next_multiple: bool = False, +) -> Iterator[Any]: + """Yield the elements from *iterable*, followed by *fillvalue*, such that + at least *n* items are emitted. + + >>> list(padded([1, 2, 3], '?', 5)) + [1, 2, 3, '?', '?'] + + If *next_multiple* is ``True``, *fillvalue* will be emitted until the + number of items emitted is a multiple of *n*:: + + >>> list(padded([1, 2, 3, 4], n=3, next_multiple=True)) + [1, 2, 3, 4, None, None] + + If *n* is ``None``, *fillvalue* will be emitted indefinitely. + + """ + it = iter(iterable) + if n is None: + yield from itertools.chain(it, itertools.repeat(fillvalue)) + elif n < 1: + raise ValueError('n must be at least 1') + else: + item_count = 0 + for item in it: + yield item + item_count += 1 + + remaining = (n - item_count) % n if next_multiple else n - item_count + for _ in range(remaining): + yield fillvalue + + +def compound(mode: str) -> Callable[[int], int]: + """ + Support multiple, comma-separated Unix chmod symbolic modes. + + >>> oct(compound('a=r,u+w')(0)) + '0o644' + """ + return compose(*map(simple, reversed(mode.split(',')))) + + +def simple(mode: str) -> Callable[[int], int]: + """ + Convert a Unix chmod symbolic mode like ``'ugo+rwx'`` to a function + suitable for applying to a mask to affect that change. + + >>> mask = simple('ugo+rwx') + >>> mask(0o554) == 0o777 + True + + >>> simple('go-x')(0o777) == 0o766 + True + + >>> simple('o-x')(0o445) == 0o444 + True + + >>> simple('a+x')(0) == 0o111 + True + + >>> simple('a=rw')(0o057) == 0o666 + True + + >>> simple('u=x')(0o666) == 0o166 + True + + >>> simple('g=')(0o157) == 0o107 + True + + >>> simple('gobbledeegook') + Traceback (most recent call last): + ValueError: ('Unrecognized symbolic mode', 'gobbledeegook') + """ + # parse the symbolic mode + parsed = re.match('(?P[ugoa]+)(?P[-+=])(?P[rwx]*)$', mode) + if not parsed: + raise ValueError("Unrecognized symbolic mode", mode) + + # generate a mask representing the specified permission + spec_map = dict(r=4, w=2, x=1) + specs = (spec_map[perm] for perm in parsed.group('what')) + spec = functools.reduce(operator.or_, specs, 0) + + # now apply spec to each subject in who + shift_map = dict(u=6, g=3, o=0) + who = parsed.group('who').replace('a', 'ugo') + masks = (spec << shift_map[subj] for subj in who) + mask = functools.reduce(operator.or_, masks) + + op = parsed.group('op') + + # if op is -, invert the mask + if op == '-': + mask ^= 0o777 + + # if op is =, retain extant values for unreferenced subjects + if op == '=': + masks = (0o7 << shift_map[subj] for subj in who) + retain = functools.reduce(operator.or_, masks) ^ 0o777 + + op_map = { + '+': operator.or_, + '-': operator.and_, + '=': lambda mask, target: target & retain ^ mask, + } + return functools.partial(op_map[op], mask) + + +class Permissions(int): + """ + >>> perms = Permissions(0o764) + >>> oct(perms) + '0o764' + >>> perms.symbolic + 'rwxrw-r--' + >>> str(perms) + 'rwxrw-r--' + >>> str(Permissions(0o222)) + '-w--w--w-' + """ + + @property + def symbolic(self) -> str: + return ''.join( + ['-', val][bit] for val, bit in zip(itertools.cycle('rwx'), self.bits) + ) + + @property + def bits(self) -> Iterator[int]: + return reversed(tuple(padded(gen_bit_values(self), 0, n=9))) + + def __str__(self) -> str: + return self.symbolic diff --git a/path/matchers.py b/path/matchers.py new file mode 100644 index 00000000..7ddc8772 --- /dev/null +++ b/path/matchers.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import fnmatch +import ntpath +from typing import TYPE_CHECKING, Any, Callable, overload + +if TYPE_CHECKING: + from typing_extensions import Literal + + +@overload +def load(param: None) -> Null: ... + + +@overload +def load(param: str) -> Pattern: ... + + +@overload +def load(param: Any) -> Any: ... + + +def load(param): + """ + If the supplied parameter is a string, assume it's a simple + pattern. + """ + return ( + Pattern(param) + if isinstance(param, str) + else param + if param is not None + else Null() + ) + + +class Base: + pass + + +class Null(Base): + def __call__(self, path: str) -> Literal[True]: + return True + + +class Pattern(Base): + pattern: str + _pattern: str + + def __init__(self, pattern: str): + self.pattern = pattern + + def get_pattern(self, normcase: Callable[[str], str]) -> str: + try: + return self._pattern + except AttributeError: + pass + self._pattern = normcase(self.pattern) + return self._pattern + + # NOTE: 'path' should be annotated with Path, but cannot due to circular imports. + def __call__(self, path) -> bool: + normcase = getattr(self, 'normcase', path.module.normcase) + pattern = self.get_pattern(normcase) + return fnmatch.fnmatchcase(normcase(path.name), pattern) + + +class CaseInsensitive(Pattern): + """ + A Pattern with a ``'normcase'`` property, suitable for passing to + :meth:`iterdir`, :meth:`dirs`, :meth:`files`, :meth:`walk`, + :meth:`walkdirs`, or :meth:`walkfiles` to match case-insensitive. + + For example, to get all files ending in .py, .Py, .pY, or .PY in the + current directory:: + + from path import Path, matchers + Path('.').files(matchers.CaseInsensitive('*.py')) + """ + + normcase = staticmethod(ntpath.normcase) diff --git a/path/py.typed b/path/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..73c890dd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[build-system] +requires = [ + "setuptools>=77", + "setuptools_scm[toml]>=3.4.1", + # jaraco/skeleton#174 + "coherent.licensed", +] +build-backend = "setuptools.build_meta" + +[project] +name = "path" +authors = [ + { name = "Jason Orendorff", email = "jason.orendorff@gmail.com" }, +] +maintainers = [ + { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, +] +description = "A module wrapper for os.path" +readme = "README.rst" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">=3.10" +license = "MIT" +dependencies = [ +] +dynamic = ["version"] + +[project.urls] +Source = "https://github.com/jaraco/path" + +[project.optional-dependencies] +test = [ + # upstream + "pytest >= 6, != 8.1.*", + + # local + "appdirs", + "packaging", + 'pywin32; platform_system == "Windows" and python_version < "3.12"', + "more_itertools", + # required for checkdocs on README.rst + "pygments", + "types-pywin32", +] + +doc = [ + # upstream + "sphinx >= 3.5", + "jaraco.packaging >= 9.3", + "rst.linker >= 1.9", + "furo", + "sphinx-lint", + + # tidelift + "jaraco.tidelift >= 1.4", + + # local +] + +check = [ + "pytest-checkdocs >= 2.14", + "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", +] + +cover = [ + "pytest-cov", +] + +enabler = [ + "pytest-enabler >= 3.4", +] + +type = [ + # upstream + + # Exclude PyPy from type checks (python/mypy#20454 jaraco/skeleton#187) + "pytest-mypy >= 1.0.1; platform_python_implementation != 'PyPy'", + + # local +] + + +[tool.setuptools_scm] diff --git a/pytest.ini b/pytest.ini index 3f31db3e..9a0f3bce 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,25 @@ [pytest] -norecursedirs=*.egg .eggs dist build -addopts=--doctest-modules --ignore=build -doctest_optionflags=ALLOW_UNICODE ELLIPSIS +norecursedirs=dist build .tox .eggs +addopts= + --doctest-modules + --import-mode importlib +consider_namespace_packages=true +filterwarnings= + ## upstream + + # Ensure ResourceWarnings are emitted + default::ResourceWarning + + # realpython/pytest-mypy#152 + ignore:'encoding' argument not specified::pytest_mypy + + # python/cpython#100750 + ignore:'encoding' argument not specified::platform + + # pypa/build#615 + ignore:'encoding' argument not specified::build.env + + # dateutil/dateutil#1284 + ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz + + ## end upstream diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..63c0825f --- /dev/null +++ b/ruff.toml @@ -0,0 +1,51 @@ +[lint] +extend-select = [ + # upstream + + "C901", # complex-structure + "I", # isort + "PERF401", # manual-list-comprehension + + # Ensure modern type annotation syntax and best practices + # Not including those covered by type-checkers or exclusive to Python 3.11+ + "FA", # flake8-future-annotations + "F404", # late-future-import + "PYI", # flake8-pyi + "UP006", # non-pep585-annotation + "UP007", # non-pep604-annotation + "UP010", # unnecessary-future-import + "UP035", # deprecated-import + "UP037", # quoted-annotation + "UP043", # unnecessary-default-type-args + + # local +] +ignore = [ + # upstream + + # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, + # irrelevant to this project. + "PYI011", # typed-argument-default-in-stub + # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + + # local +] + +[format] +# Enable preview to get hugged parenthesis unwrapping and other nice surprises +# See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 +preview = true +# https://docs.astral.sh/ruff/settings/#format_quote-style +quote-style = "preserve" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8004dcb6..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[aliases] -release = clean --all sdist bdist_wheel build_sphinx upload upload_docs -test = pytest - -[wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 01a2de25..00000000 --- a/setup.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python -# Generated by jaraco.develop 2.27.1 -# https://pypi.python.org/pypi/jaraco.develop - -import io -import sys - -import setuptools - -with io.open('README.rst', encoding='utf-8') as readme: - long_description = readme.read() - -needs_pytest = set(['pytest', 'test']).intersection(sys.argv) -pytest_runner = ['pytest_runner'] if needs_pytest else [] -needs_sphinx = set(['release', 'build_sphinx', 'upload_docs']).intersection(sys.argv) -sphinx = ['sphinx', 'rst.linker>=1.2'] if needs_sphinx else [] -needs_wheel = set(['release', 'bdist_wheel']).intersection(sys.argv) -wheel = ['wheel'] if needs_wheel else [] - -name = 'path.py' -description = 'A module wrapper for os.path' - -setup_params = dict( - name=name, - use_scm_version=True, - author="Jason Orendorff", - author_email="jason.orendorff@gmail.com", - maintainer="Jason R. Coombs", - maintainer_email="jaraco@jaraco.com", - description=description or name, - long_description=long_description, - url="https://github.com/jaraco/" + name, - py_modules=['path', 'test_path'], - install_requires=[ - ], - extras_require={ - ':python_version=="2.6"': ['importlib'], - }, - setup_requires=[ - 'setuptools_scm>=1.9', - ] + pytest_runner + sphinx + wheel, - tests_require=[ - 'pytest>=2.8', - 'appdirs', - ], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - entry_points={ - }, -) -if __name__ == '__main__': - setuptools.setup(**setup_params) diff --git a/tea.yaml b/tea.yaml new file mode 100644 index 00000000..d7fadafa --- /dev/null +++ b/tea.yaml @@ -0,0 +1,6 @@ +# https://tea.xyz/what-is-this-file +--- +version: 1.0.0 +codeOwners: + - '0x32392EaEA1FDE87733bEEc3b184C9006501c4A82' +quorum: 1 diff --git a/test_path.py b/test_path.py deleted file mode 100644 index f6aa1b67..00000000 --- a/test_path.py +++ /dev/null @@ -1,1119 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Tests for the path module. - -This suite runs on Linux, OS X, and Windows right now. To extend the -platform support, just add appropriate pathnames for your -platform (os.name) in each place where the p() function is called. -Then report the result. If you can't get the test to run at all on -your platform, there's probably a bug in path.py -- please report the issue -in the issue tracker at https://github.com/jaraco/path.py. - -TestScratchDir.test_touch() takes a while to run. It sleeps a few -seconds to allow some time to pass between calls to check the modify -time on files. -""" - -from __future__ import unicode_literals, absolute_import, print_function - -import codecs -import os -import sys -import shutil -import time -import ntpath -import posixpath -import textwrap -import platform -import importlib - -import pytest - -from path import Path, tempdir -from path import CaseInsensitivePattern as ci -from path import SpecialResolver -from path import Multi - - -def p(**choices): - """ Choose a value from several possible values, based on os.name """ - return choices[os.name] - - -class TestBasics: - def test_relpath(self): - root = Path(p(nt='C:\\', posix='/')) - foo = root / 'foo' - quux = foo / 'quux' - bar = foo / 'bar' - boz = bar / 'Baz' / 'Boz' - up = Path(os.pardir) - - # basics - assert root.relpathto(boz) == Path('foo')/'bar'/'Baz'/'Boz' - assert bar.relpathto(boz) == Path('Baz')/'Boz' - assert quux.relpathto(boz) == up/'bar'/'Baz'/'Boz' - assert boz.relpathto(quux) == up/up/up/'quux' - assert boz.relpathto(bar) == up/up - - # Path is not the first element in concatenation - assert root.relpathto(boz) == 'foo'/Path('bar')/'Baz'/'Boz' - - # x.relpathto(x) == curdir - assert root.relpathto(root) == os.curdir - assert boz.relpathto(boz) == os.curdir - # Make sure case is properly noted (or ignored) - assert boz.relpathto(boz.normcase()) == os.curdir - - # relpath() - cwd = Path(os.getcwd()) - assert boz.relpath() == cwd.relpathto(boz) - - if os.name == 'nt': - # Check relpath across drives. - d = Path('D:\\') - assert d.relpathto(boz) == boz - - def test_construction_from_none(self): - """ - - """ - try: - Path(None) - except TypeError: - pass - else: - raise Exception("DID NOT RAISE") - - def test_construction_from_int(self): - """ - Path class will construct a path as a string of the number - """ - assert Path(1) == '1' - - def test_string_compatibility(self): - """ Test compatibility with ordinary strings. """ - x = Path('xyzzy') - assert x == 'xyzzy' - assert x == str('xyzzy') - - # sorting - items = [Path('fhj'), - Path('fgh'), - 'E', - Path('d'), - 'A', - Path('B'), - 'c'] - items.sort() - assert items == ['A', 'B', 'E', 'c', 'd', 'fgh', 'fhj'] - - # Test p1/p1. - p1 = Path("foo") - p2 = Path("bar") - assert p1/p2 == p(nt='foo\\bar', posix='foo/bar') - - def test_properties(self): - # Create sample path object. - f = p(nt='C:\\Program Files\\Python\\Lib\\xyzzy.py', - posix='/usr/local/python/lib/xyzzy.py') - f = Path(f) - - # .parent - nt_lib = 'C:\\Program Files\\Python\\Lib' - posix_lib = '/usr/local/python/lib' - expected = p(nt=nt_lib, posix=posix_lib) - assert f.parent == expected - - # .name - assert f.name == 'xyzzy.py' - assert f.parent.name == p(nt='Lib', posix='lib') - - # .ext - assert f.ext == '.py' - assert f.parent.ext == '' - - # .drive - assert f.drive == p(nt='C:', posix='') - - def test_methods(self): - # .abspath() - assert Path(os.curdir).abspath() == os.getcwd() - - # .getcwd() - cwd = Path.getcwd() - assert isinstance(cwd, Path) - assert cwd == os.getcwd() - - def test_UNC(self): - if hasattr(os.path, 'splitunc'): - p = Path(r'\\python1\share1\dir1\file1.txt') - assert p.uncshare == r'\\python1\share1' - assert p.splitunc() == os.path.splitunc(str(p)) - - def test_explicit_module(self): - """ - The user may specify an explicit path module to use. - """ - nt_ok = Path.using_module(ntpath)(r'foo\bar\baz') - posix_ok = Path.using_module(posixpath)(r'foo/bar/baz') - posix_wrong = Path.using_module(posixpath)(r'foo\bar\baz') - - assert nt_ok.dirname() == r'foo\bar' - assert posix_ok.dirname() == r'foo/bar' - assert posix_wrong.dirname() == '' - - assert nt_ok / 'quux' == r'foo\bar\baz\quux' - assert posix_ok / 'quux' == r'foo/bar/baz/quux' - - def test_explicit_module_classes(self): - """ - Multiple calls to path.using_module should produce the same class. - """ - nt_path = Path.using_module(ntpath) - assert nt_path is Path.using_module(ntpath) - assert nt_path.__name__ == 'Path_ntpath' - - def test_joinpath_on_instance(self): - res = Path('foo') - foo_bar = res.joinpath('bar') - assert foo_bar == p(nt='foo\\bar', posix='foo/bar') - - def test_joinpath_to_nothing(self): - res = Path('foo') - assert res.joinpath() == res - - def test_joinpath_on_class(self): - "Construct a path from a series of strings" - foo_bar = Path.joinpath('foo', 'bar') - assert foo_bar == p(nt='foo\\bar', posix='foo/bar') - - def test_joinpath_fails_on_empty(self): - "It doesn't make sense to join nothing at all" - try: - Path.joinpath() - except TypeError: - pass - else: - raise Exception("did not raise") - - def test_joinpath_returns_same_type(self): - path_posix = Path.using_module(posixpath) - res = path_posix.joinpath('foo') - assert isinstance(res, path_posix) - res2 = res.joinpath('bar') - assert isinstance(res2, path_posix) - assert res2 == 'foo/bar' - - -class TestSelfReturn: - """ - Some methods don't necessarily return any value (e.g. makedirs, - makedirs_p, rename, mkdir, touch, chroot). These methods should return - self anyhow to allow methods to be chained. - """ - def test_makedirs_p(self, tmpdir): - """ - Path('foo').makedirs_p() == Path('foo') - """ - p = Path(tmpdir) / "newpath" - ret = p.makedirs_p() - assert p == ret - - def test_makedirs_p_extant(self, tmpdir): - p = Path(tmpdir) - ret = p.makedirs_p() - assert p == ret - - def test_rename(self, tmpdir): - p = Path(tmpdir) / "somefile" - p.touch() - target = Path(tmpdir) / "otherfile" - ret = p.rename(target) - assert target == ret - - def test_mkdir(self, tmpdir): - p = Path(tmpdir) / "newdir" - ret = p.mkdir() - assert p == ret - - def test_touch(self, tmpdir): - p = Path(tmpdir) / "empty file" - ret = p.touch() - assert p == ret - - -class TestScratchDir: - """ - Tests that run in a temporary directory (does not test tempdir class) - """ - def test_context_manager(self, tmpdir): - """Can be used as context manager for chdir.""" - d = Path(tmpdir) - subdir = d / 'subdir' - subdir.makedirs() - old_dir = os.getcwd() - with subdir: - assert os.getcwd() == os.path.realpath(subdir) - assert os.getcwd() == old_dir - - def test_touch(self, tmpdir): - # NOTE: This test takes a long time to run (~10 seconds). - # It sleeps several seconds because on Windows, the resolution - # of a file's mtime and ctime is about 2 seconds. - # - # atime isn't tested because on Windows the resolution of atime - # is something like 24 hours. - - threshold = 1 - - d = Path(tmpdir) - f = d / 'test.txt' - t0 = time.time() - threshold - f.touch() - t1 = time.time() + threshold - - assert f.exists() - assert f.isfile() - assert f.size == 0 - assert t0 <= f.mtime <= t1 - if hasattr(os.path, 'getctime'): - ct = f.ctime - assert t0 <= ct <= t1 - - time.sleep(threshold*2) - fobj = open(f, 'ab') - fobj.write('some bytes'.encode('utf-8')) - fobj.close() - - time.sleep(threshold*2) - t2 = time.time() - threshold - f.touch() - t3 = time.time() + threshold - - assert t0 <= t1 < t2 <= t3 # sanity check - - assert f.exists() - assert f.isfile() - assert f.size == 10 - assert t2 <= f.mtime <= t3 - if hasattr(os.path, 'getctime'): - ct2 = f.ctime - if os.name == 'nt': - # On Windows, "ctime" is CREATION time - assert ct == ct2 - assert ct2 < t2 - else: - # On other systems, it might be the CHANGE time - # (especially on Unix, time of inode changes) - assert ct == ct2 or ct2 == f.mtime - - def test_listing(self, tmpdir): - d = Path(tmpdir) - assert d.listdir() == [] - - f = 'testfile.txt' - af = d / f - assert af == os.path.join(d, f) - af.touch() - try: - assert af.exists() - - assert d.listdir() == [af] - - # .glob() - assert d.glob('testfile.txt') == [af] - assert d.glob('test*.txt') == [af] - assert d.glob('*.txt') == [af] - assert d.glob('*txt') == [af] - assert d.glob('*') == [af] - assert d.glob('*.html') == [] - assert d.glob('testfile') == [] - finally: - af.remove() - - # Try a test with 20 files - files = [d / ('%d.txt' % i) for i in range(20)] - for f in files: - fobj = open(f, 'w') - fobj.write('some text\n') - fobj.close() - try: - files2 = d.listdir() - files.sort() - files2.sort() - assert files == files2 - finally: - for f in files: - try: - f.remove() - except: - pass - - def test_listdir_other_encoding(self, tmpdir): - """ - Some filesystems allow non-character sequences in path names. - ``.listdir`` should still function in this case. - See issue #61 for details. - """ - assert Path(tmpdir).listdir() == [] - tmpdir_bytes = str(tmpdir).encode('ascii') - - filename = 'r\xe9\xf1emi'.encode('latin-1') - pathname = os.path.join(tmpdir_bytes, filename) - with open(pathname, 'wb'): - pass - # first demonstrate that os.listdir works - assert os.listdir(tmpdir_bytes) - - # now try with path.py - results = Path(tmpdir).listdir() - assert len(results) == 1 - res, = results - assert isinstance(res, Path) - # OS X seems to encode the bytes in the filename as %XX characters. - if platform.system() == 'Darwin': - assert res.basename() == 'r%E9%F1emi' - return - assert len(res.basename()) == len(filename) - - def test_makedirs(self, tmpdir): - d = Path(tmpdir) - - # Placeholder file so that when removedirs() is called, - # it doesn't remove the temporary directory itself. - tempf = d / 'temp.txt' - tempf.touch() - try: - foo = d / 'foo' - boz = foo / 'bar' / 'baz' / 'boz' - boz.makedirs() - try: - assert boz.isdir() - finally: - boz.removedirs() - assert not foo.exists() - assert d.exists() - - foo.mkdir(0o750) - boz.makedirs(0o700) - try: - assert boz.isdir() - finally: - boz.removedirs() - assert not foo.exists() - assert d.exists() - finally: - os.remove(tempf) - - def assertSetsEqual(self, a, b): - ad = {} - - for i in a: - ad[i] = None - - bd = {} - - for i in b: - bd[i] = None - - assert ad == bd - - def test_shutil(self, tmpdir): - # Note: This only tests the methods exist and do roughly what - # they should, neglecting the details as they are shutil's - # responsibility. - - d = Path(tmpdir) - testDir = d / 'testdir' - testFile = testDir / 'testfile.txt' - testA = testDir / 'A' - testCopy = testA / 'testcopy.txt' - testLink = testA / 'testlink.txt' - testB = testDir / 'B' - testC = testB / 'C' - testCopyOfLink = testC / testA.relpathto(testLink) - - # Create test dirs and a file - testDir.mkdir() - testA.mkdir() - testB.mkdir() - - f = open(testFile, 'w') - f.write('x' * 10000) - f.close() - - # Test simple file copying. - testFile.copyfile(testCopy) - assert testCopy.isfile() - assert testFile.bytes() == testCopy.bytes() - - # Test copying into a directory. - testCopy2 = testA / testFile.name - testFile.copy(testA) - assert testCopy2.isfile() - assert testFile.bytes() == testCopy2.bytes() - - # Make a link for the next test to use. - if hasattr(os, 'symlink'): - testFile.symlink(testLink) - else: - testFile.copy(testLink) # fallback - - # Test copying directory tree. - testA.copytree(testC) - assert testC.isdir() - self.assertSetsEqual( - testC.listdir(), - [testC / testCopy.name, - testC / testFile.name, - testCopyOfLink]) - assert not testCopyOfLink.islink() - - # Clean up for another try. - testC.rmtree() - assert not testC.exists() - - # Copy again, preserving symlinks. - testA.copytree(testC, True) - assert testC.isdir() - self.assertSetsEqual( - testC.listdir(), - [testC / testCopy.name, - testC / testFile.name, - testCopyOfLink]) - if hasattr(os, 'symlink'): - assert testCopyOfLink.islink() - assert testCopyOfLink.readlink() == testFile - - # Clean up. - testDir.rmtree() - assert not testDir.exists() - self.assertList(d.listdir(), []) - - def assertList(self, listing, expected): - assert sorted(listing) == sorted(expected) - - def test_patterns(self, tmpdir): - d = Path(tmpdir) - names = ['x.tmp', 'x.xtmp', 'x2g', 'x22', 'x.txt'] - dirs = [d, d/'xdir', d/'xdir.tmp', d/'xdir.tmp'/'xsubdir'] - - for e in dirs: - if not e.isdir(): - e.makedirs() - - for name in names: - (e/name).touch() - self.assertList(d.listdir('*.tmp'), [d/'x.tmp', d/'xdir.tmp']) - self.assertList(d.files('*.tmp'), [d/'x.tmp']) - self.assertList(d.dirs('*.tmp'), [d/'xdir.tmp']) - self.assertList(d.walk(), [e for e in dirs - if e != d] + [e/n for e in dirs - for n in names]) - self.assertList(d.walk('*.tmp'), - [e/'x.tmp' for e in dirs] + [d/'xdir.tmp']) - self.assertList(d.walkfiles('*.tmp'), [e/'x.tmp' for e in dirs]) - self.assertList(d.walkdirs('*.tmp'), [d/'xdir.tmp']) - - def test_unicode(self, tmpdir): - d = Path(tmpdir) - p = d/'unicode.txt' - - def test(enc): - """ Test that path works with the specified encoding, - which must be capable of representing the entire range of - Unicode codepoints. - """ - - given = ('Hello world\n' - '\u0d0a\u0a0d\u0d15\u0a15\r\n' - '\u0d0a\u0a0d\u0d15\u0a15\x85' - '\u0d0a\u0a0d\u0d15\u0a15\u2028' - '\r' - 'hanging') - clean = ('Hello world\n' - '\u0d0a\u0a0d\u0d15\u0a15\n' - '\u0d0a\u0a0d\u0d15\u0a15\n' - '\u0d0a\u0a0d\u0d15\u0a15\n' - '\n' - 'hanging') - givenLines = [ - ('Hello world\n'), - ('\u0d0a\u0a0d\u0d15\u0a15\r\n'), - ('\u0d0a\u0a0d\u0d15\u0a15\x85'), - ('\u0d0a\u0a0d\u0d15\u0a15\u2028'), - ('\r'), - ('hanging')] - expectedLines = [ - ('Hello world\n'), - ('\u0d0a\u0a0d\u0d15\u0a15\n'), - ('\u0d0a\u0a0d\u0d15\u0a15\n'), - ('\u0d0a\u0a0d\u0d15\u0a15\n'), - ('\n'), - ('hanging')] - expectedLines2 = [ - ('Hello world'), - ('\u0d0a\u0a0d\u0d15\u0a15'), - ('\u0d0a\u0a0d\u0d15\u0a15'), - ('\u0d0a\u0a0d\u0d15\u0a15'), - (''), - ('hanging')] - - # write bytes manually to file - f = codecs.open(p, 'w', enc) - f.write(given) - f.close() - - # test all 3 path read-fully functions, including - # path.lines() in unicode mode. - assert p.bytes() == given.encode(enc) - assert p.text(enc) == clean - assert p.lines(enc) == expectedLines - assert p.lines(enc, retain=False) == expectedLines2 - - # If this is UTF-16, that's enough. - # The rest of these will unfortunately fail because append=True - # mode causes an extra BOM to be written in the middle of the file. - # UTF-16 is the only encoding that has this problem. - if enc == 'UTF-16': - return - - # Write Unicode to file using path.write_text(). - cleanNoHanging = clean + '\n' # This test doesn't work with a - # hanging line. - p.write_text(cleanNoHanging, enc) - p.write_text(cleanNoHanging, enc, append=True) - # Check the result. - expectedBytes = 2 * cleanNoHanging.replace('\n', - os.linesep).encode(enc) - expectedLinesNoHanging = expectedLines[:] - expectedLinesNoHanging[-1] += '\n' - assert p.bytes() == expectedBytes - assert p.text(enc) == 2 * cleanNoHanging - assert p.lines(enc) == 2 * expectedLinesNoHanging - assert p.lines(enc, retain=False) == 2 * expectedLines2 - - # Write Unicode to file using path.write_lines(). - # The output in the file should be exactly the same as last time. - p.write_lines(expectedLines, enc) - p.write_lines(expectedLines2, enc, append=True) - # Check the result. - assert p.bytes() == expectedBytes - - # Now: same test, but using various newline sequences. - # If linesep is being properly applied, these will be converted - # to the platform standard newline sequence. - p.write_lines(givenLines, enc) - p.write_lines(givenLines, enc, append=True) - # Check the result. - assert p.bytes() == expectedBytes - - # Same test, using newline sequences that are different - # from the platform default. - def testLinesep(eol): - p.write_lines(givenLines, enc, linesep=eol) - p.write_lines(givenLines, enc, linesep=eol, append=True) - expected = 2 * cleanNoHanging.replace('\n', eol).encode(enc) - assert p.bytes() == expected - - testLinesep('\n') - testLinesep('\r') - testLinesep('\r\n') - testLinesep('\x0d\x85') - - # Again, but with linesep=None. - p.write_lines(givenLines, enc, linesep=None) - p.write_lines(givenLines, enc, linesep=None, append=True) - # Check the result. - expectedBytes = 2 * given.encode(enc) - assert p.bytes() == expectedBytes - assert p.text(enc) == 2 * clean - expectedResultLines = expectedLines[:] - expectedResultLines[-1] += expectedLines[0] - expectedResultLines += expectedLines[1:] - assert p.lines(enc) == expectedResultLines - - test('UTF-8') - test('UTF-16BE') - test('UTF-16LE') - test('UTF-16') - - def test_chunks(self, tmpdir): - p = (tempdir() / 'test.txt').touch() - txt = "0123456789" - size = 5 - p.write_text(txt) - for i, chunk in enumerate(p.chunks(size)): - assert chunk == txt[i * size:i * size + size] - - assert i == len(txt) / size - 1 - - @pytest.mark.skipif(not hasattr(os.path, 'samefile'), - reason="samefile not present") - def test_samefile(self, tmpdir): - f1 = (tempdir() / '1.txt').touch() - f1.write_text('foo') - f2 = (tempdir() / '2.txt').touch() - f1.write_text('foo') - f3 = (tempdir() / '3.txt').touch() - f1.write_text('bar') - f4 = (tempdir() / '4.txt') - f1.copyfile(f4) - - assert os.path.samefile(f1, f2) == f1.samefile(f2) - assert os.path.samefile(f1, f3) == f1.samefile(f3) - assert os.path.samefile(f1, f4) == f1.samefile(f4) - assert os.path.samefile(f1, f1) == f1.samefile(f1) - - def test_rmtree_p(self, tmpdir): - d = Path(tmpdir) - sub = d / 'subfolder' - sub.mkdir() - (sub / 'afile').write_text('something') - sub.rmtree_p() - assert not sub.exists() - try: - sub.rmtree_p() - except OSError: - self.fail("Calling `rmtree_p` on non-existent directory " - "should not raise an exception.") - - -class TestMergeTree: - @pytest.fixture(autouse=True) - def testing_structure(self, tmpdir): - self.test_dir = Path(tmpdir) - self.subdir_a = self.test_dir / 'A' - self.test_file = self.subdir_a / 'testfile.txt' - self.test_link = self.subdir_a / 'testlink.txt' - self.subdir_b = self.test_dir / 'B' - - self.subdir_a.mkdir() - self.subdir_b.mkdir() - - with open(self.test_file, 'w') as f: - f.write('x' * 10000) - - if hasattr(os, 'symlink'): - self.test_file.symlink(self.test_link) - else: - self.test_file.copy(self.test_link) - - def test_with_nonexisting_dst_kwargs(self): - self.subdir_a.merge_tree(self.subdir_b, symlinks=True) - assert self.subdir_b.isdir() - expected = set(( - self.subdir_b / self.test_file.name, - self.subdir_b / self.test_link.name, - )) - assert set(self.subdir_b.listdir()) == expected - assert Path(self.subdir_b / self.test_link.name).islink() - - def test_with_nonexisting_dst_args(self): - self.subdir_a.merge_tree(self.subdir_b, True) - assert self.subdir_b.isdir() - expected = set(( - self.subdir_b / self.test_file.name, - self.subdir_b / self.test_link.name, - )) - assert set(self.subdir_b.listdir()) == expected - assert Path(self.subdir_b / self.test_link.name).islink() - - def test_with_existing_dst(self): - self.subdir_b.rmtree() - self.subdir_a.copytree(self.subdir_b, True) - - self.test_link.remove() - test_new = self.subdir_a / 'newfile.txt' - test_new.touch() - with open(self.test_file, 'w') as f: - f.write('x' * 5000) - - self.subdir_a.merge_tree(self.subdir_b, True) - - assert self.subdir_b.isdir() - expected = set(( - self.subdir_b / self.test_file.name, - self.subdir_b / self.test_link.name, - self.subdir_b / test_new.name, - )) - assert set(self.subdir_b.listdir()) == expected - assert Path(self.subdir_b / self.test_link.name).islink() - assert len(Path(self.subdir_b / self.test_file.name).bytes()) == 5000 - - def test_copytree_parameters(self): - """ - merge_tree should accept parameters to copytree, such as 'ignore' - """ - ignore = shutil.ignore_patterns('testlink*') - self.subdir_a.merge_tree(self.subdir_b, ignore=ignore) - - assert self.subdir_b.isdir() - assert self.subdir_b.listdir() == [self.subdir_b / self.test_file.name] - - -class TestChdir: - def test_chdir_or_cd(self, tmpdir): - """ tests the chdir or cd method """ - d = Path(str(tmpdir)) - cwd = d.getcwd() - - # ensure the cwd isn't our tempdir - assert str(d) != str(cwd) - # now, we're going to chdir to tempdir - d.chdir() - - # we now ensure that our cwd is the tempdir - assert str(d.getcwd()) == str(tmpdir) - # we're resetting our path - d = Path(cwd) - - # we ensure that our cwd is still set to tempdir - assert str(d.getcwd()) == str(tmpdir) - - # we're calling the alias cd method - d.cd() - # now, we ensure cwd isn'r tempdir - assert str(d.getcwd()) == str(cwd) - assert str(d.getcwd()) != str(tmpdir) - - -class TestSubclass: - class PathSubclass(Path): - pass - - def test_subclass_produces_same_class(self): - """ - When operations are invoked on a subclass, they should produce another - instance of that subclass. - """ - p = self.PathSubclass('/foo') - subdir = p / 'bar' - assert isinstance(subdir, self.PathSubclass) - - -class TestTempDir: - - def test_constructor(self): - """ - One should be able to readily construct a temporary directory - """ - d = tempdir() - assert isinstance(d, Path) - assert d.exists() - assert d.isdir() - d.rmdir() - assert not d.exists() - - def test_next_class(self): - """ - It should be possible to invoke operations on a tempdir and get - Path classes. - """ - d = tempdir() - sub = d / 'subdir' - assert isinstance(sub, Path) - d.rmdir() - - def test_context_manager(self): - """ - One should be able to use a tempdir object as a context, which will - clean up the contents after. - """ - d = tempdir() - res = d.__enter__() - assert res is d - (d / 'somefile.txt').touch() - assert not isinstance(d / 'somefile.txt', tempdir) - d.__exit__(None, None, None) - assert not d.exists() - - def test_context_manager_exception(self): - """ - The context manager will not clean up if an exception occurs. - """ - d = tempdir() - d.__enter__() - (d / 'somefile.txt').touch() - assert not isinstance(d / 'somefile.txt', tempdir) - d.__exit__(TypeError, TypeError('foo'), None) - assert d.exists() - - def test_context_manager_using_with(self): - """ - The context manager will allow using the with keyword and - provide a temporry directory that will be deleted after that. - """ - - with tempdir() as d: - assert d.isdir() - assert not d.isdir() - - -class TestUnicode: - @pytest.fixture(autouse=True) - def unicode_name_in_tmpdir(self, tmpdir): - # build a snowman (dir) in the temporary directory - Path(tmpdir).joinpath('☃').mkdir() - - def test_walkdirs_with_unicode_name(self, tmpdir): - for res in Path(tmpdir).walkdirs(): - pass - - -class TestPatternMatching: - def test_fnmatch_simple(self): - p = Path('FooBar') - assert p.fnmatch('Foo*') - assert p.fnmatch('Foo[ABC]ar') - - def test_fnmatch_custom_mod(self): - p = Path('FooBar') - p.module = ntpath - assert p.fnmatch('foobar') - assert p.fnmatch('FOO[ABC]AR') - - def test_fnmatch_custom_normcase(self): - normcase = lambda path: path.upper() - p = Path('FooBar') - assert p.fnmatch('foobar', normcase=normcase) - assert p.fnmatch('FOO[ABC]AR', normcase=normcase) - - def test_listdir_simple(self): - p = Path('.') - assert len(p.listdir()) == len(os.listdir('.')) - - def test_listdir_empty_pattern(self): - p = Path('.') - assert p.listdir('') == [] - - def test_listdir_patterns(self, tmpdir): - p = Path(tmpdir) - (p/'sub').mkdir() - (p/'File').touch() - assert p.listdir('s*') == [p / 'sub'] - assert len(p.listdir('*')) == 2 - - def test_listdir_custom_module(self, tmpdir): - """ - Listdir patterns should honor the case sensitivity of the path module - used by that Path class. - """ - always_unix = Path.using_module(posixpath) - p = always_unix(tmpdir) - (p/'sub').mkdir() - (p/'File').touch() - assert p.listdir('S*') == [] - - always_win = Path.using_module(ntpath) - p = always_win(tmpdir) - assert p.listdir('S*') == [p/'sub'] - assert p.listdir('f*') == [p/'File'] - - def test_listdir_case_insensitive(self, tmpdir): - """ - Listdir patterns should honor the case sensitivity of the path module - used by that Path class. - """ - p = Path(tmpdir) - (p/'sub').mkdir() - (p/'File').touch() - assert p.listdir(ci('S*')) == [p/'sub'] - assert p.listdir(ci('f*')) == [p/'File'] - assert p.files(ci('S*')) == [] - assert p.dirs(ci('f*')) == [] - - def test_walk_case_insensitive(self, tmpdir): - p = Path(tmpdir) - (p/'sub1'/'foo').makedirs_p() - (p/'sub2'/'foo').makedirs_p() - (p/'sub1'/'foo'/'bar.Txt').touch() - (p/'sub2'/'foo'/'bar.TXT').touch() - (p/'sub2'/'foo'/'bar.txt.bz2').touch() - files = list(p.walkfiles(ci('*.txt'))) - assert len(files) == 2 - assert p/'sub2'/'foo'/'bar.TXT' in files - assert p/'sub1'/'foo'/'bar.Txt' in files - -@pytest.mark.skipif(sys.version_info < (2, 6), - reason="in_place requires io module in Python 2.6") -class TestInPlace: - reference_content = textwrap.dedent(""" - The quick brown fox jumped over the lazy dog. - """.lstrip()) - reversed_content = textwrap.dedent(""" - .god yzal eht revo depmuj xof nworb kciuq ehT - """.lstrip()) - alternate_content = textwrap.dedent(""" - Lorem ipsum dolor sit amet, consectetur adipisicing elit, - sed do eiusmod tempor incididunt ut labore et dolore magna - aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. - Duis aute irure dolor in reprehenderit in voluptate velit - esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui - officia deserunt mollit anim id est laborum. - """.lstrip()) - - @classmethod - def create_reference(cls, tmpdir): - p = Path(tmpdir)/'document' - with p.open('w') as stream: - stream.write(cls.reference_content) - return p - - def test_line_by_line_rewrite(self, tmpdir): - doc = self.create_reference(tmpdir) - # reverse all the text in the document, line by line - with doc.in_place() as (reader, writer): - for line in reader: - r_line = ''.join(reversed(line.strip())) + '\n' - writer.write(r_line) - with doc.open() as stream: - data = stream.read() - assert data == self.reversed_content - - def test_exception_in_context(self, tmpdir): - doc = self.create_reference(tmpdir) - with pytest.raises(RuntimeError) as exc: - with doc.in_place() as (reader, writer): - writer.write(self.alternate_content) - raise RuntimeError("some error") - assert "some error" in str(exc) - with doc.open() as stream: - data = stream.read() - assert not 'Lorem' in data - assert 'lazy dog' in data - - -class TestSpecialPaths: - @pytest.fixture(autouse=True, scope='class') - def appdirs_installed(cls): - pytest.importorskip('appdirs') - - @pytest.fixture - def feign_linux(self, monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Linux") - monkeypatch.setattr("sys.platform", "linux") - monkeypatch.setattr("os.pathsep", ":") - # remove any existing import of appdirs, as it sets up some - # state during import. - sys.modules.pop('appdirs') - - def test_basic_paths(self): - appdirs = importlib.import_module('appdirs') - - expected = appdirs.user_config_dir() - assert SpecialResolver(Path).user.config == expected - - expected = appdirs.site_config_dir() - assert SpecialResolver(Path).site.config == expected - - expected = appdirs.user_config_dir('My App', 'Me') - assert SpecialResolver(Path, 'My App', 'Me').user.config == expected - - def test_unix_paths(self, tmpdir, monkeypatch, feign_linux): - fake_config = tmpdir / '_config' - monkeypatch.setitem(os.environ, 'XDG_CONFIG_HOME', str(fake_config)) - expected = str(tmpdir / '_config') - assert SpecialResolver(Path).user.config == expected - - def test_unix_paths_fallback(self, tmpdir, monkeypatch, feign_linux): - "Without XDG_CONFIG_HOME set, ~/.config should be used." - fake_home = tmpdir / '_home' - monkeypatch.setitem(os.environ, 'HOME', str(fake_home)) - expected = str(tmpdir / '_home' / '.config') - assert SpecialResolver(Path).user.config == expected - - def test_property(self): - assert isinstance(Path.special().user.config, Path) - assert isinstance(Path.special().user.data, Path) - assert isinstance(Path.special().user.cache, Path) - - def test_other_parameters(self): - """ - Other parameters should be passed through to appdirs function. - """ - res = Path.special(version="1.0", multipath=True).site.config - assert isinstance(res, Path) - - def test_multipath(self, feign_linux, monkeypatch, tmpdir): - """ - If multipath is provided, on Linux return the XDG_CONFIG_DIRS - """ - fake_config_1 = str(tmpdir / '_config1') - fake_config_2 = str(tmpdir / '_config2') - config_dirs = os.pathsep.join([fake_config_1, fake_config_2]) - monkeypatch.setitem(os.environ, 'XDG_CONFIG_DIRS', config_dirs) - res = Path.special(multipath=True).site.config - assert isinstance(res, Multi) - assert fake_config_1 in res - assert fake_config_2 in res - assert '_config1' in str(res) - - def test_reused_SpecialResolver(self): - """ - Passing additional args and kwargs to SpecialResolver should be - passed through to each invocation of the function in appdirs. - """ - appdirs = importlib.import_module('appdirs') - - adp = SpecialResolver(Path, version="1.0") - res = adp.user.config - - expected = appdirs.user_config_dir(version="1.0") - assert res == expected - - -class TestMultiPath: - def test_for_class(self): - """ - Multi.for_class should return a subclass of the Path class provided. - """ - cls = Multi.for_class(Path) - assert issubclass(cls, Path) - assert issubclass(cls, Multi) - assert cls.__name__ == 'MultiPath' - - def test_detect_no_pathsep(self): - """ - If no pathsep is provided, multipath detect should return an instance - of the parent class with no Multi mix-in. - """ - path = Multi.for_class(Path).detect('/foo/bar') - assert isinstance(path, Path) - assert not isinstance(path, Multi) - - def test_detect_with_pathsep(self): - """ - If a pathsep appears in the input, detect should return an instance - of a Path with the Multi mix-in. - """ - inputs = '/foo/bar', '/baz/bing' - input = os.pathsep.join(inputs) - path = Multi.for_class(Path).detect(input) - - assert isinstance(path, Multi) - - def test_iteration(self): - """ - Iterating over a MultiPath should yield instances of the - parent class. - """ - inputs = '/foo/bar', '/baz/bing' - input = os.pathsep.join(inputs) - path = Multi.for_class(Path).detect(input) - - items = iter(path) - first = next(items) - assert first == '/foo/bar' - assert isinstance(first, Path) - assert not isinstance(first, Multi) - assert next(items) == '/baz/bing' - assert path == input - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..bdac1531 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import sys + + +def pytest_configure(config): + disable_broken_doctests(config) + + +def disable_broken_doctests(config): + """ + Workaround for python/cpython#117692. + """ + if (3, 11, 9) <= sys.version_info < (3, 12): + config.option.doctestmodules = False diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 00000000..b424bb6b --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,1400 @@ +""" +Tests for the path module. + +This suite runs on Linux, macOS, and Windows. To extend the +platform support, just add appropriate pathnames for your +platform (os.name) in each place where the p() function is called. +Then report the result. If you can't get the test to run at all on +your platform, there's probably a bug -- please report the issue +in the issue tracker. + +TestScratchDir.test_touch() takes a while to run. It sleeps a few +seconds to allow some time to pass between calls to check the modify +time on files. +""" + +import contextlib +import datetime +import importlib +import ntpath +import os +import platform +import posixpath +import re +import shutil +import stat +import subprocess +import sys +import textwrap +import time +import types + +import pytest +from more_itertools import ilen + +import path +from path import Multi, Path, SpecialResolver, TempDir, matchers + + +def os_choose(**choices): + """Choose a value from several possible values, based on os.name""" + return choices[os.name] + + +class TestBasics: + def test_relpath(self): + root = Path(os_choose(nt='C:\\', posix='/')) + foo = root / 'foo' + quux = foo / 'quux' + bar = foo / 'bar' + boz = bar / 'Baz' / 'Boz' + up = Path(os.pardir) + + # basics + assert root.relpathto(boz) == Path('foo') / 'bar' / 'Baz' / 'Boz' + assert bar.relpathto(boz) == Path('Baz') / 'Boz' + assert quux.relpathto(boz) == up / 'bar' / 'Baz' / 'Boz' + assert boz.relpathto(quux) == up / up / up / 'quux' + assert boz.relpathto(bar) == up / up + + # Path is not the first element in concatenation + assert root.relpathto(boz) == 'foo' / Path('bar') / 'Baz' / 'Boz' + + # x.relpathto(x) == curdir + assert root.relpathto(root) == os.curdir + assert boz.relpathto(boz) == os.curdir + # Make sure case is properly noted (or ignored) + assert boz.relpathto(boz.normcase()) == os.curdir + + # relpath() + cwd = Path(os.getcwd()) + assert boz.relpath() == cwd.relpathto(boz) + + if os.name == 'nt': # pragma: nocover + # Check relpath across drives. + d = Path('D:\\') + assert d.relpathto(boz) == boz + + def test_construction_without_args(self): + """ + Path class will construct a path to current directory when called with no arguments. + """ + assert Path() == '.' + + def test_construction_from_none(self): + """ """ + with pytest.raises(TypeError): + Path(None) + + def test_construction_from_int(self): + """ + Path class will construct a path as a string of the number + """ + assert Path(1) == '1' + + def test_string_compatibility(self): + """Test compatibility with ordinary strings.""" + x = Path('xyzzy') + assert x == 'xyzzy' + assert x == 'xyzzy' + + # sorting + items = [Path('fhj'), Path('fgh'), 'E', Path('d'), 'A', Path('B'), 'c'] + items.sort() + assert items == ['A', 'B', 'E', 'c', 'd', 'fgh', 'fhj'] + + # Test p1/p1. + p1 = Path("foo") + p2 = Path("bar") + assert p1 / p2 == os_choose(nt='foo\\bar', posix='foo/bar') + + def test_properties(self): + # Create sample path object. + f = Path( + os_choose( + nt='C:\\Program Files\\Python\\Lib\\xyzzy.py', + posix='/usr/local/python/lib/xyzzy.py', + ) + ) + + # .parent + nt_lib = 'C:\\Program Files\\Python\\Lib' + posix_lib = '/usr/local/python/lib' + expected = os_choose(nt=nt_lib, posix=posix_lib) + assert f.parent == expected + + # .name + assert f.name == 'xyzzy.py' + assert f.parent.name == os_choose(nt='Lib', posix='lib') + + # .suffix + assert f.suffix == '.py' + assert f.parent.suffix == '' + + # .drive + assert f.drive == os_choose(nt='C:', posix='') + + def test_absolute(self): + assert Path(os.curdir).absolute() == os.getcwd() + + def test_cwd(self): + cwd = Path.cwd() + assert isinstance(cwd, Path) + assert cwd == os.getcwd() + + def test_home(self): + home = Path.home() + assert isinstance(home, Path) + assert home == os.path.expanduser('~') + + def test_explicit_module(self): + """ + The user may specify an explicit path module to use. + """ + nt_ok = Path.using_module(ntpath)(r'foo\bar\baz') + posix_ok = Path.using_module(posixpath)(r'foo/bar/baz') + posix_wrong = Path.using_module(posixpath)(r'foo\bar\baz') + + assert nt_ok.dirname() == r'foo\bar' + assert posix_ok.dirname() == r'foo/bar' + assert posix_wrong.dirname() == '' + + assert nt_ok / 'quux' == r'foo\bar\baz\quux' + assert posix_ok / 'quux' == r'foo/bar/baz/quux' + + def test_explicit_module_classes(self): + """ + Multiple calls to path.using_module should produce the same class. + """ + nt_path = Path.using_module(ntpath) + assert nt_path is Path.using_module(ntpath) + assert nt_path.__name__ == 'Path_ntpath' + + def test_joinpath_on_instance(self): + res = Path('foo') + foo_bar = res.joinpath('bar') + assert foo_bar == os_choose(nt='foo\\bar', posix='foo/bar') + + def test_joinpath_to_nothing(self): + res = Path('foo') + assert res.joinpath() == res + + def test_joinpath_on_class(self): + "Construct a path from a series of strings" + foo_bar = Path.joinpath('foo', 'bar') + assert foo_bar == os_choose(nt='foo\\bar', posix='foo/bar') + + def test_joinpath_fails_on_empty(self): + "It doesn't make sense to join nothing at all" + with pytest.raises(TypeError): + Path.joinpath() + + def test_joinpath_returns_same_type(self): + path_posix = Path.using_module(posixpath) + res = path_posix.joinpath('foo') + assert isinstance(res, path_posix) + res2 = res.joinpath('bar') + assert isinstance(res2, path_posix) + assert res2 == 'foo/bar' + + def test_radd_string(self): + res = 'foo' + Path('bar') + assert res == Path('foobar') + + def test_fspath(self): + os.fspath(Path('foobar')) + + def test_normpath(self): + assert Path('foo//bar').normpath() == os.path.normpath('foo//bar') + + def test_expandvars(self, monkeypatch): + monkeypatch.setitem(os.environ, 'sub', 'value') + val = '$sub/$(sub)' + assert Path(val).expandvars() == os.path.expandvars(val) + assert 'value' in Path(val).expandvars() + + def test_expand(self): + val = 'foobar' + expected = os.path.normpath(os.path.expanduser(os.path.expandvars(val))) + assert Path(val).expand() == expected + + def test_splitdrive(self): + val = Path.using_module(ntpath)(r'C:\bar') + drive, rest = val.splitdrive() + assert drive == 'C:' + assert rest == r'\bar' + assert isinstance(rest, Path) + + def test_relpathto(self): + source = Path.using_module(ntpath)(r'C:\foo') + dest = Path.using_module(ntpath)(r'D:\bar') + assert source.relpathto(dest) == dest + + def test_walk_errors(self): + start = Path('/does-not-exist') + items = list(start.walk(errors='ignore')) + assert not items + + def test_walk_child_error(self, tmpdir): + def simulate_access_denied(item): + if item.name == 'sub1': + raise OSError("Access denied") + + p = Path(tmpdir) + (p / 'sub1').makedirs_p() + items = path.Traversal(simulate_access_denied)(p.walk(errors='ignore')) + assert list(items) == [p / 'sub1'] + + def test_read_md5(self, tmpdir): + target = Path(tmpdir) / 'some file' + target.write_text('quick brown fox and lazy dog') + assert target.read_md5() == b's\x15\rPOW\x7fYk\xa8\x8e\x00\x0b\xd7G\xf9' + + def test_read_hexhash(self, tmpdir): + target = Path(tmpdir) / 'some file' + target.write_text('quick brown fox and lazy dog') + assert target.read_hexhash('md5') == '73150d504f577f596ba88e000bd747f9' + + @pytest.mark.skipif("not hasattr(os, 'statvfs')") + def test_statvfs(self): + Path('.').statvfs() + + @pytest.mark.skipif("not hasattr(os, 'pathconf')") + def test_pathconf(self): + assert isinstance(Path('.').pathconf(1), int) + + def test_utime(self, tmpdir): + tmpfile = Path(tmpdir) / 'file' + tmpfile.touch() + new_time = (time.time() - 600,) * 2 + assert Path(tmpfile).utime(new_time).stat().st_atime == new_time[0] + + def test_chmod_str(self, tmpdir): + tmpfile = Path(tmpdir) / 'file' + tmpfile.touch() + tmpfile.chmod('o-r') + is_windows = platform.system() == 'Windows' + assert is_windows or not (tmpfile.stat().st_mode & stat.S_IROTH) + + @pytest.mark.skipif("not hasattr(Path, 'chown')") + def test_chown(self, tmpdir): + tmpfile = Path(tmpdir) / 'file' + tmpfile.touch() + tmpfile.chown(os.getuid(), os.getgid()) + import pwd + + name = pwd.getpwuid(os.getuid()).pw_name + tmpfile.chown(name) + + def test_renames(self, tmpdir): + tmpfile = Path(tmpdir) / 'file' + tmpfile.touch() + tmpfile.renames(Path(tmpdir) / 'foo' / 'alt') + + def test_mkdir_p(self, tmpdir): + Path(tmpdir).mkdir_p() + + def test_removedirs_p(self, tmpdir): + dir = Path(tmpdir) / 'somedir' + dir.mkdir() + (dir / 'file').touch() + (dir / 'sub').mkdir() + dir.removedirs_p() + assert dir.is_dir() + assert (dir / 'file').is_file() + # TODO: shouldn't sub get removed? + # assert not (dir / 'sub').is_dir() + + @pytest.mark.skipif("not hasattr(Path, 'group')") + def test_group(self, tmpdir): + file = Path(tmpdir).joinpath('file').touch() + assert isinstance(file.group(), str) + + +class TestReadWriteText: + def test_read_write(self, tmpdir): + file = path.Path(tmpdir) / 'filename' + file.write_text('hello world', encoding='utf-8') + assert file.read_text(encoding='utf-8') == 'hello world' + assert file.read_bytes() == b'hello world' + + +class TestPerformance: + @staticmethod + def get_command_time(cmd): + args = [sys.executable, '-m', 'timeit', '-n', '1', '-r', '1', '-u', 'usec'] + [ + cmd + ] + res = subprocess.check_output(args, text=True, encoding='utf-8') + dur = re.search(r'(\d+) usec per loop', res).group(1) + return datetime.timedelta(microseconds=int(dur)) + + def test_import_time(self, monkeypatch): + """ + Import should take less than some limit. + + Run tests in a subprocess to isolate from test suite overhead. + """ + limit = datetime.timedelta(milliseconds=20) + baseline = self.get_command_time('pass') + measure = self.get_command_time('import path') + duration = measure - baseline + assert duration < limit + + +class TestOwnership: + @pytest.mark.skipif('platform.system() == "Windows" and sys.version_info > (3, 12)') + def test_get_owner(self): + Path('/').get_owner() + + +class TestLinks: + def test_hardlink_to(self, tmpdir): + target = Path(tmpdir) / 'target' + target.write_text('hello', encoding='utf-8') + link = Path(tmpdir).joinpath('link') + link.hardlink_to(target) + assert link.read_text(encoding='utf-8') == 'hello' + + def test_link(self, tmpdir): + target = Path(tmpdir) / 'target' + target.write_text('hello', encoding='utf-8') + link = target.link(Path(tmpdir) / 'link') + assert link.read_text(encoding='utf-8') == 'hello' + + def test_symlink_to(self, tmpdir): + target = Path(tmpdir) / 'target' + target.write_text('hello', encoding='utf-8') + link = Path(tmpdir).joinpath('link') + link.symlink_to(target) + assert link.read_text(encoding='utf-8') == 'hello' + + def test_symlink_none(self, tmpdir): + root = Path(tmpdir) + with root: + file = (Path('dir').mkdir() / 'file').touch() + file.symlink() + assert Path('file').is_file() + + def test_readlinkabs_passthrough(self, tmpdir): + link = Path(tmpdir) / 'link' + Path('foo').absolute().symlink(link) + assert link.readlinkabs() == Path('foo').absolute() + + def test_readlinkabs_rendered(self, tmpdir): + link = Path(tmpdir) / 'link' + Path('foo').symlink(link) + assert link.readlinkabs() == Path(tmpdir) / 'foo' + + +class TestSymbolicLinksWalk: + def test_skip_symlinks(self, tmpdir): + root = Path(tmpdir) + sub = root / 'subdir' + sub.mkdir() + sub.symlink(root / 'link') + (sub / 'file').touch() + assert len(list(root.walk())) == 4 + + skip_links = path.Traversal( + lambda item: item.is_dir() and not item.islink(), + ) + assert len(list(skip_links(root.walk()))) == 3 + + +class TestSelfReturn: + """ + Some methods don't necessarily return any value (e.g. makedirs, + makedirs_p, rename, mkdir, touch, chroot). These methods should return + self anyhow to allow methods to be chained. + """ + + def test_makedirs_p(self, tmpdir): + """ + Path('foo').makedirs_p() == Path('foo') + """ + p = Path(tmpdir) / "newpath" + ret = p.makedirs_p() + assert p == ret + + def test_makedirs_p_extant(self, tmpdir): + p = Path(tmpdir) + ret = p.makedirs_p() + assert p == ret + + def test_rename(self, tmpdir): + p = Path(tmpdir) / "somefile" + p.touch() + target = Path(tmpdir) / "otherfile" + ret = p.rename(target) + assert target == ret + + def test_mkdir(self, tmpdir): + p = Path(tmpdir) / "newdir" + ret = p.mkdir() + assert p == ret + + def test_touch(self, tmpdir): + p = Path(tmpdir) / "empty file" + ret = p.touch() + assert p == ret + + +@pytest.mark.skipif("not hasattr(Path, 'chroot')") +def test_chroot(monkeypatch): + results = [] + monkeypatch.setattr(os, 'chroot', results.append) + Path().chroot() + assert results == [Path()] + + +@pytest.mark.skipif("not hasattr(Path, 'startfile')") +def test_startfile(monkeypatch): + results = [] + monkeypatch.setattr(os, 'startfile', results.append) + Path().startfile() + assert results == [Path()] + + +class TestScratchDir: + """ + Tests that run in a temporary directory (does not test TempDir class) + """ + + def test_context_manager(self, tmpdir): + """Can be used as context manager for chdir.""" + d = Path(tmpdir) + subdir = d / 'subdir' + subdir.makedirs() + old_dir = os.getcwd() + with subdir: + assert os.getcwd() == os.path.realpath(subdir) + assert os.getcwd() == old_dir + + def test_touch(self, tmpdir): + # NOTE: This test takes a long time to run (~10 seconds). + # It sleeps several seconds because on Windows, the resolution + # of a file's mtime and ctime is about 2 seconds. + # + # atime isn't tested because on Windows the resolution of atime + # is something like 24 hours. + + threshold = 1 + + d = Path(tmpdir) + f = d / 'test.txt' + t0 = time.time() - threshold + f.touch() + t1 = time.time() + threshold + + assert f.exists() + assert f.is_file() + assert f.size == 0 + assert t0 <= f.mtime <= t1 + if hasattr(os.path, 'getctime'): + ct = f.ctime + assert t0 <= ct <= t1 + + time.sleep(threshold * 2) + with open(f, 'ab') as fobj: + fobj.write(b'some bytes') + + time.sleep(threshold * 2) + t2 = time.time() - threshold + f.touch() + t3 = time.time() + threshold + + assert t0 <= t1 < t2 <= t3 # sanity check + + assert f.exists() + assert f.is_file() + assert f.size == 10 + assert t2 <= f.mtime <= t3 + if hasattr(os.path, 'getctime'): + ct2 = f.ctime + if platform.system() == 'Windows': # pragma: nocover + # On Windows, "ctime" is CREATION time + assert ct == ct2 + assert ct2 < t2 + else: + assert ( + # ctime is unchanged + ct == ct2 + or + # ctime is approximately the mtime + ct2 == pytest.approx(f.mtime, 0.001) + ) + + def test_listing(self, tmpdir): + d = Path(tmpdir) + assert list(d.iterdir()) == [] + + f = 'testfile.txt' + af = d / f + assert af == os.path.join(d, f) + af.touch() + try: + assert af.exists() + + assert list(d.iterdir()) == [af] + + # .glob() + assert d.glob('testfile.txt') == [af] + assert d.glob('test*.txt') == [af] + assert d.glob('*.txt') == [af] + assert d.glob('*txt') == [af] + assert d.glob('*') == [af] + assert d.glob('*.html') == [] + assert d.glob('testfile') == [] + + # .iglob matches .glob but as an iterator. + assert list(d.iglob('*')) == d.glob('*') + assert isinstance(d.iglob('*'), types.GeneratorType) + + finally: + af.remove() + + # Try a test with 20 files + files = [d / ('%d.txt' % i) for i in range(20)] + for f in files: + with open(f, 'w', encoding='utf-8') as fobj: + fobj.write('some text\n') + try: + files2 = list(d.iterdir()) + files.sort() + files2.sort() + assert files == files2 + finally: + for f in files: + with contextlib.suppress(Exception): + f.remove() + + @pytest.fixture + def bytes_filename(self, tmpdir): + name = rb'r\xe9\xf1emi' + base = str(tmpdir).encode('ascii') + try: + with open(os.path.join(base, name), 'wb'): + pass + except Exception as exc: + raise pytest.skip(f"Invalid encodings disallowed {exc}") from exc + return name + + def test_iterdir_other_encoding(self, tmpdir, bytes_filename): # pragma: nocover + """ + Some filesystems allow non-character sequences in path names. + ``.iterdir`` should still function in this case. + See issue #61 for details. + """ + # first demonstrate that os.listdir works + assert os.listdir(str(tmpdir).encode('ascii')) + + # now try with path + results = Path(tmpdir).iterdir() + (res,) = results + assert isinstance(res, Path) + assert len(res.basename()) == len(bytes_filename) + + def test_makedirs(self, tmpdir): + d = Path(tmpdir) + + # Placeholder file so that when removedirs() is called, + # it doesn't remove the temporary directory itself. + tempf = d / 'temp.txt' + tempf.touch() + try: + foo = d / 'foo' + boz = foo / 'bar' / 'baz' / 'boz' + boz.makedirs() + try: + assert boz.is_dir() + finally: + boz.removedirs() + assert not foo.exists() + assert d.exists() + + foo.mkdir(0o750) + boz.makedirs(0o700) + try: + assert boz.is_dir() + finally: + boz.removedirs() + assert not foo.exists() + assert d.exists() + finally: + os.remove(tempf) + + def assertSetsEqual(self, a, b): + ad = {} + + for i in a: + ad[i] = None + + bd = {} + + for i in b: + bd[i] = None + + assert ad == bd + + def test_shutil(self, tmpdir): + # Note: This only tests the methods exist and do roughly what + # they should, neglecting the details as they are shutil's + # responsibility. + + d = Path(tmpdir) + testDir = d / 'testdir' + testFile = testDir / 'testfile.txt' + testA = testDir / 'A' + testCopy = testA / 'testcopy.txt' + testLink = testA / 'testlink.txt' + testB = testDir / 'B' + testC = testB / 'C' + testCopyOfLink = testC / testA.relpathto(testLink) + + # Create test dirs and a file + testDir.mkdir() + testA.mkdir() + testB.mkdir() + + with open(testFile, 'w', encoding='utf-8') as f: + f.write('x' * 10000) + + # Test simple file copying. + testFile.copyfile(testCopy) + assert testCopy.is_file() + assert testFile.bytes() == testCopy.bytes() + + # Test copying into a directory. + testCopy2 = testA / testFile.name + testFile.copy(testA) + assert testCopy2.is_file() + assert testFile.bytes() == testCopy2.bytes() + + # Make a link for the next test to use. + testFile.symlink(testLink) + + # Test copying directory tree. + testA.copytree(testC) + assert testC.is_dir() + self.assertSetsEqual( + testC.iterdir(), + [testC / testCopy.name, testC / testFile.name, testCopyOfLink], + ) + assert not testCopyOfLink.islink() + + # Clean up for another try. + testC.rmtree() + assert not testC.exists() + + # Copy again, preserving symlinks. + testA.copytree(testC, True) + assert testC.is_dir() + self.assertSetsEqual( + testC.iterdir(), + [testC / testCopy.name, testC / testFile.name, testCopyOfLink], + ) + if hasattr(os, 'symlink'): + assert testCopyOfLink.islink() + assert testCopyOfLink.realpath() == testFile + + # Clean up. + testDir.rmtree() + assert not testDir.exists() + self.assertList(d.iterdir(), []) + + def assertList(self, listing, expected): + assert sorted(listing) == sorted(expected) + + def test_patterns(self, tmpdir): + d = Path(tmpdir) + names = ['x.tmp', 'x.xtmp', 'x2g', 'x22', 'x.txt'] + dirs = [d, d / 'xdir', d / 'xdir.tmp', d / 'xdir.tmp' / 'xsubdir'] + + for e in dirs: + if not e.is_dir(): + e.makedirs() + + for name in names: + (e / name).touch() + self.assertList(d.iterdir('*.tmp'), [d / 'x.tmp', d / 'xdir.tmp']) + self.assertList(d.files('*.tmp'), [d / 'x.tmp']) + self.assertList(d.dirs('*.tmp'), [d / 'xdir.tmp']) + self.assertList( + d.walk(), [e for e in dirs if e != d] + [e / n for e in dirs for n in names] + ) + self.assertList(d.walk('*.tmp'), [e / 'x.tmp' for e in dirs] + [d / 'xdir.tmp']) + self.assertList(d.walkfiles('*.tmp'), [e / 'x.tmp' for e in dirs]) + self.assertList(d.walkdirs('*.tmp'), [d / 'xdir.tmp']) + + encodings = 'UTF-8', 'UTF-16BE', 'UTF-16LE', 'UTF-16' + + @pytest.mark.parametrize("encoding", encodings) + def test_unicode(self, tmpdir, encoding): + """Test that path works with the specified encoding, + which must be capable of representing the entire range of + Unicode codepoints. + """ + d = Path(tmpdir) + p = d / 'unicode.txt' + + givenLines = [ + 'Hello world\n', + '\u0d0a\u0a0d\u0d15\u0a15\r\n', + '\u0d0a\u0a0d\u0d15\u0a15\x85', + '\u0d0a\u0a0d\u0d15\u0a15\u2028', + '\r', + 'hanging', + ] + given = ''.join(givenLines) + expectedLines = [ + 'Hello world\n', + '\u0d0a\u0a0d\u0d15\u0a15\n', + '\u0d0a\u0a0d\u0d15\u0a15\n', + '\u0d0a\u0a0d\u0d15\u0a15\n', + '\n', + 'hanging', + ] + clean = ''.join(expectedLines) + stripped = [line.replace('\n', '') for line in expectedLines] + + # write bytes manually to file + with open(p, 'wb') as strm: + strm.write(given.encode(encoding)) + + # test read-fully functions, including + # path.lines() in unicode mode. + assert p.read_bytes() == given.encode(encoding) + assert p.lines(encoding) == expectedLines + assert p.lines(encoding, retain=False) == stripped + + # If this is UTF-16, that's enough. + # The rest of these will unfortunately fail because append=True + # mode causes an extra BOM to be written in the middle of the file. + # UTF-16 is the only encoding that has this problem. + if encoding == 'UTF-16': + return + + # Write Unicode to file using path.write_text(). + # This test doesn't work with a hanging line. + cleanNoHanging = clean + '\n' + + p.write_text(cleanNoHanging, encoding) + p.write_text(cleanNoHanging, encoding, append=True) + # Check the result. + expectedBytes = 2 * cleanNoHanging.replace('\n', os.linesep).encode(encoding) + expectedLinesNoHanging = expectedLines[:] + expectedLinesNoHanging[-1] += '\n' + assert p.bytes() == expectedBytes + assert p.read_text(encoding) == 2 * cleanNoHanging + assert p.lines(encoding) == 2 * expectedLinesNoHanging + assert p.lines(encoding, retain=False) == 2 * stripped + + # Write Unicode to file using path.write_lines(). + # The output in the file should be exactly the same as last time. + p.write_lines(expectedLines, encoding) + p.write_lines(stripped, encoding, append=True) + # Check the result. + assert p.bytes() == expectedBytes + + # Now: same test, but using various newline sequences. + # If linesep is being properly applied, these will be converted + # to the platform standard newline sequence. + p.write_lines(givenLines, encoding) + p.write_lines(givenLines, encoding, append=True) + # Check the result. + assert p.bytes() == expectedBytes + + def test_chunks(self, tmpdir): + p = (TempDir() / 'test.txt').touch() + txt = "0123456789" + size = 5 + p.write_text(txt, encoding='utf-8') + for i, chunk in enumerate(p.chunks(size, encoding='utf-8')): + assert chunk == txt[i * size : i * size + size] + + assert i == len(txt) / size - 1 + + def test_samefile(self, tmpdir): + f1 = (TempDir() / '1.txt').touch() + f1.write_text('foo') + f2 = (TempDir() / '2.txt').touch() + f1.write_text('foo') + f3 = (TempDir() / '3.txt').touch() + f1.write_text('bar') + f4 = TempDir() / '4.txt' + f1.copyfile(f4) + + assert os.path.samefile(f1, f2) == f1.samefile(f2) + assert os.path.samefile(f1, f3) == f1.samefile(f3) + assert os.path.samefile(f1, f4) == f1.samefile(f4) + assert os.path.samefile(f1, f1) == f1.samefile(f1) + + def test_rmtree_p(self, tmpdir): + d = Path(tmpdir) + sub = d / 'subfolder' + sub.mkdir() + (sub / 'afile').write_text('something') + sub.rmtree_p() + assert not sub.exists() + + def test_rmtree_p_nonexistent(self, tmpdir): + d = Path(tmpdir) + sub = d / 'subfolder' + assert not sub.exists() + sub.rmtree_p() + + def test_rmdir_p_exists(self, tmpdir): + """ + Invocation of rmdir_p on an existant directory should + remove the directory. + """ + d = Path(tmpdir) + sub = d / 'subfolder' + sub.mkdir() + sub.rmdir_p() + assert not sub.exists() + + def test_rmdir_p_nonexistent(self, tmpdir): + """ + A non-existent file should not raise an exception. + """ + d = Path(tmpdir) + sub = d / 'subfolder' + assert not sub.exists() + sub.rmdir_p() + + def test_rmdir_p_sub_sub_dir(self, tmpdir): + """ + A non-empty folder should not raise an exception. + """ + d = Path(tmpdir) + sub = d / 'subfolder' + sub.mkdir() + subsub = sub / 'subfolder' + subsub.mkdir() + + sub.rmdir_p() + + +class TestMergeTree: + @pytest.fixture(autouse=True) + def testing_structure(self, tmpdir): + self.test_dir = Path(tmpdir) + self.subdir_a = self.test_dir / 'A' + self.test_file = self.subdir_a / 'testfile.txt' + self.test_link = self.subdir_a / 'testlink.txt' + self.subdir_b = self.test_dir / 'B' + + self.subdir_a.mkdir() + self.subdir_b.mkdir() + + with open(self.test_file, 'w', encoding='utf-8') as f: + f.write('x' * 10000) + + self.test_file.symlink(self.test_link) + + def check_link(self): + target = Path(self.subdir_b / self.test_link.name) + check = target.islink if hasattr(os, 'symlink') else target.is_file + assert check() + + def test_with_nonexisting_dst_kwargs(self): + self.subdir_a.merge_tree(self.subdir_b, symlinks=True) + assert self.subdir_b.is_dir() + expected = { + self.subdir_b / self.test_file.name, + self.subdir_b / self.test_link.name, + } + assert set(self.subdir_b.iterdir()) == expected + self.check_link() + + def test_with_nonexisting_dst_args(self): + self.subdir_a.merge_tree(self.subdir_b, True) + assert self.subdir_b.is_dir() + expected = { + self.subdir_b / self.test_file.name, + self.subdir_b / self.test_link.name, + } + assert set(self.subdir_b.iterdir()) == expected + self.check_link() + + def test_with_existing_dst(self): + self.subdir_b.rmtree() + self.subdir_a.copytree(self.subdir_b, True) + + self.test_link.remove() + test_new = self.subdir_a / 'newfile.txt' + test_new.touch() + with open(self.test_file, 'w', encoding='utf-8') as f: + f.write('x' * 5000) + + self.subdir_a.merge_tree(self.subdir_b, True) + + assert self.subdir_b.is_dir() + expected = { + self.subdir_b / self.test_file.name, + self.subdir_b / self.test_link.name, + self.subdir_b / test_new.name, + } + assert set(self.subdir_b.iterdir()) == expected + self.check_link() + assert len(Path(self.subdir_b / self.test_file.name).bytes()) == 5000 + + def test_copytree_parameters(self): + """ + merge_tree should accept parameters to copytree, such as 'ignore' + """ + ignore = shutil.ignore_patterns('testlink*') + self.subdir_a.merge_tree(self.subdir_b, ignore=ignore) + + assert self.subdir_b.is_dir() + assert list(self.subdir_b.iterdir()) == [self.subdir_b / self.test_file.name] + + def test_only_newer(self): + """ + merge_tree should accept a copy_function in which only + newer files are copied and older files do not overwrite + newer copies in the dest. + """ + target = self.subdir_b / 'testfile.txt' + target.write_text('this is newer', encoding='utf-8') + self.subdir_a.merge_tree( + self.subdir_b, copy_function=path.only_newer(shutil.copy2) + ) + assert target.read_text(encoding='utf-8') == 'this is newer' + + def test_nested(self): + self.subdir_a.joinpath('subsub').mkdir() + self.subdir_a.merge_tree(self.subdir_b) + assert self.subdir_b.joinpath('subsub').is_dir() + + +class TestChdir: + def test_chdir_or_cd(self, tmpdir): + """tests the chdir or cd method""" + d = Path(str(tmpdir)) + cwd = d.cwd() + + # ensure the cwd isn't our tempdir + assert str(d) != str(cwd) + # now, we're going to chdir to tempdir + d.chdir() + + # we now ensure that our cwd is the tempdir + assert str(d.cwd()) == str(tmpdir) + # we're resetting our path + d = Path(cwd) + + # we ensure that our cwd is still set to tempdir + assert str(d.cwd()) == str(tmpdir) + + # we're calling the alias cd method + d.cd() + # now, we ensure cwd isn'r tempdir + assert str(d.cwd()) == str(cwd) + assert str(d.cwd()) != str(tmpdir) + + +class TestSubclass: + def test_subclass_produces_same_class(self): + """ + When operations are invoked on a subclass, they should produce another + instance of that subclass. + """ + + class PathSubclass(Path): + pass + + p = PathSubclass('/foo') + subdir = p / 'bar' + assert isinstance(subdir, PathSubclass) + + +class TestTempDir: + def test_constructor(self): + """ + One should be able to readily construct a temporary directory + """ + d = TempDir() + assert isinstance(d, path.Path) + assert d.exists() + assert d.is_dir() + d.rmdir() + assert not d.exists() + + def test_next_class(self): + """ + It should be possible to invoke operations on a TempDir and get + Path classes. + """ + d = TempDir() + sub = d / 'subdir' + assert isinstance(sub, path.Path) + d.rmdir() + + def test_context_manager(self): + """ + One should be able to use a TempDir object as a context, which will + clean up the contents after. + """ + d = TempDir() + res = d.__enter__() + assert res == path.Path(d) + (d / 'somefile.txt').touch() + assert not isinstance(d / 'somefile.txt', TempDir) + d.__exit__(None, None, None) + assert not d.exists() + + def test_context_manager_using_with(self): + """ + The context manager will allow using the with keyword and + provide a temporary directory that will be deleted after that. + """ + + with TempDir() as d: + assert d.is_dir() + assert not d.is_dir() + + def test_cleaned_up_on_interrupt(self): + with contextlib.suppress(KeyboardInterrupt): + with TempDir() as d: + raise KeyboardInterrupt() + + assert not d.exists() + + def test_constructor_dir_argument(self, tmpdir): + """ + It should be possible to provide a dir argument to the constructor + """ + with TempDir(dir=tmpdir) as d: + assert str(d).startswith(str(tmpdir)) + + def test_constructor_prefix_argument(self): + """ + It should be possible to provide a prefix argument to the constructor + """ + prefix = 'test_prefix' + with TempDir(prefix=prefix) as d: + assert d.name.startswith(prefix) + + def test_constructor_suffix_argument(self): + """ + It should be possible to provide a suffix argument to the constructor + """ + suffix = 'test_suffix' + with TempDir(suffix=suffix) as d: + assert str(d).endswith(suffix) + + +class TestUnicode: + @pytest.fixture(autouse=True) + def unicode_name_in_tmpdir(self, tmpdir): + # build a snowman (dir) in the temporary directory + Path(tmpdir).joinpath('☃').mkdir() + + def test_walkdirs_with_unicode_name(self, tmpdir): + for _res in Path(tmpdir).walkdirs(): + pass + + +class TestPatternMatching: + def test_fnmatch_simple(self): + p = Path('FooBar') + assert p.fnmatch('Foo*') + assert p.fnmatch('Foo[ABC]ar') + + def test_fnmatch_custom_mod(self): + p = Path('FooBar') + p.module = ntpath + assert p.fnmatch('foobar') + assert p.fnmatch('FOO[ABC]AR') + + def test_fnmatch_custom_normcase(self): + def normcase(path): + return path.upper() + + p = Path('FooBar') + assert p.fnmatch('foobar', normcase=normcase) + assert p.fnmatch('FOO[ABC]AR', normcase=normcase) + + def test_iterdir_simple(self): + p = Path('.') + assert ilen(p.iterdir()) == len(os.listdir('.')) + + def test_iterdir_empty_pattern(self): + p = Path('.') + assert list(p.iterdir('')) == [] + + def test_iterdir_patterns(self, tmpdir): + p = Path(tmpdir) + (p / 'sub').mkdir() + (p / 'File').touch() + assert list(p.iterdir('s*')) == [p / 'sub'] + assert ilen(p.iterdir('*')) == 2 + + def test_iterdir_custom_module(self, tmpdir): + """ + Listdir patterns should honor the case sensitivity of the path module + used by that Path class. + """ + always_unix = Path.using_module(posixpath) + p = always_unix(tmpdir) + (p / 'sub').mkdir() + (p / 'File').touch() + assert list(p.iterdir('S*')) == [] + + always_win = Path.using_module(ntpath) + p = always_win(tmpdir) + assert list(p.iterdir('S*')) == [p / 'sub'] + assert list(p.iterdir('f*')) == [p / 'File'] + + def test_iterdir_case_insensitive(self, tmpdir): + """ + Listdir patterns should honor the case sensitivity of the path module + used by that Path class. + """ + p = Path(tmpdir) + (p / 'sub').mkdir() + (p / 'File').touch() + assert list(p.iterdir(matchers.CaseInsensitive('S*'))) == [p / 'sub'] + assert list(p.iterdir(matchers.CaseInsensitive('f*'))) == [p / 'File'] + assert p.files(matchers.CaseInsensitive('S*')) == [] + assert p.dirs(matchers.CaseInsensitive('f*')) == [] + + def test_walk_case_insensitive(self, tmpdir): + p = Path(tmpdir) + (p / 'sub1' / 'foo').makedirs_p() + (p / 'sub2' / 'foo').makedirs_p() + (p / 'sub1' / 'foo' / 'bar.Txt').touch() + (p / 'sub2' / 'foo' / 'bar.TXT').touch() + (p / 'sub2' / 'foo' / 'bar.txt.bz2').touch() + files = list(p.walkfiles(matchers.CaseInsensitive('*.txt'))) + assert len(files) == 2 + assert p / 'sub2' / 'foo' / 'bar.TXT' in files + assert p / 'sub1' / 'foo' / 'bar.Txt' in files + + +class TestInPlace: + reference_content = textwrap.dedent( + """ + The quick brown fox jumped over the lazy dog. + """.lstrip() + ) + reversed_content = textwrap.dedent( + """ + .god yzal eht revo depmuj xof nworb kciuq ehT + """.lstrip() + ) + alternate_content = textwrap.dedent( + """ + Lorem ipsum dolor sit amet, consectetur adipisicing elit, + sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit + esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui + officia deserunt mollit anim id est laborum. + """.lstrip() + ) + + @classmethod + def create_reference(cls, tmpdir): + p = Path(tmpdir) / 'document' + with p.open('w', encoding='utf-8') as stream: + stream.write(cls.reference_content) + return p + + def test_line_by_line_rewrite(self, tmpdir): + doc = self.create_reference(tmpdir) + # reverse all the text in the document, line by line + with doc.in_place(encoding='utf-8') as (reader, writer): + for line in reader: + r_line = ''.join(reversed(line.strip())) + '\n' + writer.write(r_line) + with doc.open(encoding='utf-8') as stream: + data = stream.read() + assert data == self.reversed_content + + def test_exception_in_context(self, tmpdir): + doc = self.create_reference(tmpdir) + with pytest.raises(RuntimeError) as exc: + with doc.in_place(encoding='utf-8') as (reader, writer): + writer.write(self.alternate_content) + raise RuntimeError("some error") + assert "some error" in str(exc.value) + with doc.open(encoding='utf-8') as stream: + data = stream.read() + assert 'Lorem' not in data + assert 'lazy dog' in data + + def test_write_mode_invalid(self, tmpdir): + with pytest.raises(ValueError): + with (Path(tmpdir) / 'document').in_place(mode='w'): + pass + + +class TestSpecialPaths: + @pytest.fixture(autouse=True, scope='class') + def appdirs_installed(cls): + pytest.importorskip('appdirs') + + @pytest.fixture + def feign_linux(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setattr("os.pathsep", ":") + # remove any existing import of appdirs, as it sets up some + # state during import. + sys.modules.pop('appdirs') + + def test_basic_paths(self): + appdirs = importlib.import_module('appdirs') + + expected = appdirs.user_config_dir() + assert SpecialResolver(Path).user.config == expected + + expected = appdirs.site_config_dir() + assert SpecialResolver(Path).site.config == expected + + expected = appdirs.user_config_dir('My App', 'Me') + assert SpecialResolver(Path, 'My App', 'Me').user.config == expected + + def test_unix_paths(self, tmpdir, monkeypatch, feign_linux): + fake_config = tmpdir / '_config' + monkeypatch.setitem(os.environ, 'XDG_CONFIG_HOME', str(fake_config)) + expected = str(tmpdir / '_config') + assert SpecialResolver(Path).user.config == expected + + def test_unix_paths_fallback(self, tmpdir, monkeypatch, feign_linux): + "Without XDG_CONFIG_HOME set, ~/.config should be used." + fake_home = tmpdir / '_home' + monkeypatch.delitem(os.environ, 'XDG_CONFIG_HOME', raising=False) + monkeypatch.setitem(os.environ, 'HOME', str(fake_home)) + expected = Path('~/.config').expanduser() + assert SpecialResolver(Path).user.config == expected + + def test_property(self): + assert isinstance(Path.special().user.config, Path) + assert isinstance(Path.special().user.data, Path) + assert isinstance(Path.special().user.cache, Path) + + def test_other_parameters(self): + """ + Other parameters should be passed through to appdirs function. + """ + res = Path.special(version="1.0", multipath=True).site.config + assert isinstance(res, Path) + + def test_multipath(self, feign_linux, monkeypatch, tmpdir): + """ + If multipath is provided, on Linux return the XDG_CONFIG_DIRS + """ + fake_config_1 = str(tmpdir / '_config1') + fake_config_2 = str(tmpdir / '_config2') + config_dirs = os.pathsep.join([fake_config_1, fake_config_2]) + monkeypatch.setitem(os.environ, 'XDG_CONFIG_DIRS', config_dirs) + res = Path.special(multipath=True).site.config + assert isinstance(res, Multi) + assert fake_config_1 in res + assert fake_config_2 in res + assert '_config1' in str(res) + + def test_reused_SpecialResolver(self): + """ + Passing additional args and kwargs to SpecialResolver should be + passed through to each invocation of the function in appdirs. + """ + appdirs = importlib.import_module('appdirs') + + adp = SpecialResolver(Path, version="1.0") + res = adp.user.config + + expected = appdirs.user_config_dir(version="1.0") + assert res == expected + + +class TestMultiPath: + def test_for_class(self): + """ + Multi.for_class should return a subclass of the Path class provided. + """ + cls = Multi.for_class(Path) + assert issubclass(cls, Path) + assert issubclass(cls, Multi) + expected_name = 'Multi' + Path.__name__ + assert cls.__name__ == expected_name + + def test_detect_no_pathsep(self): + """ + If no pathsep is provided, multipath detect should return an instance + of the parent class with no Multi mix-in. + """ + path = Multi.for_class(Path).detect('/foo/bar') + assert isinstance(path, Path) + assert not isinstance(path, Multi) + + def test_detect_with_pathsep(self): + """ + If a pathsep appears in the input, detect should return an instance + of a Path with the Multi mix-in. + """ + inputs = '/foo/bar', '/baz/bing' + input = os.pathsep.join(inputs) + path = Multi.for_class(Path).detect(input) + + assert isinstance(path, Multi) + + def test_iteration(self): + """ + Iterating over a MultiPath should yield instances of the + parent class. + """ + inputs = '/foo/bar', '/baz/bing' + input = os.pathsep.join(inputs) + path = Multi.for_class(Path).detect(input) + + items = iter(path) + first = next(items) + assert first == '/foo/bar' + assert isinstance(first, Path) + assert not isinstance(first, Multi) + assert next(items) == '/baz/bing' + assert path == input + + +def test_no_dependencies(): + """ + Path pie guarantees that the path module can be + transplanted into an environment without any dependencies. + """ + cmd = [sys.executable, '-S', '-c', 'import path'] + subprocess.check_call(cmd) + + +class TestHandlers: + @staticmethod + def run_with_handler(handler): + try: + raise ValueError() + except Exception: + handler("Something unexpected happened") + + def test_raise(self): + handler = path.Handlers._resolve('strict') + with pytest.raises(ValueError): + self.run_with_handler(handler) + + def test_warn(self): + handler = path.Handlers._resolve('warn') + with pytest.warns(path.TreeWalkWarning): + self.run_with_handler(handler) + + def test_ignore(self): + handler = path.Handlers._resolve('ignore') + self.run_with_handler(handler) + + def test_invalid_handler(self): + with pytest.raises(ValueError): + path.Handlers._resolve('raise') diff --git a/towncrier.toml b/towncrier.toml new file mode 100644 index 00000000..577e87a7 --- /dev/null +++ b/towncrier.toml @@ -0,0 +1,3 @@ +[tool.towncrier] +title_format = "{version}" +directory = "newsfragments" # jaraco/skeleton#184 diff --git a/tox.ini b/tox.ini index 5bfa4e96..e05a3d4a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,63 @@ -# Tox (http://tox.testrun.org/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. +[testenv] +description = perform primary checks (tests, style, types, coverage) +deps = +setenv = + PYTHONWARNDEFAULTENCODING = 1 +commands = + pytest {posargs} +usedevelop = True +extras = + test + check + cover + enabler + type -[tox] -envlist = py26, py27, pypy, py32, py33, py34 +[testenv:diffcov] +description = run tests and check that diff from main is covered +deps = + {[testenv]deps} + diff-cover +commands = + pytest {posargs} --cov-report xml + diff-cover coverage.xml --compare-branch=origin/main --format html:diffcov.html + diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 -[testenv] -commands = py.test {posargs} -deps = pytest +[testenv:docs] +description = build the documentation +extras = + doc + test +changedir = docs +commands = + python -m sphinx -W --keep-going . {toxinidir}/build/html + python -m sphinxlint + +[testenv:finalize] +description = assemble changelog and tag a release +skip_install = True +deps = + towncrier + jaraco.develop >= 7.23 +pass_env = * +commands = + python -m jaraco.develop.finalize + + +[testenv:release] +description = publish the package to PyPI and GitHub +skip_install = True +deps = + build + twine>=3 + jaraco.develop>=7.1 +pass_env = + TWINE_PASSWORD + GITHUB_TOKEN +setenv = + TWINE_USERNAME = {env:TWINE_USERNAME:__token__} +commands = + python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" + python -m build + python -m twine upload dist/* + python -m jaraco.develop.create-github-release