stage-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ ]