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,494 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from stage.cli.options import (
6
+ WORD,
7
+ DatabaseOption,
8
+ InvalidOptionError,
9
+ JsonOption,
10
+ RegistryOption,
11
+ RepairOption,
12
+ StaleDaysOption,
13
+ _count,
14
+ _database,
15
+ _parse_enum,
16
+ _print_failure,
17
+ _require_enum,
18
+ app,
19
+ run_async,
20
+ )
21
+
22
+
23
+ @app.command(
24
+ help="Find registry gaps and unclassified companies seen in feeds",
25
+ rich_help_panel="Registry and maintenance",
26
+ )
27
+ def coverage(
28
+ unregistered: Annotated[
29
+ bool,
30
+ typer.Option(
31
+ "--unregistered",
32
+ help="Show feed companies not in the registry and not already reviewed",
33
+ ),
34
+ ] = False,
35
+ classified: Annotated[
36
+ bool,
37
+ typer.Option("--classified", help="Show researched feed-company classifications"),
38
+ ] = False,
39
+ contradictions: Annotated[
40
+ bool,
41
+ typer.Option(
42
+ "--contradictions",
43
+ help="Show review verdicts the registry or the clock now disagrees with",
44
+ ),
45
+ ] = False,
46
+ show_all: Annotated[
47
+ bool,
48
+ typer.Option("--all", help="List every row instead of the first 30"),
49
+ ] = False,
50
+ stale_days: StaleDaysOption = None,
51
+ as_json: JsonOption = False,
52
+ registry: RegistryOption = None,
53
+ db: DatabaseOption = None,
54
+ ) -> None:
55
+ from datetime import UTC, datetime
56
+
57
+ from stage.cli.render import ROW_PREVIEW, render_coverage, terminal
58
+ from stage.cli.serialize import coverage_to_json, emit
59
+ from stage.companies import RegistryError, load_companies
60
+ from stage.services.coverage import CoverageReport
61
+ from stage.services.coverage import coverage as coverage_service
62
+ from stage.storage import open_repository
63
+
64
+ console = terminal()
65
+ now = datetime.now(UTC)
66
+
67
+ async def run() -> CoverageReport:
68
+ companies = load_companies(registry)
69
+ async with open_repository(_database(db)) as repository:
70
+ if stale_days is None:
71
+ return await coverage_service(
72
+ repository,
73
+ companies,
74
+ now=now,
75
+ unregistered=unregistered,
76
+ )
77
+ return await coverage_service(
78
+ repository,
79
+ companies,
80
+ now=now,
81
+ unregistered=unregistered,
82
+ stale_after_days=stale_days,
83
+ )
84
+
85
+ try:
86
+ report = run_async(run())
87
+ except RegistryError as exc:
88
+ _print_failure(exc)
89
+ raise typer.Exit(code=2) from exc
90
+
91
+ if as_json:
92
+ emit(coverage_to_json(report))
93
+ return
94
+ render_coverage(
95
+ console,
96
+ report,
97
+ now,
98
+ include_classified=classified,
99
+ include_contradictions=contradictions,
100
+ limit=None if show_all else ROW_PREVIEW,
101
+ )
102
+
103
+
104
+ @app.command(
105
+ help="Record why a feed-seen employer is not directly synced",
106
+ rich_help_panel="Registry and maintenance",
107
+ )
108
+ def classify(
109
+ company: Annotated[
110
+ str,
111
+ typer.Argument(
112
+ metavar="NAME",
113
+ click_type=WORD,
114
+ help="Employer name from stage coverage --unregistered",
115
+ ),
116
+ ],
117
+ disposition: Annotated[
118
+ str | None,
119
+ typer.Option(
120
+ "--status",
121
+ metavar="STATUS",
122
+ help="Required unless --clear: feed-only, unavailable, custom-json-candidate, "
123
+ "adapter-candidate, or deferred",
124
+ ),
125
+ ] = None,
126
+ note: Annotated[
127
+ str | None,
128
+ typer.Option(
129
+ "--note", metavar="TEXT", help="Required unless --clear: evidence for this decision"
130
+ ),
131
+ ] = None,
132
+ url: Annotated[
133
+ str | None, typer.Option("--url", metavar="URL", help="Public careers or jobs endpoint")
134
+ ] = None,
135
+ clear: Annotated[bool, typer.Option("--clear", help="Remove this classification")] = False,
136
+ db: DatabaseOption = None,
137
+ ) -> None:
138
+ from datetime import UTC, datetime
139
+
140
+ from stage.cli.render import terminal
141
+ from stage.domain import CoverageClassification, CoverageDisposition
142
+ from stage.storage import open_repository
143
+
144
+ console = terminal()
145
+ if clear:
146
+ if disposition is not None or note is not None or url is not None:
147
+ console.print("[red]--clear cannot be combined with --status, --note, or --url.[/red]")
148
+ raise typer.Exit(code=2)
149
+ try:
150
+
151
+ async def clear_entry() -> bool:
152
+ async with open_repository(_database(db)) as repository:
153
+ return await repository.clear_coverage_classification(company)
154
+
155
+ removed = run_async(clear_entry())
156
+ except ValueError as exc:
157
+ _print_failure(exc)
158
+ raise typer.Exit(code=2) from exc
159
+ if not removed:
160
+ console.print("[yellow]No matching classification was recorded.[/yellow]")
161
+ raise typer.Exit(code=1)
162
+ console.print("Classification removed.")
163
+ return
164
+ if disposition is None or note is None:
165
+ options = ", ".join(item.value for item in CoverageDisposition)
166
+ console.print(
167
+ f"[red]Pass both --status and --note, or pass --clear.[/red]\n"
168
+ f"--status must be one of: {options}"
169
+ )
170
+ raise typer.Exit(code=2)
171
+ try:
172
+ entry = CoverageClassification(
173
+ company=company,
174
+ disposition=_require_enum(disposition, CoverageDisposition, "--status"),
175
+ note=note,
176
+ checked_on=datetime.now(UTC),
177
+ url=url,
178
+ )
179
+
180
+ async def record_entry() -> bool:
181
+ async with open_repository(_database(db)) as repository:
182
+ return await repository.record_coverage_classification(entry)
183
+
184
+ replaced = run_async(record_entry())
185
+ except (InvalidOptionError, ValueError) as exc:
186
+ _print_failure(exc)
187
+ raise typer.Exit(code=2) from exc
188
+ console.print("Classification updated." if replaced else "Classification recorded.")
189
+
190
+
191
+ @app.command(
192
+ help="Check database integrity, source health, and staleness", rich_help_panel="Health"
193
+ )
194
+ def doctor(
195
+ show_all: Annotated[
196
+ bool,
197
+ typer.Option("--all", help="List every board and row instead of the first 10"),
198
+ ] = False,
199
+ verbose: Annotated[
200
+ bool,
201
+ typer.Option("--verbose", help="Print each board error in full"),
202
+ ] = False,
203
+ stale_days: StaleDaysOption = None,
204
+ as_json: JsonOption = False,
205
+ repair: RepairOption = False,
206
+ db: DatabaseOption = None,
207
+ registry: RegistryOption = None,
208
+ ) -> None:
209
+ from datetime import UTC, datetime
210
+
211
+ from stage.cli.render import (
212
+ BOARD_PREVIEW,
213
+ failure,
214
+ render_doctor,
215
+ render_repairs,
216
+ terminal,
217
+ )
218
+ from stage.cli.serialize import emit, health_to_json
219
+ from stage.companies import RegistryError, load_companies
220
+ from stage.domain import IntegrityRepair
221
+ from stage.services.health import DoctorReport
222
+ from stage.services.health import doctor as run_doctor
223
+ from stage.services.maintenance import repair_integrity
224
+ from stage.storage import open_repository
225
+
226
+ console = terminal()
227
+ now = datetime.now(UTC)
228
+ try:
229
+ rows = load_companies(registry)
230
+ except RegistryError as exc:
231
+ console.print(failure(exc))
232
+ raise typer.Exit(code=2) from exc
233
+
234
+ async def run() -> tuple[DoctorReport, tuple[IntegrityRepair, ...]]:
235
+ async with open_repository(_database(db)) as repository:
236
+ repairs = await repair_integrity(repository) if repair else ()
237
+ if stale_days is None:
238
+ return await run_doctor(repository, now=now, companies=rows), repairs
239
+ return (
240
+ await run_doctor(repository, now=now, stale_after_days=stale_days, companies=rows),
241
+ repairs,
242
+ )
243
+
244
+ report, repairs = run_async(run())
245
+ if as_json:
246
+ emit(health_to_json(report))
247
+ else:
248
+ render_repairs(console, repairs)
249
+ render_doctor(
250
+ console,
251
+ report,
252
+ now,
253
+ limit=None if show_all else BOARD_PREVIEW,
254
+ verbose=verbose,
255
+ )
256
+ if not report.is_healthy:
257
+ raise typer.Exit(code=1)
258
+
259
+
260
+ @app.command(help="Show sync history and database totals", rich_help_panel="Health")
261
+ def stats(
262
+ show_all: Annotated[
263
+ bool,
264
+ typer.Option("--all", help="List every breakdown row instead of the first 10"),
265
+ ] = False,
266
+ runs: Annotated[
267
+ int,
268
+ typer.Option(
269
+ "--runs", metavar="N", click_type=_count(1, 200), help="How many recent syncs to show"
270
+ ),
271
+ ] = 10,
272
+ as_json: JsonOption = False,
273
+ db: DatabaseOption = None,
274
+ ) -> None:
275
+ from datetime import UTC, datetime
276
+
277
+ from stage.cli.render import BOARD_PREVIEW, render_stats, terminal
278
+ from stage.cli.serialize import emit, stats_to_json
279
+ from stage.services.health import StatsReport, statistics
280
+ from stage.storage import open_repository
281
+
282
+ console = terminal()
283
+
284
+ async def run() -> StatsReport:
285
+ async with open_repository(_database(db)) as repository:
286
+ return await statistics(repository, history=max(1, runs))
287
+
288
+ report = run_async(run())
289
+ if as_json:
290
+ emit(stats_to_json(report))
291
+ else:
292
+ render_stats(
293
+ console,
294
+ report,
295
+ datetime.now(UTC),
296
+ limit=None if show_all else BOARD_PREVIEW,
297
+ )
298
+
299
+
300
+ @app.command(help="Inspect source health, rate limits, and HTTP caches", rich_help_panel="Health")
301
+ def sources(
302
+ verbose: Annotated[
303
+ bool,
304
+ typer.Option("--verbose", help="Print each error and reason in full"),
305
+ ] = False,
306
+ reset_rate_limit: Annotated[
307
+ str | None,
308
+ typer.Option(
309
+ "--reset-rate-limit",
310
+ metavar="BUCKET",
311
+ help="Reset stored rate-limit state for one host bucket",
312
+ ),
313
+ ] = None,
314
+ reset_all_rate_limits: Annotated[
315
+ bool,
316
+ typer.Option(
317
+ "--reset-all",
318
+ help="Reset stored rate-limit state for every host bucket",
319
+ ),
320
+ ] = False,
321
+ boards: Annotated[
322
+ bool,
323
+ typer.Option("--boards", help="Include every failing or stale board"),
324
+ ] = False,
325
+ clear_cache: Annotated[
326
+ str | None,
327
+ typer.Option(
328
+ "--clear-cache",
329
+ metavar="SOURCE",
330
+ help="Clear saved HTTP validators for one source to force a refetch",
331
+ ),
332
+ ] = None,
333
+ clear_cache_all: Annotated[
334
+ bool,
335
+ typer.Option("--clear-cache-all", help="Clear saved HTTP validators for every source"),
336
+ ] = False,
337
+ stale_days: StaleDaysOption = None,
338
+ as_json: JsonOption = False,
339
+ db: DatabaseOption = None,
340
+ ) -> None:
341
+ from datetime import UTC, datetime
342
+
343
+ from stage.cli.render import (
344
+ render_board_health,
345
+ render_rate_state,
346
+ render_source_health,
347
+ render_workday_crawl_progress,
348
+ terminal,
349
+ )
350
+ from stage.cli.serialize import emit, sources_to_json
351
+ from stage.services.health import DoctorReport
352
+ from stage.services.health import doctor as run_doctor
353
+ from stage.services.maintenance import RateStateView, rate_state
354
+ from stage.storage import open_repository
355
+
356
+ console = terminal()
357
+ now = datetime.now(UTC)
358
+
359
+ if reset_rate_limit is not None and reset_all_rate_limits:
360
+ console.print("[red]Pass either --reset-rate-limit BUCKET or --reset-all, not both.[/red]")
361
+ raise typer.Exit(code=2)
362
+ if clear_cache is not None and clear_cache_all:
363
+ console.print(
364
+ "[red]Pass either --clear-cache <source> or --clear-cache-all, not both.[/red]"
365
+ )
366
+ raise typer.Exit(code=2)
367
+
368
+ async def run() -> tuple[RateStateView, DoctorReport]:
369
+ async with open_repository(_database(db)) as repository:
370
+ view = await rate_state(
371
+ repository,
372
+ bucket=reset_rate_limit,
373
+ clear_all=reset_all_rate_limits,
374
+ clear_cache=clear_cache,
375
+ clear_cache_all=clear_cache_all,
376
+ )
377
+ report = (
378
+ await run_doctor(repository, now=now)
379
+ if stale_days is None
380
+ else await run_doctor(repository, now=now, stale_after_days=stale_days)
381
+ )
382
+ return view, report
383
+
384
+ view, report = run_async(run())
385
+ cleared, states = view.cleared, view.states
386
+
387
+ if view.validators_cleared or clear_cache is not None or clear_cache_all:
388
+ target = "every source" if clear_cache_all else f"source {clear_cache!r}"
389
+ console.print(f"Cleared {view.validators_cleared} saved HTTP validator(s) for {target}.")
390
+ console.print("[dim]The next sync will fully refetch those sources.[/dim]")
391
+
392
+ if reset_rate_limit is not None or reset_all_rate_limits:
393
+ target = (
394
+ "every host bucket" if reset_all_rate_limits else f"host bucket {reset_rate_limit!r}"
395
+ )
396
+ if cleared:
397
+ console.print(f"Reset rate-limit state for {target} ({cleared} row(s)).")
398
+ else:
399
+ console.print(f"[yellow]No stored rate state for {target}.[/yellow]")
400
+
401
+ if as_json:
402
+ emit(
403
+ sources_to_json(
404
+ report,
405
+ states,
406
+ include_boards=boards,
407
+ rate_states_cleared=cleared,
408
+ cache_validators_cleared=view.validators_cleared,
409
+ )
410
+ )
411
+ return
412
+
413
+ render_source_health(console, report.sources, report.stale_after_days)
414
+ console.print()
415
+ render_workday_crawl_progress(console, report.workday_crawls)
416
+ console.print()
417
+ render_rate_state(console, states, now, verbose=verbose)
418
+ if boards:
419
+ console.print()
420
+ render_board_health(console, report.sources, now, verbose=verbose)
421
+
422
+
423
+ @app.command(help="Inspect rejected postings and why they were rejected", rich_help_panel="Health")
424
+ def quarantine(
425
+ reason: Annotated[
426
+ str | None,
427
+ typer.Option(
428
+ "--reason",
429
+ metavar="REASON",
430
+ help=(
431
+ "Filter by rejection reason: out-of-scope-location, not-an-internship, "
432
+ "out-of-scope-degree, not-a-cs-role, unknown-cs-role"
433
+ ),
434
+ ),
435
+ ] = None,
436
+ source: Annotated[
437
+ str | None,
438
+ typer.Option("--source", metavar="SOURCE", help="Filter by source adapter name"),
439
+ ] = None,
440
+ company: Annotated[
441
+ str | None,
442
+ typer.Option("--company", metavar="NAME", help="Filter by exact employer name"),
443
+ ] = None,
444
+ show_all: Annotated[
445
+ bool,
446
+ typer.Option("--all", help="Show every match instead of the first page"),
447
+ ] = False,
448
+ limit: Annotated[
449
+ int,
450
+ typer.Option(
451
+ "--limit",
452
+ metavar="N",
453
+ click_type=_count(1, None),
454
+ help="Maximum number of rejected postings to show; --all shows every match",
455
+ ),
456
+ ] = 50,
457
+ as_json: JsonOption = False,
458
+ db: DatabaseOption = None,
459
+ ) -> None:
460
+ from stage.cli.render import render_quarantine, terminal
461
+ from stage.cli.serialize import emit, quarantine_to_json
462
+ from stage.domain import QuarantineFilters, RejectionReason
463
+ from stage.services.quarantine import QuarantineListing, list_quarantined
464
+ from stage.storage import open_repository
465
+
466
+ console = terminal()
467
+
468
+ try:
469
+ filters = QuarantineFilters(
470
+ reason=_parse_enum(reason, RejectionReason, "--reason"),
471
+ source=source,
472
+ company=company,
473
+ limit=None if show_all else limit,
474
+ )
475
+ except InvalidOptionError as exc:
476
+ _print_failure(exc)
477
+ raise typer.Exit(code=2) from exc
478
+
479
+ async def run() -> QuarantineListing:
480
+ async with open_repository(_database(db)) as repository:
481
+ return await list_quarantined(repository, filters)
482
+
483
+ listing = run_async(run())
484
+
485
+ if as_json:
486
+ emit(quarantine_to_json(listing.entries))
487
+ return
488
+
489
+ render_quarantine(
490
+ console,
491
+ listing.entries,
492
+ total_matching=listing.total_matching,
493
+ reason_counts=listing.reason_counts,
494
+ )