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.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/services/export.py
ADDED
|
@@ -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
|
+
]
|
stage/services/health.py
ADDED
|
@@ -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
|
+
]
|