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

domdfcoding / domdf_python_tools / 20762967711

06 Jan 2026 09:41PM UTC coverage: 97.278% (+0.003%) from 97.275%
20762967711

push

github

domdfcoding
Lint

39 of 39 new or added lines in 9 files covered. (100.0%)

19 existing lines in 1 file now uncovered.

2144 of 2204 relevant lines covered (97.28%)

0.97 hits per line

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

93.81
/domdf_python_tools/paths.py
1
#!/usr/bin/env python
2
#
3
#  paths.py
4
"""
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
import warnings
1✔
60
from collections import defaultdict, deque
1✔
61
from operator import methodcaller
1✔
62
from typing import (
1✔
63
                IO,
64
                Any,
65
                Callable,
66
                ContextManager,
67
                Dict,
68
                Iterable,
69
                Iterator,
70
                List,
71
                Optional,
72
                Sequence,
73
                Type,
74
                TypeVar,
75
                Union
76
                )
77

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

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

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

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

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

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

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

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

145

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

150
        .. TODO:: make this the file in the given directory, by default the current directory
151

152
        :param var: The value to append to the file
153
        :param filename: The file to append to
154
        """
155

156
        kwargs.setdefault("encoding", "UTF-8")
1✔
157

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

161

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

171
        Based on https://stackoverflow.com/a/12514470 by https://stackoverflow.com/users/23252/atzz
172

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

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

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

205
        return dst
1✔
206

207

208
def delete(filename: PathLike, **kwargs):  # noqa: PRM002
1✔
209
        """
210
        Delete the file in the current directory.
211

212
        .. TODO:: make this the file in the given directory, by default the current directory
213

214
        :param filename: The file to delete
215
        """
216

217
        os.remove(os.path.join(os.getcwd(), filename), **kwargs)
1✔
218

219

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

224
        .. attention::
225

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

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

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

238
        """
239

240
        if not isinstance(directory, pathlib.Path):
1✔
241
                directory = pathlib.Path(directory)
1✔
242

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

248

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

253
        :param path: Path to find the parent for
254

255
        :return: The parent directory
256
        """
257

258
        if not isinstance(path, pathlib.Path):
1✔
259
                path = pathlib.Path(path)
1✔
260

261
        return path.parent
1✔
262

263

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

268
        .. TODO:: make this the file in the given directory, by default the current directory
269

270
        :param filename: The file to read from.
271

272
        :return: The contents of the file.
273
        """
274

275
        kwargs.setdefault("encoding", "UTF-8")
1✔
276

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

280

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

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

292
        if not isinstance(path, pathlib.Path):
1✔
293
                path = pathlib.Path(path)
1✔
294

295
        abs_path = path.absolute()
1✔
296

297
        if relative_to is None:
1✔
298
                relative_to = pathlib.Path().absolute()
1✔
299

300
        if not isinstance(relative_to, pathlib.Path):
1✔
301
                relative_to = pathlib.Path(relative_to)
1✔
302

303
        relative_to = relative_to.absolute()
1✔
304

305
        try:
1✔
306
                return abs_path.relative_to(relative_to)
1✔
307
        except ValueError:
1✔
308
                return abs_path
1✔
309

310

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

315
        .. TODO:: make this the file in the given directory, by default the current directory
316

317
        :param var: The value to write to the file.
318
        :param filename: The file to write to.
319
        """
320

321
        kwargs.setdefault("encoding", "UTF-8")
1✔
322

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

326

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

331
        :param string:
332
        :param fp:
333
        """
334

335
        # this package
336
        from domdf_python_tools.stringlist import StringList
1✔
337

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

342

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

347
        :param filename:
348
        """
349

350
        if not isinstance(filename, pathlib.Path):
1✔
351
                filename = pathlib.Path(filename)
1✔
352

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

356

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

363
        :param directory:
