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
stage/cli/options.py ADDED
@@ -0,0 +1,678 @@
1
+ import sys
2
+ from collections.abc import Callable, Coroutine, Iterable
3
+ from datetime import date
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Annotated, Any
7
+
8
+ import typer
9
+ from typer._click.types import IntRange, StringParamType
10
+ from typer.core import TyperGroup
11
+
12
+ if TYPE_CHECKING:
13
+ from rich.console import Console
14
+
15
+ from stage.domain import DiscoveryEvent, JobFilters
16
+
17
+ ProgressCallback = Callable[[DiscoveryEvent], None]
18
+
19
+
20
+ def run_async[T](coroutine: Coroutine[Any, Any, T]) -> T:
21
+ import asyncio
22
+
23
+ return asyncio.run(coroutine)
24
+
25
+
26
+ class InvalidOptionError(Exception):
27
+ pass
28
+
29
+
30
+ class _Word(StringParamType):
31
+ name = "text"
32
+
33
+ def get_metavar(self, *_args: Any, **_kwargs: Any) -> str:
34
+ return ""
35
+
36
+
37
+ class _Count(IntRange):
38
+ def get_metavar(self, *_args: Any, **_kwargs: Any) -> str:
39
+ return "N"
40
+
41
+ def _describe_range(self) -> str:
42
+ return ""
43
+
44
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
45
+ try:
46
+ return super().convert(value, param, ctx)
47
+ except typer.BadParameter:
48
+ try:
49
+ int(value)
50
+ except (TypeError, ValueError):
51
+ self.fail(f"{value} is not a whole number.", param, ctx)
52
+ bounds = (
53
+ f"{self.min} or more"
54
+ if self.max is None
55
+ else f"at most {self.max}"
56
+ if self.min is None
57
+ else f"between {self.min} and {self.max}"
58
+ )
59
+ self.fail(f"{value} is out of range; expected a number {bounds}.", param, ctx)
60
+
61
+
62
+ WORD = _Word()
63
+
64
+
65
+ def _count(minimum: int, maximum: int | None = None) -> _Count:
66
+ return _Count(min=minimum, max=maximum)
67
+
68
+
69
+ def _parse_enum[E: StrEnum](value: str | None, enum: type[E], flag: str) -> E | None:
70
+ if value is None:
71
+ return None
72
+ return _require_enum(value, enum, flag)
73
+
74
+
75
+ UNMATCHABLE_VALUES = {
76
+ "--role": frozenset({"hardware"}),
77
+ "--location": frozenset({"international"}),
78
+ }
79
+
80
+
81
+ def _require_enum[E: StrEnum](value: str, enum: type[E], flag: str) -> E:
82
+ try:
83
+ return enum(value)
84
+ except ValueError as exc:
85
+ hidden = UNMATCHABLE_VALUES.get(flag, frozenset())
86
+ options = ", ".join(v for v in enum.__members__.values() if v not in hidden)
87
+ raise InvalidOptionError(f"{flag} must be one of: {options}") from exc
88
+
89
+
90
+ class _Banner(TyperGroup):
91
+ def format_help(self, ctx: Any, formatter: Any) -> None:
92
+ from stage.cli.render import splash, terminal
93
+
94
+ console = terminal()
95
+ splash(console)
96
+ super().format_help(ctx, formatter)
97
+
98
+
99
+ app = typer.Typer(
100
+ cls=_Banner,
101
+ no_args_is_help=False,
102
+ invoke_without_command=True,
103
+ help="Aggregates CS internship postings into a local SQLite database.",
104
+ epilog="Run stage help for a guide with examples.",
105
+ )
106
+ schedule_app = typer.Typer(
107
+ add_completion=False,
108
+ no_args_is_help=True,
109
+ help="Run syncs automatically in the background",
110
+ )
111
+ app.add_typer(schedule_app, name="schedule", rich_help_panel="Keeping current")
112
+
113
+
114
+ def _tidy_builtin_option_help() -> None:
115
+ from typer._click import decorators
116
+ from typer.completion import _install_completion_placeholder_function
117
+ from typer.models import OptionInfo
118
+
119
+ for default in _install_completion_placeholder_function.__defaults__ or ():
120
+ if not isinstance(default, OptionInfo) or not default.help:
121
+ continue
122
+ if "copy it" in default.help:
123
+ default.help = "Show completion for the current shell"
124
+ default.help = default.help.rstrip(".")
125
+
126
+ build = decorators.help_option
127
+
128
+ def without_a_trailing_stop(param_decls: list[str]) -> Any:
129
+ decorate = build(param_decls)
130
+
131
+ def apply(command: Any) -> Any:
132
+ decorated = decorate(command)
133
+ for param in decorated.params:
134
+ text = getattr(param, "help", None)
135
+ if text:
136
+ param.help = text.rstrip(".") # type: ignore[attr-defined]
137
+ return decorated
138
+
139
+ return apply
140
+
141
+ decorators.help_option = without_a_trailing_stop
142
+
143
+
144
+ _tidy_builtin_option_help()
145
+
146
+
147
+ def _version(value: bool) -> None:
148
+ if not value:
149
+ return
150
+ from stage import __version__
151
+
152
+ typer.echo(f"stage {__version__}")
153
+ raise typer.Exit
154
+
155
+
156
+ @app.callback(invoke_without_command=True)
157
+ def _root(
158
+ context: typer.Context,
159
+ _version_flag: Annotated[
160
+ bool,
161
+ typer.Option(
162
+ "--version",
163
+ "-V",
164
+ help="Show the installed version and exit",
165
+ callback=_version,
166
+ is_eager=True,
167
+ ),
168
+ ] = False,
169
+ ) -> None:
170
+ if context.invoked_subcommand is None:
171
+ typer.echo(context.get_help())
172
+ raise typer.Exit
173
+
174
+
175
+ RegistryOption = Annotated[
176
+ Path | None,
177
+ typer.Option(
178
+ "--registry", metavar="FILE", help="Use this company registry instead of the default"
179
+ ),
180
+ ]
181
+ DatabaseOption = Annotated[
182
+ Path | None,
183
+ typer.Option("--db", metavar="FILE", help="Use this SQLite database instead of the default"),
184
+ ]
185
+ JsonOption = Annotated[bool, typer.Option("--json", help="Print machine-readable JSON")]
186
+ RepairOption = Annotated[
187
+ bool,
188
+ typer.Option("--repair", help="Fix the integrity findings that can be fixed safely"),
189
+ ]
190
+ LocationOption = Annotated[
191
+ str | None,
192
+ typer.Option(
193
+ "--location",
194
+ metavar="PLACE",
195
+ help="Filter by location: montreal, canada, usa, unknown",
196
+ ),
197
+ ]
198
+ TermOption = Annotated[
199
+ str | None,
200
+ typer.Option("--term", metavar="TERM", help="Filter by term, such as summer-2027"),
201
+ ]
202
+ RoleOption = Annotated[
203
+ str | None,
204
+ typer.Option(
205
+ "--role",
206
+ metavar="ROLE",
207
+ help=(
208
+ "Filter by role: swe, security, data, ml-ai, quant, infra, embedded, "
209
+ "general-cs, unknown"
210
+ ),
211
+ ),
212
+ ]
213
+ LanguageOption = Annotated[
214
+ str | None,
215
+ typer.Option("--lang", metavar="LANG", help="Filter by language: en, fr, bilingual, unknown"),
216
+ ]
217
+ SourceOption = Annotated[
218
+ str | None,
219
+ typer.Option(
220
+ "--source", metavar="SOURCE", help="Filter by source adapter, such as greenhouse or lever"
221
+ ),
222
+ ]
223
+ CompanyOption = Annotated[
224
+ str | None,
225
+ typer.Option("--company", metavar="NAME", help="Filter by exact employer name"),
226
+ ]
227
+ AllOption = Annotated[
228
+ bool,
229
+ typer.Option("--all", help="Show every match instead of the first page"),
230
+ ]
231
+ NewOption = Annotated[
232
+ bool,
233
+ typer.Option("--new", help="Only postings that appeared since the sync before last"),
234
+ ]
235
+ LastDaysOption = Annotated[
236
+ int | None,
237
+ typer.Option(
238
+ "--last",
239
+ metavar="DAYS",
240
+ click_type=_count(0, 3650),
241
+ help="Look back this many days instead of the default 14; 0 for no limit",
242
+ ),
243
+ ]
244
+ StaleDaysOption = Annotated[
245
+ int | None,
246
+ typer.Option(
247
+ "--stale-days",
248
+ metavar="DAYS",
249
+ click_type=_count(1, 3650),
250
+ help="Treat a board as stale after this many days without a successful fetch",
251
+ ),
252
+ ]
253
+
254
+
255
+ def _database(explicit: Path | None) -> Path:
256
+ from stage.paths import database_path
257
+
258
+ return explicit.expanduser() if explicit else database_path()
259
+
260
+
261
+ def _lock_path(explicit: Path | None) -> Path:
262
+ target = _database(explicit)
263
+ return target.with_name(f".{target.name}.lock")
264
+
265
+
266
+ def _print_failure(exc: BaseException) -> None:
267
+ from stage.cli.render import failure, terminal
268
+
269
+ terminal().print(failure(exc))
270
+
271
+
272
+ def _print_missing(posting: str) -> None:
273
+ from stage.cli.render import terminal
274
+
275
+ terminal().print(_no_such_posting(posting))
276
+
277
+
278
+ def _validated_term(value: str | None) -> str | None:
279
+ if value is None:
280
+ return None
281
+ from stage.domain import UNKNOWN_TERM
282
+ from stage.lexicon import SEASONS
283
+
284
+ head, _, year = value.lower().partition("-")
285
+ if value.lower() == UNKNOWN_TERM or (head in SEASONS and year.isdigit() and len(year) == 4):
286
+ return value.lower()
287
+ seasons = ", ".join(f"{season}-2027" for season in SEASONS)
288
+ raise InvalidOptionError(f"--term must look like {seasons}, or {UNKNOWN_TERM}")
289
+
290
+
291
+ def _validated_source(value: str | None) -> str | None:
292
+ if value is None:
293
+ return None
294
+ from stage.sources import get_adapters
295
+
296
+ names = sorted(get_adapters())
297
+ if value in names:
298
+ return value
299
+ raise InvalidOptionError(_did_you_mean(value, names, "source"))
300
+
301
+
302
+ def _filters(
303
+ *,
304
+ location: str | None,
305
+ term: str | None,
306
+ role: str | None,
307
+ language: str | None,
308
+ source: str | None,
309
+ company: str | None,
310
+ limit: int | None,
311
+ ) -> "JobFilters":
312
+ from stage.domain import (
313
+ JobFilters,
314
+ Language,
315
+ LocationBucket,
316
+ RoleCategory,
317
+ )
318
+
319
+ return JobFilters(
320
+ location=_parse_enum(location, LocationBucket, "--location"),
321
+ term=_validated_term(term),
322
+ role=_parse_enum(role, RoleCategory, "--role"),
323
+ language=_parse_enum(language, Language, "--lang"),
324
+ source=_validated_source(source),
325
+ company=company,
326
+ limit=limit,
327
+ )
328
+
329
+
330
+ async def _resolve_posting(reference: str, repository: Any) -> str:
331
+ from stage.cli.selection import resolve
332
+
333
+ if not reference.isdigit():
334
+ return reference
335
+ return resolve(int(reference), await repository.last_sync_at())
336
+
337
+
338
+ def _did_you_mean(value: str, choices: "Iterable[str]", label: str = "command") -> str:
339
+ from difflib import get_close_matches
340
+
341
+ options = list(choices)
342
+ near = get_close_matches(value.lower(), options, n=1, cutoff=0.6)
343
+ if near:
344
+ return f"Unknown {label} {value!r}. Did you mean {near[0]!r}?"
345
+ return f"Unknown {label} {value!r}. Choose from: {', '.join(options)}"
346
+
347
+
348
+ def _needs_a_row(command: str) -> str:
349
+ return (
350
+ f"[red]stage {command} needs a row number or a posting id.[/red] Run "
351
+ "[bold]stage list[/bold] or [bold]stage search[/bold] first, then "
352
+ f"[bold]stage {command} 1[/bold] acts on the first row."
353
+ )
354
+
355
+
356
+ def _no_such_posting(posting: str) -> str:
357
+ from stage.cli.render import quoted
358
+
359
+ return (
360
+ f"[red]No posting with id {quoted(posting, 80)}.[/red] Ids look like "
361
+ "[bold]greenhouse:acme:1234[/bold] — find one with [bold]stage list --json[/bold] "
362
+ "or [bold]stage search[/bold]."
363
+ )
364
+
365
+
366
+ async def _adopt_unregistered(
367
+ console: "Console",
368
+ *,
369
+ registry: Path | None,
370
+ db: Path | None,
371
+ platforms: list[Any] | None,
372
+ excluded: list[Any] | None,
373
+ size: Any,
374
+ limit: int,
375
+ direct_only: bool,
376
+ adopt_unnamed: bool,
377
+ apply_rows: bool,
378
+ today: date,
379
+ stream: Any,
380
+ progress: "ProgressCallback | None",
381
+ show_all: bool = False,
382
+ ) -> bool:
383
+ from stage.cli.logfile import open_probe_journal, probe_journal_path
384
+ from stage.cli.render import plain
385
+ from stage.companies import load_companies, update_registry
386
+ from stage.domain import PlatformProbed
387
+ from stage.services.coverage import coverage
388
+ from stage.services.discover import (
389
+ adopt_unregistered,
390
+ probe_companies,
391
+ select_unregistered,
392
+ verify_registry,
393
+ )
394
+ from stage.storage import open_repository
395
+
396
+ rows = load_companies(registry)
397
+ async with open_repository(_database(db)) as repository:
398
+ report = await coverage(repository, rows, unregistered=True)
399
+ ranked = [entry.company for entry in report.unregistered]
400
+ apply_urls = await repository.company_apply_urls(ranked)
401
+ direct_rows, name_rows = select_unregistered(
402
+ ranked,
403
+ apply_urls,
404
+ limit=limit,
405
+ direct_only=direct_only,
406
+ platforms=platforms,
407
+ excluded=excluded,
408
+ )
409
+ direct = list(direct_rows)
410
+ names = list(name_rows)
411
+ if not names and not direct:
412
+ console.print(plain("No unregistered employers to probe. Run stage sync first."))
413
+ return False
414
+
415
+ total = len(names) + len(direct)
416
+ console.print(
417
+ plain(f"Probing {total} unregistered employer(s); {len(direct)} from direct feed links…")
418
+ )
419
+ results: list[tuple[str, Any]] = []
420
+ streams = []
421
+ if direct:
422
+ streams.append(verify_registry(direct, platforms=platforms, excluded=excluded))
423
+ if names:
424
+ streams.append(probe_companies(names, platforms=platforms, excluded=excluded, size=size))
425
+ with open_probe_journal() as journal:
426
+ for events in streams:
427
+ async for event in events:
428
+ if progress is not None:
429
+ progress(event)
430
+ if stream is not None:
431
+ stream(event)
432
+ if isinstance(event, PlatformProbed):
433
+ results.append((event.result.company, event.result))
434
+ journal(
435
+ {
436
+ "company": event.result.company,
437
+ "platform": event.result.candidate.platform.value,
438
+ "slug": event.result.candidate.slug,
439
+ "verdict": event.result.verdict.value,
440
+ "job_count": event.result.job_count,
441
+ "board_name": event.result.board_name,
442
+ "detail": event.result.detail,
443
+ }
444
+ )
445
+
446
+ outcome = adopt_unregistered(rows, results, today=today, adopt_unnamed=adopt_unnamed)
447
+ console.print(
448
+ plain(
449
+ f"{outcome.probed} probed — {len(outcome.adopted)} adoptable "
450
+ f"({outcome.postings} posting(s)), {len(outcome.review)} needing review, "
451
+ f"{len(outcome.refused)} refused, {outcome.already_known} already known"
452
+ )
453
+ )
454
+ adopted = outcome.adopted if show_all else outcome.adopted[:20]
455
+ for row in adopted:
456
+ console.print(plain(f" + {row.company.name} — {row.job_count} job(s)"))
457
+ if len(outcome.adopted) > len(adopted):
458
+ console.print(
459
+ plain(f" … and {len(outcome.adopted) - len(adopted)} more; --all lists every row")
460
+ )
461
+ if outcome.review:
462
+ console.print(
463
+ plain("Boards with postings whose platform publishes no name. Decide these by hand:")
464
+ )
465
+ review = outcome.review if show_all else outcome.review[:40]
466
+ for candidate in review:
467
+ mark = "slug is distinctive" if candidate.distinctive else "slug is generic, check it"
468
+ console.print(
469
+ plain(
470
+ f" ? {candidate.company} ({candidate.label}) — "
471
+ f"{candidate.job_count} job(s), {mark}"
472
+ )
473
+ )
474
+ if len(outcome.review) > len(review):
475
+ console.print(
476
+ plain(f" … and {len(outcome.review) - len(review)} more; --all lists every row")
477
+ )
478
+ refused = outcome.refused if show_all else outcome.refused[:10]
479
+ for company, board, reason in refused:
480
+ console.print(plain(f" - {company} ({board}): {reason}"))
481
+ if len(outcome.refused) > len(refused):
482
+ console.print(
483
+ plain(f" … and {len(outcome.refused) - len(refused)} more; --all lists every row")
484
+ )
485
+ console.print(plain(f"Every probe result was journalled to {probe_journal_path()}"))
486
+
487
+ if not outcome.adopted:
488
+ return False
489
+ if not apply_rows:
490
+ console.print(plain("Nothing written. Re-run with --apply to add these rows."))
491
+ return True
492
+
493
+ def update(existing: tuple[Any, ...]) -> tuple[list[Any], Any]:
494
+ latest = adopt_unregistered(existing, results, today=today, adopt_unnamed=adopt_unnamed)
495
+ return [*existing, *(row.company for row in latest.adopted)], latest
496
+
497
+ target, applied = update_registry(update, registry)
498
+ console.print(plain(f"applied — {len(applied.adopted)} row(s) added, written to {target}"))
499
+ return True
500
+
501
+
502
+ _HELP_GUIDE = (
503
+ "Stage aggregates CS internship postings into a local SQLite database.\n"
504
+ "\nStart here:\n"
505
+ " stage sync Fetch and save current postings\n"
506
+ " stage tui Browse everything in a full screen\n"
507
+ " stage list Recent open postings, newest first\n"
508
+ " stage list --new Only what appeared since the last sync\n"
509
+ ' stage search "machine learning" Search titles, employers, bodies\n'
510
+ " stage show 3 Inspect row 3 of the last listing\n"
511
+ " stage open 3 5 9 Open those rows in a browser\n"
512
+ " stage export --format csv Save every match to a file\n"
513
+ "\nFilters, on list, search, and export:\n"
514
+ " stage list --role swe --last 90 Filters combine\n"
515
+ ' stage search "python" --lang en Quote a phrase\n'
516
+ " stage export --format csv --all Every match, no cap\n"
517
+ " stage list --last 0 No 14-day window\n"
518
+ "\n"
519
+ " [--role --location --term --lang --source --company --new --all --last]\n"
520
+ "\nHealth and maintenance:\n"
521
+ " stage doctor Database and source health\n"
522
+ " stage stats Sync history and totals\n"
523
+ " stage schedule enable Sync in the background\n"
524
+ " stage schedule notify URL Post new postings to Discord\n"
525
+ " stage quarantine Review rejected postings\n"
526
+ " stage coverage --unregistered Employers seen but not tracked\n"
527
+ "\nDiscovery:\n"
528
+ " stage discover --url URL Read the platform from a careers page\n"
529
+ " stage discover NAME Find a company board by name\n"
530
+ "\nLearn any command:\n"
531
+ " stage --help Every command, grouped\n"
532
+ " stage help COMMAND Every option, and what each takes\n"
533
+ " stage --install-completion Tab-complete, after a new terminal\n"
534
+ )
535
+
536
+
537
+ def _render_schedule(console: Any, report: Any) -> None:
538
+ console.print(f"Scheduler: {report.backend} (per user)")
539
+ for index, (action, enabled, installed) in enumerate(report.actions):
540
+ state = "enabled" if enabled else "disabled"
541
+ console.print(f" {action.label}: {state} — {action.cadence} at {action.time} local time")
542
+ needs_update = index < len(report.needs_update) and report.needs_update[index]
543
+ if enabled and not needs_update and not action.cadence.startswith(installed):
544
+ needs_update = True
545
+ if enabled and needs_update:
546
+ console.print(
547
+ " [yellow]needs a scheduler update — run stage schedule enable[/yellow]"
548
+ )
549
+ stale = report.stale_interpreter[index] if index < len(report.stale_interpreter) else ""
550
+ if enabled and stale:
551
+ console.print(
552
+ f" [red]interpreter is missing: {stale}[/red] — this run will fail; "
553
+ "re-run stage schedule enable"
554
+ )
555
+ run = report.states[index] if index < len(report.states) else None
556
+ _render_scheduled_run(console, run)
557
+ console.print(f"Logs: {report.log_dir}")
558
+
559
+
560
+ def _render_scheduled_run(console: Any, run: dict[str, object] | None) -> None:
561
+ if run is None:
562
+ return
563
+ phase = run.get("phase")
564
+ if phase == "finished":
565
+ outcome = run.get("outcome")
566
+ outcome_name = outcome if isinstance(outcome, str) else ""
567
+ message = {
568
+ "success": "completed successfully",
569
+ "partial": "completed with attention",
570
+ "failure": "failed",
571
+ }.get(outcome_name, "finished")
572
+ console.print(f" [green]{message}[/green] {_schedule_time(run.get('finished_at'))}")
573
+ elif phase == "blocked":
574
+ console.print(f" [yellow]another run holds the lock[/yellow] — {run.get('error', '')}")
575
+ elif phase == "unresponsive":
576
+ console.print(
577
+ " [yellow]run is unresponsive[/yellow] — "
578
+ f"last heartbeat {_schedule_time(run.get('heartbeat_at'))}"
579
+ )
580
+ elif phase == "waiting":
581
+ expected = _schedule_time(run.get("starts_after"))
582
+ console.print(f" [cyan]waiting to start[/cyan] — expected {expected}")
583
+ elif isinstance(phase, str) and phase in {"syncing", "discovering", "checking"}:
584
+ label = {
585
+ "syncing": "syncing",
586
+ "discovering": "discovering",
587
+ "checking": "checking health",
588
+ }[phase]
589
+ console.print(f" [cyan]{label}[/cyan] — started {_schedule_time(run.get('started_at'))}")
590
+ else:
591
+ return
592
+ progress = run.get("progress")
593
+ if isinstance(progress, dict) and phase not in {"blocked", "unresponsive"}:
594
+ complete = progress.get("complete", 0)
595
+ total = progress.get("total", 0)
596
+ if complete or total:
597
+ source = progress.get("source")
598
+ company = progress.get("company")
599
+ detail = " / ".join(str(value) for value in (source, company) if value)
600
+ suffix = f" — {detail}" if detail else ""
601
+ console.print(f" progress: {complete}/{total}{suffix}")
602
+ if phase == "finished" and isinstance(run.get("exit_code"), int):
603
+ console.print(f" exit code: {run['exit_code']}")
604
+
605
+
606
+ def _schedule_time(value: object) -> str:
607
+ if not isinstance(value, str):
608
+ return "unknown time"
609
+ try:
610
+ from datetime import datetime
611
+
612
+ return datetime.fromisoformat(value).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
613
+ except ValueError:
614
+ return value
615
+
616
+
617
+ @app.command(
618
+ "help",
619
+ help="A guided tour, or everything one command can do",
620
+ rich_help_panel="Everyday",
621
+ )
622
+ def show_help(
623
+ context: typer.Context,
624
+ topic: Annotated[
625
+ str | None,
626
+ typer.Argument(metavar="COMMAND", click_type=WORD, help="Command name to explain"),
627
+ ] = None,
628
+ ) -> None:
629
+ root = context.parent or context
630
+ if topic is not None:
631
+ from typer.core import TyperGroup
632
+
633
+ group = root.command
634
+ if not isinstance(group, TyperGroup):
635
+ raise typer.BadParameter(f"Unknown command {topic!r}", param_hint="topic")
636
+ command = group.commands.get(topic)
637
+ if command is None:
638
+ raise typer.BadParameter(_did_you_mean(topic, group.commands), param_hint="topic")
639
+ from typer._click import Context
640
+
641
+ typer.echo(command.get_help(Context(command, info_name=topic, parent=root)))
642
+ return
643
+ _print_guide()
644
+
645
+
646
+ def _print_guide() -> None:
647
+ from stage.cli.render import splash, terminal
648
+
649
+ console = terminal()
650
+ splash(console)
651
+ console.print()
652
+ typer.echo(_HELP_GUIDE)
653
+
654
+
655
+ def main() -> None:
656
+ import sqlite3
657
+
658
+ from stage.cli.serialize import configure_terminal_output
659
+ from stage.storage.migrations import SchemaVersionError
660
+
661
+ configure_terminal_output(sys.stdout, sys.platform)
662
+ try:
663
+ app()
664
+ except SchemaVersionError as exc:
665
+ typer.echo(str(exc), err=True)
666
+ raise SystemExit(2) from None
667
+ except sqlite3.DatabaseError as exc:
668
+ typer.echo(
669
+ f"That database cannot be read ({exc}). Point --db at a Stage database, "
670
+ "or remove the file to start a new one.",
671
+ err=True,
672
+ )
673
+ raise SystemExit(2) from None
674
+ except OSError as exc:
675
+ target = getattr(exc, "filename", None)
676
+ location = f" at {target}" if target else ""
677
+ typer.echo(f"Cannot use that location{location}: {exc.strerror or exc}", err=True)
678
+ raise SystemExit(2) from None