pathlib-next 0.9.7__tar.gz → 0.9.9__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.7 → pathlib_next-0.9.9}/CHANGELOG.md +46 -1
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/PKG-INFO +1 -1
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/divergences.md +1 -1
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/pyproject.toml +1 -1
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/AGENTS.md +10 -8
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/__init__.py +7 -1
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/glob.py +28 -18
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_glob_parity.py +59 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_routing.py +7 -1
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uri_path.py +32 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/.gitignore +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/AGENTS.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/LICENSE +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/README.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/cli.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/mempath.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/path.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/protocols.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/archive.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/ftp.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/git.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/http.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/local.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/objstore.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/sftp.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/testing.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/uri.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/utils.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/benchmarks.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/changelog.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/guides/cli.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/guides/extending.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/index.md +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/az_listing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/github_listing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/gs_listing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/http_listing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/s3_listing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/mkdocs.yml +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/fspath.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/path.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/checksum.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/az.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/dav.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/ftp.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/gs.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/s3.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/source.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/__init__.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/archive.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/sync.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/conftest.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_archive_parity.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_archive_safety.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_archive_uri.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_az.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_checksum.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_contract.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_contract_helpers.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_dav.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_destructive_safety.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_ftp.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_ftp_objstore_parity.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_glob.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_gs.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_http.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_http_live.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_httpdav_safety.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_io_parity.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_local.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_low_core.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_low_schemes.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_low_sync.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_mempath.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_mro_precedence.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_parity_io.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_pathname.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_plugins.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_properties.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_pure_parity.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_query.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_s3.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sftp.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sftp_asyncssh.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sftp_transport.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_smoke.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_source.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync_safety.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync_sftp.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync_sftp_parity.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_transport_security.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uri_core_parity.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_utils.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_walk.py +0 -0
- {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_webdav.py +0 -0
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.9] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **CI gains a URI-only job** (`test.yml`): the package installed with just
|
|
14
|
+
the `uri` extra and no scheme client at all, running the modules that must
|
|
15
|
+
work without one plus an explicit check that a pure-path join imports
|
|
16
|
+
nothing. Every other job installs all extras, which is why a join that
|
|
17
|
+
built a backend -- and so needed paramiko to spell an `sftp:` path --
|
|
18
|
+
survived from 0.9.3 to 0.9.8 behind a green matrix.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **`glob(bound_loops=True)` no longer drops a directory shared under two
|
|
22
|
+
names.** It bounded on every identity seen during the walk, so one
|
|
23
|
+
directory junctioned in as both `site-a` and `site-b` -- a deliberate
|
|
24
|
+
layout, and not a loop -- expanded under the first name only, silently. A
|
|
25
|
+
loop is a directory reachable BELOW ITSELF, so the bound is now the
|
|
26
|
+
current descent path rather than everything seen, which is the line
|
|
27
|
+
`find -L` draws. The loop case is unchanged: 128 matches become 2.
|
|
28
|
+
Reported by yaconfiglib against 0.9.7.
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
- **Corrected the 0.9.8 entry's provenance.** It says 0.9.7 introduced the
|
|
32
|
+
join-builds-a-backend defect; it did not. Measured against the published
|
|
33
|
+
wheels in a venv with only the `uri` extra: `UriPath("sftp://h/mnt") / "c"`
|
|
34
|
+
raises `ImportError` on **0.9.3 and 0.9.6** as well, so the defect predates
|
|
35
|
+
the 0.9.7 join rewrite, which preserved it rather than causing it. 0.9.8
|
|
36
|
+
fixes it for the first time. (A related and DELIBERATE behaviour, unchanged
|
|
37
|
+
throughout: constructing an `http:`/`s3:` path at all requires that
|
|
38
|
+
scheme's extra, because the scheme module imports its client -- see the
|
|
39
|
+
extras table in the shipped header.)
|
|
40
|
+
|
|
41
|
+
## [0.9.8] - 2026-09-17
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- **Joining a URI path no longer builds a backend.** 0.9.7 routed `/` and
|
|
45
|
+
`joinpath()` through the child builder a listing uses, which reads the
|
|
46
|
+
`backend` property -- and that property CREATES one, so spelling
|
|
47
|
+
`UriPath("sftp://h/x") / "y"` imported paramiko and raised `ImportError`
|
|
48
|
+
without the extra (`http:` wanted `requests`, `s3:` `botocore`). A join
|
|
49
|
+
is a pure-path operation and is lazy again; a child still shares its
|
|
50
|
+
parent's connection when one already exists, which is all the sharing was
|
|
51
|
+
ever for.
|
|
52
|
+
|
|
10
53
|
## [0.9.7] - 2026-09-17
|
|
11
54
|
|
|
12
55
|
### Added
|
|
@@ -1450,7 +1493,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
1450
1493
|
- Sync error handling.
|
|
1451
1494
|
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
|
|
1452
1495
|
|
|
1453
|
-
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.
|
|
1496
|
+
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.9...HEAD
|
|
1497
|
+
[0.9.9]: https://github.com/jose-pr/pathlib-next/compare/v0.9.8...v0.9.9
|
|
1498
|
+
[0.9.8]: https://github.com/jose-pr/pathlib-next/compare/v0.9.7...v0.9.8
|
|
1454
1499
|
[0.9.7]: https://github.com/jose-pr/pathlib-next/compare/v0.9.6...v0.9.7
|
|
1455
1500
|
[0.9.6]: https://github.com/jose-pr/pathlib-next/compare/v0.9.5...v0.9.6
|
|
1456
1501
|
[0.9.5]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...v0.9.5
|
|
@@ -57,7 +57,7 @@ these operations itself always keeps its own implementation.
|
|
|
57
57
|
| `Uri("http://h/d/").name` (trailing `/`) | `PurePosixPath("d/").name == "d"` | A trailing `/` is kept: `name` is `""` and `parent` is `http://h/d`. | For HTTP/WebDAV a trailing slash is how a directory URL is spelled; normalizing it away changes which URL is requested. |
|
|
58
58
|
| `Path.glob()` / `rglob()` edge cases | Version-dependent pathlib rules | Hidden entries are included by default (pathlib parity; `include_hidden=False` filters them). `recurse_symlinks=True` raises `NotImplementedError`: `**` never descends into directory symlinks. A trailing `**` follows the running interpreter (files too on 3.13+). The two rules pathlib changed mid-series follow the running interpreter by default (`native=True`): a trailing `/` is ignored before 3.11 and selects directories only from 3.11, and `a**` raises `ValueError` before 3.13 and is a plain wildcard from 3.13. `native=False` applies one rule on every version instead -- trailing `/` always selects directories only, `a**` is always a plain wildcard. | Loop-safe recursion without `st_dev`/`st_ino` (most backends' stats lack them) rules out following links. The `native` default keeps `LocalPath` answering exactly what the `pathlib` beside it answers; `native=False` is for a caller that wants one answer across backends and interpreters, which is what the contract suite asserts. |
|
|
59
59
|
| `Path.glob(on_error=)` / `rglob(on_error=)` | `pathlib` skips an unreadable directory silently, with no hook | Same default, plus a hook: `on_error(error)` is called per failed listing (the contract `walk()` and `os.walk` use), so a caller can log it or raise it. `error.filename` names the directory. | "Silently nothing" is the worst answer for a caller assembling something from a tree -- a configuration layer, a source list -- because the result is incomplete and nothing says so. The default stays pathlib's. |
|
|
60
|
-
| `Path.glob(bound_loops=)` / `rglob(bound_loops=)` | `pathlib` walks a directory loop until the recursion limit (and on Windows/3.9 eventually raises `WinError 1921` on the over-long path) | `bound_loops=True`
|
|
60
|
+
| `Path.glob(bound_loops=)` / `rglob(bound_loops=)` | `pathlib` walks a directory loop until the recursion limit (and on Windows/3.9 eventually raises `WinError 1921` on the over-long path) | `bound_loops=True` skips a directory whose `(st_dev, st_ino)` is already on the current descent path -- reachable below itself, which is what a loop is. A directory deliberately reachable under two sibling names still expands under both. Default `False` keeps pathlib's behaviour. | A Windows junction reports `is_symlink() == False`, so `recurse_symlinks=False` cannot see it and an ordinary config tree with a junction back into itself matched one file 128 times (and failed outright at `WinError 1921` on the path length). Identity is the only signal that works, and the ancestor chain is the part of it that means "loop" -- keying on everything seen also dropped a shared layer junctioned in twice. Backends whose stats lack identity are unaffected. |
|
|
61
61
|
| `Path.glob(None)` / `rglob(None)` | `pathlib` has no such form (its pattern is always applied to a directory) | `None` expands the pattern THIS PATH CARRIES: `LocalPath("/etc/*.conf").glob(None)` splits at the first wildcard and globs from there (`utils.glob.glob()`). `glob("")` still raises `ValueError`, as pathlib does. | An extension: a path that is itself a pattern is a common shape for config and CLI inputs, and before 0.9.4 `glob("")` was the accidental spelling for it. `None` cannot collide with a real pattern, so parity is untouched. |
|
|
62
62
|
| `Uri.query` | N/A (pathlib has no query) | The query is kept exactly as received (percent-encoded) and sent unchanged; `Query(...).decode()`/`to_dict()` decode. `Uri.parts` is `(source, path, query, fragment)`, not path segments (`segments` is). A `%2F` in a path decodes to `/` and is not distinguishable from a separator. | Decoding at parse time and re-encoding changed what reached the server (a signed URL's `%2B` became `+`, an escaped `&` split a value). The decoded-path model cannot keep `%2F` distinct. |
|
|
63
63
|
| `Path.copy(follow_symlinks=False)` on a symlink | CPython 3.14: copies the link as a link | Same: the link is recreated (not its metadata). Where the source cannot `readlink()` or the target cannot create links, raises `NotImplementedError` instead of copying content. `copy(recursive=True)` into its own subtree raises `OSError(EINVAL)` before creating anything. | Copying the link target's content under the link's permissions produced a 0o777 regular file. |
|
|
@@ -13,7 +13,7 @@ build-backend = "hatchling.build"
|
|
|
13
13
|
# `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
|
|
14
14
|
# Distribution name and import name differing is ordinary and intended.
|
|
15
15
|
name = "pathlib-next"
|
|
16
|
-
version = "0.9.
|
|
16
|
+
version = "0.9.9"
|
|
17
17
|
authors = [{ name = "Jose A" }]
|
|
18
18
|
description = "Generic Path Protocol based pathlib"
|
|
19
19
|
readme = "README.md"
|
|
@@ -103,14 +103,16 @@ silently absent and `from pathlib_next.uri import UriPath` raises
|
|
|
103
103
|
skipped in silence (pathlib's behaviour), so a caller could not tell
|
|
104
104
|
an unreadable directory from an absent one. `error.filename` names the
|
|
105
105
|
directory even when the backend left it unset.
|
|
106
|
-
- **`bound_loops=True`**
|
|
107
|
-
|
|
108
|
-
bounds a Windows junction loop,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
- **`bound_loops=True`** skips a directory whose `(st_dev, st_ino)` is
|
|
107
|
+
already on the CURRENT DESCENT PATH — a directory reachable below
|
|
108
|
+
itself, which is what a loop is. It bounds a Windows junction loop,
|
|
109
|
+
which `recurse_symlinks=False` cannot (a junction reports
|
|
110
|
+
`is_symlink() == False`) and which `pathlib` itself walks until the
|
|
111
|
+
recursion limit. One directory deliberately reachable under two
|
|
112
|
+
SIBLING names (a shared layer junctioned in twice) is not a loop and
|
|
113
|
+
both names expand — the rule is the ancestor chain, not everything
|
|
114
|
+
seen, the same line `find -L` draws. A backend whose stat carries no
|
|
115
|
+
identity (`MemPath`, most remote schemes) is walked unbounded.
|
|
114
116
|
- **`native=True`** (default) follows the running interpreter on the two
|
|
115
117
|
rules pathlib changed mid-series: a trailing `/` is ignored before 3.11
|
|
116
118
|
and selects directories only from 3.11; `a**` raises `ValueError`
|
|
@@ -1149,7 +1149,13 @@ class UriPath(Uri, Path):
|
|
|
1149
1149
|
def _make_child_relpath(
|
|
1150
1150
|
self, name: str, stat_hint: "FileStat" = None, **kwargs
|
|
1151
1151
|
) -> _ty.Self:
|
|
1152
|
-
|
|
1152
|
+
# The backend ALREADY BUILT, never `self.backend` -- that property
|
|
1153
|
+
# creates one, and naming a child is a pure-path operation: since
|
|
1154
|
+
# `/` walks segments through here, reading the property made
|
|
1155
|
+
# `UriPath("sftp://h/x") / "y"` import paramiko (and `http:` import
|
|
1156
|
+
# requests) just to spell a path. A listing has one by the time it
|
|
1157
|
+
# gets here, so its children still share the instance.
|
|
1158
|
+
inst = super()._make_child_relpath(name, backend=self._backend, **kwargs)
|
|
1153
1159
|
inst._stat_hint = stat_hint
|
|
1154
1160
|
return inst
|
|
1155
1161
|
|
|
@@ -337,39 +337,49 @@ def _recurse(
|
|
|
337
337
|
"""Yield `top` and every directory below it (plus every other entry when
|
|
338
338
|
`with_files`), never descending through a directory symlink.
|
|
339
339
|
|
|
340
|
-
With `bound_loops`, a directory
|
|
341
|
-
|
|
340
|
+
With `bound_loops`, a directory whose identity (`st_dev`, `st_ino`) is
|
|
341
|
+
already on the CURRENT DESCENT PATH is skipped: that is what a loop is
|
|
342
|
+
-- a directory reachable below itself -- and it bounds a Windows
|
|
342
343
|
junction loop, which no symlink check can see (a junction reports
|
|
343
|
-
`is_symlink() == False`).
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
`is_symlink() == False`).
|
|
345
|
+
|
|
346
|
+
The ancestor chain is the whole rule, not a set of everything seen. One
|
|
347
|
+
directory deliberately reachable under two SIBLING names (a shared
|
|
348
|
+
config layer junctioned in as `site-a` and `site-b`) is not a loop: the
|
|
349
|
+
walk terminates, and both names must expand. A `visited` set spanning
|
|
350
|
+
the traversal dropped the second one silently. `find -L` draws the line
|
|
351
|
+
in the same place.
|
|
352
|
+
|
|
353
|
+
A backend whose stat has no identity cannot be bounded this way and is
|
|
354
|
+
walked as before.
|
|
346
355
|
"""
|
|
347
356
|
yield top
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
visited.add(key)
|
|
353
|
-
stack = [top]
|
|
357
|
+
top_key = _identity(top) if bound_loops else None
|
|
358
|
+
# Each stack entry carries the identities of the directories the walk is
|
|
359
|
+
# currently inside -- pushed on descent, dropped with the branch.
|
|
360
|
+
stack = [(top, (top_key,) if top_key is not None else ())]
|
|
354
361
|
while stack:
|
|
355
|
-
directory = stack.pop()
|
|
362
|
+
directory, ancestors = stack.pop()
|
|
356
363
|
for child, stat in _scan(directory, on_error):
|
|
357
364
|
if not include_hidden and child.is_hidden():
|
|
358
365
|
continue
|
|
359
366
|
is_dir = _entry_is_dir(child, stat, follow_symlinks=False)
|
|
367
|
+
child_ancestors = ancestors
|
|
360
368
|
if is_dir and bound_loops:
|
|
361
369
|
key = _identity(child)
|
|
362
370
|
if key is not None:
|
|
363
|
-
if key in
|
|
364
|
-
#
|
|
365
|
-
#
|
|
366
|
-
#
|
|
371
|
+
if key in ancestors:
|
|
372
|
+
# The child IS one of its own ancestors: descending
|
|
373
|
+
# would walk the same tree again, without end.
|
|
374
|
+
# Skipped entirely -- yielding it would let the next
|
|
375
|
+
# pattern component match everything under it a
|
|
376
|
+
# second time, through the loop.
|
|
367
377
|
continue
|
|
368
|
-
|
|
378
|
+
child_ancestors = ancestors + (key,)
|
|
369
379
|
if is_dir or with_files:
|
|
370
380
|
yield child
|
|
371
381
|
if is_dir:
|
|
372
|
-
stack.append(child)
|
|
382
|
+
stack.append((child, child_ancestors))
|
|
373
383
|
|
|
374
384
|
|
|
375
385
|
def _scan(directory: _Globable, on_error=None):
|
|
@@ -479,3 +479,62 @@ def test_bound_loops_is_accepted_where_stats_have_no_identity():
|
|
|
479
479
|
(mem / "s").mkdir()
|
|
480
480
|
(mem / "s" / "x.py").write_text("x")
|
|
481
481
|
assert [str(p) for p in mem.glob("**/*.py", bound_loops=True)] == ["/m/s/x.py"]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _junction(link, target):
|
|
485
|
+
"""`mklink /J` returns 0 without elevation, unlike `os.symlink`."""
|
|
486
|
+
import subprocess
|
|
487
|
+
|
|
488
|
+
subprocess.run(
|
|
489
|
+
["cmd", "/d", "/c", "mklink", "/J", str(link), str(target)],
|
|
490
|
+
check=True,
|
|
491
|
+
capture_output=True,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.skipif(os.name != "nt", reason="junctions are Windows")
|
|
496
|
+
def test_bound_loops_keeps_a_directory_shared_under_two_names(tmp_path):
|
|
497
|
+
"""A loop is a directory reachable BELOW ITSELF -- not one reachable
|
|
498
|
+
twice. Two sibling junctions onto one shared directory (a common config
|
|
499
|
+
layer linked in as `site-a` and `site-b`) is a deliberate layout that
|
|
500
|
+
terminates on its own, and both names must expand. Keying the bound on
|
|
501
|
+
everything seen dropped the second one silently."""
|
|
502
|
+
conf = tmp_path / "conf"
|
|
503
|
+
conf.mkdir()
|
|
504
|
+
(conf / "own.yaml").write_text("own")
|
|
505
|
+
shared = tmp_path / "shared"
|
|
506
|
+
shared.mkdir()
|
|
507
|
+
(shared / "common.yaml").write_text("common")
|
|
508
|
+
_junction(conf / "site-a", shared)
|
|
509
|
+
_junction(conf / "site-b", shared)
|
|
510
|
+
|
|
511
|
+
base = pathlib_next.LocalPath(tmp_path)
|
|
512
|
+
expected = sorted(
|
|
513
|
+
str(q.relative_to(tmp_path)).replace("\\", "/")
|
|
514
|
+
for q in base.glob("conf/**/*.yaml")
|
|
515
|
+
)
|
|
516
|
+
bounded = sorted(
|
|
517
|
+
str(q.relative_to(tmp_path)).replace("\\", "/")
|
|
518
|
+
for q in base.glob("conf/**/*.yaml", bound_loops=True)
|
|
519
|
+
)
|
|
520
|
+
assert "conf/site-b/common.yaml" in expected # the default finds both
|
|
521
|
+
assert bounded == expected # and so does the bounded walk
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@pytest.mark.skipif(os.name != "nt", reason="junctions are Windows")
|
|
525
|
+
def test_bound_loops_still_bounds_a_loop_back_to_an_ancestor(tmp_path):
|
|
526
|
+
"""The case the flag exists for: unbounded, the two real files are
|
|
527
|
+
matched 128 times (and a deeper walk fails outright on the path
|
|
528
|
+
length)."""
|
|
529
|
+
conf = tmp_path / "conf"
|
|
530
|
+
(conf / "deep").mkdir(parents=True)
|
|
531
|
+
(conf / "10-base.yaml").write_text("a")
|
|
532
|
+
(conf / "deep" / "20-extra.yaml").write_text("b")
|
|
533
|
+
_junction(conf / "deep" / "loop", conf)
|
|
534
|
+
|
|
535
|
+
base = pathlib_next.LocalPath(tmp_path)
|
|
536
|
+
assert len(list(base.glob("conf/**/*.yaml"))) > 2
|
|
537
|
+
assert sorted(q.name for q in base.glob("conf/**/*.yaml", bound_loops=True)) == [
|
|
538
|
+
"10-base.yaml",
|
|
539
|
+
"20-extra.yaml",
|
|
540
|
+
]
|
|
@@ -74,7 +74,13 @@ def test_github_token_backend_does_not_follow_join_to_another_host():
|
|
|
74
74
|
# A str join stays on the original host, so the token goes nowhere new.
|
|
75
75
|
literal = root / "github://evil.invalid/x/y"
|
|
76
76
|
assert literal.source.host == "github.com"
|
|
77
|
-
assert literal.backend
|
|
77
|
+
assert literal.backend.token == "ghp_SECRET"
|
|
78
|
+
# Identity is only promised when the parent already HAS a backend: a
|
|
79
|
+
# join must not build one (it is a pure-path operation), so a child of a
|
|
80
|
+
# connectionless parent makes its own on first use.
|
|
81
|
+
assert root._backend is None
|
|
82
|
+
root.backend # now it exists, and the next child shares it
|
|
83
|
+
assert (root / "docs").backend is root.backend
|
|
78
84
|
|
|
79
85
|
# Crossing hosts needs a Uri argument, and that drops the token.
|
|
80
86
|
evil = root / GitHubPath("github://evil.invalid/x/y")
|
|
@@ -357,3 +357,35 @@ def test_a_same_endpoint_uri_destination_keeps_the_configured_backend():
|
|
|
357
357
|
base._coerce_target("http://trusted.invalid/api/b.txt").backend is base.backend
|
|
358
358
|
)
|
|
359
359
|
assert base._coerce_target("http://other.invalid/b.txt").backend is not base.backend
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_joining_a_path_never_builds_a_backend():
|
|
363
|
+
"""A join is a PURE-PATH operation. 0.9.7 routed `/` through
|
|
364
|
+
`_make_child_relpath()`, which read the `backend` property -- and that
|
|
365
|
+
property builds one, so spelling `UriPath("sftp://h/x") / "y"` imported
|
|
366
|
+
paramiko and raised ImportError without the extra. Every scheme, not
|
|
367
|
+
just the ones whose client happens to be installed."""
|
|
368
|
+
# Schemes whose class module does not import a client at import time.
|
|
369
|
+
# (`http:`/`s3:` cannot even be CONSTRUCTED without their extra -- that
|
|
370
|
+
# is deliberate and documented, and a separate matter from joining.)
|
|
371
|
+
# (`zip:`/`tar:` are left out on purpose: their "backend" is the shared
|
|
372
|
+
# archive handle from the registry, stdlib-only and opened lazily, so it
|
|
373
|
+
# is attached at construction and a join inherits it.)
|
|
374
|
+
for uri in ("sftp://h/mnt", "file:///tmp/x", "data:,abc"):
|
|
375
|
+
base = UriPath(uri)
|
|
376
|
+
child = base / "child"
|
|
377
|
+
assert child.path.endswith("/child")
|
|
378
|
+
assert child._backend is None, uri
|
|
379
|
+
assert base._backend is None, uri
|
|
380
|
+
assert base.joinpath("a", "b")._backend is None, uri
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_joining_shares_a_backend_that_already_exists():
|
|
384
|
+
"""What the property read was there for: children of a listing share the
|
|
385
|
+
parent's live connection. That still holds -- by then it exists."""
|
|
386
|
+
base = UriPath("sftp://h/mnt")
|
|
387
|
+
sentinel = object()
|
|
388
|
+
base._backend = sentinel
|
|
389
|
+
assert (base / "c")._backend is sentinel
|
|
390
|
+
assert base.joinpath("c", "d")._backend is sentinel
|
|
391
|
+
assert base._make_child_relpath("c")._backend is sentinel
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|