364
        """  # noqa: D400
365

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

373

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

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

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

389
        __slots__ = ()
1✔
390

391
        if sys.version_info < (3, 11):
1✔
392
                _accessor = pathlib._normal_accessor  # type: ignore[attr-defined]
1✔
393
        _closed = False
1✔
394

395
        def _init(self, *args, **kwargs):
1✔
396
                pass
1✔
397

398
        @classmethod
1✔
399
        def _from_parts(cls, args, init=True):
1✔
400
                return super()._from_parts(args)  # type: ignore[misc]
1✔
401

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

406
                return super().__new__(cls, *args, **kwargs)
1✔
407

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

412
                .. versionadded:: 0.3.8
413
                """
414

415
                make_executable(self)
1✔
416

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

426
                .. versionadded:: 0.3.8
427

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

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

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

444
                .. versionadded:: 0.3.8
445

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

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

454
                .. attention::
455

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

459
                """
460

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

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

475
                .. versionadded:: 0.3.8
476

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

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

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

495
                .. versionadded:: 0.3.8
496

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

503
                .. versionchanged:: 3.1.0
504

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

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

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

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

526
                .. versionadded:: 0.5.0
527

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

533
                .. versionchanged:: 2.4.0  Added the ``trailing_whitespace`` option.
534
                """
535

536
                if isinstance(data, str):
1✔
537
                        warnings.warn(
1✔
538
                                        "Passing a string to PathPlus.write_lines writes each character to its own line.\n"
539
                                        "That probably isn't what you intended.",
540
                                        )
541

542
                if trailing_whitespace:
1✔
543
                        data = list(data)
1✔
544
                        if data[-1].strip():
1✔
545
                                data.append('')
1✔
546

547
                        self.write_text('\n'.join(data), encoding=encoding, errors=errors)
1✔
548
                else:
549
                        self.write_clean('\n'.join(data), encoding=encoding, errors=errors)
1✔
550

551
        def read_text(
1✔
552
                        self,
553
                        encoding: Optional[str] = "UTF-8",
554
                        errors: Optional[str] = None,
555
                        ) -> str:
556
                """
557
                Open the file in text mode, read it, and close the file.
558

559
                .. versionadded:: 0.3.8
560

561
                :param encoding: The encoding to write to the file in.
562
                :param errors:
563

564
                :return: The content of the file.
565
                """
566

567
                return super().read_text(encoding=encoding, errors=errors)
1✔
568

569
        def read_lines(
1✔
570
                        self,
571
                        encoding: Optional[str] = "UTF-8",
572
                        errors: Optional[str] = None,
573
                        ) -> List[str]:
574
                """
575
                Open the file in text mode, return a list containing the lines in the file,
576
                and close the file.
577

578
                .. versionadded:: 0.5.0
579

580
                :param encoding: The encoding to write to the file in.
581
                :param errors:
582

583
                :return: The content of the file.
584
                """  # noqa: D400
585

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

588
        def open(  # type: ignore  # noqa: A003  # pylint: disable=redefined-builtin
1✔
589
                self,
590
                mode: str = 'r',
591
                buffering: int = -1,
592
                encoding: Optional[str] = "UTF-8",
593
                errors: Optional[str] = None,
594
                newline: Optional[str] = NEWLINE_DEFAULT,
595
        ) -> IO[Any]:
596
                """
597
                Open the file pointed by this path and return a file object, as
598
                the built-in :func:`open` function does.
599

600
                .. versionadded:: 0.3.8
601

602
                :param mode: The mode to open the file in.
603
                :default mode: ``'r'`` (read only)
604
                :param buffering:
605
                :param encoding:
606
                :param errors:
607
                :param newline:
608
                :default newline: `universal newlines <https://docs.python.org/3/glossary.html#term-universal-newlines>`__ for reading, Unix line endings (``LF``) for writing.
609

610
                :rtype:
611

612
                .. versionchanged:: 0.5.1
613

614
                        Defaults to Unix line endings (``LF``) on all platforms.
615
                """  # noqa: D400
616

617
                if 'b' in mode:
1✔
618
                        encoding = None
1✔
619
                        newline = None
1✔
620

621
                if newline is NEWLINE_DEFAULT:
