stage-cli 1.0.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 (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/services/sync.py ADDED
@@ -0,0 +1,1257 @@
1
+ import asyncio
2
+ import random
3
+ import time
4
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
5
+ from dataclasses import dataclass, field, replace
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ from stage.classify import (
9
+ classify_role,
10
+ resolve_eligibility,
11
+ screen_degree_scope,
12
+ screen_is_cs_role,
13
+ screen_is_internship,
14
+ screen_location,
15
+ to_quarantined,
16
+ )
17
+ from stage.dedup import resolve_duplicates
18
+ from stage.domain import (
19
+ BucketPlan,
20
+ Company,
21
+ CompanyDeferred,
22
+ CompanyFailed,
23
+ CompanyFinished,
24
+ CompanyStarted,
25
+ CompanyUnchanged,
26
+ CompanyVisit,
27
+ DetailFetch,
28
+ HttpValidator,
29
+ Job,
30
+ PlannedRequest,
31
+ QuarantinedJob,
32
+ RateState,
33
+ RequestLogged,
34
+ Rotation,
35
+ RotationMember,
36
+ SourceBlocked,
37
+ SourceCapped,
38
+ SourceFailed,
39
+ SourceFinished,
40
+ SourceFresh,
41
+ SourceResting,
42
+ SourceRotated,
43
+ SourceRunStats,
44
+ SourceStarted,
45
+ SourceVisit,
46
+ SyncEvent,
47
+ SyncFinished,
48
+ SyncOutcome,
49
+ SyncRun,
50
+ SyncStarted,
51
+ UnroutableCompanies,
52
+ WorkdayCrawl,
53
+ WorkdayCrawlStep,
54
+ WorkdayFacet,
55
+ is_resting,
56
+ rotate,
57
+ )
58
+ from stage.http import (
59
+ CEILING_BACKSTOP,
60
+ HostBudget,
61
+ HostBudgetExceededError,
62
+ HttpClient,
63
+ HttpError,
64
+ RatePosture,
65
+ ValidatorCache,
66
+ resolve,
67
+ )
68
+ from stage.normalize import (
69
+ canonical_apply_url,
70
+ detect_language,
71
+ resolve_location,
72
+ resolve_term,
73
+ )
74
+ from stage.sources import (
75
+ Adapter,
76
+ FeedAdapter,
77
+ FetchResult,
78
+ adapter_for_platform,
79
+ get_adapters,
80
+ get_feeds,
81
+ )
82
+ from stage.sources.workday import WorkdayAdapter
83
+ from stage.storage import AsyncRepository, SourceBatch, SourceBatchResult
84
+
85
+
86
+ class NoSourcesSelectedError(Exception):
87
+ pass
88
+
89
+
90
+ def _bucket_postures(
91
+ grouped: Mapping[str, tuple[Adapter, list[Company]]], feeds: Mapping[str, FeedAdapter]
92
+ ) -> dict[str, RatePosture]:
93
+ claims: list[tuple[tuple[str, ...], RatePosture]] = [
94
+ (
95
+ _adapter_buckets(adapter, companies),
96
+ resolve(
97
+ adapter.rate_profile, [company.rate_profile for company in companies]
98
+ ).sized_for(len(companies), _reserve_for(adapter)),
99
+ )
100
+ for adapter, companies in grouped.values()
101
+ ]
102
+ claims.extend(
103
+ (_bucket_keys(feed.hosts, feed.bucket_key), resolve(feed.rate_profile, []))
104
+ for feed in feeds.values()
105
+ )
106
+
107
+ postures: dict[str, RatePosture] = {}
108
+ for keys, posture in claims:
109
+ for bucket in keys:
110
+ stored = postures.get(bucket)
111
+ postures[bucket] = posture if stored is None else stored.strictest(posture)
112
+ return postures
113
+
114
+
115
+ def _bucket_keys(hosts: frozenset[str], bucket_key: str) -> tuple[str, ...]:
116
+ return (bucket_key,) if bucket_key else tuple(sorted(hosts))
117
+
118
+
119
+ def _adapter_buckets(adapter: Adapter, companies: Sequence[Company]) -> tuple[str, ...]:
120
+ if adapter.bucket_key:
121
+ return (adapter.bucket_key,)
122
+ return tuple(sorted(adapter.hosts_for(companies) or adapter.hosts))
123
+
124
+
125
+ def _plan_bounds(
126
+ adapter: Adapter,
127
+ source: str,
128
+ companies: Sequence[Company],
129
+ page_budgets: Mapping[str, int],
130
+ detail_budget: int,
131
+ ) -> list[tuple[str, str, int, int]]:
132
+ members: dict[str, int] = {}
133
+ worst: dict[str, int] = {}
134
+ for company in companies:
135
+ for bucket in _adapter_buckets(adapter, [company]):
136
+ members[bucket] = members.get(bucket, 0) + 1
137
+ worst[bucket] = worst.get(bucket, 0) + page_budgets.get(company.registry_key, 0)
138
+ bounds = [(bucket, source, members[bucket], worst[bucket]) for bucket in sorted(members)]
139
+ if detail_budget and bounds:
140
+ bucket, name, count, total = bounds[0]
141
+ bounds[0] = (bucket, name, count, total + detail_budget)
142
+ return bounds
143
+
144
+
145
+ def _reserve_for(adapter: Adapter) -> int:
146
+ return getattr(adapter, "retry_reserve", 0) + adapter.detail_budget
147
+
148
+
149
+ DAILY_RUNS = 4
150
+
151
+
152
+ async def _spent_today(repository: AsyncRepository, since: datetime) -> tuple[dict[str, int], bool]:
153
+ return await repository.requests_since(since)
154
+
155
+
156
+ def _daily_allowance(posture: RatePosture, spent: int, has_history: bool, buckets: int = 1) -> int:
157
+ if not has_history:
158
+ return posture.max_requests_per_run
159
+ span = max(1, buckets)
160
+ cap = min(CEILING_BACKSTOP * 2, posture.max_requests_per_run * DAILY_RUNS) * span
161
+ return max(0, min(posture.max_requests_per_run, cap - spent))
162
+
163
+
164
+ def _active_block(
165
+ rate_state: Mapping[str, RateState], buckets: Sequence[str], now: datetime
166
+ ) -> RateState | None:
167
+ for bucket in buckets:
168
+ state = rate_state.get(bucket)
169
+ if state is not None and state.is_blocked(now):
170
+ return state
171
+ return None
172
+
173
+
174
+ def _group_by_adapter(
175
+ companies: Sequence[Company],
176
+ ) -> tuple[dict[str, tuple[Adapter, list[Company]]], list[Company]]:
177
+ grouped: dict[str, tuple[Adapter, list[Company]]] = {}
178
+ unroutable: list[Company] = []
179
+ for company in companies:
180
+ if not company.enabled:
181
+ continue
182
+ adapter = adapter_for_platform(company.platform)
183
+ if adapter is None:
184
+ unroutable.append(company)
185
+ continue
186
+ entry = grouped.setdefault(adapter.name, (adapter, []))
187
+ entry[1].append(company)
188
+ return grouped, unroutable
189
+
190
+
191
+ def _registry_boards(
192
+ companies: Sequence[Company],
193
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
194
+ boards: set[str] = set()
195
+ unreadable: set[str] = set()
196
+ covered: set[str] = set()
197
+ for company in companies:
198
+ adapter = adapter_for_platform(company.platform)
199
+ if adapter is None:
200
+ continue
201
+ covered.add(adapter.name)
202
+ try:
203
+ boards.add(adapter.board_key(company))
204
+ except Exception:
205
+ unreadable.add(adapter.name)
206
+ return tuple(sorted(covered - unreadable)), tuple(sorted(boards))
207
+
208
+
209
+ def _select(
210
+ companies: Sequence[Company],
211
+ sources: Sequence[str] | None,
212
+ excluded: Sequence[str] | None = None,
213
+ ) -> tuple[dict[str, tuple[Adapter, list[Company]]], dict[str, FeedAdapter], list[Company]]:
214
+ grouped, unroutable = _group_by_adapter(companies)
215
+ feeds = dict(get_feeds())
216
+ known = set(grouped) | set(feeds) | set(get_adapters())
217
+ if sources is not None:
218
+ selected = set(sources)
219
+ unknown = selected - known
220
+ if unknown:
221
+ raise NoSourcesSelectedError(
222
+ f"no enabled companies for source(s): {', '.join(sorted(unknown))}"
223
+ )
224
+ grouped = {name: value for name, value in grouped.items() if name in selected}
225
+ feeds = {name: value for name, value in feeds.items() if name in selected}
226
+ if excluded:
227
+ dropped = set(excluded)
228
+ unknown = dropped - known
229
+ if unknown:
230
+ raise NoSourcesSelectedError(
231
+ f"unknown source(s) to exclude: {', '.join(sorted(unknown))}"
232
+ )
233
+ grouped = {name: value for name, value in grouped.items() if name not in dropped}
234
+ feeds = {name: value for name, value in feeds.items() if name not in dropped}
235
+ if not grouped and not feeds:
236
+ detail = ""
237
+ if unroutable:
238
+ platforms = ", ".join(sorted({company.platform.value for company in unroutable}))
239
+ detail = f" ({len(unroutable)} enabled row(s) sit on unrouted platform(s): {platforms})"
240
+ raise NoSourcesSelectedError(
241
+ f"the registry has no enabled companies with a matching adapter{detail}"
242
+ )
243
+ return grouped, feeds, unroutable
244
+
245
+
246
+ def normalize_batch(jobs: Sequence[Job]) -> tuple[tuple[Job, ...], tuple[QuarantinedJob, ...]]:
247
+ kept: list[Job] = []
248
+ rejected: list[QuarantinedJob] = []
249
+ for job in jobs:
250
+ location = resolve_location(job.location_raw)
251
+ term = resolve_term(
252
+ title=job.title_raw,
253
+ description=job.description,
254
+ structured_terms=job.signals.terms,
255
+ structured_season=job.signals.season,
256
+ )
257
+ role = classify_role(job.title_raw, job.description, job.signals.category)
258
+ language = detect_language(job.title_raw, job.description)
259
+ eligibility = resolve_eligibility(job)
260
+ normalized = replace(
261
+ job,
262
+ apply_url_canonical=canonical_apply_url(job.apply_url_raw),
263
+ location=location.bucket,
264
+ remote_scope=location.remote_scope,
265
+ term=term.term,
266
+ role=role.role,
267
+ language=language.language,
268
+ degree_requirement=eligibility.degree_requirement,
269
+ work_auth_flag=eligibility.work_auth_flag,
270
+ )
271
+ rejection = (
272
+ screen_is_internship(normalized)
273
+ or screen_location(normalized)
274
+ or screen_degree_scope(normalized)
275
+ or screen_is_cs_role(normalized)
276
+ )
277
+ if rejection is None:
278
+ kept.append(normalized)
279
+ else:
280
+ rejected.append(to_quarantined(normalized, rejection))
281
+ return tuple(kept), tuple(rejected)
282
+
283
+
284
+ async def _fetch_company(
285
+ adapter: Adapter,
286
+ company: Company,
287
+ client: HttpClient,
288
+ now: datetime,
289
+ facets: Mapping[tuple[str, str], WorkdayFacet],
290
+ details: Sequence[str],
291
+ crawls: Mapping[str, WorkdayCrawl],
292
+ page_budget: int,
293
+ final_workday_pass: bool,
294
+ ) -> tuple[Company, FetchResult | None, str, float, bool, bool]:
295
+ started = time.perf_counter()
296
+ try:
297
+ if isinstance(adapter, WorkdayAdapter):
298
+ result = await adapter.fetch(
299
+ company,
300
+ client,
301
+ now,
302
+ facets,
303
+ details,
304
+ crawl=crawls.get(_safe_board_key(adapter, company)),
305
+ page_budget=page_budget,
306
+ )
307
+ crawl = result.workday_crawl
308
+ if (
309
+ final_workday_pass
310
+ and crawl is not None
311
+ and not crawl.complete
312
+ and not crawl.discard
313
+ ):
314
+ message = (
315
+ "resumed once and still incomplete; postings stay open, crawl state discarded"
316
+ )
317
+ result = replace(
318
+ result,
319
+ degraded=f"{result.degraded}; {message}" if result.degraded else message,
320
+ workday_crawl=replace(crawl, discard=True),
321
+ )
322
+ else:
323
+ result = await adapter.fetch(company, client, now, facets, details)
324
+ except HostBudgetExceededError as exc:
325
+ elapsed = (time.perf_counter() - started) * 1000
326
+ return company, None, f"{type(exc).__name__}: {exc}", elapsed, False, True
327
+ except Exception as exc:
328
+ elapsed = (time.perf_counter() - started) * 1000
329
+ unreachable = isinstance(exc, HttpError)
330
+ return company, None, f"{type(exc).__name__}: {exc}", elapsed, True, unreachable
331
+ return company, result, "", (time.perf_counter() - started) * 1000, True, False
332
+
333
+
334
+ def _keepable_validators(cache: ValidatorCache, skip_urls: set[str]) -> tuple[HttpValidator, ...]:
335
+ return tuple(validator for url, validator in cache.pending.items() if url not in skip_urls)
336
+
337
+
338
+ def _advance_cursor(
339
+ settled: Sequence[RateState], rotation: Rotation, bucket: str, now: datetime
340
+ ) -> tuple[RateState, ...]:
341
+ if not rotation.rotating:
342
+ return tuple(
343
+ state.with_cursor("", now)
344
+ if state.bucket == bucket and state.rotation_cursor
345
+ else state
346
+ for state in settled
347
+ )
348
+ updated = [
349
+ state.with_cursor(rotation.cursor, now) if state.bucket == bucket else state
350
+ for state in settled
351
+ ]
352
+ if not any(state.bucket == bucket for state in updated):
353
+ updated.append(RateState(bucket=bucket, updated_at=now, rotation_cursor=rotation.cursor))
354
+ return tuple(updated)
355
+
356
+
357
+ def _bucket_plans(
358
+ bounds: Sequence[tuple[str, str, int, int]], postures: Mapping[str, RatePosture]
359
+ ) -> tuple[BucketPlan, ...]:
360
+ planned: dict[str, int] = {}
361
+ worst: dict[str, int] = {}
362
+ sources: dict[str, list[str]] = {}
363
+ for bucket, source, companies, worst_case in bounds:
364
+ planned[bucket] = planned.get(bucket, 0) + companies
365
+ worst[bucket] = worst.get(bucket, 0) + worst_case
366
+ sources.setdefault(bucket, []).append(source)
367
+ return tuple(
368
+ BucketPlan(
369
+ bucket=bucket,
370
+ sources=tuple(sorted(sources[bucket])),
371
+ planned=planned[bucket],
372
+ worst_case=worst[bucket],
373
+ ceiling=postures[bucket].max_requests_per_run
374
+ if bucket in postures
375
+ else RatePosture().max_requests_per_run,
376
+ )
377
+ for bucket in sorted(planned)
378
+ )
379
+
380
+
381
+ def _safe_plan(adapter: Adapter, company: Company) -> tuple[tuple[str, ...], str]:
382
+ try:
383
+ return adapter.plan(company), ""
384
+ except Exception as exc:
385
+ return (), f"{type(exc).__name__}: {exc}"
386
+
387
+
388
+ def _is_resting(
389
+ adapter: Adapter,
390
+ company: Company,
391
+ visits: Mapping[str, SourceVisit],
392
+ now: datetime,
393
+ ) -> bool:
394
+ visit = visits.get(_safe_board_key(adapter, company))
395
+ return visit is not None and is_resting(visit, now)
396
+
397
+
398
+ def _partial_reason(
399
+ failed_sources: Sequence[str],
400
+ unroutable: Sequence[Company],
401
+ blocked_sources: Sequence[str],
402
+ deferred_sources: Sequence[str],
403
+ ) -> str:
404
+ reasons = []
405
+ if failed_sources:
406
+ reasons.append(f"{len(failed_sources)} source(s) failed")
407
+ if blocked_sources:
408
+ reasons.append(f"{len(blocked_sources)} source(s) blocked")
409
+ if deferred_sources:
410
+ reasons.append(f"{len(deferred_sources)} source(s) stopped on their host budget")
411
+ if unroutable:
412
+ reasons.append(f"{len(unroutable)} row(s) no adapter could route")
413
+ return "; ".join(reasons)
414
+
415
+
416
+ def _stalest_first(
417
+ adapter: Adapter, companies: Sequence[Company], last_success: Mapping[str, datetime | None]
418
+ ) -> list[Company]:
419
+ def age(company: Company) -> tuple[int, datetime]:
420
+ seen = last_success.get(_safe_board_key(adapter, company))
421
+ return (1, seen) if seen is not None else (0, datetime.min.replace(tzinfo=UTC))
422
+
423
+ return sorted(companies, key=age)
424
+
425
+
426
+ def _safe_board_key(adapter: Adapter, company: Company) -> str:
427
+ try:
428
+ return adapter.board_key(company)
429
+ except Exception:
430
+ return f"<unresolved>:{company.registry_key}"
431
+
432
+
433
+ def _drain(client: HttpClient, source: str) -> list[RequestLogged]:
434
+ return [
435
+ RequestLogged(
436
+ source=source,
437
+ method=record.method,
438
+ url=record.url,
439
+ status=record.status,
440
+ elapsed_ms=record.elapsed_ms,
441
+ attempt=record.attempt,
442
+ error=record.error,
443
+ )
444
+ for record in client.drain_log()
445
+ ]
446
+
447
+
448
+ @dataclass(slots=True)
449
+ class FetchHarvest:
450
+ collected: list[Job] = field(default_factory=list)
451
+ closable: list[str] = field(default_factory=list)
452
+ unchanged: list[str] = field(default_factory=list)
453
+ skip_urls: set[str] = field(default_factory=set)
454
+ visits: list[CompanyVisit] = field(default_factory=list)
455
+ resolved_facets: list[WorkdayFacet] = field(default_factory=list)
456
+ forgotten_facets: list[WorkdayFacet] = field(default_factory=list)
457
+ workday_crawls: list[WorkdayCrawlStep] = field(default_factory=list)
458
+ detail_outcomes: list[DetailFetch] = field(default_factory=list)
459
+ errors: int = 0
460
+ deferred_boards: int = 0
461
+ succeeded: bool = False
462
+
463
+
464
+ @dataclass(slots=True)
465
+ class RunLedger:
466
+ stats: list[SourceRunStats] = field(default_factory=list)
467
+ failed: list[str] = field(default_factory=list)
468
+ succeeded: list[bool] = field(default_factory=list)
469
+ blocked: list[str] = field(default_factory=list)
470
+ deferred: list[str] = field(default_factory=list)
471
+ plan_bounds: list[tuple[str, str, int, int]] = field(default_factory=list)
472
+
473
+
474
+ async def _merge(
475
+ streams: Sequence[tuple[str, AsyncIterator[SyncEvent]]],
476
+ ledger: "RunLedger",
477
+ ) -> AsyncIterator[SyncEvent]:
478
+ if len(streams) == 1:
479
+ source, stream = streams[0]
480
+ try:
481
+ async for event in stream:
482
+ yield event
483
+ except asyncio.CancelledError:
484
+ raise
485
+ except Exception as stream_exc:
486
+ error = f"{type(stream_exc).__name__}: {stream_exc}"
487
+ ledger.failed.append(source)
488
+ ledger.stats.append(SourceRunStats(source=source, errors=1))
489
+ yield SourceFailed(source=source, error=error)
490
+ return
491
+
492
+ queue: asyncio.Queue[SyncEvent | tuple[str, Exception] | None] = asyncio.Queue()
493
+
494
+ async def pump(source: str, stream: AsyncIterator[SyncEvent]) -> None:
495
+ try:
496
+ async for event in stream:
497
+ await queue.put(event)
498
+ except asyncio.CancelledError:
499
+ raise
500
+ except Exception as exc:
501
+ await queue.put((source, exc))
502
+ finally:
503
+ queue.put_nowait(None)
504
+
505
+ tasks = [asyncio.create_task(pump(source, stream)) for source, stream in streams]
506
+ try:
507
+ done = 0
508
+ while done < len(tasks):
509
+ item = await queue.get()
510
+ if item is None:
511
+ done += 1
512
+ elif isinstance(item, tuple):
513
+ source, queued_exc = item
514
+ error = f"{type(queued_exc).__name__}: {queued_exc}"
515
+ ledger.failed.append(source)
516
+ ledger.stats.append(SourceRunStats(source=source, errors=1))
517
+ yield SourceFailed(source=source, error=error)
518
+ else:
519
+ yield item
520
+ finally:
521
+ for task in tasks:
522
+ task.cancel()
523
+ await asyncio.gather(*tasks, return_exceptions=True)
524
+
525
+
526
+ async def _plan_company_source(
527
+ adapter: Adapter, companies: Sequence[Company], validators: dict[str, HttpValidator]
528
+ ) -> AsyncIterator[SyncEvent]:
529
+ for company in companies:
530
+ urls, error = _safe_plan(adapter, company)
531
+ if error:
532
+ yield CompanyFailed(
533
+ source=adapter.name, company=company.name, error=error, elapsed_ms=0.0
534
+ )
535
+ continue
536
+ for url in urls:
537
+ cached = validators.get(url)
538
+ has_validator = cached is not None and cached.usable
539
+ yield PlannedRequest(
540
+ source=adapter.name,
541
+ company=company.name,
542
+ url=url,
543
+ has_validator=has_validator,
544
+ expectation="likely 304 (validator on file)" if has_validator else "full response",
545
+ )
546
+
547
+
548
+ async def _rehearse_company_source(
549
+ adapter: Adapter,
550
+ source_name: str,
551
+ ordered: Sequence[Company],
552
+ seed: dict[str, HttpValidator],
553
+ ledger: "RunLedger",
554
+ page_budgets: Mapping[str, int],
555
+ detail_budget: int,
556
+ source_clock: float,
557
+ ) -> AsyncIterator[SyncEvent]:
558
+ ledger.plan_bounds.extend(
559
+ _plan_bounds(adapter, source_name, ordered, page_budgets, detail_budget)
560
+ )
561
+ async for event in _plan_company_source(adapter, ordered, seed):
562
+ yield event
563
+ yield SourceFinished(
564
+ source=source_name,
565
+ fetched=0,
566
+ added=0,
567
+ updated=0,
568
+ closed=0,
569
+ failed_companies=0,
570
+ elapsed_ms=(time.perf_counter() - source_clock) * 1000,
571
+ )
572
+
573
+
574
+ def _drop_recently_refreshed(
575
+ adapter: Adapter,
576
+ companies: Sequence[Company],
577
+ last_success: Mapping[str, datetime | None],
578
+ open_crawls: frozenset[str],
579
+ run_started_at: datetime,
580
+ window_h: float,
581
+ skip: bool,
582
+ ) -> tuple[list[Company], int]:
583
+ if skip or window_h <= 0:
584
+ return list(companies), 0
585
+ since = run_started_at - timedelta(hours=window_h)
586
+ kept = [
587
+ company
588
+ for company in companies
589
+ if _safe_board_key(adapter, company) in open_crawls
590
+ or (seen := last_success.get(_safe_board_key(adapter, company))) is None
591
+ or seen < since
592
+ ]
593
+ return kept, len(companies) - len(kept)
594
+
595
+
596
+ def _drop_resting(
597
+ adapter: Adapter,
598
+ companies: Sequence[Company],
599
+ board_visits: Mapping[str, SourceVisit],
600
+ run_started_at: datetime,
601
+ skip: bool,
602
+ ) -> tuple[list[Company], int]:
603
+ if skip:
604
+ return list(companies), 0
605
+ awake = [
606
+ company
607
+ for company in companies
608
+ if not _is_resting(adapter, company, board_visits, run_started_at)
609
+ ]
610
+ return awake, len(companies) - len(awake)
611
+
612
+
613
+ async def _persist_source_batch(
614
+ repository: AsyncRepository,
615
+ template: SourceBatch,
616
+ collected: Sequence[Job],
617
+ ) -> tuple[SourceBatchResult, float, float]:
618
+ normalize_clock = time.perf_counter()
619
+ kept, rejected = await asyncio.to_thread(normalize_batch, list(collected))
620
+ normalize_ms = (time.perf_counter() - normalize_clock) * 1000
621
+ write_clock = time.perf_counter()
622
+ counts = await repository.apply_source_batch(replace(template, jobs=kept, quarantined=rejected))
623
+ return counts, normalize_ms, (time.perf_counter() - write_clock) * 1000
624
+
625
+
626
+ async def _run_company_source(
627
+ repository: AsyncRepository,
628
+ adapter: Adapter,
629
+ companies: Sequence[Company],
630
+ run_started_at: datetime,
631
+ shuffler: random.Random,
632
+ dry_run: bool,
633
+ ledger: "RunLedger",
634
+ rate_state: Mapping[str, RateState],
635
+ budgets: dict[str, HostBudget],
636
+ postures: Mapping[str, RatePosture],
637
+ force_refresh: bool,
638
+ spent_today: Mapping[str, int],
639
+ has_history: bool,
640
+ ) -> AsyncIterator[SyncEvent]:
641
+ source_name = adapter.name
642
+ buckets = _adapter_buckets(adapter, companies)
643
+ block = _active_block(rate_state, buckets, run_started_at)
644
+ if block is not None:
645
+ ledger.blocked.append(source_name)
646
+ if not dry_run:
647
+ ledger.stats.append(SourceRunStats(source=source_name, blocked=True))
648
+ yield SourceBlocked(
649
+ source=source_name,
650
+ bucket=block.bucket,
651
+ blocked_until=block.blocked_until or run_started_at,
652
+ remaining_s=block.blocks_remaining_s(run_started_at),
653
+ reason=block.reason,
654
+ consecutive_failures=block.consecutive_failures,
655
+ )
656
+ return
657
+
658
+ rotation_bucket = buckets[0]
659
+ stored = rate_state.get(rotation_bucket)
660
+ workday_adapter = adapter if isinstance(adapter, WorkdayAdapter) else None
661
+ is_workday = workday_adapter is not None
662
+ window_h = postures.get(rotation_bucket, RatePosture()).refresh_interval_h
663
+ board_visits = {
664
+ visit.board: visit for visit in await repository.all_visits() if visit.source == source_name
665
+ }
666
+ last_success = {board: visit.last_success_at for board, visit in board_visits.items()}
667
+ rotation = rotate(
668
+ [RotationMember(key=company.registry_key) for company in companies],
669
+ cursor=stored.rotation_cursor if stored is not None else "",
670
+ budget=adapter.rotation_slice,
671
+ )
672
+ covered = set(rotation.selected)
673
+ ordered = [company for company in companies if company.registry_key in covered]
674
+ source_clock = time.perf_counter()
675
+ fetch_clock = time.perf_counter()
676
+
677
+ open_crawls = (
678
+ frozenset(await repository.load_workday_crawls())
679
+ if workday_adapter is not None
680
+ else frozenset()
681
+ )
682
+ skip_refresh = force_refresh or dry_run
683
+ ordered, refreshed_recently = _drop_recently_refreshed(
684
+ adapter, ordered, last_success, open_crawls, run_started_at, window_h, skip_refresh
685
+ )
686
+ ordered, resting_boards = _drop_resting(
687
+ adapter, ordered, board_visits, run_started_at, skip_refresh
688
+ )
689
+
690
+ seed = dict(await repository.load_validators(source_name))
691
+ resuming_boards: frozenset[str] = frozenset()
692
+ base = postures.get(rotation_bucket) or resolve(
693
+ adapter.rate_profile, [company.rate_profile for company in ordered]
694
+ )
695
+ allowance = _daily_allowance(
696
+ base, spent_today.get(source_name, 0), has_history, buckets=len(buckets)
697
+ )
698
+ posture = replace(base, max_requests_per_run=allowance)
699
+ if workday_adapter is not None:
700
+ facets = await repository.load_workday_facets()
701
+ crawls = await repository.load_workday_crawls()
702
+ resuming_boards = frozenset(crawls)
703
+ page_budgets, detail_budget = (
704
+ workday_adapter.crawl_budgets(ordered, crawls, facets, posture.max_requests_per_run)
705
+ if ordered
706
+ else ({}, 0)
707
+ )
708
+ else:
709
+ page_budgets = {
710
+ company.registry_key: adapter.max_requests_per_company for company in ordered
711
+ }
712
+ detail_budget = adapter.detail_budget
713
+ facets = {}
714
+ crawls = {}
715
+ shuffler.shuffle(ordered)
716
+ ordered = _stalest_first(adapter, ordered, last_success)
717
+ yield SourceStarted(source=source_name, companies=len(ordered))
718
+ if allowance < base.max_requests_per_run:
719
+ yield SourceCapped(
720
+ source=source_name,
721
+ bucket=rotation_bucket,
722
+ spent=spent_today.get(source_name, 0),
723
+ allowance=allowance,
724
+ ceiling=base.max_requests_per_run,
725
+ )
726
+ if refreshed_recently:
727
+ yield SourceFresh(
728
+ source=source_name,
729
+ skipped=refreshed_recently,
730
+ remaining=len(ordered),
731
+ refresh_interval_h=window_h,
732
+ )
733
+ if resting_boards:
734
+ yield SourceResting(source=source_name, skipped=resting_boards, remaining=len(ordered))
735
+ if rotation.rotating:
736
+ yield SourceRotated(
737
+ source=source_name,
738
+ bucket=rotation_bucket,
739
+ selected=len(rotation.selected),
740
+ deferred=len(rotation.deferred),
741
+ cursor=rotation.cursor,
742
+ wrapped=rotation.wrapped,
743
+ )
744
+ detail_queue = await repository.detail_queue(source_name, detail_budget)
745
+
746
+ if dry_run:
747
+ async for event in _rehearse_company_source(
748
+ adapter,
749
+ source_name,
750
+ ordered,
751
+ seed,
752
+ ledger,
753
+ page_budgets,
754
+ detail_budget if is_workday else 0,
755
+ source_clock,
756
+ ):
757
+ yield event
758
+ return
759
+
760
+ cache = ValidatorCache(seed)
761
+ harvest = FetchHarvest()
762
+
763
+ async with HttpClient(
764
+ allowed_hosts=adapter.hosts_for(ordered),
765
+ posture=posture,
766
+ cache=cache,
767
+ rng=shuffler,
768
+ bucket_key=adapter.bucket_key,
769
+ rate_state=rate_state,
770
+ now=run_started_at,
771
+ budgets=budgets,
772
+ postures={**postures, rotation_bucket: posture},
773
+ ) as client:
774
+ pending = [
775
+ _fetch_company(
776
+ adapter,
777
+ company,
778
+ client,
779
+ run_started_at,
780
+ facets,
781
+ detail_queue,
782
+ crawls,
783
+ page_budgets[company.registry_key],
784
+ _safe_board_key(adapter, company) in resuming_boards,
785
+ )
786
+ for company in ordered
787
+ ]
788
+ for company in ordered:
789
+ yield CompanyStarted(source=source_name, company=company.name)
790
+
791
+ for finished in asyncio.as_completed(pending):
792
+ company, result, error, elapsed, attempted, unreachable = await finished
793
+ for record in _drain(client, source_name):
794
+ yield record
795
+ board = _safe_board_key(adapter, company)
796
+ if result is None and not attempted:
797
+ harvest.skip_urls.update(_safe_plan(adapter, company)[0])
798
+ harvest.deferred_boards += 1
799
+ yield CompanyDeferred(source=source_name, company=company.name, reason=error)
800
+ elif result is None:
801
+ harvest.errors += 1
802
+ harvest.skip_urls.update(_safe_plan(adapter, company)[0])
803
+ harvest.visits.append(
804
+ CompanyVisit(board=board, succeeded=False, error=error, label=company.name)
805
+ )
806
+ yield CompanyFailed(
807
+ source=source_name,
808
+ company=company.name,
809
+ error=error,
810
+ elapsed_ms=elapsed,
811
+ unreachable=unreachable,
812
+ )
813
+ elif result.not_modified:
814
+ harvest.visits.append(CompanyVisit(board=board, succeeded=True, label=company.name))
815
+ harvest.unchanged.append(board)
816
+ yield CompanyUnchanged(source=source_name, company=company.name, elapsed_ms=elapsed)
817
+ else:
818
+ harvest.succeeded = True
819
+ harvest.visits.append(CompanyVisit(board=board, succeeded=True, label=company.name))
820
+ harvest.collected.extend(result.jobs)
821
+ harvest.resolved_facets.extend(
822
+ entry for entry in result.facets if isinstance(entry, WorkdayFacet)
823
+ )
824
+ harvest.forgotten_facets.extend(
825
+ entry for entry in result.forgotten_facets if isinstance(entry, WorkdayFacet)
826
+ )
827
+ harvest.detail_outcomes.extend(
828
+ entry for entry in result.detail_fetches if isinstance(entry, DetailFetch)
829
+ )
830
+ if isinstance(result.workday_crawl, WorkdayCrawlStep):
831
+ harvest.workday_crawls.append(result.workday_crawl)
832
+ if result.authoritative:
833
+ harvest.closable.append(board)
834
+ if result.degraded:
835
+ harvest.skip_urls.update(_safe_plan(adapter, company)[0])
836
+ harvest.skip_urls.update(result.stale_urls)
837
+ yield CompanyFinished(
838
+ source=source_name,
839
+ company=company.name,
840
+ fetched=len(result.jobs),
841
+ elapsed_ms=elapsed,
842
+ degraded=result.degraded,
843
+ )
844
+
845
+ metrics = _client_metrics(client)
846
+ validators = _keepable_validators(cache, harvest.skip_urls)
847
+ settled = _advance_cursor(
848
+ client.rate_state(run_started_at), rotation, rotation_bucket, run_started_at
849
+ )
850
+
851
+ fetch_ms = (time.perf_counter() - fetch_clock) * 1000
852
+ counts, normalize_ms, write_ms = await _persist_source_batch(
853
+ repository,
854
+ SourceBatch(
855
+ source=source_name,
856
+ run_started_at=run_started_at,
857
+ jobs=(),
858
+ closable_boards=tuple(harvest.closable),
859
+ unchanged_boards=tuple(harvest.unchanged),
860
+ validators=validators,
861
+ rate_state=settled,
862
+ workday_facets=tuple(harvest.resolved_facets),
863
+ forgotten_facets=tuple(harvest.forgotten_facets),
864
+ workday_crawls=tuple(harvest.workday_crawls),
865
+ detail_fetches=tuple(harvest.detail_outcomes),
866
+ visits=tuple(harvest.visits),
867
+ resolve_duplicates=resolve_duplicates,
868
+ ),
869
+ harvest.collected,
870
+ )
871
+ if harvest.succeeded or harvest.unchanged:
872
+ ledger.succeeded.append(True)
873
+ if harvest.errors:
874
+ ledger.failed.append(source_name)
875
+ if harvest.deferred_boards:
876
+ ledger.deferred.append(source_name)
877
+
878
+ elapsed_ms = (time.perf_counter() - source_clock) * 1000
879
+ ledger.stats.append(
880
+ replace(
881
+ _stats(source_name, counts, harvest.errors, metrics, elapsed_ms),
882
+ deferred=len(rotation.deferred) + harvest.deferred_boards,
883
+ )
884
+ )
885
+ yield _finished(
886
+ source_name,
887
+ counts,
888
+ harvest.errors,
889
+ metrics,
890
+ elapsed_ms,
891
+ fetch_ms=fetch_ms,
892
+ normalize_ms=normalize_ms,
893
+ write_ms=write_ms,
894
+ )
895
+
896
+
897
+ async def _run_feed_source(
898
+ repository: AsyncRepository,
899
+ feed: FeedAdapter,
900
+ run_started_at: datetime,
901
+ shuffler: random.Random,
902
+ dry_run: bool,
903
+ ledger: "RunLedger",
904
+ rate_state: Mapping[str, RateState],
905
+ budgets: dict[str, HostBudget],
906
+ postures: Mapping[str, RatePosture],
907
+ ) -> AsyncIterator[SyncEvent]:
908
+ source_name = feed.name
909
+ block = _active_block(rate_state, _bucket_keys(feed.hosts, feed.bucket_key), run_started_at)
910
+ if block is not None:
911
+ ledger.blocked.append(source_name)
912
+ if not dry_run:
913
+ ledger.stats.append(SourceRunStats(source=source_name, blocked=True))
914
+ yield SourceBlocked(
915
+ source=source_name,
916
+ bucket=block.bucket,
917
+ blocked_until=block.blocked_until or run_started_at,
918
+ remaining_s=block.blocks_remaining_s(run_started_at),
919
+ reason=block.reason,
920
+ consecutive_failures=block.consecutive_failures,
921
+ )
922
+ return
923
+
924
+ source_clock = time.perf_counter()
925
+ yield SourceStarted(source=source_name, companies=0)
926
+
927
+ fetch_clock = time.perf_counter()
928
+ seed = dict(await repository.load_validators(source_name))
929
+
930
+ if dry_run:
931
+ planned_urls = feed.plan(run_started_at)
932
+ for bucket in _bucket_keys(feed.hosts, feed.bucket_key):
933
+ ledger.plan_bounds.append((bucket, source_name, len(planned_urls), len(planned_urls)))
934
+ for url in planned_urls:
935
+ cached = seed.get(url)
936
+ has_validator = cached is not None and cached.usable
937
+ yield PlannedRequest(
938
+ source=source_name,
939
+ company=f"season {feed.season_year(run_started_at)}",
940
+ url=url,
941
+ has_validator=has_validator,
942
+ expectation="likely 304 (validator on file)" if has_validator else "full response",
943
+ )
944
+ yield SourceFinished(
945
+ source=source_name,
946
+ fetched=0,
947
+ added=0,
948
+ updated=0,
949
+ closed=0,
950
+ failed_companies=0,
951
+ elapsed_ms=(time.perf_counter() - source_clock) * 1000,
952
+ )
953
+ return
954
+
955
+ cache = ValidatorCache(seed)
956
+ collected: list[Job] = []
957
+ errors = 0
958
+ unchanged = False
959
+ degraded = ""
960
+ authoritative = True
961
+ stale_urls: tuple[str, ...] = ()
962
+ label = f"{source_name} {feed.season_year(run_started_at)}"
963
+
964
+ async with HttpClient(
965
+ allowed_hosts=feed.hosts,
966
+ posture=resolve(feed.rate_profile, []),
967
+ cache=cache,
968
+ rng=shuffler,
969
+ bucket_key=feed.bucket_key,
970
+ rate_state=rate_state,
971
+ now=run_started_at,
972
+ budgets=budgets,
973
+ postures=postures,
974
+ ) as client:
975
+ yield CompanyStarted(source=source_name, company=label)
976
+ started = time.perf_counter()
977
+ try:
978
+ result = await feed.fetch(client, run_started_at)
979
+ except Exception as exc:
980
+ errors = 1
981
+ result = None
982
+ error_text = f"{type(exc).__name__}: {exc}"
983
+ elapsed = (time.perf_counter() - started) * 1000
984
+
985
+ for record in _drain(client, source_name):
986
+ yield record
987
+
988
+ if result is None:
989
+ yield CompanyFailed(
990
+ source=source_name, company=label, error=error_text, elapsed_ms=elapsed
991
+ )
992
+ elif result.not_modified:
993
+ unchanged = True
994
+ yield CompanyUnchanged(source=source_name, company=label, elapsed_ms=elapsed)
995
+ else:
996
+ collected.extend(result.jobs)
997
+ degraded = result.degraded
998
+ authoritative = result.authoritative
999
+ stale_urls = result.stale_urls
1000
+ yield CompanyFinished(
1001
+ source=source_name,
1002
+ company=label,
1003
+ fetched=len(result.jobs),
1004
+ elapsed_ms=elapsed,
1005
+ degraded=result.degraded,
1006
+ )
1007
+
1008
+ metrics = _client_metrics(client)
1009
+ incomplete = bool(errors) or bool(degraded)
1010
+ skip_urls = set(feed.plan(run_started_at)) | set(stale_urls) if incomplete else set()
1011
+ validators = _keepable_validators(cache, skip_urls)
1012
+ settled = client.rate_state(run_started_at)
1013
+
1014
+ fetch_ms = (time.perf_counter() - fetch_clock) * 1000
1015
+ counts, normalize_ms, write_ms = await _persist_source_batch(
1016
+ repository,
1017
+ SourceBatch(
1018
+ source=source_name,
1019
+ run_started_at=run_started_at,
1020
+ jobs=(),
1021
+ validators=validators,
1022
+ rate_state=settled,
1023
+ resolve_duplicates=resolve_duplicates,
1024
+ closes_whole_source=authoritative and not incomplete and not unchanged,
1025
+ visits=(
1026
+ CompanyVisit(
1027
+ board=source_name,
1028
+ succeeded=not errors,
1029
+ error=error_text if errors else "",
1030
+ label=label,
1031
+ ),
1032
+ ),
1033
+ ),
1034
+ collected,
1035
+ )
1036
+ if not errors:
1037
+ ledger.succeeded.append(True)
1038
+ else:
1039
+ ledger.failed.append(source_name)
1040
+
1041
+ elapsed_ms = (time.perf_counter() - source_clock) * 1000
1042
+ ledger.stats.append(_stats(source_name, counts, errors, metrics, elapsed_ms))
1043
+ yield _finished(
1044
+ source_name,
1045
+ counts,
1046
+ errors,
1047
+ metrics,
1048
+ elapsed_ms,
1049
+ fetch_ms=fetch_ms,
1050
+ normalize_ms=normalize_ms,
1051
+ write_ms=write_ms,
1052
+ )
1053
+
1054
+
1055
+ def _client_metrics(client: HttpClient) -> tuple[int, int, int, int, float, float]:
1056
+ p50, p95 = client.latency_percentiles()
1057
+ return (
1058
+ client.request_count,
1059
+ client.not_modified_count,
1060
+ client.retry_count,
1061
+ client.tightening_count,
1062
+ p50,
1063
+ p95,
1064
+ )
1065
+
1066
+
1067
+ def _stats(
1068
+ source: str,
1069
+ counts: SourceBatchResult,
1070
+ errors: int,
1071
+ metrics: tuple[int, int, int, int, float, float],
1072
+ elapsed_ms: float,
1073
+ ) -> SourceRunStats:
1074
+ requests, not_modified, retries, tightenings, p50, p95 = metrics
1075
+ return SourceRunStats(
1076
+ source=source,
1077
+ fetched=counts.fetched,
1078
+ added=counts.added,
1079
+ updated=counts.updated,
1080
+ closed=counts.closed,
1081
+ quarantined=counts.quarantined,
1082
+ errors=errors,
1083
+ requests=requests,
1084
+ not_modified=not_modified,
1085
+ retries=retries,
1086
+ tightenings=tightenings,
1087
+ latency_p50_ms=p50,
1088
+ latency_p95_ms=p95,
1089
+ elapsed_ms=elapsed_ms,
1090
+ stored=counts.stored,
1091
+ )
1092
+
1093
+
1094
+ def _finished(
1095
+ source: str,
1096
+ counts: SourceBatchResult,
1097
+ errors: int,
1098
+ metrics: tuple[int, int, int, int, float, float],
1099
+ elapsed_ms: float,
1100
+ fetch_ms: float = 0.0,
1101
+ normalize_ms: float = 0.0,
1102
+ write_ms: float = 0.0,
1103
+ ) -> SourceFinished:
1104
+ requests, not_modified, retries, tightenings, p50, p95 = metrics
1105
+ return SourceFinished(
1106
+ source=source,
1107
+ fetched=counts.fetched,
1108
+ added=counts.added,
1109
+ updated=counts.updated,
1110
+ closed=counts.closed,
1111
+ quarantined=counts.quarantined,
1112
+ failed_companies=errors,
1113
+ elapsed_ms=elapsed_ms,
1114
+ fetch_ms=fetch_ms,
1115
+ normalize_ms=normalize_ms,
1116
+ write_ms=write_ms,
1117
+ requests=requests,
1118
+ not_modified=not_modified,
1119
+ retries=retries,
1120
+ tightenings=tightenings,
1121
+ latency_p50_ms=p50,
1122
+ latency_p95_ms=p95,
1123
+ )
1124
+
1125
+
1126
+ async def sync(
1127
+ repository: AsyncRepository,
1128
+ companies: Sequence[Company],
1129
+ *,
1130
+ sources: Sequence[str] | None = None,
1131
+ excluded: Sequence[str] | None = None,
1132
+ dry_run: bool = False,
1133
+ force_refresh: bool = False,
1134
+ now_fn: Callable[[], datetime] = lambda: datetime.now(UTC),
1135
+ rng: random.Random | None = None,
1136
+ ) -> AsyncIterator[SyncEvent]:
1137
+ grouped, feeds, unroutable = _select(companies, sources, excluded)
1138
+ shuffler = rng or random.Random()
1139
+
1140
+ run_started_at = now_fn()
1141
+ run_clock = time.perf_counter()
1142
+ yield SyncStarted(
1143
+ sources=tuple(sorted(set(grouped) | set(feeds))),
1144
+ companies=sum(len(entry[1]) for entry in grouped.values()),
1145
+ started_at=run_started_at,
1146
+ )
1147
+ if unroutable:
1148
+ yield UnroutableCompanies(
1149
+ companies=tuple(company.name for company in unroutable),
1150
+ platforms=tuple(sorted({company.platform.value for company in unroutable})),
1151
+ )
1152
+
1153
+ ledger = RunLedger()
1154
+
1155
+ rate_state = await repository.load_rate_state()
1156
+ spent_today, has_history = await _spent_today(repository, run_started_at - timedelta(hours=24))
1157
+ budgets: dict[str, HostBudget] = {}
1158
+ postures = _bucket_postures(grouped, feeds)
1159
+
1160
+ streams: list[tuple[str, AsyncIterator[SyncEvent]]] = [
1161
+ (
1162
+ name,
1163
+ _run_company_source(
1164
+ repository,
1165
+ grouped[name][0],
1166
+ grouped[name][1],
1167
+ run_started_at,
1168
+ shuffler,
1169
+ dry_run,
1170
+ ledger,
1171
+ rate_state,
1172
+ budgets,
1173
+ postures,
1174
+ force_refresh,
1175
+ spent_today,
1176
+ has_history,
1177
+ ),
1178
+ )
1179
+ for name in sorted(grouped)
1180
+ ]
1181
+ streams.extend(
1182
+ (
1183
+ name,
1184
+ _run_feed_source(
1185
+ repository,
1186
+ feeds[name],
1187
+ run_started_at,
1188
+ shuffler,
1189
+ dry_run,
1190
+ ledger,
1191
+ rate_state,
1192
+ budgets,
1193
+ postures,
1194
+ ),
1195
+ )
1196
+ for name in sorted(feeds)
1197
+ )
1198
+
1199
+ async for event in _merge(streams, ledger):
1200
+ yield event
1201
+
1202
+ if dry_run:
1203
+ for event in _bucket_plans(ledger.plan_bounds, postures):
1204
+ yield event
1205
+ yield SyncFinished(
1206
+ outcome=(
1207
+ SyncOutcome.PARTIAL
1208
+ if ledger.failed or unroutable or ledger.blocked
1209
+ else SyncOutcome.SUCCESS
1210
+ ),
1211
+ added=0,
1212
+ updated=0,
1213
+ closed=0,
1214
+ failed_sources=tuple(ledger.failed),
1215
+ elapsed_ms=(time.perf_counter() - run_clock) * 1000,
1216
+ dry_run=True,
1217
+ )
1218
+ return
1219
+
1220
+ if not ledger.succeeded and (ledger.failed or ledger.blocked):
1221
+ outcome_status = SyncOutcome.FAILURE
1222
+ elif ledger.failed or unroutable or ledger.blocked or ledger.deferred:
1223
+ outcome_status = SyncOutcome.PARTIAL
1224
+ else:
1225
+ outcome_status = SyncOutcome.SUCCESS
1226
+ partial_reason = (
1227
+ _partial_reason(ledger.failed, unroutable, ledger.blocked, ledger.deferred)
1228
+ if outcome_status is not SyncOutcome.SUCCESS
1229
+ else ""
1230
+ )
1231
+
1232
+ sources_with_boards, known_boards = _registry_boards(companies)
1233
+ await repository.close_orphan_boards(sources_with_boards, known_boards)
1234
+
1235
+ purged = await repository.purge(now_fn())
1236
+
1237
+ await repository.record_sync_run(
1238
+ SyncRun(
1239
+ started_at=run_started_at,
1240
+ finished_at=now_fn(),
1241
+ outcome=outcome_status,
1242
+ sources=tuple(ledger.stats),
1243
+ )
1244
+ )
1245
+ yield SyncFinished(
1246
+ outcome=outcome_status,
1247
+ added=sum(item.added for item in ledger.stats),
1248
+ updated=sum(item.updated for item in ledger.stats),
1249
+ closed=sum(item.closed for item in ledger.stats),
1250
+ quarantined=sum(item.quarantined for item in ledger.stats),
1251
+ purged=purged.purged,
1252
+ failed_sources=tuple(ledger.failed),
1253
+ elapsed_ms=(time.perf_counter() - run_clock) * 1000,
1254
+ requests=sum(item.requests for item in ledger.stats),
1255
+ not_modified=sum(item.not_modified for item in ledger.stats),
1256
+ partial_reason=partial_reason,
1257
+ )