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

domdfcoding / domdf_python_tools / 3732685666

pending completion
3732685666

push

github

Dominic Davis-Foster
Bump version v3.5.0 -> v3.5.1

2143 of 2200 relevant lines covered (97.41%)

0.97 hits per line

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

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

7
.. versionchanged:: 1.0.0
8

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

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

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

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

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

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

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

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

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

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

143

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

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

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

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

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

159

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

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

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

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

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

203
        return dst
1✔
204

205

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

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

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

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

217

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

222
        .. attention::
223

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

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

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

236
        """
237

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

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

246

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

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

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

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

259
        return path.parent
1✔
260

261

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

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

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

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

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

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

278

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

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

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

293
        abs_path = path.absolute()
1✔
294

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

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

301
        relative_to = relative_to.absolute()
1✔
302

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

308

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

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

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

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

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

324

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

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

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

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

340

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

345
        :param filename:
346
        """
347

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

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

354

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

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

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

371

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

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

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

387
        __slots__ = ()
1✔
388

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

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

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

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

404
                self = cls._from_parts(args, init=False)
1✔
405
                if not self._flavour.is_supported:
1✔
406
                        raise NotImplementedError(f"cannot instantiate {cls.__name__!r} on your system")
407

408
                self._init()
1✔
409
                return self
1✔
410

411
        def make_executable(self) -> None:
1✔
412
                """
413
                Make the file executable.
414

415
                .. versionadded:: 0.3.8
416
                """
417

418
                make_executable(self)
1✔
419

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

429
                .. versionadded:: 0.3.8
430

431
                :param string:
432
                :param encoding: The encoding to write to the file in.
433
                :param errors:
434
                """
435

436
                with self.open('w', encoding=encoding, errors=errors) as fp:
1✔
437
                        clean_writer(string, fp)
1✔
438

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

447
                .. versionadded:: 0.3.8
448

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

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

457
                .. attention::
458

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

462
                """
463

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

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

478
                .. versionadded:: 0.3.8
479

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

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

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

498
                .. versionadded:: 0.3.8
499

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

506
                .. versionchanged:: 3.1.0
507

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

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

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

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

529
                .. versionadded:: 0.5.0
530

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

536
                .. versionchanged:: 2.4.0  Added the ``trailing_whitespace`` option.
537
                """
538

539
                if trailing_whitespace:
1✔
540
                        data = list(data)
1✔
541
                        if data[-1].strip():
1✔
542
                                data.append('')
1✔
543

544
                        self.write_text('\n'.join(data), encoding=encoding, errors=errors)
1✔
545
                else:
546
                        self.write_clean('\n'.join(data), encoding=encoding, errors=errors)
1✔
547

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

556
                .. versionadded:: 0.3.8
557

558
                :param encoding: The encoding to write to the file in.
559
                :param errors:
560

561
                :return: The content of the file.
562
                """
563

564
                return super().read_text(encoding=encoding, errors=errors)
1✔
565

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

575
                .. versionadded:: 0.5.0
576

577
                :param encoding: The encoding to write to the file in.
578
                :param errors:
579

580
                :return: The content of the file.
581
                """  # noqa: D400
582

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

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

597
                .. versionadded:: 0.3.8
598

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

607
                :rtype:
608

609
                .. versionchanged:: 0.5.1
610

611
                        Defaults to Unix line endings (``LF``) on all platforms.
612
                """  # noqa: D400
613

614
                if 'b' in mode:
1✔
615
                        encoding = None
1✔
616
                        newline = None
1✔
617

618
                if newline is NEWLINE_DEFAULT:
1✔
619
                        if 'r' in mode:
1✔
620
                                newline = None
1✔
621
                        else:
622
                                newline = '\n'
1✔
623

624
                return super().open(
1✔
625
                                mode,
626
                                buffering=buffering,
627
                                encoding=encoding,
628
                                errors=errors,
629
                                newline=newline,
630
                                )
631

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

645
                .. versionadded:: 0.5.0
646

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

655
                :rtype:
656

657
                .. versionchanged:: 1.0.0
658

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