1✔
622
                        if 'r' in mode:
1✔
623
                                newline = None
1✔
624
                        else:
625
                                newline = '\n'
1✔
626

627
                return super().open(
1✔
628
                                mode,
629
                                buffering=buffering,
630
                                encoding=encoding,
631
                                errors=errors,
632
                                newline=newline,
633
                                )
634

635
        def dump_json(
1✔
636
                        self,
637
                        data: Any,
638
                        encoding: Optional[str] = "UTF-8",
639
                        errors: Optional[str] = None,
640
                        json_library: JsonLibrary = json,  # type: ignore[assignment]
641
                        *,
642
                        compress: bool = False,
643
                        **kwargs,
644
                        ) -> None:
645
                r"""
646
                Dump ``data`` to the file as JSON.
647

648
                .. versionadded:: 0.5.0
649

650
                :param data: The object to serialise to JSON.
651
                :param encoding: The encoding to write to the file in.
652
                :param errors:
653
                :param json_library: The JSON serialisation library to use.
654
                :default json_library: :mod:`json`
655
                :param compress: Whether to compress the JSON file using gzip.
656
                :param \*\*kwargs: Keyword arguments to pass to the JSON serialisation function.
657

658
                :rtype:
659

660
                .. versionchanged:: 1.0.0
661

662
                        Now uses :meth:`PathPlus.write_clean <domdf_python_tools.paths.PathPlus.write_clean>`
663
                        rather than :meth:`PathPlus.write_text <domdf_python_tools.paths.PathPlus.write_text>`,
664
                        and as a result returns :py:obj:`None` rather than :class:`int`.
665

666
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
667
                """
668

669
                if compress:
1✔
670
                        with gzip.open(self, mode="wt", encoding=encoding, errors=errors) as fp:
1✔
671
                                fp.write(json_library.dumps(data, **kwargs))
1✔
672

673
                else:
674
                        self.write_clean(
1✔
675
                                        json_library.dumps(data, **kwargs),
676
                                        encoding=encoding,
677
                                        errors=errors,
678
                                        )
679

680
        def load_json(
1✔
681
                        self,
682
                        encoding: Optional[str] = "UTF-8",
683
                        errors: Optional[str] = None,
684
                        json_library: JsonLibrary = json,  # type: ignore[assignment]
685
                        *,
686
                        decompress: bool = False,
687
                        **kwargs,
688
                        ) -> Any:
689
                r"""
690
                Load JSON data from the file.
691

692
                .. versionadded:: 0.5.0
693

694
                :param encoding: The encoding to write to the file in.
695
                :param errors:
696
                :param json_library: The JSON serialisation library to use.
697
                :default json_library: :mod:`json`
698
                :param decompress: Whether to decompress the JSON file using gzip.
699
                        Will raise an exception if the file is not compressed.
700
                :param \*\*kwargs: Keyword arguments to pass to the JSON deserialisation function.
701

702
                :return: The deserialised JSON data.
703

704
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
705
                """
706

707
                if decompress:
1✔
708
                        with gzip.open(self, mode="rt", encoding=encoding, errors=errors) as fp:
1✔
709
                                content = fp.read()
1✔
710
                else:
711
                        content = self.read_text(encoding=encoding, errors=errors)
1✔
712

713
                return json_library.loads(
1✔
714
                                content,
715
                                **kwargs,
716
                                )
717

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

720
                def is_mount(self) -> bool:
1✔
721
                        """
722
                        Check if this path is a POSIX mount point.
723

724
                        .. versionadded:: 0.3.8 for Python 3.7 and above
725
                        .. versionadded:: 0.11.0 for Python 3.6
726
                        """
727

728
                        # Need to exist and be a dir
729
                        if not self.exists() or not self.is_dir():
1✔
730
                                return False
1✔
731

732
                        # https://github.com/python/cpython/pull/18839/files
733
                        try:
1✔
734
                                parent_dev = self.parent.stat().st_dev
1✔
UNCOV
735
                        except OSError:
×
UNCOV
736
                                return False
