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