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,337 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from stage.cli.options import (
|
|
7
|
+
DatabaseOption,
|
|
8
|
+
JsonOption,
|
|
9
|
+
RegistryOption,
|
|
10
|
+
_count,
|
|
11
|
+
_database,
|
|
12
|
+
_lock_path,
|
|
13
|
+
_print_failure,
|
|
14
|
+
app,
|
|
15
|
+
run_async,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command(
|
|
20
|
+
help="Fetch enabled sources and store matching postings", rich_help_panel="Keeping current"
|
|
21
|
+
)
|
|
22
|
+
def sync(
|
|
23
|
+
source: Annotated[
|
|
24
|
+
list[str] | None,
|
|
25
|
+
typer.Option(
|
|
26
|
+
"--source", metavar="SOURCE", help="Sync only these source adapters; repeatable"
|
|
27
|
+
),
|
|
28
|
+
] = None,
|
|
29
|
+
exclude: Annotated[
|
|
30
|
+
list[str] | None,
|
|
31
|
+
typer.Option("--exclude", metavar="NAME", help="Skip these source adapters; repeatable"),
|
|
32
|
+
] = None,
|
|
33
|
+
dry_run: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--dry-run",
|
|
37
|
+
help="Show the sync plan without fetching or changing the database",
|
|
38
|
+
),
|
|
39
|
+
] = False,
|
|
40
|
+
force_refresh: Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--force-refresh",
|
|
44
|
+
help="Re-fetch every board even if it was refreshed inside its window",
|
|
45
|
+
),
|
|
46
|
+
] = False,
|
|
47
|
+
request_log: Annotated[
|
|
48
|
+
Path | None,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--request-log",
|
|
51
|
+
metavar="FILE",
|
|
52
|
+
help="Write outbound HTTP requests to this JSON Lines file",
|
|
53
|
+
),
|
|
54
|
+
] = None,
|
|
55
|
+
scheduled_progress: Annotated[
|
|
56
|
+
Path | None,
|
|
57
|
+
typer.Option("--scheduled-progress", hidden=True),
|
|
58
|
+
] = None,
|
|
59
|
+
registry: RegistryOption = None,
|
|
60
|
+
db: DatabaseOption = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
from contextlib import ExitStack
|
|
63
|
+
|
|
64
|
+
from stage.cli.logfile import open_request_log
|
|
65
|
+
from stage.cli.render import failure, render_sync, terminal
|
|
66
|
+
from stage.cli.runlock import AnotherRunInProgressError, single_run
|
|
67
|
+
from stage.cli.schedule_state import ScheduleStateWriter
|
|
68
|
+
from stage.companies import RegistryError, load_companies
|
|
69
|
+
from stage.domain import SyncOutcome
|
|
70
|
+
from stage.services.sync import NoSourcesSelectedError
|
|
71
|
+
from stage.services.sync import sync as sync_service
|
|
72
|
+
from stage.storage import open_repository
|
|
73
|
+
|
|
74
|
+
console = terminal()
|
|
75
|
+
scheduled_state = (
|
|
76
|
+
ScheduleStateWriter.open(scheduled_progress, "sync")
|
|
77
|
+
if scheduled_progress is not None
|
|
78
|
+
else None
|
|
79
|
+
)
|
|
80
|
+
progress = scheduled_state.sync_event if scheduled_state is not None else None
|
|
81
|
+
|
|
82
|
+
if source and exclude:
|
|
83
|
+
console.print("[red]Pass either --source or --exclude, not both.[/red]")
|
|
84
|
+
raise typer.Exit(code=2)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
companies = load_companies(registry)
|
|
88
|
+
except RegistryError as exc:
|
|
89
|
+
console.print(failure(exc))
|
|
90
|
+
raise typer.Exit(code=2) from exc
|
|
91
|
+
|
|
92
|
+
async def run() -> SyncOutcome:
|
|
93
|
+
with ExitStack() as stack:
|
|
94
|
+
stream = (
|
|
95
|
+
stack.enter_context(open_request_log(request_log))
|
|
96
|
+
if request_log is not None
|
|
97
|
+
else None
|
|
98
|
+
)
|
|
99
|
+
async with open_repository(_database(db)) as repository:
|
|
100
|
+
events = sync_service(
|
|
101
|
+
repository,
|
|
102
|
+
companies,
|
|
103
|
+
sources=source or None,
|
|
104
|
+
excluded=exclude or None,
|
|
105
|
+
dry_run=dry_run,
|
|
106
|
+
force_refresh=force_refresh,
|
|
107
|
+
)
|
|
108
|
+
outcome = await render_sync(console, events, request_log=stream, progress=progress)
|
|
109
|
+
if scheduled_progress is not None and not dry_run:
|
|
110
|
+
await _announce_new_postings(repository, console)
|
|
111
|
+
return outcome
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
if dry_run:
|
|
115
|
+
outcome = run_async(run())
|
|
116
|
+
else:
|
|
117
|
+
with single_run("sync", _lock_path(db)):
|
|
118
|
+
outcome = run_async(run())
|
|
119
|
+
except AnotherRunInProgressError as exc:
|
|
120
|
+
if scheduled_state is not None:
|
|
121
|
+
scheduled_state.blocked(str(exc))
|
|
122
|
+
console.print(failure(exc))
|
|
123
|
+
raise typer.Exit(code=2) from exc
|
|
124
|
+
except (RegistryError, NoSourcesSelectedError) as exc:
|
|
125
|
+
console.print(failure(exc))
|
|
126
|
+
raise typer.Exit(code=2) from exc
|
|
127
|
+
|
|
128
|
+
raise typer.Exit(code=0 if outcome is SyncOutcome.SUCCESS else 1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _announce_new_postings(repository: Any, console: Any) -> None:
|
|
132
|
+
from dataclasses import replace
|
|
133
|
+
|
|
134
|
+
from stage.cli import notify
|
|
135
|
+
from stage.cli.render import plain
|
|
136
|
+
from stage.domain import JobFilters
|
|
137
|
+
from stage.normalize.location import display_location
|
|
138
|
+
from stage.services.query import list_jobs
|
|
139
|
+
|
|
140
|
+
webhook = notify.read()
|
|
141
|
+
if not webhook:
|
|
142
|
+
return
|
|
143
|
+
since = await repository.previous_sync_at()
|
|
144
|
+
if since is None:
|
|
145
|
+
return
|
|
146
|
+
filters = replace(JobFilters(limit=notify.MAX_LISTED), first_seen_after=since)
|
|
147
|
+
listing = await list_jobs(repository, filters, window_days=None)
|
|
148
|
+
if not listing.jobs:
|
|
149
|
+
return
|
|
150
|
+
postings = [
|
|
151
|
+
notify.Posting(
|
|
152
|
+
company=job.company,
|
|
153
|
+
title=job.title_raw,
|
|
154
|
+
location=display_location(job.location_raw),
|
|
155
|
+
url=job.apply_url_raw,
|
|
156
|
+
)
|
|
157
|
+
for job in listing.jobs
|
|
158
|
+
]
|
|
159
|
+
try:
|
|
160
|
+
notify.post(webhook, notify.compose(postings, listing.total_matching))
|
|
161
|
+
except notify.NotifyError as exc:
|
|
162
|
+
console.print(plain(f"Could not post to Discord: {exc}", style="yellow"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command(
|
|
166
|
+
help="Remove expired postings and prune old diagnostic files",
|
|
167
|
+
rich_help_panel="Registry and maintenance",
|
|
168
|
+
)
|
|
169
|
+
def purge(
|
|
170
|
+
dry_run: Annotated[
|
|
171
|
+
bool,
|
|
172
|
+
typer.Option("--dry-run", help="Preview retention cleanup without removing postings"),
|
|
173
|
+
] = False,
|
|
174
|
+
db: DatabaseOption = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
from datetime import UTC, datetime
|
|
177
|
+
|
|
178
|
+
from stage.cli.render import terminal
|
|
179
|
+
from stage.domain import PurgeResult
|
|
180
|
+
from stage.services.maintenance import preview_purge, purge_expired
|
|
181
|
+
from stage.storage import open_repository
|
|
182
|
+
|
|
183
|
+
console = terminal()
|
|
184
|
+
|
|
185
|
+
async def run() -> PurgeResult:
|
|
186
|
+
async with open_repository(_database(db)) as repository:
|
|
187
|
+
now = datetime.now(UTC)
|
|
188
|
+
if dry_run:
|
|
189
|
+
return await preview_purge(repository, now=now)
|
|
190
|
+
return await purge_expired(repository, now=now)
|
|
191
|
+
|
|
192
|
+
result = run_async(run())
|
|
193
|
+
|
|
194
|
+
from stage.cli.housekeeping import tidy
|
|
195
|
+
|
|
196
|
+
swept = tidy(dry_run=dry_run)
|
|
197
|
+
if swept.captures_removed or swept.journal_rotated:
|
|
198
|
+
parts = []
|
|
199
|
+
if swept.captures_removed:
|
|
200
|
+
verb = "Would remove" if dry_run else "Removed"
|
|
201
|
+
parts.append(f"{verb} {swept.captures_removed} old captured payload(s)")
|
|
202
|
+
if swept.journal_rotated:
|
|
203
|
+
parts.append("rotated the probe journal" if not dry_run else "would rotate the journal")
|
|
204
|
+
console.print(f"[dim]{', '.join(parts)}.[/dim]")
|
|
205
|
+
|
|
206
|
+
if dry_run:
|
|
207
|
+
if result.purged:
|
|
208
|
+
console.print(
|
|
209
|
+
f"Would purge {result.purged} posting(s) and create or refresh "
|
|
210
|
+
f"{result.tombstoned} tombstone(s). No postings removed."
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
console.print("[dim]Nothing would be removed. No postings removed.[/dim]")
|
|
214
|
+
return
|
|
215
|
+
if not result.purged:
|
|
216
|
+
if result.promoted:
|
|
217
|
+
console.print(
|
|
218
|
+
f"Nothing outside the retention window, and {result.promoted} orphaned "
|
|
219
|
+
"duplicate(s) were promoted back into view."
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
console.print("[dim]Nothing outside the retention window.[/dim]")
|
|
223
|
+
return
|
|
224
|
+
promoted = f", {result.promoted} duplicate(s) promoted" if result.promoted else ""
|
|
225
|
+
console.print(
|
|
226
|
+
f"Purged {result.purged} posting(s), {result.tombstoned} tombstone(s) kept{promoted}."
|
|
227
|
+
)
|
|
228
|
+
console.print(
|
|
229
|
+
"[dim]Tombstones keep the original first_seen so a still-open posting is not "
|
|
230
|
+
"re-ingested as new.[/dim]"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command(
|
|
235
|
+
help="Reclassify stored postings after a lexicon change",
|
|
236
|
+
rich_help_panel="Registry and maintenance",
|
|
237
|
+
)
|
|
238
|
+
def rescreen(db: DatabaseOption = None) -> None:
|
|
239
|
+
from datetime import UTC, datetime
|
|
240
|
+
|
|
241
|
+
from stage.cli.render import terminal
|
|
242
|
+
from stage.services.maintenance import RESCREEN_LIMIT, RescreenResult
|
|
243
|
+
from stage.services.maintenance import rescreen as rescreen_service
|
|
244
|
+
from stage.storage import open_repository
|
|
245
|
+
|
|
246
|
+
console = terminal()
|
|
247
|
+
|
|
248
|
+
async def run() -> RescreenResult:
|
|
249
|
+
async with open_repository(_database(db)) as repository:
|
|
250
|
+
return await rescreen_service(repository, now=datetime.now(UTC))
|
|
251
|
+
|
|
252
|
+
result = run_async(run())
|
|
253
|
+
if not result.examined and not result.released:
|
|
254
|
+
console.print("[dim]Nothing stored yet — run stage sync.[/dim]")
|
|
255
|
+
return
|
|
256
|
+
if result.skipped:
|
|
257
|
+
console.print(
|
|
258
|
+
f"[yellow]{result.skipped} posting(s) were not examined[/yellow] — this pass reads "
|
|
259
|
+
f"at most {RESCREEN_LIMIT} rows. Run stage purge, then rescreen again."
|
|
260
|
+
)
|
|
261
|
+
if not result.changed:
|
|
262
|
+
console.print(
|
|
263
|
+
f"Re-screened {result.examined} posting(s); the lexicon agrees with every one."
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
changes: list[str] = []
|
|
267
|
+
if result.updated:
|
|
268
|
+
changes.append(f"[yellow]{result.updated} classification(s) updated[/yellow]")
|
|
269
|
+
if result.quarantined:
|
|
270
|
+
changes.append(f"[yellow]{result.quarantined} moved to quarantine[/yellow]")
|
|
271
|
+
if result.released:
|
|
272
|
+
changes.append(f"[green]{result.released} restored from quarantine[/green]")
|
|
273
|
+
if result.relabelled:
|
|
274
|
+
changes.append(f"[dim]{result.relabelled} rejection(s) given a sharper reason[/dim]")
|
|
275
|
+
if result.relocated:
|
|
276
|
+
changes.append(f"[dim]{result.relocated} quarantine location(s) re-read[/dim]")
|
|
277
|
+
console.print(f"Re-screened {result.examined} posting(s) — {', '.join(changes)}.")
|
|
278
|
+
if result.released:
|
|
279
|
+
console.print(
|
|
280
|
+
"[dim]Restored postings keep their original title, link, and location. A later source "
|
|
281
|
+
"refresh may fill metadata that quarantine does not retain.[/dim]"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.command(help="Check one live board per platform against each parser", rich_help_panel="Health")
|
|
286
|
+
def canary(
|
|
287
|
+
verbose: Annotated[
|
|
288
|
+
bool,
|
|
289
|
+
typer.Option("--verbose", help="Print each probe note in full"),
|
|
290
|
+
] = False,
|
|
291
|
+
timeout: Annotated[
|
|
292
|
+
int,
|
|
293
|
+
typer.Option(
|
|
294
|
+
"--timeout",
|
|
295
|
+
metavar="SECONDS",
|
|
296
|
+
click_type=_count(1, 3600),
|
|
297
|
+
help="Give up after this long; every probe is a live network request",
|
|
298
|
+
),
|
|
299
|
+
] = 300,
|
|
300
|
+
as_json: JsonOption = False,
|
|
301
|
+
registry: RegistryOption = None,
|
|
302
|
+
db: DatabaseOption = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
from stage.cli.render import render_canary, terminal
|
|
305
|
+
from stage.cli.serialize import canary_to_json, emit
|
|
306
|
+
from stage.companies import RegistryError, load_companies
|
|
307
|
+
from stage.services.canary import CanaryReport
|
|
308
|
+
from stage.services.canary import canary as run_canary
|
|
309
|
+
from stage.storage import open_repository
|
|
310
|
+
|
|
311
|
+
console = terminal()
|
|
312
|
+
|
|
313
|
+
async def run() -> CanaryReport:
|
|
314
|
+
import asyncio
|
|
315
|
+
|
|
316
|
+
companies = load_companies(registry)
|
|
317
|
+
async with open_repository(_database(db)) as repository:
|
|
318
|
+
return await asyncio.wait_for(run_canary(repository, companies), timeout)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
report = run_async(run())
|
|
322
|
+
except RegistryError as exc:
|
|
323
|
+
_print_failure(exc)
|
|
324
|
+
raise typer.Exit(code=2) from exc
|
|
325
|
+
except TimeoutError:
|
|
326
|
+
console.print(
|
|
327
|
+
f"[red]Gave up after {timeout}s.[/red] Boards can be slow or unreachable; "
|
|
328
|
+
"raise the ceiling with [bold]--timeout[/bold]."
|
|
329
|
+
)
|
|
330
|
+
raise typer.Exit(code=1) from None
|
|
331
|
+
|
|
332
|
+
if as_json:
|
|
333
|
+
emit(canary_to_json(report))
|
|
334
|
+
else:
|
|
335
|
+
render_canary(console, report, verbose=verbose)
|
|
336
|
+
if not report.passed:
|
|
337
|
+
raise typer.Exit(code=1)
|