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/companies.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from datetime import UTC, date, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from stage.domain import (
|
|
15
|
+
KNOWN_FIELDS,
|
|
16
|
+
REQUIRED_FIELDS,
|
|
17
|
+
Company,
|
|
18
|
+
CustomBoard,
|
|
19
|
+
Platform,
|
|
20
|
+
SourceOfRecord,
|
|
21
|
+
public_https_url,
|
|
22
|
+
)
|
|
23
|
+
from stage.lexicon import fold
|
|
24
|
+
from stage.paths import registry_path
|
|
25
|
+
from stage.sources.platforms import SlugRejectedError, oracle_target
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RegistryError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _require_str(row: dict[str, Any], key: str, index: int) -> str:
|
|
33
|
+
value = row.get(key)
|
|
34
|
+
if not isinstance(value, str) or not value.strip():
|
|
35
|
+
raise RegistryError(f"registry entry {index}: field {key!r} must be a non-empty string")
|
|
36
|
+
return value.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _optional_str(row: dict[str, Any], key: str) -> str | None:
|
|
40
|
+
value = row.get(key)
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
if not isinstance(value, str):
|
|
44
|
+
raise RegistryError(f"registry: field {key!r} must be a string")
|
|
45
|
+
return value.strip() or None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _optional_pin(row: dict[str, Any], key: str) -> str | None:
|
|
49
|
+
value = row.get(key)
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
if not isinstance(value, str):
|
|
53
|
+
raise RegistryError(f"registry: field {key!r} must be a string")
|
|
54
|
+
return value.strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _require_bool(row: dict[str, Any], key: str, index: int, *, default: bool) -> bool:
|
|
58
|
+
value = row.get(key, default)
|
|
59
|
+
if not isinstance(value, bool):
|
|
60
|
+
raise RegistryError(
|
|
61
|
+
f"registry entry {index}: {key!r} must be an unquoted true or false, "
|
|
62
|
+
f"not {type(value).__name__} {value!r}"
|
|
63
|
+
)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_date(row: dict[str, Any], key: str, index: int) -> date | None:
|
|
68
|
+
value = row.get(key)
|
|
69
|
+
if value is None:
|
|
70
|
+
return None
|
|
71
|
+
if isinstance(value, bool):
|
|
72
|
+
raise RegistryError(f"registry entry {index}: field {key!r} must be a date")
|
|
73
|
+
if isinstance(value, date):
|
|
74
|
+
return value
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
try:
|
|
77
|
+
return date.fromisoformat(value)
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
raise RegistryError(
|
|
80
|
+
f"registry entry {index}: field {key!r} must be an ISO date like "
|
|
81
|
+
f"2026-08-08, not {value!r}"
|
|
82
|
+
) from exc
|
|
83
|
+
raise RegistryError(f"registry entry {index}: field {key!r} must be a date")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _oracle_fields(
|
|
87
|
+
row: dict[str, Any], index: int, platform: Platform
|
|
88
|
+
) -> tuple[str | None, str | None]:
|
|
89
|
+
host = _optional_str(row, "oracle_host")
|
|
90
|
+
site = _optional_str(row, "oracle_site")
|
|
91
|
+
if platform is not Platform.ORACLE_CLOUD:
|
|
92
|
+
if host is not None or site is not None or row.get("oracle_keyword") is not None:
|
|
93
|
+
raise RegistryError(
|
|
94
|
+
f"registry entry {index}: Oracle fields only belong on platform oracle_cloud"
|
|
95
|
+
)
|
|
96
|
+
return None, None
|
|
97
|
+
if host is None or site is None:
|
|
98
|
+
raise RegistryError(
|
|
99
|
+
f"registry entry {index}: platform oracle_cloud needs oracle_host and oracle_site"
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
return oracle_target(host, site)
|
|
103
|
+
except SlugRejectedError as exc:
|
|
104
|
+
raise RegistryError(f"registry entry {index}: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _custom_int(raw: dict[str, Any], name: str, index: int) -> int:
|
|
108
|
+
value = raw.get(name, 0)
|
|
109
|
+
if value in (None, ""):
|
|
110
|
+
return 0
|
|
111
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
112
|
+
raise RegistryError(f"registry entry {index}: custom.{name} must be a non-negative integer")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _custom_board(row: dict[str, Any], index: int, platform: Platform) -> CustomBoard | None:
|
|
117
|
+
raw = row.get("custom")
|
|
118
|
+
if raw is None:
|
|
119
|
+
if platform is Platform.CUSTOM_JSON:
|
|
120
|
+
raise RegistryError(
|
|
121
|
+
f"registry entry {index}: platform custom_json needs a 'custom' block "
|
|
122
|
+
"with url and a title field mapping"
|
|
123
|
+
)
|
|
124
|
+
return None
|
|
125
|
+
if platform is not Platform.CUSTOM_JSON:
|
|
126
|
+
raise RegistryError(
|
|
127
|
+
f"registry entry {index}: a 'custom' block only belongs on platform custom_json"
|
|
128
|
+
)
|
|
129
|
+
if not isinstance(raw, dict):
|
|
130
|
+
raise RegistryError(f"registry entry {index}: 'custom' must be a mapping")
|
|
131
|
+
|
|
132
|
+
url = public_https_url(str(raw.get("url", "")))
|
|
133
|
+
if url is None:
|
|
134
|
+
raise RegistryError(
|
|
135
|
+
f"registry entry {index}: custom.url must be a public https address without credentials"
|
|
136
|
+
)
|
|
137
|
+
mapping = raw.get("fields", {})
|
|
138
|
+
if not isinstance(mapping, dict):
|
|
139
|
+
raise RegistryError(f"registry entry {index}: custom.fields must be a mapping")
|
|
140
|
+
unknown = sorted(set(mapping) - set(KNOWN_FIELDS))
|
|
141
|
+
if unknown:
|
|
142
|
+
raise RegistryError(
|
|
143
|
+
f"registry entry {index}: custom.fields has unknown key(s) "
|
|
144
|
+
f"{', '.join(unknown)}; known: {', '.join(KNOWN_FIELDS)}"
|
|
145
|
+
)
|
|
146
|
+
missing = [name for name in REQUIRED_FIELDS if not str(mapping.get(name, "")).strip()]
|
|
147
|
+
if missing:
|
|
148
|
+
raise RegistryError(f"registry entry {index}: custom.fields must map {', '.join(missing)}")
|
|
149
|
+
for key, value in mapping.items():
|
|
150
|
+
if not isinstance(value, str) or not value.strip():
|
|
151
|
+
raise RegistryError(
|
|
152
|
+
f"registry entry {index}: custom.fields[{key!r}] must be a non-empty string"
|
|
153
|
+
)
|
|
154
|
+
method = str(raw.get("method", "GET") or "GET").upper()
|
|
155
|
+
if method not in ("GET", "POST"):
|
|
156
|
+
raise RegistryError(f"registry entry {index}: custom.method must be GET or POST")
|
|
157
|
+
body = raw.get("body", {})
|
|
158
|
+
if not isinstance(body, dict):
|
|
159
|
+
raise RegistryError(f"registry entry {index}: custom.body must be a mapping")
|
|
160
|
+
headers_raw = raw.get("headers", {})
|
|
161
|
+
if not isinstance(headers_raw, dict):
|
|
162
|
+
raise RegistryError(f"registry entry {index}: custom.headers must be a mapping")
|
|
163
|
+
headers: dict[str, str] = {}
|
|
164
|
+
for key, value in headers_raw.items():
|
|
165
|
+
if not isinstance(value, str) or not value.strip():
|
|
166
|
+
raise RegistryError(
|
|
167
|
+
f"registry entry {index}: custom.headers[{key!r}] must be a non-empty string"
|
|
168
|
+
)
|
|
169
|
+
headers[str(key)] = value
|
|
170
|
+
fmt = str(raw.get("format", "json") or "json").lower()
|
|
171
|
+
if fmt not in ("json", "rss", "html", "sitemap", "jsonld"):
|
|
172
|
+
raise RegistryError(
|
|
173
|
+
f"registry entry {index}: custom.format must be json, rss, html, sitemap or jsonld"
|
|
174
|
+
)
|
|
175
|
+
row_selector = str(raw.get("row_selector", "") or "")
|
|
176
|
+
if fmt == "html" and not row_selector:
|
|
177
|
+
raise RegistryError(f"registry entry {index}: custom.format html needs custom.row_selector")
|
|
178
|
+
extract = str(raw.get("extract", "") or "")
|
|
179
|
+
handshake_url = ""
|
|
180
|
+
if raw.get("handshake_url"):
|
|
181
|
+
checked = public_https_url(str(raw["handshake_url"]))
|
|
182
|
+
if checked is None:
|
|
183
|
+
raise RegistryError(
|
|
184
|
+
f"registry entry {index}: custom.handshake_url must be a public https address"
|
|
185
|
+
)
|
|
186
|
+
handshake_url = checked
|
|
187
|
+
token_pattern = str(raw.get("token_pattern", "") or "")
|
|
188
|
+
token_header = str(raw.get("token_header", "") or "")
|
|
189
|
+
token_prefix = str(raw.get("token_prefix", "") or "")
|
|
190
|
+
if handshake_url and not (token_pattern and token_header):
|
|
191
|
+
raise RegistryError(
|
|
192
|
+
f"registry entry {index}: custom.handshake_url needs token_pattern and token_header"
|
|
193
|
+
)
|
|
194
|
+
if token_pattern:
|
|
195
|
+
try:
|
|
196
|
+
re.compile(token_pattern)
|
|
197
|
+
except re.error as exc:
|
|
198
|
+
raise RegistryError(
|
|
199
|
+
f"registry entry {index}: custom.token_pattern is not a valid regex: {exc}"
|
|
200
|
+
) from exc
|
|
201
|
+
item_tag = str(raw.get("item_tag", "item") or "item")
|
|
202
|
+
if not re.fullmatch(r"[A-Za-z_][\w.:-]{0,63}", item_tag):
|
|
203
|
+
raise RegistryError(f"registry entry {index}: custom.item_tag must be an xml element name")
|
|
204
|
+
row_filter = str(raw.get("row_filter", "") or "")
|
|
205
|
+
if row_filter:
|
|
206
|
+
try:
|
|
207
|
+
re.compile(row_filter)
|
|
208
|
+
except re.error as exc:
|
|
209
|
+
raise RegistryError(
|
|
210
|
+
f"registry entry {index}: custom.row_filter is not a valid regex: {exc}"
|
|
211
|
+
) from exc
|
|
212
|
+
page_param = str(raw.get("page_param", "") or "")
|
|
213
|
+
paging = {
|
|
214
|
+
name: _custom_int(raw, name, index)
|
|
215
|
+
for name in ("page_size", "page_start", "page_step", "max_pages")
|
|
216
|
+
}
|
|
217
|
+
if page_param and paging["page_size"] < 1:
|
|
218
|
+
raise RegistryError(
|
|
219
|
+
f"registry entry {index}: custom.page_param needs a positive custom.page_size"
|
|
220
|
+
)
|
|
221
|
+
return CustomBoard(
|
|
222
|
+
url=url,
|
|
223
|
+
method=method,
|
|
224
|
+
fmt=fmt,
|
|
225
|
+
row_selector=row_selector,
|
|
226
|
+
row_filter=row_filter,
|
|
227
|
+
item_tag=item_tag,
|
|
228
|
+
headers=headers,
|
|
229
|
+
extract=extract,
|
|
230
|
+
handshake_url=handshake_url,
|
|
231
|
+
token_pattern=token_pattern,
|
|
232
|
+
token_header=token_header,
|
|
233
|
+
token_prefix=token_prefix,
|
|
234
|
+
body=body,
|
|
235
|
+
authoritative=_require_bool(raw, "authoritative", index, default=True),
|
|
236
|
+
page_param=page_param,
|
|
237
|
+
page_size=paging["page_size"],
|
|
238
|
+
page_start=paging["page_start"],
|
|
239
|
+
page_step=paging["page_step"],
|
|
240
|
+
max_pages=paging["max_pages"],
|
|
241
|
+
jobs_path=str(raw.get("jobs_path", "") or ""),
|
|
242
|
+
fields={key: value.strip() for key, value in mapping.items()},
|
|
243
|
+
url_template=str(raw.get("url_template", "") or ""),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _company_from_row(row: dict[str, Any], index: int) -> Company:
|
|
248
|
+
platform_value = _require_str(row, "platform", index)
|
|
249
|
+
try:
|
|
250
|
+
platform = Platform(platform_value)
|
|
251
|
+
except ValueError as exc:
|
|
252
|
+
raise RegistryError(f"registry entry {index}: unknown platform {platform_value!r}") from exc
|
|
253
|
+
|
|
254
|
+
record_value = row.get("source_of_record", SourceOfRecord.MANUAL.value)
|
|
255
|
+
try:
|
|
256
|
+
source_of_record = SourceOfRecord(record_value)
|
|
257
|
+
except ValueError as exc:
|
|
258
|
+
raise RegistryError(
|
|
259
|
+
f"registry entry {index}: unknown source_of_record {record_value!r}"
|
|
260
|
+
) from exc
|
|
261
|
+
|
|
262
|
+
enabled = _require_bool(row, "enabled", index, default=True)
|
|
263
|
+
name_gate_exempt = _require_bool(row, "name_gate_exempt", index, default=False)
|
|
264
|
+
oracle_host, oracle_site = _oracle_fields(row, index, platform)
|
|
265
|
+
|
|
266
|
+
return Company(
|
|
267
|
+
name=_require_str(row, "name", index),
|
|
268
|
+
platform=platform,
|
|
269
|
+
slug=_require_str(row, "slug", index),
|
|
270
|
+
enabled=enabled,
|
|
271
|
+
rate_profile=_optional_str(row, "rate_profile"),
|
|
272
|
+
last_verified=_parse_date(row, "last_verified", index),
|
|
273
|
+
recheck_after=_parse_date(row, "recheck_after", index),
|
|
274
|
+
paused_until=_parse_date(row, "paused_until", index),
|
|
275
|
+
source_of_record=source_of_record,
|
|
276
|
+
workday_tenant=_optional_str(row, "workday_tenant"),
|
|
277
|
+
workday_site=_optional_str(row, "workday_site"),
|
|
278
|
+
workday_dc=_optional_str(row, "workday_dc"),
|
|
279
|
+
workday_facet=_optional_str(row, "workday_facet"),
|
|
280
|
+
oracle_host=oracle_host,
|
|
281
|
+
oracle_site=oracle_site,
|
|
282
|
+
oracle_keyword=_optional_pin(row, "oracle_keyword"),
|
|
283
|
+
name_gate_exempt=name_gate_exempt,
|
|
284
|
+
notes=_optional_str(row, "notes"),
|
|
285
|
+
custom=_custom_board(row, index, platform),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def board_identity(company: Company) -> tuple[Platform, str, str]:
|
|
290
|
+
if company.platform is Platform.ORACLE_CLOUD:
|
|
291
|
+
target = ":".join(part for part in (company.oracle_host, company.oracle_site) if part)
|
|
292
|
+
else:
|
|
293
|
+
target = company.workday_site or ""
|
|
294
|
+
return (company.platform, company.slug.lower(), target.lower())
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def board_label(company: Company) -> str:
|
|
298
|
+
if company.workday_site:
|
|
299
|
+
return f"{company.platform.value}/{company.slug}/{company.workday_site}"
|
|
300
|
+
if company.oracle_host:
|
|
301
|
+
return f"{company.platform.value}/{company.oracle_host}/{company.oracle_site or '?'}"
|
|
302
|
+
return f"{company.platform.value}/{company.slug}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _registry_row(company: Company) -> dict[str, Any]:
|
|
306
|
+
row: dict[str, Any] = {
|
|
307
|
+
"name": company.name,
|
|
308
|
+
"platform": company.platform.value,
|
|
309
|
+
"slug": company.slug,
|
|
310
|
+
"source_of_record": company.source_of_record.value,
|
|
311
|
+
}
|
|
312
|
+
if company.last_verified is not None:
|
|
313
|
+
row["last_verified"] = company.last_verified
|
|
314
|
+
if company.recheck_after is not None:
|
|
315
|
+
row["recheck_after"] = company.recheck_after
|
|
316
|
+
if company.paused_until is not None:
|
|
317
|
+
row["paused_until"] = company.paused_until
|
|
318
|
+
for key in (
|
|
319
|
+
"workday_tenant",
|
|
320
|
+
"workday_site",
|
|
321
|
+
"workday_dc",
|
|
322
|
+
"workday_facet",
|
|
323
|
+
"oracle_host",
|
|
324
|
+
"oracle_site",
|
|
325
|
+
"oracle_keyword",
|
|
326
|
+
):
|
|
327
|
+
value = getattr(company, key)
|
|
328
|
+
if value is not None:
|
|
329
|
+
row[key] = value
|
|
330
|
+
if company.rate_profile is not None:
|
|
331
|
+
row["rate_profile"] = company.rate_profile
|
|
332
|
+
if company.name_gate_exempt:
|
|
333
|
+
row["name_gate_exempt"] = True
|
|
334
|
+
if not company.enabled:
|
|
335
|
+
row["enabled"] = False
|
|
336
|
+
if company.notes:
|
|
337
|
+
row["notes"] = company.notes
|
|
338
|
+
if company.custom is not None:
|
|
339
|
+
block: dict[str, Any] = {"url": company.custom.url}
|
|
340
|
+
if company.custom.posts:
|
|
341
|
+
block["method"] = "POST"
|
|
342
|
+
if company.custom.body:
|
|
343
|
+
block["body"] = dict(company.custom.body)
|
|
344
|
+
if company.custom.headers:
|
|
345
|
+
block["headers"] = dict(company.custom.headers)
|
|
346
|
+
if company.custom.fmt != "json":
|
|
347
|
+
block["format"] = company.custom.fmt
|
|
348
|
+
if not company.custom.authoritative:
|
|
349
|
+
block["authoritative"] = False
|
|
350
|
+
if company.custom.row_selector:
|
|
351
|
+
block["row_selector"] = company.custom.row_selector
|
|
352
|
+
if company.custom.row_filter:
|
|
353
|
+
block["row_filter"] = company.custom.row_filter
|
|
354
|
+
if company.custom.item_tag != "item":
|
|
355
|
+
block["item_tag"] = company.custom.item_tag
|
|
356
|
+
if company.custom.extract:
|
|
357
|
+
block["extract"] = company.custom.extract
|
|
358
|
+
if company.custom.handshake_url:
|
|
359
|
+
block["handshake_url"] = company.custom.handshake_url
|
|
360
|
+
block["token_pattern"] = company.custom.token_pattern
|
|
361
|
+
block["token_header"] = company.custom.token_header
|
|
362
|
+
if company.custom.token_prefix:
|
|
363
|
+
block["token_prefix"] = company.custom.token_prefix
|
|
364
|
+
if company.custom.jobs_path:
|
|
365
|
+
block["jobs_path"] = company.custom.jobs_path
|
|
366
|
+
block["fields"] = dict(company.custom.fields)
|
|
367
|
+
if company.custom.url_template:
|
|
368
|
+
block["url_template"] = company.custom.url_template
|
|
369
|
+
if company.custom.page_param:
|
|
370
|
+
block["page_param"] = company.custom.page_param
|
|
371
|
+
block["page_size"] = company.custom.page_size
|
|
372
|
+
if company.custom.page_start:
|
|
373
|
+
block["page_start"] = company.custom.page_start
|
|
374
|
+
if company.custom.page_step:
|
|
375
|
+
block["page_step"] = company.custom.page_step
|
|
376
|
+
if company.custom.max_pages:
|
|
377
|
+
block["max_pages"] = company.custom.max_pages
|
|
378
|
+
row["custom"] = block
|
|
379
|
+
return row
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def registry_entry_yaml(company: Company) -> str:
|
|
383
|
+
dumped = yaml.safe_dump(
|
|
384
|
+
[_registry_row(company)], sort_keys=False, allow_unicode=True, default_flow_style=False
|
|
385
|
+
)
|
|
386
|
+
return dumped.rstrip("\n")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _registry_payload(companies: Sequence[Company]) -> str:
|
|
390
|
+
ordered = sorted(companies, key=lambda item: item.name.lower())
|
|
391
|
+
return yaml.safe_dump(
|
|
392
|
+
[_registry_row(item) for item in ordered],
|
|
393
|
+
sort_keys=False,
|
|
394
|
+
allow_unicode=True,
|
|
395
|
+
default_flow_style=False,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _is_shard_dir(target: Path) -> bool:
|
|
400
|
+
return target.suffix.lower() not in {".yaml", ".yml"}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _shard_name(name: str) -> str:
|
|
404
|
+
first = fold(name)[:1]
|
|
405
|
+
return first if "a" <= first <= "z" else "other"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _registry_files(source: Path) -> list[Path]:
|
|
409
|
+
if source.is_dir():
|
|
410
|
+
return sorted(source.glob("*.yaml"))
|
|
411
|
+
return [source] if source.exists() else []
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _rows_in(source: Path) -> list[Any]:
|
|
415
|
+
try:
|
|
416
|
+
raw = yaml.safe_load(source.read_text(encoding="utf-8"))
|
|
417
|
+
except yaml.YAMLError as exc:
|
|
418
|
+
raise RegistryError(f"{source} is not valid YAML: {exc}") from exc
|
|
419
|
+
except OSError as exc:
|
|
420
|
+
raise RegistryError(f"{source} could not be read: {exc.strerror or exc}") from exc
|
|
421
|
+
if raw is None:
|
|
422
|
+
return []
|
|
423
|
+
if not isinstance(raw, list):
|
|
424
|
+
raise RegistryError(f"{source.name} must contain a list of company entries")
|
|
425
|
+
return raw
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@contextmanager
|
|
429
|
+
def _registry_lock(target: Path) -> Iterator[None]:
|
|
430
|
+
if _is_shard_dir(target):
|
|
431
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
lock_path = target / ".registry.lock"
|
|
433
|
+
else:
|
|
434
|
+
lock_path = target.with_name(f".{target.name}.lock")
|
|
435
|
+
try:
|
|
436
|
+
lock = lock_path.open("a+b")
|
|
437
|
+
except OSError as exc:
|
|
438
|
+
raise RegistryError(f"{target} could not be locked: {exc.strerror or exc}") from exc
|
|
439
|
+
with lock:
|
|
440
|
+
try:
|
|
441
|
+
if os.name == "nt":
|
|
442
|
+
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
|
443
|
+
|
|
444
|
+
lock.seek(0, os.SEEK_END)
|
|
445
|
+
if lock.tell() == 0:
|
|
446
|
+
lock.write(b"0")
|
|
447
|
+
lock.flush()
|
|
448
|
+
lock.seek(0)
|
|
449
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1)
|
|
450
|
+
else:
|
|
451
|
+
fcntl = cast(Any, importlib.import_module("fcntl"))
|
|
452
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
453
|
+
except OSError as exc:
|
|
454
|
+
raise RegistryError(f"{target} could not be locked: {exc.strerror or exc}") from exc
|
|
455
|
+
yield
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _write_registry(companies: Sequence[Company], target: Path) -> Path:
|
|
459
|
+
if not _is_shard_dir(target):
|
|
460
|
+
return _write_file(_registry_payload(companies), target)
|
|
461
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
462
|
+
grouped: dict[str, list[Company]] = {}
|
|
463
|
+
for company in companies:
|
|
464
|
+
grouped.setdefault(_shard_name(company.name), []).append(company)
|
|
465
|
+
for shard, rows in sorted(grouped.items()):
|
|
466
|
+
_write_file(_registry_payload(rows), target / f"{shard}.yaml")
|
|
467
|
+
for stale in target.glob("*.yaml"):
|
|
468
|
+
if stale.stem not in grouped:
|
|
469
|
+
stale.unlink()
|
|
470
|
+
return target
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _write_file(payload: str, target: Path) -> Path:
|
|
474
|
+
staged: Path | None = None
|
|
475
|
+
try:
|
|
476
|
+
with tempfile.NamedTemporaryFile(
|
|
477
|
+
mode="w",
|
|
478
|
+
encoding="utf-8",
|
|
479
|
+
dir=target.parent,
|
|
480
|
+
prefix=f".{target.name}.",
|
|
481
|
+
suffix=".partial",
|
|
482
|
+
delete=False,
|
|
483
|
+
) as stream:
|
|
484
|
+
stream.write(payload)
|
|
485
|
+
stream.flush()
|
|
486
|
+
os.fsync(stream.fileno())
|
|
487
|
+
staged = Path(stream.name)
|
|
488
|
+
staged.replace(target)
|
|
489
|
+
except OSError as exc:
|
|
490
|
+
if staged is not None:
|
|
491
|
+
staged.unlink(missing_ok=True)
|
|
492
|
+
raise RegistryError(f"{target} could not be written: {exc.strerror or exc}") from exc
|
|
493
|
+
return target
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def write_registry(companies: Sequence[Company], path: Path | None = None) -> Path:
|
|
497
|
+
target = path or registry_path()
|
|
498
|
+
with _registry_lock(target):
|
|
499
|
+
return _write_registry(companies, target)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def load_companies(path: Path | None = None, today: date | None = None) -> tuple[Company, ...]:
|
|
503
|
+
source = path or registry_path()
|
|
504
|
+
moment = today or datetime.now(UTC).date()
|
|
505
|
+
files = _registry_files(source)
|
|
506
|
+
if not files:
|
|
507
|
+
raise RegistryError(f"registry not found at {source}")
|
|
508
|
+
|
|
509
|
+
companies: list[Company] = []
|
|
510
|
+
seen: set[tuple[Platform, str, str]] = set()
|
|
511
|
+
for file in files:
|
|
512
|
+
for index, row in enumerate(_rows_in(file), start=1):
|
|
513
|
+
if not isinstance(row, dict):
|
|
514
|
+
raise RegistryError(f"{file.name}: entry {index} is not a mapping")
|
|
515
|
+
try:
|
|
516
|
+
company = _company_from_row(row, index)
|
|
517
|
+
except RegistryError as exc:
|
|
518
|
+
raise RegistryError(f"{file.name}: {exc}") from exc
|
|
519
|
+
key = board_identity(company)
|
|
520
|
+
if key in seen:
|
|
521
|
+
raise RegistryError(
|
|
522
|
+
f"{file.name}: registry entry {index}: duplicate board {board_label(company)}"
|
|
523
|
+
)
|
|
524
|
+
seen.add(key)
|
|
525
|
+
companies.append(_resume_if_due(company, moment))
|
|
526
|
+
return tuple(companies)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _resume_if_due(company: Company, today: date) -> Company:
|
|
530
|
+
if company.paused_until is None or not company.pause_elapsed(today):
|
|
531
|
+
return company
|
|
532
|
+
return replace(company, enabled=True, paused_until=None)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def update_registry[T](
|
|
536
|
+
update: Callable[[tuple[Company, ...]], tuple[Sequence[Company], T]],
|
|
537
|
+
path: Path | None = None,
|
|
538
|
+
) -> tuple[Path, T]:
|
|
539
|
+
target = path or registry_path()
|
|
540
|
+
with _registry_lock(target):
|
|
541
|
+
companies, result = update(load_companies(target))
|
|
542
|
+
return _write_registry(companies, target), result
|