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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from stage.domain import SyncOutcome
|
|
13
|
+
from stage.domain.text import dump, summary
|
|
14
|
+
from stage.paths import data_dir
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from stage.domain import DiscoveryEvent, SyncEvent
|
|
18
|
+
|
|
19
|
+
HEARTBEAT_INTERVAL_SECONDS = 1.0
|
|
20
|
+
_HEARTBEAT_MISSES_BEFORE_UNRESPONSIVE = 3
|
|
21
|
+
_STATE_RETRY_DELAYS_SECONDS = (0.0, 0.02, 0.1)
|
|
22
|
+
_ACTIVE_PHASES = frozenset({"waiting", "syncing", "discovering", "checking"})
|
|
23
|
+
_OUTCOMES = frozenset(outcome.value for outcome in SyncOutcome)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def state_path(action: str) -> Path:
|
|
27
|
+
root = data_dir() / "schedule"
|
|
28
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return root / f"{action}.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_state(action: str) -> dict[str, object] | None:
|
|
33
|
+
return read_state_path(state_path(action))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_state_path(path: Path) -> dict[str, object] | None:
|
|
37
|
+
for delay in _STATE_RETRY_DELAYS_SECONDS:
|
|
38
|
+
if delay:
|
|
39
|
+
time.sleep(delay)
|
|
40
|
+
try:
|
|
41
|
+
with path.open(encoding="utf-8") as stream:
|
|
42
|
+
payload = json.load(stream)
|
|
43
|
+
except (OSError, json.JSONDecodeError):
|
|
44
|
+
continue
|
|
45
|
+
return payload if isinstance(payload, dict) else None
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def state_for_status(
|
|
50
|
+
payload: dict[str, object] | None, *, now: datetime | None = None
|
|
51
|
+
) -> dict[str, object] | None:
|
|
52
|
+
if payload is None or payload.get("phase") not in _ACTIVE_PHASES:
|
|
53
|
+
return payload
|
|
54
|
+
interval = _positive_number(payload, "heartbeat_interval_seconds")
|
|
55
|
+
heartbeat = _time(payload.get("heartbeat_at"))
|
|
56
|
+
if interval <= 0 or heartbeat is None:
|
|
57
|
+
return payload
|
|
58
|
+
age = (now or datetime.now(UTC)).astimezone(UTC) - heartbeat
|
|
59
|
+
if age < timedelta(seconds=interval * _HEARTBEAT_MISSES_BEFORE_UNRESPONSIVE):
|
|
60
|
+
return payload
|
|
61
|
+
status = dict(payload)
|
|
62
|
+
status["phase"] = "unresponsive"
|
|
63
|
+
status["heartbeat_age_seconds"] = round(age.total_seconds())
|
|
64
|
+
return status
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_active(payload: dict[str, object] | None) -> bool:
|
|
68
|
+
status = state_for_status(payload)
|
|
69
|
+
return bool(status and status.get("phase") in _ACTIVE_PHASES)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ScheduleStateWriter:
|
|
73
|
+
def __init__(self, path: Path, payload: dict[str, object]) -> None:
|
|
74
|
+
self.path = path
|
|
75
|
+
self._payload = payload
|
|
76
|
+
self._last_write = 0.0
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def start(
|
|
80
|
+
cls,
|
|
81
|
+
action: str,
|
|
82
|
+
log_path: Path,
|
|
83
|
+
*,
|
|
84
|
+
destination: Path | None = None,
|
|
85
|
+
now: datetime | None = None,
|
|
86
|
+
) -> "ScheduleStateWriter":
|
|
87
|
+
moment = now or datetime.now(UTC)
|
|
88
|
+
stamp = _stamp(moment)
|
|
89
|
+
return cls(
|
|
90
|
+
destination or state_path(action),
|
|
91
|
+
{
|
|
92
|
+
"schema_version": 2,
|
|
93
|
+
"action": action,
|
|
94
|
+
"run_id": uuid.uuid4().hex,
|
|
95
|
+
"phase": "waiting",
|
|
96
|
+
"triggered_at": stamp,
|
|
97
|
+
"updated_at": stamp,
|
|
98
|
+
"heartbeat_at": stamp,
|
|
99
|
+
"heartbeat_interval_seconds": HEARTBEAT_INTERVAL_SECONDS,
|
|
100
|
+
"log_path": str(log_path),
|
|
101
|
+
"progress": _progress(),
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def open(cls, path: Path, action: str) -> "ScheduleStateWriter":
|
|
107
|
+
payload = read_state_path(path)
|
|
108
|
+
if payload is None:
|
|
109
|
+
return cls.start(action, Path(), destination=path)
|
|
110
|
+
return cls(path, payload)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def path_is_blocked(self) -> bool:
|
|
114
|
+
self._reload()
|
|
115
|
+
return self._payload.get("phase") == "blocked"
|
|
116
|
+
|
|
117
|
+
def waiting(self, delay_seconds: float) -> None:
|
|
118
|
+
moment = datetime.now(UTC)
|
|
119
|
+
self._set(
|
|
120
|
+
force=True,
|
|
121
|
+
phase="waiting",
|
|
122
|
+
jitter_seconds=round(delay_seconds),
|
|
123
|
+
starts_after=_stamp(moment + timedelta(seconds=delay_seconds)),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def started(self, phase: str) -> None:
|
|
127
|
+
values: dict[str, object] = {"phase": phase}
|
|
128
|
+
if not self._payload.get("started_at"):
|
|
129
|
+
values["started_at"] = _stamp(datetime.now(UTC))
|
|
130
|
+
self._set(values, force=True)
|
|
131
|
+
|
|
132
|
+
def heartbeat(self) -> None:
|
|
133
|
+
self._reload()
|
|
134
|
+
self._set(force=True, heartbeat_at=_stamp(datetime.now(UTC)))
|
|
135
|
+
|
|
136
|
+
def checking(self) -> None:
|
|
137
|
+
self._reload()
|
|
138
|
+
self._set(force=True, phase="checking")
|
|
139
|
+
|
|
140
|
+
def complete(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
sync_exit_code: int | None,
|
|
144
|
+
doctor_exit_code: int | None,
|
|
145
|
+
exit_code: int,
|
|
146
|
+
) -> None:
|
|
147
|
+
self._reload()
|
|
148
|
+
values: dict[str, object] = {
|
|
149
|
+
"finished_at": _stamp(datetime.now(UTC)),
|
|
150
|
+
"sync_exit_code": sync_exit_code,
|
|
151
|
+
"doctor_exit_code": doctor_exit_code,
|
|
152
|
+
"exit_code": exit_code,
|
|
153
|
+
}
|
|
154
|
+
if self._payload.get("phase") != "blocked":
|
|
155
|
+
values["phase"] = "finished"
|
|
156
|
+
values["outcome"] = _outcome(
|
|
157
|
+
self._payload.get("sync_outcome"), exit_code, doctor_exit_code
|
|
158
|
+
)
|
|
159
|
+
self._set(values, force=True)
|
|
160
|
+
|
|
161
|
+
def failed(self, detail: str, *, exit_code: int = 2) -> None:
|
|
162
|
+
self._reload()
|
|
163
|
+
self._set(
|
|
164
|
+
force=True,
|
|
165
|
+
phase="finished",
|
|
166
|
+
outcome=SyncOutcome.FAILURE.value,
|
|
167
|
+
finished_at=_stamp(datetime.now(UTC)),
|
|
168
|
+
exit_code=exit_code,
|
|
169
|
+
error=summary(detail, 240),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def blocked(self, detail: str) -> None:
|
|
173
|
+
self._reload()
|
|
174
|
+
self._set(
|
|
175
|
+
force=True,
|
|
176
|
+
phase="blocked",
|
|
177
|
+
blocked_at=_stamp(datetime.now(UTC)),
|
|
178
|
+
error=summary(detail, 240),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def sync_event(self, event: "SyncEvent") -> None:
|
|
182
|
+
from stage.domain import (
|
|
183
|
+
CompanyDeferred,
|
|
184
|
+
CompanyFailed,
|
|
185
|
+
CompanyFinished,
|
|
186
|
+
CompanyStarted,
|
|
187
|
+
CompanyUnchanged,
|
|
188
|
+
SourceBlocked,
|
|
189
|
+
SourceFailed,
|
|
190
|
+
SourceStarted,
|
|
191
|
+
SyncFinished,
|
|
192
|
+
SyncStarted,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
progress = _progress_from(self._payload)
|
|
196
|
+
values: dict[str, object] = {"phase": "syncing"}
|
|
197
|
+
if isinstance(event, SyncStarted):
|
|
198
|
+
progress["total"] = event.companies
|
|
199
|
+
elif isinstance(event, SourceStarted):
|
|
200
|
+
progress["source"] = event.source
|
|
201
|
+
progress["source_total"] = event.companies
|
|
202
|
+
progress["source_complete"] = 0
|
|
203
|
+
elif isinstance(event, CompanyStarted):
|
|
204
|
+
progress["source"] = event.source
|
|
205
|
+
progress["company"] = event.company
|
|
206
|
+
elif isinstance(event, (CompanyFinished, CompanyUnchanged, CompanyFailed, CompanyDeferred)):
|
|
207
|
+
_increment(progress, "complete")
|
|
208
|
+
_increment(progress, "source_complete")
|
|
209
|
+
progress["company"] = event.company
|
|
210
|
+
if isinstance(event, CompanyFinished):
|
|
211
|
+
_increment(progress, "fetched", event.fetched)
|
|
212
|
+
if event.degraded:
|
|
213
|
+
_increment(progress, "warnings")
|
|
214
|
+
elif isinstance(event, (CompanyFailed, CompanyDeferred)):
|
|
215
|
+
_increment(progress, "warnings")
|
|
216
|
+
elif isinstance(event, (SourceBlocked, SourceFailed)):
|
|
217
|
+
progress["source"] = event.source
|
|
218
|
+
_increment(progress, "warnings")
|
|
219
|
+
elif isinstance(event, SyncFinished):
|
|
220
|
+
values["sync_outcome"] = event.outcome.value
|
|
221
|
+
progress["fetched"] = max(_number(progress, "fetched"), event.added + event.updated)
|
|
222
|
+
_increment(progress, "warnings", len(event.failed_sources))
|
|
223
|
+
values["progress"] = progress
|
|
224
|
+
self._set(values, force=isinstance(event, SyncFinished))
|
|
225
|
+
|
|
226
|
+
def discovery_event(self, event: "DiscoveryEvent") -> None:
|
|
227
|
+
from stage.domain import DiscoveryFinished, DiscoveryStarted, PlatformProbed, ProbeVerdict
|
|
228
|
+
|
|
229
|
+
progress = _progress_from(self._payload)
|
|
230
|
+
values: dict[str, object] = {"phase": "discovering"}
|
|
231
|
+
if isinstance(event, DiscoveryStarted):
|
|
232
|
+
progress["total"] = event.probes_planned
|
|
233
|
+
elif isinstance(event, PlatformProbed):
|
|
234
|
+
_increment(progress, "complete")
|
|
235
|
+
progress["company"] = event.result.company
|
|
236
|
+
progress["source"] = event.result.candidate.label
|
|
237
|
+
if event.result.verdict is ProbeVerdict.UNVERIFIED:
|
|
238
|
+
_increment(progress, "warnings")
|
|
239
|
+
elif isinstance(event, DiscoveryFinished):
|
|
240
|
+
progress["fetched"] = len(event.matched)
|
|
241
|
+
_increment(progress, "warnings", event.errors + len(event.unverified))
|
|
242
|
+
values["progress"] = progress
|
|
243
|
+
self._set(values, force=isinstance(event, DiscoveryFinished))
|
|
244
|
+
|
|
245
|
+
def _reload(self) -> None:
|
|
246
|
+
payload = read_state_path(self.path)
|
|
247
|
+
if payload is not None and payload.get("run_id") == self._payload.get("run_id"):
|
|
248
|
+
self._payload = payload
|
|
249
|
+
|
|
250
|
+
def _merge_heartbeat(self) -> None:
|
|
251
|
+
stored = read_state_path(self.path)
|
|
252
|
+
if stored is None or stored.get("run_id") != self._payload.get("run_id"):
|
|
253
|
+
return
|
|
254
|
+
theirs = _time(stored.get("heartbeat_at"))
|
|
255
|
+
mine = _time(self._payload.get("heartbeat_at"))
|
|
256
|
+
if theirs is not None and (mine is None or theirs > mine):
|
|
257
|
+
self._payload["heartbeat_at"] = stored["heartbeat_at"]
|
|
258
|
+
|
|
259
|
+
def _set(
|
|
260
|
+
self,
|
|
261
|
+
values: Mapping[str, object] | None = None,
|
|
262
|
+
*,
|
|
263
|
+
force: bool = False,
|
|
264
|
+
**extra: object,
|
|
265
|
+
) -> None:
|
|
266
|
+
if values is not None:
|
|
267
|
+
self._payload.update(values)
|
|
268
|
+
self._payload.update(extra)
|
|
269
|
+
self._payload["updated_at"] = _stamp(datetime.now(UTC))
|
|
270
|
+
now = time.monotonic()
|
|
271
|
+
if not force and now - self._last_write < 0.5:
|
|
272
|
+
return
|
|
273
|
+
self._merge_heartbeat()
|
|
274
|
+
self._write()
|
|
275
|
+
self._last_write = now
|
|
276
|
+
|
|
277
|
+
def _write(self) -> None:
|
|
278
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
staged: Path | None = None
|
|
280
|
+
try:
|
|
281
|
+
with suppress(OSError):
|
|
282
|
+
with tempfile.NamedTemporaryFile(
|
|
283
|
+
mode="w",
|
|
284
|
+
encoding="utf-8",
|
|
285
|
+
dir=self.path.parent,
|
|
286
|
+
prefix=f".{self.path.name}.",
|
|
287
|
+
suffix=".tmp",
|
|
288
|
+
delete=False,
|
|
289
|
+
) as stream:
|
|
290
|
+
staged = Path(stream.name)
|
|
291
|
+
stream.write(dump(self._payload))
|
|
292
|
+
stream.write("\n")
|
|
293
|
+
stream.flush()
|
|
294
|
+
os.fsync(stream.fileno())
|
|
295
|
+
for delay in _STATE_RETRY_DELAYS_SECONDS:
|
|
296
|
+
if delay:
|
|
297
|
+
time.sleep(delay)
|
|
298
|
+
with suppress(OSError):
|
|
299
|
+
staged.replace(self.path)
|
|
300
|
+
return
|
|
301
|
+
finally:
|
|
302
|
+
if staged is not None:
|
|
303
|
+
with suppress(OSError):
|
|
304
|
+
staged.unlink(missing_ok=True)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _outcome(sync_outcome: object, exit_code: int, doctor_exit_code: int | None) -> str:
|
|
308
|
+
if exit_code >= 2 or doctor_exit_code not in (None, 0):
|
|
309
|
+
return SyncOutcome.FAILURE.value
|
|
310
|
+
if isinstance(sync_outcome, str) and sync_outcome in _OUTCOMES:
|
|
311
|
+
return sync_outcome
|
|
312
|
+
return SyncOutcome.PARTIAL.value if exit_code else SyncOutcome.SUCCESS.value
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _progress() -> dict[str, object]:
|
|
316
|
+
return {
|
|
317
|
+
"total": 0,
|
|
318
|
+
"complete": 0,
|
|
319
|
+
"source": "",
|
|
320
|
+
"source_total": 0,
|
|
321
|
+
"source_complete": 0,
|
|
322
|
+
"company": "",
|
|
323
|
+
"fetched": 0,
|
|
324
|
+
"warnings": 0,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _progress_from(payload: dict[str, object]) -> dict[str, object]:
|
|
329
|
+
progress = _progress()
|
|
330
|
+
stored = payload.get("progress")
|
|
331
|
+
if isinstance(stored, dict):
|
|
332
|
+
for key in progress:
|
|
333
|
+
if key in stored:
|
|
334
|
+
progress[key] = stored[key]
|
|
335
|
+
return progress
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _increment(progress: dict[str, object], key: str, amount: int = 1) -> None:
|
|
339
|
+
progress[key] = _number(progress, key) + amount
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _number(payload: dict[str, object], key: str) -> int:
|
|
343
|
+
value = payload.get(key)
|
|
344
|
+
return value if isinstance(value, int) else 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _positive_number(payload: dict[str, object], key: str) -> float:
|
|
348
|
+
value = payload.get(key)
|
|
349
|
+
return float(value) if isinstance(value, int | float) and value > 0 else 0.0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _time(value: object) -> datetime | None:
|
|
353
|
+
if not isinstance(value, str):
|
|
354
|
+
return None
|
|
355
|
+
try:
|
|
356
|
+
moment = datetime.fromisoformat(value)
|
|
357
|
+
except ValueError:
|
|
358
|
+
return None
|
|
359
|
+
return moment.astimezone(UTC) if moment.tzinfo is not None else None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _stamp(moment: datetime) -> str:
|
|
363
|
+
return moment.astimezone(UTC).isoformat()
|
stage/cli/selection.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
MAX_REMEMBERED = 100_000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StaleSelectionError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Selection:
|
|
15
|
+
ids: tuple[str, ...]
|
|
16
|
+
synced_at: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def selection_path() -> Path:
|
|
20
|
+
from stage.paths import data_dir
|
|
21
|
+
|
|
22
|
+
return data_dir() / "last-listing.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def remember(ids: tuple[str, ...], synced_at: datetime | None, path: Path | None = None) -> int:
|
|
26
|
+
target = path or selection_path()
|
|
27
|
+
stored = list(ids[:MAX_REMEMBERED])
|
|
28
|
+
payload = {
|
|
29
|
+
"ids": stored,
|
|
30
|
+
"synced_at": synced_at.isoformat() if synced_at else "",
|
|
31
|
+
}
|
|
32
|
+
try:
|
|
33
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
target.write_text(json.dumps(payload), encoding="utf-8")
|
|
35
|
+
except OSError:
|
|
36
|
+
return 0
|
|
37
|
+
return len(stored)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read(path: Path | None = None) -> Selection | None:
|
|
41
|
+
target = path or selection_path()
|
|
42
|
+
try:
|
|
43
|
+
payload = json.loads(target.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return None
|
|
46
|
+
if not isinstance(payload, dict):
|
|
47
|
+
return None
|
|
48
|
+
stored = payload.get("ids")
|
|
49
|
+
if not isinstance(stored, list):
|
|
50
|
+
return None
|
|
51
|
+
return Selection(
|
|
52
|
+
ids=tuple(str(value) for value in stored),
|
|
53
|
+
synced_at=str(payload.get("synced_at", "")),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve(row: int, synced_at: datetime | None, path: Path | None = None) -> str:
|
|
58
|
+
selection = read(path)
|
|
59
|
+
if selection is None or not selection.ids:
|
|
60
|
+
raise StaleSelectionError(
|
|
61
|
+
"No listing to number against. Run stage list or stage search first."
|
|
62
|
+
)
|
|
63
|
+
current = synced_at.isoformat() if synced_at else ""
|
|
64
|
+
if selection.synced_at != current:
|
|
65
|
+
raise StaleSelectionError(
|
|
66
|
+
"The database has changed since that listing. Run stage list again."
|
|
67
|
+
)
|
|
68
|
+
if row < 1 or row > len(selection.ids):
|
|
69
|
+
raise StaleSelectionError(
|
|
70
|
+
f"That listing had {len(selection.ids)} row(s), so {row} is out of range."
|
|
71
|
+
)
|
|
72
|
+
return selection.ids[row - 1]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"MAX_REMEMBERED",
|
|
77
|
+
"Selection",
|
|
78
|
+
"StaleSelectionError",
|
|
79
|
+
"read",
|
|
80
|
+
"remember",
|
|
81
|
+
"resolve",
|
|
82
|
+
"selection_path",
|
|
83
|
+
]
|
stage/cli/serialize.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from stage.cli.schedule import ScheduleStatus
|
|
8
|
+
from stage.services.canary import CanaryReport
|
|
9
|
+
from stage.services.coverage import CoverageReport
|
|
10
|
+
from stage.services.health import DoctorReport, StatsReport
|
|
11
|
+
from stage.services.query import PostingDetail
|
|
12
|
+
|
|
13
|
+
from stage.domain import Job, QuarantinedJob, RateState, VisitState
|
|
14
|
+
from stage.domain.text import dump
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _ReconfigurableOutput(Protocol):
|
|
18
|
+
def reconfigure(self, *, encoding: str) -> object: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def configure_terminal_output(stdout: object, platform: str) -> None:
|
|
22
|
+
if platform != "win32" or not hasattr(stdout, "reconfigure"):
|
|
23
|
+
return
|
|
24
|
+
cast(_ReconfigurableOutput, stdout).reconfigure(encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def emit(payload: str) -> None:
|
|
28
|
+
output = f"{payload}\n"
|
|
29
|
+
buffer = getattr(sys.stdout, "buffer", None)
|
|
30
|
+
if buffer is None:
|
|
31
|
+
sys.stdout.write(output)
|
|
32
|
+
return
|
|
33
|
+
buffer.write(output.encode("utf-8"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fail(message: str) -> None:
|
|
37
|
+
from stage.domain.text import sanitize
|
|
38
|
+
|
|
39
|
+
sys.stderr.write(f"{sanitize(message)}\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def jobs_to_json(jobs: Sequence[Job]) -> str:
|
|
43
|
+
return dump([asdict(job) for job in jobs])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _posting_payload(detail: "PostingDetail") -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"job": asdict(detail.job),
|
|
49
|
+
"canonical": asdict(detail.canonical) if detail.canonical else None,
|
|
50
|
+
"duplicates": [asdict(job) for job in detail.duplicates],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def posting_to_json(detail: "PostingDetail") -> str:
|
|
55
|
+
return dump(_posting_payload(detail))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def postings_to_json(details: Sequence["PostingDetail"]) -> str:
|
|
59
|
+
return dump([_posting_payload(detail) for detail in details])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def quarantine_to_json(entries: Sequence[QuarantinedJob]) -> str:
|
|
63
|
+
return dump([asdict(entry) for entry in entries])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def coverage_to_json(report: "CoverageReport") -> str:
|
|
67
|
+
return dump(
|
|
68
|
+
{
|
|
69
|
+
"enabled": report.enabled,
|
|
70
|
+
"disabled": report.disabled,
|
|
71
|
+
"stale_after_days": report.stale_after_days,
|
|
72
|
+
"rows": [asdict(row) for row in report.rows],
|
|
73
|
+
"unregistered": [asdict(row) for row in report.unregistered],
|
|
74
|
+
"classifications": [asdict(entry) for entry in report.classifications],
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def health_to_json(report: "DoctorReport") -> str:
|
|
80
|
+
return dump(
|
|
81
|
+
{
|
|
82
|
+
"schema_version": report.schema_version,
|
|
83
|
+
"last_sync_at": report.last_sync_at,
|
|
84
|
+
"never_synced": report.never_synced,
|
|
85
|
+
"healthy": report.is_healthy,
|
|
86
|
+
"warnings": report.warnings,
|
|
87
|
+
"due_for_recheck": list(report.due_for_recheck),
|
|
88
|
+
"workday_crawls": [asdict(crawl) for crawl in report.workday_crawls],
|
|
89
|
+
"integrity": [asdict(finding) for finding in report.integrity],
|
|
90
|
+
"blocks": [asdict(state) for state in report.blocks],
|
|
91
|
+
"sources": [
|
|
92
|
+
{
|
|
93
|
+
**asdict(source),
|
|
94
|
+
"cache_hit_ratio": source.cache_hit_ratio,
|
|
95
|
+
"success_rate": source.success_rate,
|
|
96
|
+
}
|
|
97
|
+
for source in report.sources
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sources_to_json(
|
|
104
|
+
report: "DoctorReport",
|
|
105
|
+
states: Sequence[RateState],
|
|
106
|
+
*,
|
|
107
|
+
include_boards: bool,
|
|
108
|
+
rate_states_cleared: int,
|
|
109
|
+
cache_validators_cleared: int,
|
|
110
|
+
) -> str:
|
|
111
|
+
sources = []
|
|
112
|
+
for source in report.sources:
|
|
113
|
+
payload = asdict(source)
|
|
114
|
+
payload.pop("boards")
|
|
115
|
+
payload["cache_hit_ratio"] = source.cache_hit_ratio
|
|
116
|
+
payload["success_rate"] = source.success_rate
|
|
117
|
+
sources.append(payload)
|
|
118
|
+
boards = (
|
|
119
|
+
[
|
|
120
|
+
asdict(board)
|
|
121
|
+
for source in report.sources
|
|
122
|
+
for board in source.boards
|
|
123
|
+
if board.state is not VisitState.HEALTHY
|
|
124
|
+
]
|
|
125
|
+
if include_boards
|
|
126
|
+
else []
|
|
127
|
+
)
|
|
128
|
+
return dump(
|
|
129
|
+
{
|
|
130
|
+
"healthy": report.is_healthy,
|
|
131
|
+
"stale_after_days": report.stale_after_days,
|
|
132
|
+
"sources": sources,
|
|
133
|
+
"rate_states": [asdict(state) for state in states],
|
|
134
|
+
"boards": boards,
|
|
135
|
+
"workday_crawls": [asdict(crawl) for crawl in report.workday_crawls],
|
|
136
|
+
"rate_states_cleared": rate_states_cleared,
|
|
137
|
+
"cache_validators_cleared": cache_validators_cleared,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def stats_to_json(report: "StatsReport") -> str:
|
|
143
|
+
return dump(
|
|
144
|
+
{
|
|
145
|
+
"schema_version": report.schema_version,
|
|
146
|
+
"total_jobs": report.total_jobs,
|
|
147
|
+
"duplicates": report.duplicates,
|
|
148
|
+
"tombstones": report.tombstones,
|
|
149
|
+
"cached_urls": report.cached_urls,
|
|
150
|
+
"quarantined": report.quarantined,
|
|
151
|
+
"composition": report.composition,
|
|
152
|
+
"runs": [asdict(run) for run in report.runs],
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def schedule_to_json(report: "ScheduleStatus") -> str:
|
|
158
|
+
return dump(
|
|
159
|
+
{
|
|
160
|
+
"backend": report.backend,
|
|
161
|
+
"log_dir": str(report.log_dir),
|
|
162
|
+
"actions": [
|
|
163
|
+
{
|
|
164
|
+
"key": action.key,
|
|
165
|
+
"label": action.label,
|
|
166
|
+
"cadence": action.cadence,
|
|
167
|
+
"time": action.time,
|
|
168
|
+
"enabled": enabled,
|
|
169
|
+
"installed": installed,
|
|
170
|
+
"needs_update": (
|
|
171
|
+
report.needs_update[index] if index < len(report.needs_update) else False
|
|
172
|
+
),
|
|
173
|
+
"stale_interpreter": (
|
|
174
|
+
report.stale_interpreter[index]
|
|
175
|
+
if index < len(report.stale_interpreter)
|
|
176
|
+
else ""
|
|
177
|
+
),
|
|
178
|
+
"run": report.states[index] if index < len(report.states) else None,
|
|
179
|
+
}
|
|
180
|
+
for index, (action, enabled, installed) in enumerate(report.actions)
|
|
181
|
+
],
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def canary_to_json(report: "CanaryReport") -> str:
|
|
187
|
+
return dump(
|
|
188
|
+
{
|
|
189
|
+
"passed": report.passed,
|
|
190
|
+
"skipped_platforms": list(report.skipped_platforms),
|
|
191
|
+
"probes": [
|
|
192
|
+
{**asdict(probe), "failure": probe.is_failure, "empty": probe.is_empty}
|
|
193
|
+
for probe in report.probes
|
|
194
|
+
],
|
|
195
|
+
}
|
|
196
|
+
)
|