×
737

738
                        dev = self.stat().st_dev
1✔
739
                        if dev != parent_dev:
1✔
UNCOV
740
                                return True
×
741
                        ino = self.stat().st_ino
1✔
742
                        parent_ino = self.parent.stat().st_ino
1✔
743
                        return ino == parent_ino
1✔
744

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

747
                def rename(self: _P, target: Union[str, pathlib.PurePath]) -> _P:
748
                        """
749
                        Rename this path to the target path.
750

751
                        The target path may be absolute or relative. Relative paths are
752
                        interpreted relative to the current working directory, *not* the
753
                        directory of the Path object.
754

755
                        .. versionadded:: 0.3.8 for Python 3.8 and above
756
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
757

758
                        :param target:
759

760
                        :returns: The new Path instance pointing to the target path.
761
                        """
762

763
                        os.rename(self, target)
764
                        return self.__class__(target)
765

766
                def replace(self: _P, target: Union[str, pathlib.PurePath]) -> _P:
767
                        """
768
                        Rename this path to the target path, overwriting if that path exists.
769

770
                        The target path may be absolute or relative. Relative paths are
771
                        interpreted relative to the current working directory, *not* the
772
                        directory of the Path object.
773

774
                        Returns the new Path instance pointing to the target path.
775

776
                        .. versionadded:: 0.3.8 for Python 3.8 and above
777
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
778

779
                        :param target:
780

781
                        :returns: The new Path instance pointing to the target path.
782
                        """
783

784
                        os.replace(self, target)
785
                        return self.__class__(target)
786

787
                def unlink(self, missing_ok: bool = False) -> None:
788
                        """
789
                        Remove this file or link.
790

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

793
                        :param missing_ok:
794

795
                        .. versionadded:: 0.3.8 for Python 3.8 and above
796
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
797
                        """
798

799
                        try:
800
                                os.unlink(self)
801
                        except FileNotFoundError:
802
                                if not missing_ok:
803
                                        raise
804

805
        def __enter__(self):
1✔
806
                return self
1✔
807

808
        def __exit__(self, t, v, tb):
1✔
809
                # https://bugs.python.org/issue39682
810
                # In previous versions of pathlib, this method marked this path as
811
                # closed; subsequent attempts to perform I/O would raise an IOError.
812
                # This functionality was never documented, and had the effect of
813
                # making Path objects mutable, contrary to PEP 428. In Python 3.9 the
814
                # _closed attribute was removed, and this method made a no-op.
815
                # This method and __enter__()/__exit__() should be deprecated and
816
                # removed in the future.
817
                pass
1✔
818

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

821
                def is_relative_to(self, *other: Union[str, os.PathLike]) -> bool:
1✔
822
                        r"""
823
                        Returns whether the path is relative to another path.
824

825
                        .. versionadded:: 0.3.8 for Python 3.9 and above.
826
                        .. latex:vspace:: -10px
827
                        .. versionadded:: 1.4.0 for Python 3.6 and Python 3.7.
828
                        .. latex:vspace:: -10px
829

830
                        :param \*other:
831

832
                        .. latex:vspace:: -20px
833

834
                        :rtype:
835

836
                        .. latex:vspace:: -20px
837
                        """
838

UNCOV
839
                        try:
×
UNCOV
840
                                self.relative_to(*other)
×
UNCOV
841
                                return True
×
UNCOV
842
                        except ValueError:
×
UNCOV
843
                                return False
×
844

845
        def abspath(self) -> "PathPlus":
1✔
846
                """
847
                Return the absolute version of the path.
848

849
                .. versionadded:: 1.3.0
850
                """
851

852
                return self.__class__(os.path.abspath(self))
1✔
853

854
        def iterchildren(
1✔
855
                        self: _PP,
856
                        exclude_dirs: Optional[Iterable[str]] = unwanted_dirs,
857
                        match: Optional[str] = None,
858
                        matchcase: bool = True,
859
                        ) -> Iterator[_PP]:
