├── .github └── workflows │ ├── build.yaml │ └── test.yaml ├── .gitignore ├── .readthedocs.yaml ├── CHANGES.rst ├── LICENSE.txt ├── README.rst ├── docs ├── api.rst ├── changes.rst ├── conf.py ├── examples.rst ├── examples │ └── zippath.py ├── index.rst ├── license.rst └── requirements.txt ├── pathlib_abc ├── __init__.py ├── _fnmatch.py ├── _glob.py └── _os.py ├── pyproject.toml ├── tests ├── __init__.py ├── support │ ├── __init__.py │ ├── lexical_path.py │ ├── local_path.py │ └── zip_path.py ├── test_copy.py ├── test_join.py ├── test_join_posix.py ├── test_join_windows.py ├── test_read.py └── test_write.py └── tox.ini /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | 6 | permissions: 7 | contents: write 8 | id-token: write 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: hynek/build-and-inspect-python-package@v2 16 | 17 | publish: 18 | runs-on: ubuntu-latest 19 | needs: build 20 | if: startsWith(github.ref, 'refs/tags/') 21 | environment: 22 | name: release 23 | url: https://pypi.org/project/pathlib-abc 24 | steps: 25 | - uses: actions/download-artifact@v4 26 | with: 27 | name: Packages 28 | path: dist 29 | - name: Upload wheel to release 30 | uses: svenstaro/upload-release-action@v2 31 | with: 32 | file: dist/* 33 | tag: ${{ github.ref }} 34 | overwrite: true 35 | file_glob: true 36 | - name: Publish 37 | uses: pypa/gh-action-pypi-publish@release/v1 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | tests: 12 | runs-on: ubuntu-latest 13 | env: 14 | FORCE_COLOR: "1" 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ ubuntu-latest, windows-latest, macos-latest ] 19 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | allow-prereleases: true 28 | cache: pip 29 | - run: python -Im pip install tox 30 | - run: | 31 | python -Im tox run \ 32 | -f py$(echo ${{ matrix.python-version }} | tr -d .) 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .tox/ 3 | __pycache__/ 4 | dist/ 5 | sync.sh 6 | venv*/ 7 | .python-version 8 | docs/_build 9 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.12" 6 | sphinx: 7 | configuration: docs/conf.py 8 | fail_on_warning: true 9 | python: 10 | install: 11 | - requirements: docs/requirements.txt 12 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Version History 2 | =============== 3 | 4 | Unreleased 5 | ---------- 6 | 7 | - Nothing yet 8 | 9 | v0.4.3 10 | ------ 11 | 12 | - Set correct ``__name__`` for ABCs and ``PathParser``. 13 | 14 | v0.4.2 15 | ------ 16 | 17 | - Emit encoding warnings from ``magic_open()``, ``ReadablePath.read_text()``, 18 | and ``WritablePath.write_text()`` at the correct stack level. 19 | 20 | v0.4.1 21 | ------ 22 | 23 | - When ``magic_open()`` is called to open a path in binary mode, raise 24 | ``ValueError`` if any of the *encoding*, *errors* or *newline* arguments 25 | are given. 26 | - In ``ReadablePath.glob()``, raise ``ValueError`` when given an empty 27 | pattern. 28 | - In ``ReadablePath.glob()`` and ``JoinablePath.full_match()``, stop 29 | accepting ``JoinablePath`` objects as patterns. Only strings are allowed. 30 | - In ``ReadablePath.copy()`` and ``copy_into()``, stop accepting strings as 31 | target paths. Only ``WritablePath`` objects are allowed. 32 | 33 | v0.4.0 34 | ------ 35 | 36 | - Several months worth of upstream refactoring: 37 | 38 | - Rename ``PurePathBase`` to ``JoinablePath``. 39 | - Split ``PathBase`` into ``ReadablePath`` and ``WritablePath``. 40 | - Replace ``stat()`` with ``info`` attribute and ``PathInfo`` protocol. 41 | - Remove many nonessential methods. 42 | - Add support for copying between path instances. 43 | 44 | - Drop support for Python 3.7 and 3.8. 45 | 46 | v0.3.1 47 | ------ 48 | 49 | - Add support for Python 3.7. 50 | 51 | v0.3.0 52 | ------ 53 | 54 | - Rename ``PathModuleBase`` to ``ParserBase``, and ``PurePathBase.pathmod`` 55 | to ``PurePathBase.parser``. 56 | - Add ``ParserBase.splitext()``. 57 | - Add ``PurePathBase.full_match()``. 58 | - Treat a single dot ("``.``") as a valid file extension. 59 | - Revert ``match()`` back to 3.12 behaviour (no recursive wildcards). 60 | - Replace ``PathBase.glob(follow_symlinks=...)`` with ``recurse_symlinks=...``. 61 | - Suppress all ``OSError`` exceptions from ``PathBase.exists()`` and 62 | ``is_*()`` methods. 63 | - Disallow passing ``bytes`` to initialisers. 64 | - Improve walking and globbing performance. 65 | - Expand test coverage. 66 | - Clarify that we're using the PSF license. 67 | 68 | 69 | v0.2.0 70 | ------ 71 | 72 | - Add ``PathModuleBase`` ABC to support path syntax customization. 73 | - Add CI. Thank you Edgar Ramírez Mondragón! 74 | - Return paths with trailing slashes if a glob pattern ends with a slash. 75 | - Return both files and directory paths if a glob pattern ends with ``**``, 76 | rather than directories only. 77 | - Improve ``PathBase.resolve()`` performance by avoiding some path object 78 | allocations. 79 | - Remove ``PurePathBase.is_reserved()``. 80 | - Remove automatic path normalization. Specifically, the ABCs no longer 81 | convert alternate separators nor remove either dot or empty segments. 82 | - Remove caching of the path drive, root, tail, and string. 83 | - Remove deprecation warnings and audit events. 84 | 85 | 86 | v0.1.1 87 | ------ 88 | 89 | - Improve globbing performance by avoiding re-initialising path objects. 90 | - Add docs. 91 | 92 | 93 | v0.1.0 94 | ------ 95 | 96 | - Initial release. 97 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 2 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 3 | otherwise using this software ("Python") in source or binary form and 4 | its associated documentation. 5 | 6 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 7 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 8 | analyze, test, perform and/or display publicly, prepare derivative works, 9 | distribute, and otherwise use Python alone or in any derivative version, 10 | provided, however, that PSF's License Agreement and PSF's notice of copyright, 11 | i.e., "Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved" 12 | are retained in Python alone or in any derivative version prepared by Licensee. 13 | 14 | 3. In the event Licensee prepares a derivative work that is based on 15 | or incorporates Python or any part thereof, and wants to make 16 | the derivative work available to others as provided herein, then 17 | Licensee hereby agrees to include in any such work a brief summary of 18 | the changes made to Python. 19 | 20 | 4. PSF is making Python available to Licensee on an "AS IS" 21 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 22 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 23 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 24 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 25 | INFRINGE ANY THIRD PARTY RIGHTS. 26 | 27 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 28 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 29 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 30 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 31 | 32 | 6. This License Agreement will automatically terminate upon a material 33 | breach of its terms and conditions. 34 | 35 | 7. Nothing in this License Agreement shall be deemed to create any 36 | relationship of agency, partnership, or joint venture between PSF and 37 | Licensee. This License Agreement does not grant permission to use PSF 38 | trademarks or trade name in a trademark sense to endorse or promote 39 | products or services of Licensee, or any third party. 40 | 41 | 8. By copying, installing or otherwise using Python, Licensee 42 | agrees to be bound by the terms and conditions of this License 43 | Agreement. 44 | 45 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | pathlib-abc 3 | =========== 4 | 5 | |pypi| |docs| 6 | 7 | Base classes for ``pathlib.Path``-ish objects. Requires Python 3.9+. 8 | 9 | This package is a preview of ``pathlib`` functionality planned for a future 10 | release of Python; specifically, it provides three ABCs that can be used to 11 | implement path classes for non-local filesystems, such as archive files and 12 | storage servers: 13 | 14 | ``JoinablePath`` 15 | Abstract base class for paths that do not perform I/O. 16 | ``ReadablePath`` 17 | Abstract base class for paths that support reading. 18 | ``WritablePath`` 19 | Abstract base class for paths that support writing. 20 | 21 | These base classes are under active development. Once the base classes reach 22 | maturity, they may become part of the Python standard library, and this 23 | package will continue to provide a backport for older Python releases. 24 | 25 | 26 | Contributing 27 | ------------ 28 | 29 | Functional changes must be made in the upstream CPython project, and undergo 30 | their usual CLA + code review process. Once a change lands in CPython, it can 31 | be back-ported here. 32 | 33 | Other changes (such as CI improvements) can be made as pull requests to this 34 | project. 35 | 36 | 37 | 38 | .. |pypi| image:: https://img.shields.io/pypi/v/pathlib-abc.svg 39 | :target: https://pypi.python.org/pypi/pathlib-abc 40 | :alt: Latest version released on PyPi 41 | 42 | .. |docs| image:: https://readthedocs.org/projects/pathlib-abc/badge 43 | :target: http://pathlib-abc.readthedocs.io/en/latest 44 | :alt: Documentation 45 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | 5 | .. module:: pathlib_abc 6 | 7 | 8 | Functions 9 | --------- 10 | 11 | This package offers the following functions: 12 | 13 | .. function:: magic_open(path, mode='r' buffering=-1, \ 14 | encoding=None, errors=None, newline=None) 15 | 16 | Open the path and return a file object. Unlike the built-in ``open()`` 17 | function, this function tries to call :meth:`!__open_r__`, 18 | :meth:`~ReadablePath.__open_rb__`, :meth:`!__open_w__`, and 19 | :meth:`~WritablePath.__open_wb__` methods on the given path object as 20 | appropriate for the given mode. 21 | 22 | 23 | Protocols 24 | --------- 25 | 26 | This package offers the following protocols: 27 | 28 | .. class:: PathParser 29 | 30 | Protocol for path parser objects, which split and join string paths. 31 | 32 | Subclasses of :class:`JoinablePath` should provide a path parser object as 33 | an attribute named :attr:`~JoinablePath.parser`. 34 | 35 | Path parsers provide a subset of the ``os.path`` API. Python itself 36 | provides the ``posixpath`` and ``ntpath`` modules, which can be assigned 37 | to :attr:`~JoinablePath.parser` to implement path objects with POSIX or 38 | Windows syntax. 39 | 40 | .. attribute:: sep 41 | 42 | Character used to separate path components. 43 | 44 | .. attribute:: altsep 45 | 46 | Alternative path separator character, or ``None``. 47 | 48 | .. method:: split(path) 49 | 50 | Split the path into a pair ``(head, tail)``, where *head* is 51 | everything before the final path separator, and *tail* is everything 52 | after. Either part may be empty. 53 | 54 | .. note:: 55 | 56 | Trailing slashes are meaningful in ``posixpath`` and ``ntpath``, so 57 | ``P('foo/').parent`` is ``P('foo')``, and its 58 | :attr:`~JoinablePath.name` is the empty string. 59 | 60 | .. method:: splitext(name) 61 | 62 | Split the filename into a pair ``(stem, ext)``, where *ext* is a file 63 | extension beginning with a dot and containing at most one dot, and 64 | *stem* is everything before the extension. 65 | 66 | .. method:: normcase(path) 67 | 68 | Return the path with its case normalized. 69 | 70 | .. note:: 71 | 72 | This method is used to detect case sensitivity in 73 | :meth:`JoinablePath.full_match` and :meth:`ReadablePath.glob`, where 74 | it's called with the string containing a mix of upper and lowercase 75 | letters. Case-sensitive filesystems should return the string 76 | unchanged, whereas case-insensitive filesystems should return the 77 | string with its case modified (e.g. with ``upper()`` or ``lower()``.) 78 | 79 | 80 | .. class:: PathInfo 81 | 82 | Protocol for path information objects, which provide file type info. 83 | 84 | Subclasses of :class:`ReadablePath` should provide a path information 85 | object as an attribute named :attr:`~ReadablePath.info`. 86 | 87 | .. method:: exists(*, follow_symlinks=True) 88 | 89 | Return ``True`` if the path is an existing file or directory, or any 90 | other kind of file; return ``False`` if the path doesn't exist. 91 | 92 | If *follow_symlinks* is ``False``, return ``True`` for symlinks without 93 | checking if their targets exist. 94 | 95 | .. method:: is_dir(*, follow_symlinks=True) 96 | 97 | Return ``True`` if the path is a directory, or a symbolic link pointing 98 | to a directory; return ``False`` if the path is (or points to) any other 99 | kind of file, or if it doesn't exist. 100 | 101 | If *follow_symlinks* is ``False``, return ``True`` only if the path 102 | is a directory (without following symlinks); return ``False`` if the 103 | path is any other kind of file, or if it doesn't exist. 104 | 105 | .. method:: is_file(*, follow_symlinks=True) 106 | 107 | Return ``True`` if the path is a file, or a symbolic link pointing to 108 | a file; return ``False`` if the path is (or points to) a directory or 109 | other non-file, or if it doesn't exist. 110 | 111 | If *follow_symlinks* is ``False``, return ``True`` only if the path 112 | is a file (without following symlinks); return ``False`` if the path 113 | is a directory or other other non-file, or if it doesn't exist. 114 | 115 | .. method:: is_symlink() 116 | 117 | Return ``True`` if the path is a symbolic link (even if broken); return 118 | ``False`` if the path is a directory or any kind of file, or if it 119 | doesn't exist. 120 | 121 | 122 | Abstract base classes 123 | --------------------- 124 | 125 | This package offers the following abstract base classes: 126 | 127 | .. list-table:: 128 | :header-rows: 1 129 | 130 | - * ABC 131 | * Inherits from 132 | * Abstract methods 133 | * Mixin methods 134 | 135 | - * :class:`JoinablePath` 136 | * 137 | * :attr:`~JoinablePath.parser` 138 | 139 | :meth:`~JoinablePath.__str__` 140 | 141 | :meth:`~JoinablePath.with_segments` 142 | * :attr:`~JoinablePath.parts` 143 | :attr:`~JoinablePath.anchor` 144 | 145 | :attr:`~JoinablePath.parent` 146 | :attr:`~JoinablePath.parents` 147 | 148 | :attr:`~JoinablePath.name` 149 | :attr:`~JoinablePath.stem` 150 | :attr:`~JoinablePath.suffix` 151 | :attr:`~JoinablePath.suffixes` 152 | 153 | :meth:`~JoinablePath.with_name` 154 | :meth:`~JoinablePath.with_stem` 155 | :meth:`~JoinablePath.with_suffix` 156 | 157 | :meth:`~JoinablePath.joinpath` 158 | :meth:`~JoinablePath.__truediv__` 159 | :meth:`~JoinablePath.__rtruediv__` 160 | 161 | :meth:`~JoinablePath.full_match` 162 | 163 | - * :class:`ReadablePath` 164 | * :class:`JoinablePath` 165 | * :attr:`~ReadablePath.info` 166 | 167 | :meth:`~ReadablePath.__open_rb__` 168 | 169 | :meth:`~ReadablePath.iterdir` 170 | 171 | :meth:`~ReadablePath.readlink` 172 | * :meth:`~ReadablePath.read_bytes` 173 | :meth:`~ReadablePath.read_text` 174 | 175 | :meth:`~ReadablePath.copy` 176 | :meth:`~ReadablePath.copy_into` 177 | 178 | :meth:`~ReadablePath.glob` 179 | 180 | :meth:`~ReadablePath.walk` 181 | 182 | - * :class:`WritablePath` 183 | * :class:`JoinablePath` 184 | * :meth:`~WritablePath.__open_wb__` 185 | 186 | :meth:`~WritablePath.mkdir` 187 | 188 | :meth:`~WritablePath.symlink_to` 189 | * :meth:`~WritablePath.write_bytes` 190 | :meth:`~WritablePath.write_text` 191 | 192 | :meth:`~WritablePath._copy_from` 193 | 194 | 195 | .. class:: JoinablePath 196 | 197 | Abstract base class for path objects without I/O support. 198 | 199 | .. attribute:: parser 200 | 201 | (**Abstract attribute**.) Implementation of :class:`PathParser` used for 202 | low-level splitting and joining. 203 | 204 | .. method:: __str__() 205 | 206 | (**Abstract method**.) Return a string representation of the path, 207 | suitable for passing to methods of the :attr:`parser`. 208 | 209 | .. method:: with_segments(*pathsegments) 210 | 211 | (**Abstract method**.) Create a new path object of the same type by 212 | combining the given *pathsegments*. This method is called whenever a 213 | derivative path is created, such as from :attr:`parent` and 214 | :meth:`with_name`. 215 | 216 | .. attribute:: parts 217 | 218 | Tuple of path components. The default implementation repeatedly calls 219 | :meth:`PathParser.split` to decompose the path. 220 | 221 | .. attribute:: anchor 222 | 223 | The path's irreducible prefix. The default implementation repeatedly 224 | calls :meth:`PathParser.split` until the directory name stops changing. 225 | 226 | .. attribute:: parent 227 | 228 | The path's lexical parent. The default implementation calls 229 | :meth:`PathParser.split` once. 230 | 231 | .. attribute:: parents 232 | 233 | Sequence of the path's lexical parents, beginning with the immediate 234 | parent. The default implementation repeatedly calls 235 | :meth:`PathParser.split`. 236 | 237 | .. attribute:: name 238 | 239 | The path's base name. The name is empty if the path has only an anchor, 240 | or ends with a slash. The default implementation calls 241 | :meth:`PathParser.split` once. 242 | 243 | .. attribute:: stem 244 | 245 | The path's base name with the file extension omitted. The default 246 | implementation calls :meth:`PathParser.splitext` on :attr:`name`. 247 | 248 | .. attribute:: suffix 249 | 250 | The path's file extension. The default implementation calls 251 | :meth:`PathParser.splitext` on :attr:`name`. 252 | 253 | .. attribute:: suffixes 254 | 255 | Sequence of the path's file extensions. The default implementation 256 | repeatedly calls :meth:`PathParser.splitext` on :attr:`name`. 257 | 258 | .. method:: with_name(name) 259 | 260 | Return a new path with a different :attr:`name`. The name may be empty. 261 | The default implementation calls :meth:`PathParser.split` to remove the 262 | old name, and :meth:`with_segments` to create the new path object. 263 | 264 | .. method:: with_stem(stem) 265 | 266 | Return a new path with a different :attr:`stem`, similarly to 267 | :meth:`with_name`. 268 | 269 | .. method:: with_suffix(suffix) 270 | 271 | Return a new path with a different :attr:`suffix`, similarly to 272 | :meth:`with_name`. 273 | 274 | .. method:: joinpath(*pathsegments) 275 | 276 | Return a new path with the given path segments joined onto the end. The 277 | default implementation calls :meth:`with_segments` with the combined 278 | segments. 279 | 280 | .. method:: __truediv__(pathsegment) 281 | 282 | Return a new path with the given path segment joined on the end. 283 | 284 | .. method:: __rtruediv__(pathsegment) 285 | 286 | Return a new path with the given path segment joined on the beginning. 287 | 288 | .. method:: full_match(pattern) 289 | 290 | Return true if the path matches the given glob-style pattern, false 291 | otherwise. The default implementation uses :meth:`PathParser.normcase` 292 | to establish case sensitivity. 293 | 294 | 295 | .. class:: ReadablePath 296 | 297 | Abstract base class for path objects with support for reading data. This 298 | is a subclass of :class:`JoinablePath` 299 | 300 | .. attribute:: info 301 | 302 | (**Abstract attribute**.) Implementation of :class:`PathInfo` that 303 | supports querying the file type. 304 | 305 | .. method:: __open_rb__(buffering=-1) 306 | 307 | (**Abstract method.**) Open the path for reading in binary mode, and 308 | return a file object. 309 | 310 | .. method:: iterdir() 311 | 312 | (**Abstract method**.) Yield path objects for the directory contents. 313 | 314 | .. method:: readlink() 315 | 316 | (**Abstract method**.) Return the symlink target as a new path object. 317 | 318 | .. method:: read_bytes() 319 | 320 | Return the binary contents of the path. The default implementation 321 | calls :meth:`__open_rb__`. 322 | 323 | .. method:: read_text(encoding=None, errors=None, newline=None) 324 | 325 | Return the text contents of the path. The default implementation calls 326 | :meth:`!__open_r__` if it exists, falling back to :meth:`__open_rb__`. 327 | 328 | .. method:: copy(target, **kwargs) 329 | 330 | Copy the path to the given target, which should be an instance of 331 | :class:`WritablePath`. The default implementation calls 332 | :meth:`WritablePath._copy_from`, passing along keyword arguments. 333 | 334 | .. method:: copy_into(target_dir, **kwargs) 335 | 336 | Copy the path *into* the given target directory, which should be an 337 | instance of :class:`WritablePath`. See :meth:`copy`. 338 | 339 | .. method:: glob(pattern, *, recurse_symlinks=True) 340 | 341 | Yield path objects in the file tree that match the given glob-style 342 | pattern. The default implementation uses :attr:`info` and 343 | :meth:`iterdir`. 344 | 345 | .. warning:: 346 | 347 | For performance reasons, the default value for *recurse_symlinks* is 348 | ``True`` in this base class, but for historical reasons, the default 349 | is ``False`` in ``pathlib.Path``. Furthermore, ``True`` is the *only* 350 | acceptable value for *recurse_symlinks* in this base class. 351 | 352 | For maximum compatibility, users should supply 353 | ``recurse_symlinks=True`` explicitly when globbing recursively. 354 | 355 | .. method:: walk(top_down=True, on_error=None, follow_symlinks=False) 356 | 357 | Yield a ``(dirpath, dirnames, filenames)`` triplet for each directory 358 | in the file tree, like ``os.walk()``. The default implementation uses 359 | :attr:`info` and :meth:`iterdir`. 360 | 361 | 362 | .. class:: WritablePath 363 | 364 | Abstract base class for path objects with support for writing data. This 365 | is a subclass of :class:`JoinablePath` 366 | 367 | .. method:: __open_wb__(buffering=-1) 368 | 369 | (**Abstract method**.) Open the path for writing in binary mode, and 370 | return a file object. 371 | 372 | .. method:: mkdir() 373 | 374 | (**Abstract method**.) Create this path as a directory. 375 | 376 | .. method:: symlink_to(target, target_is_directory=False) 377 | 378 | (**Abstract method**.) Create this path as a symlink to the given 379 | target. 380 | 381 | .. method:: write_bytes(data) 382 | 383 | Write the given binary data to the path, and return the number of bytes 384 | written. The default implementation calls :meth:`__open_wb__`. 385 | 386 | .. method:: write_text(data, encoding=None, errors=None, newline=None) 387 | 388 | Write the given text data to the path, and return the number of bytes 389 | written. The default implementation calls :meth:`!__open_w__` if it 390 | exists, falling back to :meth:`__open_wb__`. 391 | 392 | .. method:: _copy_from(source, *, follow_symlinks=True) 393 | 394 | Copy the path from the given source, which should be an instance of 395 | :class:`ReadablePath`. The default implementation uses 396 | :attr:`ReadablePath.info` to establish the type of the source path. It 397 | uses :meth:`~ReadablePath.__open_rb__` and :meth:`__open_wb__` to copy 398 | regular files; :meth:`~ReadablePath.iterdir` and :meth:`mkdir` to copy 399 | directories; and :meth:`~ReadablePath.readlink` and :meth:`symlink_to` 400 | to copy symlinks when *follow_symlinks* is false. 401 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | project = 'pathlib-abc' 2 | copyright = '2023' 3 | author = 'Barney Gale' 4 | extensions = [ 5 | 'sphinx_copybutton', 6 | ] 7 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 8 | html_theme = 'furo' 9 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | ZipPath 5 | ------- 6 | 7 | .. literalinclude:: examples/zippath.py 8 | -------------------------------------------------------------------------------- /docs/examples/zippath.py: -------------------------------------------------------------------------------- 1 | import posixpath 2 | 3 | from pathlib_abc import PathInfo, ReadablePath 4 | 5 | 6 | class MissingInfo(PathInfo): 7 | __slots__ = () 8 | 9 | def exists(self, follow_symlinks=True): return False 10 | def is_dir(self, follow_symlinks=True): return False 11 | def is_file(self, follow_symlinks=True): return False 12 | def is_symlink(self): return False 13 | 14 | 15 | class ZipPathInfo(PathInfo): 16 | __slots__ = ('zip_info', 'children') 17 | 18 | def __init__(self): 19 | self.zip_info = None 20 | self.children = {} 21 | 22 | def exists(self, follow_symlinks=True): 23 | return True 24 | 25 | def is_dir(self, follow_symlinks=True): 26 | if self.zip_info is None: 27 | return True 28 | else: 29 | return self.zip_info.filename.endswith('/') 30 | 31 | def is_file(self, follow_symlinks=True): 32 | if self.zip_info is None: 33 | return False 34 | else: 35 | return not self.zip_info.filename.endswith('/') 36 | 37 | def is_symlink(self): 38 | return False 39 | 40 | def resolve(self, path, create=False): 41 | if not path: 42 | return self 43 | name, _, path = path.partition('/') 44 | if not name: 45 | info = self 46 | elif name in self.children: 47 | info = self.children[name] 48 | elif create: 49 | info = self.children[name] = ZipPathInfo() 50 | else: 51 | return MissingInfo() 52 | return info.resolve(path, create) 53 | 54 | 55 | class ZipPath(ReadablePath): 56 | __slots__ = ('_segments', 'zip_file') 57 | parser = posixpath 58 | 59 | def __init__(self, *pathsegments, zip_file): 60 | self._segments = pathsegments 61 | self.zip_file = zip_file 62 | if not hasattr(zip_file, 'filetree'): 63 | # Read the contents into a tree of ZipPathInfo objects. 64 | zip_file.filetree = ZipPathInfo() 65 | for zip_info in zip_file.filelist: 66 | info = zip_file.filetree.resolve(zip_info.filename, create=True) 67 | info.zip_info = zip_info 68 | 69 | def __str__(self): 70 | if not self._segments: 71 | return '' 72 | return self.parser.join(*self._segments) 73 | 74 | def with_segments(self, *pathsegments): 75 | return type(self)(*pathsegments, zip_file=self.zip_file) 76 | 77 | @property 78 | def info(self): 79 | return self.zip_file.filetree.resolve(str(self)) 80 | 81 | def __open_rb__(self, buffering=-1): 82 | return self.zip_file.open(self.info.zip_info, 'r') 83 | 84 | def iterdir(self): 85 | return (self / name for name in self.info.children) 86 | 87 | def readlink(self): 88 | raise NotImplementedError 89 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents 4 | -------- 5 | 6 | .. toctree:: 7 | examples 8 | api 9 | changes 10 | license 11 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | PSF LICENSE AGREEMENT 5 | --------------------- 6 | 7 | .. literalinclude:: ../LICENSE.txt 8 | :language: text 9 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-copybutton 3 | furo 4 | -------------------------------------------------------------------------------- /pathlib_abc/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Protocols for supporting classes in pathlib. 3 | """ 4 | 5 | # This module also provides abstract base classes for rich path objects. 6 | # These ABCs are a *private* part of the Python standard library, but they're 7 | # made available as a PyPI package called "pathlib-abc". It's possible they'll 8 | # become an official part of the standard library in future. 9 | # 10 | # Three ABCs are provided -- _JoinablePath, _ReadablePath and _WritablePath 11 | 12 | 13 | from abc import ABC, abstractmethod 14 | from pathlib_abc._glob import _PathGlobber 15 | from pathlib_abc._os import magic_open, ensure_distinct_paths, ensure_different_files, copyfileobj 16 | from typing import Optional, Protocol, runtime_checkable 17 | try: 18 | from io import text_encoding 19 | except ImportError: 20 | def text_encoding(encoding): 21 | return encoding 22 | 23 | 24 | __all__ = ['PathParser', 'PathInfo', 'JoinablePath', 'ReadablePath', 'WritablePath', 'magic_open'] 25 | 26 | 27 | def _explode_path(path, split): 28 | """ 29 | Split the path into a 2-tuple (anchor, parts), where *anchor* is the 30 | uppermost parent of the path (equivalent to path.parents[-1]), and 31 | *parts* is a reversed list of parts following the anchor. 32 | """ 33 | parent, name = split(path) 34 | names = [] 35 | while path != parent: 36 | names.append(name) 37 | path = parent 38 | parent, name = split(path) 39 | return path, names 40 | 41 | 42 | @runtime_checkable 43 | class PathParser(Protocol): 44 | """Protocol for path parsers, which do low-level path manipulation. 45 | 46 | Path parsers provide a subset of the os.path API, specifically those 47 | functions needed to provide JoinablePath functionality. Each JoinablePath 48 | subclass references its path parser via a 'parser' class attribute. 49 | """ 50 | 51 | sep: str 52 | altsep: Optional[str] 53 | def split(self, path: str) -> tuple[str, str]: ... 54 | def splitext(self, path: str) -> tuple[str, str]: ... 55 | def normcase(self, path: str) -> str: ... 56 | 57 | 58 | @runtime_checkable 59 | class PathInfo(Protocol): 60 | """Protocol for path info objects, which support querying the file type. 61 | Methods may return cached results. 62 | """ 63 | def exists(self, *, follow_symlinks: bool = True) -> bool: ... 64 | def is_dir(self, *, follow_symlinks: bool = True) -> bool: ... 65 | def is_file(self, *, follow_symlinks: bool = True) -> bool: ... 66 | def is_symlink(self) -> bool: ... 67 | 68 | 69 | class JoinablePath(ABC): 70 | """Abstract base class for pure path objects. 71 | 72 | This class *does not* provide several magic methods that are defined in 73 | its implementation PurePath. They are: __init__, __fspath__, __bytes__, 74 | __reduce__, __hash__, __eq__, __lt__, __le__, __gt__, __ge__. 75 | """ 76 | __slots__ = () 77 | 78 | @property 79 | @abstractmethod 80 | def parser(self): 81 | """Implementation of pathlib._types.Parser used for low-level path 82 | parsing and manipulation. 83 | """ 84 | raise NotImplementedError 85 | 86 | @abstractmethod 87 | def with_segments(self, *pathsegments): 88 | """Construct a new path object from any number of path-like objects. 89 | Subclasses may override this method to customize how new path objects 90 | are created from methods like `iterdir()`. 91 | """ 92 | raise NotImplementedError 93 | 94 | @abstractmethod 95 | def __str__(self): 96 | """Return the string representation of the path, suitable for 97 | passing to system calls.""" 98 | raise NotImplementedError 99 | 100 | @property 101 | def anchor(self): 102 | """The concatenation of the drive and root, or ''.""" 103 | return _explode_path(str(self), self.parser.split)[0] 104 | 105 | @property 106 | def name(self): 107 | """The final path component, if any.""" 108 | return self.parser.split(str(self))[1] 109 | 110 | @property 111 | def suffix(self): 112 | """ 113 | The final component's last suffix, if any. 114 | 115 | This includes the leading period. For example: '.txt' 116 | """ 117 | return self.parser.splitext(self.name)[1] 118 | 119 | @property 120 | def suffixes(self): 121 | """ 122 | A list of the final component's suffixes, if any. 123 | 124 | These include the leading periods. For example: ['.tar', '.gz'] 125 | """ 126 | split = self.parser.splitext 127 | stem, suffix = split(self.name) 128 | suffixes = [] 129 | while suffix: 130 | suffixes.append(suffix) 131 | stem, suffix = split(stem) 132 | return suffixes[::-1] 133 | 134 | @property 135 | def stem(self): 136 | """The final path component, minus its last suffix.""" 137 | return self.parser.splitext(self.name)[0] 138 | 139 | def with_name(self, name): 140 | """Return a new path with the file name changed.""" 141 | split = self.parser.split 142 | if split(name)[0]: 143 | raise ValueError(f"Invalid name {name!r}") 144 | path = str(self) 145 | path = path.removesuffix(split(path)[1]) + name 146 | return self.with_segments(path) 147 | 148 | def with_stem(self, stem): 149 | """Return a new path with the stem changed.""" 150 | suffix = self.suffix 151 | if not suffix: 152 | return self.with_name(stem) 153 | elif not stem: 154 | # If the suffix is non-empty, we can't make the stem empty. 155 | raise ValueError(f"{self!r} has a non-empty suffix") 156 | else: 157 | return self.with_name(stem + suffix) 158 | 159 | def with_suffix(self, suffix): 160 | """Return a new path with the file suffix changed. If the path 161 | has no suffix, add given suffix. If the given suffix is an empty 162 | string, remove the suffix from the path. 163 | """ 164 | stem = self.stem 165 | if not stem: 166 | # If the stem is empty, we can't make the suffix non-empty. 167 | raise ValueError(f"{self!r} has an empty name") 168 | elif suffix and not suffix.startswith('.'): 169 | raise ValueError(f"Invalid suffix {suffix!r}") 170 | else: 171 | return self.with_name(stem + suffix) 172 | 173 | @property 174 | def parts(self): 175 | """An object providing sequence-like access to the 176 | components in the filesystem path.""" 177 | anchor, parts = _explode_path(str(self), self.parser.split) 178 | if anchor: 179 | parts.append(anchor) 180 | return tuple(reversed(parts)) 181 | 182 | def joinpath(self, *pathsegments): 183 | """Combine this path with one or several arguments, and return a 184 | new path representing either a subpath (if all arguments are relative 185 | paths) or a totally different path (if one of the arguments is 186 | anchored). 187 | """ 188 | return self.with_segments(str(self), *pathsegments) 189 | 190 | def __truediv__(self, key): 191 | try: 192 | return self.with_segments(str(self), key) 193 | except TypeError: 194 | return NotImplemented 195 | 196 | def __rtruediv__(self, key): 197 | try: 198 | return self.with_segments(key, str(self)) 199 | except TypeError: 200 | return NotImplemented 201 | 202 | @property 203 | def parent(self): 204 | """The logical parent of the path.""" 205 | path = str(self) 206 | parent = self.parser.split(path)[0] 207 | if path != parent: 208 | return self.with_segments(parent) 209 | return self 210 | 211 | @property 212 | def parents(self): 213 | """A sequence of this path's logical parents.""" 214 | split = self.parser.split 215 | path = str(self) 216 | parent = split(path)[0] 217 | parents = [] 218 | while path != parent: 219 | parents.append(self.with_segments(parent)) 220 | path = parent 221 | parent = split(path)[0] 222 | return tuple(parents) 223 | 224 | def full_match(self, pattern): 225 | """ 226 | Return True if this path matches the given glob-style pattern. The 227 | pattern is matched against the entire path. 228 | """ 229 | case_sensitive = self.parser.normcase('Aa') == 'Aa' 230 | globber = _PathGlobber(self.parser.sep, case_sensitive, recursive=True) 231 | match = globber.compile(pattern, altsep=self.parser.altsep) 232 | return match(str(self)) is not None 233 | 234 | 235 | class ReadablePath(JoinablePath): 236 | """Abstract base class for readable path objects. 237 | 238 | The Path class implements this ABC for local filesystem paths. Users may 239 | create subclasses to implement readable virtual filesystem paths, such as 240 | paths in archive files or on remote storage systems. 241 | """ 242 | __slots__ = () 243 | 244 | @property 245 | @abstractmethod 246 | def info(self): 247 | """ 248 | A PathInfo object that exposes the file type and other file attributes 249 | of this path. 250 | """ 251 | raise NotImplementedError 252 | 253 | @abstractmethod 254 | def __open_rb__(self, buffering=-1): 255 | """ 256 | Open the file pointed to by this path for reading in binary mode and 257 | return a file object, like open(mode='rb'). 258 | """ 259 | raise NotImplementedError 260 | 261 | def read_bytes(self): 262 | """ 263 | Open the file in bytes mode, read it, and close the file. 264 | """ 265 | with magic_open(self, mode='rb', buffering=0) as f: 266 | return f.read() 267 | 268 | def read_text(self, encoding=None, errors=None, newline=None): 269 | """ 270 | Open the file in text mode, read it, and close the file. 271 | """ 272 | # Call io.text_encoding() here to ensure any warning is raised at an 273 | # appropriate stack level. 274 | encoding = text_encoding(encoding) 275 | with magic_open(self, mode='r', encoding=encoding, errors=errors, newline=newline) as f: 276 | return f.read() 277 | 278 | @abstractmethod 279 | def iterdir(self): 280 | """Yield path objects of the directory contents. 281 | 282 | The children are yielded in arbitrary order, and the 283 | special entries '.' and '..' are not included. 284 | """ 285 | raise NotImplementedError 286 | 287 | def glob(self, pattern, *, recurse_symlinks=True): 288 | """Iterate over this subtree and yield all existing files (of any 289 | kind, including directories) matching the given relative pattern. 290 | """ 291 | anchor, parts = _explode_path(pattern, self.parser.split) 292 | if anchor: 293 | raise NotImplementedError("Non-relative patterns are unsupported") 294 | elif not parts: 295 | raise ValueError(f"Unacceptable pattern: {pattern!r}") 296 | elif not recurse_symlinks: 297 | raise NotImplementedError("recurse_symlinks=False is unsupported") 298 | case_sensitive = self.parser.normcase('Aa') == 'Aa' 299 | globber = _PathGlobber(self.parser.sep, case_sensitive, recursive=True) 300 | select = globber.selector(parts) 301 | return select(self.joinpath('')) 302 | 303 | def walk(self, top_down=True, on_error=None, follow_symlinks=False): 304 | """Walk the directory tree from this directory, similar to os.walk().""" 305 | paths = [self] 306 | while paths: 307 | path = paths.pop() 308 | if isinstance(path, tuple): 309 | yield path 310 | continue 311 | dirnames = [] 312 | filenames = [] 313 | if not top_down: 314 | paths.append((path, dirnames, filenames)) 315 | try: 316 | for child in path.iterdir(): 317 | if child.info.is_dir(follow_symlinks=follow_symlinks): 318 | if not top_down: 319 | paths.append(child) 320 | dirnames.append(child.name) 321 | else: 322 | filenames.append(child.name) 323 | except OSError as error: 324 | if on_error is not None: 325 | on_error(error) 326 | if not top_down: 327 | while not isinstance(paths.pop(), tuple): 328 | pass 329 | continue 330 | if top_down: 331 | yield path, dirnames, filenames 332 | paths += [path.joinpath(d) for d in reversed(dirnames)] 333 | 334 | @abstractmethod 335 | def readlink(self): 336 | """ 337 | Return the path to which the symbolic link points. 338 | """ 339 | raise NotImplementedError 340 | 341 | def copy(self, target, **kwargs): 342 | """ 343 | Recursively copy this file or directory tree to the given destination. 344 | """ 345 | ensure_distinct_paths(self, target) 346 | target._copy_from(self, **kwargs) 347 | return target.joinpath() # Empty join to ensure fresh metadata. 348 | 349 | def copy_into(self, target_dir, **kwargs): 350 | """ 351 | Copy this file or directory tree into the given existing directory. 352 | """ 353 | name = self.name 354 | if not name: 355 | raise ValueError(f"{self!r} has an empty name") 356 | return self.copy(target_dir / name, **kwargs) 357 | 358 | 359 | class WritablePath(JoinablePath): 360 | """Abstract base class for writable path objects. 361 | 362 | The Path class implements this ABC for local filesystem paths. Users may 363 | create subclasses to implement writable virtual filesystem paths, such as 364 | paths in archive files or on remote storage systems. 365 | """ 366 | __slots__ = () 367 | 368 | @abstractmethod 369 | def symlink_to(self, target, target_is_directory=False): 370 | """ 371 | Make this path a symlink pointing to the target path. 372 | Note the order of arguments (link, target) is the reverse of os.symlink. 373 | """ 374 | raise NotImplementedError 375 | 376 | @abstractmethod 377 | def mkdir(self): 378 | """ 379 | Create a new directory at this given path. 380 | """ 381 | raise NotImplementedError 382 | 383 | @abstractmethod 384 | def __open_wb__(self, buffering=-1): 385 | """ 386 | Open the file pointed to by this path for writing in binary mode and 387 | return a file object, like open(mode='wb'). 388 | """ 389 | raise NotImplementedError 390 | 391 | def write_bytes(self, data): 392 | """ 393 | Open the file in bytes mode, write to it, and close the file. 394 | """ 395 | # type-check for the buffer interface before truncating the file 396 | view = memoryview(data) 397 | with magic_open(self, mode='wb') as f: 398 | return f.write(view) 399 | 400 | def write_text(self, data, encoding=None, errors=None, newline=None): 401 | """ 402 | Open the file in text mode, write to it, and close the file. 403 | """ 404 | # Call io.text_encoding() here to ensure any warning is raised at an 405 | # appropriate stack level. 406 | encoding = text_encoding(encoding) 407 | if not isinstance(data, str): 408 | raise TypeError('data must be str, not %s' % 409 | data.__class__.__name__) 410 | with magic_open(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f: 411 | return f.write(data) 412 | 413 | def _copy_from(self, source, follow_symlinks=True): 414 | """ 415 | Recursively copy the given path to this path. 416 | """ 417 | stack = [(source, self)] 418 | while stack: 419 | src, dst = stack.pop() 420 | if not follow_symlinks and src.info.is_symlink(): 421 | dst.symlink_to(str(src.readlink()), src.info.is_dir()) 422 | elif src.info.is_dir(): 423 | children = src.iterdir() 424 | dst.mkdir() 425 | for child in children: 426 | stack.append((child, dst.joinpath(child.name))) 427 | else: 428 | ensure_different_files(src, dst) 429 | with magic_open(src, 'rb') as source_f: 430 | with magic_open(dst, 'wb') as target_f: 431 | copyfileobj(source_f, target_f) 432 | 433 | 434 | # For tests. 435 | _PathParser = PathParser 436 | _JoinablePath = JoinablePath 437 | _ReadablePath = ReadablePath 438 | _WritablePath = WritablePath 439 | -------------------------------------------------------------------------------- /pathlib_abc/_fnmatch.py: -------------------------------------------------------------------------------- 1 | """Filename matching with shell patterns. 2 | 3 | fnmatch(FILENAME, PATTERN) matches according to the local convention. 4 | fnmatchcase(FILENAME, PATTERN) always takes case in account. 5 | 6 | The functions operate by translating the pattern into a regular 7 | expression. They cache the compiled regular expressions for speed. 8 | 9 | The function translate(PATTERN) returns a regular expression 10 | corresponding to PATTERN. (It does not compile it.) 11 | """ 12 | 13 | import functools 14 | import itertools 15 | import os 16 | import posixpath 17 | import re 18 | 19 | __all__ = ["filter", "filterfalse", "fnmatch", "fnmatchcase", "translate"] 20 | 21 | 22 | def fnmatch(name, pat): 23 | """Test whether FILENAME matches PATTERN. 24 | 25 | Patterns are Unix shell style: 26 | 27 | * matches everything 28 | ? matches any single character 29 | [seq] matches any character in seq 30 | [!seq] matches any char not in seq 31 | 32 | An initial period in FILENAME is not special. 33 | Both FILENAME and PATTERN are first case-normalized 34 | if the operating system requires it. 35 | If you don't want this, use fnmatchcase(FILENAME, PATTERN). 36 | """ 37 | name = os.path.normcase(name) 38 | pat = os.path.normcase(pat) 39 | return fnmatchcase(name, pat) 40 | 41 | 42 | @functools.lru_cache(maxsize=32768, typed=True) 43 | def _compile_pattern(pat): 44 | if isinstance(pat, bytes): 45 | pat_str = str(pat, 'ISO-8859-1') 46 | res_str = translate(pat_str) 47 | res = bytes(res_str, 'ISO-8859-1') 48 | else: 49 | res = translate(pat) 50 | return re.compile(res).match 51 | 52 | 53 | def filter(names, pat): 54 | """Construct a list from those elements of the iterable NAMES that match PAT.""" 55 | result = [] 56 | pat = os.path.normcase(pat) 57 | match = _compile_pattern(pat) 58 | if os.path is posixpath: 59 | # normcase on posix is NOP. Optimize it away from the loop. 60 | for name in names: 61 | if match(name): 62 | result.append(name) 63 | else: 64 | for name in names: 65 | if match(os.path.normcase(name)): 66 | result.append(name) 67 | return result 68 | 69 | 70 | def filterfalse(names, pat): 71 | """Construct a list from those elements of the iterable NAMES that do not match PAT.""" 72 | pat = os.path.normcase(pat) 73 | match = _compile_pattern(pat) 74 | if os.path is posixpath: 75 | # normcase on posix is NOP. Optimize it away from the loop. 76 | return list(itertools.filterfalse(match, names)) 77 | 78 | result = [] 79 | for name in names: 80 | if match(os.path.normcase(name)) is None: 81 | result.append(name) 82 | return result 83 | 84 | 85 | def fnmatchcase(name, pat): 86 | """Test whether FILENAME matches PATTERN, including case. 87 | 88 | This is a version of fnmatch() which doesn't case-normalize 89 | its arguments. 90 | """ 91 | match = _compile_pattern(pat) 92 | return match(name) is not None 93 | 94 | 95 | def translate(pat): 96 | """Translate a shell PATTERN to a regular expression. 97 | 98 | There is no way to quote meta-characters. 99 | """ 100 | 101 | parts, star_indices = _translate(pat, '*', '.') 102 | return _join_translated_parts(parts, star_indices) 103 | 104 | 105 | _re_setops_sub = re.compile(r'([&~|])').sub 106 | _re_escape = functools.lru_cache(maxsize=512)(re.escape) 107 | 108 | 109 | def _translate(pat, star, question_mark): 110 | res = [] 111 | add = res.append 112 | star_indices = [] 113 | 114 | i, n = 0, len(pat) 115 | while i < n: 116 | c = pat[i] 117 | i = i+1 118 | if c == '*': 119 | # store the position of the wildcard 120 | star_indices.append(len(res)) 121 | add(star) 122 | # compress consecutive `*` into one 123 | while i < n and pat[i] == '*': 124 | i += 1 125 | elif c == '?': 126 | add(question_mark) 127 | elif c == '[': 128 | j = i 129 | if j < n and pat[j] == '!': 130 | j = j+1 131 | if j < n and pat[j] == ']': 132 | j = j+1 133 | while j < n and pat[j] != ']': 134 | j = j+1 135 | if j >= n: 136 | add('\\[') 137 | else: 138 | stuff = pat[i:j] 139 | if '-' not in stuff: 140 | stuff = stuff.replace('\\', r'\\') 141 | else: 142 | chunks = [] 143 | k = i+2 if pat[i] == '!' else i+1 144 | while True: 145 | k = pat.find('-', k, j) 146 | if k < 0: 147 | break 148 | chunks.append(pat[i:k]) 149 | i = k+1 150 | k = k+3 151 | chunk = pat[i:j] 152 | if chunk: 153 | chunks.append(chunk) 154 | else: 155 | chunks[-1] += '-' 156 | # Remove empty ranges -- invalid in RE. 157 | for k in range(len(chunks)-1, 0, -1): 158 | if chunks[k-1][-1] > chunks[k][0]: 159 | chunks[k-1] = chunks[k-1][:-1] + chunks[k][1:] 160 | del chunks[k] 161 | # Escape backslashes and hyphens for set difference (--). 162 | # Hyphens that create ranges shouldn't be escaped. 163 | stuff = '-'.join(s.replace('\\', r'\\').replace('-', r'\-') 164 | for s in chunks) 165 | i = j+1 166 | if not stuff: 167 | # Empty range: never match. 168 | add('(?!)') 169 | elif stuff == '!': 170 | # Negated empty range: match any character. 171 | add('.') 172 | else: 173 | # Escape set operations (&&, ~~ and ||). 174 | stuff = _re_setops_sub(r'\\\1', stuff) 175 | if stuff[0] == '!': 176 | stuff = '^' + stuff[1:] 177 | elif stuff[0] in ('^', '['): 178 | stuff = '\\' + stuff 179 | add(f'[{stuff}]') 180 | else: 181 | add(_re_escape(c)) 182 | assert i == n 183 | return res, star_indices 184 | 185 | 186 | def _join_translated_parts(parts, star_indices): 187 | if not star_indices: 188 | return fr'(?s:{"".join(parts)})\Z' 189 | iter_star_indices = iter(star_indices) 190 | j = next(iter_star_indices) 191 | buffer = parts[:j] # fixed pieces at the start 192 | append, extend = buffer.append, buffer.extend 193 | i = j + 1 194 | for j in iter_star_indices: 195 | # Now deal with STAR fixed STAR fixed ... 196 | # For an interior `STAR fixed` pairing, we want to do a minimal 197 | # .*? match followed by `fixed`, with no possibility of backtracking. 198 | # Atomic groups ("(?>...)") allow us to spell that directly. 199 | # Note: people rely on the undocumented ability to join multiple 200 | # translate() results together via "|" to build large regexps matching 201 | # "one of many" shell patterns. 202 | append('(?>.*?') 203 | extend(parts[i:j]) 204 | append(')') 205 | i = j + 1 206 | append('.*') 207 | extend(parts[i:]) 208 | res = ''.join(buffer) 209 | return fr'(?s:{res})\Z' 210 | -------------------------------------------------------------------------------- /pathlib_abc/_glob.py: -------------------------------------------------------------------------------- 1 | """Filename globbing utility.""" 2 | 3 | import contextlib 4 | import os 5 | import re 6 | from pathlib_abc import _fnmatch as fnmatch 7 | import functools 8 | import itertools 9 | import operator 10 | import stat 11 | import sys 12 | 13 | 14 | __all__ = ["glob", "iglob", "escape", "translate"] 15 | 16 | def glob(pathname, *, root_dir=None, dir_fd=None, recursive=False, 17 | include_hidden=False): 18 | """Return a list of paths matching a pathname pattern. 19 | 20 | The pattern may contain simple shell-style wildcards a la 21 | fnmatch. Unlike fnmatch, filenames starting with a 22 | dot are special cases that are not matched by '*' and '?' 23 | patterns by default. 24 | 25 | If `include_hidden` is true, the patterns '*', '?', '**' will match hidden 26 | directories. 27 | 28 | If `recursive` is true, the pattern '**' will match any files and 29 | zero or more directories and subdirectories. 30 | """ 31 | return list(iglob(pathname, root_dir=root_dir, dir_fd=dir_fd, recursive=recursive, 32 | include_hidden=include_hidden)) 33 | 34 | def iglob(pathname, *, root_dir=None, dir_fd=None, recursive=False, 35 | include_hidden=False): 36 | """Return an iterator which yields the paths matching a pathname pattern. 37 | 38 | The pattern may contain simple shell-style wildcards a la 39 | fnmatch. However, unlike fnmatch, filenames starting with a 40 | dot are special cases that are not matched by '*' and '?' 41 | patterns. 42 | 43 | If recursive is true, the pattern '**' will match any files and 44 | zero or more directories and subdirectories. 45 | """ 46 | sys.audit("glob.glob", pathname, recursive) 47 | sys.audit("glob.glob/2", pathname, recursive, root_dir, dir_fd) 48 | if root_dir is not None: 49 | root_dir = os.fspath(root_dir) 50 | else: 51 | root_dir = pathname[:0] 52 | it = _iglob(pathname, root_dir, dir_fd, recursive, False, 53 | include_hidden=include_hidden) 54 | if not pathname or recursive and _isrecursive(pathname[:2]): 55 | try: 56 | s = next(it) # skip empty string 57 | if s: 58 | it = itertools.chain((s,), it) 59 | except StopIteration: 60 | pass 61 | return it 62 | 63 | def _iglob(pathname, root_dir, dir_fd, recursive, dironly, 64 | include_hidden=False): 65 | dirname, basename = os.path.split(pathname) 66 | if not has_magic(pathname): 67 | assert not dironly 68 | if basename: 69 | if _lexists(_join(root_dir, pathname), dir_fd): 70 | yield pathname 71 | else: 72 | # Patterns ending with a slash should match only directories 73 | if _isdir(_join(root_dir, dirname), dir_fd): 74 | yield pathname 75 | return 76 | if not dirname: 77 | if recursive and _isrecursive(basename): 78 | yield from _glob2(root_dir, basename, dir_fd, dironly, 79 | include_hidden=include_hidden) 80 | else: 81 | yield from _glob1(root_dir, basename, dir_fd, dironly, 82 | include_hidden=include_hidden) 83 | return 84 | # `os.path.split()` returns the argument itself as a dirname if it is a 85 | # drive or UNC path. Prevent an infinite recursion if a drive or UNC path 86 | # contains magic characters (i.e. r'\\?\C:'). 87 | if dirname != pathname and has_magic(dirname): 88 | dirs = _iglob(dirname, root_dir, dir_fd, recursive, True, 89 | include_hidden=include_hidden) 90 | else: 91 | dirs = [dirname] 92 | if has_magic(basename): 93 | if recursive and _isrecursive(basename): 94 | glob_in_dir = _glob2 95 | else: 96 | glob_in_dir = _glob1 97 | else: 98 | glob_in_dir = _glob0 99 | for dirname in dirs: 100 | for name in glob_in_dir(_join(root_dir, dirname), basename, dir_fd, dironly, 101 | include_hidden=include_hidden): 102 | yield os.path.join(dirname, name) 103 | 104 | # These 2 helper functions non-recursively glob inside a literal directory. 105 | # They return a list of basenames. _glob1 accepts a pattern while _glob0 106 | # takes a literal basename (so it only has to check for its existence). 107 | 108 | def _glob1(dirname, pattern, dir_fd, dironly, include_hidden=False): 109 | names = _listdir(dirname, dir_fd, dironly) 110 | if not (include_hidden or _ishidden(pattern)): 111 | names = (x for x in names if not _ishidden(x)) 112 | return fnmatch.filter(names, pattern) 113 | 114 | def _glob0(dirname, basename, dir_fd, dironly, include_hidden=False): 115 | if basename: 116 | if _lexists(_join(dirname, basename), dir_fd): 117 | return [basename] 118 | else: 119 | # `os.path.split()` returns an empty basename for paths ending with a 120 | # directory separator. 'q*x/' should match only directories. 121 | if _isdir(dirname, dir_fd): 122 | return [basename] 123 | return [] 124 | 125 | _deprecated_function_message = ( 126 | "{name} is deprecated and will be removed in Python {remove}. Use " 127 | "glob.glob and pass a directory to its root_dir argument instead." 128 | ) 129 | 130 | def glob0(dirname, pattern): 131 | import warnings 132 | warnings._deprecated("glob.glob0", _deprecated_function_message, remove=(3, 15)) 133 | return _glob0(dirname, pattern, None, False) 134 | 135 | def glob1(dirname, pattern): 136 | import warnings 137 | warnings._deprecated("glob.glob1", _deprecated_function_message, remove=(3, 15)) 138 | return _glob1(dirname, pattern, None, False) 139 | 140 | # This helper function recursively yields relative pathnames inside a literal 141 | # directory. 142 | 143 | def _glob2(dirname, pattern, dir_fd, dironly, include_hidden=False): 144 | assert _isrecursive(pattern) 145 | if not dirname or _isdir(dirname, dir_fd): 146 | yield pattern[:0] 147 | yield from _rlistdir(dirname, dir_fd, dironly, 148 | include_hidden=include_hidden) 149 | 150 | # If dironly is false, yields all file names inside a directory. 151 | # If dironly is true, yields only directory names. 152 | def _iterdir(dirname, dir_fd, dironly): 153 | try: 154 | fd = None 155 | fsencode = None 156 | if dir_fd is not None: 157 | if dirname: 158 | fd = arg = os.open(dirname, _dir_open_flags, dir_fd=dir_fd) 159 | else: 160 | arg = dir_fd 161 | if isinstance(dirname, bytes): 162 | fsencode = os.fsencode 163 | elif dirname: 164 | arg = dirname 165 | elif isinstance(dirname, bytes): 166 | arg = bytes(os.curdir, 'ASCII') 167 | else: 168 | arg = os.curdir 169 | try: 170 | with os.scandir(arg) as it: 171 | for entry in it: 172 | try: 173 | if not dironly or entry.is_dir(): 174 | if fsencode is not None: 175 | yield fsencode(entry.name) 176 | else: 177 | yield entry.name 178 | except OSError: 179 | pass 180 | finally: 181 | if fd is not None: 182 | os.close(fd) 183 | except OSError: 184 | return 185 | 186 | def _listdir(dirname, dir_fd, dironly): 187 | with contextlib.closing(_iterdir(dirname, dir_fd, dironly)) as it: 188 | return list(it) 189 | 190 | # Recursively yields relative pathnames inside a literal directory. 191 | def _rlistdir(dirname, dir_fd, dironly, include_hidden=False): 192 | names = _listdir(dirname, dir_fd, dironly) 193 | for x in names: 194 | if include_hidden or not _ishidden(x): 195 | yield x 196 | path = _join(dirname, x) if dirname else x 197 | for y in _rlistdir(path, dir_fd, dironly, 198 | include_hidden=include_hidden): 199 | yield _join(x, y) 200 | 201 | 202 | def _lexists(pathname, dir_fd): 203 | # Same as os.path.lexists(), but with dir_fd 204 | if dir_fd is None: 205 | return os.path.lexists(pathname) 206 | try: 207 | os.lstat(pathname, dir_fd=dir_fd) 208 | except (OSError, ValueError): 209 | return False 210 | else: 211 | return True 212 | 213 | def _isdir(pathname, dir_fd): 214 | # Same as os.path.isdir(), but with dir_fd 215 | if dir_fd is None: 216 | return os.path.isdir(pathname) 217 | try: 218 | st = os.stat(pathname, dir_fd=dir_fd) 219 | except (OSError, ValueError): 220 | return False 221 | else: 222 | return stat.S_ISDIR(st.st_mode) 223 | 224 | def _join(dirname, basename): 225 | # It is common if dirname or basename is empty 226 | if not dirname or not basename: 227 | return dirname or basename 228 | return os.path.join(dirname, basename) 229 | 230 | magic_check = re.compile('([*?[])') 231 | magic_check_bytes = re.compile(b'([*?[])') 232 | 233 | def has_magic(s): 234 | if isinstance(s, bytes): 235 | match = magic_check_bytes.search(s) 236 | else: 237 | match = magic_check.search(s) 238 | return match is not None 239 | 240 | def _ishidden(path): 241 | return path[0] in ('.', b'.'[0]) 242 | 243 | def _isrecursive(pattern): 244 | if isinstance(pattern, bytes): 245 | return pattern == b'**' 246 | else: 247 | return pattern == '**' 248 | 249 | def escape(pathname): 250 | """Escape all special characters. 251 | """ 252 | # Escaping is done by wrapping any of "*?[" between square brackets. 253 | # Metacharacters do not work in the drive part and shouldn't be escaped. 254 | drive, pathname = os.path.splitdrive(pathname) 255 | if isinstance(pathname, bytes): 256 | pathname = magic_check_bytes.sub(br'[\1]', pathname) 257 | else: 258 | pathname = magic_check.sub(r'[\1]', pathname) 259 | return drive + pathname 260 | 261 | 262 | _special_parts = ('', '.', '..') 263 | _dir_open_flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) 264 | _no_recurse_symlinks = object() 265 | 266 | 267 | def translate(pat, *, recursive=False, include_hidden=False, seps=None): 268 | """Translate a pathname with shell wildcards to a regular expression. 269 | 270 | If `recursive` is true, the pattern segment '**' will match any number of 271 | path segments. 272 | 273 | If `include_hidden` is true, wildcards can match path segments beginning 274 | with a dot ('.'). 275 | 276 | If a sequence of separator characters is given to `seps`, they will be 277 | used to split the pattern into segments and match path separators. If not 278 | given, os.path.sep and os.path.altsep (where available) are used. 279 | """ 280 | if not seps: 281 | if os.path.altsep: 282 | seps = (os.path.sep, os.path.altsep) 283 | else: 284 | seps = os.path.sep 285 | escaped_seps = ''.join(map(re.escape, seps)) 286 | any_sep = f'[{escaped_seps}]' if len(seps) > 1 else escaped_seps 287 | not_sep = f'[^{escaped_seps}]' 288 | if include_hidden: 289 | one_last_segment = f'{not_sep}+' 290 | one_segment = f'{one_last_segment}{any_sep}' 291 | any_segments = f'(?:.+{any_sep})?' 292 | any_last_segments = '.*' 293 | else: 294 | one_last_segment = f'[^{escaped_seps}.]{not_sep}*' 295 | one_segment = f'{one_last_segment}{any_sep}' 296 | any_segments = f'(?:{one_segment})*' 297 | any_last_segments = f'{any_segments}(?:{one_last_segment})?' 298 | 299 | results = [] 300 | parts = re.split(any_sep, pat) 301 | last_part_idx = len(parts) - 1 302 | for idx, part in enumerate(parts): 303 | if part == '*': 304 | results.append(one_segment if idx < last_part_idx else one_last_segment) 305 | elif recursive and part == '**': 306 | if idx < last_part_idx: 307 | if parts[idx + 1] != '**': 308 | results.append(any_segments) 309 | else: 310 | results.append(any_last_segments) 311 | else: 312 | if part: 313 | if not include_hidden and part[0] in '*?': 314 | results.append(r'(?!\.)') 315 | results.extend(fnmatch._translate(part, f'{not_sep}*', not_sep)[0]) 316 | if idx < last_part_idx: 317 | results.append(any_sep) 318 | res = ''.join(results) 319 | return fr'(?s:{res})\Z' 320 | 321 | 322 | @functools.lru_cache(maxsize=512) 323 | def _compile_pattern(pat, seps, case_sensitive, recursive=True): 324 | """Compile given glob pattern to a re.Pattern object (observing case 325 | sensitivity).""" 326 | flags = 0 if case_sensitive else re.IGNORECASE 327 | regex = translate(pat, recursive=recursive, include_hidden=True, seps=seps) 328 | return re.compile(regex, flags=flags).match 329 | 330 | 331 | class _GlobberBase: 332 | """Abstract class providing shell-style pattern matching and globbing. 333 | """ 334 | 335 | def __init__(self, sep, case_sensitive, case_pedantic=False, recursive=False): 336 | self.sep = sep 337 | self.case_sensitive = case_sensitive 338 | self.case_pedantic = case_pedantic 339 | self.recursive = recursive 340 | 341 | # Abstract methods 342 | 343 | @staticmethod 344 | def lexists(path): 345 | """Implements os.path.lexists(). 346 | """ 347 | raise NotImplementedError 348 | 349 | @staticmethod 350 | def scandir(path): 351 | """Like os.scandir(), but generates (entry, name, path) tuples. 352 | """ 353 | raise NotImplementedError 354 | 355 | @staticmethod 356 | def concat_path(path, text): 357 | """Implements path concatenation. 358 | """ 359 | raise NotImplementedError 360 | 361 | # High-level methods 362 | 363 | def compile(self, pat, altsep=None): 364 | seps = (self.sep, altsep) if altsep else self.sep 365 | return _compile_pattern(pat, seps, self.case_sensitive, self.recursive) 366 | 367 | def selector(self, parts): 368 | """Returns a function that selects from a given path, walking and 369 | filtering according to the glob-style pattern parts in *parts*. 370 | """ 371 | if not parts: 372 | return self.select_exists 373 | part = parts.pop() 374 | if self.recursive and part == '**': 375 | selector = self.recursive_selector 376 | elif part in _special_parts: 377 | selector = self.special_selector 378 | elif not self.case_pedantic and magic_check.search(part) is None: 379 | selector = self.literal_selector 380 | else: 381 | selector = self.wildcard_selector 382 | return selector(part, parts) 383 | 384 | def special_selector(self, part, parts): 385 | """Returns a function that selects special children of the given path. 386 | """ 387 | if parts: 388 | part += self.sep 389 | select_next = self.selector(parts) 390 | 391 | def select_special(path, exists=False): 392 | path = self.concat_path(path, part) 393 | return select_next(path, exists) 394 | return select_special 395 | 396 | def literal_selector(self, part, parts): 397 | """Returns a function that selects a literal descendant of a path. 398 | """ 399 | 400 | # Optimization: consume and join any subsequent literal parts here, 401 | # rather than leaving them for the next selector. This reduces the 402 | # number of string concatenation operations. 403 | while parts and magic_check.search(parts[-1]) is None: 404 | part += self.sep + parts.pop() 405 | if parts: 406 | part += self.sep 407 | 408 | select_next = self.selector(parts) 409 | 410 | def select_literal(path, exists=False): 411 | path = self.concat_path(path, part) 412 | return select_next(path, exists=False) 413 | return select_literal 414 | 415 | def wildcard_selector(self, part, parts): 416 | """Returns a function that selects direct children of a given path, 417 | filtering by pattern. 418 | """ 419 | 420 | match = None if part == '*' else self.compile(part) 421 | dir_only = bool(parts) 422 | if dir_only: 423 | select_next = self.selector(parts) 424 | 425 | def select_wildcard(path, exists=False): 426 | try: 427 | entries = self.scandir(path) 428 | except OSError: 429 | pass 430 | else: 431 | for entry, entry_name, entry_path in entries: 432 | if match is None or match(entry_name): 433 | if dir_only: 434 | try: 435 | if not entry.is_dir(): 436 | continue 437 | except OSError: 438 | continue 439 | entry_path = self.concat_path(entry_path, self.sep) 440 | yield from select_next(entry_path, exists=True) 441 | else: 442 | yield entry_path 443 | return select_wildcard 444 | 445 | def recursive_selector(self, part, parts): 446 | """Returns a function that selects a given path and all its children, 447 | recursively, filtering by pattern. 448 | """ 449 | # Optimization: consume following '**' parts, which have no effect. 450 | while parts and parts[-1] == '**': 451 | parts.pop() 452 | 453 | # Optimization: consume and join any following non-special parts here, 454 | # rather than leaving them for the next selector. They're used to 455 | # build a regular expression, which we use to filter the results of 456 | # the recursive walk. As a result, non-special pattern segments 457 | # following a '**' wildcard don't require additional filesystem access 458 | # to expand. 459 | follow_symlinks = self.recursive is not _no_recurse_symlinks 460 | if follow_symlinks: 461 | while parts and parts[-1] not in _special_parts: 462 | part += self.sep + parts.pop() 463 | 464 | match = None if part == '**' else self.compile(part) 465 | dir_only = bool(parts) 466 | select_next = self.selector(parts) 467 | 468 | def select_recursive(path, exists=False): 469 | match_pos = len(str(path)) 470 | if match is None or match(str(path), match_pos): 471 | yield from select_next(path, exists) 472 | stack = [path] 473 | while stack: 474 | yield from select_recursive_step(stack, match_pos) 475 | 476 | def select_recursive_step(stack, match_pos): 477 | path = stack.pop() 478 | try: 479 | entries = self.scandir(path) 480 | except OSError: 481 | pass 482 | else: 483 | for entry, _entry_name, entry_path in entries: 484 | is_dir = False 485 | try: 486 | if entry.is_dir(follow_symlinks=follow_symlinks): 487 | is_dir = True 488 | except OSError: 489 | pass 490 | 491 | if is_dir or not dir_only: 492 | entry_path_str = str(entry_path) 493 | if dir_only: 494 | entry_path = self.concat_path(entry_path, self.sep) 495 | if match is None or match(entry_path_str, match_pos): 496 | if dir_only: 497 | yield from select_next(entry_path, exists=True) 498 | else: 499 | # Optimization: directly yield the path if this is 500 | # last pattern part. 501 | yield entry_path 502 | if is_dir: 503 | stack.append(entry_path) 504 | 505 | return select_recursive 506 | 507 | def select_exists(self, path, exists=False): 508 | """Yields the given path, if it exists. 509 | """ 510 | if exists: 511 | # Optimization: this path is already known to exist, e.g. because 512 | # it was returned from os.scandir(), so we skip calling lstat(). 513 | yield path 514 | elif self.lexists(path): 515 | yield path 516 | 517 | 518 | class _StringGlobber(_GlobberBase): 519 | """Provides shell-style pattern matching and globbing for string paths. 520 | """ 521 | lexists = staticmethod(os.path.lexists) 522 | concat_path = operator.add 523 | 524 | @staticmethod 525 | def scandir(path): 526 | # We must close the scandir() object before proceeding to 527 | # avoid exhausting file descriptors when globbing deep trees. 528 | with os.scandir(path) as scandir_it: 529 | entries = list(scandir_it) 530 | return ((entry, entry.name, entry.path) for entry in entries) 531 | 532 | 533 | class _PathGlobber(_GlobberBase): 534 | """Provides shell-style pattern matching and globbing for pathlib paths. 535 | """ 536 | 537 | @staticmethod 538 | def lexists(path): 539 | return path.info.exists(follow_symlinks=False) 540 | 541 | @staticmethod 542 | def scandir(path): 543 | return ((child.info, child.name, child) for child in path.iterdir()) 544 | 545 | @staticmethod 546 | def concat_path(path, text): 547 | return path.with_segments(str(path) + text) 548 | -------------------------------------------------------------------------------- /pathlib_abc/_os.py: -------------------------------------------------------------------------------- 1 | """ 2 | Low-level OS functionality wrappers used by pathlib. 3 | """ 4 | 5 | from errno import * 6 | from io import TextIOWrapper 7 | from stat import S_ISDIR, S_ISREG, S_ISLNK, S_IMODE 8 | import os 9 | import sys 10 | try: 11 | from io import text_encoding 12 | except ImportError: 13 | def text_encoding(encoding): 14 | return encoding 15 | try: 16 | import fcntl 17 | except ImportError: 18 | fcntl = None 19 | try: 20 | import posix 21 | except ImportError: 22 | posix = None 23 | try: 24 | import _winapi 25 | except ImportError: 26 | _winapi = None 27 | 28 | 29 | def _get_copy_blocksize(infd): 30 | """Determine blocksize for fastcopying on Linux. 31 | Hopefully the whole file will be copied in a single call. 32 | The copying itself should be performed in a loop 'till EOF is 33 | reached (0 return) so a blocksize smaller or bigger than the actual 34 | file size should not make any difference, also in case the file 35 | content changes while being copied. 36 | """ 37 | try: 38 | blocksize = max(os.fstat(infd).st_size, 2 ** 23) # min 8 MiB 39 | except OSError: 40 | blocksize = 2 ** 27 # 128 MiB 41 | # On 32-bit architectures truncate to 1 GiB to avoid OverflowError, 42 | # see gh-82500. 43 | if sys.maxsize < 2 ** 32: 44 | blocksize = min(blocksize, 2 ** 30) 45 | return blocksize 46 | 47 | 48 | if fcntl and hasattr(fcntl, 'FICLONE'): 49 | def _ficlone(source_fd, target_fd): 50 | """ 51 | Perform a lightweight copy of two files, where the data blocks are 52 | copied only when modified. This is known as Copy on Write (CoW), 53 | instantaneous copy or reflink. 54 | """ 55 | fcntl.ioctl(target_fd, fcntl.FICLONE, source_fd) 56 | else: 57 | _ficlone = None 58 | 59 | 60 | if posix and hasattr(posix, '_fcopyfile'): 61 | def _fcopyfile(source_fd, target_fd): 62 | """ 63 | Copy a regular file content using high-performance fcopyfile(3) 64 | syscall (macOS). 65 | """ 66 | posix._fcopyfile(source_fd, target_fd, posix._COPYFILE_DATA) 67 | else: 68 | _fcopyfile = None 69 | 70 | 71 | if hasattr(os, 'copy_file_range'): 72 | def _copy_file_range(source_fd, target_fd): 73 | """ 74 | Copy data from one regular mmap-like fd to another by using a 75 | high-performance copy_file_range(2) syscall that gives filesystems 76 | an opportunity to implement the use of reflinks or server-side 77 | copy. 78 | This should work on Linux >= 4.5 only. 79 | """ 80 | blocksize = _get_copy_blocksize(source_fd) 81 | offset = 0 82 | while True: 83 | sent = os.copy_file_range(source_fd, target_fd, blocksize, 84 | offset_dst=offset) 85 | if sent == 0: 86 | break # EOF 87 | offset += sent 88 | else: 89 | _copy_file_range = None 90 | 91 | 92 | if hasattr(os, 'sendfile'): 93 | def _sendfile(source_fd, target_fd): 94 | """Copy data from one regular mmap-like fd to another by using 95 | high-performance sendfile(2) syscall. 96 | This should work on Linux >= 2.6.33 only. 97 | """ 98 | blocksize = _get_copy_blocksize(source_fd) 99 | offset = 0 100 | while True: 101 | sent = os.sendfile(target_fd, source_fd, offset, blocksize) 102 | if sent == 0: 103 | break # EOF 104 | offset += sent 105 | else: 106 | _sendfile = None 107 | 108 | 109 | if _winapi and hasattr(_winapi, 'CopyFile2'): 110 | def copyfile2(source, target): 111 | """ 112 | Copy from one file to another using CopyFile2 (Windows only). 113 | """ 114 | _winapi.CopyFile2(source, target, 0) 115 | else: 116 | copyfile2 = None 117 | 118 | 119 | def copyfileobj(source_f, target_f): 120 | """ 121 | Copy data from file-like object source_f to file-like object target_f. 122 | """ 123 | try: 124 | source_fd = source_f.fileno() 125 | target_fd = target_f.fileno() 126 | except Exception: 127 | pass # Fall through to generic code. 128 | else: 129 | try: 130 | # Use OS copy-on-write where available. 131 | if _ficlone: 132 | try: 133 | _ficlone(source_fd, target_fd) 134 | return 135 | except OSError as err: 136 | if err.errno not in (EBADF, EOPNOTSUPP, ETXTBSY, EXDEV): 137 | raise err 138 | 139 | # Use OS copy where available. 140 | if _fcopyfile: 141 | try: 142 | _fcopyfile(source_fd, target_fd) 143 | return 144 | except OSError as err: 145 | if err.errno not in (EINVAL, ENOTSUP): 146 | raise err 147 | if _copy_file_range: 148 | try: 149 | _copy_file_range(source_fd, target_fd) 150 | return 151 | except OSError as err: 152 | if err.errno not in (ETXTBSY, EXDEV): 153 | raise err 154 | if _sendfile: 155 | try: 156 | _sendfile(source_fd, target_fd) 157 | return 158 | except OSError as err: 159 | if err.errno != ENOTSOCK: 160 | raise err 161 | except OSError as err: 162 | # Produce more useful error messages. 163 | err.filename = source_f.name 164 | err.filename2 = target_f.name 165 | raise err 166 | 167 | # Last resort: copy with fileobj read() and write(). 168 | read_source = source_f.read 169 | write_target = target_f.write 170 | while buf := read_source(1024 * 1024): 171 | write_target(buf) 172 | 173 | 174 | def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None, 175 | newline=None): 176 | """ 177 | Open the file pointed to by this path and return a file object, as 178 | the built-in open() function does. 179 | """ 180 | text = 'b' not in mode 181 | if text: 182 | # Call io.text_encoding() here to ensure any warning is raised at an 183 | # appropriate stack level. 184 | encoding = text_encoding(encoding) 185 | try: 186 | return open(path, mode, buffering, encoding, errors, newline) 187 | except TypeError: 188 | pass 189 | cls = type(path) 190 | mode = ''.join(sorted(c for c in mode if c not in 'bt')) 191 | if text: 192 | try: 193 | attr = getattr(cls, f'__open_{mode}__') 194 | except AttributeError: 195 | pass 196 | else: 197 | return attr(path, buffering, encoding, errors, newline) 198 | elif encoding is not None: 199 | raise ValueError("binary mode doesn't take an encoding argument") 200 | elif errors is not None: 201 | raise ValueError("binary mode doesn't take an errors argument") 202 | elif newline is not None: 203 | raise ValueError("binary mode doesn't take a newline argument") 204 | 205 | try: 206 | attr = getattr(cls, f'__open_{mode}b__') 207 | except AttributeError: 208 | pass 209 | else: 210 | stream = attr(path, buffering) 211 | if text: 212 | stream = TextIOWrapper(stream, encoding, errors, newline) 213 | return stream 214 | 215 | raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}") 216 | 217 | 218 | def ensure_distinct_paths(source, target): 219 | """ 220 | Raise OSError(EINVAL) if the other path is within this path. 221 | """ 222 | # Note: there is no straightforward, foolproof algorithm to determine 223 | # if one directory is within another (a particularly perverse example 224 | # would be a single network share mounted in one location via NFS, and 225 | # in another location via CIFS), so we simply checks whether the 226 | # other path is lexically equal to, or within, this path. 227 | if source == target: 228 | err = OSError(EINVAL, "Source and target are the same path") 229 | elif source in target.parents: 230 | err = OSError(EINVAL, "Source path is a parent of target path") 231 | else: 232 | return 233 | err.filename = str(source) 234 | err.filename2 = str(target) 235 | raise err 236 | 237 | 238 | def ensure_different_files(source, target): 239 | """ 240 | Raise OSError(EINVAL) if both paths refer to the same file. 241 | """ 242 | try: 243 | source_file_id = source.info._file_id 244 | target_file_id = target.info._file_id 245 | except AttributeError: 246 | if source != target: 247 | return 248 | else: 249 | try: 250 | if source_file_id() != target_file_id(): 251 | return 252 | except (OSError, ValueError): 253 | return 254 | err = OSError(EINVAL, "Source and target are the same file") 255 | err.filename = str(source) 256 | err.filename2 = str(target) 257 | raise err 258 | 259 | 260 | def copy_info(info, target, follow_symlinks=True): 261 | """Copy metadata from the given PathInfo to the given local path.""" 262 | copy_times_ns = ( 263 | hasattr(info, '_access_time_ns') and 264 | hasattr(info, '_mod_time_ns') and 265 | (follow_symlinks or os.utime in os.supports_follow_symlinks)) 266 | if copy_times_ns: 267 | t0 = info._access_time_ns(follow_symlinks=follow_symlinks) 268 | t1 = info._mod_time_ns(follow_symlinks=follow_symlinks) 269 | os.utime(target, ns=(t0, t1), follow_symlinks=follow_symlinks) 270 | 271 | # We must copy extended attributes before the file is (potentially) 272 | # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. 273 | copy_xattrs = ( 274 | hasattr(info, '_xattrs') and 275 | hasattr(os, 'setxattr') and 276 | (follow_symlinks or os.setxattr in os.supports_follow_symlinks)) 277 | if copy_xattrs: 278 | xattrs = info._xattrs(follow_symlinks=follow_symlinks) 279 | for attr, value in xattrs: 280 | try: 281 | os.setxattr(target, attr, value, follow_symlinks=follow_symlinks) 282 | except OSError as e: 283 | if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): 284 | raise 285 | 286 | copy_posix_permissions = ( 287 | hasattr(info, '_posix_permissions') and 288 | (follow_symlinks or os.chmod in os.supports_follow_symlinks)) 289 | if copy_posix_permissions: 290 | posix_permissions = info._posix_permissions(follow_symlinks=follow_symlinks) 291 | try: 292 | os.chmod(target, posix_permissions, follow_symlinks=follow_symlinks) 293 | except NotImplementedError: 294 | # if we got a NotImplementedError, it's because 295 | # * follow_symlinks=False, 296 | # * lchown() is unavailable, and 297 | # * either 298 | # * fchownat() is unavailable or 299 | # * fchownat() doesn't implement AT_SYMLINK_NOFOLLOW. 300 | # (it returned ENOSUP.) 301 | # therefore we're out of options--we simply cannot chown the 302 | # symlink. give up, suppress the error. 303 | # (which is what shutil always did in this circumstance.) 304 | pass 305 | 306 | copy_bsd_flags = ( 307 | hasattr(info, '_bsd_flags') and 308 | hasattr(os, 'chflags') and 309 | (follow_symlinks or os.chflags in os.supports_follow_symlinks)) 310 | if copy_bsd_flags: 311 | bsd_flags = info._bsd_flags(follow_symlinks=follow_symlinks) 312 | try: 313 | os.chflags(target, bsd_flags, follow_symlinks=follow_symlinks) 314 | except OSError as why: 315 | if why.errno not in (EOPNOTSUPP, ENOTSUP): 316 | raise 317 | 318 | 319 | class _PathInfoBase: 320 | __slots__ = ('_path', '_stat_result', '_lstat_result') 321 | 322 | def __init__(self, path): 323 | self._path = str(path) 324 | 325 | def __repr__(self): 326 | path_type = "WindowsPath" if os.name == "nt" else "PosixPath" 327 | return f"<{path_type}.info>" 328 | 329 | def _stat(self, *, follow_symlinks=True, ignore_errors=False): 330 | """Return the status as an os.stat_result, or None if stat() fails and 331 | ignore_errors is true.""" 332 | if follow_symlinks: 333 | try: 334 | result = self._stat_result 335 | except AttributeError: 336 | pass 337 | else: 338 | if ignore_errors or result is not None: 339 | return result 340 | try: 341 | self._stat_result = os.stat(self._path) 342 | except (OSError, ValueError): 343 | self._stat_result = None 344 | if not ignore_errors: 345 | raise 346 | return self._stat_result 347 | else: 348 | try: 349 | result = self._lstat_result 350 | except AttributeError: 351 | pass 352 | else: 353 | if ignore_errors or result is not None: 354 | return result 355 | try: 356 | self._lstat_result = os.lstat(self._path) 357 | except (OSError, ValueError): 358 | self._lstat_result = None 359 | if not ignore_errors: 360 | raise 361 | return self._lstat_result 362 | 363 | def _posix_permissions(self, *, follow_symlinks=True): 364 | """Return the POSIX file permissions.""" 365 | return S_IMODE(self._stat(follow_symlinks=follow_symlinks).st_mode) 366 | 367 | def _file_id(self, *, follow_symlinks=True): 368 | """Returns the identifier of the file.""" 369 | st = self._stat(follow_symlinks=follow_symlinks) 370 | return st.st_dev, st.st_ino 371 | 372 | def _access_time_ns(self, *, follow_symlinks=True): 373 | """Return the access time in nanoseconds.""" 374 | return self._stat(follow_symlinks=follow_symlinks).st_atime_ns 375 | 376 | def _mod_time_ns(self, *, follow_symlinks=True): 377 | """Return the modify time in nanoseconds.""" 378 | return self._stat(follow_symlinks=follow_symlinks).st_mtime_ns 379 | 380 | if hasattr(os.stat_result, 'st_flags'): 381 | def _bsd_flags(self, *, follow_symlinks=True): 382 | """Return the flags.""" 383 | return self._stat(follow_symlinks=follow_symlinks).st_flags 384 | 385 | if hasattr(os, 'listxattr'): 386 | def _xattrs(self, *, follow_symlinks=True): 387 | """Return the xattrs as a list of (attr, value) pairs, or an empty 388 | list if extended attributes aren't supported.""" 389 | try: 390 | return [ 391 | (attr, os.getxattr(self._path, attr, follow_symlinks=follow_symlinks)) 392 | for attr in os.listxattr(self._path, follow_symlinks=follow_symlinks)] 393 | except OSError as err: 394 | if err.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): 395 | raise 396 | return [] 397 | 398 | 399 | class _WindowsPathInfo(_PathInfoBase): 400 | """Implementation of pathlib.types.PathInfo that provides status 401 | information for Windows paths. Don't try to construct it yourself.""" 402 | __slots__ = ('_exists', '_is_dir', '_is_file', '_is_symlink') 403 | 404 | def exists(self, *, follow_symlinks=True): 405 | """Whether this path exists.""" 406 | if not follow_symlinks and self.is_symlink(): 407 | return True 408 | try: 409 | return self._exists 410 | except AttributeError: 411 | if os.path.exists(self._path): 412 | self._exists = True 413 | return True 414 | else: 415 | self._exists = self._is_dir = self._is_file = False 416 | return False 417 | 418 | def is_dir(self, *, follow_symlinks=True): 419 | """Whether this path is a directory.""" 420 | if not follow_symlinks and self.is_symlink(): 421 | return False 422 | try: 423 | return self._is_dir 424 | except AttributeError: 425 | if os.path.isdir(self._path): 426 | self._is_dir = self._exists = True 427 | return True 428 | else: 429 | self._is_dir = False 430 | return False 431 | 432 | def is_file(self, *, follow_symlinks=True): 433 | """Whether this path is a regular file.""" 434 | if not follow_symlinks and self.is_symlink(): 435 | return False 436 | try: 437 | return self._is_file 438 | except AttributeError: 439 | if os.path.isfile(self._path): 440 | self._is_file = self._exists = True 441 | return True 442 | else: 443 | self._is_file = False 444 | return False 445 | 446 | def is_symlink(self): 447 | """Whether this path is a symbolic link.""" 448 | try: 449 | return self._is_symlink 450 | except AttributeError: 451 | self._is_symlink = os.path.islink(self._path) 452 | return self._is_symlink 453 | 454 | 455 | class _PosixPathInfo(_PathInfoBase): 456 | """Implementation of pathlib.types.PathInfo that provides status 457 | information for POSIX paths. Don't try to construct it yourself.""" 458 | __slots__ = () 459 | 460 | def exists(self, *, follow_symlinks=True): 461 | """Whether this path exists.""" 462 | st = self._stat(follow_symlinks=follow_symlinks, ignore_errors=True) 463 | if st is None: 464 | return False 465 | return True 466 | 467 | def is_dir(self, *, follow_symlinks=True): 468 | """Whether this path is a directory.""" 469 | st = self._stat(follow_symlinks=follow_symlinks, ignore_errors=True) 470 | if st is None: 471 | return False 472 | return S_ISDIR(st.st_mode) 473 | 474 | def is_file(self, *, follow_symlinks=True): 475 | """Whether this path is a regular file.""" 476 | st = self._stat(follow_symlinks=follow_symlinks, ignore_errors=True) 477 | if st is None: 478 | return False 479 | return S_ISREG(st.st_mode) 480 | 481 | def is_symlink(self): 482 | """Whether this path is a symbolic link.""" 483 | st = self._stat(follow_symlinks=False, ignore_errors=True) 484 | if st is None: 485 | return False 486 | return S_ISLNK(st.st_mode) 487 | 488 | 489 | PathInfo = _WindowsPathInfo if os.name == 'nt' else _PosixPathInfo 490 | 491 | 492 | class DirEntryInfo(_PathInfoBase): 493 | """Implementation of pathlib.types.PathInfo that provides status 494 | information by querying a wrapped os.DirEntry object. Don't try to 495 | construct it yourself.""" 496 | __slots__ = ('_entry',) 497 | 498 | def __init__(self, entry): 499 | super().__init__(entry.path) 500 | self._entry = entry 501 | 502 | def _stat(self, *, follow_symlinks=True, ignore_errors=False): 503 | try: 504 | return self._entry.stat(follow_symlinks=follow_symlinks) 505 | except OSError: 506 | if not ignore_errors: 507 | raise 508 | return None 509 | 510 | def exists(self, *, follow_symlinks=True): 511 | """Whether this path exists.""" 512 | if not follow_symlinks: 513 | return True 514 | return self._stat(ignore_errors=True) is not None 515 | 516 | def is_dir(self, *, follow_symlinks=True): 517 | """Whether this path is a directory.""" 518 | try: 519 | return self._entry.is_dir(follow_symlinks=follow_symlinks) 520 | except OSError: 521 | return False 522 | 523 | def is_file(self, *, follow_symlinks=True): 524 | """Whether this path is a regular file.""" 525 | try: 526 | return self._entry.is_file(follow_symlinks=follow_symlinks) 527 | except OSError: 528 | return False 529 | 530 | def is_symlink(self): 531 | """Whether this path is a symbolic link.""" 532 | try: 533 | return self._entry.is_symlink() 534 | except OSError: 535 | return False 536 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pathlib_abc" 7 | version = "0.4.3" 8 | authors = [ 9 | { name="Barney Gale", email="barney.gale@gmail.com" }, 10 | ] 11 | description = "Backport of pathlib ABCs" 12 | readme = "README.rst" 13 | license = {file = "LICENSE.txt"} 14 | requires-python = ">=3.9" 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: Python Software Foundation License", 18 | "Operating System :: OS Independent", 19 | "Intended Audience :: Developers" 20 | ] 21 | 22 | [project.urls] 23 | Homepage = "https://github.com/barneygale/pathlib-abc" 24 | Issues = "https://github.com/barneygale/pathlib-abc/issues" 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from test.support import load_package_tests 3 | 4 | def load_tests(*args): 5 | return load_package_tests(os.path.dirname(__file__), *args) 6 | -------------------------------------------------------------------------------- /tests/support/__init__.py: -------------------------------------------------------------------------------- 1 | is_pypi = True 2 | -------------------------------------------------------------------------------- /tests/support/lexical_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple implementation of JoinablePath, for use in pathlib tests. 3 | """ 4 | 5 | import ntpath 6 | import os.path 7 | import posixpath 8 | 9 | from . import is_pypi 10 | 11 | if is_pypi: 12 | from pathlib_abc import _JoinablePath 13 | else: 14 | from pathlib.types import _JoinablePath 15 | 16 | 17 | class LexicalPath(_JoinablePath): 18 | __slots__ = ('_segments',) 19 | parser = os.path 20 | 21 | def __init__(self, *pathsegments): 22 | self._segments = pathsegments 23 | 24 | def __hash__(self): 25 | return hash(str(self)) 26 | 27 | def __eq__(self, other): 28 | if not isinstance(other, LexicalPath): 29 | return NotImplemented 30 | return str(self) == str(other) 31 | 32 | def __str__(self): 33 | if not self._segments: 34 | return '' 35 | return self.parser.join(*self._segments) 36 | 37 | def __repr__(self): 38 | return f'{type(self).__name__}({str(self)!r})' 39 | 40 | def with_segments(self, *pathsegments): 41 | return type(self)(*pathsegments) 42 | 43 | 44 | class LexicalPosixPath(LexicalPath): 45 | __slots__ = () 46 | parser = posixpath 47 | 48 | 49 | class LexicalWindowsPath(LexicalPath): 50 | __slots__ = () 51 | parser = ntpath 52 | -------------------------------------------------------------------------------- /tests/support/local_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementations of ReadablePath and WritablePath for local paths, for use in 3 | pathlib tests. 4 | 5 | LocalPathGround is also defined here. It helps establish the "ground truth" 6 | about local paths in tests. 7 | """ 8 | 9 | import os 10 | 11 | from . import is_pypi 12 | from .lexical_path import LexicalPath 13 | 14 | if is_pypi: 15 | from shutil import rmtree 16 | from pathlib_abc import PathInfo, _ReadablePath, _WritablePath 17 | can_symlink = True 18 | testfn = "TESTFN" 19 | else: 20 | from pathlib.types import PathInfo, _ReadablePath, _WritablePath 21 | from test.support import os_helper 22 | can_symlink = os_helper.can_symlink() 23 | testfn = os_helper.TESTFN 24 | rmtree = os_helper.rmtree 25 | 26 | 27 | class LocalPathGround: 28 | can_symlink = can_symlink 29 | 30 | def __init__(self, path_cls): 31 | self.path_cls = path_cls 32 | 33 | def setup(self, local_suffix=""): 34 | root = self.path_cls(testfn + local_suffix) 35 | os.mkdir(root) 36 | return root 37 | 38 | def teardown(self, root): 39 | rmtree(root) 40 | 41 | def create_file(self, p, data=b''): 42 | with open(p, 'wb') as f: 43 | f.write(data) 44 | 45 | def create_dir(self, p): 46 | os.mkdir(p) 47 | 48 | def create_symlink(self, p, target): 49 | os.symlink(target, p) 50 | 51 | def create_hierarchy(self, p): 52 | os.mkdir(os.path.join(p, 'dirA')) 53 | os.mkdir(os.path.join(p, 'dirB')) 54 | os.mkdir(os.path.join(p, 'dirC')) 55 | os.mkdir(os.path.join(p, 'dirC', 'dirD')) 56 | with open(os.path.join(p, 'fileA'), 'wb') as f: 57 | f.write(b"this is file A\n") 58 | with open(os.path.join(p, 'dirB', 'fileB'), 'wb') as f: 59 | f.write(b"this is file B\n") 60 | with open(os.path.join(p, 'dirC', 'fileC'), 'wb') as f: 61 | f.write(b"this is file C\n") 62 | with open(os.path.join(p, 'dirC', 'novel.txt'), 'wb') as f: 63 | f.write(b"this is a novel\n") 64 | with open(os.path.join(p, 'dirC', 'dirD', 'fileD'), 'wb') as f: 65 | f.write(b"this is file D\n") 66 | if self.can_symlink: 67 | # Relative symlinks. 68 | os.symlink('fileA', os.path.join(p, 'linkA')) 69 | os.symlink('non-existing', os.path.join(p, 'brokenLink')) 70 | os.symlink('dirB', 71 | os.path.join(p, 'linkB'), 72 | target_is_directory=True) 73 | os.symlink(os.path.join('..', 'dirB'), 74 | os.path.join(p, 'dirA', 'linkC'), 75 | target_is_directory=True) 76 | # Broken symlink (pointing to itself). 77 | os.symlink('brokenLinkLoop', os.path.join(p, 'brokenLinkLoop')) 78 | 79 | isdir = staticmethod(os.path.isdir) 80 | isfile = staticmethod(os.path.isfile) 81 | islink = staticmethod(os.path.islink) 82 | readlink = staticmethod(os.readlink) 83 | 84 | def readtext(self, p): 85 | with open(p, 'r', encoding='utf-8') as f: 86 | return f.read() 87 | 88 | def readbytes(self, p): 89 | with open(p, 'rb') as f: 90 | return f.read() 91 | 92 | 93 | class LocalPathInfo(PathInfo): 94 | """ 95 | Simple implementation of PathInfo for a local path 96 | """ 97 | __slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink') 98 | 99 | def __init__(self, path): 100 | self._path = str(path) 101 | self._exists = None 102 | self._is_dir = None 103 | self._is_file = None 104 | self._is_symlink = None 105 | 106 | def exists(self, *, follow_symlinks=True): 107 | """Whether this path exists.""" 108 | if not follow_symlinks and self.is_symlink(): 109 | return True 110 | if self._exists is None: 111 | self._exists = os.path.exists(self._path) 112 | return self._exists 113 | 114 | def is_dir(self, *, follow_symlinks=True): 115 | """Whether this path is a directory.""" 116 | if not follow_symlinks and self.is_symlink(): 117 | return False 118 | if self._is_dir is None: 119 | self._is_dir = os.path.isdir(self._path) 120 | return self._is_dir 121 | 122 | def is_file(self, *, follow_symlinks=True): 123 | """Whether this path is a regular file.""" 124 | if not follow_symlinks and self.is_symlink(): 125 | return False 126 | if self._is_file is None: 127 | self._is_file = os.path.isfile(self._path) 128 | return self._is_file 129 | 130 | def is_symlink(self): 131 | """Whether this path is a symbolic link.""" 132 | if self._is_symlink is None: 133 | self._is_symlink = os.path.islink(self._path) 134 | return self._is_symlink 135 | 136 | 137 | class ReadableLocalPath(_ReadablePath, LexicalPath): 138 | """ 139 | Simple implementation of a ReadablePath class for local filesystem paths. 140 | """ 141 | __slots__ = ('info',) 142 | 143 | def __init__(self, *pathsegments): 144 | super().__init__(*pathsegments) 145 | self.info = LocalPathInfo(self) 146 | 147 | def __fspath__(self): 148 | return str(self) 149 | 150 | def __open_rb__(self, buffering=-1): 151 | return open(self, 'rb') 152 | 153 | def iterdir(self): 154 | return (self / name for name in os.listdir(self)) 155 | 156 | def readlink(self): 157 | return self.with_segments(os.readlink(self)) 158 | 159 | 160 | class WritableLocalPath(_WritablePath, LexicalPath): 161 | """ 162 | Simple implementation of a WritablePath class for local filesystem paths. 163 | """ 164 | 165 | __slots__ = () 166 | 167 | def __fspath__(self): 168 | return str(self) 169 | 170 | def __open_wb__(self, buffering=-1): 171 | return open(self, 'wb') 172 | 173 | def mkdir(self, mode=0o777): 174 | os.mkdir(self, mode) 175 | 176 | def symlink_to(self, target, target_is_directory=False): 177 | os.symlink(target, self, target_is_directory) 178 | -------------------------------------------------------------------------------- /tests/support/zip_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementations of ReadablePath and WritablePath for zip file members, for use 3 | in pathlib tests. 4 | 5 | ZipPathGround is also defined here. It helps establish the "ground truth" 6 | about zip file members in tests. 7 | """ 8 | 9 | import errno 10 | import io 11 | import posixpath 12 | import stat 13 | import zipfile 14 | from stat import S_IFMT, S_ISDIR, S_ISREG, S_ISLNK 15 | 16 | from . import is_pypi 17 | 18 | if is_pypi: 19 | from pathlib_abc import PathInfo, _ReadablePath, _WritablePath 20 | else: 21 | from pathlib.types import PathInfo, _ReadablePath, _WritablePath 22 | 23 | 24 | class ZipPathGround: 25 | can_symlink = True 26 | 27 | def __init__(self, path_cls): 28 | self.path_cls = path_cls 29 | 30 | def setup(self, local_suffix=""): 31 | return self.path_cls(zip_file=zipfile.ZipFile(io.BytesIO(), "w")) 32 | 33 | def teardown(self, root): 34 | root.zip_file.close() 35 | 36 | def create_file(self, path, data=b''): 37 | path.zip_file.writestr(str(path), data) 38 | 39 | def create_dir(self, path): 40 | zip_info = zipfile.ZipInfo(str(path) + '/') 41 | zip_info.external_attr |= stat.S_IFDIR << 16 42 | zip_info.external_attr |= stat.FILE_ATTRIBUTE_DIRECTORY 43 | path.zip_file.writestr(zip_info, '') 44 | 45 | def create_symlink(self, path, target): 46 | zip_info = zipfile.ZipInfo(str(path)) 47 | zip_info.external_attr = stat.S_IFLNK << 16 48 | path.zip_file.writestr(zip_info, target.encode()) 49 | 50 | def create_hierarchy(self, p): 51 | # Add regular files 52 | self.create_file(p.joinpath('fileA'), b'this is file A\n') 53 | self.create_file(p.joinpath('dirB/fileB'), b'this is file B\n') 54 | self.create_file(p.joinpath('dirC/fileC'), b'this is file C\n') 55 | self.create_file(p.joinpath('dirC/dirD/fileD'), b'this is file D\n') 56 | self.create_file(p.joinpath('dirC/novel.txt'), b'this is a novel\n') 57 | # Add symlinks 58 | self.create_symlink(p.joinpath('linkA'), 'fileA') 59 | self.create_symlink(p.joinpath('linkB'), 'dirB') 60 | self.create_symlink(p.joinpath('dirA/linkC'), '../dirB') 61 | self.create_symlink(p.joinpath('brokenLink'), 'non-existing') 62 | self.create_symlink(p.joinpath('brokenLinkLoop'), 'brokenLinkLoop') 63 | 64 | def readtext(self, p): 65 | with p.zip_file.open(str(p), 'r') as f: 66 | f = io.TextIOWrapper(f, encoding='utf-8') 67 | return f.read() 68 | 69 | def readbytes(self, p): 70 | with p.zip_file.open(str(p), 'r') as f: 71 | return f.read() 72 | 73 | readlink = readtext 74 | 75 | def isdir(self, p): 76 | path_str = str(p) + "/" 77 | return path_str in p.zip_file.NameToInfo 78 | 79 | def isfile(self, p): 80 | info = p.zip_file.NameToInfo.get(str(p)) 81 | if info is None: 82 | return False 83 | return not stat.S_ISLNK(info.external_attr >> 16) 84 | 85 | def islink(self, p): 86 | info = p.zip_file.NameToInfo.get(str(p)) 87 | if info is None: 88 | return False 89 | return stat.S_ISLNK(info.external_attr >> 16) 90 | 91 | 92 | class MissingZipPathInfo(PathInfo): 93 | """ 94 | PathInfo implementation that is used when a zip file member is missing. 95 | """ 96 | __slots__ = () 97 | 98 | def exists(self, follow_symlinks=True): 99 | return False 100 | 101 | def is_dir(self, follow_symlinks=True): 102 | return False 103 | 104 | def is_file(self, follow_symlinks=True): 105 | return False 106 | 107 | def is_symlink(self): 108 | return False 109 | 110 | def resolve(self): 111 | return self 112 | 113 | 114 | missing_zip_path_info = MissingZipPathInfo() 115 | 116 | 117 | class ZipPathInfo(PathInfo): 118 | """ 119 | PathInfo implementation for an existing zip file member. 120 | """ 121 | __slots__ = ('zip_file', 'zip_info', 'parent', 'children') 122 | 123 | def __init__(self, zip_file, parent=None): 124 | self.zip_file = zip_file 125 | self.zip_info = None 126 | self.parent = parent or self 127 | self.children = {} 128 | 129 | def exists(self, follow_symlinks=True): 130 | if follow_symlinks and self.is_symlink(): 131 | return self.resolve().exists() 132 | return True 133 | 134 | def is_dir(self, follow_symlinks=True): 135 | if follow_symlinks and self.is_symlink(): 136 | return self.resolve().is_dir() 137 | elif self.zip_info is None: 138 | return True 139 | elif fmt := S_IFMT(self.zip_info.external_attr >> 16): 140 | return S_ISDIR(fmt) 141 | else: 142 | return self.zip_info.filename.endswith('/') 143 | 144 | def is_file(self, follow_symlinks=True): 145 | if follow_symlinks and self.is_symlink(): 146 | return self.resolve().is_file() 147 | elif self.zip_info is None: 148 | return False 149 | elif fmt := S_IFMT(self.zip_info.external_attr >> 16): 150 | return S_ISREG(fmt) 151 | else: 152 | return not self.zip_info.filename.endswith('/') 153 | 154 | def is_symlink(self): 155 | if self.zip_info is None: 156 | return False 157 | elif fmt := S_IFMT(self.zip_info.external_attr >> 16): 158 | return S_ISLNK(fmt) 159 | else: 160 | return False 161 | 162 | def resolve(self, path=None, create=False, follow_symlinks=True): 163 | """ 164 | Traverse zip hierarchy (parents, children and symlinks) starting 165 | from this PathInfo. This is called from three places: 166 | 167 | - When a zip file member is added to ZipFile.filelist, this method 168 | populates the ZipPathInfo tree (using create=True). 169 | - When ReadableZipPath.info is accessed, this method is finds a 170 | ZipPathInfo entry for the path without resolving any final symlink 171 | (using follow_symlinks=False) 172 | - When ZipPathInfo methods are called with follow_symlinks=True, this 173 | method resolves any symlink in the final path position. 174 | """ 175 | link_count = 0 176 | stack = path.split('/')[::-1] if path else [] 177 | info = self 178 | while True: 179 | if info.is_symlink() and (follow_symlinks or stack): 180 | link_count += 1 181 | if link_count >= 40: 182 | return missing_zip_path_info # Symlink loop! 183 | path = info.zip_file.read(info.zip_info).decode() 184 | stack += path.split('/')[::-1] if path else [] 185 | info = info.parent 186 | 187 | if stack: 188 | name = stack.pop() 189 | else: 190 | return info 191 | 192 | if name == '..': 193 | info = info.parent 194 | elif name and name != '.': 195 | if name not in info.children: 196 | if create: 197 | info.children[name] = ZipPathInfo(info.zip_file, info) 198 | else: 199 | return missing_zip_path_info # No such child! 200 | info = info.children[name] 201 | 202 | 203 | class ZipFileList: 204 | """ 205 | `list`-like object that we inject as `ZipFile.filelist`. We maintain a 206 | tree of `ZipPathInfo` objects representing the zip file members. 207 | """ 208 | 209 | __slots__ = ('tree', '_items') 210 | 211 | def __init__(self, zip_file): 212 | self.tree = ZipPathInfo(zip_file) 213 | self._items = [] 214 | for item in zip_file.filelist: 215 | self.append(item) 216 | 217 | def __len__(self): 218 | return len(self._items) 219 | 220 | def __iter__(self): 221 | return iter(self._items) 222 | 223 | def append(self, item): 224 | self._items.append(item) 225 | self.tree.resolve(item.filename, create=True).zip_info = item 226 | 227 | 228 | class ReadableZipPath(_ReadablePath): 229 | """ 230 | Simple implementation of a ReadablePath class for .zip files. 231 | """ 232 | 233 | __slots__ = ('_segments', 'zip_file') 234 | parser = posixpath 235 | 236 | def __init__(self, *pathsegments, zip_file): 237 | self._segments = pathsegments 238 | self.zip_file = zip_file 239 | if not isinstance(zip_file.filelist, ZipFileList): 240 | zip_file.filelist = ZipFileList(zip_file) 241 | 242 | def __hash__(self): 243 | return hash((str(self), self.zip_file)) 244 | 245 | def __eq__(self, other): 246 | if not isinstance(other, ReadableZipPath): 247 | return NotImplemented 248 | return str(self) == str(other) and self.zip_file is other.zip_file 249 | 250 | def __str__(self): 251 | if not self._segments: 252 | return '' 253 | return self.parser.join(*self._segments) 254 | 255 | def __repr__(self): 256 | return f'{type(self).__name__}({str(self)!r}, zip_file={self.zip_file!r})' 257 | 258 | def with_segments(self, *pathsegments): 259 | return type(self)(*pathsegments, zip_file=self.zip_file) 260 | 261 | @property 262 | def info(self): 263 | tree = self.zip_file.filelist.tree 264 | return tree.resolve(str(self), follow_symlinks=False) 265 | 266 | def __open_rb__(self, buffering=-1): 267 | info = self.info.resolve() 268 | if not info.exists(): 269 | raise FileNotFoundError(errno.ENOENT, "File not found", self) 270 | elif info.is_dir(): 271 | raise IsADirectoryError(errno.EISDIR, "Is a directory", self) 272 | return self.zip_file.open(info.zip_info, 'r') 273 | 274 | def iterdir(self): 275 | info = self.info.resolve() 276 | if not info.exists(): 277 | raise FileNotFoundError(errno.ENOENT, "File not found", self) 278 | elif not info.is_dir(): 279 | raise NotADirectoryError(errno.ENOTDIR, "Not a directory", self) 280 | return (self / name for name in info.children) 281 | 282 | def readlink(self): 283 | info = self.info 284 | if not info.exists(): 285 | raise FileNotFoundError(errno.ENOENT, "File not found", self) 286 | elif not info.is_symlink(): 287 | raise OSError(errno.EINVAL, "Not a symlink", self) 288 | return self.with_segments(self.zip_file.read(info.zip_info).decode()) 289 | 290 | 291 | class WritableZipPath(_WritablePath): 292 | """ 293 | Simple implementation of a WritablePath class for .zip files. 294 | """ 295 | 296 | __slots__ = ('_segments', 'zip_file') 297 | parser = posixpath 298 | 299 | def __init__(self, *pathsegments, zip_file): 300 | self._segments = pathsegments 301 | self.zip_file = zip_file 302 | 303 | def __hash__(self): 304 | return hash((str(self), self.zip_file)) 305 | 306 | def __eq__(self, other): 307 | if not isinstance(other, WritableZipPath): 308 | return NotImplemented 309 | return str(self) == str(other) and self.zip_file is other.zip_file 310 | 311 | def __str__(self): 312 | if not self._segments: 313 | return '' 314 | return self.parser.join(*self._segments) 315 | 316 | def __repr__(self): 317 | return f'{type(self).__name__}({str(self)!r}, zip_file={self.zip_file!r})' 318 | 319 | def with_segments(self, *pathsegments): 320 | return type(self)(*pathsegments, zip_file=self.zip_file) 321 | 322 | def __open_wb__(self, buffering=-1): 323 | return self.zip_file.open(str(self), 'w') 324 | 325 | def mkdir(self, mode=0o777): 326 | zinfo = zipfile.ZipInfo(str(self) + '/') 327 | zinfo.external_attr |= stat.S_IFDIR << 16 328 | zinfo.external_attr |= stat.FILE_ATTRIBUTE_DIRECTORY 329 | self.zip_file.writestr(zinfo, '') 330 | 331 | def symlink_to(self, target, target_is_directory=False): 332 | zinfo = zipfile.ZipInfo(str(self)) 333 | zinfo.external_attr = stat.S_IFLNK << 16 334 | if target_is_directory: 335 | zinfo.external_attr |= 0x10 336 | self.zip_file.writestr(zinfo, str(target)) 337 | -------------------------------------------------------------------------------- /tests/test_copy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for copying from pathlib.types._ReadablePath to _WritablePath. 3 | """ 4 | 5 | import contextlib 6 | import unittest 7 | 8 | from .support import is_pypi 9 | from .support.local_path import LocalPathGround 10 | from .support.zip_path import ZipPathGround, ReadableZipPath, WritableZipPath 11 | 12 | 13 | class CopyTestBase: 14 | def setUp(self): 15 | self.source_root = self.source_ground.setup() 16 | self.source_ground.create_hierarchy(self.source_root) 17 | self.target_root = self.target_ground.setup(local_suffix="_target") 18 | 19 | def tearDown(self): 20 | self.source_ground.teardown(self.source_root) 21 | self.target_ground.teardown(self.target_root) 22 | 23 | def test_copy_file(self): 24 | source = self.source_root / 'fileA' 25 | target = self.target_root / 'copyA' 26 | result = source.copy(target) 27 | self.assertEqual(result, target) 28 | self.assertTrue(self.target_ground.isfile(target)) 29 | self.assertEqual(self.source_ground.readbytes(source), 30 | self.target_ground.readbytes(result)) 31 | 32 | def test_copy_file_empty(self): 33 | source = self.source_root / 'empty' 34 | target = self.target_root / 'copyA' 35 | self.source_ground.create_file(source, b'') 36 | result = source.copy(target) 37 | self.assertEqual(result, target) 38 | self.assertTrue(self.target_ground.isfile(target)) 39 | self.assertEqual(self.target_ground.readbytes(result), b'') 40 | 41 | def test_copy_file_to_existing_file(self): 42 | source = self.source_root / 'fileA' 43 | target = self.target_root / 'copyA' 44 | self.target_ground.create_file(target, b'this is a copy\n') 45 | with contextlib.ExitStack() as stack: 46 | if isinstance(target, WritableZipPath): 47 | stack.enter_context(self.assertWarns(UserWarning)) 48 | result = source.copy(target) 49 | self.assertEqual(result, target) 50 | self.assertTrue(self.target_ground.isfile(target)) 51 | self.assertEqual(self.source_ground.readbytes(source), 52 | self.target_ground.readbytes(result)) 53 | 54 | def test_copy_file_to_directory(self): 55 | if isinstance(self.target_root, WritableZipPath): 56 | self.skipTest('needs local target') 57 | source = self.source_root / 'fileA' 58 | target = self.target_root / 'copyA' 59 | self.target_ground.create_dir(target) 60 | self.assertRaises(OSError, source.copy, target) 61 | 62 | def test_copy_file_to_itself(self): 63 | source = self.source_root / 'fileA' 64 | self.assertRaises(OSError, source.copy, source) 65 | self.assertRaises(OSError, source.copy, source, follow_symlinks=False) 66 | 67 | def test_copy_dir(self): 68 | source = self.source_root / 'dirC' 69 | target = self.target_root / 'copyC' 70 | result = source.copy(target) 71 | self.assertEqual(result, target) 72 | self.assertTrue(self.target_ground.isdir(target)) 73 | self.assertTrue(self.target_ground.isfile(target / 'fileC')) 74 | self.assertEqual(self.target_ground.readtext(target / 'fileC'), 'this is file C\n') 75 | self.assertTrue(self.target_ground.isdir(target / 'dirD')) 76 | self.assertTrue(self.target_ground.isfile(target / 'dirD' / 'fileD')) 77 | self.assertEqual(self.target_ground.readtext(target / 'dirD' / 'fileD'), 'this is file D\n') 78 | 79 | def test_copy_dir_follow_symlinks_true(self): 80 | if not self.source_ground.can_symlink: 81 | self.skipTest('needs symlink support on source') 82 | source = self.source_root / 'dirC' 83 | target = self.target_root / 'copyC' 84 | self.source_ground.create_symlink(source / 'linkC', 'fileC') 85 | self.source_ground.create_symlink(source / 'linkD', 'dirD') 86 | result = source.copy(target) 87 | self.assertEqual(result, target) 88 | self.assertTrue(self.target_ground.isdir(target)) 89 | self.assertFalse(self.target_ground.islink(target / 'linkC')) 90 | self.assertTrue(self.target_ground.isfile(target / 'linkC')) 91 | self.assertEqual(self.target_ground.readtext(target / 'linkC'), 'this is file C\n') 92 | self.assertFalse(self.target_ground.islink(target / 'linkD')) 93 | self.assertTrue(self.target_ground.isdir(target / 'linkD')) 94 | self.assertTrue(self.target_ground.isfile(target / 'linkD' / 'fileD')) 95 | self.assertEqual(self.target_ground.readtext(target / 'linkD' / 'fileD'), 'this is file D\n') 96 | 97 | def test_copy_dir_follow_symlinks_false(self): 98 | if not self.source_ground.can_symlink: 99 | self.skipTest('needs symlink support on source') 100 | if not self.target_ground.can_symlink: 101 | self.skipTest('needs symlink support on target') 102 | source = self.source_root / 'dirC' 103 | target = self.target_root / 'copyC' 104 | self.source_ground.create_symlink(source / 'linkC', 'fileC') 105 | self.source_ground.create_symlink(source / 'linkD', 'dirD') 106 | result = source.copy(target, follow_symlinks=False) 107 | self.assertEqual(result, target) 108 | self.assertTrue(self.target_ground.isdir(target)) 109 | self.assertTrue(self.target_ground.islink(target / 'linkC')) 110 | self.assertEqual(self.target_ground.readlink(target / 'linkC'), 'fileC') 111 | self.assertTrue(self.target_ground.islink(target / 'linkD')) 112 | self.assertEqual(self.target_ground.readlink(target / 'linkD'), 'dirD') 113 | 114 | def test_copy_dir_to_existing_directory(self): 115 | if isinstance(self.target_root, WritableZipPath): 116 | self.skipTest('needs local target') 117 | source = self.source_root / 'dirC' 118 | target = self.target_root / 'copyC' 119 | self.target_ground.create_dir(target) 120 | self.assertRaises(FileExistsError, source.copy, target) 121 | 122 | def test_copy_dir_to_itself(self): 123 | source = self.source_root / 'dirC' 124 | self.assertRaises(OSError, source.copy, source) 125 | self.assertRaises(OSError, source.copy, source, follow_symlinks=False) 126 | 127 | def test_copy_dir_into_itself(self): 128 | source = self.source_root / 'dirC' 129 | target = self.source_root / 'dirC' / 'dirD' / 'copyC' 130 | self.assertRaises(OSError, source.copy, target) 131 | self.assertRaises(OSError, source.copy, target, follow_symlinks=False) 132 | 133 | def test_copy_into(self): 134 | source = self.source_root / 'fileA' 135 | target_dir = self.target_root / 'dirA' 136 | self.target_ground.create_dir(target_dir) 137 | result = source.copy_into(target_dir) 138 | self.assertEqual(result, target_dir / 'fileA') 139 | self.assertTrue(self.target_ground.isfile(result)) 140 | self.assertEqual(self.source_ground.readbytes(source), 141 | self.target_ground.readbytes(result)) 142 | 143 | def test_copy_into_empty_name(self): 144 | source = self.source_root.with_segments() 145 | target_dir = self.target_root / 'dirA' 146 | self.target_ground.create_dir(target_dir) 147 | self.assertRaises(ValueError, source.copy_into, target_dir) 148 | 149 | 150 | class ZipToZipPathCopyTest(CopyTestBase, unittest.TestCase): 151 | source_ground = ZipPathGround(ReadableZipPath) 152 | target_ground = ZipPathGround(WritableZipPath) 153 | 154 | 155 | if not is_pypi: 156 | from pathlib import Path 157 | 158 | class ZipToLocalPathCopyTest(CopyTestBase, unittest.TestCase): 159 | source_ground = ZipPathGround(ReadableZipPath) 160 | target_ground = LocalPathGround(Path) 161 | 162 | 163 | class LocalToZipPathCopyTest(CopyTestBase, unittest.TestCase): 164 | source_ground = LocalPathGround(Path) 165 | target_ground = ZipPathGround(WritableZipPath) 166 | 167 | 168 | class LocalToLocalPathCopyTest(CopyTestBase, unittest.TestCase): 169 | source_ground = LocalPathGround(Path) 170 | target_ground = LocalPathGround(Path) 171 | 172 | 173 | if __name__ == "__main__": 174 | unittest.main() 175 | -------------------------------------------------------------------------------- /tests/test_join.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for pathlib.types._JoinablePath 3 | """ 4 | 5 | import unittest 6 | 7 | from .support import is_pypi 8 | from .support.lexical_path import LexicalPath 9 | 10 | if is_pypi: 11 | from pathlib_abc import _PathParser, _JoinablePath 12 | else: 13 | from pathlib.types import _PathParser, _JoinablePath 14 | 15 | 16 | class JoinTestBase: 17 | def test_is_joinable(self): 18 | p = self.cls() 19 | self.assertIsInstance(p, _JoinablePath) 20 | 21 | def test_parser(self): 22 | self.assertIsInstance(self.cls.parser, _PathParser) 23 | 24 | def test_constructor(self): 25 | P = self.cls 26 | p = P('a') 27 | self.assertIsInstance(p, P) 28 | P() 29 | P('a', 'b', 'c') 30 | P('/a', 'b', 'c') 31 | P('a/b/c') 32 | P('/a/b/c') 33 | 34 | def test_with_segments(self): 35 | class P(self.cls): 36 | def __init__(self, *pathsegments, session_id): 37 | super().__init__(*pathsegments) 38 | self.session_id = session_id 39 | 40 | def with_segments(self, *pathsegments): 41 | return type(self)(*pathsegments, session_id=self.session_id) 42 | p = P('foo', 'bar', session_id=42) 43 | self.assertEqual(42, (p / 'foo').session_id) 44 | self.assertEqual(42, ('foo' / p).session_id) 45 | self.assertEqual(42, p.joinpath('foo').session_id) 46 | self.assertEqual(42, p.with_name('foo').session_id) 47 | self.assertEqual(42, p.with_stem('foo').session_id) 48 | self.assertEqual(42, p.with_suffix('.foo').session_id) 49 | self.assertEqual(42, p.with_segments('foo').session_id) 50 | self.assertEqual(42, p.parent.session_id) 51 | for parent in p.parents: 52 | self.assertEqual(42, parent.session_id) 53 | 54 | def test_join(self): 55 | P = self.cls 56 | sep = self.cls.parser.sep 57 | p = P(f'a{sep}b') 58 | pp = p.joinpath('c') 59 | self.assertEqual(pp, P(f'a{sep}b{sep}c')) 60 | self.assertIs(type(pp), type(p)) 61 | pp = p.joinpath('c', 'd') 62 | self.assertEqual(pp, P(f'a{sep}b{sep}c{sep}d')) 63 | pp = p.joinpath(f'{sep}c') 64 | self.assertEqual(pp, P(f'{sep}c')) 65 | 66 | def test_div(self): 67 | # Basically the same as joinpath(). 68 | P = self.cls 69 | sep = self.cls.parser.sep 70 | p = P(f'a{sep}b') 71 | pp = p / 'c' 72 | self.assertEqual(pp, P(f'a{sep}b{sep}c')) 73 | self.assertIs(type(pp), type(p)) 74 | pp = p / f'c{sep}d' 75 | self.assertEqual(pp, P(f'a{sep}b{sep}c{sep}d')) 76 | pp = p / 'c' / 'd' 77 | self.assertEqual(pp, P(f'a{sep}b{sep}c{sep}d')) 78 | pp = 'c' / p / 'd' 79 | self.assertEqual(pp, P(f'c{sep}a{sep}b{sep}d')) 80 | pp = p/ f'{sep}c' 81 | self.assertEqual(pp, P(f'{sep}c')) 82 | 83 | def test_full_match(self): 84 | P = self.cls 85 | # Simple relative pattern. 86 | self.assertTrue(P('b.py').full_match('b.py')) 87 | self.assertFalse(P('a/b.py').full_match('b.py')) 88 | self.assertFalse(P('/a/b.py').full_match('b.py')) 89 | self.assertFalse(P('a.py').full_match('b.py')) 90 | self.assertFalse(P('b/py').full_match('b.py')) 91 | self.assertFalse(P('/a.py').full_match('b.py')) 92 | self.assertFalse(P('b.py/c').full_match('b.py')) 93 | # Wildcard relative pattern. 94 | self.assertTrue(P('b.py').full_match('*.py')) 95 | self.assertFalse(P('a/b.py').full_match('*.py')) 96 | self.assertFalse(P('/a/b.py').full_match('*.py')) 97 | self.assertFalse(P('b.pyc').full_match('*.py')) 98 | self.assertFalse(P('b./py').full_match('*.py')) 99 | self.assertFalse(P('b.py/c').full_match('*.py')) 100 | # Multi-part relative pattern. 101 | self.assertTrue(P('ab/c.py').full_match('a*/*.py')) 102 | self.assertFalse(P('/d/ab/c.py').full_match('a*/*.py')) 103 | self.assertFalse(P('a.py').full_match('a*/*.py')) 104 | self.assertFalse(P('/dab/c.py').full_match('a*/*.py')) 105 | self.assertFalse(P('ab/c.py/d').full_match('a*/*.py')) 106 | # Absolute pattern. 107 | self.assertTrue(P('/b.py').full_match('/*.py')) 108 | self.assertFalse(P('b.py').full_match('/*.py')) 109 | self.assertFalse(P('a/b.py').full_match('/*.py')) 110 | self.assertFalse(P('/a/b.py').full_match('/*.py')) 111 | # Multi-part absolute pattern. 112 | self.assertTrue(P('/a/b.py').full_match('/a/*.py')) 113 | self.assertFalse(P('/ab.py').full_match('/a/*.py')) 114 | self.assertFalse(P('/a/b/c.py').full_match('/a/*.py')) 115 | # Multi-part glob-style pattern. 116 | self.assertTrue(P('a').full_match('**')) 117 | self.assertTrue(P('c.py').full_match('**')) 118 | self.assertTrue(P('a/b/c.py').full_match('**')) 119 | self.assertTrue(P('/a/b/c.py').full_match('**')) 120 | self.assertTrue(P('/a/b/c.py').full_match('/**')) 121 | self.assertTrue(P('/a/b/c.py').full_match('/a/**')) 122 | self.assertTrue(P('/a/b/c.py').full_match('**/*.py')) 123 | self.assertTrue(P('/a/b/c.py').full_match('/**/*.py')) 124 | self.assertTrue(P('/a/b/c.py').full_match('/a/**/*.py')) 125 | self.assertTrue(P('/a/b/c.py').full_match('/a/b/**/*.py')) 126 | self.assertTrue(P('/a/b/c.py').full_match('/**/**/**/**/*.py')) 127 | self.assertFalse(P('c.py').full_match('**/a.py')) 128 | self.assertFalse(P('c.py').full_match('c/**')) 129 | self.assertFalse(P('a/b/c.py').full_match('**/a')) 130 | self.assertFalse(P('a/b/c.py').full_match('**/a/b')) 131 | self.assertFalse(P('a/b/c.py').full_match('**/a/b/c')) 132 | self.assertFalse(P('a/b/c.py').full_match('**/a/b/c.')) 133 | self.assertFalse(P('a/b/c.py').full_match('**/a/b/c./**')) 134 | self.assertFalse(P('a/b/c.py').full_match('**/a/b/c./**')) 135 | self.assertFalse(P('a/b/c.py').full_match('/a/b/c.py/**')) 136 | self.assertFalse(P('a/b/c.py').full_match('/**/a/b/c.py')) 137 | # Matching against empty path 138 | self.assertFalse(P('').full_match('*')) 139 | self.assertTrue(P('').full_match('**')) 140 | self.assertFalse(P('').full_match('**/*')) 141 | # Matching with empty pattern 142 | self.assertTrue(P('').full_match('')) 143 | self.assertTrue(P('.').full_match('.')) 144 | self.assertFalse(P('/').full_match('')) 145 | self.assertFalse(P('/').full_match('.')) 146 | self.assertFalse(P('foo').full_match('')) 147 | self.assertFalse(P('foo').full_match('.')) 148 | 149 | def test_parts(self): 150 | # `parts` returns a tuple. 151 | sep = self.cls.parser.sep 152 | P = self.cls 153 | p = P(f'a{sep}b') 154 | parts = p.parts 155 | self.assertEqual(parts, ('a', 'b')) 156 | # When the path is absolute, the anchor is a separate part. 157 | p = P(f'{sep}a{sep}b') 158 | parts = p.parts 159 | self.assertEqual(parts, (sep, 'a', 'b')) 160 | 161 | def test_parent(self): 162 | # Relative 163 | P = self.cls 164 | p = P('a/b/c') 165 | self.assertEqual(p.parent, P('a/b')) 166 | self.assertEqual(p.parent.parent, P('a')) 167 | self.assertEqual(p.parent.parent.parent, P('')) 168 | self.assertEqual(p.parent.parent.parent.parent, P('')) 169 | # Anchored 170 | p = P('/a/b/c') 171 | self.assertEqual(p.parent, P('/a/b')) 172 | self.assertEqual(p.parent.parent, P('/a')) 173 | self.assertEqual(p.parent.parent.parent, P('/')) 174 | self.assertEqual(p.parent.parent.parent.parent, P('/')) 175 | 176 | def test_parents(self): 177 | # Relative 178 | P = self.cls 179 | p = P('a/b/c') 180 | par = p.parents 181 | self.assertEqual(len(par), 3) 182 | self.assertEqual(par[0], P('a/b')) 183 | self.assertEqual(par[1], P('a')) 184 | self.assertEqual(par[2], P('')) 185 | self.assertEqual(par[-1], P('')) 186 | self.assertEqual(par[-2], P('a')) 187 | self.assertEqual(par[-3], P('a/b')) 188 | self.assertEqual(par[0:1], (P('a/b'),)) 189 | self.assertEqual(par[:2], (P('a/b'), P('a'))) 190 | self.assertEqual(par[:-1], (P('a/b'), P('a'))) 191 | self.assertEqual(par[1:], (P('a'), P(''))) 192 | self.assertEqual(par[::2], (P('a/b'), P(''))) 193 | self.assertEqual(par[::-1], (P(''), P('a'), P('a/b'))) 194 | self.assertEqual(list(par), [P('a/b'), P('a'), P('')]) 195 | with self.assertRaises(IndexError): 196 | par[-4] 197 | with self.assertRaises(IndexError): 198 | par[3] 199 | with self.assertRaises(TypeError): 200 | par[0] = p 201 | # Anchored 202 | p = P('/a/b/c') 203 | par = p.parents 204 | self.assertEqual(len(par), 3) 205 | self.assertEqual(par[0], P('/a/b')) 206 | self.assertEqual(par[1], P('/a')) 207 | self.assertEqual(par[2], P('/')) 208 | self.assertEqual(par[-1], P('/')) 209 | self.assertEqual(par[-2], P('/a')) 210 | self.assertEqual(par[-3], P('/a/b')) 211 | self.assertEqual(par[0:1], (P('/a/b'),)) 212 | self.assertEqual(par[:2], (P('/a/b'), P('/a'))) 213 | self.assertEqual(par[:-1], (P('/a/b'), P('/a'))) 214 | self.assertEqual(par[1:], (P('/a'), P('/'))) 215 | self.assertEqual(par[::2], (P('/a/b'), P('/'))) 216 | self.assertEqual(par[::-1], (P('/'), P('/a'), P('/a/b'))) 217 | self.assertEqual(list(par), [P('/a/b'), P('/a'), P('/')]) 218 | with self.assertRaises(IndexError): 219 | par[-4] 220 | with self.assertRaises(IndexError): 221 | par[3] 222 | 223 | def test_anchor(self): 224 | P = self.cls 225 | sep = self.cls.parser.sep 226 | self.assertEqual(P('').anchor, '') 227 | self.assertEqual(P(f'a{sep}b').anchor, '') 228 | self.assertEqual(P(sep).anchor, sep) 229 | self.assertEqual(P(f'{sep}a{sep}b').anchor, sep) 230 | 231 | def test_name(self): 232 | P = self.cls 233 | self.assertEqual(P('').name, '') 234 | self.assertEqual(P('/').name, '') 235 | self.assertEqual(P('a/b').name, 'b') 236 | self.assertEqual(P('/a/b').name, 'b') 237 | self.assertEqual(P('a/b.py').name, 'b.py') 238 | self.assertEqual(P('/a/b.py').name, 'b.py') 239 | 240 | def test_suffix(self): 241 | P = self.cls 242 | self.assertEqual(P('').suffix, '') 243 | self.assertEqual(P('.').suffix, '') 244 | self.assertEqual(P('..').suffix, '') 245 | self.assertEqual(P('/').suffix, '') 246 | self.assertEqual(P('a/b').suffix, '') 247 | self.assertEqual(P('/a/b').suffix, '') 248 | self.assertEqual(P('/a/b/.').suffix, '') 249 | self.assertEqual(P('a/b.py').suffix, '.py') 250 | self.assertEqual(P('/a/b.py').suffix, '.py') 251 | self.assertEqual(P('a/.hgrc').suffix, '') 252 | self.assertEqual(P('/a/.hgrc').suffix, '') 253 | self.assertEqual(P('a/.hg.rc').suffix, '.rc') 254 | self.assertEqual(P('/a/.hg.rc').suffix, '.rc') 255 | self.assertEqual(P('a/b.tar.gz').suffix, '.gz') 256 | self.assertEqual(P('/a/b.tar.gz').suffix, '.gz') 257 | self.assertEqual(P('a/trailing.dot.').suffix, '.') 258 | self.assertEqual(P('/a/trailing.dot.').suffix, '.') 259 | self.assertEqual(P('a/..d.o.t..').suffix, '.') 260 | self.assertEqual(P('a/inn.er..dots').suffix, '.dots') 261 | self.assertEqual(P('photo').suffix, '') 262 | self.assertEqual(P('photo.jpg').suffix, '.jpg') 263 | 264 | def test_suffixes(self): 265 | P = self.cls 266 | self.assertEqual(P('').suffixes, []) 267 | self.assertEqual(P('.').suffixes, []) 268 | self.assertEqual(P('/').suffixes, []) 269 | self.assertEqual(P('a/b').suffixes, []) 270 | self.assertEqual(P('/a/b').suffixes, []) 271 | self.assertEqual(P('/a/b/.').suffixes, []) 272 | self.assertEqual(P('a/b.py').suffixes, ['.py']) 273 | self.assertEqual(P('/a/b.py').suffixes, ['.py']) 274 | self.assertEqual(P('a/.hgrc').suffixes, []) 275 | self.assertEqual(P('/a/.hgrc').suffixes, []) 276 | self.assertEqual(P('a/.hg.rc').suffixes, ['.rc']) 277 | self.assertEqual(P('/a/.hg.rc').suffixes, ['.rc']) 278 | self.assertEqual(P('a/b.tar.gz').suffixes, ['.tar', '.gz']) 279 | self.assertEqual(P('/a/b.tar.gz').suffixes, ['.tar', '.gz']) 280 | self.assertEqual(P('a/trailing.dot.').suffixes, ['.dot', '.']) 281 | self.assertEqual(P('/a/trailing.dot.').suffixes, ['.dot', '.']) 282 | self.assertEqual(P('a/..d.o.t..').suffixes, ['.o', '.t', '.', '.']) 283 | self.assertEqual(P('a/inn.er..dots').suffixes, ['.er', '.', '.dots']) 284 | self.assertEqual(P('photo').suffixes, []) 285 | self.assertEqual(P('photo.jpg').suffixes, ['.jpg']) 286 | 287 | def test_stem(self): 288 | P = self.cls 289 | self.assertEqual(P('..').stem, '..') 290 | self.assertEqual(P('').stem, '') 291 | self.assertEqual(P('/').stem, '') 292 | self.assertEqual(P('a/b').stem, 'b') 293 | self.assertEqual(P('a/b.py').stem, 'b') 294 | self.assertEqual(P('a/.hgrc').stem, '.hgrc') 295 | self.assertEqual(P('a/.hg.rc').stem, '.hg') 296 | self.assertEqual(P('a/b.tar.gz').stem, 'b.tar') 297 | self.assertEqual(P('a/trailing.dot.').stem, 'trailing.dot') 298 | self.assertEqual(P('a/..d.o.t..').stem, '..d.o.t.') 299 | self.assertEqual(P('a/inn.er..dots').stem, 'inn.er.') 300 | self.assertEqual(P('photo').stem, 'photo') 301 | self.assertEqual(P('photo.jpg').stem, 'photo') 302 | 303 | def test_with_name(self): 304 | P = self.cls 305 | self.assertEqual(P('a/b').with_name('d.xml'), P('a/d.xml')) 306 | self.assertEqual(P('/a/b').with_name('d.xml'), P('/a/d.xml')) 307 | self.assertEqual(P('a/b.py').with_name('d.xml'), P('a/d.xml')) 308 | self.assertEqual(P('/a/b.py').with_name('d.xml'), P('/a/d.xml')) 309 | self.assertEqual(P('a/Dot ending.').with_name('d.xml'), P('a/d.xml')) 310 | self.assertEqual(P('/a/Dot ending.').with_name('d.xml'), P('/a/d.xml')) 311 | self.assertRaises(ValueError, P('a/b').with_name, '/c') 312 | self.assertRaises(ValueError, P('a/b').with_name, 'c/') 313 | self.assertRaises(ValueError, P('a/b').with_name, 'c/d') 314 | 315 | def test_with_stem(self): 316 | P = self.cls 317 | self.assertEqual(P('a/b').with_stem('d'), P('a/d')) 318 | self.assertEqual(P('/a/b').with_stem('d'), P('/a/d')) 319 | self.assertEqual(P('a/b.py').with_stem('d'), P('a/d.py')) 320 | self.assertEqual(P('/a/b.py').with_stem('d'), P('/a/d.py')) 321 | self.assertEqual(P('/a/b.tar.gz').with_stem('d'), P('/a/d.gz')) 322 | self.assertEqual(P('a/Dot ending.').with_stem('d'), P('a/d.')) 323 | self.assertEqual(P('/a/Dot ending.').with_stem('d'), P('/a/d.')) 324 | self.assertRaises(ValueError, P('foo.gz').with_stem, '') 325 | self.assertRaises(ValueError, P('/a/b/foo.gz').with_stem, '') 326 | self.assertRaises(ValueError, P('a/b').with_stem, '/c') 327 | self.assertRaises(ValueError, P('a/b').with_stem, 'c/') 328 | self.assertRaises(ValueError, P('a/b').with_stem, 'c/d') 329 | 330 | def test_with_suffix(self): 331 | P = self.cls 332 | self.assertEqual(P('a/b').with_suffix('.gz'), P('a/b.gz')) 333 | self.assertEqual(P('/a/b').with_suffix('.gz'), P('/a/b.gz')) 334 | self.assertEqual(P('a/b.py').with_suffix('.gz'), P('a/b.gz')) 335 | self.assertEqual(P('/a/b.py').with_suffix('.gz'), P('/a/b.gz')) 336 | # Stripping suffix. 337 | self.assertEqual(P('a/b.py').with_suffix(''), P('a/b')) 338 | self.assertEqual(P('/a/b').with_suffix(''), P('/a/b')) 339 | # Single dot 340 | self.assertEqual(P('a/b').with_suffix('.'), P('a/b.')) 341 | self.assertEqual(P('/a/b').with_suffix('.'), P('/a/b.')) 342 | self.assertEqual(P('a/b.py').with_suffix('.'), P('a/b.')) 343 | self.assertEqual(P('/a/b.py').with_suffix('.'), P('/a/b.')) 344 | # Path doesn't have a "filename" component. 345 | self.assertRaises(ValueError, P('').with_suffix, '.gz') 346 | self.assertRaises(ValueError, P('/').with_suffix, '.gz') 347 | # Invalid suffix. 348 | self.assertRaises(ValueError, P('a/b').with_suffix, 'gz') 349 | self.assertRaises(ValueError, P('a/b').with_suffix, '/') 350 | self.assertRaises(ValueError, P('a/b').with_suffix, '/.gz') 351 | self.assertRaises(ValueError, P('a/b').with_suffix, 'c/d') 352 | self.assertRaises(ValueError, P('a/b').with_suffix, '.c/.d') 353 | self.assertRaises(ValueError, P('a/b').with_suffix, './.d') 354 | self.assertRaises(ValueError, P('a/b').with_suffix, '.d/.') 355 | self.assertRaises(TypeError, P('a/b').with_suffix, None) 356 | 357 | 358 | class LexicalPathJoinTest(JoinTestBase, unittest.TestCase): 359 | cls = LexicalPath 360 | 361 | 362 | if not is_pypi: 363 | from pathlib import PurePath, Path 364 | 365 | class PurePathJoinTest(JoinTestBase, unittest.TestCase): 366 | cls = PurePath 367 | 368 | class PathJoinTest(JoinTestBase, unittest.TestCase): 369 | cls = Path 370 | 371 | 372 | if __name__ == "__main__": 373 | unittest.main() 374 | -------------------------------------------------------------------------------- /tests/test_join_posix.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Posix-flavoured pathlib.types._JoinablePath 3 | """ 4 | 5 | import os 6 | import unittest 7 | 8 | from .support import is_pypi 9 | from .support.lexical_path import LexicalPosixPath 10 | 11 | 12 | class JoinTestBase: 13 | def test_join(self): 14 | P = self.cls 15 | p = P('//a') 16 | pp = p.joinpath('b') 17 | self.assertEqual(pp, P('//a/b')) 18 | pp = P('/a').joinpath('//c') 19 | self.assertEqual(pp, P('//c')) 20 | pp = P('//a').joinpath('/c') 21 | self.assertEqual(pp, P('/c')) 22 | 23 | def test_div(self): 24 | # Basically the same as joinpath(). 25 | P = self.cls 26 | p = P('//a') 27 | pp = p / 'b' 28 | self.assertEqual(pp, P('//a/b')) 29 | pp = P('/a') / '//c' 30 | self.assertEqual(pp, P('//c')) 31 | pp = P('//a') / '/c' 32 | self.assertEqual(pp, P('/c')) 33 | 34 | 35 | class LexicalPosixPathJoinTest(JoinTestBase, unittest.TestCase): 36 | cls = LexicalPosixPath 37 | 38 | 39 | if not is_pypi: 40 | from pathlib import PurePosixPath, PosixPath 41 | 42 | class PurePosixPathJoinTest(JoinTestBase, unittest.TestCase): 43 | cls = PurePosixPath 44 | 45 | if os.name != 'nt': 46 | class PosixPathJoinTest(JoinTestBase, unittest.TestCase): 47 | cls = PosixPath 48 | 49 | 50 | if __name__ == "__main__": 51 | unittest.main() 52 | -------------------------------------------------------------------------------- /tests/test_join_windows.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Windows-flavoured pathlib.types._JoinablePath 3 | """ 4 | 5 | import os 6 | import unittest 7 | 8 | from .support import is_pypi 9 | from .support.lexical_path import LexicalWindowsPath 10 | 11 | 12 | class JoinTestBase: 13 | def test_join(self): 14 | P = self.cls 15 | p = P('C:/a/b') 16 | pp = p.joinpath('x/y') 17 | self.assertEqual(pp, P(r'C:/a/b\x/y')) 18 | pp = p.joinpath('/x/y') 19 | self.assertEqual(pp, P('C:/x/y')) 20 | # Joining with a different drive => the first path is ignored, even 21 | # if the second path is relative. 22 | pp = p.joinpath('D:x/y') 23 | self.assertEqual(pp, P('D:x/y')) 24 | pp = p.joinpath('D:/x/y') 25 | self.assertEqual(pp, P('D:/x/y')) 26 | pp = p.joinpath('//host/share/x/y') 27 | self.assertEqual(pp, P('//host/share/x/y')) 28 | # Joining with the same drive => the first path is appended to if 29 | # the second path is relative. 30 | pp = p.joinpath('c:x/y') 31 | self.assertEqual(pp, P(r'c:/a/b\x/y')) 32 | pp = p.joinpath('c:/x/y') 33 | self.assertEqual(pp, P('c:/x/y')) 34 | # Joining with files with NTFS data streams => the filename should 35 | # not be parsed as a drive letter 36 | pp = p.joinpath('./d:s') 37 | self.assertEqual(pp, P(r'C:/a/b\./d:s')) 38 | pp = p.joinpath('./dd:s') 39 | self.assertEqual(pp, P(r'C:/a/b\./dd:s')) 40 | pp = p.joinpath('E:d:s') 41 | self.assertEqual(pp, P('E:d:s')) 42 | # Joining onto a UNC path with no root 43 | pp = P('//server').joinpath('share') 44 | self.assertEqual(pp, P(r'//server\share')) 45 | pp = P('//./BootPartition').joinpath('Windows') 46 | self.assertEqual(pp, P(r'//./BootPartition\Windows')) 47 | 48 | def test_div(self): 49 | # Basically the same as joinpath(). 50 | P = self.cls 51 | p = P('C:/a/b') 52 | self.assertEqual(p / 'x/y', P(r'C:/a/b\x/y')) 53 | self.assertEqual(p / 'x' / 'y', P(r'C:/a/b\x\y')) 54 | self.assertEqual(p / '/x/y', P('C:/x/y')) 55 | self.assertEqual(p / '/x' / 'y', P(r'C:/x\y')) 56 | # Joining with a different drive => the first path is ignored, even 57 | # if the second path is relative. 58 | self.assertEqual(p / 'D:x/y', P('D:x/y')) 59 | self.assertEqual(p / 'D:' / 'x/y', P('D:x/y')) 60 | self.assertEqual(p / 'D:/x/y', P('D:/x/y')) 61 | self.assertEqual(p / 'D:' / '/x/y', P('D:/x/y')) 62 | self.assertEqual(p / '//host/share/x/y', P('//host/share/x/y')) 63 | # Joining with the same drive => the first path is appended to if 64 | # the second path is relative. 65 | self.assertEqual(p / 'c:x/y', P(r'c:/a/b\x/y')) 66 | self.assertEqual(p / 'c:/x/y', P('c:/x/y')) 67 | # Joining with files with NTFS data streams => the filename should 68 | # not be parsed as a drive letter 69 | self.assertEqual(p / './d:s', P(r'C:/a/b\./d:s')) 70 | self.assertEqual(p / './dd:s', P(r'C:/a/b\./dd:s')) 71 | self.assertEqual(p / 'E:d:s', P('E:d:s')) 72 | 73 | def test_str(self): 74 | p = self.cls(r'a\b\c') 75 | self.assertEqual(str(p), 'a\\b\\c') 76 | p = self.cls(r'c:\a\b\c') 77 | self.assertEqual(str(p), 'c:\\a\\b\\c') 78 | p = self.cls('\\\\a\\b\\') 79 | self.assertEqual(str(p), '\\\\a\\b\\') 80 | p = self.cls(r'\\a\b\c') 81 | self.assertEqual(str(p), '\\\\a\\b\\c') 82 | p = self.cls(r'\\a\b\c\d') 83 | self.assertEqual(str(p), '\\\\a\\b\\c\\d') 84 | 85 | def test_parts(self): 86 | P = self.cls 87 | p = P(r'c:a\b') 88 | parts = p.parts 89 | self.assertEqual(parts, ('c:', 'a', 'b')) 90 | p = P(r'c:\a\b') 91 | parts = p.parts 92 | self.assertEqual(parts, ('c:\\', 'a', 'b')) 93 | p = P(r'\\a\b\c\d') 94 | parts = p.parts 95 | self.assertEqual(parts, ('\\\\a\\b\\', 'c', 'd')) 96 | 97 | def test_parent(self): 98 | # Anchored 99 | P = self.cls 100 | p = P('z:a/b/c') 101 | self.assertEqual(p.parent, P('z:a/b')) 102 | self.assertEqual(p.parent.parent, P('z:a')) 103 | self.assertEqual(p.parent.parent.parent, P('z:')) 104 | self.assertEqual(p.parent.parent.parent.parent, P('z:')) 105 | p = P('z:/a/b/c') 106 | self.assertEqual(p.parent, P('z:/a/b')) 107 | self.assertEqual(p.parent.parent, P('z:/a')) 108 | self.assertEqual(p.parent.parent.parent, P('z:/')) 109 | self.assertEqual(p.parent.parent.parent.parent, P('z:/')) 110 | p = P('//a/b/c/d') 111 | self.assertEqual(p.parent, P('//a/b/c')) 112 | self.assertEqual(p.parent.parent, P('//a/b/')) 113 | self.assertEqual(p.parent.parent.parent, P('//a/b/')) 114 | 115 | def test_parents(self): 116 | # Anchored 117 | P = self.cls 118 | p = P('z:a/b') 119 | par = p.parents 120 | self.assertEqual(len(par), 2) 121 | self.assertEqual(par[0], P('z:a')) 122 | self.assertEqual(par[1], P('z:')) 123 | self.assertEqual(par[0:1], (P('z:a'),)) 124 | self.assertEqual(par[:-1], (P('z:a'),)) 125 | self.assertEqual(par[:2], (P('z:a'), P('z:'))) 126 | self.assertEqual(par[1:], (P('z:'),)) 127 | self.assertEqual(par[::2], (P('z:a'),)) 128 | self.assertEqual(par[::-1], (P('z:'), P('z:a'))) 129 | self.assertEqual(list(par), [P('z:a'), P('z:')]) 130 | with self.assertRaises(IndexError): 131 | par[2] 132 | p = P('z:/a/b') 133 | par = p.parents 134 | self.assertEqual(len(par), 2) 135 | self.assertEqual(par[0], P('z:/a')) 136 | self.assertEqual(par[1], P('z:/')) 137 | self.assertEqual(par[0:1], (P('z:/a'),)) 138 | self.assertEqual(par[0:-1], (P('z:/a'),)) 139 | self.assertEqual(par[:2], (P('z:/a'), P('z:/'))) 140 | self.assertEqual(par[1:], (P('z:/'),)) 141 | self.assertEqual(par[::2], (P('z:/a'),)) 142 | self.assertEqual(par[::-1], (P('z:/'), P('z:/a'),)) 143 | self.assertEqual(list(par), [P('z:/a'), P('z:/')]) 144 | with self.assertRaises(IndexError): 145 | par[2] 146 | p = P('//a/b/c/d') 147 | par = p.parents 148 | self.assertEqual(len(par), 2) 149 | self.assertEqual(par[0], P('//a/b/c')) 150 | self.assertEqual(par[1], P('//a/b/')) 151 | self.assertEqual(par[0:1], (P('//a/b/c'),)) 152 | self.assertEqual(par[0:-1], (P('//a/b/c'),)) 153 | self.assertEqual(par[:2], (P('//a/b/c'), P('//a/b/'))) 154 | self.assertEqual(par[1:], (P('//a/b/'),)) 155 | self.assertEqual(par[::2], (P('//a/b/c'),)) 156 | self.assertEqual(par[::-1], (P('//a/b/'), P('//a/b/c'))) 157 | self.assertEqual(list(par), [P('//a/b/c'), P('//a/b/')]) 158 | with self.assertRaises(IndexError): 159 | par[2] 160 | 161 | def test_anchor(self): 162 | P = self.cls 163 | self.assertEqual(P('c:').anchor, 'c:') 164 | self.assertEqual(P('c:a/b').anchor, 'c:') 165 | self.assertEqual(P('c:\\').anchor, 'c:\\') 166 | self.assertEqual(P('c:\\a\\b\\').anchor, 'c:\\') 167 | self.assertEqual(P('\\\\a\\b\\').anchor, '\\\\a\\b\\') 168 | self.assertEqual(P('\\\\a\\b\\c\\d').anchor, '\\\\a\\b\\') 169 | 170 | def test_name(self): 171 | P = self.cls 172 | self.assertEqual(P('c:').name, '') 173 | self.assertEqual(P('c:/').name, '') 174 | self.assertEqual(P('c:a/b').name, 'b') 175 | self.assertEqual(P('c:/a/b').name, 'b') 176 | self.assertEqual(P('c:a/b.py').name, 'b.py') 177 | self.assertEqual(P('c:/a/b.py').name, 'b.py') 178 | self.assertEqual(P('//My.py/Share.php').name, '') 179 | self.assertEqual(P('//My.py/Share.php/a/b').name, 'b') 180 | 181 | def test_stem(self): 182 | P = self.cls 183 | self.assertEqual(P('c:').stem, '') 184 | self.assertEqual(P('c:..').stem, '..') 185 | self.assertEqual(P('c:/').stem, '') 186 | self.assertEqual(P('c:a/b').stem, 'b') 187 | self.assertEqual(P('c:a/b.py').stem, 'b') 188 | self.assertEqual(P('c:a/.hgrc').stem, '.hgrc') 189 | self.assertEqual(P('c:a/.hg.rc').stem, '.hg') 190 | self.assertEqual(P('c:a/b.tar.gz').stem, 'b.tar') 191 | self.assertEqual(P('c:a/trailing.dot.').stem, 'trailing.dot') 192 | 193 | def test_suffix(self): 194 | P = self.cls 195 | self.assertEqual(P('c:').suffix, '') 196 | self.assertEqual(P('c:/').suffix, '') 197 | self.assertEqual(P('c:a/b').suffix, '') 198 | self.assertEqual(P('c:/a/b').suffix, '') 199 | self.assertEqual(P('c:a/b.py').suffix, '.py') 200 | self.assertEqual(P('c:/a/b.py').suffix, '.py') 201 | self.assertEqual(P('c:a/.hgrc').suffix, '') 202 | self.assertEqual(P('c:/a/.hgrc').suffix, '') 203 | self.assertEqual(P('c:a/.hg.rc').suffix, '.rc') 204 | self.assertEqual(P('c:/a/.hg.rc').suffix, '.rc') 205 | self.assertEqual(P('c:a/b.tar.gz').suffix, '.gz') 206 | self.assertEqual(P('c:/a/b.tar.gz').suffix, '.gz') 207 | self.assertEqual(P('c:a/trailing.dot.').suffix, '.') 208 | self.assertEqual(P('c:/a/trailing.dot.').suffix, '.') 209 | self.assertEqual(P('//My.py/Share.php').suffix, '') 210 | self.assertEqual(P('//My.py/Share.php/a/b').suffix, '') 211 | 212 | def test_suffixes(self): 213 | P = self.cls 214 | self.assertEqual(P('c:').suffixes, []) 215 | self.assertEqual(P('c:/').suffixes, []) 216 | self.assertEqual(P('c:a/b').suffixes, []) 217 | self.assertEqual(P('c:/a/b').suffixes, []) 218 | self.assertEqual(P('c:a/b.py').suffixes, ['.py']) 219 | self.assertEqual(P('c:/a/b.py').suffixes, ['.py']) 220 | self.assertEqual(P('c:a/.hgrc').suffixes, []) 221 | self.assertEqual(P('c:/a/.hgrc').suffixes, []) 222 | self.assertEqual(P('c:a/.hg.rc').suffixes, ['.rc']) 223 | self.assertEqual(P('c:/a/.hg.rc').suffixes, ['.rc']) 224 | self.assertEqual(P('c:a/b.tar.gz').suffixes, ['.tar', '.gz']) 225 | self.assertEqual(P('c:/a/b.tar.gz').suffixes, ['.tar', '.gz']) 226 | self.assertEqual(P('//My.py/Share.php').suffixes, []) 227 | self.assertEqual(P('//My.py/Share.php/a/b').suffixes, []) 228 | self.assertEqual(P('c:a/trailing.dot.').suffixes, ['.dot', '.']) 229 | self.assertEqual(P('c:/a/trailing.dot.').suffixes, ['.dot', '.']) 230 | 231 | def test_with_name(self): 232 | P = self.cls 233 | self.assertEqual(P(r'c:a\b').with_name('d.xml'), P(r'c:a\d.xml')) 234 | self.assertEqual(P(r'c:\a\b').with_name('d.xml'), P(r'c:\a\d.xml')) 235 | self.assertEqual(P(r'c:a\Dot ending.').with_name('d.xml'), P(r'c:a\d.xml')) 236 | self.assertEqual(P(r'c:\a\Dot ending.').with_name('d.xml'), P(r'c:\a\d.xml')) 237 | self.assertRaises(ValueError, P(r'c:a\b').with_name, r'd:\e') 238 | self.assertRaises(ValueError, P(r'c:a\b').with_name, r'\\My\Share') 239 | 240 | def test_with_stem(self): 241 | P = self.cls 242 | self.assertEqual(P('c:a/b').with_stem('d'), P('c:a/d')) 243 | self.assertEqual(P('c:/a/b').with_stem('d'), P('c:/a/d')) 244 | self.assertEqual(P('c:a/Dot ending.').with_stem('d'), P('c:a/d.')) 245 | self.assertEqual(P('c:/a/Dot ending.').with_stem('d'), P('c:/a/d.')) 246 | self.assertRaises(ValueError, P('c:a/b').with_stem, 'd:/e') 247 | self.assertRaises(ValueError, P('c:a/b').with_stem, '//My/Share') 248 | 249 | def test_with_suffix(self): 250 | P = self.cls 251 | self.assertEqual(P('c:a/b').with_suffix('.gz'), P('c:a/b.gz')) 252 | self.assertEqual(P('c:/a/b').with_suffix('.gz'), P('c:/a/b.gz')) 253 | self.assertEqual(P('c:a/b.py').with_suffix('.gz'), P('c:a/b.gz')) 254 | self.assertEqual(P('c:/a/b.py').with_suffix('.gz'), P('c:/a/b.gz')) 255 | # Path doesn't have a "filename" component. 256 | self.assertRaises(ValueError, P('').with_suffix, '.gz') 257 | self.assertRaises(ValueError, P('/').with_suffix, '.gz') 258 | self.assertRaises(ValueError, P('//My/Share').with_suffix, '.gz') 259 | # Invalid suffix. 260 | self.assertRaises(ValueError, P('c:a/b').with_suffix, 'gz') 261 | self.assertRaises(ValueError, P('c:a/b').with_suffix, '/') 262 | self.assertRaises(ValueError, P('c:a/b').with_suffix, '\\') 263 | self.assertRaises(ValueError, P('c:a/b').with_suffix, 'c:') 264 | self.assertRaises(ValueError, P('c:a/b').with_suffix, '/.gz') 265 | self.assertRaises(ValueError, P('c:a/b').with_suffix, '\\.gz') 266 | self.assertRaises(ValueError, P('c:a/b').with_suffix, 'c:.gz') 267 | self.assertRaises(ValueError, P('c:a/b').with_suffix, 'c/d') 268 | self.assertRaises(ValueError, P('c:a/b').with_suffix, 'c\\d') 269 | self.assertRaises(ValueError, P('c:a/b').with_suffix, '.c/d') 270 | self.assertRaises(ValueError, P('c:a/b').with_suffix, '.c\\d') 271 | self.assertRaises(TypeError, P('c:a/b').with_suffix, None) 272 | 273 | 274 | class LexicalWindowsPathJoinTest(JoinTestBase, unittest.TestCase): 275 | cls = LexicalWindowsPath 276 | 277 | 278 | if not is_pypi: 279 | from pathlib import PureWindowsPath, WindowsPath 280 | 281 | class PureWindowsPathJoinTest(JoinTestBase, unittest.TestCase): 282 | cls = PureWindowsPath 283 | 284 | if os.name == 'nt': 285 | class WindowsPathJoinTest(JoinTestBase, unittest.TestCase): 286 | cls = WindowsPath 287 | 288 | 289 | if __name__ == "__main__": 290 | unittest.main() 291 | -------------------------------------------------------------------------------- /tests/test_read.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for pathlib.types._ReadablePath 3 | """ 4 | 5 | import collections.abc 6 | import io 7 | import sys 8 | import unittest 9 | 10 | from .support import is_pypi 11 | from .support.local_path import ReadableLocalPath, LocalPathGround 12 | from .support.zip_path import ReadableZipPath, ZipPathGround 13 | 14 | if is_pypi: 15 | from pathlib_abc import PathInfo, _ReadablePath 16 | from pathlib_abc._os import magic_open 17 | else: 18 | from pathlib.types import PathInfo, _ReadablePath 19 | from pathlib._os import magic_open 20 | 21 | 22 | class ReadTestBase: 23 | def setUp(self): 24 | self.root = self.ground.setup() 25 | self.ground.create_hierarchy(self.root) 26 | 27 | def tearDown(self): 28 | self.ground.teardown(self.root) 29 | 30 | def test_is_readable(self): 31 | self.assertIsInstance(self.root, _ReadablePath) 32 | 33 | def test_open_r(self): 34 | p = self.root / 'fileA' 35 | with magic_open(p, 'r', encoding='utf-8') as f: 36 | self.assertIsInstance(f, io.TextIOBase) 37 | self.assertEqual(f.read(), 'this is file A\n') 38 | 39 | @unittest.skipIf( 40 | not getattr(sys.flags, 'warn_default_encoding', 0), 41 | "Requires warn_default_encoding", 42 | ) 43 | def test_open_r_encoding_warning(self): 44 | p = self.root / 'fileA' 45 | with self.assertWarns(EncodingWarning) as wc: 46 | with magic_open(p, 'r'): 47 | pass 48 | self.assertEqual(wc.filename, __file__) 49 | 50 | def test_open_rb(self): 51 | p = self.root / 'fileA' 52 | with magic_open(p, 'rb') as f: 53 | self.assertEqual(f.read(), b'this is file A\n') 54 | self.assertRaises(ValueError, magic_open, p, 'rb', encoding='utf8') 55 | self.assertRaises(ValueError, magic_open, p, 'rb', errors='strict') 56 | self.assertRaises(ValueError, magic_open, p, 'rb', newline='') 57 | 58 | def test_read_bytes(self): 59 | p = self.root / 'fileA' 60 | self.assertEqual(p.read_bytes(), b'this is file A\n') 61 | 62 | def test_read_text(self): 63 | p = self.root / 'fileA' 64 | self.assertEqual(p.read_text(encoding='utf-8'), 'this is file A\n') 65 | q = self.root / 'abc' 66 | self.ground.create_file(q, b'\xe4bcdefg') 67 | self.assertEqual(q.read_text(encoding='latin-1'), 'äbcdefg') 68 | self.assertEqual(q.read_text(encoding='utf-8', errors='ignore'), 'bcdefg') 69 | 70 | @unittest.skipIf( 71 | not getattr(sys.flags, 'warn_default_encoding', 0), 72 | "Requires warn_default_encoding", 73 | ) 74 | def test_read_text_encoding_warning(self): 75 | p = self.root / 'fileA' 76 | with self.assertWarns(EncodingWarning) as wc: 77 | p.read_text() 78 | self.assertEqual(wc.filename, __file__) 79 | 80 | def test_read_text_with_newlines(self): 81 | p = self.root / 'abc' 82 | self.ground.create_file(p, b'abcde\r\nfghlk\n\rmnopq') 83 | # Check that `\n` character change nothing 84 | self.assertEqual(p.read_text(encoding='utf-8', newline='\n'), 'abcde\r\nfghlk\n\rmnopq') 85 | # Check that `\r` character replaces `\n` 86 | self.assertEqual(p.read_text(encoding='utf-8', newline='\r'), 'abcde\r\nfghlk\n\rmnopq') 87 | # Check that `\r\n` character replaces `\n` 88 | self.assertEqual(p.read_text(encoding='utf-8', newline='\r\n'), 'abcde\r\nfghlk\n\rmnopq') 89 | 90 | def test_iterdir(self): 91 | expected = ['dirA', 'dirB', 'dirC', 'fileA'] 92 | if self.ground.can_symlink: 93 | expected += ['linkA', 'linkB', 'brokenLink', 'brokenLinkLoop'] 94 | expected = {self.root.joinpath(name) for name in expected} 95 | actual = set(self.root.iterdir()) 96 | self.assertEqual(actual, expected) 97 | 98 | def test_iterdir_nodir(self): 99 | p = self.root / 'fileA' 100 | self.assertRaises(OSError, p.iterdir) 101 | 102 | def test_iterdir_info(self): 103 | for child in self.root.iterdir(): 104 | self.assertIsInstance(child.info, PathInfo) 105 | self.assertTrue(child.info.exists(follow_symlinks=False)) 106 | 107 | def test_glob(self): 108 | if not self.ground.can_symlink: 109 | self.skipTest("requires symlinks") 110 | 111 | p = self.root 112 | sep = self.root.parser.sep 113 | altsep = self.root.parser.altsep 114 | def check(pattern, expected): 115 | if altsep: 116 | expected = {name.replace(altsep, sep) for name in expected} 117 | expected = {p.joinpath(name) for name in expected} 118 | actual = set(p.glob(pattern, recurse_symlinks=True)) 119 | self.assertEqual(actual, expected) 120 | 121 | it = p.glob("fileA") 122 | self.assertIsInstance(it, collections.abc.Iterator) 123 | self.assertEqual(list(it), [p.joinpath("fileA")]) 124 | check("*A", ["dirA", "fileA", "linkA"]) 125 | check("*A", ['dirA', 'fileA', 'linkA']) 126 | check("*B/*", ["dirB/fileB", "linkB/fileB"]) 127 | check("*B/*", ['dirB/fileB', 'linkB/fileB']) 128 | check("brokenLink", ['brokenLink']) 129 | check("brokenLinkLoop", ['brokenLinkLoop']) 130 | check("**/", ["", "dirA/", "dirA/linkC/", "dirB/", "dirC/", "dirC/dirD/", "linkB/"]) 131 | check("**/*/", ["dirA/", "dirA/linkC/", "dirB/", "dirC/", "dirC/dirD/", "linkB/"]) 132 | check("*/", ["dirA/", "dirB/", "dirC/", "linkB/"]) 133 | check("*/dirD/**/", ["dirC/dirD/"]) 134 | check("*/dirD/**", ["dirC/dirD/", "dirC/dirD/fileD"]) 135 | check("dir*/**", ["dirA/", "dirA/linkC", "dirA/linkC/fileB", "dirB/", "dirB/fileB", "dirC/", 136 | "dirC/fileC", "dirC/dirD", "dirC/dirD/fileD", "dirC/novel.txt"]) 137 | check("dir*/**/", ["dirA/", "dirA/linkC/", "dirB/", "dirC/", "dirC/dirD/"]) 138 | check("dir*/**/..", ["dirA/..", "dirA/linkC/..", "dirB/..", "dirC/..", "dirC/dirD/.."]) 139 | check("dir*/*/**", ["dirA/linkC/", "dirA/linkC/fileB", "dirC/dirD/", "dirC/dirD/fileD"]) 140 | check("dir*/*/**/", ["dirA/linkC/", "dirC/dirD/"]) 141 | check("dir*/*/**/..", ["dirA/linkC/..", "dirC/dirD/.."]) 142 | check("dir*/*/..", ["dirC/dirD/..", "dirA/linkC/.."]) 143 | check("dir*/*/../dirD/**/", ["dirC/dirD/../dirD/"]) 144 | check("dir*/**/fileC", ["dirC/fileC"]) 145 | check("dir*/file*", ["dirB/fileB", "dirC/fileC"]) 146 | check("**/*/fileA", []) 147 | check("fileB", []) 148 | check("**/*/fileB", ["dirB/fileB", "dirA/linkC/fileB", "linkB/fileB"]) 149 | check("**/fileB", ["dirB/fileB", "dirA/linkC/fileB", "linkB/fileB"]) 150 | check("*/fileB", ["dirB/fileB", "linkB/fileB"]) 151 | check("*/fileB", ['dirB/fileB', 'linkB/fileB']) 152 | check("**/file*", 153 | ["fileA", "dirA/linkC/fileB", "dirB/fileB", "dirC/fileC", "dirC/dirD/fileD", 154 | "linkB/fileB"]) 155 | with self.assertRaisesRegex(ValueError, 'Unacceptable pattern'): 156 | list(p.glob('')) 157 | 158 | def test_walk_top_down(self): 159 | it = self.root.walk() 160 | 161 | path, dirnames, filenames = next(it) 162 | dirnames.sort() 163 | filenames.sort() 164 | self.assertEqual(path, self.root) 165 | self.assertEqual(dirnames, ['dirA', 'dirB', 'dirC']) 166 | self.assertEqual(filenames, ['brokenLink', 'brokenLinkLoop', 'fileA', 'linkA', 'linkB'] 167 | if self.ground.can_symlink else ['fileA']) 168 | 169 | path, dirnames, filenames = next(it) 170 | self.assertEqual(path, self.root / 'dirA') 171 | self.assertEqual(dirnames, []) 172 | self.assertEqual(filenames, ['linkC'] if self.ground.can_symlink else []) 173 | 174 | path, dirnames, filenames = next(it) 175 | self.assertEqual(path, self.root / 'dirB') 176 | self.assertEqual(dirnames, []) 177 | self.assertEqual(filenames, ['fileB']) 178 | 179 | path, dirnames, filenames = next(it) 180 | filenames.sort() 181 | self.assertEqual(path, self.root / 'dirC') 182 | self.assertEqual(dirnames, ['dirD']) 183 | self.assertEqual(filenames, ['fileC', 'novel.txt']) 184 | 185 | path, dirnames, filenames = next(it) 186 | self.assertEqual(path, self.root / 'dirC' / 'dirD') 187 | self.assertEqual(dirnames, []) 188 | self.assertEqual(filenames, ['fileD']) 189 | 190 | self.assertRaises(StopIteration, next, it) 191 | 192 | def test_walk_prune(self): 193 | expected = {self.root, self.root / 'dirA', self.root / 'dirC', self.root / 'dirC' / 'dirD'} 194 | actual = set() 195 | for path, dirnames, filenames in self.root.walk(): 196 | actual.add(path) 197 | if path == self.root: 198 | dirnames.remove('dirB') 199 | self.assertEqual(actual, expected) 200 | 201 | def test_walk_bottom_up(self): 202 | seen_root = seen_dira = seen_dirb = seen_dirc = seen_dird = False 203 | for path, dirnames, filenames in self.root.walk(top_down=False): 204 | if path == self.root: 205 | self.assertFalse(seen_root) 206 | self.assertTrue(seen_dira) 207 | self.assertTrue(seen_dirb) 208 | self.assertTrue(seen_dirc) 209 | self.assertEqual(sorted(dirnames), ['dirA', 'dirB', 'dirC']) 210 | self.assertEqual(sorted(filenames), 211 | ['brokenLink', 'brokenLinkLoop', 'fileA', 'linkA', 'linkB'] 212 | if self.ground.can_symlink else ['fileA']) 213 | seen_root = True 214 | elif path == self.root / 'dirA': 215 | self.assertFalse(seen_root) 216 | self.assertFalse(seen_dira) 217 | self.assertEqual(dirnames, []) 218 | self.assertEqual(filenames, ['linkC'] if self.ground.can_symlink else []) 219 | seen_dira = True 220 | elif path == self.root / 'dirB': 221 | self.assertFalse(seen_root) 222 | self.assertFalse(seen_dirb) 223 | self.assertEqual(dirnames, []) 224 | self.assertEqual(filenames, ['fileB']) 225 | seen_dirb = True 226 | elif path == self.root / 'dirC': 227 | self.assertFalse(seen_root) 228 | self.assertFalse(seen_dirc) 229 | self.assertTrue(seen_dird) 230 | self.assertEqual(dirnames, ['dirD']) 231 | self.assertEqual(sorted(filenames), ['fileC', 'novel.txt']) 232 | seen_dirc = True 233 | elif path == self.root / 'dirC' / 'dirD': 234 | self.assertFalse(seen_root) 235 | self.assertFalse(seen_dirc) 236 | self.assertFalse(seen_dird) 237 | self.assertEqual(dirnames, []) 238 | self.assertEqual(filenames, ['fileD']) 239 | seen_dird = True 240 | else: 241 | raise AssertionError(f"Unexpected path: {path}") 242 | self.assertTrue(seen_root) 243 | 244 | def test_info_exists(self): 245 | p = self.root 246 | self.assertTrue(p.info.exists()) 247 | self.assertTrue((p / 'dirA').info.exists()) 248 | self.assertTrue((p / 'dirA').info.exists(follow_symlinks=False)) 249 | self.assertTrue((p / 'fileA').info.exists()) 250 | self.assertTrue((p / 'fileA').info.exists(follow_symlinks=False)) 251 | self.assertFalse((p / 'non-existing').info.exists()) 252 | self.assertFalse((p / 'non-existing').info.exists(follow_symlinks=False)) 253 | if self.ground.can_symlink: 254 | self.assertTrue((p / 'linkA').info.exists()) 255 | self.assertTrue((p / 'linkA').info.exists(follow_symlinks=False)) 256 | self.assertTrue((p / 'linkB').info.exists()) 257 | self.assertTrue((p / 'linkB').info.exists(follow_symlinks=True)) 258 | self.assertFalse((p / 'brokenLink').info.exists()) 259 | self.assertTrue((p / 'brokenLink').info.exists(follow_symlinks=False)) 260 | self.assertFalse((p / 'brokenLinkLoop').info.exists()) 261 | self.assertTrue((p / 'brokenLinkLoop').info.exists(follow_symlinks=False)) 262 | self.assertFalse((p / 'fileA\udfff').info.exists()) 263 | self.assertFalse((p / 'fileA\udfff').info.exists(follow_symlinks=False)) 264 | self.assertFalse((p / 'fileA\x00').info.exists()) 265 | self.assertFalse((p / 'fileA\x00').info.exists(follow_symlinks=False)) 266 | 267 | def test_info_is_dir(self): 268 | p = self.root 269 | self.assertTrue((p / 'dirA').info.is_dir()) 270 | self.assertTrue((p / 'dirA').info.is_dir(follow_symlinks=False)) 271 | self.assertFalse((p / 'fileA').info.is_dir()) 272 | self.assertFalse((p / 'fileA').info.is_dir(follow_symlinks=False)) 273 | self.assertFalse((p / 'non-existing').info.is_dir()) 274 | self.assertFalse((p / 'non-existing').info.is_dir(follow_symlinks=False)) 275 | if self.ground.can_symlink: 276 | self.assertFalse((p / 'linkA').info.is_dir()) 277 | self.assertFalse((p / 'linkA').info.is_dir(follow_symlinks=False)) 278 | self.assertTrue((p / 'linkB').info.is_dir()) 279 | self.assertFalse((p / 'linkB').info.is_dir(follow_symlinks=False)) 280 | self.assertFalse((p / 'brokenLink').info.is_dir()) 281 | self.assertFalse((p / 'brokenLink').info.is_dir(follow_symlinks=False)) 282 | self.assertFalse((p / 'brokenLinkLoop').info.is_dir()) 283 | self.assertFalse((p / 'brokenLinkLoop').info.is_dir(follow_symlinks=False)) 284 | self.assertFalse((p / 'dirA\udfff').info.is_dir()) 285 | self.assertFalse((p / 'dirA\udfff').info.is_dir(follow_symlinks=False)) 286 | self.assertFalse((p / 'dirA\x00').info.is_dir()) 287 | self.assertFalse((p / 'dirA\x00').info.is_dir(follow_symlinks=False)) 288 | 289 | def test_info_is_file(self): 290 | p = self.root 291 | self.assertTrue((p / 'fileA').info.is_file()) 292 | self.assertTrue((p / 'fileA').info.is_file(follow_symlinks=False)) 293 | self.assertFalse((p / 'dirA').info.is_file()) 294 | self.assertFalse((p / 'dirA').info.is_file(follow_symlinks=False)) 295 | self.assertFalse((p / 'non-existing').info.is_file()) 296 | self.assertFalse((p / 'non-existing').info.is_file(follow_symlinks=False)) 297 | if self.ground.can_symlink: 298 | self.assertTrue((p / 'linkA').info.is_file()) 299 | self.assertFalse((p / 'linkA').info.is_file(follow_symlinks=False)) 300 | self.assertFalse((p / 'linkB').info.is_file()) 301 | self.assertFalse((p / 'linkB').info.is_file(follow_symlinks=False)) 302 | self.assertFalse((p / 'brokenLink').info.is_file()) 303 | self.assertFalse((p / 'brokenLink').info.is_file(follow_symlinks=False)) 304 | self.assertFalse((p / 'brokenLinkLoop').info.is_file()) 305 | self.assertFalse((p / 'brokenLinkLoop').info.is_file(follow_symlinks=False)) 306 | self.assertFalse((p / 'fileA\udfff').info.is_file()) 307 | self.assertFalse((p / 'fileA\udfff').info.is_file(follow_symlinks=False)) 308 | self.assertFalse((p / 'fileA\x00').info.is_file()) 309 | self.assertFalse((p / 'fileA\x00').info.is_file(follow_symlinks=False)) 310 | 311 | def test_info_is_symlink(self): 312 | p = self.root 313 | self.assertFalse((p / 'fileA').info.is_symlink()) 314 | self.assertFalse((p / 'dirA').info.is_symlink()) 315 | self.assertFalse((p / 'non-existing').info.is_symlink()) 316 | if self.ground.can_symlink: 317 | self.assertTrue((p / 'linkA').info.is_symlink()) 318 | self.assertTrue((p / 'linkB').info.is_symlink()) 319 | self.assertTrue((p / 'brokenLink').info.is_symlink()) 320 | self.assertFalse((p / 'linkA\udfff').info.is_symlink()) 321 | self.assertFalse((p / 'linkA\x00').info.is_symlink()) 322 | self.assertTrue((p / 'brokenLinkLoop').info.is_symlink()) 323 | self.assertFalse((p / 'fileA\udfff').info.is_symlink()) 324 | self.assertFalse((p / 'fileA\x00').info.is_symlink()) 325 | 326 | 327 | class ZipPathReadTest(ReadTestBase, unittest.TestCase): 328 | ground = ZipPathGround(ReadableZipPath) 329 | 330 | 331 | class LocalPathReadTest(ReadTestBase, unittest.TestCase): 332 | ground = LocalPathGround(ReadableLocalPath) 333 | 334 | 335 | if not is_pypi: 336 | from pathlib import Path 337 | 338 | class PathReadTest(ReadTestBase, unittest.TestCase): 339 | ground = LocalPathGround(Path) 340 | 341 | 342 | if __name__ == "__main__": 343 | unittest.main() 344 | -------------------------------------------------------------------------------- /tests/test_write.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for pathlib.types._WritablePath 3 | """ 4 | 5 | import io 6 | import os 7 | import sys 8 | import unittest 9 | 10 | from .support import is_pypi 11 | from .support.local_path import WritableLocalPath, LocalPathGround 12 | from .support.zip_path import WritableZipPath, ZipPathGround 13 | 14 | if is_pypi: 15 | from pathlib_abc import _WritablePath 16 | from pathlib_abc._os import magic_open 17 | else: 18 | from pathlib.types import _WritablePath 19 | from pathlib._os import magic_open 20 | 21 | 22 | class WriteTestBase: 23 | def setUp(self): 24 | self.root = self.ground.setup() 25 | 26 | def tearDown(self): 27 | self.ground.teardown(self.root) 28 | 29 | def test_is_writable(self): 30 | self.assertIsInstance(self.root, _WritablePath) 31 | 32 | def test_open_w(self): 33 | p = self.root / 'fileA' 34 | with magic_open(p, 'w', encoding='utf-8') as f: 35 | self.assertIsInstance(f, io.TextIOBase) 36 | f.write('this is file A\n') 37 | self.assertEqual(self.ground.readtext(p), 'this is file A\n') 38 | 39 | @unittest.skipIf( 40 | not getattr(sys.flags, 'warn_default_encoding', 0), 41 | "Requires warn_default_encoding", 42 | ) 43 | def test_open_w_encoding_warning(self): 44 | p = self.root / 'fileA' 45 | with self.assertWarns(EncodingWarning) as wc: 46 | with magic_open(p, 'w'): 47 | pass 48 | self.assertEqual(wc.filename, __file__) 49 | 50 | def test_open_wb(self): 51 | p = self.root / 'fileA' 52 | with magic_open(p, 'wb') as f: 53 | #self.assertIsInstance(f, io.BufferedWriter) 54 | f.write(b'this is file A\n') 55 | self.assertEqual(self.ground.readbytes(p), b'this is file A\n') 56 | self.assertRaises(ValueError, magic_open, p, 'wb', encoding='utf8') 57 | self.assertRaises(ValueError, magic_open, p, 'wb', errors='strict') 58 | self.assertRaises(ValueError, magic_open, p, 'wb', newline='') 59 | 60 | def test_write_bytes(self): 61 | p = self.root / 'fileA' 62 | p.write_bytes(b'abcdefg') 63 | self.assertEqual(self.ground.readbytes(p), b'abcdefg') 64 | # Check that trying to write str does not truncate the file. 65 | self.assertRaises(TypeError, p.write_bytes, 'somestr') 66 | self.assertEqual(self.ground.readbytes(p), b'abcdefg') 67 | 68 | def test_write_text(self): 69 | p = self.root / 'fileA' 70 | p.write_text('äbcdefg', encoding='latin-1') 71 | self.assertEqual(self.ground.readbytes(p), b'\xe4bcdefg') 72 | # Check that trying to write bytes does not truncate the file. 73 | self.assertRaises(TypeError, p.write_text, b'somebytes', encoding='utf-8') 74 | self.assertEqual(self.ground.readbytes(p), b'\xe4bcdefg') 75 | 76 | @unittest.skipIf( 77 | not getattr(sys.flags, 'warn_default_encoding', 0), 78 | "Requires warn_default_encoding", 79 | ) 80 | def test_write_text_encoding_warning(self): 81 | p = self.root / 'fileA' 82 | with self.assertWarns(EncodingWarning) as wc: 83 | p.write_text('abcdefg') 84 | self.assertEqual(wc.filename, __file__) 85 | 86 | def test_write_text_with_newlines(self): 87 | # Check that `\n` character change nothing 88 | p = self.root / 'fileA' 89 | p.write_text('abcde\r\nfghlk\n\rmnopq', encoding='utf-8', newline='\n') 90 | self.assertEqual(self.ground.readbytes(p), b'abcde\r\nfghlk\n\rmnopq') 91 | 92 | # Check that `\r` character replaces `\n` 93 | p = self.root / 'fileB' 94 | p.write_text('abcde\r\nfghlk\n\rmnopq', encoding='utf-8', newline='\r') 95 | self.assertEqual(self.ground.readbytes(p), b'abcde\r\rfghlk\r\rmnopq') 96 | 97 | # Check that `\r\n` character replaces `\n` 98 | p = self.root / 'fileC' 99 | p.write_text('abcde\r\nfghlk\n\rmnopq', encoding='utf-8', newline='\r\n') 100 | self.assertEqual(self.ground.readbytes(p), b'abcde\r\r\nfghlk\r\n\rmnopq') 101 | 102 | # Check that no argument passed will change `\n` to `os.linesep` 103 | os_linesep_byte = bytes(os.linesep, encoding='ascii') 104 | p = self.root / 'fileD' 105 | p.write_text('abcde\nfghlk\n\rmnopq', encoding='utf-8') 106 | self.assertEqual(self.ground.readbytes(p), 107 | b'abcde' + os_linesep_byte + 108 | b'fghlk' + os_linesep_byte + b'\rmnopq') 109 | 110 | def test_mkdir(self): 111 | p = self.root / 'newdirA' 112 | self.assertFalse(self.ground.isdir(p)) 113 | p.mkdir() 114 | self.assertTrue(self.ground.isdir(p)) 115 | 116 | def test_symlink_to(self): 117 | if not self.ground.can_symlink: 118 | self.skipTest('needs symlinks') 119 | link = self.root.joinpath('linkA') 120 | link.symlink_to('fileA') 121 | self.assertTrue(self.ground.islink(link)) 122 | self.assertEqual(self.ground.readlink(link), 'fileA') 123 | 124 | 125 | class ZipPathWriteTest(WriteTestBase, unittest.TestCase): 126 | ground = ZipPathGround(WritableZipPath) 127 | 128 | 129 | class LocalPathWriteTest(WriteTestBase, unittest.TestCase): 130 | ground = LocalPathGround(WritableLocalPath) 131 | 132 | 133 | if not is_pypi: 134 | from pathlib import Path 135 | 136 | class PathWriteTest(WriteTestBase, unittest.TestCase): 137 | ground = LocalPathGround(Path) 138 | 139 | 140 | if __name__ == "__main__": 141 | unittest.main() 142 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = py{37,38,39,310,311,312,313},sphinx 4 | 5 | [testenv] 6 | pass_env = 7 | FORCE_COLOR 8 | NO_COLOR 9 | set_env = 10 | PYTHONWARNDEFAULTENCODING = 1 11 | deps = pytest 12 | commands = pytest tests 13 | 14 | [testenv:sphinx] 15 | usedevelop = True 16 | deps = -rdocs/requirements.txt 17 | commands = sphinx-build -n -W --keep-going -b html docs docs/_build/html 18 | --------------------------------------------------------------------------------