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.
Files changed (42) hide show
  1. keble_scraper_api/__init__.py +3 -0
  2. keble_scraper_api/api/dependencies.py +100 -0
  3. keble_scraper_api/api/errors.py +72 -0
  4. keble_scraper_api/api/router.py +473 -0
  5. keble_scraper_api/api/schemas.py +26 -0
  6. keble_scraper_api/application/admin.py +285 -0
  7. keble_scraper_api/application/artifacts.py +393 -0
  8. keble_scraper_api/application/auth.py +324 -0
  9. keble_scraper_api/application/failures.py +427 -0
  10. keble_scraper_api/application/fetching.py +72 -0
  11. keble_scraper_api/application/jobs.py +891 -0
  12. keble_scraper_api/application/proxy_health.py +126 -0
  13. keble_scraper_api/application/scheduling.py +24 -0
  14. keble_scraper_api/artifact_namespace.py +76 -0
  15. keble_scraper_api/container.py +361 -0
  16. keble_scraper_api/errors.py +25 -0
  17. keble_scraper_api/infrastructure/artifact_store.py +585 -0
  18. keble_scraper_api/infrastructure/callbacks.py +285 -0
  19. keble_scraper_api/infrastructure/celery_factory.py +63 -0
  20. keble_scraper_api/infrastructure/cloudflare.py +259 -0
  21. keble_scraper_api/infrastructure/credentials.py +75 -0
  22. keble_scraper_api/infrastructure/dispatcher.py +57 -0
  23. keble_scraper_api/infrastructure/http_fetcher.py +351 -0
  24. keble_scraper_api/infrastructure/mongo.py +2345 -0
  25. keble_scraper_api/infrastructure/proxy_router.py +460 -0
  26. keble_scraper_api/infrastructure/request_profiles.py +220 -0
  27. keble_scraper_api/infrastructure/url_policy.py +341 -0
  28. keble_scraper_api/main.py +162 -0
  29. keble_scraper_api/maintenance/__init__.py +1 -0
  30. keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
  31. keble_scraper_api/models.py +473 -0
  32. keble_scraper_api/protocols.py +947 -0
  33. keble_scraper_api/settings.py +484 -0
  34. keble_scraper_api/telemetry.py +109 -0
  35. keble_scraper_api/workers/celery_app.py +48 -0
  36. keble_scraper_api/workers/cli.py +106 -0
  37. keble_scraper_api/workers/runtime.py +78 -0
  38. keble_scraper_api/workers/tasks.py +97 -0
  39. keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
  40. keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
  41. keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
  42. keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,473 @@