860
                """
861
                Returns an iterator over all children (files and directories) of the current path object.
862

863
                .. versionadded:: 2.3.0
864

865
                :param exclude_dirs: A list of directory names which should be excluded from the output,
866
                        together with their children.
867
                :param match: A pattern to match filenames against.
868
                        The pattern should be in the format taken by :func:`~.matchglob`.
869
                :param matchcase: Whether the filename's case should match the pattern.
870

871
                :rtype:
872

873
                .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
874
                """
875

876
                if not self.abspath().is_dir():
1✔
UNCOV
877
                        return
×
878

879
                if exclude_dirs is None:
1✔
880
                        exclude_dirs = ()
1✔
881

882
                if match and not os.path.isabs(match) and self.is_absolute():
1✔
883
                        match = (self / match).as_posix()
1✔
884

885
                file: _PP
886
                for file in self.iterdir():
1✔
887
                        parts = file.parts
1✔
888
                        if any(d in parts for d in exclude_dirs):
1✔
889
                                continue
1✔
890

891
                        if match is None or (match is not None and matchglob(file, match, matchcase)):
1✔
892
                                yield file
1✔
893

894
                        if file.is_dir():
1✔
895
                                yield from file.iterchildren(exclude_dirs, match)
1✔
896

897
        @classmethod
1✔
898
        def from_uri(cls: Type[_PP], uri: str) -> _PP:
1✔
899
                """
900
                Construct a :class:`~.PathPlus` from a ``file`` URI returned by :meth:`pathlib.PurePath.as_uri`.
901

902
                .. versionadded:: 2.9.0
903

904
                :param uri:
905

906
                :rtype: :class:`~.PathPlus`
907
                """
908

909
                parseresult = urllib.parse.urlparse(uri)
1✔
910

911
                if parseresult.scheme != "file":
1✔
UNCOV
912
                        raise ValueError(f"Unsupported URI scheme {parseresult.scheme!r}")
×
913
                if parseresult.params or parseresult.query or parseresult.fragment:
1✔
UNCOV
914
                        raise ValueError("Malformed file URI")
×
915

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

918
                        if parseresult.netloc:
919
                                path = ''.join([
920
                                                "//",
921
                                                urllib.parse.unquote_to_bytes(parseresult.netloc).decode("UTF-8"),
922
                                                urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8"),
923
                                                ])
924
                        else:
925
                                path = urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8").lstrip('/')
926

927
                else:  # pragma: no cover (Windows)
928
                        if parseresult.netloc:
1✔
UNCOV
929
                                raise ValueError("Malformed file URI")
×
930

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

933
                return cls(path)
1✔
934

935
        def move(self: _PP, dst: PathLike) -> _PP:
1✔
936
                """
937
                Recursively move ``self`` to ``dst``.
938

939
                ``self`` may be a file or a directory.
940

941
                See :func:`shutil.move` for more details.
942

943
                .. versionadded:: 3.2.0
944

945
                :param dst:
946

947
                :returns: The new location of ``self``.
948
                :rtype: :class:`~.PathPlus`
949
                """
950

951
                new_path = shutil.move(os.fspath(self), dst)
1✔
952
                return self.__class__(new_path)
1✔
953

954
        def stream(self, chunk_size: int = 1024) -> Iterator[bytes]:
1✔
955
                """
956
                Stream the file in ``chunk_size`` sized chunks.
957

958
                :param chunk_size: The chunk size, in bytes
959

960
                .. versionadded:: 3.2.0
961
                """
962

963
                with self.open("rb") as fp:
1✔
964
                        while True:
965
                                chunk = fp.read(chunk_size)
1✔
966
                                if not chunk:
1✔
967
                                        break
1✔
968
                                yield chunk
1✔
969

970

971
class PosixPathPlus(PathPlus, pathlib.PurePosixPath):
1✔
972
        """
973
        :class:`~.PathPlus` subclass for non-Windows systems.
974

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

977
        .. versionadded:: 0.3.8
978
        """
979

980
        __slots__ = ()
1✔
981

982

