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

domdfcoding / domdf_python_tools / 30272993863

27 Jul 2026 02:00PM UTC coverage: 97.235% (-0.04%) from 97.278%
30272993863

push

github

domdfcoding
Emit warning if passing boolean as first argument to PathPlus.maybe_make

1 of 2 new or added lines in 1 file covered. (50.0%)

2145 of 2206 relevant lines covered (97.23%)

0.97 hits per line

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

93.53
/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
                if isinstance(mode, bool):
1✔
NEW
462
                        warnings.warn(
×
463
                                        f"Boolean {mode} passed as mode argument, which was probably unintended. You probably wanted 'parents={mode}'",
464
                                        )
465

466
                try:
1✔
467
                        self.mkdir(mode, parents, exist_ok=True)
1✔
468
                except FileExistsError:
1✔
469
                        pass
1✔
470

471
        def append_text(
1✔
472
                        self,
473
                        string: str,
474
                        encoding: Optional[str] = "UTF-8",
475
                        errors: Optional[str] = None,
476
                        ):
477
                """
478
                Open the file in text mode, append the given string to it, and close the file.
479

480
                .. versionadded:: 0.3.8
481

482
                :param string:
483
                :param encoding: The encoding to write to the file in.
484
                :param errors:
485
                """
486

487
                with self.open('a', encoding=encoding, errors=errors) as fp:
1✔
488
                        fp.write(string)
1✔
489

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

500
                .. versionadded:: 0.3.8
501

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

508
                .. versionchanged:: 3.1.0
509

510
                        Added the ``newline`` argument to match Python 3.10.
511
                        (see :github:pull:`22420 <python/cpython>`)
512
                """
513

514
                if not isinstance(data, str):
1✔
515
                        raise TypeError(f'data must be str, not {data.__class__.__name__}')
1✔
516

517
                with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
1✔
518
                        return f.write(data)
1✔
519

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

531
                .. versionadded:: 0.5.0
532

533
                :param data:
534
                :param encoding: The encoding to write to the file in.
535
                :param errors:
536
                :param trailing_whitespace: If :py:obj:`True` trailing whitespace is preserved.
537

538
                .. versionchanged:: 2.4.0  Added the ``trailing_whitespace`` option.
539
                """
540

541
                if isinstance(data, str):
1✔
542
                        warnings.warn(
1✔
543
                                        "Passing a string to PathPlus.write_lines writes each character to its own line.\n"
544
                                        "That probably isn't what you intended.",
545
                                        )
546

547
                if trailing_whitespace:
1✔
548
                        data = list(data)
1✔
549
                        if data[-1].strip():
1✔
550
                                data.append('')
1✔
551

552
                        self.write_text('\n'.join(data), encoding=encoding, errors=errors)
1✔
553
                else:
554
                        self.write_clean('\n'.join(data), encoding=encoding, errors=errors)
1✔
555

556
        def read_text(
1✔
557
                        self,
558
                        encoding: Optional[str] = "UTF-8",
559
                        errors: Optional[str] = None,
560
                        ) -> str:
561
                """
562
                Open the file in text mode, read it, and close the file.
563

564
                .. versionadded:: 0.3.8
565

566
                :param encoding: The encoding to write to the file in.
567
                :param errors:
568

569
                :return: The content of the file.
570
                """
571

572
                return super().read_text(encoding=encoding, errors=errors)
1✔
573

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

583
                .. versionadded:: 0.5.0
584

585
                :param encoding: The encoding to write to the file in.
586
                :param errors:
587

588
                :return: The content of the file.
589
                """  # noqa: D400
590

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

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

605
                .. versionadded:: 0.3.8
606

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

615
                :rtype:
616

617
                .. versionchanged:: 0.5.1
618

619
                        Defaults to Unix line endings (``LF``) on all platforms.
620
                """  # noqa: D400
621

622
                if 'b' in mode:
1✔
623
                        encoding = None
1✔
624
                        newline = None
1✔
625

626
                if newline is NEWLINE_DEFAULT:
1✔
627
                        if 'r' in mode:
