Skip to content

Core Path API

pathlib_next.path

Object-oriented filesystem paths.

This module provides classes to represent abstract paths and concrete paths with operations that have semantics appropriate for different operating systems.

FsPathLike

Bases: Protocol

Anything implementing __fspath__ -- registered with os.PathLike so os.fspath() and friends accept it.

Path

Bases: Pathname, Chmod, Stat, BinaryOpen

Base class for manipulating paths with I/O.

__init_subclass__(**kwargs)

Guarantee pathlib_next operation precedence in every subclass.

Concrete path classes are routinely built by mixing a pathlib class with this one -- our own LocalPath does it, and the documented downstream recipe (class X(PosixPathname, Path)) does it transitively. Python's MRO then resolves a name to whichever base declares it first, which for those classes can be pathlib rather than pathlib_next -- and which one wins changes with the interpreter version, because stdlib pathlib keeps gaining and changing methods (see _OPERATION_NAMES).

Rather than making every downstream implementer rediscover this and hand-write forwarding methods, re-assert our implementations here for any subclass that would otherwise inherit a non-pathlib_next one. A subclass (or an intermediate mixin) that defines the method itself is always left alone -- this only displaces implementations coming from outside this library.

Source code in src/pathlib_next/path.py
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
def __init_subclass__(cls, **kwargs):
    """Guarantee pathlib_next operation precedence in every subclass.

    Concrete path classes are routinely built by mixing a `pathlib`
    class with this one -- our own `LocalPath` does it, and the
    documented downstream recipe (`class X(PosixPathname, Path)`) does
    it transitively. Python's MRO then resolves a name to whichever
    base declares it first, which for those classes can be `pathlib`
    rather than `pathlib_next` -- and which one wins changes with the
    interpreter version, because stdlib `pathlib` keeps gaining and
    changing methods (see `_OPERATION_NAMES`).

    Rather than making every downstream implementer rediscover this and
    hand-write forwarding methods, re-assert our implementations here
    for any subclass that would otherwise inherit a non-pathlib_next
    one. A subclass (or an intermediate mixin) that defines the method
    *itself* is always left alone -- this only displaces implementations
    coming from outside this library.
    """
    super().__init_subclass__(**kwargs)
    for name in _OPERATION_NAMES:
        # A class that defines the operation in its OWN body is always
        # authoritative -- never displace a deliberate override (this
        # also covers `LocalPath.copy`/`move`'s explicit routing).
        if name in vars(cls):
            continue
        # Find which class in the MRO actually supplies the inherited
        # implementation. Checking the resolved function's `__module__`
        # is not enough: a downstream mixin may legitimately define the
        # method in its own module, and that must be honored too.
        owner = next((base for base in cls.__mro__[1:] if name in vars(base)), None)
        if owner is None:
            continue
        # Only stdlib `pathlib` is displaced. Anything else -- a
        # downstream mixin, a user base class, or pathlib_next itself --
        # is a deliberate implementation and is left alone. Matching on
        # stdlib specifically (rather than "not pathlib_next") is what
        # keeps this guard from hijacking third-party code.
        owner_module = getattr(owner, "__module__", "") or ""
        if owner_module != "pathlib" and not owner_module.startswith("pathlib."):
            continue
        ours = getattr(Path, name, None)
        if ours is None:
            continue
        setattr(cls, name, ours)
    for name in _LOCAL_COMPANION_NAMES:
        if not _is_stdlib_owner(cls, name):
            continue
        ours = _local_companion(cls, name)
        if ours is not None:
            setattr(cls, name, ours)

copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True, recursive=False, ignore_error=None, progress=None)

Copy this file's content to target.

follow_symlinks/preserve_metadata are named to match CPython 3.14's Path.copy() (added after this method); overwrite is our own extension (3.14 has no equivalent -- it always raises if the destination exists). preserve_metadata defaults to True here (unlike 3.14's False) to match this method's pre-existing behavior of always propagating st_mode; only st_mode is preserved, not timestamps/xattrs -- full metadata preservation is not implemented. ignore_error accepts a bool or a callable, matching Path.rm()'s bool-or-callable contract. True ignores every error; False and None (the default) fail on the first error. A callable is invoked as ignore_error(error) -- this call site's own arity -- and, as it always has here, is a notification hook: the error is suppressed regardless of what it returns, so handlers like errors.append (returning None) keep working. Only errors from child copies during a recursive=True copy are routed here.

