stage-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/cli/logfile.py ADDED
@@ -0,0 +1,56 @@
1
+ from collections.abc import Callable, Iterator
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+ from typing import TextIO
5
+
6
+ MAX_LOG_BYTES = 5 * 1024 * 1024
7
+ GENERATIONS = 3
8
+
9
+
10
+ def rotate(path: Path, *, max_bytes: int = MAX_LOG_BYTES, generations: int = GENERATIONS) -> bool:
11
+ if generations < 1:
12
+ raise ValueError("a rotated log needs at least one generation")
13
+ if not path.exists() or path.stat().st_size < max_bytes:
14
+ return False
15
+
16
+ oldest = path.with_name(f"{path.name}.{generations}")
17
+ oldest.unlink(missing_ok=True)
18
+ for index in range(generations - 1, 0, -1):
19
+ candidate = path.with_name(f"{path.name}.{index}")
20
+ if candidate.exists():
21
+ candidate.rename(path.with_name(f"{path.name}.{index + 1}"))
22
+ path.rename(path.with_name(f"{path.name}.1"))
23
+ return True
24
+
25
+
26
+ @contextmanager
27
+ def open_request_log(
28
+ path: Path, *, max_bytes: int = MAX_LOG_BYTES, generations: int = GENERATIONS
29
+ ) -> Iterator[TextIO]:
30
+ resolved = path.expanduser()
31
+ resolved.parent.mkdir(parents=True, exist_ok=True)
32
+ rotate(resolved, max_bytes=max_bytes, generations=generations)
33
+ with resolved.open("a", encoding="utf-8") as stream:
34
+ yield stream
35
+
36
+
37
+ def probe_journal_path() -> Path:
38
+ from stage.paths import data_dir
39
+
40
+ return data_dir() / "probe-journal.jsonl"
41
+
42
+
43
+ @contextmanager
44
+ def open_probe_journal(path: Path | None = None) -> Iterator[Callable[[object], None]]:
45
+ import json
46
+
47
+ resolved = (path or probe_journal_path()).expanduser()
48
+ resolved.parent.mkdir(parents=True, exist_ok=True)
49
+ rotate(resolved)
50
+ with resolved.open("a", encoding="utf-8") as stream:
51
+
52
+ def record(entry: object) -> None:
53
+ stream.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
54
+ stream.flush()
55
+
56
+ yield record
stage/cli/notify.py ADDED
@@ -0,0 +1,170 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from urllib.error import HTTPError, URLError
6
+ from urllib.parse import urlsplit
7
+ from urllib.request import Request, urlopen
8
+
9
+ from stage.paths import data_dir, restrict_permissions
10
+
11
+ WEBHOOK_HOSTS = frozenset(
12
+ {
13
+ "discord.com",
14
+ "discordapp.com",
15
+ "canary.discord.com",
16
+ "ptb.discord.com",
17
+ "media.discordapp.net",
18
+ }
19
+ )
20
+ DESCRIPTION_LIMIT = 4096
21
+ DESCRIPTION_BUDGET = 3900
22
+ MAX_LISTED = 40
23
+ TITLE_LIMIT = 200
24
+ TIMEOUT_SECONDS = 15
25
+
26
+
27
+ class NotifyError(Exception):
28
+ pass
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Posting:
33
+ company: str
34
+ title: str
35
+ location: str
36
+ url: str
37
+
38
+
39
+ def webhook_path() -> Path:
40
+ return data_dir() / "notify.json"
41
+
42
+
43
+ def redact(url: str) -> str:
44
+ try:
45
+ parts = urlsplit(url)
46
+ except ValueError:
47
+ return "<unreadable url>"
48
+ tail = parts.path.rsplit("/", 1)[-1]
49
+ return f"{parts.scheme}://{parts.netloc}/…/{tail[:4]}…" if tail else url
50
+
51
+
52
+ def validate(url: str) -> str:
53
+ url = url.strip()
54
+ if any(character.isspace() for character in url):
55
+ raise NotifyError("A webhook URL cannot contain spaces or line breaks.")
56
+ try:
57
+ parts = urlsplit(url)
58
+ except ValueError as exc:
59
+ raise NotifyError(f"That is not a usable URL: {exc}") from None
60
+ if parts.scheme != "https" or parts.hostname not in WEBHOOK_HOSTS:
61
+ raise NotifyError(
62
+ "That is not a Discord webhook. Create one in a channel you manage: "
63
+ "Edit Channel, Integrations, New Webhook, then copy the URL."
64
+ )
65
+ if "/api/webhooks/" not in parts.path:
66
+ raise NotifyError("A Discord webhook URL contains /api/webhooks/.")
67
+ return url
68
+
69
+
70
+ def remember(url: str, path: Path | None = None) -> None:
71
+ target = path or webhook_path()
72
+ body = json.dumps({"webhook": validate(url)})
73
+ target.parent.mkdir(parents=True, exist_ok=True)
74
+ descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
75
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
76
+ handle.write(body)
77
+ restrict_permissions(target)
78
+
79
+
80
+ def forget(path: Path | None = None) -> None:
81
+ target = path or webhook_path()
82
+ target.unlink(missing_ok=True)
83
+
84
+
85
+ def read(path: Path | None = None) -> str:
86
+ target = path or webhook_path()
87
+ try:
88
+ payload = json.loads(target.read_text(encoding="utf-8"))
89
+ except (OSError, json.JSONDecodeError):
90
+ return ""
91
+ stored = payload.get("webhook") if isinstance(payload, dict) else None
92
+ return str(stored) if stored else ""
93
+
94
+
95
+ def _entry(posting: Posting) -> str:
96
+ title = posting.title[:TITLE_LIMIT].replace("[", "(").replace("]", ")")
97
+ where = posting.location or "location unknown"
98
+ if not posting.url:
99
+ return f"{title}\n{posting.company} · {where}"
100
+ return f"[{title}]({posting.url})\n{posting.company} · {where}"
101
+
102
+
103
+ def _footer(hidden: int) -> str:
104
+ return f"…and {hidden} more. Run `stage list --new`."
105
+
106
+
107
+ def compose(postings: list[Posting], total: int) -> dict[str, object]:
108
+ lines: list[str] = []
109
+ spent = 0
110
+ for posting in postings[:MAX_LISTED]:
111
+ entry = _entry(posting)
112
+ room = DESCRIPTION_BUDGET - len(_footer(total))
113
+ if lines and spent + len(entry) + 2 > room:
114
+ break
115
+ lines.append(entry)
116
+ spent += len(entry) + 2
117
+
118
+ hidden = total - len(lines)
119
+ if hidden > 0:
120
+ lines.append(_footer(hidden))
121
+ description = "\n\n".join(lines)[:DESCRIPTION_LIMIT]
122
+ return {
123
+ "username": "Stage",
124
+ "embeds": [
125
+ {
126
+ "title": f"{total} new posting(s)"[:256],
127
+ "description": description,
128
+ "color": 0x4A9EFF,
129
+ }
130
+ ],
131
+ }
132
+
133
+
134
+ def post(url: str, payload: dict[str, object]) -> None:
135
+ request = Request(
136
+ validate(url),
137
+ data=json.dumps(payload).encode("utf-8"),
138
+ headers={"Content-Type": "application/json", "User-Agent": "stage"},
139
+ method="POST",
140
+ )
141
+ try:
142
+ with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
143
+ if response.status >= 400:
144
+ raise NotifyError(f"Discord answered {response.status}.")
145
+ except HTTPError as exc:
146
+ if exc.code in (401, 403, 404):
147
+ raise NotifyError(
148
+ "Discord will not accept that webhook. It was probably deleted; "
149
+ "create a new one and run stage schedule notify again."
150
+ ) from None
151
+ if exc.code == 429:
152
+ raise NotifyError("Discord is rate limiting this webhook. Try again shortly.") from None
153
+ raise NotifyError(f"Discord answered {exc.code}.") from None
154
+ except (URLError, TimeoutError) as exc:
155
+ raise NotifyError(f"Could not reach Discord: {exc}") from None
156
+
157
+
158
+ __all__ = [
159
+ "MAX_LISTED",
160
+ "NotifyError",
161
+ "Posting",
162
+ "compose",
163
+ "forget",
164
+ "post",
165
+ "read",
166
+ "redact",
167
+ "remember",
168
+ "validate",
169
+ "webhook_path",
170
+ ]