983
class WindowsPathPlus(PathPlus, pathlib.PureWindowsPath):
1✔
984
        """
985
        :class:`~.PathPlus` subclass for Windows systems.
986

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

989
        .. versionadded:: 0.3.8
990

991
        .. autoclasssumm:: WindowsPathPlus
992
                :autosummary-sections: ;;
993

994
        The following methods are unsupported on Windows:
995

996
        * :meth:`~pathlib.Path.group`
997
        * :meth:`~pathlib.Path.is_mount`
998
        * :meth:`~pathlib.Path.owner`
999
        """
1000

1001
        __slots__ = ()
1✔
1002

1003
        def owner(self):  # pragma: no cover
1004
                """
1005
                Unsupported on Windows.
1006
                """
1007

1008
                raise NotImplementedError("Path.owner() is unsupported on this system")
1009

1010
        def group(self):  # pragma: no cover
1011
                """
1012
                Unsupported on Windows.
1013
                """
1014

1015
                raise NotImplementedError("Path.group() is unsupported on this system")
1016

1017
        def is_mount(self):  # pragma: no cover
1018
                """
1019
                Unsupported on Windows.
1020
                """
1021

1022
                raise NotImplementedError("Path.is_mount() is unsupported on this system")
1023

1024

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

1029
        .. versionadded:: 1.7.0
1030

1031
        :param base_directory: The directory to start searching from
1032
        :param \*filename: The filename(s) to search for
1033
        :param height: The maximum height to traverse to.
1034
        """
1035

1036
        if not filename:
1✔
1037
                raise TypeError("traverse_to_file expected 2 or more arguments, got 1")
1✔
1038

1039
        for level, directory in enumerate((base_directory, *base_directory.parents)):
1✔
1040
                if height > 0 and ((level - 1) > height):
1✔
UNCOV
1041
                        break
×
1042

1043
                for file in filename:
1✔
1044
                        if (directory / file).is_file():
1✔
1045
                                return directory
1✔
1046

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

1049

1050
def matchglob(filename: PathLike, pattern: str, matchcase: bool = True) -> bool:
1✔
1051
        """
1052
        Given a filename and a glob pattern, return whether the filename matches the glob.
1053

1054
        .. versionadded:: 2.3.0
1055

1056
        :param filename:
1057
        :param pattern: A pattern structured like a filesystem path, where each element consists of the glob syntax.
1058
                Each element is matched by :mod:`fnmatch`.
1059
                The special element ``**`` matches zero or more files or directories.
1060
        :param matchcase: Whether the filename's case should match the pattern.
1061

1062
        :rtype:
1063

1064
        .. seealso:: :wikipedia:`Glob (programming)#Syntax` on Wikipedia
1065
        .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
1066
        """
1067

1068
        match_func = fnmatch.fnmatchcase if matchcase else fnmatch.fnmatch
1✔
1069

1070
        filename = PathPlus(filename)
1✔
1071

1072
        pattern_parts = deque(pathlib.PurePath(pattern).parts)
1✔
1073
        filename_parts = deque(filename.parts)
1✔
1074

1075
        if not pattern_parts[-1]:
1✔
UNCOV
1076
                pattern_parts.pop()
×
1077

1078
        while True:
1079
                if not pattern_parts and not filename_parts:
1✔
1080
                        return True
1✔
1081
                elif not pattern_parts and filename_parts:
1✔
1082
                        # Pattern exhausted but still filename elements
1083
                        return False
1✔
1084

1085
                pattern_part = pattern_parts.popleft()
1✔
1086

1087
                if pattern_part == "**" and not filename_parts:
1✔
1088
                        return True
1✔
1089
                else:
1090
                        filename_part = filename_parts.popleft()
1✔
1091

1092
                if pattern_part == "**":
1✔
1093
                        if not pattern_parts:
1✔
1094
                                return True
1✔
1095

1096
                        while pattern_part == "**":
1✔
1097
                                if not pattern_parts:
1✔
1098
                                        return True
1✔
1099

1100
                                pattern_part = pattern_parts.popleft()
1✔
1101

1102
                        if pattern_parts and not filename_parts:
1✔
1103
                                # Filename must match everything after **
UNCOV
1104
                                return False
×
1105

1106
                        if match_func(filename_part, pattern_part):
1✔
1107
                                continue
1✔
1108
                        else:
1109
                                while not match_func(filename_part, pattern_part):
1✔
1110
                                        if not filename_parts:
1✔
1111
                                                return False
1✔
1112

1113
                                        filename_part = filename_parts.popleft()
1✔
1114

1115
                elif match_func(filename_part, pattern_part):
1✔
1116
                        continue
1✔
1117
                else:
1118
                        return False
1✔
1119

1120

1121
class TemporaryPathPlus(tempfile.TemporaryDirectory):
1✔
1122
        """
