pathlib-next 0.8.0__tar.gz → 0.8.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.
Files changed (109) hide show
  1. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/CHANGELOG.md +41 -1
  2. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/PKG-INFO +1 -1
  3. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/pyproject.toml +1 -1
  4. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/fspath.py +31 -0
  5. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/__init__.py +19 -2
  6. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_smoke.py +7 -2
  7. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_utils.py +61 -0
  8. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/.gitignore +0 -0
  9. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/LICENSE +0 -0
  10. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/README.md +0 -0
  11. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/api/mempath.md +0 -0
  12. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/api/path.md +0 -0
  13. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/api/testing.md +0 -0
  14. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/api/uri.md +0 -0
  15. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/api/utils.md +0 -0
  16. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/benchmarks.md +0 -0
  17. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/changelog.md +0 -0
  18. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/divergences.md +0 -0
  19. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/guides/cli.md +0 -0
  20. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/guides/extending.md +0 -0
  21. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/guides/schemes.md +0 -0
  22. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/docs/index.md +0 -0
  23. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/az_listing.py +0 -0
  24. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/data_and_archive.py +0 -0
  25. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/ftp_listing.py +0 -0
  26. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/github_listing.py +0 -0
  27. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/gitlab_listing.py +0 -0
  28. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/gs_listing.py +0 -0
  29. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/http_listing.py +0 -0
  30. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/local_and_mem.py +0 -0
  31. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/s3_listing.py +0 -0
  32. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/sftp_sync.py +0 -0
  33. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/examples/webdav_roundtrip.py +0 -0
  34. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/mkdocs.yml +0 -0
  35. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/__init__.py +0 -0
  36. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/mempath.py +0 -0
  37. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/path.py +0 -0
  38. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/protocols/__init__.py +0 -0
  39. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/protocols/fs.py +0 -0
  40. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/protocols/io.py +0 -0
  41. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/py.typed +0 -0
  42. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/testing.py +0 -0
  43. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/tools/__init__.py +0 -0
  44. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/tools/uripath.py +0 -0
  45. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/__init__.py +0 -0
  46. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/query.py +0 -0
  47. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  48. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  49. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  50. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
  51. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  52. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  53. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/az.py +0 -0
  54. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/data.py +0 -0
  55. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/dav.py +0 -0
  56. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/file.py +0 -0
  57. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/ftp.py +0 -0
  58. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  59. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  60. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  61. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  62. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/github.py +0 -0
  63. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  64. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/gs.py +0 -0
  65. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/http.py +0 -0
  66. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/s3.py +0 -0
  67. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
  68. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  69. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  70. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/source.py +0 -0
  71. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/archive.py +0 -0
  72. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/checksum.py +0 -0
  73. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/glob.py +0 -0
  74. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/stat.py +0 -0
  75. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/sync.py +0 -0
  76. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/conftest.py +0 -0
  77. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_archive_uri.py +0 -0
  78. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_az.py +0 -0
  79. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_az_fake.py +0 -0
  80. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_contract.py +0 -0
  81. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_data_uri.py +0 -0
  82. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_dav.py +0 -0
  83. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_ftp.py +0 -0
  84. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_gitrepo.py +0 -0
  85. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_glob.py +0 -0
  86. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_gs.py +0 -0
  87. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_gs_fake.py +0 -0
  88. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_http.py +0 -0
  89. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_http_live.py +0 -0
  90. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_http_parser.py +0 -0
  91. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_local.py +0 -0
  92. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_mempath.py +0 -0
  93. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_parity_io.py +0 -0
  94. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_parity_pure.py +0 -0
  95. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_path_gaps.py +0 -0
  96. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_pathname.py +0 -0
  97. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_plugins.py +0 -0
  98. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_properties.py +0 -0
  99. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_query.py +0 -0
  100. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_s3.py +0 -0
  101. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_sftp.py +0 -0
  102. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_sftp_asyncssh.py +0 -0
  103. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_source.py +0 -0
  104. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_sync.py +0 -0
  105. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_uri_parse.py +0 -0
  106. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_uri_path.py +0 -0
  107. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_uripath_tool.py +0 -0
  108. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_walk.py +0 -0
  109. {pathlib_next-0.8.0 → pathlib_next-0.8.1}/tests/test_webdav.py +0 -0
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.1] - 2026-07-16
11
+
12
+ ### Fixed
13
+ - **`LocalPath.walk()`/`rm()` raised `TypeError: cannot unpack non-iterable
14
+ DirEntry object` on Python 3.11/3.12.** Those stdlib versions define their
15
+ own `pathlib.Path._scandir()` (returning raw `os.scandir()` `DirEntry`
16
+ objects), which sits ahead of this project's `_scandir()` in `LocalPath`'s
17
+ MRO and silently shadowed it -- breaking the `(name, FileStat|None)`
18
+ contract `walk()`/`glob()`/`rm()` expect. `LocalPath` now defines its own
19
+ `_scandir()` explicitly, reusing each `DirEntry`'s cached `lstat()` so the
20
+ perf win from `_scandir()` unification is preserved. On 3.12+, stdlib
21
+ `pathlib.Path` also defines its own `walk()` ahead of ours in the MRO, and
22
+ that stdlib `walk()` treats `self._scandir()`'s return value as a context
23
+ manager (`with scandir_it:`) -- our own `_scandir()` is a plain generator,
24
+ so stdlib's `walk()` raised `TypeError: 'generator' object does not
25
+ support the context manager protocol` even with the override above.
26
+ `LocalPath` now also overrides `walk()` explicitly, routing to this
27
+ project's own implementation regardless of Python version. Introduced in
28
+ 0.8.0 (`8cdbefa`), exposed on the CI 3.11/3.12 legs.
29
+ - **`Test No-Extras` CI job was red.** `tests/test_smoke.py` unconditionally
30
+ constructed an `http://`/`sftp://` `UriPath` in two tests, requiring
31
+ `requests`/`paramiko` even though the no-extras job installs neither; a
32
+ third test wrongly assumed `S3Path` requires `boto3` to register (it only
33
+ needs `botocore`, imported lazily inside a method). The two hard tests now
34
+ `pytest.importorskip` their extra; the `S3Path` check now probes for
35
+ `botocore`. Introduced in 0.8.0 (`94bd545`/`8cdbefa`), fixed with the
36
+ expected skip count (2) verified in a real no-extras venv.
37
+ - **Importable on a clean Python 3.9 install.** `pathlib_next.utils` used
38
+ `typing.ParamSpec` (3.10+), falling back to `typing_extensions.ParamSpec` and
39
+ then to a bare `typing.TypeVar`. A `TypeVar` has no `.args`, so the
40
+ `*args: K.args` annotations raised `AttributeError: 'TypeVar' object has no
41
+ attribute 'args'` at import time, making `import pathlib_next` fail on 3.9
42
+ whenever `typing_extensions` was absent. Since `typing_extensions` is not a
43
+ runtime dependency, this broke a plain `pip install pathlib_next` on 3.9. The
44
+ final fallback is now a minimal `ParamSpec` shim providing `.args`/`.kwargs`,
45
+ so no runtime dependency is added and 3.10+ keeps using `typing.ParamSpec`
46
+ unchanged.
47
+
10
48
  ## [0.8.0] - 2026-07-13