1✔
628
                                newline = None
1✔
629
                        else:
630
                                newline = '\n'
1✔
631

632
                return super().open(
1✔
633
                                mode,
634
                                buffering=buffering,
635
                                encoding=encoding,
636
                                errors=errors,
637
                                newline=newline,
638
                                )
639

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

653
                .. versionadded:: 0.5.0
654

655
                :param data: The object to serialise to JSON.
656
                :param encoding: The encoding to write to the file in.
657
                :param errors:
658
                :param json_library: The JSON serialisation library to use.
659
                :default json_library: :mod:`json`
660
                :param compress: Whether to compress the JSON file using gzip.
661
                :param \*\*kwargs: Keyword arguments to pass to the JSON serialisation function.
662

663
                :rtype:
664

665
                .. versionchanged:: 1.0.0
666

667
                        Now uses :meth:`PathPlus.write_clean <domdf_python_tools.paths.PathPlus.write_clean>`
668
                        rather than :meth:`PathPlus.write_text <domdf_python_tools.paths.PathPlus.write_text>`,
669
                        and as a result returns :py:obj:`None` rather than :class:`int`.
670

671
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
672
                """
673

674
                if compress:
1✔
675
                        with gzip.open(self, mode="wt", encoding=encoding, errors=errors) as fp:
1✔
676
                                fp.write(json_library.dumps(data, **kwargs))
1✔
677

678
                else:
679
                        self.write_clean(
1✔
680
                                        json_library.dumps(data, **kwargs),
681
                                        encoding=encoding,
682
                                        errors=errors,
683
                                        )
684

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

697
                .. versionadded:: 0.5.0
698

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

707
                :return: The deserialised JSON data.
708

709
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
710
                """
711

712
                if decompress:
1✔
713
                        with gzip.open(self, mode="rt", encoding=encoding, errors=errors) as fp:
1✔
714
                                content = fp.read()
1✔
715
                else:
716
                        content = self.read_text(encoding=encoding, errors=errors)
1✔
717

718
                return json_library.loads(
1✔
719
                                content,
720
                                **kwargs,
721
                                )
722

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

725
                def is_mount(self) -> bool:
1✔
726
                        """
727
                        Check if this path is a POSIX mount point.
728

729
                        .. versionadded:: 0.3.8 for Python 3.7 and above
730
                        .. versionadded:: 0.11.0 for Python 3.6
731
                        """
732

733
                        # Need to exist and be a dir
734
                        if not self.exists() or not self.is_dir():
1✔
735
                                return False
1✔
736

737
                        # https://github.com/python/cpython/pull/18839/files
738
                        try:
1✔
739
                                parent_dev = self.parent.stat().st_dev
1✔
740
                        except OSError:
×
741
                                return False
×
742

743
                        dev = self.stat().st_dev
1✔
744
                        if dev != parent_dev:
1✔
745
                                return True
×
746
                        ino = self.stat().st_ino
1✔
747
                        parent_ino = self.parent.stat().st_ino
1✔
748
                        return ino == parent_ino
1✔
749

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

752
                def rename(self: _P, target: Union[str, pathlib.PurePath]) -> _P:
753
                        """
754
                        Rename this path to the target path.
755

756
                        The target path may be absolute or relative. Relative paths are
757
                        interpreted relative to the current working directory, *not* the
758
                        directory of the Path object.
759

760
                        .. versionadded:: 0.3.8 for Python 3.8 and above
761
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
762

763
                        :param target:
764

765
                        :returns: The new Path instance pointing to the target path.
766
                        """
767

768
                        os.rename(self, target)
769
                        return self.__class__(target)
770

771
                def replace(self: _P, target: Union[str, pathlib.PurePath]) -> _P:
772
                        """
773
                        Rename this path to the target path, overwriting if that path exists.
774

775
                        The target path may be absolute or relative. Relative paths are
776
                        interpreted relative to the current working directory, *not* the
777
                        directory of the Path object.
778

779
                        Returns the new Path instance pointing to the target path.
780

781
                        .. versionadded:: 0.3.8 for Python 3.8 and above
782
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
783

784
                        :param target:
785

786
                        :returns: The new Path instance pointing to the target path.
787
                        """
