asanypath 2.2.0__tar.gz → 2.3.0__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 (76) hide show
  1. {asanypath-2.2.0 → asanypath-2.3.0}/PKG-INFO +59 -12
  2. {asanypath-2.2.0 → asanypath-2.3.0}/README.md +57 -6
  3. {asanypath-2.2.0 → asanypath-2.3.0}/pyproject.toml +1 -6
  4. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/__init__.py +2 -0
  5. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/__init__.pyi +1 -0
  6. asanypath-2.3.0/src/asanypath/_backend_opts.py +61 -0
  7. asanypath-2.3.0/src/asanypath/_transfer.py +425 -0
  8. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/_version.py +2 -2
  9. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/artifactory.py +5 -0
  10. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/azure.py +7 -1
  11. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/cache.py +16 -20
  12. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/cli.py +133 -32
  13. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/cloud.py +48 -11
  14. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/gcs.py +6 -1
  15. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/http.py +49 -1
  16. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/local.py +17 -8
  17. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/s3.py +13 -1
  18. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/ssh.py +1 -0
  19. asanypath-2.3.0/tests/test_backend_options.py +109 -0
  20. asanypath-2.3.0/tests/test_cache.py +497 -0
  21. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_cli.py +135 -0
  22. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_cloudpathmixin.py +346 -4
  23. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_ftp.py +19 -0
  24. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_http.py +93 -1
  25. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_s3.py +20 -1
  26. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_ssh.py +29 -0
  27. asanypath-2.2.0/src/asanypath/_transfer.py +0 -224
  28. asanypath-2.2.0/tests/test_cache.py +0 -242
  29. {asanypath-2.2.0 → asanypath-2.3.0}/.copilot/skills/async-python-lib/SKILL.md +0 -0
  30. {asanypath-2.2.0 → asanypath-2.3.0}/.copilot/skills/gitguro/SKILL.md +0 -0
  31. {asanypath-2.2.0 → asanypath-2.3.0}/.github/CODEOWNERS +0 -0
  32. {asanypath-2.2.0 → asanypath-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  33. {asanypath-2.2.0 → asanypath-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  34. {asanypath-2.2.0 → asanypath-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  35. {asanypath-2.2.0 → asanypath-2.3.0}/.github/dependabot.yml +0 -0
  36. {asanypath-2.2.0 → asanypath-2.3.0}/.github/workflows/publish.yml +0 -0
  37. {asanypath-2.2.0 → asanypath-2.3.0}/.github/workflows/unittests.yml +0 -0
  38. {asanypath-2.2.0 → asanypath-2.3.0}/.gitignore +0 -0
  39. {asanypath-2.2.0 → asanypath-2.3.0}/.pre-commit-config.yaml +0 -0
  40. {asanypath-2.2.0 → asanypath-2.3.0}/CODE_OF_CONDUCT.md +0 -0
  41. {asanypath-2.2.0 → asanypath-2.3.0}/CONTRIBUTING.md +0 -0
  42. {asanypath-2.2.0 → asanypath-2.3.0}/LICENSE +0 -0
  43. {asanypath-2.2.0 → asanypath-2.3.0}/SECURITY.md +0 -0
  44. {asanypath-2.2.0 → asanypath-2.3.0}/asanypath.png +0 -0
  45. {asanypath-2.2.0 → asanypath-2.3.0}/compose.yml +0 -0
  46. {asanypath-2.2.0 → asanypath-2.3.0}/hatch_build.py +0 -0
  47. {asanypath-2.2.0 → asanypath-2.3.0}/scripts/bench.py +0 -0
  48. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/_credstore.py +0 -0
  49. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/_resume.py +0 -0
  50. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/asanypath.py +0 -0
  51. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/asanypath.pyi +0 -0
  52. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/batcher.py +0 -0
  53. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/common.py +0 -0
  54. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/exceptions.py +0 -0
  55. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/ftp.py +0 -0
  56. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/options.py +0 -0
  57. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/py.typed +0 -0
  58. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/sync.py +0 -0
  59. {asanypath-2.2.0 → asanypath-2.3.0}/src/asanypath/unsupported.py +0 -0
  60. {asanypath-2.2.0 → asanypath-2.3.0}/tests/__init__.py +0 -0
  61. {asanypath-2.2.0 → asanypath-2.3.0}/tests/conftest.py +0 -0
  62. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_artifactory.py +0 -0
  63. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_asanypath.py +0 -0
  64. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_azure.py +0 -0
  65. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_batcher.py +0 -0
  66. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_commonpathmixin.py +0 -0
  67. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_credstore.py +0 -0
  68. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_gcs.py +0 -0
  69. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_http_auth.py +0 -0
  70. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_local.py +0 -0
  71. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_options.py +0 -0
  72. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_py_typed.py +0 -0
  73. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_resume.py +0 -0
  74. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_sync.py +0 -0
  75. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_syncpath.py +0 -0
  76. {asanypath-2.2.0 → asanypath-2.3.0}/tests/test_unsupported_path.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asanypath
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Async pathlib for local and cloud storage
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -19,12 +19,11 @@ Classifier: Programming Language :: Python :: 3.14
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: anyio>=4
22
- Requires-Dist: asanypath-native~=2.2.0
22
+ Requires-Dist: asanypath-native~=2.3.0
23
23
  Requires-Dist: msgspec>=0.22.0
24
24
  Requires-Dist: yarl>=1.2
25
25
  Provides-Extra: all
26
26
  Requires-Dist: aioftp>=0.21; extra == 'all'
27
- Requires-Dist: diskcache>=5.6; extra == 'all'
28
27
  Requires-Dist: keyring>=24; extra == 'all'
29
28
  Provides-Extra: bench
30
29
  Requires-Dist: adlfs; extra == 'bench'
@@ -38,15 +37,12 @@ Requires-Dist: gcloud-aio-storage; extra == 'bench'
38
37
  Requires-Dist: gcsfs; extra == 'bench'
39
38
  Requires-Dist: requests; extra == 'bench'
40
39
  Requires-Dist: s3fs; extra == 'bench'
41
- Provides-Extra: cache
42
- Requires-Dist: diskcache>=5.6; extra == 'cache'
43
40
  Provides-Extra: cli
44
41
  Requires-Dist: rich-click>=1.9.7; extra == 'cli'
45
42
  Provides-Extra: credstore
46
43
  Requires-Dist: keyring>=24; extra == 'credstore'
47
44
  Provides-Extra: dev
48
45
  Requires-Dist: aioftp>=0.21; extra == 'dev'
49
- Requires-Dist: diskcache>=5.6; extra == 'dev'
50
46
  Requires-Dist: keyring>=24; extra == 'dev'
51
47
  Requires-Dist: pre-commit>=4; extra == 'dev'
52
48
  Requires-Dist: pytest-asyncio>=1.3; extra == 'dev'
@@ -149,6 +145,46 @@ await src.copy(dst, recursive=True)
149
145
  await path.rmdir(recursive=True)
150
146
  ```
151
147
 
148
+ Copies automatically use spare transfer concurrency for large ranged reads.
149
+ The default target is 8 streams, with an 8 MiB minimum range size and at most
150
+ 8 range streams per file. Files smaller than 16 MiB stay single-stream. When
151
+ enough files are active to meet the target, automatic copies use one request
152
+ per file instead of adding ranges. The target is best-effort, not a minimum
153
+ number of requests that must always be launched.
154
+
155
+ S3, GCS, Azure, Artifactory, SSH, and range-capable HTTP/HTTPS sources use the
156
+ shared reader for local downloads and source reads in cross-backend copies.
157
+ FTP/FTPS, sources without size metadata, and HTTP servers without advertised
158
+ range support retain single-stream downloads. Local-only copies and native
159
+ server-side copy/rename operations retain their existing implementations.
160
+ Uploads remain single writes; this does not introduce multipart uploads.
161
+
162
+ Concurrent library copies share a default budget within the event loop. To
163
+ configure a batch explicitly:
164
+
165
+ ```python
166
+ import asyncio
167
+ from asanypath import TransferBudget
168
+
169
+ with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_file_cap=8):
170
+ await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
171
+ ```
172
+
173
+ The ceiling bounds concurrent reads in this budget, not metadata requests or
174
+ other processes. Parallel range buffers are bounded by the active streams
175
+ times the range size; cross-backend destinations still buffer the complete
176
+ object for their existing write API. `floor=1` disables automatic splitting.
177
+ Explicit `chunk_size` requests retain sequential streaming when splitting
178
+ would be inappropriate, and `chunk_size=0` disables streaming to local paths.
179
+
180
+ S3/Azure ETags, GCS generations, and HTTP conditional requests pin the source
181
+ version when available. Other sources are checked against fresh metadata
182
+ after the download, which cannot guarantee a snapshot if the backend does
183
+ not expose a reliable change token. Use `atomic=True` to keep an existing
184
+ local destination intact on failure. Moves remove the source only after a
185
+ successful copy. The updated Python package requires the matching native
186
+ extension for conditional cloud reads.
187
+
152
188
  For synchronous code (no `await` needed):
153
189
 
154
190
  ```python
@@ -219,9 +255,8 @@ Reading the same object or URL twice always re-downloads it — there is **no
219
255
  cache by default**, and the read path is unchanged unless you opt in, so
220
256
  non-caching callers pay zero overhead.
221
257
 
222
- Enable it with the optional `cache` extra (`pip install asanypath[cache]`,
223
- backed by [`diskcache`](https://pypi.org/project/diskcache/) — a pure-Python,
224
- zero-dependency store). Opt in per read with `use_cache=True`:
258
+ Caching is included in the native extension with no extra dependencies or
259
+ installation extras. Opt in per read with `use_cache=True`:
225
260
 
226
261
  ```python
227
262
  from asanypath import AsAnyPath
@@ -240,7 +275,7 @@ from asanypath import configure_cache
240
275
 
241
276
  configure_cache(
242
277
  directory="/var/cache/asanypath", # default: ~/.cache/asanypath
243
- size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
278
+ size_limit=2 * 1024**3, # stored key + payload budget (default 1 GiB, LRU)
244
279
  ttl=3600, # per-entry expiry in seconds (default: none)
245
280
  enabled=True, # make use_cache default to True process-wide
246
281
  )
@@ -255,9 +290,15 @@ Behaviour and trade-offs:
255
290
  default flips process-wide; an explicit `use_cache=False` always bypasses.
256
291
  - **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
257
292
  FTP). Local paths are never cached.
258
- - **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
293
+ - **Size-bounded, LRU-evicted;** disk I/O runs in a worker thread so the
259
294
  event loop stays responsive. Concurrent reads of the same key collapse to a
260
- single fetch.
295
+ single fetch within a cache instance. The byte budget counts UTF-8 keys and
296
+ payloads; SQLite metadata and transaction journals add filesystem overhead.
297
+ Objects larger than the budget are returned without being cached.
298
+ - **Byte-only native storage:** SQLite stores raw bytes, never Python pickles.
299
+ The database lives under `native-v1/` in the configured cache directory.
300
+ Legacy DiskCache files are ignored, not migrated or deleted. Multiple
301
+ processes can share the new store; use the same size limit across them.
261
302
  - **Staleness is on you.** Entries are keyed by URL with no revalidation — a
262
303
  cached object that changes upstream is served stale until it expires (`ttl`)
263
304
  or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
@@ -356,6 +397,12 @@ asanypath cp /local/file.txt s3://bucket/file.txt
356
397
  # Copy cloud → local
357
398
  asanypath cp s3://bucket/file.txt /local/file.txt
358
399
 
400
+ # Allow 32 concurrent files; fill spare capacity toward 8 streams for large files
401
+ asanypath cp -r s3://bucket/data/ ./data/ -j 32 --concurrency-floor 8
402
+
403
+ # Disable automatic range splitting (also supported by mv and sync)
404
+ asanypath cp s3://bucket/file.txt ./file.txt --concurrency-floor 1
405
+
359
406
  # Delete a file
360
407
  asanypath rm s3://bucket/file.txt --force
361
408
 
@@ -90,6 +90,46 @@ await src.copy(dst, recursive=True)
90
90
  await path.rmdir(recursive=True)
91
91
  ```
92
92
 
93
+ Copies automatically use spare transfer concurrency for large ranged reads.
94
+ The default target is 8 streams, with an 8 MiB minimum range size and at most
95
+ 8 range streams per file. Files smaller than 16 MiB stay single-stream. When
96
+ enough files are active to meet the target, automatic copies use one request
97
+ per file instead of adding ranges. The target is best-effort, not a minimum
98
+ number of requests that must always be launched.
99
+
100
+ S3, GCS, Azure, Artifactory, SSH, and range-capable HTTP/HTTPS sources use the
101
+ shared reader for local downloads and source reads in cross-backend copies.
102
+ FTP/FTPS, sources without size metadata, and HTTP servers without advertised
103
+ range support retain single-stream downloads. Local-only copies and native
104
+ server-side copy/rename operations retain their existing implementations.
105
+ Uploads remain single writes; this does not introduce multipart uploads.
106
+
107
+ Concurrent library copies share a default budget within the event loop. To
108
+ configure a batch explicitly:
109
+
110
+ ```python
111
+ import asyncio
112
+ from asanypath import TransferBudget
113
+
114
+ with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_file_cap=8):
115
+ await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
116
+ ```
117
+
118
+ The ceiling bounds concurrent reads in this budget, not metadata requests or
119
+ other processes. Parallel range buffers are bounded by the active streams
120
+ times the range size; cross-backend destinations still buffer the complete
121
+ object for their existing write API. `floor=1` disables automatic splitting.
122
+ Explicit `chunk_size` requests retain sequential streaming when splitting
123
+ would be inappropriate, and `chunk_size=0` disables streaming to local paths.
124
+
125
+ S3/Azure ETags, GCS generations, and HTTP conditional requests pin the source
126
+ version when available. Other sources are checked against fresh metadata
127
+ after the download, which cannot guarantee a snapshot if the backend does
128
+ not expose a reliable change token. Use `atomic=True` to keep an existing
129
+ local destination intact on failure. Moves remove the source only after a
130
+ successful copy. The updated Python package requires the matching native
131
+ extension for conditional cloud reads.
132
+
93
133
  For synchronous code (no `await` needed):
94
134
 
95
135
  ```python
@@ -160,9 +200,8 @@ Reading the same object or URL twice always re-downloads it — there is **no
160
200
  cache by default**, and the read path is unchanged unless you opt in, so
161
201
  non-caching callers pay zero overhead.
162
202
 
163
- Enable it with the optional `cache` extra (`pip install asanypath[cache]`,
164
- backed by [`diskcache`](https://pypi.org/project/diskcache/) — a pure-Python,
165
- zero-dependency store). Opt in per read with `use_cache=True`:
203
+ Caching is included in the native extension with no extra dependencies or
204
+ installation extras. Opt in per read with `use_cache=True`:
166
205
 
167
206
  ```python
168
207
  from asanypath import AsAnyPath
@@ -181,7 +220,7 @@ from asanypath import configure_cache
181
220
 
182
221
  configure_cache(
183
222
  directory="/var/cache/asanypath", # default: ~/.cache/asanypath
184
- size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
223
+ size_limit=2 * 1024**3, # stored key + payload budget (default 1 GiB, LRU)
185
224
  ttl=3600, # per-entry expiry in seconds (default: none)
186
225
  enabled=True, # make use_cache default to True process-wide
187
226
  )
@@ -196,9 +235,15 @@ Behaviour and trade-offs:
196
235
  default flips process-wide; an explicit `use_cache=False` always bypasses.
197
236
  - **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
198
237
  FTP). Local paths are never cached.
199
- - **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
238
+ - **Size-bounded, LRU-evicted;** disk I/O runs in a worker thread so the
200
239
  event loop stays responsive. Concurrent reads of the same key collapse to a
201
- single fetch.
240
+ single fetch within a cache instance. The byte budget counts UTF-8 keys and
241
+ payloads; SQLite metadata and transaction journals add filesystem overhead.
242
+ Objects larger than the budget are returned without being cached.
243
+ - **Byte-only native storage:** SQLite stores raw bytes, never Python pickles.
244
+ The database lives under `native-v1/` in the configured cache directory.
245
+ Legacy DiskCache files are ignored, not migrated or deleted. Multiple
246
+ processes can share the new store; use the same size limit across them.
202
247
  - **Staleness is on you.** Entries are keyed by URL with no revalidation — a
203
248
  cached object that changes upstream is served stale until it expires (`ttl`)
204
249
  or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
@@ -297,6 +342,12 @@ asanypath cp /local/file.txt s3://bucket/file.txt
297
342
  # Copy cloud → local
298
343
  asanypath cp s3://bucket/file.txt /local/file.txt
299
344
 
345
+ # Allow 32 concurrent files; fill spare capacity toward 8 streams for large files
346
+ asanypath cp -r s3://bucket/data/ ./data/ -j 32 --concurrency-floor 8
347
+
348
+ # Disable automatic range splitting (also supported by mv and sync)
349
+ asanypath cp s3://bucket/file.txt ./file.txt --concurrency-floor 1
350
+
300
351
  # Delete a file
301
352
  asanypath rm s3://bucket/file.txt --force
302
353
 
@@ -58,7 +58,6 @@ dev = [
58
58
  cli = ["rich-click>=1.9.7"]
59
59
  ftp = ["aioftp>=0.21"]
60
60
  credstore = ["keyring>=24"]
61
- cache = ["diskcache>=5.6"]
62
61
  bench = [
63
62
  "asyncssh>=2.14",
64
63
  "fsspec",
@@ -72,7 +71,7 @@ bench = [
72
71
  "aiofiles",
73
72
  "requests",
74
73
  ]
75
- all = ["asanypath[credstore,ftp,cache]"]
74
+ all = ["asanypath[credstore,ftp]"]
76
75
  [project.scripts]
77
76
  asanypath = "asanypath.cli:main"
78
77
 
@@ -151,10 +150,6 @@ disable_error_code = ["no-any-return", "misc", "union-attr", "type-var", "attr-d
151
150
  module = "asanypath.batcher"
152
151
  disallow_untyped_defs = false
153
152
 
154
- [[tool.mypy.overrides]]
155
- module = "diskcache.*"
156
- ignore_missing_imports = true
157
-
158
153
  [tool.pytest.ini_options]
159
154
  asyncio_mode = "auto"
160
155
  testpaths = ["tests"]
@@ -4,6 +4,7 @@
4
4
  # license that can be found in the LICENSE file or at
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
+ from asanypath._transfer import TransferBudget
7
8
  from asanypath.artifactory import ArtifactoryPath
8
9
  from asanypath.asanypath import (
9
10
  PROTOCOL_MAP,
@@ -53,6 +54,7 @@ __all__ = [
53
54
  "AsAnyPath",
54
55
  "AsyncPath",
55
56
  "SyncPath",
57
+ "TransferBudget",
56
58
  "BackendOptions",
57
59
  "AccessGrant",
58
60
  "AccessPolicy",
@@ -6,6 +6,7 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from ._transfer import TransferBudget as TransferBudget
9
10
  from ._version import __version__ as __version__
10
11
  from .artifactory import ArtifactoryPath as ArtifactoryPath
11
12
  from .asanypath import (
@@ -0,0 +1,61 @@
1
+ """CLI backend options: ``scheme.key=value`` mapped onto path constructor kwargs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ from collections.abc import Callable, Iterable
8
+ from typing import Any
9
+
10
+ from asanypath.asanypath import PROTOCOL_MAP, _match_base_url_flavor
11
+
12
+ Options = dict[str, dict[str, Any]]
13
+
14
+
15
+ def option_keys(cls: type) -> dict[str, Callable[[str], Any]]:
16
+ """Return ``{key: coercer}`` for the keyword arguments a path class accepts."""
17
+ keys: dict[str, Callable[[str], Any]] = {}
18
+ for name, param in inspect.signature(cls.__init__).parameters.items():
19
+ if name == "self" or param.kind in (
20
+ param.VAR_POSITIONAL,
21
+ param.VAR_KEYWORD,
22
+ param.POSITIONAL_ONLY,
23
+ ):
24
+ continue
25
+ ann = str(param.annotation)
26
+ keys[name] = int if re.search(r"\bint\b", ann) and "str" not in ann else str
27
+ return keys
28
+
29
+
30
+ def parse_options(specs: Iterable[str]) -> Options:
31
+ """Parse ``scheme.key=value`` specs; raise ValueError on anything unsupported."""
32
+ parsed: Options = {}
33
+ for spec in specs:
34
+ lhs, eq, value = spec.partition("=")
35
+ scheme, dot, key = lhs.partition(".")
36
+ if not eq or not dot or not key:
37
+ raise ValueError(f"invalid option {spec!r}; expected SCHEME.KEY=VALUE")
38
+ scheme = scheme.lower()
39
+ if scheme not in PROTOCOL_MAP:
40
+ raise ValueError(f"unknown protocol {scheme!r} in option {spec!r}")
41
+ keys = option_keys(PROTOCOL_MAP[scheme])
42
+ if key not in keys:
43
+ valid = ", ".join(sorted(keys)) or "none"
44
+ raise ValueError(f"{scheme} does not support option {key!r} (valid: {valid})")
45
+ try:
46
+ parsed.setdefault(scheme, {})[key] = keys[key](value)
47
+ except ValueError:
48
+ raise ValueError(f"invalid value {value!r} for {scheme}.{key}") from None
49
+ return parsed
50
+
51
+
52
+ def kwargs_for(path: str, options: Options) -> dict[str, Any]:
53
+ """Return the options that apply to the backend serving ``path``."""
54
+ if not options:
55
+ return {}
56
+ flavor = _match_base_url_flavor(path)
57
+ merged: dict[str, Any] = {}
58
+ for scheme, values in options.items():
59
+ if PROTOCOL_MAP[scheme] is flavor:
60
+ merged.update(values)
61
+ return merged