keble-scraper-api 0.2.0__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.
- keble_scraper_api/__init__.py +3 -0
- keble_scraper_api/api/dependencies.py +100 -0
- keble_scraper_api/api/errors.py +72 -0
- keble_scraper_api/api/router.py +473 -0
- keble_scraper_api/api/schemas.py +26 -0
- keble_scraper_api/application/admin.py +285 -0
- keble_scraper_api/application/artifacts.py +393 -0
- keble_scraper_api/application/auth.py +324 -0
- keble_scraper_api/application/failures.py +427 -0
- keble_scraper_api/application/fetching.py +72 -0
- keble_scraper_api/application/jobs.py +891 -0
- keble_scraper_api/application/proxy_health.py +126 -0
- keble_scraper_api/application/scheduling.py +24 -0
- keble_scraper_api/artifact_namespace.py +76 -0
- keble_scraper_api/container.py +361 -0
- keble_scraper_api/errors.py +25 -0
- keble_scraper_api/infrastructure/artifact_store.py +585 -0
- keble_scraper_api/infrastructure/callbacks.py +285 -0
- keble_scraper_api/infrastructure/celery_factory.py +63 -0
- keble_scraper_api/infrastructure/cloudflare.py +259 -0
- keble_scraper_api/infrastructure/credentials.py +75 -0
- keble_scraper_api/infrastructure/dispatcher.py +57 -0
- keble_scraper_api/infrastructure/http_fetcher.py +351 -0
- keble_scraper_api/infrastructure/mongo.py +2345 -0
- keble_scraper_api/infrastructure/proxy_router.py +460 -0
- keble_scraper_api/infrastructure/request_profiles.py +220 -0
- keble_scraper_api/infrastructure/url_policy.py +341 -0
- keble_scraper_api/main.py +162 -0
- keble_scraper_api/maintenance/__init__.py +1 -0
- keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
- keble_scraper_api/models.py +473 -0
- keble_scraper_api/protocols.py +947 -0
- keble_scraper_api/settings.py +484 -0
- keble_scraper_api/telemetry.py +109 -0
- keble_scraper_api/workers/celery_app.py +48 -0
- keble_scraper_api/workers/cli.py +106 -0
- keble_scraper_api/workers/runtime.py +78 -0
- keble_scraper_api/workers/tasks.py +97 -0
- keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
- keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
- keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
- keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""Private OSS, shared local-development, and in-memory artifact stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from uuid import UUID, uuid4
|
|
12
|
+
|
|
13
|
+
import oss2
|
|
14
|
+
from pydantic import AnyHttpUrl
|
|
15
|
+
|
|
16
|
+
from keble_scraper_contract import ScrapeArtifactDownload
|
|
17
|
+
|
|
18
|
+
from keble_scraper_api.artifact_namespace import (
|
|
19
|
+
ArtifactStoreBackend,
|
|
20
|
+
ArtifactStoreIdentity,
|
|
21
|
+
)
|
|
22
|
+
from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
|
|
23
|
+
|
|
24
|
+
_AUDIT_QUERY_PARAMETER = "x-keble-audit-id"
|
|
25
|
+
_CONTENT_SHA256_METADATA_HEADER = "x-oss-meta-keble-content-sha256"
|
|
26
|
+
_NAMESPACE_OWNER_DIRECTORY = ".keble-namespace-owners"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _namespace_owner_filename(*, owner: str) -> str:
|
|
30
|
+
"""Return a stable non-reversible filename for one configured database owner.
|
|
31
|
+
|
|
32
|
+
Steps:
|
|
33
|
+
1. hash the validated non-secret owner identity with SHA-256;
|
|
34
|
+
2. append a fixed canary suffix for deterministic listing.
|
|
35
|
+
|
|
36
|
+
Side effects if changes:
|
|
37
|
+
- OSS and local-directory stores must derive identical owner claim names;
|
|
38
|
+
- changing the derivation requires an explicit namespace migration.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
if not owner or owner != owner.strip():
|
|
42
|
+
raise ValueError("artifact namespace owner must be a non-blank exact identity")
|
|
43
|
+
return f"{hashlib.sha256(owner.encode()).hexdigest()}.owner"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _validate_namespace_owner_keys(
|
|
47
|
+
*,
|
|
48
|
+
expected_key: str,
|
|
49
|
+
keys: Sequence[str],
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Reject any namespace claim set not owned solely by the expected database.
|
|
52
|
+
|
|
53
|
+
Steps:
|
|
54
|
+
1. normalize the finite listed claim keys;
|
|
55
|
+
2. accept an empty pre-claim set or the one exact expected key;
|
|
56
|
+
3. reject every foreign or multiple-owner state.
|
|
57
|
+
|
|
58
|
+
Side effects if changes:
|
|
59
|
+
- two databases must never both authorize destructive cleanup in one prefix.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
normalized = tuple(sorted(set(keys)))
|
|
63
|
+
if normalized not in {(), (expected_key,)}:
|
|
64
|
+
raise ScraperDomainError(
|
|
65
|
+
code=ScraperErrorCode.CONFIGURATION_INVALID,
|
|
66
|
+
message="artifact namespace is already claimed by another database owner",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class OssArtifactStore:
|
|
71
|
+
"""Store content-addressed objects in one private Alibaba OSS prefix.
|
|
72
|
+
|
|
73
|
+
Side effects if changes:
|
|
74
|
+
- raw/sanitized artifact durability, signed downloads, and storage deletion.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
endpoint: str,
|
|
81
|
+
bucket_name: str,
|
|
82
|
+
access_key_id: str,
|
|
83
|
+
access_key_secret: str,
|
|
84
|
+
prefix: str,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Construct one private bucket client from secret-system values.
|
|
87
|
+
|
|
88
|
+
Steps:
|
|
89
|
+
1. build the authenticated OSS bucket client;
|
|
90
|
+
2. retain normalized non-secret endpoint/bucket/prefix identity fields.
|
|
91
|
+
|
|
92
|
+
Side effects if changes:
|
|
93
|
+
- external OSS connections and object namespace ownership.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
auth = oss2.Auth(access_key_id, access_key_secret)
|
|
97
|
+
self._endpoint = endpoint.rstrip("/")
|
|
98
|
+
self._bucket_name = bucket_name
|
|
99
|
+
self._bucket = oss2.Bucket(auth, self._endpoint, bucket_name)
|
|
100
|
+
self._prefix = prefix.strip("/")
|
|
101
|
+
|
|
102
|
+
def namespace_identity(self) -> ArtifactStoreIdentity:
|
|
103
|
+
"""Return the exact OSS endpoint, bucket, and prefix identity.
|
|
104
|
+
|
|
105
|
+
Steps:
|
|
106
|
+
1. expose normalized non-secret OSS location fields.
|
|
107
|
+
|
|
108
|
+
Side effects if changes:
|
|
109
|
+
- Mongo marker equality fences endpoint/bucket/prefix drift.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
return ArtifactStoreIdentity(
|
|
113
|
+
backend=ArtifactStoreBackend.OSS,
|
|
114
|
+
endpoint=self._endpoint,
|
|
115
|
+
bucket=self._bucket_name,
|
|
116
|
+
prefix=self._prefix,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def ensure_namespace_owner(self, *, owner: str) -> None:
|
|
120
|
+
"""Claim one OSS prefix for one stable Mongo deployment identity.
|
|
121
|
+
|
|
122
|
+
Steps:
|
|
123
|
+
1. list the bounded owner-canary prefix and reject foreign claims;
|
|
124
|
+
2. idempotently write this owner's deterministic canary;
|
|
125
|
+
3. list and read back the claim before authorizing startup.
|
|
126
|
+
|
|
127
|
+
Side effects if changes:
|
|
128
|
+
- performs bounded OSS LIST, PUT, and GET operations at process startup;
|
|
129
|
+
- cross-database orphan deletion is blocked before serving work.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
owner_prefix = f"{self._prefix}/{_NAMESPACE_OWNER_DIRECTORY}/"
|
|
133
|
+
expected_key = owner_prefix + _namespace_owner_filename(owner=owner)
|
|
134
|
+
before = await asyncio.to_thread(
|
|
135
|
+
self._list_namespace_owner_keys,
|
|
136
|
+
owner_prefix=owner_prefix,
|
|
137
|
+
)
|
|
138
|
+
_validate_namespace_owner_keys(expected_key=expected_key, keys=before)
|
|
139
|
+
await asyncio.to_thread(
|
|
140
|
+
self._bucket.put_object,
|
|
141
|
+
expected_key,
|
|
142
|
+
owner.encode(),
|
|
143
|
+
headers={"Content-Type": "text/plain; charset=utf-8"},
|
|
144
|
+
)
|
|
145
|
+
after = await asyncio.to_thread(
|
|
146
|
+
self._list_namespace_owner_keys,
|
|
147
|
+
owner_prefix=owner_prefix,
|
|
148
|
+
)
|
|
149
|
+
_validate_namespace_owner_keys(expected_key=expected_key, keys=after)
|
|
150
|
+
response = await asyncio.to_thread(self._bucket.get_object, expected_key)
|
|
151
|
+
recorded_owner = await asyncio.to_thread(response.read)
|
|
152
|
+
if recorded_owner != owner.encode():
|
|
153
|
+
raise ScraperDomainError(
|
|
154
|
+
code=ScraperErrorCode.CONFIGURATION_INVALID,
|
|
155
|
+
message="artifact namespace owner canary content differs",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _list_namespace_owner_keys(self, *, owner_prefix: str) -> tuple[str, ...]:
|
|
159
|
+
"""List at most two OSS claim keys for split-owner detection.
|
|
160
|
+
|
|
161
|
+
Steps:
|
|
162
|
+
1. request the bounded owner prefix from OSS;
|
|
163
|
+
2. return exact object keys without response payload content.
|
|
164
|
+
|
|
165
|
+
Side effects if changes:
|
|
166
|
+
- startup LIST request count and cross-database collision detection.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
result = self._bucket.list_objects_v2(prefix=owner_prefix, max_keys=2)
|
|
170
|
+
return tuple(item.key for item in result.object_list)
|
|
171
|
+
|
|
172
|
+
def object_key(self, *, content_sha256: str) -> str:
|
|
173
|
+
"""Derive the prefix-scoped content key without external I/O.
|
|
174
|
+
|
|
175
|
+
Side effects if changes:
|
|
176
|
+
- Mongo object references and every OSS write/delete/sign operation.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
return f"{self._prefix}/{content_sha256[:2]}/{content_sha256}"
|
|
180
|
+
|
|
181
|
+
async def put_content_addressed(
|
|
182
|
+
self,
|
|
183
|
+
*,
|
|
184
|
+
content_sha256: str,
|
|
185
|
+
content: bytes,
|
|
186
|
+
content_type: str,
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Idempotently write one hash-keyed private object.
|
|
189
|
+
|
|
190
|
+
Side effects if changes:
|
|
191
|
+
- OSS bytes and later artifact metadata references.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
object_key = self.object_key(content_sha256=content_sha256)
|
|
195
|
+
await asyncio.to_thread(
|
|
196
|
+
self._bucket.put_object,
|
|
197
|
+
object_key,
|
|
198
|
+
content,
|
|
199
|
+
headers=self.write_headers(
|
|
200
|
+
content_sha256=content_sha256,
|
|
201
|
+
content_type=content_type,
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
return object_key
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def write_headers(*, content_sha256: str, content_type: str) -> dict[str, str]:
|
|
208
|
+
"""Build object metadata that makes content identity auditable by HEAD.
|
|
209
|
+
|
|
210
|
+
Steps:
|
|
211
|
+
1. retain the transport content type used by current readers;
|
|
212
|
+
2. persist the canonical SHA-256 as private user metadata.
|
|
213
|
+
|
|
214
|
+
Side effects if changes:
|
|
215
|
+
- OSS metadata and rollout integrity probes for newly written objects.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"Content-Type": content_type,
|
|
220
|
+
_CONTENT_SHA256_METADATA_HEADER: content_sha256,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async def signed_download(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
artifact_id: UUID,
|
|
227
|
+
object_key: str,
|
|
228
|
+
expires_seconds: int,
|
|
229
|
+
audit_id: UUID,
|
|
230
|
+
) -> ScrapeArtifactDownload:
|
|
231
|
+
"""Generate one short-lived GET signature with an OSS-log audit identity.
|
|
232
|
+
|
|
233
|
+
Side effects if changes:
|
|
234
|
+
- private raw artifact access duration and exact access-log correlation.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
url = await asyncio.to_thread(
|
|
238
|
+
self._bucket.sign_url,
|
|
239
|
+
"GET",
|
|
240
|
+
object_key,
|
|
241
|
+
expires_seconds,
|
|
242
|
+
params={_AUDIT_QUERY_PARAMETER: str(audit_id)},
|
|
243
|
+
)
|
|
244
|
+
return ScrapeArtifactDownload(
|
|
245
|
+
artifact_id=artifact_id,
|
|
246
|
+
download_url=AnyHttpUrl(url),
|
|
247
|
+
expires_at=datetime.now(UTC) + timedelta(seconds=expires_seconds),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def delete_many(self, *, object_keys: Sequence[str]) -> None:
|
|
251
|
+
"""Delete a bounded set of confirmed orphan objects.
|
|
252
|
+
|
|
253
|
+
Side effects if changes:
|
|
254
|
+
- irreversible OSS storage removal after repository eligibility checks.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
if object_keys:
|
|
258
|
+
await asyncio.to_thread(
|
|
259
|
+
self._bucket.batch_delete_objects, list(object_keys)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class LocalDirectoryArtifactStore:
|
|
264
|
+
"""Share content-addressed development artifacts across local processes.
|
|
265
|
+
|
|
266
|
+
Side effects if changes:
|
|
267
|
+
- local scraper API/worker artifact interoperability and cleanup paths;
|
|
268
|
+
- localhost artifact URLs consumed by `ScraperApiClient.read_verified`.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
def __init__(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
directory: str,
|
|
275
|
+
base_url: str,
|
|
276
|
+
prefix: str,
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Bind one isolated directory and its separately served localhost URL.
|
|
279
|
+
|
|
280
|
+
Steps:
|
|
281
|
+
1. normalize the configured directory and content-address prefix;
|
|
282
|
+
2. reject path-like prefixes that could escape the owned directory;
|
|
283
|
+
3. retain a URL root served by the local canary file server.
|
|
284
|
+
|
|
285
|
+
Side effects if changes:
|
|
286
|
+
- local filesystem namespace and development artifact download routing.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
normalized_prefix = prefix.strip("/")
|
|
290
|
+
prefix_parts = normalized_prefix.split("/")
|
|
291
|
+
if not normalized_prefix or any(
|
|
292
|
+
part in {"", ".", ".."} for part in prefix_parts
|
|
293
|
+
):
|
|
294
|
+
raise ValueError("local artifact prefix must contain safe path segments")
|
|
295
|
+
self._directory = Path(directory).expanduser().resolve()
|
|
296
|
+
self._base_url = base_url.rstrip("/")
|
|
297
|
+
self._prefix = normalized_prefix
|
|
298
|
+
|
|
299
|
+
def namespace_identity(self) -> ArtifactStoreIdentity:
|
|
300
|
+
"""Return the exact shared directory and content prefix identity.
|
|
301
|
+
|
|
302
|
+
Steps:
|
|
303
|
+
1. expose the resolved directory and normalized key prefix.
|
|
304
|
+
|
|
305
|
+
Side effects if changes:
|
|
306
|
+
- Mongo marker equality fences directory/backend swaps.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
return ArtifactStoreIdentity(
|
|
310
|
+
backend=ArtifactStoreBackend.LOCAL_DIRECTORY,
|
|
311
|
+
directory=str(self._directory),
|
|
312
|
+
prefix=self._prefix,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def ensure_namespace_owner(self, *, owner: str) -> None:
|
|
316
|
+
"""Claim one local directory/prefix for one database identity.
|
|
317
|
+
|
|
318
|
+
Steps:
|
|
319
|
+
1. create the private owner-canary directory;
|
|
320
|
+
2. reject existing foreign owner files before writing this canary;
|
|
321
|
+
3. re-list and verify exact owner content after the atomic file write.
|
|
322
|
+
|
|
323
|
+
Side effects if changes:
|
|
324
|
+
- local API/worker processes share one ownership fence;
|
|
325
|
+
- cross-database cleanup cannot enter a shared directory prefix.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
await asyncio.to_thread(self._ensure_namespace_owner_sync, owner=owner)
|
|
329
|
+
|
|
330
|
+
def _ensure_namespace_owner_sync(self, *, owner: str) -> None:
|
|
331
|
+
"""Perform the local claim under one filesystem lock.
|
|
332
|
+
|
|
333
|
+
Steps:
|
|
334
|
+
1. lock the owner directory across local processes;
|
|
335
|
+
2. validate, write, revalidate, and read back the exact owner claim.
|
|
336
|
+
|
|
337
|
+
Side effects if changes:
|
|
338
|
+
- creates one lock file plus one stable owner canary under the prefix.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
owner_directory = self._directory / self._prefix / _NAMESPACE_OWNER_DIRECTORY
|
|
342
|
+
owner_directory.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
expected_path = owner_directory / _namespace_owner_filename(owner=owner)
|
|
344
|
+
lock_path = owner_directory / ".claim.lock"
|
|
345
|
+
with lock_path.open("a+b") as lock_file:
|
|
346
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
347
|
+
before = tuple(
|
|
348
|
+
str(path)
|
|
349
|
+
for path in owner_directory.iterdir()
|
|
350
|
+
if path.is_file() and path.name != lock_path.name
|
|
351
|
+
)
|
|
352
|
+
_validate_namespace_owner_keys(
|
|
353
|
+
expected_key=str(expected_path),
|
|
354
|
+
keys=before,
|
|
355
|
+
)
|
|
356
|
+
expected_path.write_text(owner, encoding="utf-8")
|
|
357
|
+
after = tuple(
|
|
358
|
+
str(path)
|
|
359
|
+
for path in owner_directory.iterdir()
|
|
360
|
+
if path.is_file() and path.name != lock_path.name
|
|
361
|
+
)
|
|
362
|
+
_validate_namespace_owner_keys(
|
|
363
|
+
expected_key=str(expected_path),
|
|
364
|
+
keys=after,
|
|
365
|
+
)
|
|
366
|
+
if expected_path.read_text(encoding="utf-8") != owner:
|
|
367
|
+
raise ScraperDomainError(
|
|
368
|
+
code=ScraperErrorCode.CONFIGURATION_INVALID,
|
|
369
|
+
message="artifact namespace owner canary content differs",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def object_key(self, *, content_sha256: str) -> str:
|
|
373
|
+
"""Derive one prefix-scoped relative object key from a canonical hash.
|
|
374
|
+
|
|
375
|
+
Side effects if changes:
|
|
376
|
+
- Mongo object references, local paths, and localhost download URLs.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
if len(content_sha256) != 64 or any(
|
|
380
|
+
character not in "0123456789abcdef" for character in content_sha256
|
|
381
|
+
):
|
|
382
|
+
raise ValueError("content_sha256 must be a lowercase SHA-256 digest")
|
|
383
|
+
return f"{self._prefix}/{content_sha256[:2]}/{content_sha256}"
|
|
384
|
+
|
|
385
|
+
async def put_content_addressed(
|
|
386
|
+
self,
|
|
387
|
+
*,
|
|
388
|
+
content_sha256: str,
|
|
389
|
+
content: bytes,
|
|
390
|
+
content_type: str,
|
|
391
|
+
) -> str:
|
|
392
|
+
"""Atomically write one hash-keyed object into the shared directory.
|
|
393
|
+
|
|
394
|
+
Steps:
|
|
395
|
+
1. derive and validate the owned relative key;
|
|
396
|
+
2. create only its parent directories;
|
|
397
|
+
3. replace the final object atomically with equivalent hashed bytes.
|
|
398
|
+
|
|
399
|
+
Side effects if changes:
|
|
400
|
+
- shared local artifact bytes visible to API and worker processes.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
del content_type
|
|
404
|
+
object_key = self.object_key(content_sha256=content_sha256)
|
|
405
|
+
if hashlib.sha256(content).hexdigest() != content_sha256:
|
|
406
|
+
raise ValueError(
|
|
407
|
+
"local artifact content does not match its SHA-256 identity"
|
|
408
|
+
)
|
|
409
|
+
path = self._owned_path(object_key=object_key)
|
|
410
|
+
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
411
|
+
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
|
412
|
+
await asyncio.to_thread(temporary.write_bytes, content)
|
|
413
|
+
await asyncio.to_thread(temporary.replace, path)
|
|
414
|
+
return object_key
|
|
415
|
+
|
|
416
|
+
async def signed_download(
|
|
417
|
+
self,
|
|
418
|
+
*,
|
|
419
|
+
artifact_id: UUID,
|
|
420
|
+
object_key: str,
|
|
421
|
+
expires_seconds: int,
|
|
422
|
+
audit_id: UUID,
|
|
423
|
+
) -> ScrapeArtifactDownload:
|
|
424
|
+
"""Return the isolated localhost URL for one existing development object.
|
|
425
|
+
|
|
426
|
+
Steps:
|
|
427
|
+
1. validate that the persisted key remains inside the configured root;
|
|
428
|
+
2. require the content-addressed file to exist;
|
|
429
|
+
3. expose its local-server URL with the same audit query used by OSS.
|
|
430
|
+
|
|
431
|
+
Side effects if changes:
|
|
432
|
+
- development-only raw artifact access and local log reconciliation.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
path = self._owned_path(object_key=object_key)
|
|
436
|
+
if not await asyncio.to_thread(path.is_file):
|
|
437
|
+
raise FileNotFoundError(object_key)
|
|
438
|
+
return ScrapeArtifactDownload(
|
|
439
|
+
artifact_id=artifact_id,
|
|
440
|
+
download_url=AnyHttpUrl(
|
|
441
|
+
f"{self._base_url}/{object_key}?{_AUDIT_QUERY_PARAMETER}={audit_id}"
|
|
442
|
+
),
|
|
443
|
+
expires_at=datetime.now(UTC) + timedelta(seconds=expires_seconds),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def delete_many(self, *, object_keys: Sequence[str]) -> None:
|
|
447
|
+
"""Delete exact owned local objects while preserving unrelated files.
|
|
448
|
+
|
|
449
|
+
Side effects if changes:
|
|
450
|
+
- local maintenance cleanup and retained canary evidence.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
for object_key in object_keys:
|
|
454
|
+
path = self._owned_path(object_key=object_key)
|
|
455
|
+
await asyncio.to_thread(path.unlink, missing_ok=True)
|
|
456
|
+
|
|
457
|
+
def _owned_path(self, *, object_key: str) -> Path:
|
|
458
|
+
"""Resolve one persisted key and reject paths outside the owned root.
|
|
459
|
+
|
|
460
|
+
Side effects if changes:
|
|
461
|
+
- local filesystem traversal protection for reads, writes, and cleanup.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
path = (self._directory / object_key).resolve()
|
|
465
|
+
if not path.is_relative_to(self._directory):
|
|
466
|
+
raise ValueError(
|
|
467
|
+
"local artifact object key escaped its configured directory"
|
|
468
|
+
)
|
|
469
|
+
return path
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class InMemoryArtifactStore:
|
|
473
|
+
"""Deterministic object store for offline tests only.
|
|
474
|
+
|
|
475
|
+
Side effects if changes:
|
|
476
|
+
- unit-test artifact content and cleanup assertions.
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
def __init__(self) -> None:
|
|
480
|
+
"""Initialize isolated in-process bytes.
|
|
481
|
+
|
|
482
|
+
Steps:
|
|
483
|
+
1. create an empty content map and an unclaimed namespace owner slot.
|
|
484
|
+
|
|
485
|
+
Side effects if changes:
|
|
486
|
+
- each test must receive a fresh store to prevent leakage.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
self.objects: dict[str, bytes] = {}
|
|
490
|
+
self._namespace_owner: str | None = None
|
|
491
|
+
|
|
492
|
+
def namespace_identity(self) -> ArtifactStoreIdentity:
|
|
493
|
+
"""Return the fixed process-memory backend identity used only in tests.
|
|
494
|
+
|
|
495
|
+
Steps:
|
|
496
|
+
1. expose the non-durable memory key namespace.
|
|
497
|
+
|
|
498
|
+
Side effects if changes:
|
|
499
|
+
- Mongo markers distinguish memory from local and OSS backends.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
return ArtifactStoreIdentity(
|
|
503
|
+
backend=ArtifactStoreBackend.MEMORY,
|
|
504
|
+
prefix="memory",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
async def ensure_namespace_owner(self, *, owner: str) -> None:
|
|
508
|
+
"""Retain one in-process owner for deterministic offline tests.
|
|
509
|
+
|
|
510
|
+
Steps:
|
|
511
|
+
1. initialize the owner on first use;
|
|
512
|
+
2. accept exact repeats and reject a second owner.
|
|
513
|
+
|
|
514
|
+
Side effects if changes:
|
|
515
|
+
- unit-test cleanup remains isolated to one logical database owner.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
if self._namespace_owner is None:
|
|
519
|
+
self._namespace_owner = owner
|
|
520
|
+
elif self._namespace_owner != owner:
|
|
521
|
+
raise ScraperDomainError(
|
|
522
|
+
code=ScraperErrorCode.CONFIGURATION_INVALID,
|
|
523
|
+
message="artifact namespace is already claimed by another database owner",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def object_key(self, *, content_sha256: str) -> str:
|
|
527
|
+
"""Derive the isolated test key without mutating stored bytes.
|
|
528
|
+
|
|
529
|
+
Side effects if changes:
|
|
530
|
+
- offline metadata/object identity and cleanup assertions.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
return f"memory/{content_sha256[:2]}/{content_sha256}"
|
|
534
|
+
|
|
535
|
+
async def put_content_addressed(
|
|
536
|
+
self,
|
|
537
|
+
*,
|
|
538
|
+
content_sha256: str,
|
|
539
|
+
content: bytes,
|
|
540
|
+
content_type: str,
|
|
541
|
+
) -> str:
|
|
542
|
+
"""Store bytes by hash for deterministic test behavior.
|
|
543
|
+
|
|
544
|
+
Side effects if changes:
|
|
545
|
+
- offline artifact deduplication assertions.
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
object_key = self.object_key(content_sha256=content_sha256)
|
|
549
|
+
self.objects[object_key] = content
|
|
550
|
+
return object_key
|
|
551
|
+
|
|
552
|
+
async def signed_download(
|
|
553
|
+
self,
|
|
554
|
+
*,
|
|
555
|
+
artifact_id: UUID,
|
|
556
|
+
object_key: str,
|
|
557
|
+
expires_seconds: int,
|
|
558
|
+
audit_id: UUID,
|
|
559
|
+
) -> ScrapeArtifactDownload:
|
|
560
|
+
"""Return a non-network test URL only for an existing object.
|
|
561
|
+
|
|
562
|
+
Side effects if changes:
|
|
563
|
+
- offline raw-download tests.
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
if object_key not in self.objects:
|
|
567
|
+
raise KeyError(object_key)
|
|
568
|
+
return ScrapeArtifactDownload(
|
|
569
|
+
artifact_id=artifact_id,
|
|
570
|
+
download_url=AnyHttpUrl(
|
|
571
|
+
"https://artifacts.test/"
|
|
572
|
+
f"{object_key}?{_AUDIT_QUERY_PARAMETER}={audit_id}"
|
|
573
|
+
),
|
|
574
|
+
expires_at=datetime.now(UTC) + timedelta(seconds=expires_seconds),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
async def delete_many(self, *, object_keys: Sequence[str]) -> None:
|
|
578
|
+
"""Delete exact test keys without affecting other test instances.
|
|
579
|
+
|
|
580
|
+
Side effects if changes:
|
|
581
|
+
- maintenance cleanup assertions.
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
for object_key in object_keys:
|
|
585
|
+
self.objects.pop(object_key, None)
|