788

789
                        os.replace(self, target)
790
                        return self.__class__(target)
791

792
                def unlink(self, missing_ok: bool = False) -> None:
793
                        """
794
                        Remove this file or link.
795

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

798
                        :param missing_ok:
799

800
                        .. versionadded:: 0.3.8 for Python 3.8 and above
801
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
802
                        """
803

804
                        try:
805
                                os.unlink(self)
806
                        except FileNotFoundError:
807
                                if not missing_ok:
808
                                        raise
809

810
        def __enter__(self):
1✔
811
                return self
1✔
812

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

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

826
                def is_relative_to(self, *other: Union[str, os.PathLike]) -> bool:
1✔
827
                        r"""
828
                        Returns whether the path is relative to another path.
829

830
                        .. versionadded:: 0.3.8 for Python 3.9 and above.
831
                        .. latex:vspace:: -10px
832
                        .. versionadded:: 1.4.0 for Python 3.6 and Python 3.7.
833
                        .. latex:vspace:: -10px
834

835
                        :param \*other:
836

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

839
                        :rtype:
840

841
                        .. latex:vspace:: -20px
842
                        """
843

844
                        try:
×
845
                                self.relative_to(*other)
×
846
                                return True
×
847
                        except ValueError:
×
848
                                return False
×
849

850
        def abspath(self) -> "PathPlus":
1✔
851
                """
852
                Return the absolute version of the path.
853

854
                .. versionadded:: 1.3.0
855
                """
856

857
                return self.__class__(os.path.abspath(self))
1✔
858

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

868
                .. versionadded:: 2.3.0
869

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

876
                :rtype:
877

878
                .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
879
                """
880

881
                if not self.abspath().is_dir():
1✔
882
                        return
×
883

884
                if exclude_dirs is None:
1✔
885
                        exclude_dirs = ()
1✔
886

887
                if match and not os.path.isabs(match) and self.is_absolute():
1✔
888
                        match = (self / match).as_posix()
1✔
889

890
                file: _PP
891
                for file in self.iterdir():
1✔
892
                        parts = file.parts
1✔
893
                        if any(d in parts for d in exclude_dirs):
1✔
894
                                continue
1✔
895

896
                        if match is None or (match is not None and matchglob(file, match, matchcase)):
1✔
897
                                yield file
1✔
898

899
                        if file.is_dir():
1✔
900
                                yield from file.iterchildren(exclude_dirs, match)
1✔
901

902
        @classmethod
1✔
903
        def from_uri(cls: Type[_PP], uri: str) -> _PP:
1✔
904
                """
905
                Construct a :class:`~.PathPlus` from a ``file`` URI returned by :meth:`pathlib.PurePath.as_uri`.
906

907
                .. versionadded:: 2.9.0
908

909
                :param uri:
910

911
                :rtype: :class:`~.PathPlus`
912
                """
913

914
                parseresult = urllib.parse.urlparse(uri)
1✔
915

916
                if parseresult.scheme != "file":
1✔
917
                        raise ValueError(f"Unsupported URI scheme {parseresult.scheme!r}")
×
918
                if parseresult.params or parseresult.query or parseresult.fragment:
1✔
919
                        raise ValueError("Malformed file URI")
×
920

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

923
                        if parseresult.netloc:
924
                                path = ''.join([
925
                                                "//",
926
                                                urllib.parse.unquote_to_bytes(parseresult.netloc).decode("UTF-8"),
927
                                                urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8"),
928
                                                ])
929
                        else:
930
                                path = urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8").lstrip('/')
931

932
                else:  # pragma: no cover (Windows)
933
                        if parseresult.netloc:
1✔
934
                                raise ValueError("Malformed file URI")
×
935

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

938
                return cls(path)
1✔
939

940
        def move(self: _PP, dst: PathLike) -> _PP:
1✔
941
                """
942
                Recursively move ``self`` to ``dst``.
943

944
                ``self`` may be a file or a directory.
945