1
+ """Persistence-owned mutable models and structural conversions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from decimal import Decimal
7
+ from enum import StrEnum
8
+ from typing import Literal, TypeAlias
9
+ from uuid import UUID, uuid5
10
+
11
+ from keble_helpers.typings import PydanticModelConfig
12
+ from pydantic import AwareDatetime, BaseModel, Field, SecretStr
13
+
14
+ from keble_scraper_contract import (
15
+ ProxyCandidateSource,
16
+ ProxyCandidateState,
17
+ ProxyHealthObservation,
18
+ ScrapeArtifactRef,
19
+ ScrapeAttemptView,
20
+ ScrapeCompletionEvent,
21
+ ScrapeCost,
22
+ ScrapeFailure,
23
+ ScrapeJobCreate,
24
+ ScrapeJobState,
25
+ ScrapeJobView,
26
+ ScrapeLocalFailureCode,
27
+ ScrapeRetentionClass,
28
+ ScrapeTransportPolicy,
29
+ ScrapeTransportRoute,
30
+ )
31
+
32
+ _COMPLETION_EVENT_NAMESPACE = UUID("6fe96d16-f8d8-4742-ae72-a20922cf72fd")
33
+
34
+
35
+ class PersistenceModel(BaseModel):
36
+ """Mutable internal model with strict snake-case persistence fields.
37
+
38
+ Side effects if changes:
39
+ - all Mongo document validation and update serialization.
40
+ """
41
+
42
+ model_config = PydanticModelConfig.default(extra="forbid", validate_default=True)
43
+
44
+
45
+ class ScrapeJobMongoObject(PersistenceModel):
46
+ """Canonical mutable lifecycle document for `scrape_jobs`.
47
+
48
+ Steps:
49
+ 1. retain immutable command, attempt limit, and deadline facts;
50
+ 2. retain mutable lease/retry/terminal lifecycle state;
51
+ 3. convert with immutable attempts into the public view.
52
+
53
+ Side effects if changes:
54
+ - idempotent creation, leases, optimistic cancellation, and job reads.
55
+ """
56
+
57
+ job_id: UUID
58
+ caller_identity: str
59
+ create: ScrapeJobCreate
60
+ request_hash: str
61
+ state: ScrapeJobState
62
+ revision: int = Field(ge=1)
63
+ attempt_count: int = Field(default=0, ge=0)
64
+ attempt_limit: int = Field(ge=1, le=10)
65
+ deadline_at: datetime
66
+ artifact_ids: list[UUID] = Field(default_factory=list)
67
+ total_cost: ScrapeCost = Field(default_factory=ScrapeCost)
68
+ created_at: datetime
69
+ updated_at: datetime
70
+ next_attempt_at: datetime | None = None
71
+ lease_owner: str | None = None
72
+ lease_expires_at: datetime | None = None
73
+ terminal_failure: ScrapeFailure | None = None
74
+ history_expires_at: datetime | None = None
75
+
76
+ def to_view(self, *, attempts: tuple[ScrapeAttemptView, ...]) -> ScrapeJobView:
77
+ """Convert Mongo lifecycle plus immutable attempts into the wire view.
78
+
79
+ Steps:
80
+ 1. copy immutable command and stored budget facts;
81
+ 2. attach caller-ordered immutable attempts;
82
+ 3. expose terminal/retry/lease state without persistence-only ownership fields.
83
+
84
+ Side effects if changes:
85
+ - API create/read/cancel responses and Shopify polling.
86
+ """
87
+
88
+ return ScrapeJobView(
89
+ job_id=self.job_id,
90
+ caller_identity=self.caller_identity,
91
+ create=self.create,
92
+ state=self.state,
93
+ revision=self.revision,
94
+ attempt_limit=self.attempt_limit,
95
+ deadline_at=self.deadline_at,
96
+ attempts=attempts,
97
+ artifact_ids=tuple(self.artifact_ids),
98
+ total_cost=self.total_cost,
99
+ created_at=self.created_at,
100
+ updated_at=self.updated_at,
101
+ next_attempt_at=self.next_attempt_at,
102
+ lease_expires_at=self.lease_expires_at,
103
+ terminal_failure=self.terminal_failure,
104
+ )
105
+
106
+
107
+ class ScrapeAttemptMongoObject(PersistenceModel):
108
+ """Immutable attempt row stored in `scrape_attempts`.
109
+
110
+ Side effects if changes:
111
+ - attempt ordering, retry evidence, and cost reconciliation.
112
+ """
113
+
114
+ job_id: UUID
115
+ attempt: ScrapeAttemptView
116
+
117
+
118
+ class ScrapeCacheEntryMongoObject(PersistenceModel):
119
+ """Validated transport cache pointer stored in `scrape_cache_entries`.
120
+
121
+ Side effects if changes:
122
+ - cache-mode behavior, conditional headers, and artifact retention.
123
+ """
124
+
125
+ request_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
126
+ artifact_ids: list[UUID]
127
+ etag: str | None = None
128
+ last_modified: str | None = None
129
+ response_status: int = Field(ge=100, le=599)
130
+ content_type: str
131
+ expires_at: datetime
132
+ updated_at: datetime
133
+
134
+
135
+ class ArtifactBatchLookup(PersistenceModel):
136
+ """Resolve ordered cache artifacts without converting absence into an exception.
137
+
138
+ Steps:
139
+ 1. retain artifacts in the exact cache-pointer order;
140
+ 2. retain every missing identity separately;
141
+ 3. let the execution service invalidate stale cache state before fetching.
142
+
143
+ Side effects if changes:
144
+ - stale cache pointers must never enter paid retry crash loops;
145
+ - cache completion and 304 handling consume this exact result.
146
+ """
147
+
148
+ artifacts: tuple[ScrapeArtifactRef, ...]
149
+ missing_artifact_ids: tuple[UUID, ...]
150
+
151
+
152
+ class ScrapeArtifactMongoObject(PersistenceModel):
153
+ """Private object metadata and reference count in `scrape_artifacts`.
154
+
155
+ Side effects if changes:
156
+ - content deduplication, signed downloads, pins, and orphan deletion.
157
+ """
158
+
159
+ artifact: ScrapeArtifactRef
160
+ object_key: str
161
+ reference_count: int = Field(default=0, ge=0)
162
+ deletion_lease_id: UUID | None = None
163
+ deletion_lease_expires_at: datetime | None = None
164
+
165
+
166
+ class ScrapeArtifactPinMongoObject(PersistenceModel):
167
+ """Version-fenced downstream retention reference for one artifact.
168
+
169
+ Steps:
170
+ 1. Persist one stable artifact-and-owner identity exactly once.
171
+ 2. Advance expiry only with a newer retention revision.
172
+ 3. Retain creation and last-policy-change timestamps for audit evidence.
173
+
174
+ Side effects if changes:
175
+ - artifact reference counts and orphan eligibility;
176
+ - Shopify latest-to-archive retention synchronization.
177
+ """
178
+
179
+ artifact_id: UUID
180
+ owner_identity: str
181
+ retention_class: ScrapeRetentionClass
182
+ retention_revision: int = Field(ge=1)
183
+ expires_at: datetime | None = None
184
+ created_at: datetime
185
+ updated_at: datetime
186
+
187
+
188
+ class ProxyCandidateMongoObject(PersistenceModel):
189
+ """Encrypted proxy candidate and bounded health summary.
190
+
191
+ Steps:
192
+ 1. retain a redacted endpoint beside encrypted credentials;
193
+ 2. retain bounded health observations and score;
194
+ 3. schedule the next quarantined health check.
195
+
196
+ Side effects if changes:
197
+ - experimental proxy selection, health decay, and credential redaction.
198
+ """
199
+
200
+ candidate_id: UUID
201
+ endpoint_redacted: str
202
+ encrypted_proxy_url: str
203
+ source: ProxyCandidateSource
204
+ source_reference: str
205
+ state: ProxyCandidateState
206
+ health_score: Decimal = Field(ge=0, le=1)
207
+ observations: list[ProxyHealthObservation] = Field(
208
+ default_factory=list, max_length=20
209
+ )
210
+ next_health_at: datetime
211
+ created_at: datetime
212
+ updated_at: datetime
213
+
214
+
215
+ class OutboxState(StrEnum):
216
+ """Finite callback outbox lifecycle.
217
+
218
+ Side effects if changes:
219
+ - callback retry, replay identity, and stale delivery repair.
220
+ """
221
+
222
+ PENDING = "PENDING"
223
+ LEASED = "LEASED"
224
+ DELIVERED = "DELIVERED"
225
+ DEAD_LETTER = "DEAD_LETTER"
226
+
227
+
228
+ class ScrapeOutboxMongoObject(PersistenceModel):
229
+ """Durable signed callback delivery row in `scrape_outbox`.
230
+
231
+ Steps:
232
+ 1. retain one deterministic completion event identity;
233
+ 2. own immutable attempt limit and mutable lease state;
234
+ 3. retain due/delivery timing for recovery and acknowledgement.
235
+
236
+ Side effects if changes:
237
+ - webhook delivery retries and exactly-once event identity.
238
+ """
239
+
240
+ event: ScrapeCompletionEvent
241
+ state: OutboxState = OutboxState.PENDING
242
+ attempt_count: int = Field(default=0, ge=0)
243
+ attempt_limit: int = Field(ge=1, le=10)
244
+ next_attempt_at: datetime
245
+ lease_owner: str | None = None
246
+ lease_expires_at: datetime | None = None
247
+ delivered_at: datetime | None = None
248
+ history_expires_at: datetime | None = None
249
+
250
+
251
+ class DispatchableJob(PersistenceModel):
252
+ """Minimal due-job projection required for queue publication.
253
+
254
+ Steps:
255
+ 1. retain the opaque job identity;
256
+ 2. retain only the queue-selecting transport policy;
257
+ 3. exclude attempts, artifacts, callbacks, and request bodies.
258
+
259
+ Side effects if changes:
260
+ - maintenance batch reads and Celery queue selection share this projection.
261
+ """
262
+
263
+ job_id: UUID
264
+ transport_policy: ScrapeTransportPolicy
265
+
266
+
267
+ class LeaseRecoverySummary(PersistenceModel):
268
+ """Count retryable and terminalized rows from one bounded recovery sweep.
269
+
270
+ Steps:
271
+ 1. count leases returned to durable retry state;
272
+ 2. count rows terminalized by immutable attempt/deadline policy;
273
+ 3. expose no individual job payloads to maintenance metrics.
274
+
275
+ Side effects if changes:
276
+ - maintenance telemetry and recovery acceptance tests use these counters.
277
+ """
278
+
279
+ recovered_count: int = Field(default=0, ge=0)
280
+ terminalized_count: int = Field(default=0, ge=0)
281
+
282
+
283
+ class HistoryRetentionSummary(PersistenceModel):
284
+ """Report one singleton, bounded history-retention transaction.
285
+
286
+ Steps:
287
+ 1. disclose whether this caller acquired the retention lease;
288
+ 2. count deleted terminal parents and immutable attempt children;
289
+ 3. count independently expired delivered callbacks and audit facts.
290
+
291
+ Side effects if changes:
292
+ - maintenance telemetry and retention-capacity tests consume these counters.
293
+ """
294
+
295
+ lease_acquired: bool
296
+ jobs_deleted: int = Field(default=0, ge=0)
297
+ attempts_deleted: int = Field(default=0, ge=0)
298
+ delivered_outbox_deleted: int = Field(default=0, ge=0)
299
+ audit_events_deleted: int = Field(default=0, ge=0)
300
+
301
+
302
+ class AuditAction(StrEnum):
303
+ """Audited operator/service side-effect family.
304
+
305
+ Side effects if changes:
306
+ - compliance exports and raw-download accountability.
307
+ """
308
+
309
+ JOB_CREATE = "JOB_CREATE"
310
+ JOB_CANCEL = "JOB_CANCEL"
311
+ ARTIFACT_DOWNLOAD = "ARTIFACT_DOWNLOAD"
312
+ ARTIFACT_PIN = "ARTIFACT_PIN"
313
+ PROXY_IMPORT = "PROXY_IMPORT"
314
+ CLEANUP_RUN = "CLEANUP_RUN"
315
+ BUDGET_UPDATE = "BUDGET_UPDATE"
316
+
317
+
318
+ class ScrapeAuditEventMongoObject(PersistenceModel):
319
+ """Immutable operator/service audit fact in `scrape_audit_events`.
320
+
321
+ Steps:
322
+ 1. retain the server-owned actor/action/target evidence;
323
+ 2. retain one occurrence time and absolute history-expiry clock;
324
+ 3. expose no credential or arbitrary provider payload.
325
+
326
+ Side effects if changes:
327
+ - security investigations, operations UI history, and bounded audit storage.
328
+ """
329
+
330
+ audit_id: UUID
331
+ actor_id: str
332
+ actor_role: str
333
+ action: AuditAction
334
+ target_identity: str
335
+ reason: str
336
+ request_id: str
337
+ occurred_at: datetime
338
+ history_expires_at: datetime | None = None
339
+
340
+
341
+ class FetchLease(PersistenceModel):
342
+ """Worker-owned job lease and immutable attempt ordinal.
343
+
344
+ Side effects if changes:
345
+ - late acknowledgement and lease-expiry recovery semantics.
346
+ """
347
+
348
+ job: ScrapeJobMongoObject
349
+ attempt_number: int = Field(ge=1)
350
+ lease_owner: str
351
+
352
+
353
+ class RouteReservation(PersistenceModel):
354
+ """Secret-bearing internal transport decision excluded from API models.
355
+
356
+ Side effects if changes:
357
+ - fetch egress, affinity, trust, and budget accounting.
358
+ """
359
+
360
+ route: ScrapeTransportRoute
361
+ proxy_url: SecretStr | None = None
362
+ session_id: str | None = None
363
+ reserved_cost_usd: Decimal = Field(default=Decimal("0"), ge=0)
364
+
365
+
366
+ class RouteReservationGranted(PersistenceModel):
367
+ """Successful preflight decision containing one priced route reservation.
368
+
369
+ Steps:
370
+ 1. discriminate the granted path explicitly;
371
+ 2. retain the secret-bearing reservation only inside the worker process;
372
+ 3. exclude failure/error objects from normal control flow.
373
+
374
+ Side effects if changes:
375
+ - execution and route tests consume this exact granted branch.
376
+ """
377
+
378
+ decision: Literal["GRANTED"] = "GRANTED"
379
+ reservation: RouteReservation
380
+
381
+
382
+ class RouteReservationDenied(PersistenceModel):
383
+ """Expected local route/cost denial represented without an exception object.
384
+
385
+ Steps:
386
+ 1. discriminate the denied path explicitly;
387
+ 2. retain the attempted route and finite local failure code;
388
+ 3. retain one bounded safe message for the typed attempt conversion.
389
+
390
+ Side effects if changes:
391
+ - zero-provider-attempt audit rows and bounded retry decisions depend on this branch.
392
+ """
393
+
394
+ decision: Literal["DENIED"] = "DENIED"
395
+ route: ScrapeTransportRoute
396
+ code: ScrapeLocalFailureCode
397
+ safe_message: str = Field(min_length=1, max_length=512)
398
+ retry_at: AwareDatetime | None = None
399
+
400
+
401
+ RouteReservationOutcome: TypeAlias = RouteReservationGranted | RouteReservationDenied
402
+
403
+
404
+ class CallbackTransportDisposition(StrEnum):
405
+ """Finite registered-callback HTTP outcome returned without exceptions-as-data.
406
+
407
+ Steps:
408
+ 1. distinguish acknowledged delivery from HTTP retryability;
409
+ 2. keep permanent receiver rejection separate from transient capacity;
410
+ 3. leave client/runtime exceptions outside this expected-result vocabulary.
411
+
412
+ Side effects if changes:
413
+ - outbox acknowledgement, backoff, and dead-letter timing.
414
+ """
415
+
416
+ DELIVERED = "DELIVERED"
417
+ RETRYABLE = "RETRYABLE"
418
+ PERMANENT = "PERMANENT"
419
+
420
+
421
+ class CallbackTransportResult(PersistenceModel):
422
+ """One sanitized HTTP callback result with an optional provider horizon.
423
+
424
+ Steps:
425
+ 1. retain only a finite disposition and bounded status evidence;
426
+ 2. retain a parsed future retry time when the receiver supplies one;
427
+ 3. omit response bodies, URLs, and credentials from durable control flow.
428
+
429
+ Side effects if changes:
430
+ - callback retries consume this exact expected transport contract.
431
+ """
432
+
433
+ disposition: CallbackTransportDisposition
434
+ status_code: int = Field(ge=100, le=599)
435
+ retry_at: datetime | None = None
436
+
437
+
438
+ def build_completion_event(
439
+ *,
440
+ job: ScrapeJobMongoObject,
441
+ state: ScrapeJobState,
442
+ artifacts: tuple[ScrapeArtifactRef, ...],
443
+ failure: ScrapeFailure | None,
444
+ occurred_at: datetime,
445
+ ) -> ScrapeCompletionEvent | None:
446
+ """Convert one terminal stored job into its deterministic callback event.
447
+
448
+ Steps:
449
+ 1. return no event when the command registered no callback audience;
450
+ 2. derive one stable event identity from job and terminal state;
451
+ 3. copy only registered correlation, artifact, and typed-failure evidence.
452
+
453
+ Side effects if changes:
454
+ - success, retry exhaustion, and lease recovery must emit the same outbox identity;
455
+ - callback HMAC replay protection depends on deterministic event IDs.
456
+ """
457
+
458
+ command = job.create
459
+ if command.callback_identity is None or command.callback_correlation_id is None:
460
+ return None
461
+ return ScrapeCompletionEvent(
462
+ event_id=uuid5(
463
+ _COMPLETION_EVENT_NAMESPACE,
464
+ f"{job.job_id}:{state.value}",
465
+ ),
466
+ audience=command.callback_identity,
467
+ job_id=job.job_id,
468
+ correlation_id=command.callback_correlation_id,
469
+ state=state,
470
+ artifacts=artifacts,
471
+ failure=failure,
472
+ occurred_at=occurred_at,
473
+ )