663
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
664
                """
665

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

670
                else:
671
                        self.write_clean(
1✔
672
                                        json_library.dumps(data, **kwargs),
673
                                        encoding=encoding,
674
                                        errors=errors,
675
                                        )
676

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

689
                .. versionadded:: 0.5.0
690

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

699
                :return: The deserialised JSON data.
700

701
                .. versionchanged:: 1.9.0  Added the ``compress`` keyword-only argument.
702
                """
703

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

710
                return json_library.loads(
1✔
711
                                content,
712
                                **kwargs,
713
                                )
714

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

717
                def is_mount(self) -> bool:
1✔
718
                        """
719
                        Check if this path is a POSIX mount point.
720

721
                        .. versionadded:: 0.3.8 for Python 3.7 and above
722
                        .. versionadded:: 0.11.0 for Python 3.6
723
                        """
724

725
                        # Need to exist and be a dir
726
                        if not self.exists() or not self.is_dir():
1✔
727
                                return False
1✔
728

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

735
                        dev = self.stat().st_dev
1✔
736
                        if dev != parent_dev:
1✔
737
                                return True
×
738
                        ino = self.stat().st_ino
1✔
739
                        parent_ino = self.parent.stat().st_ino
1✔
740
                        return ino == parent_ino
1✔
741

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

744
                def rename(self: _P, target: Union[str, pathlib.PurePath]) -> _P:  # type: ignore
745
                        """
746
                        Rename this path to the target path.
747

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

752
                        .. versionadded:: 0.3.8 for Python 3.8 and above
753
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
754

755
                        :param target:
756

757
                        :returns: The new Path instance pointing to the target path.
758
                        """
759

760
                        os.rename(self, target)
761
                        return self.__class__(target)
762

763
                def replace(self: _P, target: Union[str, pathlib.PurePath]) -> _P:  # type: ignore
764
                        """
765
                        Rename this path to the target path, overwriting if that path exists.
766

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

771
                        Returns the new Path instance pointing to the target path.
772

773
                        .. versionadded:: 0.3.8 for Python 3.8 and above
774
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
775

776
                        :param target:
777

778
                        :returns: The new Path instance pointing to the target path.
779
                        """
780

781
                        os.replace(self, target)
782
                        return self.__class__(target)
783

784
                def unlink(self, missing_ok: bool = False) -> None:
785
                        """
786
                        Remove this file or link.
787

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

790
                        .. versionadded:: 0.3.8 for Python 3.8 and above
791
                        .. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
792
                        """
793

794
                        try:
795
                                os.unlink(self)
796
                        except FileNotFoundError:
797
                                if not missing_ok:
798
                                        raise
799

800
        def __enter__(self):
1✔
801
                return self
1✔
802

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

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

816
                def is_relative_to(self, *other: Union[str, os.PathLike]) -> bool:
1✔
817
                        r"""
818
                        Returns whether the path is relative to another path.
819

820
                        .. versionadded:: 0.3.8 for Python 3.9 and above.
821
                        .. latex:vspace:: -10px
822
                        .. versionadded:: 1.4.0 for Python 3.6 and Python 3.7.
823
                        .. latex:vspace:: -10px
824

825
                        :param \*other:
826

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

829
                        :rtype:
830

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

834
                        try:
×
835
                                self.relative_to(*other)
×
836
                                return True
×
837
                        except ValueError:
×
838
                                return False
×
839

840
        def abspath(self) -> "PathPlus":
1✔
841
                """
842
                Return the absolute version of the path.
843

844
                .. versionadded:: 1.3.0
845
                """
846

847
                return self.__class__(os.path.abspath(self))
1✔
848

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

858
                .. versionadded:: 2.3.0
859

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

866
                :rtype:
867

868
                .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
869
                """
870

871
                if not self.abspath().is_dir():
1✔
872
                        return
×
873

874
                if exclude_dirs is None:
1✔
875
                        exclude_dirs = ()
1✔
876

877
                if match and not os.path.isabs(match) and self.is_absolute():
1✔
878
                        match = (self / match).as_posix()
1✔
879

880
                file: _PP
881
                for file in self.iterdir():
1✔
882
                        parts = file.parts
1✔
883
                        if any(d in parts for d in exclude_dirs):
