dnakit 0.1.0.dev0__py3-none-any.whl

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 (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/search/_http.py ADDED
@@ -0,0 +1,271 @@
1
+ """Bounded HTTPS helpers shared by public database adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Mapping
7
+ from typing import Any, BinaryIO, cast
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
+ from urllib.request import Request, urlopen
11
+
12
+ from dnakit._version import __version__
13
+ from dnakit.exceptions import ConfigurationError, QueryError
14
+
15
+ from .models import SearchConfig
16
+
17
+ _SENSITIVE_QUERY_KEYS = frozenset(
18
+ {
19
+ "access_token",
20
+ "api-key",
21
+ "api_key",
22
+ "apikey",
23
+ "auth",
24
+ "authorization",
25
+ "email",
26
+ "key",
27
+ "sig",
28
+ "signature",
29
+ "token",
30
+ "x-amz-credential",
31
+ "x-amz-security-token",
32
+ "x-amz-signature",
33
+ }
34
+ )
35
+
36
+
37
+ def require_text(value: object, name: str, *, max_length: int = 4_096) -> str:
38
+ if not isinstance(value, str) or not value.strip():
39
+ raise ConfigurationError(f"{name} must be non-empty text.", code="INVALID_QUERY")
40
+ resolved = value.strip()
41
+ if len(resolved) > max_length or "\x00" in resolved:
42
+ raise ConfigurationError(
43
+ f"{name} exceeds its text limit or contains NUL.", code="INVALID_QUERY"
44
+ )
45
+ return resolved
46
+
47
+
48
+ def require_https_base(base_url: str) -> str:
49
+ resolved = require_text(base_url, "base_url", max_length=2_048).rstrip("/")
50
+ parsed = urlsplit(resolved)
51
+ if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
52
+ raise ConfigurationError(
53
+ "Remote database base_url must be an HTTPS origin without credentials.",
54
+ code="INVALID_REMOTE_BASE_URL",
55
+ )
56
+ if parsed.query or parsed.fragment:
57
+ raise ConfigurationError(
58
+ "Remote database base_url cannot contain a query or fragment.",
59
+ code="INVALID_REMOTE_BASE_URL",
60
+ )
61
+ return resolved
62
+
63
+
64
+ def build_url(
65
+ base_url: str,
66
+ path: str,
67
+ params: Iterable[tuple[str, object]] = (),
68
+ ) -> str:
69
+ base = require_https_base(base_url)
70
+ if not isinstance(path, str) or not path.startswith("/") or "\x00" in path:
71
+ raise ConfigurationError("Remote API path must start with '/'.", code="INVALID_QUERY")
72
+ encoded: list[tuple[str, str]] = []
73
+ for key, raw_value in params:
74
+ require_text(key, "query parameter name", max_length=128)
75
+ values = raw_value if isinstance(raw_value, (tuple, list)) else (raw_value,)
76
+ for value in values:
77
+ if value is None:
78
+ continue
79
+ if isinstance(value, bool):
80
+ encoded.append((key, "true" if value else "false"))
81
+ elif isinstance(value, (str, int, float)) and not isinstance(value, complex):
82
+ encoded.append((key, str(value)))
83
+ else:
84
+ raise ConfigurationError(
85
+ "Remote query parameters must be scalar values or scalar sequences.",
86
+ code="INVALID_QUERY",
87
+ )
88
+ query = urlencode(encoded, doseq=True)
89
+ url = f"{base}{path}" + (f"?{query}" if query else "")
90
+ if len(url) > 16_384:
91
+ raise ConfigurationError("Remote query URL exceeds 16 KiB.", code="QUERY_URL_LIMIT")
92
+ return url
93
+
94
+
95
+ def redact_url(url: str) -> str:
96
+ parsed = urlsplit(url)
97
+ redacted = [
98
+ (key, "REDACTED" if key.casefold() in _SENSITIVE_QUERY_KEYS else value)
99
+ for key, value in parse_qsl(parsed.query, keep_blank_values=True)
100
+ ]
101
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(redacted), ""))
102
+
103
+
104
+ def _read_limited(stream: BinaryIO, *, max_bytes: int) -> bytes:
105
+ chunks: list[bytes] = []
106
+ observed = 0
107
+ while True:
108
+ chunk = stream.read(min(65_536, max_bytes - observed + 1))
109
+ if not chunk:
110
+ break
111
+ observed += len(chunk)
112
+ if observed > max_bytes:
113
+ raise QueryError(
114
+ "Remote database response exceeded max_response_bytes.",
115
+ code="QUERY_RESPONSE_SIZE_LIMIT",
116
+ context={"max_response_bytes": max_bytes},
117
+ )
118
+ chunks.append(chunk)
119
+ return b"".join(chunks)
120
+
121
+
122
+ def request_json(
123
+ url: str,
124
+ config: SearchConfig,
125
+ *,
126
+ provider: str,
127
+ headers: Mapping[str, str] | None = None,
128
+ ) -> dict[str, Any] | list[Any]:
129
+ request_headers = {
130
+ "Accept": "application/json",
131
+ "User-Agent": f"DNAKit/{__version__} public-database-adapter",
132
+ **dict(headers or {}),
133
+ }
134
+ request = Request(url, headers=request_headers, method="GET")
135
+ safe_url = redact_url(url)
136
+ try:
137
+ with urlopen(request, timeout=float(config.timeout)) as response:
138
+ final_url = getattr(response, "geturl", lambda: url)()
139
+ if urlsplit(final_url).scheme != "https":
140
+ raise QueryError(
141
+ "Remote database redirected to a non-HTTPS URL.",
142
+ code="INSECURE_QUERY_REDIRECT",
143
+ context={"provider": provider},
144
+ )
145
+ payload = _read_limited(response, max_bytes=config.max_response_bytes)
146
+ except HTTPError as exc:
147
+ raise QueryError(
148
+ "Remote database returned an HTTP error.",
149
+ code="QUERY_HTTP_ERROR",
150
+ context={"provider": provider, "status": exc.code, "url": safe_url},
151
+ ) from exc
152
+ except QueryError:
153
+ raise
154
+ except (OSError, TimeoutError, URLError) as exc:
155
+ raise QueryError(
156
+ "Could not reach the remote database.",
157
+ code="QUERY_NETWORK_ERROR",
158
+ context={"provider": provider, "url": safe_url},
159
+ ) from exc
160
+ try:
161
+ decoded = json.loads(payload.decode("utf-8"))
162
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
163
+ raise QueryError(
164
+ "Remote database returned invalid JSON.",
165
+ code="QUERY_RESPONSE_ERROR",
166
+ context={"provider": provider, "url": safe_url},
167
+ ) from exc
168
+ if not isinstance(decoded, (dict, list)):
169
+ raise QueryError(
170
+ "Remote database returned an unexpected JSON root.",
171
+ code="QUERY_RESPONSE_ERROR",
172
+ context={"provider": provider, "url": safe_url},
173
+ )
174
+ return cast(dict[str, Any] | list[Any], decoded)
175
+
176
+
177
+ def request_text(
178
+ url: str,
179
+ config: SearchConfig,
180
+ *,
181
+ provider: str,
182
+ headers: Mapping[str, str] | None = None,
183
+ method: str = "GET",
184
+ data: bytes | None = None,
185
+ ) -> str:
186
+ """Return one bounded UTF-8 text response from an HTTPS endpoint."""
187
+
188
+ if method not in {"GET", "POST"}:
189
+ raise ConfigurationError("Remote request method must be GET or POST.", code="INVALID_QUERY")
190
+ if data is not None and (not isinstance(data, bytes) or len(data) > 5_000_000):
191
+ raise ConfigurationError(
192
+ "Remote request body must be bytes no larger than 5 MB.",
193
+ code="QUERY_REQUEST_SIZE_LIMIT",
194
+ )
195
+ request_headers = {
196
+ "Accept": "text/plain",
197
+ "User-Agent": f"DNAKit/{__version__} public-database-adapter",
198
+ **dict(headers or {}),
199
+ }
200
+ request = Request(url, headers=request_headers, data=data, method=method)
201
+ safe_url = redact_url(url)
202
+ try:
203
+ with urlopen(request, timeout=float(config.timeout)) as response:
204
+ final_url = getattr(response, "geturl", lambda: url)()
205
+ if urlsplit(final_url).scheme != "https":
206
+ raise QueryError(
207
+ "Remote database redirected to a non-HTTPS URL.",
208
+ code="INSECURE_QUERY_REDIRECT",
209
+ context={"provider": provider},
210
+ )
211
+ payload = _read_limited(response, max_bytes=config.max_response_bytes)
212
+ except HTTPError as exc:
213
+ raise QueryError(
214
+ "Remote database returned an HTTP error.",
215
+ code="QUERY_HTTP_ERROR",
216
+ context={"provider": provider, "status": exc.code, "url": safe_url},
217
+ ) from exc
218
+ except QueryError:
219
+ raise
220
+ except (OSError, TimeoutError, URLError) as exc:
221
+ raise QueryError(
222
+ "Could not reach the remote database.",
223
+ code="QUERY_NETWORK_ERROR",
224
+ context={"provider": provider, "url": safe_url},
225
+ ) from exc
226
+ try:
227
+ return payload.decode("utf-8")
228
+ except UnicodeDecodeError as exc:
229
+ raise QueryError(
230
+ "Remote database returned invalid UTF-8 text.",
231
+ code="QUERY_RESPONSE_ERROR",
232
+ context={"provider": provider, "url": safe_url},
233
+ ) from exc
234
+
235
+
236
+ def mapping_records(value: object, *, key: str | None = None) -> tuple[dict[str, object], ...]:
237
+ selected = value
238
+ if key is not None:
239
+ if not isinstance(value, Mapping):
240
+ return ()
241
+ selected = value.get(key)
242
+ if isinstance(selected, Mapping):
243
+ return (dict(selected),)
244
+ if not isinstance(selected, list):
245
+ return ()
246
+ return tuple(dict(item) for item in selected if isinstance(item, Mapping))
247
+
248
+
249
+ def limited_records(
250
+ records: Iterable[Mapping[str, object]], config: SearchConfig
251
+ ) -> tuple[dict[str, object], ...]:
252
+ materialized = tuple(dict(record) for record in records)
253
+ if len(materialized) > config.max_records:
254
+ raise QueryError(
255
+ "Remote database returned more records than max_records.",
256
+ code="QUERY_RECORD_LIMIT",
257
+ context={"max_records": config.max_records, "observed": len(materialized)},
258
+ )
259
+ return materialized
260
+
261
+
262
+ __all__ = [
263
+ "build_url",
264
+ "limited_records",
265
+ "mapping_records",
266
+ "redact_url",
267
+ "request_json",
268
+ "request_text",
269
+ "require_https_base",
270
+ "require_text",
271
+ ]
@@ -0,0 +1,108 @@
1
+ """Shared validation and provenance helpers for query adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from urllib.parse import quote
7
+
8
+ from dnakit.core import (
9
+ Citation,
10
+ ExecutionMode,
11
+ ImplementationInfo,
12
+ ImplementationLabel,
13
+ OriginClass,
14
+ Provenance,
15
+ ReferenceInfo,
16
+ )
17
+ from dnakit.exceptions import ConfigurationError
18
+
19
+ from ._http import require_text
20
+ from .models import SearchConfig
21
+
22
+
23
+ def resolved_config(config: SearchConfig | None) -> SearchConfig:
24
+ if config is None:
25
+ return SearchConfig()
26
+ if not isinstance(config, SearchConfig):
27
+ raise TypeError("config must be SearchConfig or None.")
28
+ return config
29
+
30
+
31
+ def query_values(
32
+ value: str | int | Sequence[str | int],
33
+ *,
34
+ name: str = "query",
35
+ maximum: int = 100,
36
+ ) -> tuple[str, ...]:
37
+ raw: Iterable[str | int]
38
+ if isinstance(value, (str, int)) and not isinstance(value, bool):
39
+ raw = (value,)
40
+ elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
41
+ raw = value
42
+ else:
43
+ raise ConfigurationError(
44
+ f"{name} must be text, an integer, or a sequence of them.", code="INVALID_QUERY"
45
+ )
46
+ resolved: list[str] = []
47
+ for item in raw:
48
+ if isinstance(item, bool) or not isinstance(item, (str, int)):
49
+ raise ConfigurationError(
50
+ f"{name} values must be text or integers.", code="INVALID_QUERY"
51
+ )
52
+ resolved.append(require_text(str(item), name, max_length=1_024))
53
+ if len(resolved) > maximum:
54
+ raise ConfigurationError(
55
+ f"{name} accepts at most {maximum} values.", code="QUERY_VALUE_LIMIT"
56
+ )
57
+ if not resolved:
58
+ raise ConfigurationError(f"{name} must not be empty.", code="INVALID_QUERY")
59
+ return tuple(resolved)
60
+
61
+
62
+ def quoted_path_values(values: Sequence[str]) -> str:
63
+ return quote(",".join(values), safe=",")
64
+
65
+
66
+ def page_size(value: int, config: SearchConfig, *, maximum: int = 1_000) -> int:
67
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
68
+ raise ConfigurationError("page_size must be a positive integer.", code="INVALID_QUERY")
69
+ if value > maximum or value > config.max_records:
70
+ raise ConfigurationError(
71
+ "page_size exceeds the provider or SearchConfig record limit.",
72
+ code="QUERY_RECORD_LIMIT",
73
+ context={
74
+ "page_size": value,
75
+ "provider_maximum": maximum,
76
+ "max_records": config.max_records,
77
+ },
78
+ )
79
+ return value
80
+
81
+
82
+ def adapter_provenance(
83
+ provider: str,
84
+ *,
85
+ citation_url: str,
86
+ filters: Mapping[str, object] | None = None,
87
+ version: str | None = None,
88
+ ) -> Provenance:
89
+ return Provenance(
90
+ implementation=ImplementationInfo(
91
+ label=ImplementationLabel.ADAPTER,
92
+ execution_mode=ExecutionMode.EXTERNAL,
93
+ origin_class=OriginClass.INTEGRATION,
94
+ citations=(
95
+ Citation(provider.casefold().replace(" ", "-"), title=provider, url=citation_url),
96
+ ),
97
+ ),
98
+ reference=ReferenceInfo(provider, version=version, filters=filters),
99
+ )
100
+
101
+
102
+ __all__ = [
103
+ "adapter_provenance",
104
+ "page_size",
105
+ "query_values",
106
+ "quoted_path_values",
107
+ "resolved_config",
108
+ ]