progress, when given, is called as progress(path, bytes_copied, total_size) for every chunk written during each file copy (path is the source Path being streamed -- self for a single-file copy, or the relevant child during a recursive=True copy). bytes_copied increases monotonically per file and reaches total_size (or None if the size couldn't be determined) at the end of that file. Directories themselves don't get a progress call (only the files inside them do). With progress=None (the default), behavior is unchanged -- no per-chunk overhead. Native backend transfers that bypass the generic streaming copy (e.g. SftpPath's asyncssh concurrent fan-out) do not invoke progress; see docs/divergences.md's "Deliberate extensions" section.

Source code in src/pathlib_next/path.py
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
def copy(
    self,
    target: "Path | str",
    *,
    overwrite=False,
    follow_symlinks=True,
    preserve_metadata=True,
    recursive=False,
    ignore_error=None,
    progress: "_ty.Callable[[_ty.Self, int, _ty.Optional[int]], None]" = None,
):
    """Copy this file's content to `target`.

    `follow_symlinks`/`preserve_metadata` are named to match CPython
    3.14's Path.copy() (added after this method); `overwrite` is our
    own extension (3.14 has no equivalent -- it always raises if the
    destination exists). `preserve_metadata` defaults to True here
    (unlike 3.14's False) to match this method's pre-existing behavior
    of always propagating st_mode; only st_mode is preserved, not
    timestamps/xattrs -- full metadata preservation is not implemented.
    `ignore_error` accepts a bool or a callable, matching `Path.rm()`'s
    bool-or-callable contract. `True` ignores every error; `False` and
    `None` (the default) fail on the first error. A callable is invoked
    as `ignore_error(error)` -- this call site's own arity -- and, as it
    always has here, is a *notification* hook: the error is suppressed
    regardless of what it returns, so handlers like `errors.append`
    (returning None) keep working. Only errors from *child* copies
    during a `recursive=True` copy are routed here.

    `progress`, when given, is called as `progress(path, bytes_copied,
    total_size)` for every chunk written during each *file* copy (`path`
    is the source `Path` being streamed -- `self` for a single-file
    copy, or the relevant child during a `recursive=True` copy).
    `bytes_copied` increases monotonically per file and reaches
    `total_size` (or `None` if the size couldn't be determined) at the
    end of that file. Directories themselves don't get a progress call
    (only the files inside them do). With `progress=None` (the
    default), behavior is unchanged -- no per-chunk overhead. Native
    backend transfers that bypass the generic streaming copy (e.g.
    `SftpPath`'s asyncssh concurrent fan-out) do not invoke `progress`;
    see `docs/divergences.md`'s "Deliberate extensions" section.
    """
    if isinstance(target, str):
        target = self._coerce_target(target)
    src = self

    if not follow_symlinks and src.is_symlink():
        # pathlib 3.14: copy the link itself, not what it points at.
        # Copying the target's content (and chmod'ing it with the link's
        # own 0o777) silently defeated the flag.
        return src._copy_symlink(target, overwrite=overwrite)

    if recursive and src.is_dir():
        if _contains(src, target):
            # Checked before anything is created: the new directory would
            # be listed and copied into itself without end.
            raise OSError(
                _errno.EINVAL,
                "Cannot copy a directory into itself",
                str(target),
            )
        # Listed before the target exists, as shutil.copytree does.
        children = list(src.iterdir())
        if target.exists():
            if not target.is_dir():
                raise FileExistsError(target)
            if not overwrite:
                raise FileExistsError(target)
        else:
            target.mkdir()
        windows_target = _utils.is_windows_flavoured(target)
        for child in children:
            try:
                # The names come from a listing the destination does not
                # control (an archive, a remote index, an object-store
                # key), so one that is not a single component inside
                # `target` must never be joined onto it: on a Windows
                # target "C:x" joins to a drive-relative path outside it
                # entirely, and so does "a\\b". Reported through
                # `ignore_error` like any other per-child failure, not
                # silently skipped. `PathSyncer` and
                # `utils.unpack_archive()` apply the same rule per
                # destination; an archive listing keeps such names,
                # because they are ordinary filenames on POSIX.
                if not _utils.is_safe_child_name(
                    child.name, windows=windows_target
                ):
                    raise ValueError(
                        f"refusing unsafe child name {child.name!r} "
                        f"under {target}"
                    )
                child.copy(
                    target / child.name,
                    overwrite=overwrite,
                    follow_symlinks=follow_symlinks,
                    preserve_metadata=preserve_metadata,
                    recursive=True,
                    ignore_error=ignore_error,
                    progress=progress,
                )
            except Exception as e:
                # A callable stays a notify-and-suppress hook (its return
                # value was never consulted here, and callers such as
                # `errors.append` rely on that). Bools are new: True
                # suppresses, False/None raise -- matching rm()'s bool
                # semantics without changing the callable contract.
                if callable(ignore_error):
                    ignore_error(e)
                elif not ignore_error:
                    raise
        return

    if _same_file(src, target):
        raise OSError(
            _errno.EINVAL, "Source and target are the same file", str(target)
        )
    # stat(), not exists(): exists() reads a transient error (a 503, a
    # timeout) as "missing", and overwrite=False must never be decided by
    # a failure. Only FileNotFoundError means the target is absent.
    try:
        target.stat()
        target_exists = True
    except FileNotFoundError:
        target_exists = False
    if target_exists:
        if target.is_dir():
            raise IsADirectoryError(target)
        if not overwrite:
            raise FileExistsError(target)

    # Open the source before the target is touched at all: a missing or
    # unreadable source must leave an existing target intact and must not
    # leave a new empty one behind.
    with src.open("rb") as input:
        if target_exists:
            target.unlink()
        created = False
        try:
            with target.open("wb") as output:
                created = True
                BinaryOpen._copy_stream(
                    src,
                    input,
                    output,
                    progress=(
                        None
                        if progress is None
                        else lambda copied, total: progress(src, copied, total)
                    ),
                )
        except BaseException:
            # A half-written target is not a copy of anything; do not
            # leave it looking like one.
            if created:
                try:
                    target.unlink(missing_ok=True)
                except Exception:
                    pass
            raise

    if preserve_metadata:
        try:
            stat = src.stat(follow_symlinks=follow_symlinks)
            # Only a mode the source backend actually reported is
            # metadata. FileStat's placeholder (0o444 for a file) is not:
            # applying it made every copy from MemPath/HTTP/S3/... a
            # read-only file that a re-copy or re-sync could not replace.
            if getattr(stat, "mode_known", True) and stat.st_mode:
                # Permission bits only: the file-type bits of st_mode
                # (0o100000 for a regular file) are not a mode, and a
                # backend such as FTP's SITE CHMOD sends them verbatim.
                target.chmod(_stat.S_IMODE(stat.st_mode))
        except NotImplementedError:
            pass

glob(pattern, *, case_sensitive=None, include_hidden=True, recursive=None, dironly=None, recurse_symlinks=False, native=True, on_error=None, bound_loops=False)

Iterate over this subtree and yield all existing files (of any kind, including directories) matching the given relative pattern.

Pathlib semantics: hidden entries are matched (include_hidden=False filters them out and skips hidden directories), a trailing separator selects directories only, "" never descends into a directory symlink, and a trailing "" also selects files on Python 3.13+ (directories only before). A missing or non-directory parent selects nothing. An empty pattern raises ValueError, an absolute one glob.NonRelativePatternError (a NotImplementedError and a ValueError). recurse_symlinks=True is not supported.

pattern=None expands the pattern THIS PATH CARRIES (LocalPath("/etc/*.conf").glob(None)) instead of applying one to a directory: the path is split at its first wildcard and globbed from there. "" still raises, as pathlib does. Every keyword means what it does for a pattern argument -- recursive=None auto-enables recursion for a carried "**", and native= applies. include_hidden keeps THIS method's default (True); the module-level utils.glob.glob() this delegates to defaults it to False, stdlib-glob-style, so the two spellings differ on hidden names unless the keyword is passed.

native=True (the default) follows the running interpreter on the two rules pathlib changed mid-series: a trailing "/" is ignored before 3.11, and a component that merely contains "" ("a") raises ValueError before 3.13. native=False applies one rule on every version -- trailing "/" always selects directories only, "a**" is always a plain wildcard -- so a pattern answers the same everywhere, which is what a cross-backend or cross-version caller usually wants.

on_error(error) is called when a directory cannot be listed, the same contract as walk() and os.walk: raising from it propagates (an unreadable directory becomes an error), returning treats that directory as empty. Without it such a listing is skipped silently -- pathlib's behaviour, and the reason a config loader could not tell a missing layer from an unreadable one. error.filename names the directory even when the backend left it empty.

bound_loops=True descends a directory at most once per "**", keyed on (st_dev, st_ino): that bounds a Windows junction loop, which no symlink check can see (a junction reports is_symlink() == False, so recurse_symlinks=False does not help and pathlib itself loops until the recursion limit). A backend whose stat carries no identity is walked unbounded, as before.

A "" component auto-enables recursion. Pass recursive=False explicitly to treat "" as a plain "*" instead. Note for remote schemes (http/sftp): a recursive glob walks the whole remote subtree, one request/roundtrip per directory.

Source code in src/pathlib_next/path.py
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
def glob(
    self,
    pattern: str | _ty.Self | None,
    *,
    case_sensitive: bool = None,
    include_hidden: bool = True,
    recursive: bool = None,
    dironly: bool = None,
    recurse_symlinks: bool = False,
    native: bool = True,
    on_error: "_ty.Callable[[OSError], None]" = None,
    bound_loops: bool = False,
):
    """Iterate over this subtree and yield all existing files (of any
    kind, including directories) matching the given relative pattern.

    Pathlib semantics: hidden entries are matched (`include_hidden=False`
    filters them out and skips hidden directories), a trailing separator
    selects directories only, "**" never descends into a directory
    symlink, and a trailing "**" also selects files on Python 3.13+
    (directories only before). A missing or non-directory parent selects
    nothing. An empty pattern raises `ValueError`, an absolute one
    `glob.NonRelativePatternError` (a `NotImplementedError` and a
    `ValueError`). `recurse_symlinks=True` is not supported.

    `pattern=None` expands the pattern THIS PATH CARRIES
    (`LocalPath("/etc/*.conf").glob(None)`) instead of applying one to a
    directory: the path is split at its first wildcard and globbed from
    there. `""` still raises, as pathlib does. Every keyword means what
    it does for a pattern argument -- `recursive=None` auto-enables
    recursion for a carried "**", and `native=` applies. `include_hidden`
    keeps THIS method's default (True); the module-level
    `utils.glob.glob()` this delegates to defaults it to False,
    stdlib-`glob`-style, so the two spellings differ on hidden names
    unless the keyword is passed.

    `native=True` (the default) follows the running interpreter on the
    two rules pathlib changed mid-series: a trailing "/" is ignored
    before 3.11, and a component that merely contains "**" ("a**")
    raises `ValueError` before 3.13. `native=False` applies one rule on
    every version -- trailing "/" always selects directories only, "a**"
    is always a plain wildcard -- so a pattern answers the same
    everywhere, which is what a cross-backend or cross-version caller
    usually wants.

    `on_error(error)` is called when a directory cannot be listed, the
    same contract as `walk()` and `os.walk`: raising from it propagates
    (an unreadable directory becomes an error), returning treats that
    directory as empty. Without it such a listing is skipped silently --
    pathlib's behaviour, and the reason a config loader could not tell a
    missing layer from an unreadable one. `error.filename` names the
    directory even when the backend left it empty.

    `bound_loops=True` descends a directory at most once per "**",
    keyed on `(st_dev, st_ino)`: that bounds a Windows junction loop,
    which no symlink check can see (a junction reports
    `is_symlink() == False`, so `recurse_symlinks=False` does not help
    and pathlib itself loops until the recursion limit). A backend whose
    stat carries no identity is walked unbounded, as before.

    A "**" component auto-enables recursion. Pass `recursive=False`
    explicitly to treat "**" as a plain "*" instead.
    Note for remote schemes (http/sftp): a recursive glob walks the
    whole remote subtree, one request/roundtrip per directory.
    """
    if recurse_symlinks:
        raise NotImplementedError("glob(recurse_symlinks=True)")
    if pattern is None:
        # The pattern is the path itself; `glob()` splits at the first
        # wildcard, so an absolute one is fine here (unlike a pattern
        # argument, which must stay relative to self).
        return _glob.glob(
            self,
            recursive=recursive,
            include_hidden=include_hidden,
            case_sensitive=case_sensitive,
            dironly=bool(dironly),
            native=native,
            on_error=on_error,
            bound_loops=bound_loops,
        )
    # Validates eagerly (like pathlib 3.13+); the returned selection is
    # lazy. The pattern is never joined onto self: `self / pattern` let an
    # absolute pattern escape self and re-parsed "?" as a URI query.
    parts, trailing_sep = _glob.parse_pattern(pattern, native=native)
    if recursive is None:
        recursive = _glob.RECURSIVE in parts
    return _glob.select(
        self,
        parts,
        dironly=trailing_sep if dironly is None else dironly,
        recursive=recursive,
        include_hidden=include_hidden,
        case_sensitive=case_sensitive,
        native=native,
        on_error=on_error,
        bound_loops=bound_loops,
    )

is_dir_binding()

Whether this directory is another tree's second NAME rather than part of this one: a Windows junction or a mount point (bind or otherwise).

The distinction that matters to anything walking a tree. A symlink announces itself -- is_symlink() is True and every walker here already declines to follow one. A binding does not: it looks like an ordinary directory, and its contents belong to whoever mounted or junctioned it. rm(recursive=True) removes the binding itself rather than the contents behind it.

An implementation that cannot answer (stdlib's is_mount() raises on Windows before 3.12) counts as "not a binding" rather than breaking the operation that asked.

Source code in src/pathlib_next/path.py
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
def is_dir_binding(self) -> bool:
    """Whether this directory is another tree's second NAME rather than
    part of this one: a Windows junction or a mount point (bind or
    otherwise).

    The distinction that matters to anything walking a tree. A symlink
    announces itself -- `is_symlink()` is True and every walker here
    already declines to follow one. A binding does not: it looks like an
    ordinary directory, and its contents belong to whoever mounted or
    junctioned it. `rm(recursive=True)` removes the binding itself
    rather than the contents behind it.

    An implementation that cannot answer (stdlib's `is_mount()` raises
    on Windows before 3.12) counts as "not a binding" rather than
    breaking the operation that asked.
    """
    for test in (self.is_junction, self.is_mount):
        try:
            if test():
                return True
        except (NotImplementedError, OSError, ValueError):
            continue
    return False

is_hidden()

Return True if the final path component is a hidden file/directory.

Source code in src/pathlib_next/path.py
662
663
664
def is_hidden(self):
    """Return True if the final path component is a hidden file/directory."""
    return self.name.startswith(".")

is_junction()

Whether this path is a Windows junction (pathlib 3.12 parity).

A junction is NOT a symlink: it is a second NAME for a directory, the Windows equivalent of a Linux bind mount. is_symlink() is False for one, readlink() does not describe it, and a non-following stat reports a plain directory -- which is exactly why a symlink check cannot protect a recursive walk from it.

Default False; LocalPath/FileUri answer for real. A remote scheme has no such concept unless it says otherwise.

Source code in src/pathlib_next/path.py
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
def is_junction(self) -> bool:
    """Whether this path is a Windows junction (pathlib 3.12 parity).

    A junction is NOT a symlink: it is a second NAME for a directory,
    the Windows equivalent of a Linux bind mount. `is_symlink()` is
    False for one, `readlink()` does not describe it, and a
    non-following stat reports a plain directory -- which is exactly why
    a symlink check cannot protect a recursive walk from it.

    Default False; `LocalPath`/`FileUri` answer for real. A remote
    scheme has no such concept unless it says otherwise.
    """
    return False

is_mount()

Whether this path is a mount point (pathlib parity).

The POSIX half of the same idea: a bind mount makes one tree visible under a second name, with no link anywhere to say so. Default False; LocalPath/FileUri answer for real.

Source code in src/pathlib_next/path.py
1200
1201
1202
1203
1204
1205
1206
1207
def is_mount(self) -> bool:
    """Whether this path is a mount point (pathlib parity).

    The POSIX half of the same idea: a bind mount makes one tree visible
    under a second name, with no link anywhere to say so. Default False;
    `LocalPath`/`FileUri` answer for real.
    """
    return False

iterdir()

Yield path objects of the directory contents.

The children are yielded in arbitrary order, and the special entries '.' and '..' are not included.

Source code in src/pathlib_next/path.py
694
695
696
697
698
699
700
701
@_utils.notimplemented
def iterdir(self) -> "_ty.Iterator[_ty.Self]":
    """Yield path objects of the directory contents.

    The children are yielded in arbitrary order, and the
    special entries '.' and '..' are not included.
    """
    ...

mkdir(mode=511, parents=False, exist_ok=False)

Create a new directory at this given path.

Source code in src/pathlib_next/path.py
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
def mkdir(self, mode=0o777, parents=False, exist_ok=False):
    """
    Create a new directory at this given path.
    """
    try:
        self._mkdir(mode)
    except FileNotFoundError:
        if not parents or self.parent == self:
            raise
        # Parents may already exist (e.g. a sibling branch created them)
        # -- mirror CPython: exist_ok=True for parents, original
        # exist_ok only for the final retry of self.
        self.parent.mkdir(parents=True, exist_ok=True)
        self.mkdir(mode, parents=False, exist_ok=exist_ok)
    except FileExistsError:
        if not exist_ok or not self.is_dir():
            raise

move(target, *, overwrite=False)

Move this file or directory to target, falling back to copy+unlink/rm if rename is unsupported.

Source code in src/pathlib_next/path.py
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
def move(self, target: "Path|str", *, overwrite=False):
    """Move this file or directory to target, falling back to copy+unlink/rm if rename is unsupported."""
    if isinstance(target, str):
        target = self._coerce_target(target)
    src = self
    # rename() only makes sense between paths the same backend can see.
    native = src._rename_compatible(target)

    # Everything that can fail cheaply is checked before the target is
    # touched: a missing source, or a file onto a directory, used to
    # delete the target and only then raise.
    src_stat = FileStat.from_path(src, follow_symlink=False)
    if src_stat is None:
        raise FileNotFoundError(
            _errno.ENOENT, "No such file or directory", str(src)
        )
    # The same file under another spelling (a case-only rename on a
    # case-insensitive filesystem, or `x.move(x)`) is renamed in place:
    # removing the "existing" target would delete the source itself.
    if not _same_file(src, target):
        target_stat = FileStat.from_path(target, follow_symlink=False)
        if target_stat is not None:
            if not overwrite:
                raise FileExistsError(target)
            if target_stat.is_dir() and not target_stat.is_symlink():
                if not src_stat.is_dir():
                    raise IsADirectoryError(target)
                target.rm(recursive=True, missing_ok=True)
            elif (
                native
                and type(target) is type(src)
                and callable(getattr(src, "replace", None))
            ):
                # Local paths: os.replace() swaps atomically and leaves
                # the target untouched if it fails (e.g. a locked source).
                try:
                    return src.replace(target)
                except OSError as error:
                    if error.errno != _errno.EXDEV:
                        raise
                    native = False
            else:
                target.unlink(missing_ok=True)

    if native:
        try:
            return src.rename(target)
        except NotImplementedError:
            pass
        except OSError as error:
            # Another filesystem or drive: fall back to copy + delete, as
            # shutil.move and 3.14's Path.move do.
            if error.errno != _errno.EXDEV:
                raise

    if src.is_dir():
        src.copy(target, overwrite=overwrite, recursive=True)
        src.rm(recursive=True)
    else:
        src.copy(target, overwrite=overwrite)
        src.unlink()

rename(target)

Rename this file or directory to the given target.

Source code in src/pathlib_next/path.py
1238
1239
1240
1241
@_utils.notimplemented
def rename(self, target: "_ty.Self | str"):
    """Rename this file or directory to the given target."""
    ...

rglob(pattern, *, case_sensitive=None, include_hidden=True, recursive=True, dironly=None, recurse_symlinks=False, native=True, on_error=None, bound_loops=False)

Equivalent to glob(f"**/{pattern}", recursive=True).

pattern=None and native= mean what they do on glob(); with None this is glob(None, recursive=True), since a carried pattern brings its own anchor and nothing can be prefixed to it.

Source code in src/pathlib_next/path.py
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
def rglob(
    self,
    pattern: str | None,
    *,
    case_sensitive: bool = None,
    include_hidden: bool = True,
    recursive: bool = True,
    dironly: bool = None,
    recurse_symlinks: bool = False,
    native: bool = True,
    on_error: "_ty.Callable[[OSError], None]" = None,
    bound_loops: bool = False,
):
    """Equivalent to `glob(f"**/{pattern}", recursive=True)`.

    `pattern=None` and `native=` mean what they do on `glob()`; with
    `None` this is `glob(None, recursive=True)`, since a carried pattern
    brings its own anchor and nothing can be prefixed to it."""
    if pattern is None:
        return self.glob(
            None,
            case_sensitive=case_sensitive,
            include_hidden=include_hidden,
            recursive=recursive,
            dironly=dironly,
            recurse_symlinks=recurse_symlinks,
            native=native,
            on_error=on_error,
            bound_loops=bound_loops,
        )
    if not (isinstance(pattern, str) and not pattern):
        # Reject an absolute pattern before "**/" hides its anchor;
        # glob() validates without listing anything.
        self.glob(pattern, recursive=False, native=native)
    return self.glob(
        f"**/{pattern}",
        case_sensitive=case_sensitive,
        include_hidden=include_hidden,
        recursive=recursive,
        dironly=dironly,
        recurse_symlinks=recurse_symlinks,
        native=native,
        on_error=on_error,
        bound_loops=bound_loops,
    )

rm(recursive=False, missing_ok=False, ignore_error=False, *, follow_symlinks=False, follow_binds=False)

Remove this file or directory, optionally recursively and ignoring errors.

follow_symlinks applies to a symlink met during a recursive removal, follow_binds to a binding -- a directory that is another tree's second NAME rather than part of this one (is_dir_binding(): a Windows junction, a mount point, a bind mount). Both take:

  • False (default) -- remove the entry itself, never what is behind it: unlink() for a symlink, rmdir() for a binding. This is what rm -r does, and on a live mount the rmdir() fails rather than emptying someone else's filesystem.
  • True -- remove the contents behind it, then the entry. What a walker that cannot tell the difference does by accident.
  • None -- leave it in place. The enclosing directory is then not empty, so its own removal reports that.
  • a callable policy(path) -> bool | None -- asked per entry with the same three answers, so one tree can keep one mount and follow another.

Path components BEFORE the final one are followed as usual; these decide what happens to the entry itself. The name matches stat()/walk()/copy()'s follow_symlinks= rather than inventing a second vocabulary for the same idea.

Source code in src/pathlib_next/path.py
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
def rm(
    self,
    /,
    recursive=False,
    missing_ok=False,
    ignore_error: bool | _ty.Callable[[Exception, _ty.Self], bool] = False,
    *,
    follow_symlinks: "bool | None | _ty.Callable[[_ty.Self], bool | None]" = False,
    follow_binds: "bool | None | _ty.Callable[[_ty.Self], bool | None]" = False,
):
    """Remove this file or directory, optionally recursively and ignoring errors.

    `follow_symlinks` applies to a symlink met during a recursive
    removal, `follow_binds` to a binding -- a directory that is another
    tree's second NAME rather than part of this one (`is_dir_binding()`:
    a Windows junction, a mount point, a bind mount). Both take:

    - `False` (default) -- remove the entry itself, never what is behind
      it: `unlink()` for a symlink, `rmdir()` for a binding. This is what
      `rm -r` does, and on a live mount the `rmdir()` fails rather than
      emptying someone else's filesystem.
    - `True` -- remove the contents behind it, then the entry. What a
      walker that cannot tell the difference does by accident.
    - `None` -- leave it in place. The enclosing directory is then not
      empty, so its own removal reports that.
    - a callable `policy(path) -> bool | None` -- asked per entry with
      the same three answers, so one tree can keep one mount and follow
      another.

    Path components BEFORE the final one are followed as usual; these
    decide what happens to the entry itself. The name matches
    `stat()`/`walk()`/`copy()`'s `follow_symlinks=` rather than
    inventing a second vocabulary for the same idea.
    """
    # Same bool-or-callable normalization as copy()/PathSyncer, via the
    # shared helper. A supplied callable keeps rm()'s own `(error, path)`
    # arity -- arities differ per call site by design, see the helper.
    _onerror = _utils.as_error_handler(ignore_error)
    _check_follow("follow_symlinks", follow_symlinks)
    _check_follow("follow_binds", follow_binds)

    # An error the handler declined, on its way out: each enclosing
    # directory's `except` catches it again, and consulting the handler
    # there reported one failure once per ancestor.
    declined = []

    def _handle(error, path):
        if any(error is seen for seen in declined):
            raise error
        if not _onerror(error, path):
            declined.append(error)
            raise error

    def _scan_entries(path):
        for entry in path._scandir():
            if isinstance(entry, tuple) and len(entry) == 2:
                yield entry
                continue
            try:
                stat = FileStat.from_stat(entry.stat(follow_symlinks=False))
            except OSError:
                stat = None
            yield entry.name, stat

    def _remove_tree(path):
        try:
            entries = list(_scan_entries(path))
        except Exception as error:
            _handle(error, path)
            return

        for name, child_stat in entries:
            child = path / name
            try:
                if child_stat is None:
                    child_stat = FileStat.from_path(child, follow_symlink=False)
                if child_stat is not None and child_stat.is_symlink():
                    # A link, whatever it points at. The non-following
                    # stat says "link", so this branch is reached before
                    # the directory one.
                    policy = _follow_policy(follow_symlinks, child)
                    if policy == "ignore":
                        continue
                    if policy == "follow" and child.is_dir():
                        _remove_tree(child)
                    else:
                        child.unlink()
                elif child_stat is not None and child_stat.is_dir():
                    if child._is_junction_link():
                        # A binding: what is inside belongs to the tree
                        # it names, not to this one.
                        policy = _follow_policy(follow_binds, child)
                        if policy == "ignore":
                            continue
                        if policy == "follow":
                            _remove_tree(child)
                        else:
                            # `rmdir()` on a live mount fails (EBUSY) --
                            # the right answer, and far better than
                            # emptying someone else's filesystem.
                            child.rmdir()
                    else:
                        _remove_tree(child)
                else:
                    child.unlink()
            except Exception as error:
                _handle(error, child)

        try:
            path.rmdir()
        except Exception as error:
            _handle(error, path)

    try:
        stat = FileStat.from_path(self, follow_symlink=False)
    except Exception as error:
        _handle(error, self)
        return
    if stat is None:
        if not missing_ok:
            _handle(_os_error(FileNotFoundError, _errno.ENOENT, self), self)
    elif stat.is_dir():
        binding = self._is_junction_link()
        policy = _follow_policy(follow_binds, self) if binding else "follow"
        if policy == "ignore":
            return
        if recursive and (not binding or policy == "follow"):
            _remove_tree(self)
        else:
            try:
                self.rmdir()
            except Exception as error:
                _handle(error, self)
    else:
        try:
            self.unlink()
        except Exception as error:
            _handle(error, self)

rmdir()

Remove this directory. The directory must be empty.

Source code in src/pathlib_next/path.py
1009
1010
1011
1012
1013
@_utils.notimplemented
def rmdir(self):
    """
    Remove this directory.  The directory must be empty.
    """

samefile(other_path)

Return whether other_path is the same or not as this file, by comparing (st_dev, st_ino) when the backend's stat() provides them. Raises NotImplementedError otherwise (e.g. our own FileStat doesn't carry st_dev/st_ino) -- LocalPath gets a real implementation from pathlib.Path via MRO instead of this one.

Source code in src/pathlib_next/path.py
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
def samefile(self, other_path: str | _ty.Self):
    """Return whether other_path is the same or not as this file, by
    comparing (st_dev, st_ino) when the backend's stat() provides them.
    Raises NotImplementedError otherwise (e.g. our own FileStat doesn't
    carry st_dev/st_ino) -- LocalPath gets a real implementation from
    pathlib.Path via MRO instead of this one.
    """
    # with_segments(), never the bare constructor or `_coerce_target()`:
    # a str is a path on this backend. `type(self)(str)` gave a MemPath
    # a fresh, empty backend, and a URI parse drops this path's host.
    other = (
        other_path
        if isinstance(other_path, Path)
        else self.with_segments(other_path)
    )
    st1 = self.stat()
    st2 = other.stat()
    ident1 = (getattr(st1, "st_dev", None), getattr(st1, "st_ino", None))
    ident2 = (getattr(st2, "st_dev", None), getattr(st2, "st_ino", None))
    if None in ident1 or None in ident2:
        raise NotImplementedError(
            "samefile() requires stat() to provide st_dev/st_ino"
        )
    return ident1 == ident2

Make this path a symlink pointing to target.

Signature-compatible with pathlib.Path.symlink_to(); force is a pathlib_next extension (see docs/divergences.md).

With force=True, an existing entry at this path is removed first. No filesystem or transport offers an atomic "replace a symlink" operation, so this is an unlink-then-symlink sequence and is therefore not atomic: between the two steps the path does not exist, and a concurrent writer can win the race. It is a convenience, not a locking primitive.

Only a non-directory entry is removed -- an existing directory at the link path is left alone and the underlying FileExistsError (or the backend's equivalent) propagates. Silently deleting a directory tree is never what force= on a symlink call is asking for.

Source code in src/pathlib_next/path.py
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
def symlink_to(
    self,
    target: "_ty.Self | str",
    target_is_directory: bool = False,
    *,
    force: bool = False,
) -> None:
    """Make this path a symlink pointing to `target`.

    Signature-compatible with `pathlib.Path.symlink_to()`; `force` is a
    pathlib_next extension (see `docs/divergences.md`).

    With `force=True`, an existing entry at *this* path is removed
    first. No filesystem or transport offers an atomic "replace a
    symlink" operation, so this is an unlink-then-symlink sequence and
    is therefore **not** atomic: between the two steps the path does not
    exist, and a concurrent writer can win the race. It is a
    convenience, not a locking primitive.

    Only a non-directory entry is removed -- an existing *directory* at
    the link path is left alone and the underlying `FileExistsError`
    (or the backend's equivalent) propagates. Silently deleting a
    directory tree is never what `force=` on a symlink call is asking
    for.
    """
    # Normalize a str target to a path object, so the primitive only
    # ever handles one type. Routed through `_symlink_target()` rather
    # than inlining `type(self)(target)`: for a URI-backed path that
    # constructor re-parses the string as URI syntax, which silently
    # truncated a link target at a "?"/"#" and percent-decoded it (see
    # `UriPath._symlink_target`).
    target = self._symlink_target(target)
    if force:
        try:
            self.unlink(missing_ok=True)
        except (IsADirectoryError, PermissionError, OSError) as error:
            # A directory at the link path (POSIX: IsADirectoryError;
            # Windows and several remote backends: PermissionError or a
            # plain OSError) is not something force= should remove.
            # Let the symlink attempt below raise the error that
            # actually describes the conflict.
            if not self.is_dir():
                raise error
    return self._symlink_to(target, target_is_directory)

touch(mode=None, exist_ok=True)

Create this file, if it doesn't exist.

Raises FileExistsError if exist_ok is False and the file already exists (pathlib parity). An existing file is never truncated.

Differences from pathlib.Path.touch, which creates through os.open() (so the process umask applies) and bumps an existing file's mtime:

  • mode is applied with chmod() only when passed explicitly, and then verbatim: a remote server's umask is unknowable. The default (None) leaves a new file with the permissions the backend gives it, instead of chmod'ing it to a world-writable 0o666.
  • An existing file's mtime is left unchanged: the generic protocol has no timestamp primitive, and rewriting the content to bump it is not a touch. LocalPath and FileUri use pathlib's own touch.
Source code in src/pathlib_next/path.py
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
def touch(self, mode=None, exist_ok=True):
    """
    Create this file, if it doesn't exist.

    Raises FileExistsError if exist_ok is False and the file already
    exists (pathlib parity). An existing file is never truncated.

    Differences from `pathlib.Path.touch`, which creates through
    `os.open()` (so the process umask applies) and bumps an existing
    file's mtime:

    - `mode` is applied with `chmod()` only when passed explicitly, and
      then verbatim: a remote server's umask is unknowable. The default
      (`None`) leaves a new file with the permissions the backend gives
      it, instead of chmod'ing it to a world-writable 0o666.
    - An existing file's mtime is left unchanged: the generic protocol
      has no timestamp primitive, and rewriting the content to bump it
      is not a touch. `LocalPath` and `FileUri` use pathlib's own touch.
    """
    # stat() directly, not exists(): exists() reads *any* OSError (a
    # timeout, a dropped connection) as "missing", which let a transient
    # failure fall through to a truncating open("w").
    try:
        self.stat()
    except FileNotFoundError:
        pass
    else:
        if not exist_ok:
            raise _os_error(FileExistsError, _errno.EEXIST, self)
        return
    try:
        with self.open("x"):
            ...
    except FileExistsError:
        # Created since the stat() above: an existing file, not ours to
        # truncate.
        if not exist_ok:
            raise
        return
    except NotImplementedError:
        # _open() doesn't support "x" (optional per the mode contract),
        # so the stat() above is the only guard before the truncating
        # "w" -- a small TOCTOU window.
        with self.open("w"):
            ...
    if mode is not None:
        try:
            self.chmod(mode)
        except NotImplementedError:
            pass

Remove this file or link. If the path is a directory, use rmdir() instead.

Source code in src/pathlib_next/path.py
1002
1003
1004
1005
1006
1007
@_utils.notimplemented
def unlink(self, missing_ok=False):
    """
    Remove this file or link.
    If the path is a directory, use rmdir() instead.
    """

walk(top_down=True, on_error=None, follow_symlinks=False)

Walk the directory tree from this directory, similar to os.walk().

Uses _scandir() rather than iterdir() + a stat() per entry -- for schemes whose listing already carries type metadata (HTTP/DAV indexes, SFTP listdir_attr, FTP MLSD, S3 list pages), this turns a remote-tree walk from O(entries) round trips into O(dirs). The pre-seeded stat is trusted only when follow_symlinks is False (matching walk()'s own default and the lstat-like semantics of those listing calls); an explicit follow_symlinks=True always re-stat()s each entry so a symlink is still resolved.

Source code in src/pathlib_next/path.py
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
def walk(
    self,
    top_down=True,
    on_error: _ty.Callable[[OSError], None] = None,
    follow_symlinks=False,
):
    """Walk the directory tree from this directory, similar to os.walk().

    Uses `_scandir()` rather than `iterdir()` + a `stat()` per entry --
    for schemes whose listing already carries type metadata (HTTP/DAV
    indexes, SFTP `listdir_attr`, FTP MLSD, S3 list pages), this turns a
    remote-tree walk from O(entries) round trips into O(dirs). The
    pre-seeded stat is trusted only when `follow_symlinks` is False
    (matching `walk()`'s own default and the lstat-like semantics of
    those listing calls); an explicit `follow_symlinks=True` always
    re-`stat()`s each entry so a symlink is still resolved.
    """
    paths: "list[_ty.Self|tuple[_ty.Self, list[str], list[str]]]" = [self]

    while paths:
        path = paths.pop()
        if isinstance(path, tuple):
            yield path
            continue
        try:
            # `_scandir()` is a generator -- listing this directory may
            # not actually happen (and so may not raise) until the first
            # `next()`, not at this call. Materializing it here (rather
            # than iterating lazily below) keeps any such error inside
            # this try, matching the on_error contract regardless of
            # whether a given implementation happens to fail eagerly or
            # lazily.
            entries = list(path._scandir())
        except OSError as error:
            if on_error is not None:
                on_error(error)
            continue

        dirnames: "list[str]" = []
        filenames: "list[str]" = []
        for name, stat in entries:
            try:
                if stat is None or follow_symlinks:
                    stat = FileStat.from_path(
                        path / name, follow_symlink=follow_symlinks
                    )
                is_dir = stat.is_dir() if stat is not None else False
            except OSError:
                # Carried over from os.path.isdir().
                is_dir = False

            if is_dir:
                dirnames.append(name)
            else:
                filenames.append(name)

        if top_down:
            yield path, dirnames, filenames
        else:
            paths.append((path, dirnames, filenames))

        paths += [path / d for d in reversed(dirnames)]

Pathname

Bases: FsPathLike, Generic[_P]

Base class for manipulating paths without I/O.

anchor property

drive + root.

drive property

No generic concept of a drive; always "". LocalPath gets a real one from pathlib on Windows.

name property

The final path component, if any.

parent abstractmethod property

The logical parent of the path.

parents property

An immutable sequence providing access to the logical ancestors of the path.

parts abstractmethod property

The individual components/parts of the path.

root property

The root of the path, if any (e.g. "/"). See docs/divergences.md for how this is derived generically vs. LocalPath's real one.

segments abstractmethod property

The sequence of path component strings.

stem property

The final path component, minus its last suffix.

suffix property

The final component's last suffix, if any.

This includes the leading period. For example: '.txt'

suffixes property

A list of the final component's suffixes, if any.

These include the leading periods. For example: ['.tar', '.gz']

__eq__(other)

Compare by (exact type, segments).

The ABC previously defined no equality at all, so every pure subclass that didn't hand-write one -- including MemPath, the documented reference exemplar -- compared by identity. That made is_relative_to() (which decides via ==) silently return False for every subclass, and broke paths as dict keys or set members. _BaseFSPathname/LocalPath are unaffected: pathlib.PurePath precedes Pathname in their MRO and keeps its own __eq__, as does Uri, which defines one.

Source code in src/pathlib_next/path.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def __eq__(self, other: object) -> bool:
    """Compare by (exact type, segments).

    The ABC previously defined no equality at all, so every pure
    subclass that didn't hand-write one -- including `MemPath`, the
    documented reference exemplar -- compared by identity. That made
    `is_relative_to()` (which decides via `==`) silently return False
    for every subclass, and broke paths as dict keys or set members.
    `_BaseFSPathname`/`LocalPath` are unaffected: `pathlib.PurePath`
    precedes `Pathname` in their MRO and keeps its own `__eq__`, as
    does `Uri`, which defines one.
    """
    if type(self) is not type(other):
        return NotImplemented
    return tuple(self.segments) == tuple(other.segments)

__rtruediv__(key)

"prefix" / path, as pathlib supports. self is passed as an object, so per-instance state it carries (a MemPath backend) survives, and an absolute self restarts the join.

Source code in src/pathlib_next/path.py
281
282
283
284
285
286
287
288
289
290
def __rtruediv__(self, key: str) -> _ty.Self:
    """`"prefix" / path`, as pathlib supports. `self` is passed as an
    object, so per-instance state it carries (a `MemPath` backend)
    survives, and an absolute `self` restarts the join."""
    if not isinstance(key, str):
        return NotImplemented
    try:
        return type(self)(key, self)
    except (TypeError, NotImplementedError):
        return NotImplemented

as_posix()

Return the string representation of the path with forward slashes.

Source code in src/pathlib_next/path.py
411
412
413
def as_posix(self) -> str:
    """Return the string representation of the path with forward slashes."""
    return "/".join(self.segments)

as_uri() abstractmethod

Return the path as a URI string.

Source code in src/pathlib_next/path.py
144
145
146
147
@_abc.abstractmethod
def as_uri(self) -> str:
    """Return the path as a URI string."""
    ...

full_match(pattern, *, case_sensitive=None)

Return True if this path matches the glob-style pattern against the whole path (3.13 parity). Unlike match(), this isn't a right-anchored partial match, and "**" matches any number of path segments (including zero).

Source code in src/pathlib_next/path.py
401
402
403
404
405
406
407
408
409
def full_match(self, pattern: str, *, case_sensitive: bool = None) -> bool:
    """Return True if this path matches the glob-style `pattern`
    against the whole path (3.13 parity). Unlike match(), this isn't a
    right-anchored partial match, and "**" matches any number of path
    segments (including zero).
    """
    if case_sensitive is None:
        case_sensitive = self._is_case_sensitive
    return _glob.full_match(self.segments, pattern, case_sensitive)

has_glob_pattern()

Return True if any of the path segments contain glob wildcards.

The anchor is not one of them: a Windows extended-length path (\\?\C:\data, and the \\?\UNC\server\share form) carries a literal ? in its drive, which is a prefix, not a wildcard -- so a caller asking "is this a pattern or a plain path?" got True for every such path.

Source code in src/pathlib_next/path.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
def has_glob_pattern(self):
    """Return True if any of the path segments contain glob wildcards.

    The anchor is not one of them: a Windows extended-length path
    (`\\\\?\\C:\\data`, and the `\\\\?\\UNC\\server\\share` form) carries a
    literal `?` in its drive, which is a prefix, not a wildcard -- so a
    caller asking "is this a pattern or a plain path?" got `True` for
    every such path.
    """
    segments = list(self.segments)
    anchor = getattr(self, "anchor", "")
    if anchor and segments and segments[0] == anchor:
        segments = segments[1:]
    for segment in segments:
        if _glob.WILDCARD_PATTERN.search(segment) is not None:
            return True
    return False

is_absolute()

True if the path is absolute

Source code in src/pathlib_next/path.py
324
325
326
327
@_utils.notimplemented
def is_absolute(self) -> bool:
    """True if the path is absolute"""
    ...

is_relative_to(other)

Return True if the path is relative to another path or False.

Source code in src/pathlib_next/path.py
243
244
245
246
247
248
249
250
251
252
253
254
def is_relative_to(self, other: _ty.Self | str):
    """Return True if the path is relative to another path or False."""
    cls = type(self)
    # with_segments(other), NOT cls(self, other): joining `other` under
    # `self` first turned `MemPath("a/b").is_relative_to("a")` into a
    # comparison against "a/b/a" and answered False, while the object
    # form of the same call answered True. CPython parses `other`
    # standalone (`self.with_segments(other)`) and so do we -- via
    # with_segments rather than the bare constructor so per-instance
    # state a subclass carries (MemPath's backend) survives.
    other = other if isinstance(other, cls) else self.with_segments(other)
    return other == self or other in self.parents

joinpath(*args)

Combine this path with one or more paths/segments.

Source code in src/pathlib_next/path.py
292
293
294
def joinpath(self, *args: str | _ty.Self) -> _ty.Self:
    """Combine this path with one or more paths/segments."""
    return type(self)(self, *args)

match(path_pattern, *, case_sensitive=None)

Return True if this path matches the given glob-style pattern.

pathlib's semantics on the running interpreter: a relative pattern matches the last N segments (from the right), an absolute pattern must match the whole path, and each segment is matched on its own, so * never crosses a "/". "*" acts like "" (use full_match() for recursive matching). An empty pattern raises ValueError.

A compiled re.Pattern cannot be split into segments; it is matched against the whole path string instead ("/"-joined segments, so a Uri's scheme and host are never part of it).

Source code in src/pathlib_next/path.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def match(self, path_pattern: str | _re.Pattern, *, case_sensitive=None):
    """
    Return True if this path matches the given glob-style pattern.

    pathlib's semantics on the running interpreter: a relative pattern
    matches the last N segments (from the right), an absolute pattern
    must match the whole path, and each segment is matched on its own,
    so `*` never crosses a "/". "**" acts like "*" (use `full_match()`
    for recursive matching). An empty pattern raises ValueError.

    A compiled `re.Pattern` cannot be split into segments; it is matched
    against the whole path string instead ("/"-joined segments, so a
    `Uri`'s scheme and host are never part of it).
    """
    import fnmatch as _fnmatch

    if case_sensitive is None:
        case_sensitive = self._is_case_sensitive
    anchored, names = self._match_parts()
    if isinstance(path_pattern, _re.Pattern):
        path = ("/" if anchored else "") + "/".join(names)
        return path_pattern.match(path) is not None
    if isinstance(path_pattern, Pathname):
        path_pattern = "/".join(path_pattern.segments)
    elif not isinstance(path_pattern, str):
        path_pattern = _os.fspath(path_pattern)
    pattern_anchored = path_pattern.startswith("/")
    pattern_names = [s for s in path_pattern.split("/") if s and s != "."]
    if not pattern_names and not pattern_anchored:
        raise ValueError("empty pattern")
    if _sys.version_info[:2] == (3, 12):
        # 3.12 matches the whole path at once, as a string with its
        # separators swapped for newlines, rather than part by part --
        # different enough at the edges (see `_match_lines_312`) that it
        # gets its own pass.
        return _match_lines_312(
            anchored, names, pattern_anchored, pattern_names, case_sensitive
        )
    flags = 0 if case_sensitive else _re.IGNORECASE
    if pattern_anchored and not (anchored and len(names) == len(pattern_names)):
        return False
    # The root counts as one more part a relative pattern can reach.
    path_parts = len(names) + anchored
    if len(pattern_names) > path_parts:
        return False
    for index, pattern in enumerate(reversed(pattern_names)):
        if index == len(names):
            # A relative pattern as long as the path reaches its root.
            # 3.13+ compiles the root part "/" like any other part with
            # glob.translate: "*" and "?" never match the separator, but
            # a bracket expression such as "[!a]" does. 3.9-3.11 fnmatch
            # the root string, so "*" and "?" match it too. (3.12 never
            # reaches here -- `_match_lines_312` answered above.)
            if _sys.version_info >= (3, 13):
                from .fspath import _translate_segment

                regex = f"(?s:{_translate_segment(pattern, '[^/]')})\\Z"
                return _re.match(regex, "/", flags) is not None
            return _re.match(_fnmatch.translate(pattern), "/", flags) is not None
        name = names[len(names) - 1 - index]
        if _re.match(_fnmatch.translate(pattern), name, flags) is None:
            return False
    return True

relative_to(other) abstractmethod

Return the relative path to another path identified by the passed arguments. If the operation is not possible (because this is not related to the other path), raise ValueError.

Source code in src/pathlib_next/path.py
235
236
237
238
239
240
241
@_abc.abstractmethod
def relative_to(self, other: _ty.Self | str) -> _ty.Self:
    """Return the relative path to another path identified by the passed
    arguments.  If the operation is not possible (because this is not
    related to the other path), raise ValueError.
    """
    ...

with_name(name)

Return a new path with the name changed.

Raises ValueError for an empty name, ".", or a name containing "/", as pathlib does. Without the check a name such as "../../etc/passwd" or "x/y" was spliced in verbatim, so a caller relying on pathlib's validation of an untrusted file name got a path outside the directory. A bare ".." is accepted, also as pathlib does; use utils.is_safe_child_name() to reject it. with_stem()/ with_suffix() validate their result through here.

Source code in src/pathlib_next/path.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def with_name(self, name: str) -> _ty.Self:
    """Return a new path with the name changed.

    Raises ValueError for an empty name, ".", or a name containing "/",
    as pathlib does. Without the check a name such as "../../etc/passwd"
    or "x/y" was spliced in verbatim, so a caller relying on pathlib's
    validation of an untrusted file name got a path outside the
    directory. A bare ".." is accepted, also as pathlib does; use
    `utils.is_safe_child_name()` to reject it. `with_stem()`/
    `with_suffix()` validate their result through here.
    """
    if not name or "/" in name or name == ".":
        raise ValueError("Invalid name %r" % (name,))
    if not self.name:
        raise ValueError("%r has an empty name" % (self,))
    return self.with_segments(*self.segments[:-1], name)

with_segments(*segments) abstractmethod

Construct a same-type path instance from new segments.

Source code in src/pathlib_next/path.py
190
191
192
193
@_abc.abstractmethod
def with_segments(self, *segments: str) -> _ty.Self:
    """Construct a same-type path instance from new segments."""
    ...

with_stem(stem)

Return a new path with the stem changed (validated like with_name()).

Source code in src/pathlib_next/path.py
212
213
214
215
def with_stem(self, stem: str) -> _ty.Self:
    """Return a new path with the stem changed (validated like
    `with_name()`)."""
    return self.with_name(stem + self.suffix)

with_suffix(suffix)

Return a new path with the suffix changed or added.

Source code in src/pathlib_next/path.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def with_suffix(self, suffix: str) -> _ty.Self:
    """Return a new path with the suffix changed or added."""
    name = self.name
    if (
        suffix
        and not suffix.startswith(".")
        or (suffix == "." and _sys.version_info < (3, 14))
    ):
        raise ValueError("Invalid suffix %r" % (suffix))
    if not name:
        raise ValueError("%r has an empty name" % (self,))
    old_suffix = self.suffix
    if not old_suffix:
        name = name + suffix
    else:
        name = name[: -len(old_suffix)] + suffix
    return self.with_name(name)

pathlib_next.fspath

LocalPath

Bases: WindowsPath if name == 'nt' else PosixPath, Path, _BaseFSPathname

The real local filesystem path: pathlib.WindowsPath/PosixPath with this library's Path mixed in via MRO. Behaves exactly like pathlib.Path for anything not explicitly overridden here (see docs/divergences.md).

glob(pattern, *, case_sensitive=None, include_hidden=True, recursive=None, dironly=None, recurse_symlinks=False, native=True, on_error=None, bound_loops=False)

Iterate over this subtree and yield all existing files (of any kind, including directories) matching the given relative pattern.

Same semantics as Path.glob(), including pattern=None (expand the pattern this path carries) and native= (follow the running interpreter, or one rule on every version); every separator of this flavour splits the pattern, and a pattern with a drive or root raises glob.NonRelativePatternError like pathlib.

Source code in src/pathlib_next/fspath.py
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
def glob(
    self,
    pattern: "str | _proto.FsPathLike | None",
    *,
    case_sensitive: bool = None,
    include_hidden: bool = True,
    recursive: bool = None,
    dironly: bool = None,
    recurse_symlinks: bool = False,
    native: bool = True,
    on_error: "_ty.Callable[[OSError], None]" = None,
    bound_loops: bool = False,
):
    """Iterate over this subtree and yield all existing files (of any
    kind, including directories) matching the given relative pattern.

    Same semantics as Path.glob(), including `pattern=None` (expand the
    pattern this path carries) and `native=` (follow the running
    interpreter, or one rule on every version); every separator of this
    flavour splits the pattern, and a pattern with a drive or root
    raises `glob.NonRelativePatternError` like pathlib.
    """
    if pattern is None:
        return _proto.Path.glob(
            self,
            None,
            case_sensitive=case_sensitive,
            include_hidden=include_hidden,
            recursive=recursive,
            dironly=dironly,
            recurse_symlinks=recurse_symlinks,
            native=native,
            on_error=on_error,
            bound_loops=bound_loops,
        )
    pattern = _os.fspath(pattern)
    if pattern:
        anchored = self.with_segments(pattern)
        if anchored.drive or anchored.root:
            raise _glob.NonRelativePatternError(
                "Non-relative patterns are unsupported"
            )
        for sep in self._path_separators:
            pattern = pattern.replace(sep, "/")
        if any(self._parser.splitdrive(part)[0] for part in pattern.split("/")):
            # "sub/C:/x": joining "C:" would re-anchor outside self, and
            # no child can be named that, so nothing matches (pathlib).
            return iter(())
    return _proto.Path.glob(
        self,
        pattern,
        case_sensitive=case_sensitive,
        include_hidden=include_hidden,
        recursive=recursive,
        dironly=dironly,
        recurse_symlinks=recurse_symlinks,
        native=native,
        on_error=on_error,
        bound_loops=bound_loops,
    )

is_junction()

Whether this is a Windows junction. os.path.isjunction() on 3.12+, the reparse tag directly before that.

lstat() reports a junction (IO_REPARSE_TAG_MOUNT_POINT) as a plain directory -- CPython only rewrites the mode to S_IFLNK for real symlinks -- so rm(recursive=True) used to walk into one and delete the target's files. shutil.rmtree guards the same case.

Source code in src/pathlib_next/fspath.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def is_junction(self) -> bool:
    """Whether this is a Windows junction. `os.path.isjunction()` on
    3.12+, the reparse tag directly before that.

    lstat() reports a junction (IO_REPARSE_TAG_MOUNT_POINT) as a plain
    directory -- CPython only rewrites the mode to S_IFLNK for real
    symlinks -- so `rm(recursive=True)` used to walk into one and delete
    the target's files. `shutil.rmtree` guards the same case.
    """
    if _os.name != "nt":
        return False
    isjunction = getattr(_os.path, "isjunction", None)
    if isjunction is not None:
        try:
            return bool(isjunction(self))
        except (OSError, ValueError):
            return False
    try:
        st = _os.lstat(self)
    except OSError:
        return False
    return getattr(st, "st_reparse_tag", 0) == getattr(
        _stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003
    )

is_mount()

Whether this is a mount point -- a bind mount included, which is how POSIX spells what a junction does on Windows.

Source code in src/pathlib_next/fspath.py
269
270
271
272
273
274
275
def is_mount(self) -> bool:
    """Whether this is a mount point -- a bind mount included, which is
    how POSIX spells what a junction does on Windows."""
    try:
        return bool(_os.path.ismount(self))
    except (OSError, ValueError):
        return False

PosixPathname

Bases: PurePosixPath, _BaseFSPathname

Pure (no I/O) POSIX-flavour path, implementing the Pathname protocol on top of pathlib.PurePosixPath.

WindowsPathname

Bases: PureWindowsPath, _BaseFSPathname

Pure (no I/O) Windows-flavour path, implementing the Pathname protocol on top of pathlib.PureWindowsPath.