1✔
884
                                continue
1✔
885

886
                        if match is None or (match is not None and matchglob(file, match, matchcase)):
1✔
887
                                yield file
1✔
888

889
                        if file.is_dir():
1✔
890
                                yield from file.iterchildren(exclude_dirs, match)
1✔
891

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

897
                .. versionadded:: 2.9.0
898

899
                :param uri:
900

901
                :rtype: :class:`~.PathPlus`
902
                """
903

904
                parseresult = urllib.parse.urlparse(uri)
1✔
905

906
                if parseresult.scheme != "file":
1✔
907
                        raise ValueError(f"Unsupported URI scheme {parseresult.scheme!r}")
×
908
                if parseresult.params or parseresult.query or parseresult.fragment:
1✔
909
                        raise ValueError("Malformed file URI")
×
910

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

913
                        if parseresult.netloc:
914
                                path = ''.join([
915
                                                "//",
916
                                                urllib.parse.unquote_to_bytes(parseresult.netloc).decode("UTF-8"),
917
                                                urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8"),
918
                                                ])
919
                        else:
920
                                path = urllib.parse.unquote_to_bytes(parseresult.path).decode("UTF-8").lstrip('/')
921

922
                else:  # pragma: no cover (Windows)
923
                        if parseresult.netloc:
1✔
924
                                raise ValueError("Malformed file URI")
×
925

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

928
                return cls(path)
1✔
929

930
        def move(self: _PP, dst: PathLike) -> _PP:
1✔
931
                """
932
                Recursively move ``self`` to ``dst``.
933

934
                ``self`` may be a file or a directory.
935

936
                See :func:`shutil.move` for more details.
937

938
                .. versionadded:: 3.2.0
939

940
                :param dst:
941

942
                :returns: The new location of ``self``.
943
                :rtype: :class:`~.PathPlus`
944
                """
945

946
                new_path = shutil.move(os.fspath(self), dst)
1✔
947
                return self.__class__(new_path)
1✔
948

949
        def stream(self, chunk_size: int = 1024) -> Iterator[bytes]:
1✔
950
                """
951
                Stream the file in ``chunk_size`` sized chunks.
952

953
                :param chunk_size: The chunk size, in bytes
954

955
                .. versionadded:: 3.2.0
956
                """
957

958
                with self.open("rb") as fp:
1✔
959
                        while True:
960
                                chunk = fp.read(chunk_size)
1✔
961
                                if not chunk:
1✔
962
                                        break
1✔
963
                                yield chunk
1✔
964

965

966
class PosixPathPlus(PathPlus, pathlib.PurePosixPath):
1✔
967
        """
968
        :class:`~.PathPlus` subclass for non-Windows systems.
969

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

972
        .. versionadded:: 0.3.8
973
        """
974

975
        __slots__ = ()
1✔
976

977

978
class WindowsPathPlus(PathPlus, pathlib.PureWindowsPath):
1✔
979
        """
980
        :class:`~.PathPlus` subclass for Windows systems.
981

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

984
        .. versionadded:: 0.3.8
985

986
        .. autoclasssumm:: WindowsPathPlus
987
                :autosummary-sections: ;;
988

989
        The following methods are unsupported on Windows:
990

991
        * :meth:`~pathlib.Path.group`
992
        * :meth:`~pathlib.Path.is_mount`
993
        * :meth:`~pathlib.Path.owner`
994
        """
995

996
        __slots__ = ()
1✔
997

998
        def owner(self):  # pragma: no cover
999
                """
1000
                Unsupported on Windows.
1001
                """
1002

1003
                raise NotImplementedError("Path.owner() is unsupported on this system")
1004

1005
        def group(self):  # pragma: no cover
1006
                """
1007
                Unsupported on Windows.
1008
                """
1009

1010
                raise NotImplementedError("Path.group() is unsupported on this system")
1011

1012
        def is_mount(self):  # pragma: no cover
1013
                """
1014
                Unsupported on Windows.
1015
                """
1016

1017
                raise NotImplementedError("Path.is_mount() is unsupported on this system")
1018

1019

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

1024
        .. versionadded:: 1.7.0
1025

1026
        :param base_directory: The directory to start searching from
1027
        :param \*filename: The filename(s) to search for
1028
        :param height: The maximum height to traverse to.
1029
        """
