• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

domdfcoding / domdf_python_tools / 7166137878

11 Dec 2023 10:30AM CUT coverage: 97.456%. Remained the same
7166137878

push

github

domdfcoding
Bump version v3.7.0 -> v3.8.0

1 of 1 new or added line in 1 file covered. (100.0%)

2145 of 2201 relevant lines covered (97.46%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.77
/domdf_python_tools/paths.py
1
#!/usr/bin/env python
2
#
3
#  paths.py
4
"""
1✔
5
Functions for paths and files.
6

7
.. versionchanged:: 1.0.0
8

9
        Removed ``relpath2``.
10
        Use :func:`domdf_python_tools.paths.relpath` instead.
11
"""
12
#
13
#  Copyright © 2018-2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
14
#
15
#  Parts of the docstrings, the PathPlus class and the DirComparator class
16
#  based on Python and its Documentation
17
#  Licensed under the Python Software Foundation License Version 2.
18
#  Copyright © 2001-2021 Python Software Foundation. All rights reserved.
19
#  Copyright © 2000 BeOpen.com. All rights reserved.
20
#  Copyright © 1995-2000 Corporation for National Research Initiatives. All rights reserved.
21
#  Copyright © 1991-1995 Stichting Mathematisch Centrum. All rights reserved.
22
#
23
#  copytree based on https://stackoverflow.com/a/12514470/3092681
24
#      Copyright © 2012 atzz
25
#      Licensed under CC-BY-SA
26
#
27
#  Permission is hereby granted, free of charge, to any person obtaining a copy
28
#  of this software and associated documentation files (the "Software"), to deal
29
#  in the Software without restriction, including without limitation the rights
30
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31
#  copies of the Software, and to permit persons to whom the Software is
32
#  furnished to do so, subject to the following conditions:
33
#
34
#  The above copyright notice and this permission notice shall be included in all
35
#  copies or substantial portions of the Software.
36
#
37
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
38
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
39
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
40
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
41
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
42
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
43
#  OR OTHER DEALINGS IN THE SOFTWARE.
44
#
45

46
# stdlib
47
import contextlib
1✔
48
import filecmp
1✔
49
import fnmatch
1✔
50
import gzip
1✔
51
import json
1✔
52
import os
1✔
53
import pathlib
1✔
54
import shutil
1✔
55
import stat
1✔
56
import sys
1✔
57
import tempfile
1✔
58
import urllib.parse
1✔
59
from collections import defaultdict, deque
1✔
60
from operator import methodcaller
1✔
61
from typing import (
1✔
62
                IO,
63
                Any,
64
                Callable,
65
                ContextManager,
66
                Dict,
67
                Iterable,
68
                Iterator,
69
                List,
70
                Optional,
71
                Sequence,
72
                Type,
73
                TypeVar,
74
                Union
75
                )
76

77
# this package
78
from domdf_python_tools.compat import nullcontext
1✔
79
from domdf_python_tools.typing import JsonLibrary, PathLike
1✔
80

81
__all__ = [
1✔
82
                "append",
83
                "copytree",
84
                "delete",
85
                "maybe_make",
86
                "parent_path",
87
                "read",
88
                "relpath",
89
                "write",
90
                "clean_writer",
91
                "make_executable",
92
                "PathPlus",
93
                "PosixPathPlus",
94
                "WindowsPathPlus",
95
                "in_directory",
96
                "_P",
97
                "_PP",
98
                "traverse_to_file",
99
                "matchglob",
100
                "unwanted_dirs",
101
                "TemporaryPathPlus",
102
                "sort_paths",
103
                "DirComparator",
104
                "compare_dirs",
105
                ]
106

107
NEWLINE_DEFAULT = type("NEWLINE_DEFAULT", (object, ), {"__repr__": lambda self: "NEWLINE_DEFAULT"})()
1✔
108

109
_P = TypeVar("_P", bound=pathlib.Path)
1✔
110
"""
111
.. versionadded:: 0.11.0
112

113
.. versionchanged:: 1.7.0  Now bound to :class:`pathlib.Path`.
114
"""
115

116
_PP = TypeVar("_PP", bound="PathPlus")
1✔
117
"""
118
.. versionadded:: 2.3.0
119
"""
120

121
unwanted_dirs = (
1✔
122
                ".git",
123
                ".hg",
124
                "venv",
125
                ".venv",
126
                ".mypy_cache",
127
                "__pycache__",
128
                ".pytest_cache",
129
                ".tox",
130
                ".tox4",
131
                ".nox",
132
                "__pypackages__",
133
                )
134
"""
135
A list of directories which will likely be unwanted when searching directory trees for files.
136

137
.. versionadded:: 2.3.0
138
.. versionchanged:: 2.9.0  Added ``.hg`` (`mercurial <https://www.mercurial-scm.org>`_)
139
.. versionchanged:: 3.0.0  Added ``__pypackages__`` (:pep:`582`)
140
.. versionchanged:: 3.2.0  Added ``.nox`` (https://nox.thea.codes/)
141
"""
142

143

144
def append(var: str, filename: PathLike, **kwargs) -> int:
1✔
145
        """
146
        Append ``var`` to the file ``filename`` in the current directory.
147

148
        .. TODO:: make this the file in the given directory, by default the current directory
149

150
        :param var: The value to append to the file
151
        :param filename: The file to append to
152
        """
153

154
        kwargs.setdefault("encoding", "UTF-8")
1✔
155

156
        with open(os.path.join(os.getcwd(), filename), 'a', **kwargs) as f:  # noqa: ENC001
1✔
157
                return f.write(var)
1✔
158

159

160
def copytree(
1✔
161
                src: PathLike,
162
                dst: PathLike,
163
                symlinks: bool = False,
164
                ignore: Optional[Callable] = None,
165
                ) -> PathLike:
166
        """
167
        Alternative to :func:`shutil.copytree` to support copying to a directory that already exists.
168

169
        Based on https://stackoverflow.com/a/12514470 by https://stackoverflow.com/users/23252/atzz
170

171
        In Python 3.8 and above :func:`shutil.copytree` takes a ``dirs_exist_ok`` argument,
172
        which has the same result.
173

174
        :param src: Source file to copy
175
        :param dst: Destination to copy file to
176
        :param symlinks: Whether to represent symbolic links in the source as symbolic
177
                links in the destination. If false or omitted, the contents and metadata
178
                of the linked files are copied to the new tree. When symlinks is false,
179
                if the file pointed by the symlink doesn't exist, an exception will be
180
                added in the list of errors raised in an Error exception at the end of
181
                the copy process. You can set the optional ignore_dangling_symlinks
182
                flag to true if you want to silence this exception. Notice that this
183
                option has no effect on platforms that don’t support :func:`os.symlink`.
184
        :param ignore: A callable that will receive as its arguments the source
185
                directory, and a list of its contents. The ignore callable will be
186
                called once for each directory that is copied. The callable must return
187
                a sequence of directory and file names relative to the current
188
                directory (i.e. a subset of the items in its second argument); these
189
                names will then be ignored in the copy process.
190
                :func:`shutil.ignore_patterns` can be used to create such a callable
191
                that ignores names based on
192
                glob-style patterns.
193
        """
194

195
        for item in os.listdir(src):
1✔
196
                s = os.path.join(src, item)
1✔
197
                d = os.path.join(dst, item)
1✔
198
                if os.path.isdir(s):
1✔
199
                        shutil.copytree(s, d, symlinks, ignore)
1✔
200
                else:
201
                        shutil.copy2(s, d)
1✔
202

203
        return dst
1✔
204

205

206
def delete(filename: PathLike, **kwargs):
1✔
207
        """
208
        Delete the file in the current directory.
209

210
        .. TODO:: make this the file in the given directory, by default the current directory
211

212
        :param filename: The file to delete
213
        """
214

215
        os.remove(os.path.join(os.getcwd(), filename), **kwargs)
1✔
216

217

218
def maybe_make(directory: PathLike, mode: int = 0o777, parents: bool = False):
1✔
219
        """
220
        Create a directory at the given path, but only if the directory does not already exist.
221

222
        .. attention::
223

224
                This will fail silently if a file with the same name already exists.
225
                This appears to be due to the behaviour of :func:`os.mkdir`.
226

227
        :param directory: Directory to create
228
        :param mode: Combined with the process's umask value to determine the file mode and access flags
229
        :param parents: If :py:obj:`False` (the default), a missing parent raises a :class:`FileNotFoundError`.
230
                If :py:obj:`True`, any missing parents of this path are created as needed; they are created with the
231
                default permissions without taking mode into account (mimicking the POSIX ``mkdir -p`` command).
232
        :no-default parents:
233

234
        .. versionchanged:: 1.6.0  Removed the ``'exist_ok'`` option, since it made no sense in this context.
235

236
        """
237

238
        if not isinstance(directory, pathlib.Path):
1✔
239
                directory = pathlib.Path(directory)
1✔
240

241
        try:
1✔
242
                directory.mkdir(mode, parents, exist_ok=True)
1✔
243
        except FileExistsError:
1✔
244
                pass
1✔
245

246

247
def parent_path(path: PathLike) -> pathlib.Path:
1✔
248
        """
249
        Returns the path of the parent directory for the given file or directory.
250

251
        :param path: Path to find the parent for
252

253
        :return: The parent directory
254
        """
255

256
        if not isinstance(path, pathlib.Path):
1✔
257
                path = pathlib.Path(path)
1✔
258

259
        return path.parent
1✔
260

261

262
def read(filename: PathLike, **kwargs) -> str:
1✔
263
        """
264
        Read a file in the current directory (in text mode).
265

266
        .. TODO:: make this the file in the given directory, by default the current directory
267

268
        :param filename: The file to read from.
269

270
        :return: The contents of the file.
271
        """
272

273
        kwargs.setdefault("encoding", "UTF-8")
1✔
274

275
        with open(os.path.join(os.getcwd(), filename), **kwargs) as f:  # noqa: ENC001
1✔
276
                return f.read()
1✔
277

278

279
def relpath(path: PathLike, relative_to: Optional[PathLike] = None) -> pathlib.Path:
1✔
280
        """
281
        Returns the path for the given file or directory relative to the given
282
        directory or, if that would require path traversal, returns the absolute path.
283

284
        :param path: Path to find the relative path for
285
        :param relative_to: The directory to find the path relative to.
286
                Defaults to the current directory.
287
        :no-default relative_to:
288
        """  # noqa: D400
289

290
        if not isinstance(path, pathlib.Path):
1✔
291
                path = pathlib.Path(path)
1✔
292

293
        abs_path = path.absolute()
1✔
294

295
        if relative_to is None:
1✔
296
                relative_to = pathlib.Path().absolute()
1✔
297

298
        if not isinstance(relative_to, pathlib.Path):
1✔
299
                relative_to = pathlib.Path(relative_to)
1✔
300

301
        relative_to = relative_to.absolute()
1✔
302

303
        try:
1✔
304
                return abs_path.relative_to(relative_to)
1✔
305
        except ValueError:
1✔
306
                return abs_path
1✔
307

308

309
def write(var: str, filename: PathLike, **kwargs) -> None:
1✔
310
        """
311
        Write a variable to file in the current directory.
312

313
        .. TODO:: make this the file in the given directory, by default the current directory
314

315
        :param var: The value to write to the file.
316
        :param filename: The file to write to.
317
        """
318

319
        kwargs.setdefault("encoding", "UTF-8")
1✔
320

321
        with open(os.path.join(os.getcwd(), filename), 'w', **kwargs) as f:  # noqa: ENC001
1✔
322
                f.write(var)
1✔
323

324

325
def clean_writer(string: str, fp: IO) -> None:
1✔
326
        """
327
        Write string to ``fp`` without trailing spaces.
328

329
        :param string:
330
        :param fp:
331
        """
332

333
        # this package
334
        from domdf_python_tools.stringlist import StringList
1✔
335

336
        buffer = StringList(string)
1✔
337
        buffer.blankline(ensure_single=True)
1✔
338
        fp.write(str(buffer))
1✔
339

340

341
def make_executable(filename: PathLike) -> None:
1✔
342
        """
343
        Make the given file executable.
344

345
        :param filename:
346
        """
347

348
        if not isinstance(filename, pathlib.Path):
1✔
349
                filename = pathlib.Path(filename)
1✔
350

351
        st = os.stat(str(filename))
1✔
352
        os.chmod(str(filename), st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
1✔
353

354

355
@contextlib.contextmanager
1✔
356
def in_directory(directory: PathLike):
1✔
357
        """
358
        Context manager to change into the given directory for the
359
        duration of the ``with`` block.
360

361
        :param directory:
362
        """  # noqa: D400
363

364
        oldwd = os.getcwd()
1✔
365
        try:
1✔
366
                os.chdir(str(directory))
1✔
367
                yield
1✔
368
        finally:
369
                os.chdir(oldwd)
1✔
370

371

372
class PathPlus(pathlib.Path):
1✔
373
        """
374
        Subclass of :class:`pathlib.Path` with additional methods and a default encoding of UTF-8.
375

376
        Path represents a filesystem path but, unlike :class:`pathlib.PurePath`, also offers
377
        methods to do system calls on path objects.
378
        Depending on your system, instantiating a :class:`~.PathPlus` will return
379
        either a :class:`~.PosixPathPlus` or a :class:`~.WindowsPathPlus`. object.
380
        You can also instantiate a :class:`~.PosixPathPlus` or :class:`WindowsPath` directly,
381
        but cannot instantiate a :class:`~.WindowsPathPlus` on a POSIX system or vice versa.
382

383
        .. versionadded:: 0.3.8
384
        .. versionchanged:: 0.5.1  Defaults to Unix line endings (``LF``) on all platforms.
385
        """
386

387
        __slots__ = ()
1✔
388

389
        if sys.version_info < (3, 11):
1✔
390
                _accessor = pathlib._normal_accessor  # type: ignore
1✔
391
        _closed = False
1✔
392

393
        def _init(self, *args, **kwargs):
1✔
394
                pass
1✔
395

396
        @classmethod
1✔
397
        def _from_parts(cls, args, init=True):
1✔
398
                return super()._from_parts(args)  # type: ignore
1✔
399

400
        def __new__(cls: Type[_PP], *args, **kwargs) -> _PP:  # noqa: D102
1✔
401
                if cls is PathPlus:
1✔
402
                        cls = WindowsPathPlus if os.name == "nt" else PosixPathPlus  # type: ignore
1✔
403

404
                return super().__new__(cls, *args, **kwargs)
1✔
405

406
        def make_executable(self) -> None:
1✔
407
                """
408
                Make the file executable.
409

410
                .. versionadded:: 0.3.8
411
                """
412

413
                make_executable(self)
1✔
414

415
        def write_clean(
1✔
416
                        self,
417
                        string: str,
418
                        encoding: Optional[str] = "UTF-8",
419
                        errors: Optional[str] = None,
420
                        ):
421
                """
422
                Write to the file without trailing whitespace, and with a newline at the end of the file.
423

424
                .. versionadded:: 0.3.8
425

426
                :param string:
427
                :param encoding: The encoding to write to the file in.
428
                :param errors:
429
                """
430

431
                with self.open('w', encoding=encoding, errors=errors) as fp:
1✔
432
                        clean_writer(string, fp)
1✔
433

434
        def maybe_make(
1✔
435
                        self,
436
                        mode: int = 0o777,
437
                        parents: bool = False,
438
                        ):
439
                """
440
                Create a directory at this path, but only if the directory does not already exist.
441

442
                .. versionadded:: 0.3.8
443

444
                :param mode: Combined with the process’ umask value to determine the file mode and access flags
445
                :param parents: If :py:obj:`False` (the default), a missing parent raises a :class:`FileNotFoundError`.
446
                        If :py:obj:`True`, any missing parents of this path are created as needed; they are created with the
447
                        default permissions without taking mode into account (mimicking the POSIX mkdir -p command).
448
                :no-default parents:
449

450
                .. versionchanged:: 1.6.0  Removed the ``'exist_ok'`` option, since it made no sense in this context.
451

452
                .. attention::
453

454
                        This will fail silently if a file with the same name already exists.
455
                        This appears to be due to the behaviour of :func:`os.mkdir`.
456

457
                """
458

459
                try:
1✔
460
                        self.mkdir(mode, parents, exist_ok=True)
1✔
461
                except FileExistsError:
1✔
462
                        pass
1✔
463

464
        def append_text(
1✔
465
                        self,
466
                        string: str,
467
                        encoding: Optional[str] = "UTF-8",
468
                        errors: Optional[str] = None,
469
                        ):
470
                """
471
                Open the file in text mode, append the given string to it, and close the file.
472

473
                .. versionadded:: 0.3.8
474

475
                :param string:
476
                :param encoding: The encoding to write to the file in.
477
                :param errors:
478
                """
479

480
                with self.open('a', encoding=encoding, errors=errors) as fp:
1✔
481
                        fp.write(string)
1✔
482

483
        def write_text(
1✔
484
                        self,
485
                        data: str,
486
                        encoding: Optional[str] = "UTF-8",
487
                        errors: Optional[str] = None,
488
                        newline: Optional[str] = NEWLINE_DEFAULT,
489
                        ) -> int:
490
                """
491
                Open the file in text mode, write to it, and close the file.
492

493
                .. versionadded:: 0.3.8
494

495
                :param data:
496
                :param encoding: The encoding to write to the file in.
497
                :param errors:
498
                :param newline:
499
                :default newline: `universal newlines <https://docs.python.org/3/glossary.html#term-universal-newlines>`__ for reading, Unix line endings (``LF``) for writing.
500

501
                .. versionchanged:: 3.1.0
502

503
                        Added the ``newline`` argument to match Python 3.10.
504
                        (see :github:pull:`22420 <python/cpython>`)
505
                """
506

507
                if not isinstance(data, str):
1✔
508
                        raise TypeError(f'data must be str, not {data.__class__.__name__}')
1✔
509

510
                with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
1✔
511
                        return f.write(data)
1✔
512

513
        def write_lines(
1✔
514
                        self,
515
                        data: Iterable[str],
516
                        encoding: Optional[str] = "UTF-8",
517
                        errors: Optional[str] = None,
518
                        *,
519
                        trailing_whitespace: bool = False
520
                        ) -> None:
521
                """
522
                Write the given list of lines to the file without trailing whitespace.
523

524
                .. versionadded:: 0.5.0
525

526
                :param data:
527
                :param encoding: The encoding to write to the file in.
528
                :param errors:
529
                :param trailing_whitespace: If :py:obj:`True` trailing whitespace is preserved.
530

531
                .. versionchanged:: 2.4.0  Added the ``trailing_whitespace`` option.
532
                """
533

534
                if trailing_whitespace:
1✔
535
                        data = list(data)
1✔
536
                        if data[-1].strip():
1✔
537
                                data.append('')
1✔
538

539
                        self.write_text('\n'.join(data), encoding=encoding, errors=errors)
1✔
540
                else:
541
                        self.write_clean('\n'.join(data), encoding=encoding, errors=errors)
1✔
542

543
        def read_text(
1✔
544
                        self,
545
                        encoding: Optional[str] = "UTF-8",
546
                        errors: Optional[str] = None,
547
                        ) -> str:
548
                """
549
                Open the file in text mode, read it, and close the file.
550

551
                .. versionadded:: 0.3.8
552

553
                :param encoding: The encoding to write to the file in.
554
                :param errors:
555

556
                :return: The content of the file.
557
                """
558

559
                return super().read_text(encoding=encoding, errors=errors)
1✔
560

561
        def read_lines(
1✔
562
                        self,
563
                        encoding: Optional[str] = "UTF-8",
564
                        errors: Optional[str] = None,
565
                        ) -> List[str]:
566
                """
567
                Open the file in text mode, return a list containing the lines in the file,
568
                and close the file.
569

570
                .. versionadded:: 0.5.0
571

572
                :param encoding: The encoding to write to the file in.
573
                :param errors:
574

575
                :return: The content of the file.
576
                """  # noqa: D400
577

578
                return self.read_text(encoding=encoding, errors=errors).split('\n')
1✔
579

580
        def open(  # type: ignore  # noqa: A003  # pylint: disable=redefined-builtin
1✔
581
                self,
582
                mode: str = 'r',
583
                buffering: int = -1,
584
                encoding: Optional[str] = "UTF-8",
585
                errors: Optional[str] = None,
586
                newline: Optional[str] = NEWLINE_DEFAULT,
587
                ) -> IO[Any]:
588
                """
589
                Open the file pointed by this path and return a file object, as
590
                the built-in :func:`open` function does.
591

592
                .. versionadded:: 0.3.8
593

594
                :param mode: The mode to open the file in.
595
                :default mode: ``'r'`` (read only)
596
                :param buffering:
597
                :param encoding:
598
                :param errors:
599
                :param newline:
600
                :default newline: `universal newlines <https://docs.python.org/3/glossary.html#term-universal-newlines>`__ for reading, Unix line endings (``LF``) for writing.
601

602
                :rtype:
603

604
                .. versionchanged:: 0.5.1
605

606
                        Defaults to Unix line endings (``LF``) on all platforms.
607
                """  # noqa: D400
608

609
                if 'b' in mode:
1✔
610
                        encoding = None
1✔
611
                        newline = None
1✔
612

613
                if newline is NEWLINE_DEFAULT:
1✔
614
                        if 'r' in mode:
1✔
615
                                newline = None
1✔
616
                        else:
617
                                newline = '\n'
1✔
618

619
                return super().open(
1✔
620
                                mode,
621
                                buffering=buffering,
622
                                encoding=encoding,
623
                                errors=errors,
624
                                newline=newline,
625
                                )
626

627
        def dump_json(
1✔
628
                        self,
629
                        data: Any,
630
                        encoding: Optional[str] = "UTF-8",
631
                        errors: Optional[str] = None,
632
                        json_library: JsonLibrary = json,  # type: ignore
633
                        *,
634
                        compress: bool = False,
635
                        **kwargs,
636
                        ) -> None:
637
                r"""
638
                Dump ``data`` to the file as JSON.
639

640
                .. versionadded:: 0.5.0
641

642
                :param data: The object to serialise to JSON.
643
                :param encoding: The encoding to write to the file in.
644
                :param errors:
645
                :param json_library: The JSON serialisation library to use.
646
                :default json_library: :mod:`json`
647
                :param compress: Whether to compress the JSON file using gzip.
648
                :param \*\*kwargs: Keyword arguments to pass to the JSON serialisation function.
649

650
                :rtype:
651

652
                .. versionchanged:: 1.0.0
653

654
                        Now uses :meth:`PathPlus.write_clean <domdf_python_tools.paths.PathPlus.write_clean>`
655
                        rather than :meth:`PathPlus.write_text <domdf_python_tools.paths.PathPlus.write_text>`,
656
                        and as a result returns :py:obj:`None` rather than :class:`int`.
657

658
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
659
                """
660

661
                if compress:
1✔
662
                        with gzip.open(self, mode="wt", encoding=encoding, errors=errors) as fp:
1✔
663
                                fp.write(json_library.dumps(data, **kwargs))
1✔
664

665
                else:
666
                        self.write_clean(
1✔
667
                                        json_library.dumps(data, **kwargs),
668
                                        encoding=encoding,
669
                                        errors=errors,
670
                                        )
671

672
        def load_json(
1✔
673
                        self,
674
                        encoding: Optional[str] = "UTF-8",
675
                        errors: Optional[str] = None,
676
                        json_library: JsonLibrary = json,  # type: ignore
677
                        *,
678
                        decompress: bool = False,
679
                        **kwargs,
680
                        ) -> Any:
681
                r"""
682
                Load JSON data from the file.
683

684
                .. versionadded:: 0.5.0
685

686
                :param encoding: The encoding to write to the file in.
687
                :param errors:
688
                :param json_library: The JSON serialisation library to use.
689
                :default json_library: :mod:`json`
690
                :param decompress: Whether to decompress the JSON file using gzip.
691
                        Will raise an exception if the file is not compressed.
692
                :param \*\*kwargs: Keyword arguments to pass to the JSON deserialisation function.
693

694
                :return: The deserialised JSON data.
695

696
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
697
                """
698

699
                if decompress:
1✔
700
                        with gzip.open(self, mode="rt", encoding=encoding, errors=errors) as fp:
1✔
701
                                content = fp.read()
1✔
702
                else:
703
                        content = self.read_text(encoding=encoding, errors=errors)
1✔
704

705
                return json_library.loads(
1✔
706
                                content,
707
                                **kwargs,
708
                                )
709

710
        if sys.version_info < (3, 10):  # pragma: no cover (py310+)
1✔
711

712
                def is_mount(self) -> bool:
1✔
713
                        """
714
                        Check if this path is a POSIX mount point.
715

716
                        .. versionadded:: 0.3.8 for Python 3.7 and above
717
                        .. versionadded:: 0.11.0 for Python 3.6
718
                        """
719

720
                        # Need to exist and be a dir
721
                        if not self.exists() or not self.is_dir():
1✔
722
                                return False
1✔
723

724
                        # https://github.com/python/cpython/pull/18839/files
725
                        try:
1✔
726
                                parent_dev = self.parent.stat().st_dev
1✔
727
                        except OSError:
×
728
                                return False
×
729

730
                        dev = self.stat().st_dev
1✔
731
                        if dev != parent_dev:
1✔
732
                                return True
×
733
                        ino = self.stat().st_ino
1✔
734
                        parent_ino = self.parent.stat().st_ino
1✔
735
                        return ino == parent_ino
1✔
736

737
        if sys.version_info < (3, 8):  # pragma: no cover (py38+)
738

739
                def rename(self: _P, target: Union[str, pathlib.PurePath]) -> _P:
740
                        """
741
                        Rename this path to the target path.
742

743
                        The target path may be absolute or relative. Relative paths are
744
                        interpreted relative to the current working directory, *not* the
745
                        directory of the Path object.
746

747
                        .. versionadded:: 0.3.8 for Python 3.8 and above
748
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
749

750
                        :param target:
751

752
                        :returns: The new Path instance pointing to the target path.
753
                        """
754

755
                        os.rename(self, target)
756
                        return self.__class__(target)
757

758
                def replace(self: _P, target: Union[str, pathlib.PurePath]) -> _P:
759
                        """
760
                        Rename this path to the target path, overwriting if that path exists.
761

762
                        The target path may be absolute or relative. Relative paths are
763
                        interpreted relative to the current working directory, *not* the
764
                        directory of the Path object.
765

766
                        Returns the new Path instance pointing to the target path.
767

768
                        .. versionadded:: 0.3.8 for Python 3.8 and above
769
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
770

771
                        :param target:
772

773
                        :returns: The new Path instance pointing to the target path.
774
                        """
775

776
                        os.replace(self, target)
777
                        return self.__class__(target)
778

779
                def unlink(self, missing_ok: bool = False) -> None:
780
                        """
781
                        Remove this file or link.
782

783
                        If the path is a directory, use :meth:`~domdf_python_tools.paths.PathPlus.rmdir()` instead.
784

785
                        .. versionadded:: 0.3.8 for Python 3.8 and above
786
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
787
                        """
788

789
                        try:
790
                                os.unlink(self)
791
                        except FileNotFoundError:
792
                                if not missing_ok:
793
                                        raise
794

795
        def __enter__(self):
1✔
796
                return self
1✔
797

798
        def __exit__(self, t, v, tb):
1✔
799
                # https://bugs.python.org/issue39682
800
                # In previous versions of pathlib, this method marked this path as
801
                # closed; subsequent attempts to perform I/O would raise an IOError.
802
                # This functionality was never documented, and had the effect of
803
                # making Path objects mutable, contrary to PEP 428. In Python 3.9 the
804
                # _closed attribute was removed, and this method made a no-op.
805
                # This method and __enter__()/__exit__() should be deprecated and
806
                # removed in the future.
807
                pass
1✔
808

809
        if sys.version_info < (3, 9):  # pragma: no cover (py39+)
1✔
810

811
                def is_relative_to(self, *other: Union[str, os.PathLike]) -> bool:
1✔
812
                        r"""
813
                        Returns whether the path is relative to another path.
814

815
                        .. versionadded:: 0.3.8 for Python 3.9 and above.
816
                        .. latex:vspace:: -10px
817
                        .. versionadded:: 1.4.0 for Python 3.6 and Python 3.7.
818
                        .. latex:vspace:: -10px
819

820
                        :param \*other:
821

822
                        .. latex:vspace:: -20px
823

824
                        :rtype:
825

826
                        .. latex:vspace:: -20px
827
                        """
828

829
                        try:
×
830
                                self.relative_to(*other)
×
831
                                return True
×
832
                        except ValueError:
×
833
                                return False
×
834

835
        def abspath(self) -> "PathPlus":
1✔
836
                """
837
                Return the absolute version of the path.
838

839
                .. versionadded:: 1.3.0
840
                """
841

842
                return self.__class__(os.path.abspath(self))
1✔
843

844
        def iterchildren(
1✔
845
                        self: _PP,
846
                        exclude_dirs: Optional[Iterable[str]] = unwanted_dirs,
847
                        match: Optional[str] = None,
848
                        matchcase: bool = True,
849
                        ) -> Iterator[_PP]:
850
                """
851
                Returns an iterator over all children (files and directories) of the current path object.
852

853
                .. versionadded:: 2.3.0
854

855
                :param exclude_dirs: A list of directory names which should be excluded from the output,
856
                        together with their children.
857
                :param match: A pattern to match filenames against.
858
                        The pattern should be in the format taken by :func:`~.matchglob`.
859
                :param matchcase: Whether the filename's case should match the pattern.
860

861
                :rtype:
862

863
                .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
864
                """
865

866
                if not self.abspath().is_dir():
1✔
867
                        return
×
868

869
                if exclude_dirs is None:
1✔
870
                        exclude_dirs = ()
1✔
871

872
                if match and not os.path.isabs(match) and self.is_absolute():
1✔
873
                        match = (self / match).as_posix()
1✔
874

875
                file: _PP
876
                for file in self.iterdir():
1✔
877
                        parts = file.parts
1✔
878
                        if any(d in parts for d in exclude_dirs):
1✔
879
                                continue
1✔
880

881
                        if match is None or (match is not None and matchglob(file, match, matchcase)):
1✔
882
                                yield file
1✔
883

884
                        if file.is_dir():
1✔
885
                                yield from file.iterchildren(exclude_dirs, match)
1✔
886

887
        @classmethod
1✔
888
        def from_uri(cls: Type[_PP], uri: str) -> _PP:
1✔
889
                """
890
                Construct a :class:`~.PathPlus` from a ``file`` URI returned by :meth:`pathlib.PurePath.as_uri`.
891

892
                .. versionadded:: 2.9.0
893

894
                :param uri:
895

896
                :rtype: :class:`~.PathPlus`
897
                """
898

899
                parseresult = urllib.parse.urlparse(uri)
1✔
900

901
                if parseresult.scheme != "file":
1✔
902
                        raise ValueError(f"Unsupported URI scheme {parseresult.scheme!r}")
×
903
                if parseresult.params or parseresult.query or parseresult.fragment:
1✔
904
                        raise ValueError("Malformed file URI")
×
905

906
                if sys.platform == "win32":  # pragma: no cover (!Windows)
907

908
                        if parseresult.netloc:
909
                                path = ''.join([
910
                                                "//",
911
                                                urllib.parse.unquote_to_bytes(parseresult.netloc).decode("UTF-8"),
912
                                                urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8"),
913
                                                ])
914
                        else:
915
                                path = urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8").lstrip('/')
916

917
                else:  # pragma: no cover (Windows)
918
                        if parseresult.netloc:
1✔
919
                                raise ValueError("Malformed file URI")
×
920

921
                        path = urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8")
1✔
922

923
                return cls(path)
1✔
924

925
        def move(self: _PP, dst: PathLike) -> _PP:
1✔
926
                """
927
                Recursively move ``self`` to ``dst``.
928

929
                ``self`` may be a file or a directory.
930

931
                See :func:`shutil.move` for more details.
932

933
                .. versionadded:: 3.2.0
934

935
                :param dst:
936

937
                :returns: The new location of ``self``.
938
                :rtype: :class:`~.PathPlus`
939
                """
940

941
                new_path = shutil.move(os.fspath(self), dst)
1✔
942
                return self.__class__(new_path)
1✔
943

944
        def stream(self, chunk_size: int = 1024) -> Iterator[bytes]:
1✔
945
                """
946
                Stream the file in ``chunk_size`` sized chunks.
947

948
                :param chunk_size: The chunk size, in bytes
949

950
                .. versionadded:: 3.2.0
951
                """
952

953
                with self.open("rb") as fp:
1✔
954
                        while True:
955
                                chunk = fp.read(chunk_size)
1✔
956
                                if not chunk:
1✔
957
                                        break
1✔
958
                                yield chunk
1✔
959

960

961
class PosixPathPlus(PathPlus, pathlib.PurePosixPath):
1✔
962
        """
963
        :class:`~.PathPlus` subclass for non-Windows systems.
964

965
        On a POSIX system, instantiating a :class:`~.PathPlus` object should return an instance of this class.
966

967
        .. versionadded:: 0.3.8
968
        """
969

970
        __slots__ = ()
1✔
971

972

973
class WindowsPathPlus(PathPlus, pathlib.PureWindowsPath):
1✔
974
        """
975
        :class:`~.PathPlus` subclass for Windows systems.
976

977
        On a Windows system, instantiating a :class:`~.PathPlus`  object should return an instance of this class.
978

979
        .. versionadded:: 0.3.8
980

981
        .. autoclasssumm:: WindowsPathPlus
982
                :autosummary-sections: ;;
983

984
        The following methods are unsupported on Windows:
985

986
        * :meth:`~pathlib.Path.group`
987
        * :meth:`~pathlib.Path.is_mount`
988
        * :meth:`~pathlib.Path.owner`
989
        """
990

991
        __slots__ = ()
1✔
992

993
        def owner(self):  # pragma: no cover
994
                """
995
                Unsupported on Windows.
996
                """
997

998
                raise NotImplementedError("Path.owner() is unsupported on this system")
999

1000
        def group(self):  # pragma: no cover
1001
                """
1002
                Unsupported on Windows.
1003
                """
1004

1005
                raise NotImplementedError("Path.group() is unsupported on this system")
1006

1007
        def is_mount(self):  # pragma: no cover
1008
                """
1009
                Unsupported on Windows.
1010
                """
1011

1012
                raise NotImplementedError("Path.is_mount() is unsupported on this system")
1013

1014

1015
def traverse_to_file(base_directory: _P, *filename: PathLike, height: int = -1) -> _P:
1✔
1016
        r"""
1017
        Traverse the parents of the given directory until the desired file is found.
1018

1019
        .. versionadded:: 1.7.0
1020

1021
        :param base_directory: The directory to start searching from
1022
        :param \*filename: The filename(s) to search for
1023
        :param height: The maximum height to traverse to.
1024
        """
1025

1026
        if not filename:
1✔
1027
                raise TypeError("traverse_to_file expected 2 or more arguments, got 1")
1✔
1028

1029
        for level, directory in enumerate((base_directory, *base_directory.parents)):
1✔
1030
                if height > 0 and ((level - 1) > height):
1✔
1031
                        break
×
1032

1033
                for file in filename:
1✔
1034
                        if (directory / file).is_file():
1✔
1035
                                return directory
1✔
1036

1037
        raise FileNotFoundError(f"'{filename[0]!s}' not found in {base_directory}")
1✔
1038

1039

1040
def matchglob(filename: PathLike, pattern: str, matchcase: bool = True) -> bool:
1✔
1041
        """
1042
        Given a filename and a glob pattern, return whether the filename matches the glob.
1043

1044
        .. versionadded:: 2.3.0
1045

1046
        :param filename:
1047
        :param pattern: A pattern structured like a filesystem path, where each element consists of the glob syntax.
1048
                Each element is matched by :mod:`fnmatch`.
1049
                The special element ``**`` matches zero or more files or directories.
1050
        :param matchcase: Whether the filename's case should match the pattern.
1051

1052
        :rtype:
1053

1054
        .. seealso:: :wikipedia:`Glob (programming)#Syntax` on Wikipedia
1055
        .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
1056
        """
1057

1058
        match_func = fnmatch.fnmatchcase if matchcase else fnmatch.fnmatch
1✔
1059

1060
        filename = PathPlus(filename)
1✔
1061

1062
        pattern_parts = deque(pathlib.PurePath(pattern).parts)
1✔
1063
        filename_parts = deque(filename.parts)
1✔
1064

1065
        if not pattern_parts[-1]:
1✔
1066
                pattern_parts.pop()
×
1067

1068
        while True:
1069
                if not pattern_parts and not filename_parts:
1✔
1070
                        return True
1✔
1071
                elif not pattern_parts and filename_parts:
1✔
1072
                        # Pattern exhausted but still filename elements
1073
                        return False
1✔
1074

1075
                pattern_part = pattern_parts.popleft()
1✔
1076

1077
                if pattern_part == "**" and not filename_parts:
1✔
1078
                        return True
1✔
1079
                else:
1080
                        filename_part = filename_parts.popleft()
1✔
1081

1082
                if pattern_part == "**":
1✔
1083
                        if not pattern_parts:
1✔
1084
                                return True
1✔
1085

1086
                        while pattern_part == "**":
1✔
1087
                                if not pattern_parts:
1✔
1088
                                        return True
1✔
1089

1090
                                pattern_part = pattern_parts.popleft()
1✔
1091

1092
                        if pattern_parts and not filename_parts:
1✔
1093
                                # Filename must match everything after **
1094
                                return False
×
1095

1096
                        if match_func(filename_part, pattern_part):
1✔
1097
                                continue
1✔
1098
                        else:
1099
                                while not match_func(filename_part, pattern_part):
1✔
1100
                                        if not filename_parts:
1✔
1101
                                                return False
1✔
1102

1103
                                        filename_part = filename_parts.popleft()
1✔
1104

1105
                elif match_func(filename_part, pattern_part):
1✔
1106
                        continue
1✔
1107
                else:
1108
                        return False
1✔
1109

1110

1111
class TemporaryPathPlus(tempfile.TemporaryDirectory):
1✔
1112
        """
1113
        Securely creates a temporary directory using the same rules as :func:`tempfile.mkdtemp`.
1114
        The resulting object can be used as a context manager.
1115
        On completion of the context or destruction of the object
1116
        the newly created temporary directory and all its contents are removed from the filesystem.
1117

1118
        Unlike :func:`tempfile.TemporaryDirectory` this class is based around a :class:`~.PathPlus` object.
1119

1120
        .. versionadded:: 2.4.0
1121
        .. autosummary-widths:: 6/16
1122
        """
1123

1124
        name: PathPlus
1✔
1125
        """
1126
        The temporary directory itself.
1127

1128
        This will be assigned to the target of the :keyword:`as` clause if the :class:`~.TemporaryPathPlus`
1129
        is used as a context manager.
1130
        """
1131

1132
        def __init__(
1✔
1133
                        self,
1134
                        suffix: Optional[str] = None,
1135
                        prefix: Optional[str] = None,
1136
                        dir: Optional[PathLike] = None,  # noqa: A002  # pylint: disable=redefined-builtin
1137
                        ) -> None:
1138

1139
                super().__init__(suffix, prefix, dir)
1✔
1140
                self.name = PathPlus(self.name)
1✔
1141

1142
        def cleanup(self) -> None:
1✔
1143
                """
1144
                Cleanup the temporary directory by removing it and its contents.
1145

1146
                If the :class:`~.TemporaryPathPlus` is used as a context manager
1147
                this is called when leaving the :keyword:`with` block.
1148
                """
1149

1150
                context: ContextManager
1151

1152
                if sys.platform == "win32":  # pragma: no cover (!Windows)
1153
                        context = contextlib.suppress(PermissionError, NotADirectoryError)
1154
                else:  # pragma: no cover (Windows)
1155
                        context = nullcontext()
1✔
1156

1157
                with context:
1✔
1158
                        super().cleanup()
1✔
1159

1160
        def __enter__(self) -> PathPlus:
1✔
1161
                return self.name
1✔
1162

1163

1164
def sort_paths(*paths: PathLike) -> List[PathPlus]:
1✔
1165
        """
1166
        Sort the ``paths`` by directory, then by file.
1167

1168
        .. versionadded:: 2.6.0
1169

1170
        :param paths:
1171
        """
1172

1173
        directories: Dict[str, List[PathPlus]] = defaultdict(list)
1✔
1174
        local_contents: List[PathPlus] = []
1✔
1175
        files: List[PathPlus] = []
1✔
1176

1177
        for obj in [PathPlus(path) for path in paths]:
1✔
1178
                if len(obj.parts) > 1:
1✔
1179
                        key = obj.parts[0]
1✔
1180
                        directories[key].append(obj)
1✔
1181
                else:
1182
                        local_contents.append(obj)
1✔
1183

1184
        # sort directories
1185
        directories = {directory: directories[directory] for directory in sorted(directories.keys())}
1✔
1186

1187
        for directory, contents in directories.items():
1✔
1188
                contents = [path.relative_to(directory) for path in contents]
1✔
1189
                files.extend(PathPlus(directory) / path for path in sort_paths(*contents))
1✔
1190

1191
        return files + sorted(local_contents, key=methodcaller("as_posix"))
1✔
1192

1193

1194
class DirComparator(filecmp.dircmp):
1✔
1195
        r"""
1196
        Compare the content of ``a`` and ``a``.
1197

1198
        In contrast with :class:`filecmp.dircmp`, this
1199
        subclass compares the content of files with the same path.
1200

1201
        .. versionadded:: 2.7.0
1202

1203
        :param a: The "left" directory to compare.
1204
        :param b: The "right" directory to compare.
1205
        :param ignore: A list of names to ignore.
1206
        :default ignore: :py:obj:`filecmp.DEFAULT_IGNORES`
1207
        :param hide: A list of names to hide.
1208
        :default hide: ``[`` :py:obj:`os.curdir`, :py:obj:`os.pardir` ``]``
1209
        """
1210

1211
        # From https://stackoverflow.com/a/24860799, public domain.
1212
        # Thanks Philippe
1213

1214
        def __init__(
1✔
1215
                        self,
1216
                        a: PathLike,
1217
                        b: PathLike,
1218
                        ignore: Optional[Sequence[str]] = None,
1219
                        hide: Optional[Sequence[str]] = None,
1220
                        ):
1221
                super().__init__(a, b, ignore=ignore, hide=hide)
1✔
1222

1223
        def phase3(self) -> None:  # noqa: D102
1✔
1224
                # Find out differences between common files.
1225
                # Ensure we are using content comparison with shallow=False.
1226

1227
                fcomp = filecmp.cmpfiles(self.left, self.right, self.common_files, shallow=False)
1✔
1228
                self.same_files, self.diff_files, self.funny_files = fcomp
1✔
1229

1230
        def phase4(self) -> None:  # noqa: D102
1✔
1231
                # Find out differences between common subdirectories
1232

1233
                # From https://github.com/python/cpython/pull/23424
1234

1235
                self.subdirs = {}
1✔
1236

1237
                for x in self.common_dirs:
1✔
1238
                        a_x = os.path.join(self.left, x)
1✔
1239
                        b_x = os.path.join(self.right, x)
1✔
1240
                        self.subdirs[x] = self.__class__(a_x, b_x, self.ignore, self.hide)
1✔
1241

1242
        _methodmap = {
1✔
1243
                        "subdirs": phase4,
1244
                        "same_files": phase3,
1245
                        "diff_files": phase3,
1246
                        "funny_files": phase3,
1247
                        "common_dirs": filecmp.dircmp.phase2,
1248
                        "common_files": filecmp.dircmp.phase2,
1249
                        "common_funny": filecmp.dircmp.phase2,
1250
                        "common": filecmp.dircmp.phase1,
1251
                        "left_only": filecmp.dircmp.phase1,
1252
                        "right_only": filecmp.dircmp.phase1,
1253
                        "left_list": filecmp.dircmp.phase0,
1254
                        "right_list": filecmp.dircmp.phase0
1255
                        }
1256

1257
        methodmap = _methodmap  # type: ignore
1✔
1258

1259

1260
def compare_dirs(a: PathLike, b: PathLike) -> bool:
1✔
1261
        """
1262
        Compare the content of two directory trees.
1263

1264
        .. versionadded:: 2.7.0
1265

1266
        :param a: The "left" directory to compare.
1267
        :param b: The "right" directory to compare.
1268

1269
        :returns: :py:obj:`False` if they differ, :py:obj:`True` is they are the same.
1270
        """
1271

1272
        compared = DirComparator(a, b)
1✔
1273

1274
        if compared.left_only or compared.right_only or compared.diff_files or compared.funny_files:
1✔
1275
                return False
1✔
1276

1277
        for subdir in compared.common_dirs:
×
1278
                if not compare_dirs(os.path.join(a, subdir), os.path.join(b, subdir)):
×
1279
                        return False
×
1280

1281
        return True
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc