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,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
+ )