1030

1031
        if not filename:
1✔
1032
                raise TypeError("traverse_to_file expected 2 or more arguments, got 1")
1✔
1033

1034
        for level, directory in enumerate((base_directory, *base_directory.parents)):
1✔
1035
                if height > 0 and ((level - 1) > height):
1✔
1036
                        break
×
1037

1038
                for file in filename:
1✔
1039
                        if (directory / file).is_file():
1✔
1040
                                return directory
1✔
1041

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

1044

1045
def matchglob(filename: PathLike, pattern: str, matchcase: bool = True) -> bool:
1✔
1046
        """
1047
        Given a filename and a glob pattern, return whether the filename matches the glob.
1048

1049
        .. versionadded:: 2.3.0
1050

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

1057
        :rtype:
1058

1059
        .. seealso:: :wikipedia:`Glob (programming)#Syntax` on Wikipedia
1060
        .. versionchanged:: 2.5.0  Added the ``matchcase`` option.
1061
        """
1062

1063
        match_func = fnmatch.fnmatchcase if matchcase else fnmatch.fnmatch
1✔
1064

1065
        filename = PathPlus(filename)
1✔
1066

1067
        pattern_parts = deque(pathlib.PurePath(pattern).parts)
1✔
1068
        filename_parts = deque(filename.parts)
1✔
1069

1070
        if not pattern_parts[-1]:
1✔
1071
                pattern_parts.pop()
×
1072

1073
        while True:
1074
                if not pattern_parts and not filename_parts:
1✔
1075
                        return True
1✔
1076
                elif not pattern_parts and filename_parts:
1✔
1077
                        # Pattern exhausted but still filename elements
1078
                        return False
1✔
1079

1080
                pattern_part = pattern_parts.popleft()
1✔
1081

1082
                if pattern_part == "**" and not filename_parts:
1✔
1083
                        return True
1✔
1084
                else:
1085
                        filename_part = filename_parts.popleft()
1✔
1086

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

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

1095
                                pattern_part = pattern_parts.popleft()
1✔
1096

1097
                        if pattern_parts and not filename_parts:
1✔
1098
                                # Filename must match everything after **
1099
                                return False
×
1100

1101
                        if match_func(filename_part, pattern_part):
1✔
1102
                                continue
1✔
1103
                        else:
1104
                                while not match_func(filename_part, pattern_part):
1✔
1105
                                        if not filename_parts:
1✔
1106
                                                return False
1✔
1107

1108
                                        filename_part = filename_parts.popleft()
1✔
1109

1110
                elif match_func(filename_part, pattern_part):
1✔
1111
                        continue
1✔
1112
                else:
1113
                        return False
1✔
1114

1115

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

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

1125
        .. versionadded:: 2.4.0
1126
        .. autosummary-widths:: 6/16
1127
        """
1128

1129
        name: PathPlus
1✔
1130
        """
1131
        The temporary directory itself.
1132

1133
        This will be assigned to the target of the :keyword:`as` clause if the :class:`~.TemporaryPathPlus`
1134
        is used as a context manager.
1135
        """
1136

1137
        def __init__(
1✔
1138
                        self,
1139
                        suffix: Optional[str] = None,
1140
                        prefix: Optional[str] = None,
1141
                        dir: Optional[PathLike] = None,  # noqa: A002  # pylint: disable=redefined-builtin
1142
                        ) -> None:
1143

1144
                super().__init__(suffix, prefix, dir)
1✔
1145
                self.name = PathPlus(self.name)
1✔
1146

1147
        def cleanup(self) -> None:
1✔
1148
                """
1149
                Cleanup the temporary directory by removing it and its contents.
1150

1151
                If the :class:`~.TemporaryPathPlus` is used as a context manager
1152
                this is called when leaving the :keyword:`with` block.