1123
        Securely creates a temporary directory using the same rules as :func:`tempfile.mkdtemp`.
1124
        The resulting object can be used as a context manager.
1125
        On completion of the context or destruction of the object
1126
        the newly created temporary directory and all its contents are removed from the filesystem.
1127

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

1130
        :param suffix: A str suffix for the directory name.
1131
        :param prefix: A str prefix for the directory name.
1132
        :param dir: A directory to create this temp dir in.
1133

1134
        .. versionadded:: 2.4.0
1135
        .. autosummary-widths:: 6/16
1136
        """
1137

1138
        name: PathPlus
1✔
1139
        """
1140
        The temporary directory itself.
1141

1142
        This will be assigned to the target of the :keyword:`as` clause if the :class:`~.TemporaryPathPlus`
1143
        is used as a context manager.
1144
        """
1145

1146
        def __init__(
1✔
1147
                        self,
1148
                        suffix: Optional[str] = None,
1149
                        prefix: Optional[str] = None,
1150
                        dir: Optional[PathLike] = None,  # noqa: A002  # pylint: disable=redefined-builtin
1151
                        ) -> None:
1152

1153
                super().__init__(suffix, prefix, dir)
1✔
1154
                self.name = PathPlus(self.name)
1✔
1155

1156
        def cleanup(self) -> None:
1✔
1157
                """
1158
                Cleanup the temporary directory by removing it and its contents.
1159

1160
                If the :class:`~.TemporaryPathPlus` is used as a context manager
1161
                this is called when leaving the :keyword:`with` block.
1162
                """
1163

1164
                context: ContextManager
1165

1166
                if sys.platform == "win32":  # pragma: no cover (!Windows)
1167
                        context = contextlib.suppress(PermissionError, NotADirectoryError)
1168
                else:  # pragma: no cover (Windows)
1169
                        context = nullcontext()
1✔
1170

1171
                with context:
1✔
1172
                        super().cleanup()
1✔
1173

1174
        def __enter__(self) -> PathPlus:
1✔
1175
                return self.name
1✔
1176

1177

1178
def sort_paths(*paths: PathLike) -> List[PathPlus]:
1✔
1179
        r"""
1180
        Sort the ``paths`` by directory, then by file.
1181

1182
        .. versionadded:: 2.6.0
1183

1184
        :param \*paths:
1185
        """
1186

1187
        directories: Dict[str, List[PathPlus]] = defaultdict(list)
1✔
1188
        local_contents: List[PathPlus] = []
1✔
1189
        files: List[PathPlus] = []
1✔
1190

1191
        for obj in [PathPlus(path) for path in paths]:
1✔
1192
                if len(obj.parts) > 1:
1✔
1193
                        key = obj.parts[0]
1✔
1194
                        directories[key].append(obj)
1✔
1195
                else:
1196
                        local_contents.append(obj)
1✔
1197

1198
        # sort directories
1199
        directories = {directory: directories[directory] for directory in sorted(directories.keys())}
1✔
1200

1201
        for directory, contents in directories.items():
1✔
1202
                contents = [path.relative_to(directory) for path in contents]
1✔
1203
                files.extend(PathPlus(directory) / path for path in sort_paths(*contents))
1✔
1204

1205
        return files + sorted(local_contents, key=methodcaller("as_posix"))
1✔
1206

1207

1208
class DirComparator(filecmp.dircmp):
1✔
1209
        r"""