11
49
 
12
50
  ### Added
@@ -437,7 +475,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
437
475
  - Sync error handling.
438
476
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
439
477
 
440
- [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...HEAD
478
+ [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.8.1...HEAD
479
+ [0.8.1]: https://github.com/jose-pr/pathlib_next/compare/v0.8.0...v0.8.1
480
+ [0.8.0]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...v0.8.0
441
481
  [0.7.0]: https://github.com/jose-pr/pathlib_next/compare/v0.6.0...v0.7.0
442
482
  [0.6.0]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...v0.6.0
443
483
  [0.5.0]: https://github.com/jose-pr/pathlib_next/compare/v0.4.1...v0.5.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pathlib_next
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Generic Path Protocol based pathlib
5
5
  Project-URL: Homepage, https://github.com/jose-pr/pathlib_next/
6
6
  Project-URL: Documentation, https://jose-pr.github.io/pathlib_next/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pathlib_next"
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
  authors = [{ name = "Jose A" }]
9
9
  description = "Generic Path Protocol based pathlib"
10
10
  readme = "README.md"
@@ -11,6 +11,7 @@ import types as _types
11
11
  import typing as _ty
12
12
 
13
13
  from . import path as _proto
14
+ from .utils.stat import FileStat as _FileStat
14
15
 
15
16
  # pathlib.Path.stat()/chmod() only accept follow_symlinks= on 3.10+; below
16
17
  # that, LocalPath (which inherits them directly from pathlib.Path via MRO,
@@ -84,6 +85,36 @@ class LocalPath(
84
85
 
85
86
  __slots__ = ()
86
87
 
88
+ def _scandir(self):
89
+ # On 3.11+, `pathlib.Path._scandir()` (stdlib, ahead of ours in the
90
+ # MRO via WindowsPath/PosixPath) shadows `_proto.Path._scandir()`
91
+ # and returns `os.scandir(self)` directly -- an iterator of raw
92
+ # `os.DirEntry`, not this project's `(name, FileStat|None)` tuples.
93
+ # walk()/glob() expect the latter, so re-assert our own contract
94
+ # here regardless of what stdlib does in a given version. DirEntry's
95
+ # own cached lstat (`follow_symlinks=False`, matching walk()'s
96
+ # default) is reused instead of a fresh stat() round trip.
97
+ for entry in _os.scandir(self):
98
+ try:
99
+ stat = _FileStat.from_stat(entry.stat(follow_symlinks=False))
100
+ except OSError:
101
+ stat = None
102
+ yield entry.name, stat
103
+
104
+ def walk(self, top_down=True, on_error=None, follow_symlinks=False):
105
+ # 3.12+ stdlib `pathlib.Path.walk()` sits ahead of ours in the MRO
106
+ # and would otherwise win here. Its implementation calls
107
+ # `self._scandir()` expecting stdlib's own context-manager-capable
108
+ # `os.scandir(self)` return value ("with scandir_it:") -- our
109
+ # `_scandir()` override above is a plain generator, so stdlib's
110
+ # `walk()` breaks on it (`TypeError: 'generator' object does not
111
+ # support the context manager protocol`). Route explicitly to our
112
+ # own `walk()` (which drives `_scandir()` correctly) regardless of
113
+ # which one the MRO would otherwise resolve to.
114
+ return _proto.Path.walk(
115
+ self, top_down=top_down, on_error=on_error, follow_symlinks=follow_symlinks
116
+ )
117
+
87
118
  def stat(self, *, follow_symlinks=True):
88
119
  # pathlib.Path.stat() (next in MRO via WindowsPath/PosixPath) only
89
120
  # accepts follow_symlinks= on 3.10+; below that, lstat() is the
@@ -6,12 +6,29 @@ from email.utils import parsedate as _parsedate
6
6
  from threading import RLock
7
7
 
8
8
  try:
9
- ParamSpec = _ty.ParamSpec
9
+ ParamSpec = _ty.ParamSpec # 3.10+
10
10
  except AttributeError:
11
11
  try:
12
12
  from typing_extensions import ParamSpec
13
13
  except ImportError:
14
- ParamSpec = _ty.TypeVar
14
+
15
+ class ParamSpec(_ty.TypeVar, _root=True):
16
+ """Minimal `ParamSpec` stand-in for 3.9 without `typing_extensions`.
17
+
18
+ Only the `.args` / `.kwargs` attributes are needed: they appear in
19
+ annotations that must merely *evaluate*, and a plain `TypeVar` has
20
+ neither. `typing_extensions` is not a runtime dependency, so the
21
+ fallback keeps a bare 3.9 install importable.
22
+ """
23
+
24
+ @property
25
+ def args(self):
26
+ return self
27
+
28
+ @property
29
+ def kwargs(self):
30
+ return self
31
+
15
32
 
16
33
  K = ParamSpec("K")
17
34
  V = _ty.TypeVar("V")
@@ -4,6 +4,8 @@ These are the snippets from README.md's Quick start and examples/example.py
4
4
  that don't touch the network. On Python 3.9 this file is expected to fail
5
5
  until the Python 3.9 compatibility work lands.
6
6
  """
7
+ import pytest
8
+
7
9
  import pathlib_next
8
10
  from pathlib_next import Path, glob
9
11
  from pathlib_next.mempath import MemPath
@@ -27,6 +29,7 @@ def test_readme_local_path():
27
29
 
28
30
 
29
31
  def test_readme_http_path_construct_only():
32
+ pytest.importorskip("requests")
30
33
  http_path = UriPath("http://example.com/data.txt")
31
34
  assert http_path.source.scheme == "http"
32
35
 
@@ -80,6 +83,7 @@ def test_example_uripath_norm():
80
83
 
81
84
 
82
85
  def test_example_uripath_sftp_join():
86
+ pytest.importorskip("paramiko")
83
87
  sftp_root = UriPath("sftp://root@sftpexample/")
84
88
  sftp_root.as_posix()
85
89
  authkeys = sftp_root / "root/.ssh/authorized_keys"
@@ -111,9 +115,10 @@ def test_optional_schemes_presence_or_absence():
111
115
  except ImportError:
112
116
  assert not hasattr(schemes, "SftpPath")
113
117
 
114
- # Check s3
118
+ # Check s3 -- S3Path only needs botocore at import time (boto3 itself is
119
+ # a lazy import inside S3Backend.client()), so that's what gates it.
115
120
  try:
116
- import boto3 # noqa: F401
121
+ import botocore # noqa: F401
117
122
  assert hasattr(schemes, "S3Path")
118
123
  except ImportError:
119
124
  assert not hasattr(schemes, "S3Path")
@@ -1,3 +1,8 @@
1
+ import pathlib
2
+ import subprocess
3
+ import sys
4
+ import textwrap
5
+
1
6
  import pytest
2
7
 
3
8
  from pathlib_next import utils
@@ -306,3 +311,59 @@ def test_detect_format_does_not_peek_when_extension_is_conclusive():
306
311
  assert _detect_format("a.tar", _boom) == "tar"
307
312
 
308
313
 
314
+
315
+
316
+ @pytest.mark.skipif(
317
+ sys.version_info >= (3, 10),
318
+ reason="the ParamSpec fallback is only reachable on 3.9 (3.10+ has typing.ParamSpec)",
319
+ )
320
+ def test_paramspec_fallback_importable_without_typing_extensions():
321
+ # Regression: on Python 3.9 `typing.ParamSpec` does not exist, so the module
322
+ # falls back to `typing_extensions.ParamSpec` and, failing that, to a local
323
+ # shim. The old fallback was a bare `TypeVar`, which has no `.args`, so the
324
+ # `*args: K.args` annotations raised `AttributeError: 'TypeVar' object has no
325
+ # attribute 'args'` at import. It went unnoticed because every dev/CI env had
326
+ # `typing_extensions` installed transitively -- but it is not a runtime
327
+ # dependency, so a clean 3.9 install of the package could not import it.
328
+ #
329
+ # 3.9-only by nature, not by convenience: `Generic[K, V]` on 3.9 rejects a
330
+ # plain object ("Parameters to generic types must be types"), so the shim must
331
+ # subclass `TypeVar` -- and `TypeVar` stopped being subclassable in 3.12. The
332
+ # branch is unreachable on 3.10+ anyway, since `typing.ParamSpec` exists there.
333
+ #
334
+ # Runs in a subprocess with `typing_extensions` blocked so the fallback is
335
+ # exercised for real rather than mocked.
336
+ probe = textwrap.dedent(
337
+ """
338
+ import builtins
339
+
340
+ _real_import = builtins.__import__
341
+
342
+ def _blocked(name, *a, **k):
343
+ if name == "typing_extensions":
344
+ raise ImportError("emulating an env without typing_extensions")
345
+ return _real_import(name, *a, **k)
346
+
347
+ builtins.__import__ = _blocked
348
+
349
+ from pathlib_next.utils import LRU, K
350
+
351
+ assert K.args is not None, "ParamSpec fallback lost .args"
352
+ assert K.kwargs is not None, "ParamSpec fallback lost .kwargs"
353
+ lru = LRU(lambda x: x * 2, maxsize=4)
354
+ assert lru(21) == 42
355
+ assert lru(21) == 42
356
+ lru.invalidate(21)
357
+ assert lru(21) == 42
358
+ print("FALLBACK_OK")
359
+ """
360
+ )
361
+ result = subprocess.run(
362
+ [sys.executable, "-c", probe],
363
+ capture_output=True,
364
+ text=True,
365
+ cwd=pathlib.Path(utils.__file__).parents[3],
366
+ )
367
+ assert "FALLBACK_OK" in result.stdout, (
368
+ f"fallback import failed:\nstdout={result.stdout}\nstderr={result.stderr}"
369
+ )
File without changes
File without changes
File without changes
File without changes
File without changes