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