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

domdfcoding / domdf_python_tools / 14915795784

08 May 2025 08:45PM UTC coverage: 97.313%. Remained the same
14915795784

push

github

web-flow
Updated files with 'repo_helper'. (#134)

Co-authored-by: repo-helper[bot] <74742576+repo-helper[bot]@users.noreply.github.com>

2137 of 2196 relevant lines covered (97.31%)

0.97 hits per line

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

93.75
/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
from collections import defaultdict, deque
1✔
60
from operator import methodcaller
1✔
61
from typing import (
1✔
62
                IO,
63
                Any,
64
                Callable,
65
                ContextManager,
66
                Dict,
67
                Iterable,
68
                Iterator,
69
                List,
70
                Optional,
71
                Sequence,
72
                Type,
73
                TypeVar,
74
                Union
75
                )
76

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

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

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

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

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

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

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

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

144

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

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

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

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

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

160

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

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

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

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

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

204
        return dst
1✔
205

206

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

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

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

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

218

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

223
        .. attention::
224

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

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

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

237
        """
238

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

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

247

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

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

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

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

260
        return path.parent
1✔
261

262

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

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

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

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

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

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

279

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

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

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

294
        abs_path = path.absolute()
1✔
295

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

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

302
        relative_to = relative_to.absolute()
1✔
303

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

309

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

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

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

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

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

325

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

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

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

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

341

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

346
        :param filename:
347
        """
348

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

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

355

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

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

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

372

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

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

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

388
        __slots__ = ()
1✔
389

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

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

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

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

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

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

411
                .. versionadded:: 0.3.8
412
                """
413

414
                make_executable(self)
1✔
415

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

425
                .. versionadded:: 0.3.8
426

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

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

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

443
                .. versionadded:: 0.3.8
444

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

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

453
                .. attention::
454

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

458
                """
459

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

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

474
                .. versionadded:: 0.3.8
475

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

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

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

494
                .. versionadded:: 0.3.8
495

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

502
                .. versionchanged:: 3.1.0
503

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

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

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

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

525
                .. versionadded:: 0.5.0
526

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

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

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

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

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

552
                .. versionadded:: 0.3.8
553

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

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

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

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

571
                .. versionadded:: 0.5.0
572

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

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

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

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

593
                .. versionadded:: 0.3.8
594

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

603
                :rtype:
604

605
                .. versionchanged:: 0.5.1
606

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

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

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

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

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

641
                .. versionadded:: 0.5.0
642

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

651
                :rtype:
652

653
                .. versionchanged:: 1.0.0
654

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

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

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

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

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

685
                .. versionadded:: 0.5.0
686

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

695
                :return: The deserialised JSON data.
696

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

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

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

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

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

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

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

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

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

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

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

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

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

751
                        :param target:
752

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

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

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

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

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

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

772
                        :param target:
773

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

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

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

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

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

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

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

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

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

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

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

821
                        :param \*other:
822

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

825
                        :rtype:
826

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

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

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

840
                .. versionadded:: 1.3.0
841
                """
842

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

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

854
                .. versionadded:: 2.3.0
855

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

862
                :rtype:
863

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

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

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

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

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

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

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

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

893
                .. versionadded:: 2.9.0
894

895
                :param uri:
896

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

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

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

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

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

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

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

924
                return cls(path)
1✔
925

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

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

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

934
                .. versionadded:: 3.2.0
935

936
                :param dst:
937

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

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

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

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

951
                .. versionadded:: 3.2.0
952
                """
953

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

961

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

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

968
        .. versionadded:: 0.3.8
969
        """
970

971
        __slots__ = ()
1✔
972

973

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

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

980
        .. versionadded:: 0.3.8
981

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

985
        The following methods are unsupported on Windows:
986

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

992
        __slots__ = ()
1✔
993

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

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

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

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

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

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

1015

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

1020
        .. versionadded:: 1.7.0
1021

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

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

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

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

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

1040

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

1045
        .. versionadded:: 2.3.0
1046

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

1053
        :rtype:
1054

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

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

1061
        filename = PathPlus(filename)
1✔
1062

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

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

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

1076
                pattern_part = pattern_parts.popleft()
1✔
1077

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

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

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

1091
                                pattern_part = pattern_parts.popleft()
1✔
1092

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

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

1104
                                        filename_part = filename_parts.popleft()
1✔
1105

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

1111

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

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

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

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

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

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

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

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

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

1151
                context: ContextManager
1152

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

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

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

1164

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

1169
        .. versionadded:: 2.6.0
1170

1171
        :param paths:
1172
        """
1173

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

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

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

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

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

1194

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

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

1202
        .. versionadded:: 2.7.0
1203

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

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

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

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

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

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

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

1236
                self.subdirs = {}
1✔
1237

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

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

1258
        methodmap = _methodmap  # type: ignore
1✔
1259

1260

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

1265
        .. versionadded:: 2.7.0
1266

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

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

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

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

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

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

© 2025 Coveralls, Inc