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
@@ -0,0 +1,274 @@
1
+ import csv
2
+ import io
3
+ import logging
4
+ from collections.abc import Callable, Iterator, Sequence
5
+ from contextlib import contextmanager
6
+ from dataclasses import asdict, dataclass
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from stage.domain import (
11
+ DEFAULT_WINDOW_DAYS,
12
+ ExportFormat,
13
+ Job,
14
+ JobFilters,
15
+ dump,
16
+ first_line,
17
+ sanitize,
18
+ truncate,
19
+ )
20
+ from stage.paths import font_path
21
+ from stage.services.query import list_jobs
22
+ from stage.storage import AsyncRepository
23
+
24
+ FORMULA_PREFIXES = ("=", "+", "-", "@")
25
+ FORMULA_GUARD = "'"
26
+
27
+
28
+ def _place(raw: str) -> str:
29
+ from stage.normalize.location import display_location
30
+
31
+ return display_location(raw)
32
+
33
+
34
+ COLUMNS: tuple[tuple[str, Callable[[Job], str]], ...] = (
35
+ ("company", lambda job: job.company),
36
+ ("title", lambda job: job.title_raw),
37
+ ("location", lambda job: _place(job.location_raw)),
38
+ ("location_bucket", lambda job: job.location.value),
39
+ ("term", lambda job: job.term),
40
+ ("role", lambda job: job.role.value),
41
+ ("language", lambda job: job.language.value),
42
+ ("first_seen", lambda job: job.first_seen.astimezone(UTC).date().isoformat()),
43
+ ("source", lambda job: job.source),
44
+ ("apply_url", lambda job: job.apply_url_raw),
45
+ ("id", lambda job: job.id),
46
+ )
47
+
48
+ PDF_COLUMNS = ("company", "title", "location", "term", "first_seen")
49
+ PDF_WIDTHS = (26, 46, 22, 14, 14)
50
+ PDF_CELL_LIMIT = 300
51
+ PDF_LOGGER = "fpdf"
52
+
53
+
54
+ class ExportError(Exception):
55
+ pass
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ExportResult:
60
+ path: Path
61
+ fmt: ExportFormat
62
+ count: int
63
+ total_matching: int
64
+ notes: tuple[str, ...] = ()
65
+
66
+
67
+ def default_filename(fmt: ExportFormat, when: datetime) -> str:
68
+ return f"stage-export-{when.astimezone(UTC):%Y%m%d}.{fmt.value}"
69
+
70
+
71
+ def export_root() -> Path:
72
+ import os
73
+
74
+ override = os.environ.get("STAGE_EXPORT_DIR", "").strip()
75
+ return Path(override).expanduser() if override else Path.cwd()
76
+
77
+
78
+ def resolve_destination(
79
+ destination: Path | None, fmt: ExportFormat, when: datetime, force: bool
80
+ ) -> Path:
81
+ target = (destination or export_root() / default_filename(fmt, when)).expanduser()
82
+ if target.is_dir():
83
+ target = target / default_filename(fmt, when)
84
+ target = target.resolve()
85
+ if not target.parent.is_dir():
86
+ raise ExportError(f"{target.parent} does not exist — create it or pass another --out")
87
+ if target.exists() and not force:
88
+ raise ExportError(f"{target} already exists — pass --force to overwrite it")
89
+ return target
90
+
91
+
92
+ def _cell(value: str) -> str:
93
+ clean = sanitize(value).replace("\n", " ").replace("\r", " ").strip()
94
+ return " ".join(clean.split())
95
+
96
+
97
+ def _guard(value: str) -> str:
98
+ return FORMULA_GUARD + value if value.startswith(FORMULA_PREFIXES) else value
99
+
100
+
101
+ def render_csv(jobs: Sequence[Job]) -> str:
102
+ buffer = io.StringIO()
103
+ writer = csv.writer(buffer, lineterminator="\r\n")
104
+ writer.writerow([name for name, _ in COLUMNS])
105
+ for job in jobs:
106
+ writer.writerow([_guard(_cell(read(job))) for _, read in COLUMNS])
107
+ return buffer.getvalue()
108
+
109
+
110
+ def render_json(jobs: Sequence[Job]) -> str:
111
+ return dump([asdict(job) for job in jobs])
112
+
113
+
114
+ def _markdown_cell(value: str) -> str:
115
+ return _cell(value).replace("\\", "\\\\").replace("|", "\\|")
116
+
117
+
118
+ def render_markdown(jobs: Sequence[Job], *, generated_at: datetime) -> str:
119
+ names = [name for name, _ in COLUMNS]
120
+ lines = [
121
+ f"# Stage export — {generated_at.astimezone(UTC):%Y-%m-%d %H:%M UTC}",
122
+ "",
123
+ f"{len(jobs)} posting(s).",
124
+ "",
125
+ "| " + " | ".join(names) + " |",
126
+ "| " + " | ".join("---" for _ in names) + " |",
127
+ ]
128
+ for job in jobs:
129
+ cells = [_markdown_cell(read(job)) for _, read in COLUMNS]
130
+ lines.append("| " + " | ".join(cells) + " |")
131
+ return "\n".join(lines) + "\n"
132
+
133
+
134
+ def _write_text(target: Path, payload: str, *, encoding: str = "utf-8") -> None:
135
+ try:
136
+ target.write_text(payload, encoding=encoding, newline="")
137
+ except OSError as exc:
138
+ raise ExportError(_write_failure(target, exc)) from exc
139
+
140
+
141
+ def _write_failure(target: Path, exc: OSError) -> str:
142
+ return (
143
+ f"could not write {target.name} ({exc.strerror or type(exc).__name__}). Check the "
144
+ "destination is writable and has room, then try again"
145
+ )
146
+
147
+
148
+ @contextmanager
149
+ def _replaced(target: Path) -> Iterator[Path]:
150
+ staged = target.with_name(f"{target.name}.partial")
151
+ try:
152
+ yield staged
153
+ staged.replace(target)
154
+ except OSError as exc:
155
+ raise ExportError(_write_failure(target, exc)) from exc
156
+ finally:
157
+ staged.unlink(missing_ok=True)
158
+
159
+
160
+ class _RendererNotes(logging.Handler):
161
+ def __init__(self) -> None:
162
+ super().__init__(logging.WARNING)
163
+ self.messages: list[str] = []
164
+
165
+ def emit(self, record: logging.LogRecord) -> None:
166
+ self.messages.append(record.getMessage())
167
+
168
+
169
+ def write_pdf(jobs: Sequence[Job], target: Path, *, generated_at: datetime) -> tuple[str, ...]:
170
+ collected = _RendererNotes()
171
+ logger = logging.getLogger(PDF_LOGGER)
172
+ logger.addHandler(collected)
173
+ try:
174
+ _render_pdf(jobs, target, generated_at=generated_at)
175
+ except ExportError:
176
+ raise
177
+ except OSError as exc:
178
+ raise ExportError(_write_failure(target, exc)) from exc
179
+ except Exception as exc:
180
+ raise ExportError(
181
+ f"the PDF renderer refused this page ({type(exc).__name__}: {first_line(str(exc))}). "
182
+ "Export csv, json or md instead, or narrow the filters"
183
+ ) from exc
184
+ finally:
185
+ logger.removeHandler(collected)
186
+ return tuple(truncate(first_line(message), 160) for message in collected.messages)
187
+
188
+
189
+ def _render_pdf(jobs: Sequence[Job], target: Path, *, generated_at: datetime) -> None:
190
+ from fpdf import FPDF
191
+ from fpdf.fonts import FontFace
192
+
193
+ reader = dict(COLUMNS)
194
+ pdf = FPDF(orientation="landscape", format="A4")
195
+ pdf.set_auto_page_break(auto=True, margin=12)
196
+ pdf.add_font("dejavu", style="", fname=str(font_path()))
197
+ pdf.add_page()
198
+ pdf.set_font("dejavu", size=14)
199
+ pdf.cell(0, 8, _cell(f"Stage export — {generated_at.astimezone(UTC):%Y-%m-%d %H:%M UTC}"))
200
+ pdf.ln(10)
201
+ pdf.set_font("dejavu", size=8)
202
+ pdf.cell(0, 5, _cell(f"{len(jobs)} posting(s)"))
203
+ pdf.ln(8)
204
+
205
+ with pdf.table(
206
+ col_widths=PDF_WIDTHS,
207
+ line_height=5,
208
+ text_align="LEFT",
209
+ headings_style=FontFace(emphasis="", fill_color=(232, 232, 232)),
210
+ ) as table:
211
+ header = table.row()
212
+ for name in PDF_COLUMNS:
213
+ header.cell(name)
214
+ for job in jobs:
215
+ row = table.row()
216
+ for name in PDF_COLUMNS:
217
+ row.cell(truncate(_cell(reader[name](job)), PDF_CELL_LIMIT))
218
+ pdf.output(str(target))
219
+
220
+
221
+ async def export_jobs(
222
+ repository: AsyncRepository,
223
+ filters: JobFilters,
224
+ *,
225
+ fmt: ExportFormat,
226
+ destination: Path | None = None,
227
+ window_days: int | None = DEFAULT_WINDOW_DAYS,
228
+ force: bool = False,
229
+ now: datetime | None = None,
230
+ query: str = "",
231
+ ) -> ExportResult:
232
+ moment = now or datetime.now(UTC)
233
+ target = resolve_destination(destination, fmt, moment, force)
234
+ if query.strip():
235
+ from stage.services.query import search_jobs
236
+
237
+ listing = await search_jobs(repository, query, filters, window_days=window_days, now=moment)
238
+ else:
239
+ listing = await list_jobs(repository, filters, window_days=window_days, now=moment)
240
+ jobs = listing.jobs
241
+
242
+ notes: tuple[str, ...] = ()
243
+ with _replaced(target) as staged:
244
+ if fmt is ExportFormat.CSV:
245
+ _write_text(staged, render_csv(jobs), encoding="utf-8-sig")
246
+ elif fmt is ExportFormat.JSON:
247
+ _write_text(staged, render_json(jobs))
248
+ elif fmt is ExportFormat.MD:
249
+ _write_text(staged, render_markdown(jobs, generated_at=moment))
250
+ else:
251
+ notes = write_pdf(jobs, staged, generated_at=moment)
252
+
253
+ return ExportResult(
254
+ path=target,
255
+ fmt=fmt,
256
+ count=len(jobs),
257
+ total_matching=listing.total_matching,
258
+ notes=notes,
259
+ )
260
+
261
+
262
+ __all__ = [
263
+ "COLUMNS",
264
+ "FORMULA_PREFIXES",
265
+ "ExportError",
266
+ "ExportResult",
267
+ "default_filename",
268
+ "export_jobs",
269
+ "render_csv",
270
+ "render_json",
271
+ "render_markdown",
272
+ "resolve_destination",
273
+ "write_pdf",
274
+ ]
@@ -0,0 +1,237 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+ from datetime import UTC, datetime
4
+
5
+ from stage.domain import (
6
+ STALE_AFTER_DAYS,
7
+ Company,
8
+ IntegrityFinding,
9
+ JobFilters,
10
+ RateState,
11
+ SourceRunStats,
12
+ SourceVisit,
13
+ SyncRun,
14
+ VisitState,
15
+ VolumeSignal,
16
+ WorkdayCrawl,
17
+ assess_volume,
18
+ classify_visit,
19
+ )
20
+ from stage.storage import AsyncRepository
21
+
22
+ RUN_HISTORY = 20
23
+ COMPOSITION_COLUMNS = ("source", "location", "role", "term", "language")
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class BoardHealth:
28
+ source: str
29
+ board: str
30
+ label: str
31
+ state: VisitState
32
+ last_success_at: datetime | None
33
+ consecutive_failures: int
34
+ last_error: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class SourceHealth:
39
+ source: str
40
+ stored: int
41
+ volume: VolumeSignal
42
+ requests: int
43
+ not_modified: int
44
+ latency_p50_ms: float
45
+ latency_p95_ms: float
46
+ errors: int
47
+ tightenings: int
48
+ deferred: int
49
+ blocked: bool
50
+ boards: tuple[BoardHealth, ...] = ()
51
+
52
+ @property
53
+ def cache_hit_ratio(self) -> float | None:
54
+ if self.requests <= 0:
55
+ return None
56
+ return self.not_modified / self.requests
57
+
58
+ @property
59
+ def success_rate(self) -> float | None:
60
+ if not self.boards:
61
+ return None
62
+ succeeded = sum(1 for board in self.boards if board.state is not VisitState.FAILING)
63
+ return succeeded / len(self.boards)
64
+
65
+ @property
66
+ def failing_boards(self) -> tuple[BoardHealth, ...]:
67
+ return tuple(board for board in self.boards if board.state is VisitState.FAILING)
68
+
69
+ @property
70
+ def stale_boards(self) -> tuple[BoardHealth, ...]:
71
+ return tuple(board for board in self.boards if board.state is VisitState.STALE)
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class DoctorReport:
76
+ schema_version: int
77
+ last_sync_at: datetime | None
78
+ integrity: tuple[IntegrityFinding, ...]
79
+ sources: tuple[SourceHealth, ...]
80
+ blocks: tuple[RateState, ...]
81
+ never_synced: bool
82
+ stale_after_days: int = STALE_AFTER_DAYS
83
+ due_for_recheck: tuple[str, ...] = ()
84
+ workday_crawls: tuple[WorkdayCrawl, ...] = ()
85
+
86
+ @property
87
+ def integrity_problems(self) -> tuple[IntegrityFinding, ...]:
88
+ return tuple(finding for finding in self.integrity if not finding.is_clean)
89
+
90
+ @property
91
+ def volume_alerts(self) -> tuple[SourceHealth, ...]:
92
+ return tuple(source for source in self.sources if source.volume.is_alert)
93
+
94
+ @property
95
+ def failing_boards(self) -> tuple[BoardHealth, ...]:
96
+ return tuple(board for source in self.sources for board in source.failing_boards)
97
+
98
+ @property
99
+ def stale_boards(self) -> tuple[BoardHealth, ...]:
100
+ return tuple(board for source in self.sources for board in source.stale_boards)
101
+
102
+ @property
103
+ def warnings(self) -> int:
104
+ return len(self.failing_boards) + len(self.stale_boards) + len(self.due_for_recheck)
105
+
106
+ @property
107
+ def is_healthy(self) -> bool:
108
+ return not (self.integrity_problems or self.volume_alerts or self.blocks)
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class StatsReport:
113
+ runs: tuple[SyncRun, ...]
114
+ composition: dict[str, dict[str, int]]
115
+ total_jobs: int
116
+ duplicates: int
117
+ quarantined: dict[str, int]
118
+ tombstones: int
119
+ cached_urls: int
120
+ schema_version: int
121
+
122
+
123
+ def _live_boards(companies: Sequence[Company]) -> frozenset[str]:
124
+ from stage.services.sync import _registry_boards
125
+
126
+ return frozenset(_registry_boards(companies)[1])
127
+
128
+
129
+ async def _board_health(
130
+ repository: AsyncRepository,
131
+ now: datetime,
132
+ stale_after_days: int,
133
+ companies: Sequence[Company] = (),
134
+ ) -> dict[str, list[BoardHealth]]:
135
+ visits: list[SourceVisit] = await repository.all_visits()
136
+ live = _live_boards(companies)
137
+ grouped: dict[str, list[BoardHealth]] = {}
138
+ for visit in visits:
139
+ if live and visit.board not in live:
140
+ continue
141
+ grouped.setdefault(visit.source, []).append(
142
+ BoardHealth(
143
+ source=visit.source,
144
+ board=visit.board,
145
+ label=visit.label or visit.board,
146
+ state=classify_visit(
147
+ visit.last_success_at, visit.consecutive_failures, now, stale_after_days
148
+ ),
149
+ last_success_at=visit.last_success_at,
150
+ consecutive_failures=visit.consecutive_failures,
151
+ last_error=visit.last_error,
152
+ )
153
+ )
154
+ return grouped
155
+
156
+
157
+ async def doctor(
158
+ repository: AsyncRepository,
159
+ *,
160
+ now: datetime | None = None,
161
+ stale_after_days: int = STALE_AFTER_DAYS,
162
+ history: int = RUN_HISTORY,
163
+ companies: Sequence[Company] = (),
164
+ ) -> DoctorReport:
165
+ moment = now or datetime.now(UTC)
166
+ runs = await repository.run_history(history)
167
+ volumes = await repository.volume_history(history)
168
+ stored = await repository.stored_counts()
169
+ boards = await _board_health(repository, moment, stale_after_days, companies)
170
+ rate_state = await repository.load_rate_state()
171
+ workday_crawls = await repository.load_workday_crawls()
172
+
173
+ latest: dict[str, SourceRunStats] = {}
174
+ for run in runs:
175
+ for stats in run.sources:
176
+ latest.setdefault(stats.source, stats)
177
+ names = sorted(set(volumes) | set(stored) | set(latest) | set(boards))
178
+
179
+ sources = tuple(
180
+ SourceHealth(
181
+ source=name,
182
+ stored=stored.get(name, 0),
183
+ volume=assess_volume(name, list(volumes.get(name, ()))),
184
+ requests=latest[name].requests if name in latest else 0,
185
+ not_modified=latest[name].not_modified if name in latest else 0,
186
+ latency_p50_ms=latest[name].latency_p50_ms if name in latest else 0.0,
187
+ latency_p95_ms=latest[name].latency_p95_ms if name in latest else 0.0,
188
+ errors=latest[name].errors if name in latest else 0,
189
+ tightenings=latest[name].tightenings if name in latest else 0,
190
+ deferred=latest[name].deferred if name in latest else 0,
191
+ blocked=latest[name].blocked if name in latest else False,
192
+ boards=tuple(boards.get(name, ())),
193
+ )
194
+ for name in names
195
+ )
196
+
197
+ return DoctorReport(
198
+ schema_version=await repository.schema_version(),
199
+ last_sync_at=await repository.last_sync_at(),
200
+ integrity=tuple(await repository.integrity_findings()),
201
+ sources=sources,
202
+ blocks=tuple(state for state in rate_state.values() if state.is_blocked(moment)),
203
+ never_synced=not runs,
204
+ stale_after_days=stale_after_days,
205
+ due_for_recheck=tuple(
206
+ sorted(
207
+ f"{company.name} ({company.recheck_after})"
208
+ for company in companies
209
+ if company.due_for_recheck(moment.date())
210
+ )
211
+ ),
212
+ workday_crawls=tuple(crawl for _, crawl in sorted(workday_crawls.items())),
213
+ )
214
+
215
+
216
+ async def statistics(repository: AsyncRepository, *, history: int = RUN_HISTORY) -> StatsReport:
217
+ composition = {column: await repository.composition(column) for column in COMPOSITION_COLUMNS}
218
+ return StatsReport(
219
+ runs=tuple(await repository.run_history(history)),
220
+ composition=composition,
221
+ total_jobs=await repository.count_jobs(JobFilters(status=None, limit=0)),
222
+ duplicates=await repository.count_duplicates(),
223
+ quarantined=await repository.quarantine_reason_counts(),
224
+ tombstones=await repository.tombstone_count(),
225
+ cached_urls=await repository.cached_url_count(),
226
+ schema_version=await repository.schema_version(),
227
+ )
228
+
229
+
230
+ __all__ = [
231
+ "BoardHealth",
232
+ "DoctorReport",
233
+ "SourceHealth",
234
+ "StatsReport",
235
+ "doctor",
236
+ "statistics",
237
+ ]