pathlib-next 0.9.0__tar.gz → 0.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/CHANGELOG.md +37 -14
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/PKG-INFO +15 -14
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/README.md +9 -9
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/divergences.md +3 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/mkdocs.yml +3 -3
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/pyproject.toml +19 -6
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/AGENTS.md +27 -3
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/fspath.py +45 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/mempath.py +1 -3
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/path.py +67 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/protocols/fs.py +62 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/testing.py +4 -3
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/__init__.py +5 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/query.py +1 -3
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/_base.py +3 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/tar.py +3 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/zip.py +13 -6
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/az.py +11 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/dav.py +9 -4
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/file.py +14 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/ftp.py +9 -7
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/__init__.py +0 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/github.py +0 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/github.py +9 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/gitlab.py +6 -4
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/gs.py +6 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/http.py +109 -90
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/s3.py +3 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/sftp/__init__.py +39 -8
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +40 -8
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +2 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/source.py +6 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/utils/__init__.py +71 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/utils/archive.py +10 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/utils/glob.py +1 -4
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/utils/sync.py +1 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/conftest.py +29 -20
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_archive_uri.py +14 -4
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_checksum.py +7 -7
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_contract.py +10 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_data_uri.py +1 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_dav.py +1 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_ftp.py +1 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_gitrepo.py +9 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_glob.py +1 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_http.py +9 -3
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_http_live.py +1 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_http_parser.py +12 -3
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_local.py +10 -8
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_mro_precedence.py +13 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_parity_io.py +161 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_parity_pure.py +9 -9
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_path_gaps.py +66 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_plugins.py +10 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_properties.py +51 -13
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_s3.py +1 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_sftp.py +118 -27
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_sftp_asyncssh.py +32 -12
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_smoke.py +5 -2
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_source.py +2 -4
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_uri_path.py +0 -1
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_utils.py +20 -17
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_walk.py +24 -6
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_webdav.py +2 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/.gitignore +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/LICENSE +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/api/mempath.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/api/path.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/api/testing.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/api/uri.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/api/utils.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/benchmarks.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/changelog.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/guides/cli.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/guides/extending.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/docs/index.md +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/az_listing.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/github_listing.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/gs_listing.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/http_listing.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/s3_listing.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/protocols/checksum.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_az.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_gs.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_mempath.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_pathname.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_query.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_sync.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.9.0 → pathlib_next-0.9.1}/tests/test_uripath_tool.py +0 -0
|
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.9.1] - 2026-08-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`symlink_to(..., force=True)`** — replace an existing entry at the link
|
|
12
|
+
path instead of failing. SFTP has no atomic "replace symlink", so every
|
|
13
|
+
consumer was re-implementing the unlink-then-symlink dance. Implemented at
|
|
14
|
+
the `Path` layer over a new `_symlink_to()` backend primitive (the same
|
|
15
|
+
wrapper/primitive split as `_mkdir`/`mkdir`), so all backends inherit it.
|
|
16
|
+
Removes a **non-directory** entry only, and is documented as non-atomic.
|
|
17
|
+
- **`chown(uid=None, gid=None)`** over a new `_chown()` backend primitive.
|
|
18
|
+
`chown` was the one POSIX permission attribute `stat()` could read but
|
|
19
|
+
nothing could write back. `None` means "leave unchanged"; the normalization
|
|
20
|
+
lives on `Path` so each backend receives an already-canonical pair rather
|
|
21
|
+
than re-deriving the mapping (`os.chown` wants `-1`, SFTP omits the field).
|
|
22
|
+
- **`chmod()` accepts a string octal mode** (`"0755"`), normalized with an
|
|
23
|
+
explicit base-8 parse. A mode string outside `[0-7]` raises rather than
|
|
24
|
+
being coerced — `int("0755")` in *decimal* is a different mode, which is
|
|
25
|
+
exactly the wrong-but-plausible failure this guards.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Adopted `black`, pinned to the 3.9 floor; `src/` and `tests/` reformatted.
|
|
29
|
+
|
|
8
30
|
## [0.9.0] - 2026-07-29
|
|
9
31
|
|
|
10
32
|
### Added
|
|
@@ -710,17 +732,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
710
732
|
- Sync error handling.
|
|
711
733
|
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
|
|
712
734
|
|
|
713
|
-
[Unreleased]: https://github.com/jose-pr/
|
|
714
|
-
[0.
|
|
715
|
-
[0.8.
|
|
716
|
-
[0.8.
|
|
717
|
-
[0.8.
|
|
718
|
-
[0.8.
|
|
719
|
-
[0.8.
|
|
720
|
-
[0.8.
|
|
721
|
-
[0.
|
|
722
|
-
[0.
|
|
723
|
-
[0.
|
|
724
|
-
[0.
|
|
725
|
-
[0.4.
|
|
726
|
-
[0.
|
|
735
|
+
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...HEAD
|
|
736
|
+
[0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
|
|
737
|
+
[0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
|
|
738
|
+
[0.8.5]: https://github.com/jose-pr/pathlib-next/compare/v0.8.4...v0.8.5
|
|
739
|
+
[0.8.4]: https://github.com/jose-pr/pathlib-next/compare/v0.8.3...v0.8.4
|
|
740
|
+
[0.8.3]: https://github.com/jose-pr/pathlib-next/compare/v0.8.2...v0.8.3
|
|
741
|
+
[0.8.2]: https://github.com/jose-pr/pathlib-next/compare/v0.8.1...v0.8.2
|
|
742
|
+
[0.8.1]: https://github.com/jose-pr/pathlib-next/compare/v0.8.0...v0.8.1
|
|
743
|
+
[0.8.0]: https://github.com/jose-pr/pathlib-next/compare/v0.7.0...v0.8.0
|
|
744
|
+
[0.7.0]: https://github.com/jose-pr/pathlib-next/compare/v0.6.0...v0.7.0
|
|
745
|
+
[0.6.0]: https://github.com/jose-pr/pathlib-next/compare/v0.5.0...v0.6.0
|
|
746
|
+
[0.5.0]: https://github.com/jose-pr/pathlib-next/compare/v0.4.1...v0.5.0
|
|
747
|
+
[0.4.1]: https://github.com/jose-pr/pathlib-next/compare/v0.4.0...v0.4.1
|
|
748
|
+
[0.4.0]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.4.0
|
|
749
|
+
[0.3.5]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.3.5
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
|
-
Name:
|
|
3
|
-
Version: 0.9.
|
|
2
|
+
Name: pathlib-next
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Generic Path Protocol based pathlib
|
|
5
|
-
Project-URL: Homepage, https://github.com/jose-pr/
|
|
6
|
-
Project-URL: Documentation, https://jose-pr.github.io/
|
|
7
|
-
Project-URL: Issues, https://github.com/jose-pr/
|
|
5
|
+
Project-URL: Homepage, https://github.com/jose-pr/pathlib-next/
|
|
6
|
+
Project-URL: Documentation, https://jose-pr.github.io/pathlib-next/
|
|
7
|
+
Project-URL: Issues, https://github.com/jose-pr/pathlib-next/issues
|
|
8
8
|
Author: Jose A
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -16,6 +16,7 @@ Requires-Dist: azure-storage-blob; extra == 'az'
|
|
|
16
16
|
Requires-Dist: netimps>=0.2.0; extra == 'az'
|
|
17
17
|
Requires-Dist: uritools; extra == 'az'
|
|
18
18
|
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: black; extra == 'dev'
|
|
19
20
|
Requires-Dist: build; extra == 'dev'
|
|
20
21
|
Requires-Dist: cheroot; extra == 'dev'
|
|
21
22
|
Requires-Dist: hatchling; extra == 'dev'
|
|
@@ -58,11 +59,11 @@ Description-Content-Type: text/markdown
|
|
|
58
59
|
|
|
59
60
|
# pathlib_next
|
|
60
61
|
|
|
61
|
-
[](https://pypi.org/project/
|
|
62
|
-
[](https://pypi.org/project/
|
|
62
|
+
[](https://pypi.org/project/pathlib-next/)
|
|
63
|
+
[](https://pypi.org/project/pathlib-next/)
|
|
63
64
|
[](LICENSE)
|
|
64
|
-
[](https://jose-pr.github.io/
|
|
65
|
-
[](https://github.com/jose-pr/
|
|
65
|
+
[](https://jose-pr.github.io/pathlib-next/)
|
|
66
|
+
[](https://github.com/jose-pr/pathlib-next/actions/workflows/test.yml)
|
|
66
67
|
|
|
67
68
|
A **robust, extensible pathlib-like base** for any resource addressable as a
|
|
68
69
|
path or URI. Same method names, signatures, semantics, and exception types as
|
|
@@ -70,7 +71,7 @@ path or URI. Same method names, signatures, semantics, and exception types as
|
|
|
70
71
|
against `Path`/`UriPath` and it works against your local disk, an in-memory
|
|
71
72
|
tree, an HTTP index, or an SFTP server. Every intentional divergence from
|
|
72
73
|
`pathlib`'s behavior is documented, not silent -- see
|
|
73
|
-
[`docs/divergences.md`](https://jose-pr.github.io/
|
|
74
|
+
[`docs/divergences.md`](https://jose-pr.github.io/pathlib-next/divergences/).
|
|
74
75
|
|
|
75
76
|
## Features
|
|
76
77
|
|
|
@@ -88,7 +89,7 @@ tree, an HTTP index, or an SFTP server. Every intentional divergence from
|
|
|
88
89
|
|
|
89
90
|
Every scheme shares the same `glob()`, `walk()`, `copy()`/`move()`, `rm()`
|
|
90
91
|
implementations -- see the full matrix and notes in
|
|
91
|
-
[Schemes](https://jose-pr.github.io/
|
|
92
|
+
[Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
|
|
92
93
|
|
|
93
94
|
- **Unified path interface** across local files, in-memory paths, archive
|
|
94
95
|
members, and `file`/`data`/`ftp`/`http`/`dav`/`sftp`/`s3` URIs.
|
|
@@ -100,7 +101,7 @@ implementations -- see the full matrix and notes in
|
|
|
100
101
|
authority components.
|
|
101
102
|
- **Extensible two ways**: subclass `Path` directly for a custom
|
|
102
103
|
non-URI resource, or subclass `UriPath` for a new URI scheme -- see
|
|
103
|
-
[Extending](https://jose-pr.github.io/
|
|
104
|
+
[Extending](https://jose-pr.github.io/pathlib-next/guides/extending/).
|
|
104
105
|
|
|
105
106
|
## Installation
|
|
106
107
|
|
|
@@ -186,13 +187,13 @@ print(member.read_text())
|
|
|
186
187
|
Also built in: `data:` (RFC 2397 inline payloads), `ftp(s):` (stdlib
|
|
187
188
|
`ftplib`), `dav(s):` (WebDAV, full read/write over HTTP), and `s3:`
|
|
188
189
|
(`boto3`) -- one example per scheme in
|
|
189
|
-
[Schemes](https://jose-pr.github.io/
|
|
190
|
+
[Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
|
|
190
191
|
|
|
191
192
|
## Extending
|
|
192
193
|
|
|
193
194
|
Two first-class ways to add a new path-addressable resource -- both covered
|
|
194
195
|
in depth, with worked examples, in
|
|
195
|
-
[Extending](https://jose-pr.github.io/
|
|
196
|
+
[Extending](https://jose-pr.github.io/pathlib-next/guides/extending/):
|
|
196
197
|
|
|
197
198
|
- Subclass `Path` directly for a custom, non-URI resource (`MemPath` is the
|
|
198
199
|
reference exemplar).
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# pathlib_next
|
|
2
2
|
|
|
3
|
-
[](https://pypi.org/project/
|
|
4
|
-
[](https://pypi.org/project/
|
|
3
|
+
[](https://pypi.org/project/pathlib-next/)
|
|
4
|
+
[](https://pypi.org/project/pathlib-next/)
|
|
5
5
|
[](LICENSE)
|
|
6
|
-
[](https://jose-pr.github.io/
|
|
7
|
-
[](https://github.com/jose-pr/
|
|
6
|
+
[](https://jose-pr.github.io/pathlib-next/)
|
|
7
|
+
[](https://github.com/jose-pr/pathlib-next/actions/workflows/test.yml)
|
|
8
8
|
|
|
9
9
|
A **robust, extensible pathlib-like base** for any resource addressable as a
|
|
10
10
|
path or URI. Same method names, signatures, semantics, and exception types as
|
|
@@ -12,7 +12,7 @@ path or URI. Same method names, signatures, semantics, and exception types as
|
|
|
12
12
|
against `Path`/`UriPath` and it works against your local disk, an in-memory
|
|
13
13
|
tree, an HTTP index, or an SFTP server. Every intentional divergence from
|
|
14
14
|
`pathlib`'s behavior is documented, not silent -- see
|
|
15
|
-
[`docs/divergences.md`](https://jose-pr.github.io/
|
|
15
|
+
[`docs/divergences.md`](https://jose-pr.github.io/pathlib-next/divergences/).
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
@@ -30,7 +30,7 @@ tree, an HTTP index, or an SFTP server. Every intentional divergence from
|
|
|
30
30
|
|
|
31
31
|
Every scheme shares the same `glob()`, `walk()`, `copy()`/`move()`, `rm()`
|
|
32
32
|
implementations -- see the full matrix and notes in
|
|
33
|
-
[Schemes](https://jose-pr.github.io/
|
|
33
|
+
[Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
|
|
34
34
|
|
|
35
35
|
- **Unified path interface** across local files, in-memory paths, archive
|
|
36
36
|
members, and `file`/`data`/`ftp`/`http`/`dav`/`sftp`/`s3` URIs.
|
|
@@ -42,7 +42,7 @@ implementations -- see the full matrix and notes in
|
|
|
42
42
|
authority components.
|
|
43
43
|
- **Extensible two ways**: subclass `Path` directly for a custom
|
|
44
44
|
non-URI resource, or subclass `UriPath` for a new URI scheme -- see
|
|
45
|
-
[Extending](https://jose-pr.github.io/
|
|
45
|
+
[Extending](https://jose-pr.github.io/pathlib-next/guides/extending/).
|
|
46
46
|
|
|
47
47
|
## Installation
|
|
48
48
|
|
|
@@ -128,13 +128,13 @@ print(member.read_text())
|
|
|
128
128
|
Also built in: `data:` (RFC 2397 inline payloads), `ftp(s):` (stdlib
|
|
129
129
|
`ftplib`), `dav(s):` (WebDAV, full read/write over HTTP), and `s3:`
|
|
130
130
|
(`boto3`) -- one example per scheme in
|
|
131
|
-
[Schemes](https://jose-pr.github.io/
|
|
131
|
+
[Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
|
|
132
132
|
|
|
133
133
|
## Extending
|
|
134
134
|
|
|
135
135
|
Two first-class ways to add a new path-addressable resource -- both covered
|
|
136
136
|
in depth, with worked examples, in
|
|
137
|
-
[Extending](https://jose-pr.github.io/
|
|
137
|
+
[Extending](https://jose-pr.github.io/pathlib-next/guides/extending/):
|
|
138
138
|
|
|
139
139
|
- Subclass `Path` directly for a custom, non-URI resource (`MemPath` is the
|
|
140
140
|
reference exemplar).
|
|
@@ -60,6 +60,9 @@ these operations itself always keeps its own implementation.
|
|
|
60
60
|
| `Path.copy(target, ...)` | CPython 3.14 `Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, preserve_metadata=False)`; always raises if `target` exists | Ours predates 3.14. Signature: `copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True)`. `overwrite=True` unlinks an existing non-directory target first; `preserve_metadata` defaults to **True** (opposite of 3.14) and only propagates `st_mode`, not timestamps/xattrs | Argument names aligned with 3.14 where cheap; `preserve_metadata=True` default kept for backward compat with this method's pre-existing (pre-3.14-alignment) behavior of always copying the mode bits. Full metadata preservation (timestamps, xattrs) is not implemented. |
|
|
61
61
|
| `Path.move(target, ...)` | Not in `pathlib` at all | Our own extension: tries `rename()`, falls back to copy+unlink | N/A -- pure extension, no pathlib method to diverge from. |
|
|
62
62
|
| `Path.rm(recursive=, missing_ok=, ignore_error=)` | Not in `pathlib` (closest: `shutil.rmtree`) | Our own extension. Recursive removal deletes bottom-up and uses non-following stat/listing metadata, so a symlink to a directory is unlinked rather than traversed. | N/A -- pure extension. Non-following recursive deletion avoids deleting through symlinked directory targets and lets backends with metadata-rich listings remove trees without a stat round trip per child. |
|
|
63
|
+
| `Path.symlink_to(target, target_is_directory=False, *, force=False)` | `pathlib.Path.symlink_to(target, target_is_directory=False)` -- raises `FileExistsError` if anything already exists at the link path | Adds a keyword-only `force=`. `force=False` (the default) is stdlib-exact. `force=True` unlinks an existing **non-directory** entry at the link path first, then creates the symlink; an existing *directory* is never removed and the underlying error propagates. Not atomic: no filesystem or transport offers "replace a symlink" as one operation, so the path briefly does not exist between the unlink and the symlink. | Additive extension (an extra optional kwarg, per the parity contract). No backend can offer this atomically, so every consumer was re-implementing the same unlink-then-symlink dance -- it is path semantics, not transport semantics, so it belongs at the `Path` layer where one implementation serves every backend. Backends implement only the `_symlink_to()` primitive (same `_mkdir`/`_open` shape) and get `force=` for free. Because no stdlib version accepts the keyword, `symlink_to` is in `_OPERATION_NAMES` so `LocalPath` honors it too. |
|
|
64
|
+
| `Path.chown(uid=None, gid=None, *, follow_symlinks=True)` | Not in `pathlib` at all -- it has `owner()`/`group()` **readers** but no writer (the stdlib writers are `os.chown`/`shutil.chown`, which are functions over a path, not path methods) | Our own extension. `None` (default) leaves a field unchanged; `-1` is accepted as an alias for `None` (`os.chown`'s own sentinel); an `int` is a uid/gid and a `str` is a user/group name. A call where both fields are unchanged short-circuits without touching the backend. Implemented for `LocalPath`/`FileUri` (via `shutil.chown`, or `os.lchown` for `follow_symlinks=False`) and `SftpPath` (`setstat`); `NotImplementedError` elsewhere. | Ownership was the one permission attribute `stat()` could report (`st_uid`/`st_gid`) that nothing could write back. The valuable part is centralizing the **"unchanged" sentinel** on `Path` (`utils.as_owner()`): `os.chown` spells it `-1`, SFTP omits the field, other middlewares use `None` -- normalizing per-scheme would be three chances to disagree. Backends implement `_chown()` and receive an already-canonical pair. SFTPv3 sends uid/gid as one paired attribute, so `SftpPath` reads the current owner for whichever field is unchanged rather than guessing a value. |
|
|
65
|
+
| `Path.chmod(mode, ...)` accepting a `str` | `mode` must be an `int`; a `str` raises `TypeError` | Additionally accepts a `str`, parsed as **octal**: `"0755"`, `"755"` and `0o755` all mean the same thing. An optional `0o` prefix is allowed; any character outside `[0-7]` raises `ValueError` rather than being coerced. | The string form is how modes are written in `chmod(1)`, Ansible, Dockerfiles and shell scripts, so config-driven callers arrive holding one. Accepted only with an **explicit base 8** (`utils.as_mode()`), never a plain `int()`: `int("0755")` in decimal is 755 == `0o1363`, a different *and valid* mode, so a fallback to decimal would set plausible-but-wrong permissions with nothing raising -- which is exactly why stdlib refuses strings. Parsing in one shared helper is what makes the base non-negotiable across the five backends that implement `chmod` directly. |
|
|
63
66
|
| `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
|
|
64
67
|
| `PathSyncer(follow_symlinks=False).sync()` on a symlink source | N/A (no pathlib equivalent) | Previously always raised `NotImplementedError`. Now controlled by the new `symlink_mode` constructor kwarg (`"preserve"` default, `"reject"` opt-out): `"preserve"` creates a matching symlink on `target` with the same raw, unresolved target string `readlink()` returned (dangling links and relative targets included, never validated/resolved); `"reject"` restores the exact old unconditional-raise behavior. If `target` can't create symlinks at all (every backend except `LocalPath` and `SftpPath`), `"preserve"` also raises `NotImplementedError`, through the same `ignore_error`/`hook()` machinery as every other sync branch, not a silent skip. **This is a default-behavior change, not a pure extension** -- flagged here because existing callers relying on the old unconditional raise (e.g. to detect and skip symlinks) must now pass `symlink_mode="reject"` explicitly. | Faithful one-way tree mirroring needs symlinks preserved as symlinks by default, not silently dropped/erroring -- discovered via a real cross-host sync use case (hostctl). `follow_symlinks=True` (unchanged default) still resolves through symlinks during traversal, so this only affects callers who already opted into `follow_symlinks=False`. **User decision, 2026-07-28.** |
|
|
65
68
|
| `S3Path` directories | N/A (pathlib directories are real filesystem entries) | `is_dir()` is prefix emulation (any object key under `"<path>/"`); `mkdir()` creates a zero-byte `"<path>/"` marker object; `rmdir()` requires no other keys under that prefix (pathlib-parity "must be empty"). If an exact object key and a `"<path>/"` prefix both exist, exact object operations such as `stat()` and `rm(recursive=True)` treat the path as the object first. | S3 has no native directory concept -- this is the same prefix convention the AWS console itself uses for an empty "folder". Exact-object precedence avoids deleting a prefix tree when the addressed path is a real object. |
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
site_name: pathlib_next
|
|
2
2
|
site_description: Generic Path Protocol based pathlib
|
|
3
|
-
site_url: https://jose-pr.github.io/
|
|
4
|
-
repo_url: https://github.com/jose-pr/
|
|
5
|
-
repo_name: jose-pr/
|
|
3
|
+
site_url: https://jose-pr.github.io/pathlib-next/
|
|
4
|
+
repo_url: https://github.com/jose-pr/pathlib-next
|
|
5
|
+
repo_name: jose-pr/pathlib-next
|
|
6
6
|
edit_uri: edit/main/docs/
|
|
7
7
|
|
|
8
8
|
theme:
|
|
@@ -3,8 +3,16 @@ requires = ["hatchling"]
|
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
|
-
name
|
|
7
|
-
|
|
6
|
+
# The DISTRIBUTION name, hyphenated to match what PyPI displays and what
|
|
7
|
+
# `pip install pathlib-next` takes. PEP 503 normalises `_` to `-`, so this
|
|
8
|
+
# was already published as `pathlib-next`; spelling it that way here just
|
|
9
|
+
# stops the repo, the docs and the index from disagreeing.
|
|
10
|
+
#
|
|
11
|
+
# The IMPORT name stays `pathlib_next` (`packages` below, and every
|
|
12
|
+
# `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
|
|
13
|
+
# Distribution name and import name differing is ordinary and intended.
|
|
14
|
+
name = "pathlib-next"
|
|
15
|
+
version = "0.9.1"
|
|
8
16
|
authors = [{ name = "Jose A" }]
|
|
9
17
|
description = "Generic Path Protocol based pathlib"
|
|
10
18
|
readme = "README.md"
|
|
@@ -27,7 +35,7 @@ sftp-async = ["asyncssh<2.22; python_version<'3.10'", "asyncssh; python_version>
|
|
|
27
35
|
s3 = ["boto3", "pathlib_next[uri]"]
|
|
28
36
|
gs = ["google-cloud-storage", "pathlib_next[uri]"]
|
|
29
37
|
az = ["azure-storage-blob", "pathlib_next[uri]"]
|
|
30
|
-
dev = ['build', 'twine', 'hatchling', 'pytest', 'pytest-cov', 'pyftpdlib', 'wsgidav', 'cheroot', 'moto[s3]', 'hypothesis']
|
|
38
|
+
dev = ['build', 'twine', 'hatchling', 'pytest', 'pytest-cov', 'pyftpdlib', 'wsgidav', 'cheroot', 'moto[s3]', 'hypothesis', 'black']
|
|
31
39
|
docs = ['mkdocs', 'mkdocs-material', 'mkdocstrings[python]']
|
|
32
40
|
|
|
33
41
|
[project.entry-points."pathlib_next.schemes"]
|
|
@@ -54,9 +62,9 @@ git = "pathlib_next.uri.schemes.git:GitPath"
|
|
|
54
62
|
"git+gitlab" = "pathlib_next.uri.schemes.git:GitLabGitPath"
|
|
55
63
|
|
|
56
64
|
[project.urls]
|
|
57
|
-
Homepage = "https://github.com/jose-pr/
|
|
58
|
-
Documentation = "https://jose-pr.github.io/
|
|
59
|
-
Issues = "https://github.com/jose-pr/
|
|
65
|
+
Homepage = "https://github.com/jose-pr/pathlib-next/"
|
|
66
|
+
Documentation = "https://jose-pr.github.io/pathlib-next/"
|
|
67
|
+
Issues = "https://github.com/jose-pr/pathlib-next/issues"
|
|
60
68
|
|
|
61
69
|
|
|
62
70
|
# Ship the consumer-facing docs inside the installed package, so they are
|
|
@@ -74,6 +82,11 @@ exclude = ["/.*", "/benchmarks"]
|
|
|
74
82
|
packages = ["src/pathlib_next"]
|
|
75
83
|
|
|
76
84
|
|
|
85
|
+
[tool.black]
|
|
86
|
+
# Pinned to the project's *minimum* supported Python so black never emits
|
|
87
|
+
# syntax the 3.9 floor cannot parse.
|
|
88
|
+
target-version = ["py39"]
|
|
89
|
+
|
|
77
90
|
[tool.pytest.ini_options]
|
|
78
91
|
pythonpath = ["src"]
|
|
79
92
|
testpaths = ["tests"]
|
|
@@ -4,7 +4,7 @@ Header-file-style reference for the `pathlib_next` package: every public
|
|
|
4
4
|
export with its signature, arguments, contract, and gotchas, so this module
|
|
5
5
|
can be consumed without reading its source. Kept current with the public
|
|
6
6
|
API. For the project overview, install extras, and code layout, see the
|
|
7
|
-
<https://github.com/jose-pr/
|
|
7
|
+
<https://github.com/jose-pr/pathlib-next>. Any behavioral divergence from `pathlib.Path` is
|
|
8
8
|
recorded in `docs/divergences.md` — this file documents the *contract*, not
|
|
9
9
|
every internal deviation.
|
|
10
10
|
|
|
@@ -63,6 +63,17 @@ pathlib_next`.
|
|
|
63
63
|
`ignore_error` (bool or predicate) controls whether an error during the
|
|
64
64
|
walk is swallowed (predicate return `True`) or re-raised.
|
|
65
65
|
- `rename(target)` — not implemented by default.
|
|
66
|
+
- `_symlink_to(target, target_is_directory=False)` (not implemented by
|
|
67
|
+
default) / `symlink_to(target, target_is_directory=False, *,
|
|
68
|
+
force=False)` — same primitive/wrapper split as `_mkdir`/`mkdir`: a
|
|
69
|
+
backend implements only `_symlink_to()` and receives an already
|
|
70
|
+
normalized path object (a `str` target is turned into one by the
|
|
71
|
+
wrapper, as `copy()`/`move()` do), then reads the raw target string the
|
|
72
|
+
way its transport needs (`Uri.path` on the wire, `os.fspath()`
|
|
73
|
+
locally). `force=` is this library's extension: `False` is
|
|
74
|
+
stdlib-exact, `True` unlinks an existing **non-directory** entry at the
|
|
75
|
+
link path first (never a directory) and is **not** atomic. Listed in
|
|
76
|
+
`_OPERATION_NAMES`, since no stdlib version accepts `force=`.
|
|
66
77
|
- `copy(target, *, overwrite=False, follow_symlinks=True,
|
|
67
78
|
preserve_metadata=True, recursive=False, ignore_error=None,
|
|
68
79
|
progress=None)` — `follow_symlinks`/`preserve_metadata` names match
|
|
@@ -130,7 +141,20 @@ pathlib_next`.
|
|
|
130
141
|
properties. `exists()`/the `is_*` methods swallow `OSError`/`ValueError`
|
|
131
142
|
from `stat()` and report `False` rather than propagating (pathlib parity).
|
|
132
143
|
- **`fs.Chmod`** — `Protocol`. `chmod(mode, *, follow_symlinks=True)` (not
|
|
133
|
-
implemented by default); derives `lchmod(mode)`.
|
|
144
|
+
implemented by default); derives `lchmod(mode)`. `mode` may be a `str`,
|
|
145
|
+
parsed as **octal** (`"0755"` == `"755"` == `0o755`); a non-octal digit
|
|
146
|
+
raises `ValueError`. Each backend overrides `chmod()` directly (each has
|
|
147
|
+
real per-scheme logic), so all of them normalize through
|
|
148
|
+
`utils.as_mode()` — the shared helper is what keeps the base from
|
|
149
|
+
drifting between them.
|
|
150
|
+
Also `chown(uid=None, gid=None, *, follow_symlinks=True)` over a
|
|
151
|
+
`_chown(uid, gid, *, follow_symlinks=True)` backend primitive (not
|
|
152
|
+
implemented by default). Extension: `pathlib` has `owner()`/`group()`
|
|
153
|
+
readers but no writer. `None` leaves a field unchanged and `-1` is an
|
|
154
|
+
alias for it; an `int` is an id, a `str` is a name. `chown()` normalizes
|
|
155
|
+
via `utils.as_owner()` and short-circuits when nothing would change, so
|
|
156
|
+
`_chown()` always receives a canonical pair and only converts to its own
|
|
157
|
+
wire spelling (`-1` for `os.chown`, an omitted attr for SFTP).
|
|
134
158
|
- **`io.BinaryOpen`** — `Protocol`. `_open(mode="r", buffering=-1) ->
|
|
135
159
|
io.IOBase` (not implemented by default; must yield a **binary** stream).
|
|
136
160
|
Derives `open(mode="r", buffering=-1, encoding=None, errors=None,
|
|
@@ -249,7 +273,7 @@ extra that depends on it).
|
|
|
249
273
|
`single=True`, last value wins).
|
|
250
274
|
|
|
251
275
|
Built-in scheme modules live under `uri/schemes/` — see the table in the
|
|
252
|
-
<https://github.com/jose-pr/
|
|
276
|
+
<https://github.com/jose-pr/pathlib-next>. `PATHLIB_NEXT_SFTP_BACKEND` env var (`"paramiko"` /
|
|
253
277
|
`"asyncssh"` / `"auto"`, default `"auto"`) selects the `sftp:` backend;
|
|
254
278
|
precedence is an explicit class attribute > this env var > auto-detect
|
|
255
279
|
(prefers asyncssh if importable). `gs:` honors `STORAGE_EMULATOR_HOST` (set
|
|
@@ -6,11 +6,13 @@ import os as _os
|
|
|
6
6
|
import pathlib as _path
|
|
7
7
|
import posixpath as _posixpath
|
|
8
8
|
import re as _re
|
|
9
|
+
import shutil as _shutil
|
|
9
10
|
import sys as _sys
|
|
10
11
|
import types as _types
|
|
11
12
|
import typing as _ty
|
|
12
13
|
|
|
13
14
|
from . import path as _proto
|
|
15
|
+
from . import utils as _utils
|
|
14
16
|
from .utils.stat import FileStat as _FileStat
|
|
15
17
|
|
|
16
18
|
# pathlib.Path.stat()/chmod() only accept follow_symlinks= on 3.10+; below
|
|
@@ -148,6 +150,20 @@ class LocalPath(
|
|
|
148
150
|
# available on every supported Python version.
|
|
149
151
|
return _proto.Path.move(self, target, overwrite=overwrite)
|
|
150
152
|
|
|
153
|
+
def _symlink_to(
|
|
154
|
+
self, target: _proto.Path | str, target_is_directory: bool = False
|
|
155
|
+
) -> None:
|
|
156
|
+
# `symlink_to` is in _OPERATION_NAMES, so the generic
|
|
157
|
+
# `Path.symlink_to()` (which owns `force=`) is what resolves on
|
|
158
|
+
# LocalPath -- it delegates the actual link creation here, and
|
|
159
|
+
# stdlib's own implementation is reached explicitly via super().
|
|
160
|
+
# Unlike every remote scheme, target_is_directory is meaningful
|
|
161
|
+
# here: it is the Windows-only flag pathlib forwards to
|
|
162
|
+
# os.symlink(). stdlib accepts any os.PathLike, so the normalized
|
|
163
|
+
# path object goes straight through -- and a relative target stays
|
|
164
|
+
# relative, exactly as before.
|
|
165
|
+
return super().symlink_to(target, target_is_directory)
|
|
166
|
+
|
|
151
167
|
def stat(self, *, follow_symlinks=True):
|
|
152
168
|
# pathlib.Path.stat() (next in MRO via WindowsPath/PosixPath) only
|
|
153
169
|
# accepts follow_symlinks= on 3.10+; below that, lstat() is the
|
|
@@ -156,14 +172,42 @@ class LocalPath(
|
|
|
156
172
|
return super().stat(follow_symlinks=follow_symlinks)
|
|
157
173
|
return super().stat() if follow_symlinks else super().lstat()
|
|
158
174
|
|
|
159
|
-
def chmod(self, mode, *, follow_symlinks=True):
|
|
175
|
+
def chmod(self, mode: int | str, *, follow_symlinks: bool = True):
|
|
160
176
|
# Same follow_symlinks= 3.10+ gap as stat() above; lchmod() is the
|
|
161
177
|
# pre-existing equivalent (raises NotImplementedError itself on
|
|
162
178
|
# platforms without os.lchmod, e.g. Windows).
|
|
179
|
+
mode = _utils.as_mode(mode)
|
|
163
180
|
if _HAS_FOLLOW_SYMLINKS:
|
|
164
181
|
return super().chmod(mode, follow_symlinks=follow_symlinks)
|
|
165
182
|
return super().chmod(mode) if follow_symlinks else super().lchmod(mode)
|
|
166
183
|
|
|
184
|
+
def _chown(
|
|
185
|
+
self,
|
|
186
|
+
uid: int | str | None,
|
|
187
|
+
gid: int | str | None,
|
|
188
|
+
*,
|
|
189
|
+
follow_symlinks: bool = True,
|
|
190
|
+
) -> None:
|
|
191
|
+
# shutil.chown() is the stdlib spelling that accepts names as well
|
|
192
|
+
# as ids, and it takes None for "leave unchanged" -- the same
|
|
193
|
+
# canonical form Chmod.chown() already normalized to, so the pair
|
|
194
|
+
# passes straight through. os.chown's -1 sentinel never appears
|
|
195
|
+
# here.
|
|
196
|
+
if not follow_symlinks:
|
|
197
|
+
if not hasattr(_os, "lchown"):
|
|
198
|
+
raise NotImplementedError("chown(follow_symlinks=False)")
|
|
199
|
+
if isinstance(uid, str) or isinstance(gid, str):
|
|
200
|
+
# os.lchown takes numeric ids only; resolving a name would
|
|
201
|
+
# mean duplicating shutil's lookup, and guessing wrong here
|
|
202
|
+
# writes the wrong owner silently.
|
|
203
|
+
raise NotImplementedError(
|
|
204
|
+
"chown(follow_symlinks=False) requires numeric uid/gid"
|
|
205
|
+
)
|
|
206
|
+
return _os.lchown(
|
|
207
|
+
self, -1 if uid is None else uid, -1 if gid is None else gid
|
|
208
|
+
)
|
|
209
|
+
return _shutil.chown(self, uid, gid)
|
|
210
|
+
|
|
167
211
|
def glob(
|
|
168
212
|
self,
|
|
169
213
|
pattern: str | _proto.FsPathLike,
|
|
@@ -91,9 +91,7 @@ class MemPath(Path):
|
|
|
91
91
|
# implementation-defined root) and doesn't collapse it, which
|
|
92
92
|
# broke MemPath("/") (as_posix() == "/") into a bogus "//".
|
|
93
93
|
posix = self.as_posix().lstrip("/")
|
|
94
|
-
self._normalized = (
|
|
95
|
-
_posix.normpath("/" + posix).removeprefix("/").split("/")
|
|
96
|
-
)
|
|
94
|
+
self._normalized = _posix.normpath("/" + posix).removeprefix("/").split("/")
|
|
97
95
|
return self._normalized
|
|
98
96
|
|
|
99
97
|
@property
|
|
@@ -288,7 +288,10 @@ PurePathLike = _ty.Union[str, Pathname]
|
|
|
288
288
|
# and `read_text`/`write_text`'s `newline=` is 3.13+ in CPython, and
|
|
289
289
|
# `rglob`'s `include_hidden=`/`recursive=`/`dironly=` extensions never
|
|
290
290
|
# existed there, so on the 3.9 floor the stdlib implementation rejects
|
|
291
|
-
# keywords this library's protocols promise.
|
|
291
|
+
# keywords this library's protocols promise. `symlink_to`'s `force=`
|
|
292
|
+
# is the same shape: no stdlib version has ever accepted it, so
|
|
293
|
+
# without this guard `LocalPath().symlink_to(t, force=True)` raises
|
|
294
|
+
# TypeError while every other backend honors it.
|
|
292
295
|
#
|
|
293
296
|
# `glob`/`walk`/`_scandir` are deliberately absent: `LocalPath` overrides
|
|
294
297
|
# them itself (with local-specific behavior that must be kept), so they are
|
|
@@ -300,6 +303,7 @@ _OPERATION_NAMES = (
|
|
|
300
303
|
"rglob",
|
|
301
304
|
"read_text",
|
|
302
305
|
"write_text",
|
|
306
|
+
"symlink_to",
|
|
303
307
|
)
|
|
304
308
|
|
|
305
309
|
|
|
@@ -683,6 +687,68 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
683
687
|
"""Rename this file or directory to the given target."""
|
|
684
688
|
...
|
|
685
689
|
|
|
690
|
+
@_utils.notimplemented
|
|
691
|
+
def _symlink_to(
|
|
692
|
+
self, target: "_ty.Self", target_is_directory: bool = False
|
|
693
|
+
) -> None:
|
|
694
|
+
"""Create a symlink at this path pointing at `target`.
|
|
695
|
+
|
|
696
|
+
The backend primitive behind `symlink_to()`, in the same shape as
|
|
697
|
+
`_mkdir`/`_open`: implement only this, and the generic conveniences
|
|
698
|
+
(`force=`) come for free. `target` has already been normalized to a
|
|
699
|
+
path object of this class by `symlink_to()` -- accepting `str` and
|
|
700
|
+
turning it into a path is the wrapper's job, not the primitive's.
|
|
701
|
+
|
|
702
|
+
Implementations take the raw target string from the path object the
|
|
703
|
+
way their own backend needs it (`Uri.path` for wire protocols,
|
|
704
|
+
`os.fspath()`/`as_posix()` locally); it never carries a scheme or
|
|
705
|
+
host prefix, and a relative target stays relative.
|
|
706
|
+
"""
|
|
707
|
+
...
|
|
708
|
+
|
|
709
|
+
def symlink_to(
|
|
710
|
+
self,
|
|
711
|
+
target: "_ty.Self | str",
|
|
712
|
+
target_is_directory: bool = False,
|
|
713
|
+
*,
|
|
714
|
+
force: bool = False,
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Make this path a symlink pointing to `target`.
|
|
717
|
+
|
|
718
|
+
Signature-compatible with `pathlib.Path.symlink_to()`; `force` is a
|
|
719
|
+
pathlib_next extension (see `docs/divergences.md`).
|
|
720
|
+
|
|
721
|
+
With `force=True`, an existing entry at *this* path is removed
|
|
722
|
+
first. No filesystem or transport offers an atomic "replace a
|
|
723
|
+
symlink" operation, so this is an unlink-then-symlink sequence and
|
|
724
|
+
is therefore **not** atomic: between the two steps the path does not
|
|
725
|
+
exist, and a concurrent writer can win the race. It is a
|
|
726
|
+
convenience, not a locking primitive.
|
|
727
|
+
|
|
728
|
+
Only a non-directory entry is removed -- an existing *directory* at
|
|
729
|
+
the link path is left alone and the underlying `FileExistsError`
|
|
730
|
+
(or the backend's equivalent) propagates. Silently deleting a
|
|
731
|
+
directory tree is never what `force=` on a symlink call is asking
|
|
732
|
+
for.
|
|
733
|
+
"""
|
|
734
|
+
# Normalize a str target to a path object, so the primitive only
|
|
735
|
+
# ever handles one type -- same `type(self)(target)` form as
|
|
736
|
+
# copy()/move() use for their destination, for consistency.
|
|
737
|
+
if isinstance(target, str):
|
|
738
|
+
target = type(self)(target)
|
|
739
|
+
if force:
|
|
740
|
+
try:
|
|
741
|
+
self.unlink(missing_ok=True)
|
|
742
|
+
except (IsADirectoryError, PermissionError, OSError) as error:
|
|
743
|
+
# A directory at the link path (POSIX: IsADirectoryError;
|
|
744
|
+
# Windows and several remote backends: PermissionError or a
|
|
745
|
+
# plain OSError) is not something force= should remove.
|
|
746
|
+
# Let the symlink attempt below raise the error that
|
|
747
|
+
# actually describes the conflict.
|
|
748
|
+
if not self.is_dir():
|
|
749
|
+
raise error
|
|
750
|
+
return self._symlink_to(target, target_is_directory)
|
|
751
|
+
|
|
686
752
|
def copy(
|
|
687
753
|
self,
|
|
688
754
|
target: "Path | str",
|
|
@@ -110,15 +110,75 @@ class Chmod(_ty.Protocol):
|
|
|
110
110
|
__slots__ = ()
|
|
111
111
|
|
|
112
112
|
@_utils.notimplemented
|
|
113
|
-
def chmod(self, mode: int, *, follow_symlinks=True):
|
|
113
|
+
def chmod(self, mode: int | str, *, follow_symlinks: bool = True):
|
|
114
114
|
"""
|
|
115
115
|
Change the permissions of the path, like os.chmod().
|
|
116
|
+
|
|
117
|
+
Extension: `mode` may be a `str`, which is parsed as **octal**
|
|
118
|
+
(`"0755"`, `"755"` and `0o755` all mean the same thing) -- see
|
|
119
|
+
`utils.as_mode()` for why the base is never left implicit.
|
|
120
|
+
|
|
121
|
+
Every implementation normalizes its argument through
|
|
122
|
+
`utils.as_mode()` on entry. Unlike `symlink_to`/`chown`, `chmod` is
|
|
123
|
+
overridden directly by each backend (each has real per-scheme logic
|
|
124
|
+
-- version shims, capability gates, a `SITE CHMOD` command), so
|
|
125
|
+
there is no single wrapper to hang the conversion on; the shared
|
|
126
|
+
helper is what keeps the base from drifting between them.
|
|
116
127
|
"""
|
|
117
128
|
...
|
|
118
129
|
|
|
119
|
-
def lchmod(self, mode: int):
|
|
130
|
+
def lchmod(self, mode: int | str):
|
|
120
131
|
"""
|
|
121
132
|
Like chmod(), except if the path points to a symlink, the symlink's
|
|
122
133
|
permissions are changed, rather than its target's.
|
|
123
134
|
"""
|
|
124
135
|
self.chmod(mode, follow_symlinks=False)
|
|
136
|
+
|
|
137
|
+
@_utils.notimplemented
|
|
138
|
+
def _chown(
|
|
139
|
+
self,
|
|
140
|
+
uid: int | str | None,
|
|
141
|
+
gid: int | str | None,
|
|
142
|
+
*,
|
|
143
|
+
follow_symlinks: bool = True,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Backend primitive behind `chown()`.
|
|
146
|
+
|
|
147
|
+
Receives an already-canonical `(uid, gid)` pair: `int` to set, or
|
|
148
|
+
`None` for "leave unchanged" (a `str` is a user/group *name*).
|
|
149
|
+
Convert only to this backend's own wire spelling -- `-1` for
|
|
150
|
+
`os.chown`, an omitted attr field for SFTP `setstat`. Normalizing
|
|
151
|
+
the caller's input is `chown()`'s job, not the primitive's.
|
|
152
|
+
"""
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
def chown(
|
|
156
|
+
self,
|
|
157
|
+
uid: int | str | None = None,
|
|
158
|
+
gid: int | str | None = None,
|
|
159
|
+
*,
|
|
160
|
+
follow_symlinks: bool = True,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Change the owner and/or group of the path.
|
|
163
|
+
|
|
164
|
+
Extension: `pathlib.Path` has `owner()`/`group()` **readers** but no
|
|
165
|
+
writer (the stdlib equivalent is `shutil.chown`/`os.chown`), so
|
|
166
|
+
ownership was the one attribute `stat()` could report -- via
|
|
167
|
+
`st_uid`/`st_gid` -- and nothing could write back.
|
|
168
|
+
|
|
169
|
+
`None` (the default) leaves a field unchanged, which is what lets a
|
|
170
|
+
caller set the group without knowing the owner. `-1` is accepted as
|
|
171
|
+
an alias for `None`, matching `os.chown`'s own sentinel. An `int` is
|
|
172
|
+
a uid/gid; a `str` is a user/group name, passed through for backends
|
|
173
|
+
that can resolve one.
|
|
174
|
+
|
|
175
|
+
Backends implement `_chown()` and receive an already-canonical pair
|
|
176
|
+
(see `utils.as_owner()`), so the "unchanged" semantics cannot drift
|
|
177
|
+
between schemes.
|
|
178
|
+
"""
|
|
179
|
+
uid, gid = _utils.as_owner(uid, gid)
|
|
180
|
+
if uid is None and gid is None:
|
|
181
|
+
# Nothing to change -- don't spend a round trip (or make a
|
|
182
|
+
# backend decide what an all-sentinel call means).
|
|
183
|
+
return
|
|
184
|
+
return self._chown(uid, gid, follow_symlinks=follow_symlinks)
|
|
@@ -11,6 +11,7 @@ which is a test-only dependency. Import it explicitly:
|
|
|
11
11
|
def root(self, tmp_path):
|
|
12
12
|
return MyPath(tmp_path)
|
|
13
13
|
"""
|
|
14
|
+
|
|
14
15
|
import pytest
|
|
15
16
|
|
|
16
17
|
|
|
@@ -22,7 +23,7 @@ class PurePathContract:
|
|
|
22
23
|
assert p.name == "file.txt"
|
|
23
24
|
assert p.suffix == ".txt"
|
|
24
25
|
assert p.stem == "file"
|
|
25
|
-
|
|
26
|
+
|
|
26
27
|
# parent and parents
|
|
27
28
|
assert p.parent.name == "dir"
|
|
28
29
|
assert len(p.parents) >= 2
|
|
@@ -55,7 +56,7 @@ class ReadPathContract(PurePathContract):
|
|
|
55
56
|
def test_exists_and_types(self, root):
|
|
56
57
|
assert root.exists()
|
|
57
58
|
assert root.is_dir()
|
|
58
|
-
|
|
59
|
+
|
|
59
60
|
a = root / "a.txt"
|
|
60
61
|
assert a.exists()
|
|
61
62
|
assert a.is_file()
|
|
@@ -94,7 +95,7 @@ class PathContract(ReadPathContract):
|
|
|
94
95
|
|
|
95
96
|
Subclasses must provide a `root` fixture pointing to a writable directory
|
|
96
97
|
pre-populated with the standard `fixture_tree` (see conftest.py).
|
|
97
|
-
|
|
98
|
+
|
|
98
99
|
Note: ftp/dav/s3 stay mock-unit-only because their hand-rolled fakes
|
|
99
100
|
aren't faithful enough for a generic contract.
|
|
100
101
|
"""
|