1210
        Compare the content of ``a`` and ``a``.
1211

1212
        In contrast with :class:`filecmp.dircmp`, this
1213
        subclass compares the content of files with the same path.
1214

1215
        .. versionadded:: 2.7.0
1216

1217
        :param a: The "left" directory to compare.
1218
        :param b: The "right" directory to compare.
1219
        :param ignore: A list of names to ignore.
1220
        :default ignore: :py:obj:`filecmp.DEFAULT_IGNORES`
1221
        :param hide: A list of names to hide.
1222
        :default hide: ``[`` :py:obj:`os.curdir`, :py:obj:`os.pardir` ``]``
1223
        """
1224

1225
        # From https://stackoverflow.com/a/24860799, public domain.
1226
        # Thanks Philippe
1227

1228
        def __init__(
1✔
1229
                        self,
1230
                        a: PathLike,
1231
                        b: PathLike,
1232
                        ignore: Optional[Sequence[str]] = None,
1233
                        hide: Optional[Sequence[str]] = None,
1234
                        ):
1235
                super().__init__(a, b, ignore=ignore, hide=hide)
1✔
1236

1237
        def phase3(self) -> None:  # noqa: D102
1✔
1238
                # Find out differences between common files.
1239
                # Ensure we are using content comparison with shallow=False.
1240

1241
                fcomp = filecmp.cmpfiles(self.left, self.right, self.common_files, shallow=False)
1✔
1242
                self.same_files, self.diff_files, self.funny_files = fcomp
1✔
1243

1244
        def phase4(self) -> None:  # noqa: D102
1✔
1245
                # Find out differences between common subdirectories
1246

1247
                # From https://github.com/python/cpython/pull/23424
1248

1249
                self.subdirs = {}
1✔
1250

1251
                for x in self.common_dirs:
1✔
1252
                        a_x = os.path.join(self.left, x)
1✔
1253
                        b_x = os.path.join(self.right, x)
1✔
1254
                        self.subdirs[x] = self.__class__(a_x, b_x, self.ignore, self.hide)
1✔
1255

1256
        _methodmap = {
1✔
1257
                        "subdirs": phase4,
1258
                        "same_files": phase3,
1259
                        "diff_files": phase3,
1260
                        "funny_files": phase3,
1261
                        "common_dirs": filecmp.dircmp.phase2,
1262
                        "common_files": filecmp.dircmp.phase2,
1263
                        "common_funny": filecmp.dircmp.phase2,
1264
                        "common": filecmp.dircmp.phase1,
1265
                        "left_only": filecmp.dircmp.phase1,
1266
                        "right_only": filecmp.dircmp.phase1,
1267
                        "left_list": filecmp.dircmp.phase0,
1268
                        "right_list": filecmp.dircmp.phase0,
1269
                        }
1270

1271
        methodmap = _methodmap  # type: ignore[assignment]
1✔
1272

1273

1274
def compare_dirs(a: PathLike, b: PathLike) -> bool:
1✔
1275
        """
1276
        Compare the content of two directory trees.
1277

1278
        .. versionadded:: 2.7.0
1279

1280
        :param a: The "left" directory to compare.
1281
        :param b: The "right" directory to compare.
1282

1283
        :returns: :py:obj:`False` if they differ, :py:obj:`True` is they are the same.
1284
        """
1285

1286
        compared = DirComparator(a, b)
1✔
1287

1288
        if compared.left_only or compared.right_only or compared.diff_files or compared.funny_files:
1✔
1289
                return False
1✔
1290

UNCOV
1291
        for subdir in compared.common_dirs:
×
UNCOV
1292
                if not compare_dirs(os.path.join(a, subdir), os.path.join(b, subdir)):
×
UNCOV
1293
                        return False
×
1294

UNCOV
1295
        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

© 2026 Coveralls, Inc