946
                See :func:`shutil.move` for more details.
947

948
                .. versionadded:: 3.2.0
949

950
                :param dst:
951

952
                :returns: The new location of ``self``.
953
                :rtype: :class:`~.PathPlus`
954
                """
955

956
                new_path = shutil.move(os.fspath(self), dst)
1✔
957
                return self.__class__(new_path)
1✔
958

959
        def stream(self, chunk_size: int = 1024) -> Iterator[bytes]:
1✔
960
                """
961
                Stream the file in ``chunk_size`` sized chunks.
962

963
                :param chunk_size: The chunk size, in bytes
964

965
                .. versionadded:: 3.2.0
966
                """
967

968
                with self.open("rb") as fp:
1✔
969
                        while True:
970
                                chunk = fp.read(chunk_size)
1✔
971
                                if not chunk:
1✔
972
                                        break
1✔
973
                                yield chunk
1✔
974

975

976
class PosixPathPlus(PathPlus, pathlib.PurePosixPath):
1✔
977
        """
978
        :class:`~.PathPlus` subclass for non-Windows systems.
979

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

982
        .. versionadded:: 0.3.8
983
        """
984

985
        __slots__ = ()
1✔
986

987

988
class WindowsPathPlus(PathPlus, pathlib.PureWindowsPath):
1✔
989
        """
990
        :class:`~.PathPlus` subclass for Windows systems.
991

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

994
        .. versionadded:: 0.3.8
995

996
        .. autoclasssumm:: WindowsPathPlus
997
                :autosummary-sections: ;;
998

999
        The following methods are unsupported on Windows:
1000

1001
        * :meth:`~pathlib.Path.group`
1002
        * :meth:`~pathlib.Path.is_mount`
1003
        * :meth:`~pathlib.Path.owner`
1004
        """
1005

1006
        __slots__ = ()
1✔
1007

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

1013
                raise NotImplementedError("Path.owner() is unsupported on this system")
1014

1015
        def group(self):  # pragma: no cover
1016
                """
1017
                Unsupported on Windows.
1018
                """
1019

1020
                raise NotImplementedError("Path.group() is unsupported on this system")
1021

1022
        def is_mount(self):  # pragma: no cover
1023
                """
1024
                Unsupported on Windows.
1025
                """
1026

1027
                raise NotImplementedError("Path.is_mount() is unsupported on this system")
1028

1029

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

1034
        .. versionadded:: 1.7.0
1035

1036
        :param base_directory: The directory to start searching from
1037
        :param \*filename: The filename(s) to search for
1038
        :param height: The maximum height to traverse to.
1039
        """
1040

1041
        if not filename:
1✔
1042
                raise TypeError("traverse_to_file expected 2 or more arguments, got 1")
1✔
1043

1044
        for level, directory in enumerate((base_directory, *base_directory.parents)):
1✔
1045
                if height > 0 and ((level - 1) > height):
1✔
1046
                        break
×
1047

1048
                for file in filename:
1✔
1049
                        if (directory / file).is_file():
1✔
1050
                                return directory
1✔
1051

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

1054

1055
def matchglob(filename: PathLike, pattern: str, matchcase: bool = True) -> bool:
1✔
1056
        """
1057
        Given a filename and a glob pattern, return whether the filename matches the glob.
1058

1059
        .. versionadded:: 2.3.0
1060

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

1067
        :rtype:
1068

1069
        .. seealso:: :wikipedia:`Glob (programming)#Syntax` on Wikipedia
1070
        .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
1071
        """
1072

1073
        match_func = fnmatch.fnmatchcase if matchcase else fnmatch.fnmatch
1✔
1074

1075
        filename = PathPlus(filename)
1✔
1076

1077
        pattern_parts = deque(pathlib.PurePath(pattern).parts)
1✔
1078
        filename_parts = deque(filename.parts)
1✔
1079

1080
        if not pattern_parts[-1]:
1✔
1081
                pattern_parts.pop()
×
1082

1083
        while True:
1084
                if not pattern_parts and not filename_parts:
1✔
1085
                        return True
