stage-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from stage.cli.options import (
|
|
7
|
+
WORD,
|
|
8
|
+
AllOption,
|
|
9
|
+
CompanyOption,
|
|
10
|
+
DatabaseOption,
|
|
11
|
+
InvalidOptionError,
|
|
12
|
+
JsonOption,
|
|
13
|
+
LanguageOption,
|
|
14
|
+
LastDaysOption,
|
|
15
|
+
LocationOption,
|
|
16
|
+
NewOption,
|
|
17
|
+
RoleOption,
|
|
18
|
+
SourceOption,
|
|
19
|
+
TermOption,
|
|
20
|
+
_count,
|
|
21
|
+
_database,
|
|
22
|
+
_filters,
|
|
23
|
+
_needs_a_row,
|
|
24
|
+
_no_such_posting,
|
|
25
|
+
_print_failure,
|
|
26
|
+
_print_missing,
|
|
27
|
+
_require_enum,
|
|
28
|
+
_resolve_posting,
|
|
29
|
+
app,
|
|
30
|
+
run_async,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
MAX_OPEN_AT_ONCE = 10
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _closed_since_you_looked(repository: Any) -> str:
|
|
37
|
+
from stage.cli.selection import read
|
|
38
|
+
|
|
39
|
+
selection = read()
|
|
40
|
+
if selection is None or not selection.ids:
|
|
41
|
+
return ""
|
|
42
|
+
gone = await repository.closed_among(selection.ids)
|
|
43
|
+
if not gone:
|
|
44
|
+
return ""
|
|
45
|
+
return f"{gone} posting(s) from your last listing have closed."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _company_hint(company: str | None, repository: Any) -> str:
|
|
49
|
+
if not company:
|
|
50
|
+
return ""
|
|
51
|
+
from difflib import get_close_matches
|
|
52
|
+
|
|
53
|
+
from stage.domain.text import escape_markup
|
|
54
|
+
|
|
55
|
+
names = await repository.company_names()
|
|
56
|
+
if company in names:
|
|
57
|
+
return ""
|
|
58
|
+
asked = f"'{escape_markup(company)[:60]}'"
|
|
59
|
+
near = get_close_matches(company.casefold(), [n.casefold() for n in names], n=1, cutoff=0.6)
|
|
60
|
+
if not near:
|
|
61
|
+
return f"No employer named {asked} is stored. Try [bold]stage list --all[/bold]."
|
|
62
|
+
match = next(n for n in names if n.casefold() == near[0])
|
|
63
|
+
suggestion = f"'{escape_markup(match)[:60]}'"
|
|
64
|
+
return f"No employer named {asked} is stored. Did you mean {suggestion}?"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _window(last: int | None) -> int | None:
|
|
68
|
+
from stage.domain import DEFAULT_WINDOW_DAYS
|
|
69
|
+
|
|
70
|
+
if last is None:
|
|
71
|
+
return DEFAULT_WINDOW_DAYS
|
|
72
|
+
return last or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command(
|
|
76
|
+
help="Browse postings, stats, and review queues in a full-screen interface",
|
|
77
|
+
rich_help_panel="Everyday",
|
|
78
|
+
)
|
|
79
|
+
def tui(
|
|
80
|
+
db: DatabaseOption = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
from stage.tui.app import launch, summarize
|
|
83
|
+
|
|
84
|
+
target = _database(db)
|
|
85
|
+
launch(target, run_async(summarize(target)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("list", help="Browse recent open postings, newest first", rich_help_panel="Everyday")
|
|
89
|
+
def list_postings(
|
|
90
|
+
location: LocationOption = None,
|
|
91
|
+
term: TermOption = None,
|
|
92
|
+
role: RoleOption = None,
|
|
93
|
+
language: LanguageOption = None,
|
|
94
|
+
source: SourceOption = None,
|
|
95
|
+
company: CompanyOption = None,
|
|
96
|
+
show_all: AllOption = False,
|
|
97
|
+
last: LastDaysOption = None,
|
|
98
|
+
only_new: NewOption = False,
|
|
99
|
+
limit: Annotated[
|
|
100
|
+
int,
|
|
101
|
+
typer.Option(
|
|
102
|
+
"--limit",
|
|
103
|
+
metavar="N",
|
|
104
|
+
click_type=_count(1, None),
|
|
105
|
+
help="Maximum number of postings to show; --all shows every match",
|
|
106
|
+
),
|
|
107
|
+
] = 50,
|
|
108
|
+
as_json: JsonOption = False,
|
|
109
|
+
db: DatabaseOption = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
from dataclasses import replace as _replace
|
|
112
|
+
|
|
113
|
+
from stage.cli.selection import remember
|
|
114
|
+
from stage.cli.serialize import emit, jobs_to_json
|
|
115
|
+
from stage.services.query import JobListing, list_jobs
|
|
116
|
+
from stage.storage import open_repository
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
filters = _filters(
|
|
120
|
+
location=location,
|
|
121
|
+
term=term,
|
|
122
|
+
role=role,
|
|
123
|
+
language=language,
|
|
124
|
+
source=source,
|
|
125
|
+
company=company,
|
|
126
|
+
limit=None if show_all else limit,
|
|
127
|
+
)
|
|
128
|
+
except InvalidOptionError as exc:
|
|
129
|
+
_print_failure(exc)
|
|
130
|
+
raise typer.Exit(code=2) from exc
|
|
131
|
+
|
|
132
|
+
async def run() -> tuple[JobListing, str, str]:
|
|
133
|
+
async with open_repository(_database(db)) as repository:
|
|
134
|
+
scoped = filters
|
|
135
|
+
if only_new:
|
|
136
|
+
since = await repository.previous_sync_at()
|
|
137
|
+
if since is not None:
|
|
138
|
+
scoped = _replace(filters, first_seen_after=since)
|
|
139
|
+
found = await list_jobs(repository, scoped, window_days=_window(last))
|
|
140
|
+
hint = "" if found.jobs else await _company_hint(company, repository)
|
|
141
|
+
closed = await _closed_since_you_looked(repository)
|
|
142
|
+
return found, hint, closed
|
|
143
|
+
|
|
144
|
+
listing, hint, closed = run_async(run())
|
|
145
|
+
numbered = remember(tuple(job.id for job in listing.jobs), listing.last_sync_at)
|
|
146
|
+
|
|
147
|
+
if as_json:
|
|
148
|
+
emit(jobs_to_json(listing.jobs))
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
from stage.cli.render import render_jobs, terminal
|
|
152
|
+
|
|
153
|
+
console = terminal()
|
|
154
|
+
if closed:
|
|
155
|
+
console.print(f"[dim]{closed}[/dim]")
|
|
156
|
+
render_jobs(
|
|
157
|
+
console,
|
|
158
|
+
listing.jobs,
|
|
159
|
+
total_matching=listing.total_matching,
|
|
160
|
+
window_days=listing.window_days,
|
|
161
|
+
last_sync_at=listing.last_sync_at,
|
|
162
|
+
numbered=numbered,
|
|
163
|
+
hint=hint,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command(help="Search titles, employers, and descriptions", rich_help_panel="Everyday")
|
|
168
|
+
def search(
|
|
169
|
+
query: Annotated[
|
|
170
|
+
str,
|
|
171
|
+
typer.Argument(
|
|
172
|
+
metavar="WORDS",
|
|
173
|
+
click_type=WORD,
|
|
174
|
+
help="Words or phrases to find; accents optional",
|
|
175
|
+
),
|
|
176
|
+
],
|
|
177
|
+
location: LocationOption = None,
|
|
178
|
+
term: TermOption = None,
|
|
179
|
+
role: RoleOption = None,
|
|
180
|
+
language: LanguageOption = None,
|
|
181
|
+
source: SourceOption = None,
|
|
182
|
+
company: CompanyOption = None,
|
|
183
|
+
show_all: AllOption = False,
|
|
184
|
+
last: Annotated[
|
|
185
|
+
int | None,
|
|
186
|
+
typer.Option(
|
|
187
|
+
"--last",
|
|
188
|
+
metavar="DAYS",
|
|
189
|
+
click_type=_count(0, 3650),
|
|
190
|
+
help="Search only the last N days; by default, search every row",
|
|
191
|
+
),
|
|
192
|
+
] = None,
|
|
193
|
+
only_new: NewOption = False,
|
|
194
|
+
limit: Annotated[
|
|
195
|
+
int,
|
|
196
|
+
typer.Option(
|
|
197
|
+
"--limit",
|
|
198
|
+
metavar="N",
|
|
199
|
+
click_type=_count(1, None),
|
|
200
|
+
help="Maximum number of postings to show; --all shows every match",
|
|
201
|
+
),
|
|
202
|
+
] = 50,
|
|
203
|
+
as_json: JsonOption = False,
|
|
204
|
+
db: DatabaseOption = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
from dataclasses import replace as _replace
|
|
207
|
+
|
|
208
|
+
from stage.cli.selection import remember
|
|
209
|
+
from stage.cli.serialize import emit, jobs_to_json
|
|
210
|
+
from stage.services.query import JobListing
|
|
211
|
+
from stage.services.query import search_jobs as search_service
|
|
212
|
+
from stage.storage import open_repository
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
filters = _filters(
|
|
216
|
+
location=location,
|
|
217
|
+
term=term,
|
|
218
|
+
role=role,
|
|
219
|
+
language=language,
|
|
220
|
+
source=source,
|
|
221
|
+
company=company,
|
|
222
|
+
limit=None if show_all else limit,
|
|
223
|
+
)
|
|
224
|
+
except InvalidOptionError as exc:
|
|
225
|
+
_print_failure(exc)
|
|
226
|
+
raise typer.Exit(code=2) from exc
|
|
227
|
+
|
|
228
|
+
async def run() -> tuple[JobListing, str]:
|
|
229
|
+
async with open_repository(_database(db)) as repository:
|
|
230
|
+
scoped = filters
|
|
231
|
+
if only_new:
|
|
232
|
+
since = await repository.previous_sync_at()
|
|
233
|
+
if since is not None:
|
|
234
|
+
scoped = _replace(filters, first_seen_after=since)
|
|
235
|
+
found = await search_service(repository, query, scoped, window_days=last or None)
|
|
236
|
+
hint = "" if found.jobs else await _company_hint(company, repository)
|
|
237
|
+
return found, hint
|
|
238
|
+
|
|
239
|
+
listing, hint = run_async(run())
|
|
240
|
+
numbered = remember(tuple(job.id for job in listing.jobs), listing.last_sync_at)
|
|
241
|
+
|
|
242
|
+
if as_json:
|
|
243
|
+
emit(jobs_to_json(listing.jobs))
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
from stage.cli.render import render_search, terminal
|
|
247
|
+
|
|
248
|
+
render_search(terminal(), listing, numbered=numbered, hint=hint)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command(help="Show every stored detail for one posting", rich_help_panel="Everyday")
|
|
252
|
+
def show(
|
|
253
|
+
postings: Annotated[
|
|
254
|
+
list[str] | None,
|
|
255
|
+
typer.Argument(
|
|
256
|
+
metavar="ROW...",
|
|
257
|
+
click_type=WORD,
|
|
258
|
+
help="Row numbers from the last listing, or full posting IDs",
|
|
259
|
+
),
|
|
260
|
+
] = None,
|
|
261
|
+
as_json: JsonOption = False,
|
|
262
|
+
db: DatabaseOption = None,
|
|
263
|
+
) -> None:
|
|
264
|
+
from stage.cli.render import terminal
|
|
265
|
+
from stage.cli.selection import StaleSelectionError
|
|
266
|
+
|
|
267
|
+
if not postings:
|
|
268
|
+
terminal().print(_needs_a_row("show"))
|
|
269
|
+
raise typer.Exit(code=2)
|
|
270
|
+
from stage.cli.serialize import emit, posting_to_json, postings_to_json
|
|
271
|
+
from stage.services.query import PostingDetail, get_posting
|
|
272
|
+
from stage.storage import open_repository
|
|
273
|
+
|
|
274
|
+
async def run() -> list[PostingDetail | None]:
|
|
275
|
+
async with open_repository(_database(db)) as repository:
|
|
276
|
+
found: list[PostingDetail | None] = []
|
|
277
|
+
for reference in postings:
|
|
278
|
+
target = await _resolve_posting(reference, repository)
|
|
279
|
+
found.append(await get_posting(repository, target))
|
|
280
|
+
return found
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
details = run_async(run())
|
|
284
|
+
except StaleSelectionError as exc:
|
|
285
|
+
_print_failure(exc)
|
|
286
|
+
raise typer.Exit(code=2) from exc
|
|
287
|
+
|
|
288
|
+
if as_json:
|
|
289
|
+
found = [detail for detail in details if detail is not None]
|
|
290
|
+
emit(posting_to_json(found[0]) if len(postings) == 1 and found else postings_to_json(found))
|
|
291
|
+
if any(detail is None for detail in details):
|
|
292
|
+
raise typer.Exit(code=1)
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
from stage.cli.render import render_posting, rule, terminal
|
|
296
|
+
|
|
297
|
+
console = terminal()
|
|
298
|
+
missing = False
|
|
299
|
+
for index, (reference, detail) in enumerate(zip(postings, details, strict=True)):
|
|
300
|
+
if detail is None:
|
|
301
|
+
_print_missing(reference)
|
|
302
|
+
missing = True
|
|
303
|
+
continue
|
|
304
|
+
if index:
|
|
305
|
+
console.print(rule())
|
|
306
|
+
render_posting(console, detail)
|
|
307
|
+
if missing:
|
|
308
|
+
raise typer.Exit(code=1)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command(
|
|
312
|
+
"open", help="Open a posting's application page in the browser", rich_help_panel="Everyday"
|
|
313
|
+
)
|
|
314
|
+
def open_posting(
|
|
315
|
+
postings: Annotated[
|
|
316
|
+
list[str] | None,
|
|
317
|
+
typer.Argument(
|
|
318
|
+
metavar="ROW...",
|
|
319
|
+
click_type=WORD,
|
|
320
|
+
help="Row numbers from the last listing, or full posting IDs",
|
|
321
|
+
),
|
|
322
|
+
] = None,
|
|
323
|
+
print_only: Annotated[
|
|
324
|
+
bool, typer.Option("--print", help="Print the application URL instead of opening a browser")
|
|
325
|
+
] = False,
|
|
326
|
+
db: DatabaseOption = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
from stage.cli.render import plain, quoted, terminal
|
|
329
|
+
from stage.cli.selection import StaleSelectionError
|
|
330
|
+
from stage.domain import Job, web_url
|
|
331
|
+
from stage.services.query import get_posting
|
|
332
|
+
from stage.storage import open_repository
|
|
333
|
+
|
|
334
|
+
console = terminal()
|
|
335
|
+
|
|
336
|
+
if not postings:
|
|
337
|
+
console.print(_needs_a_row("open"))
|
|
338
|
+
raise typer.Exit(code=2)
|
|
339
|
+
|
|
340
|
+
if not print_only and len(postings) > MAX_OPEN_AT_ONCE:
|
|
341
|
+
console.print(
|
|
342
|
+
f"[red]{len(postings)} is more than {MAX_OPEN_AT_ONCE} tabs.[/red] "
|
|
343
|
+
"Open fewer, or use --print to list the URLs."
|
|
344
|
+
)
|
|
345
|
+
raise typer.Exit(code=2)
|
|
346
|
+
|
|
347
|
+
async def run() -> list[Job | None]:
|
|
348
|
+
async with open_repository(_database(db)) as repository:
|
|
349
|
+
found: list[Job | None] = []
|
|
350
|
+
for reference in postings:
|
|
351
|
+
target = await _resolve_posting(reference, repository)
|
|
352
|
+
detail = await get_posting(repository, target)
|
|
353
|
+
found.append(None if detail is None else detail.job)
|
|
354
|
+
return found
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
jobs = run_async(run())
|
|
358
|
+
except StaleSelectionError as exc:
|
|
359
|
+
_print_failure(exc)
|
|
360
|
+
raise typer.Exit(code=2) from exc
|
|
361
|
+
|
|
362
|
+
import webbrowser
|
|
363
|
+
|
|
364
|
+
missing = False
|
|
365
|
+
refused = False
|
|
366
|
+
for reference, job in zip(postings, jobs, strict=True):
|
|
367
|
+
if job is None:
|
|
368
|
+
console.print(_no_such_posting(reference))
|
|
369
|
+
missing = True
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
url = web_url(job.apply_url_raw)
|
|
373
|
+
if url is None:
|
|
374
|
+
console.print(
|
|
375
|
+
f"[red]Refusing to open {quoted(job.apply_url_raw, 60)}[/red] — a posting's "
|
|
376
|
+
"apply URL is untrusted input, and only a plain http or https address is "
|
|
377
|
+
"launched."
|
|
378
|
+
)
|
|
379
|
+
refused = True
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
if print_only:
|
|
383
|
+
console.print(plain(url))
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
if not webbrowser.open(url):
|
|
387
|
+
console.print(plain(f"No browser available. The apply URL is {url}"), style="yellow")
|
|
388
|
+
raise typer.Exit(code=1)
|
|
389
|
+
console.print(plain(f"Opened {job.company} — {url}"))
|
|
390
|
+
|
|
391
|
+
if refused:
|
|
392
|
+
raise typer.Exit(code=2)
|
|
393
|
+
if missing:
|
|
394
|
+
raise typer.Exit(code=1)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@app.command(
|
|
398
|
+
help="Export matching postings as CSV, JSON, Markdown, or PDF", rich_help_panel="Everyday"
|
|
399
|
+
)
|
|
400
|
+
def export(
|
|
401
|
+
fmt: Annotated[
|
|
402
|
+
str,
|
|
403
|
+
typer.Option("--format", metavar="FORMAT", help="Output format: csv, json, md, pdf"),
|
|
404
|
+
],
|
|
405
|
+
out: Annotated[
|
|
406
|
+
Path | None, typer.Option("--out", metavar="FILE", help="Output file or directory")
|
|
407
|
+
] = None,
|
|
408
|
+
force: Annotated[bool, typer.Option("--force", help="Replace an existing output file")] = False,
|
|
409
|
+
location: LocationOption = None,
|
|
410
|
+
term: TermOption = None,
|
|
411
|
+
role: RoleOption = None,
|
|
412
|
+
language: LanguageOption = None,
|
|
413
|
+
source: SourceOption = None,
|
|
414
|
+
company: CompanyOption = None,
|
|
415
|
+
show_all: AllOption = False,
|
|
416
|
+
last: LastDaysOption = None,
|
|
417
|
+
limit: Annotated[
|
|
418
|
+
int | None,
|
|
419
|
+
typer.Option(
|
|
420
|
+
"--limit",
|
|
421
|
+
metavar="N",
|
|
422
|
+
click_type=_count(1, None),
|
|
423
|
+
help="Maximum number of postings to export; every match by default",
|
|
424
|
+
),
|
|
425
|
+
] = None,
|
|
426
|
+
db: DatabaseOption = None,
|
|
427
|
+
) -> None:
|
|
428
|
+
from stage.cli.render import failure, render_export, terminal
|
|
429
|
+
from stage.domain import ExportFormat
|
|
430
|
+
from stage.services.export import ExportError, ExportResult, export_jobs
|
|
431
|
+
from stage.storage import open_repository
|
|
432
|
+
|
|
433
|
+
console = terminal()
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
export_format = _require_enum(fmt, ExportFormat, "--format")
|
|
437
|
+
filters = _filters(
|
|
438
|
+
location=location,
|
|
439
|
+
term=term,
|
|
440
|
+
role=role,
|
|
441
|
+
language=language,
|
|
442
|
+
source=source,
|
|
443
|
+
company=company,
|
|
444
|
+
limit=None if show_all else limit,
|
|
445
|
+
)
|
|
446
|
+
except InvalidOptionError as exc:
|
|
447
|
+
_print_failure(exc)
|
|
448
|
+
raise typer.Exit(code=2) from exc
|
|
449
|
+
|
|
450
|
+
async def run() -> ExportResult:
|
|
451
|
+
async with open_repository(_database(db)) as repository:
|
|
452
|
+
return await export_jobs(
|
|
453
|
+
repository,
|
|
454
|
+
filters,
|
|
455
|
+
fmt=export_format,
|
|
456
|
+
destination=out,
|
|
457
|
+
window_days=_window(last),
|
|
458
|
+
force=force,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
result = run_async(run())
|
|
463
|
+
except ExportError as exc:
|
|
464
|
+
console.print(failure(exc))
|
|
465
|
+
raise typer.Exit(code=2) from exc
|
|
466
|
+
|
|
467
|
+
if not result.count:
|
|
468
|
+
widen = "Relax a filter, or widen the window with [bold]--last[/bold]."
|
|
469
|
+
console.print(
|
|
470
|
+
f"[yellow]Nothing matched, so {result.path.name} holds headers only.[/yellow] {widen}"
|
|
471
|
+
)
|
|
472
|
+
return
|
|
473
|
+
render_export(console, result)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from stage.cli.options import (
|
|
6
|
+
WORD,
|
|
7
|
+
JsonOption,
|
|
8
|
+
_print_failure,
|
|
9
|
+
_render_schedule,
|
|
10
|
+
schedule_app,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@schedule_app.command(
|
|
15
|
+
"enable", help="Create this user's six-hourly sync and weekly discovery schedule"
|
|
16
|
+
)
|
|
17
|
+
def schedule_enable(
|
|
18
|
+
action: Annotated[
|
|
19
|
+
list[str] | None,
|
|
20
|
+
typer.Option(
|
|
21
|
+
"--action",
|
|
22
|
+
help="Schedule only these actions: sync, discover, verify; repeatable",
|
|
23
|
+
),
|
|
24
|
+
] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
from stage.cli.render import terminal
|
|
27
|
+
from stage.cli.schedule import ScheduleError, enable
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
report = enable(action)
|
|
31
|
+
except ScheduleError as exc:
|
|
32
|
+
_print_failure(exc)
|
|
33
|
+
raise typer.Exit(code=2) from exc
|
|
34
|
+
_render_schedule(terminal(), report)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@schedule_app.command("status", help="Show automatic scheduling and scheduled-run progress")
|
|
38
|
+
def schedule_status(
|
|
39
|
+
watch: Annotated[
|
|
40
|
+
bool,
|
|
41
|
+
typer.Option("--watch", help="Refresh while a scheduled run is active"),
|
|
42
|
+
] = False,
|
|
43
|
+
as_json: JsonOption = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
import time
|
|
46
|
+
|
|
47
|
+
from stage.cli.render import terminal
|
|
48
|
+
from stage.cli.schedule import ScheduleError, status
|
|
49
|
+
from stage.cli.schedule_state import HEARTBEAT_INTERVAL_SECONDS, is_active
|
|
50
|
+
from stage.cli.serialize import emit, schedule_to_json
|
|
51
|
+
|
|
52
|
+
if watch and as_json:
|
|
53
|
+
_print_failure(ValueError("--watch and --json cannot be used together"))
|
|
54
|
+
raise typer.Exit(code=2)
|
|
55
|
+
console = terminal()
|
|
56
|
+
try:
|
|
57
|
+
while True:
|
|
58
|
+
report = status()
|
|
59
|
+
if as_json:
|
|
60
|
+
emit(schedule_to_json(report))
|
|
61
|
+
else:
|
|
62
|
+
if watch:
|
|
63
|
+
console.clear()
|
|
64
|
+
_render_schedule(console, report)
|
|
65
|
+
if not watch or not any(is_active(state) for state in report.states):
|
|
66
|
+
return
|
|
67
|
+
time.sleep(HEARTBEAT_INTERVAL_SECONDS)
|
|
68
|
+
except ScheduleError as exc:
|
|
69
|
+
_print_failure(exc)
|
|
70
|
+
raise typer.Exit(code=2) from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@schedule_app.command("disable", help="Remove this user's automatic schedule")
|
|
74
|
+
def schedule_disable(
|
|
75
|
+
action: Annotated[
|
|
76
|
+
list[str] | None,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--action",
|
|
79
|
+
help="Remove only these actions: sync, discover, verify; repeatable",
|
|
80
|
+
),
|
|
81
|
+
] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
from stage.cli.render import terminal
|
|
84
|
+
from stage.cli.schedule import ScheduleError, disable
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
report = disable(action)
|
|
88
|
+
except ScheduleError as exc:
|
|
89
|
+
_print_failure(exc)
|
|
90
|
+
raise typer.Exit(code=2) from exc
|
|
91
|
+
_render_schedule(terminal(), report)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@schedule_app.command(
|
|
95
|
+
"notify", help="Post new postings to a Discord channel after each scheduled sync"
|
|
96
|
+
)
|
|
97
|
+
def schedule_notify(
|
|
98
|
+
webhook: Annotated[
|
|
99
|
+
str | None,
|
|
100
|
+
typer.Argument(
|
|
101
|
+
metavar="URL",
|
|
102
|
+
click_type=WORD,
|
|
103
|
+
help="Discord webhook URL, or omit to show the one in use",
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
clear: Annotated[bool, typer.Option("--clear", help="Stop posting to Discord")] = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
from stage.cli import notify
|
|
109
|
+
from stage.cli.render import plain, terminal
|
|
110
|
+
|
|
111
|
+
console = terminal()
|
|
112
|
+
if clear:
|
|
113
|
+
notify.forget()
|
|
114
|
+
console.print(plain("Discord notifications are off."))
|
|
115
|
+
return
|
|
116
|
+
if webhook is None:
|
|
117
|
+
stored = notify.read()
|
|
118
|
+
if not stored:
|
|
119
|
+
console.print(
|
|
120
|
+
"[dim]No Discord webhook stored. Create one in a channel you manage "
|
|
121
|
+
"(Edit Channel, Integrations, New Webhook) then run "
|
|
122
|
+
"[bold]stage schedule notify URL[/bold].[/dim]"
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
console.print(plain(f"Posting to {notify.redact(stored)}"))
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
notify.remember(webhook)
|
|
129
|
+
except notify.NotifyError as exc:
|
|
130
|
+
_print_failure(exc)
|
|
131
|
+
raise typer.Exit(code=2) from exc
|
|
132
|
+
console.print(
|
|
133
|
+
plain("Saved. New postings will appear in that channel after each scheduled sync.")
|
|
134
|
+
)
|
|
135
|
+
console.print(
|
|
136
|
+
"[dim]Scheduled syncs only run while this machine is awake. A missed run catches "
|
|
137
|
+
"up once afterwards. For notifications around the clock, install Stage on a "
|
|
138
|
+
"machine that stays on and run this there.[/dim]"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@schedule_app.command("test-notify", help="Send a test message to the stored Discord webhook")
|
|
143
|
+
def schedule_test_notify() -> None:
|
|
144
|
+
from stage.cli import notify
|
|
145
|
+
from stage.cli.render import plain, terminal
|
|
146
|
+
|
|
147
|
+
console = terminal()
|
|
148
|
+
stored = notify.read()
|
|
149
|
+
if not stored:
|
|
150
|
+
console.print(
|
|
151
|
+
"[yellow]No Discord webhook stored.[/yellow] Run "
|
|
152
|
+
"[bold]stage schedule notify URL[/bold] first."
|
|
153
|
+
)
|
|
154
|
+
raise typer.Exit(code=2)
|
|
155
|
+
payload = notify.compose(
|
|
156
|
+
[
|
|
157
|
+
notify.Posting(
|
|
158
|
+
company="Stage",
|
|
159
|
+
title="Test message",
|
|
160
|
+
location="your machine",
|
|
161
|
+
url="https://github.com/NicholasXydis/Stage",
|
|
162
|
+
)
|
|
163
|
+
],
|
|
164
|
+
total=1,
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
notify.post(stored, payload)
|
|
168
|
+
except notify.NotifyError as exc:
|
|
169
|
+
_print_failure(exc)
|
|
170
|
+
raise typer.Exit(code=1) from exc
|
|
171
|
+
console.print(plain(f"Sent to {notify.redact(stored)}"))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import UTC, datetime, timedelta
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
CAPTURE_KEEP = 50
|
|
6
|
+
CAPTURE_KEEP_DAYS = 30
|
|
7
|
+
JOURNAL_MAX_BYTES = 5 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Housekeeping:
|
|
12
|
+
captures_removed: int = 0
|
|
13
|
+
journal_rotated: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _prunable(paths: list[Path], now: datetime) -> list[Path]:
|
|
17
|
+
if len(paths) <= CAPTURE_KEEP:
|
|
18
|
+
return []
|
|
19
|
+
cutoff = now - timedelta(days=CAPTURE_KEEP_DAYS)
|
|
20
|
+
ordered = sorted(paths, key=lambda path: path.stat().st_mtime, reverse=True)
|
|
21
|
+
return [
|
|
22
|
+
path
|
|
23
|
+
for path in ordered[CAPTURE_KEEP:]
|
|
24
|
+
if datetime.fromtimestamp(path.stat().st_mtime, UTC) < cutoff
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tidy(*, dry_run: bool = False, now: datetime | None = None) -> Housekeeping:
|
|
29
|
+
from stage.cli.logfile import probe_journal_path
|
|
30
|
+
from stage.paths import capture_dir
|
|
31
|
+
|
|
32
|
+
moment = now or datetime.now(UTC)
|
|
33
|
+
removed = 0
|
|
34
|
+
captures = capture_dir()
|
|
35
|
+
if captures.is_dir():
|
|
36
|
+
stale = _prunable([path for path in captures.iterdir() if path.is_file()], moment)
|
|
37
|
+
removed = len(stale)
|
|
38
|
+
if not dry_run:
|
|
39
|
+
for path in stale:
|
|
40
|
+
path.unlink(missing_ok=True)
|
|
41
|
+
|
|
42
|
+
rotated = False
|
|
43
|
+
journal = probe_journal_path()
|
|
44
|
+
try:
|
|
45
|
+
oversized = journal.is_file() and journal.stat().st_size > JOURNAL_MAX_BYTES
|
|
46
|
+
except OSError:
|
|
47
|
+
oversized = False
|
|
48
|
+
if oversized:
|
|
49
|
+
rotated = True
|
|
50
|
+
if not dry_run:
|
|
51
|
+
previous = journal.with_suffix(".jsonl.1")
|
|
52
|
+
previous.unlink(missing_ok=True)
|
|
53
|
+
journal.replace(previous)
|
|
54
|
+
|
|
55
|
+
return Housekeeping(captures_removed=removed, journal_rotated=rotated)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"CAPTURE_KEEP",
|
|
60
|
+
"CAPTURE_KEEP_DAYS",
|
|
61
|
+
"JOURNAL_MAX_BYTES",
|
|
62
|
+
"Housekeeping",
|
|
63
|
+
"tidy",
|
|
64
|
+
]
|