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