1✔
1086
                elif not pattern_parts and filename_parts:
1✔
1087
                        # Pattern exhausted but still filename elements
1088
                        return False
1✔
1089

1090
                pattern_part = pattern_parts.popleft()
1✔
1091

1092
                if pattern_part == "**" and not filename_parts:
1✔
1093
                        return True
1✔
1094
                else:
1095
                        filename_part = filename_parts.popleft()
1✔
1096

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

1101
                        while pattern_part == "**":
1✔
1102
                                if not pattern_parts:
1✔
1103
                                        return True
1✔
1104

1105
                                pattern_part = pattern_parts.popleft()
1✔
1106

1107
                        if pattern_parts and not filename_parts:
1✔
1108
                                # Filename must match everything after **
1109
                                return False
×
1110

1111
                        if match_func(filename_part, pattern_part):
1✔
1112
                                continue
1✔
1113
                        else:
1114
                                while not match_func(filename_part, pattern_part):
1✔
1115
                                        if not filename_parts:
1✔
1116
                                                return False
1✔
1117

1118
                                        filename_part = filename_parts.popleft()
1✔
1119

1120
                elif match_func(filename_part, pattern_part):
1✔
1121
                        continue
1✔
1122
                else:
1123
                        return False
1✔
1124

1125

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

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

1135
        :param suffix: A str suffix for the directory name.
1136
        :param prefix: A str prefix for the directory name.
1137
        :param dir: A directory to create this temp dir in.
1138

1139
        .. versionadded:: 2.4.0
1140
        .. autosummary-widths:: 6/16
1141
        """
1142

1143
        name: PathPlus
1✔
1144
        """
1145
        The temporary directory itself.
1146

1147
        This will be assigned to the target of the :keyword:`as` clause if the :class:`~.TemporaryPathPlus`
1148
        is used as a context manager.
1149
        """
1150

1151
        def __init__(
1✔
1152
                        self,
1153
                        suffix: Optional[str] = None,
1154
                        prefix: Optional[str] = None,
1155
                        dir: Optional[PathLike] = None,  # noqa: A002  # pylint: disable=redefined-builtin
1156
                        ) -> None:
1157

1158
                super().__init__(suffix, prefix, dir)
1✔
1159
                self.name = PathPlus(self.name)
1✔
1160

1161
        def cleanup(self) -> None:
1✔
1162
                """
1163
                Cleanup the temporary directory by removing it and its contents.
1164

1165
                If the :class:`~.TemporaryPathPlus` is used as a context manager
1166
                this is called when leaving the :keyword:`with` block.
1167
                """
1168

1169
                context: ContextManager
1170

1171
                if sys.platform == "win32":  # pragma: no cover (!Windows)
1172
                        context = contextlib.suppress(PermissionError, NotADirectoryError)
1173
                else:  # pragma: no cover (Windows)
1174
                        context = nullcontext()
1✔
1175

1176
                with context:
1✔
1177
                        super().cleanup()
1✔
1178

1179
        def __enter__(self) -> PathPlus:
1✔
1180
                return self.name
1✔
1181

1182

1183
def sort_paths(*paths: PathLike) -> List[PathPlus]:
1✔
1184
        r"""
1185
        Sort the ``paths`` by directory, then by file.
1186

1187
        .. versionadded:: 2.6.0
1188

1189
        :param \*paths:
1190
        """
1191

1192
        directories: Dict[str, List[PathPlus]] = defaultdict(list)
1✔
1193
        local_contents: List[PathPlus] = []
1✔
1194
        files: List[PathPlus] = []
1✔
1195

1196
        for obj in [PathPlus(path) for path in paths]:
1✔
1197
                if len(obj.parts) > 1:
1✔
1198
                        key = obj.parts[0]
1✔
1199
                        directories[key].append(obj)
1✔
1200
                else:
1201
                        local_contents.append(obj)
1✔
1202

1203
        # sort directories
1204
        directories = {directory: directories[directory] for directory in sorted(directories.keys())}
1✔
1205

1206
        for directory, contents in directories.items():
1✔
1207
                contents = [path.relative_to(directory) for path in contents]
1✔
1208
                files.extend(PathPlus(directory) / path for path in sort_paths(*contents))
1✔
1209

1210
        return files + sorted(local_contents, key=methodcaller("as_posix"))
1✔
1211

1212

1213
class DirComparator(filecmp.dircmp):
1✔
1214
        r"""
