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/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
|
+
]
|