1153
                """
1154

1155
                context: ContextManager
1156

1157
                if sys.platform == "win32":  # pragma: no cover (!Windows)
1158
                        context = contextlib.suppress(PermissionError, NotADirectoryError)
1159
                else:  # pragma: no cover (Windows)
1160
                        context = nullcontext()
1✔
1161

1162
                with context:
1✔
1163
                        super().cleanup()
1✔
1164

1165
        def __enter__(self) -> PathPlus:
1✔
1166
                return self.name
1✔
1167

1168

1169
def sort_paths(*paths: PathLike) -> List[PathPlus]:
1✔
1170
        """
1171
        Sort the ``paths`` by directory, then by file.
1172

1173
        .. versionadded:: 2.6.0
1174

1175
        :param paths:
1176
        """
1177

1178
        directories: Dict[str, List[PathPlus]] = defaultdict(list)
1✔
1179
        local_contents: List[PathPlus] = []
1✔
1180
        files: List[PathPlus] = []
1✔
1181

1182
        for obj in [PathPlus(path) for path in paths]:
1✔
1183
                if len(obj.parts) > 1:
1✔
1184
                        key = obj.parts[0]
1✔
1185
                        directories[key].append(obj)
1✔
1186
                else:
1187
                        local_contents.append(obj)
1✔
1188

1189
        # sort directories
1190
        directories = {directory: directories[directory] for directory in sorted(directories.keys())}
1✔
1191

1192
        for directory, contents in directories.items():
1✔
1193
                contents = [path.relative_to(directory) for path in contents]
1✔
1194
                files.extend(PathPlus(directory) / path for path in sort_paths(*contents))
1✔
1195

1196
        return files + sorted(local_contents, key=methodcaller("as_posix"))
1✔
1197

1198

1199
class DirComparator(filecmp.dircmp):
1✔
1200
        r"""
1201
        Compare the content of ``a`` and ``a``.
1202

1203
        In contrast with :class:`filecmp.dircmp`, this
1204
        subclass compares the content of files with the same path.
1205

1206
        .. versionadded:: 2.7.0
1207

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

1216
        # From https://stackoverflow.com/a/24860799, public domain.
1217
        # Thanks Philippe
1218

1219
        def __init__(
1✔
1220
                        self,
1221
                        a: PathLike,
1222
                        b: PathLike,
1223
                        ignore: Optional[Sequence[str]] = None,
1224
                        hide: Optional[Sequence[str]] = None,
1225
                        ):
1226
                super().__init__(a, b, ignore=ignore, hide=hide)
1✔
1227

1228
        def phase3(self) -> None:  # noqa: D102
1✔
1229
                # Find out differences between common files.
1230
                # Ensure we are using content comparison with shallow=False.
1231

1232
                fcomp = filecmp.cmpfiles(self.left, self.right, self.common_files, shallow=False)
1✔
1233
                self.same_files, self.diff_files, self.funny_files = fcomp
1✔
1234

1235
        def phase4(self) -> None:  # noqa: D102
1✔
1236
                # Find out differences between common subdirectories
1237

1238
                # From https://github.com/python/cpython/pull/23424
1239

1240
                self.subdirs = {}
1✔
1241

1242
                for x in self.common_dirs:
1✔
1243
                        a_x = os.path.join(self.left, x)
1✔
1244
                        b_x = os.path.join(self.right, x)
1✔
1245
                        self.subdirs[x] = self.__class__(a_x, b_x, self.ignore, self.hide)
1✔
1246

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

1262
        methodmap = _methodmap  # type: ignore
1✔
1263

1264

1265
def compare_dirs(a: PathLike, b: PathLike) -> bool:
1✔
1266
        """
1267
        Compare the content of two directory trees.
1268

1269
        .. versionadded:: 2.7.0
1270

1271
        :param a: The "left" directory to compare.
1272
        :param b: The "right" directory to compare.
1273

1274
        :returns: :py:obj:`False` if they differ, :py:obj:`True` is they are the same.
1275
        """
1276

1277
        compared = DirComparator(a, b)
1✔
1278

1279
        if compared.left_only or compared.right_only or compared.diff_files or compared.funny_files:
1✔
1280
                return False
1✔
1281

1282
        for subdir in compared.common_dirs:
×
1283
                if not compare_dirs(os.path.join(a, subdir), os.path.join(b, subdir)):
×
1284
                        return False
×
1285

1286
        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