1215
        Compare the content of ``a`` and ``a``.
1216

1217
        In contrast with :class:`filecmp.dircmp`, this
1218
        subclass compares the content of files with the same path.
1219

1220
        .. versionadded:: 2.7.0
1221

1222
        :param a: The "left" directory to compare.
1223
        :param b: The "right" directory to compare.
1224
        :param ignore: A list of names to ignore.
1225
        :default ignore: :py:obj:`filecmp.DEFAULT_IGNORES`
1226
        :param hide: A list of names to hide.
1227
        :default hide: ``[`` :py:obj:`os.curdir`, :py:obj:`os.pardir` ``]``
1228
        """
1229

1230
        # From https://stackoverflow.com/a/24860799, public domain.
1231
        # Thanks Philippe
1232

1233
        def __init__(
1✔
1234
                        self,
1235
                        a: PathLike,
1236
                        b: PathLike,
1237
                        ignore: Optional[Sequence[str]] = None,
1238
                        hide: Optional[Sequence[str]] = None,
1239
                        ):
1240
                super().__init__(a, b, ignore=ignore, hide=hide)
1✔
1241

1242
        def phase3(self) -> None:  # noqa: D102
1✔
1243
                # Find out differences between common files.
1244
                # Ensure we are using content comparison with shallow=False.
1245

1246
                fcomp = filecmp.cmpfiles(self.left, self.right, self.common_files, shallow=False)
1✔
1247
                self.same_files, self.diff_files, self.funny_files = fcomp
1✔
1248

1249
        def phase4(self) -> None:  # noqa: D102
1✔
1250
                # Find out differences between common subdirectories
1251

1252
                # From https://github.com/python/cpython/pull/23424
1253

1254
                self.subdirs = {}
1✔
1255

1256
                for x in self.common_dirs:
1✔
1257
                        a_x = os.path.join(self.left, x)
1✔
1258
                        b_x = os.path.join(self.right, x)
1✔
1259
                        self.subdirs[x] = self.__class__(a_x, b_x, self.ignore, self.hide)
1✔
1260

1261
        _methodmap = {
1✔
1262
                        "subdirs": phase4,
1263
                        "same_files": phase3,
1264
                        "diff_files": phase3,
1265
                        "funny_files": phase3,
1266
                        "common_dirs": filecmp.dircmp.phase2,
1267
                        "common_files": filecmp.dircmp.phase2,
1268
                        "common_funny": filecmp.dircmp.phase2,
1269
                        "common": filecmp.dircmp.phase1,
1270
                        "left_only": filecmp.dircmp.phase1,
1271
                        "right_only": filecmp.dircmp.phase1,
1272
                        "left_list": filecmp.dircmp.phase0,
1273
                        "right_list": filecmp.dircmp.phase0,
1274
                        }
1275

1276
        methodmap = _methodmap  # type: ignore[assignment]
1✔
1277

1278

1279
def compare_dirs(a: PathLike, b: PathLike) -> bool:
1✔
1280
        """
1281
        Compare the content of two directory trees.
1282

1283
        .. versionadded:: 2.7.0
1284

1285
        :param a: The "left" directory to compare.
1286
        :param b: The "right" directory to compare.
1287

1288
        :returns: :py:obj:`False` if they differ, :py:obj:`True` is they are the same.
1289
        """
1290

1291
        compared = DirComparator(a, b)
1✔
1292

1293
        if compared.left_only or compared.right_only or compared.diff_files or compared.funny_files:
1✔
1294
                return False
1✔
1295

1296
        for subdir in compared.common_dirs:
×
1297
                if not compare_dirs(os.path.join(a, subdir), os.path.join(b, subdir)):
×
1298
                        return False
×
1299

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

© 2026 Coveralls, Inc