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,249 @@
1
+ import asyncio
2
+ import threading
3
+ from collections.abc import Callable, Mapping, Sequence
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from types import TracebackType
8
+ from typing import Self, TypeVar
9
+
10
+ from stage.domain import (
11
+ CoverageClassification,
12
+ HttpValidator,
13
+ IntegrityFinding,
14
+ IntegrityRepair,
15
+ Job,
16
+ JobFilters,
17
+ PurgeResult,
18
+ QuarantinedJob,
19
+ QuarantineFilters,
20
+ RateState,
21
+ SourceVisit,
22
+ SyncRun,
23
+ VolumePoint,
24
+ WorkdayCrawl,
25
+ WorkdayFacet,
26
+ )
27
+ from stage.storage.repository import Repository, SourceBatch, SourceBatchResult
28
+ from stage.storage.sqlite_repo import SqliteRepository
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ class WriterNotStartedError(RuntimeError):
34
+ pass
35
+
36
+
37
+ class DatabaseWriter:
38
+ def __init__(self, db_path: Path) -> None:
39
+ self._db_path = db_path
40
+ self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="stage-writer")
41
+ self._repository: Repository | None = None
42
+ self._thread_id: int | None = None
43
+
44
+ @property
45
+ def thread_id(self) -> int | None:
46
+ return self._thread_id
47
+
48
+ async def _submit(self, fn: Callable[[], T]) -> T:
49
+ loop = asyncio.get_running_loop()
50
+ return await loop.run_in_executor(self._executor, fn)
51
+
52
+ def _open(self) -> None:
53
+ self._thread_id = threading.get_ident()
54
+ self._repository = SqliteRepository.connect(self._db_path)
55
+
56
+ def _shutdown(self) -> None:
57
+ if self._repository is not None:
58
+ self._repository.close()
59
+ self._repository = None
60
+
61
+ async def start(self) -> Self:
62
+ await self._submit(self._open)
63
+ return self
64
+
65
+ async def aclose(self) -> None:
66
+ await self._submit(self._shutdown)
67
+ self._executor.shutdown(wait=True)
68
+
69
+ async def __aenter__(self) -> Self:
70
+ return await self.start()
71
+
72
+ async def __aexit__(
73
+ self,
74
+ exc_type: type[BaseException] | None,
75
+ exc: BaseException | None,
76
+ tb: TracebackType | None,
77
+ ) -> None:
78
+ await self.aclose()
79
+
80
+ async def run(self, fn: Callable[[Repository], T]) -> T:
81
+ def call() -> T:
82
+ repository = self._repository
83
+ if repository is None:
84
+ raise WriterNotStartedError("DatabaseWriter.start() has not been awaited")
85
+ return fn(repository)
86
+
87
+ return await self._submit(call)
88
+
89
+
90
+ class AsyncRepository:
91
+ def __init__(self, writer: DatabaseWriter) -> None:
92
+ self._writer = writer
93
+
94
+ async def apply_source_batch(self, batch: SourceBatch) -> SourceBatchResult:
95
+ return await self._writer.run(lambda repository: repository.apply_source_batch(batch))
96
+
97
+ async def load_validators(self, source: str) -> Mapping[str, HttpValidator]:
98
+ return await self._writer.run(lambda repository: repository.load_validators(source))
99
+
100
+ async def load_rate_state(self) -> Mapping[str, RateState]:
101
+ return await self._writer.run(lambda repository: repository.load_rate_state())
102
+
103
+ async def clear_rate_state(self, bucket: str | None = None) -> int:
104
+ return await self._writer.run(lambda repository: repository.clear_rate_state(bucket))
105
+
106
+ async def stale_members(self, source: str, before: datetime) -> list[SourceVisit]:
107
+ return await self._writer.run(lambda repository: repository.stale_members(source, before))
108
+
109
+ async def detail_queue(self, source: str, limit: int) -> list[str]:
110
+ return await self._writer.run(lambda repository: repository.detail_queue(source, limit))
111
+
112
+ async def detail_queue_size(self, source: str) -> int:
113
+ return await self._writer.run(lambda repository: repository.detail_queue_size(source))
114
+
115
+ async def load_workday_facets(self) -> Mapping[tuple[str, str], WorkdayFacet]:
116
+ return await self._writer.run(lambda repository: repository.load_workday_facets())
117
+
118
+ async def load_workday_crawls(self) -> Mapping[str, WorkdayCrawl]:
119
+ return await self._writer.run(lambda repository: repository.load_workday_crawls())
120
+
121
+ async def clear_validators(self, source: str | None = None) -> int:
122
+ return await self._writer.run(lambda repository: repository.clear_validators(source))
123
+
124
+ async def cached_url_count(self) -> int:
125
+ return await self._writer.run(lambda repository: repository.cached_url_count())
126
+
127
+ async def list_quarantined(self, filters: QuarantineFilters) -> list[QuarantinedJob]:
128
+ return await self._writer.run(lambda repository: repository.list_quarantined(filters))
129
+
130
+ async def count_quarantined(self, filters: QuarantineFilters) -> int:
131
+ return await self._writer.run(lambda repository: repository.count_quarantined(filters))
132
+
133
+ async def quarantine_reason_counts(self) -> dict[str, int]:
134
+ return await self._writer.run(lambda repository: repository.quarantine_reason_counts())
135
+
136
+ async def count_duplicates(self) -> int:
137
+ return await self._writer.run(lambda repository: repository.count_duplicates())
138
+
139
+ async def relabel_quarantine(self, entries: Sequence[QuarantinedJob]) -> int:
140
+ return await self._writer.run(lambda repository: repository.relabel_quarantine(entries))
141
+
142
+ async def refresh_quarantine_locations(
143
+ self, resolve: Callable[[str], tuple[str, str | None]]
144
+ ) -> int:
145
+ return await self._writer.run(
146
+ lambda repository: repository.refresh_quarantine_locations(resolve)
147
+ )
148
+
149
+ async def repair_integrity(self) -> list[IntegrityRepair]:
150
+ return await self._writer.run(lambda repository: repository.repair_integrity())
151
+
152
+ async def purge(self, now: datetime) -> PurgeResult:
153
+ return await self._writer.run(lambda repository: repository.purge(now))
154
+
155
+ async def close_orphan_boards(self, sources: Sequence[str], boards: Sequence[str]) -> int:
156
+ return await self._writer.run(
157
+ lambda repository: repository.close_orphan_boards(sources, boards)
158
+ )
159
+
160
+ async def preview_purge(self, now: datetime) -> PurgeResult:
161
+ return await self._writer.run(lambda repository: repository.preview_purge(now))
162
+
163
+ async def tombstone_count(self) -> int:
164
+ return await self._writer.run(lambda repository: repository.tombstone_count())
165
+
166
+ async def list_jobs(self, filters: JobFilters) -> list[Job]:
167
+ return await self._writer.run(lambda repository: repository.list_jobs(filters))
168
+
169
+ async def count_jobs(self, filters: JobFilters) -> int:
170
+ return await self._writer.run(lambda repository: repository.count_jobs(filters))
171
+
172
+ async def closed_among(self, job_ids: Sequence[str]) -> int:
173
+ return await self._writer.run(lambda repository: repository.closed_among(job_ids))
174
+
175
+ async def requests_since(self, since: datetime) -> tuple[dict[str, int], bool]:
176
+ return await self._writer.run(lambda repository: repository.requests_since(since))
177
+
178
+ async def previous_sync_at(self) -> datetime | None:
179
+ return await self._writer.run(lambda repository: repository.previous_sync_at())
180
+
181
+ async def company_names(self) -> list[str]:
182
+ return await self._writer.run(lambda repository: repository.company_names())
183
+
184
+ async def get_job(self, job_id: str) -> Job | None:
185
+ return await self._writer.run(lambda repository: repository.get_job(job_id))
186
+
187
+ async def duplicates_of(self, job_id: str) -> list[Job]:
188
+ return await self._writer.run(lambda repository: repository.duplicates_of(job_id))
189
+
190
+ async def search_jobs(self, query: str, filters: JobFilters) -> list[Job]:
191
+ return await self._writer.run(lambda repository: repository.search_jobs(query, filters))
192
+
193
+ async def count_search(self, query: str, filters: JobFilters) -> int:
194
+ return await self._writer.run(lambda repository: repository.count_search(query, filters))
195
+
196
+ async def board_counts(self) -> dict[str, int]:
197
+ return await self._writer.run(lambda repository: repository.board_counts())
198
+
199
+ async def company_counts(self) -> dict[str, dict[str, int]]:
200
+ return await self._writer.run(lambda repository: repository.company_counts())
201
+
202
+ async def quarantine_company_counts(self) -> dict[str, dict[str, int]]:
203
+ return await self._writer.run(lambda repository: repository.quarantine_company_counts())
204
+
205
+ async def quarantine_company_reasons(self) -> dict[str, dict[str, int]]:
206
+ return await self._writer.run(lambda repository: repository.quarantine_company_reasons())
207
+
208
+ async def company_apply_urls(self, companies: Sequence[str]) -> dict[str, tuple[str, ...]]:
209
+ return await self._writer.run(lambda repository: repository.company_apply_urls(companies))
210
+
211
+ async def coverage_classifications(self) -> list[CoverageClassification]:
212
+ return await self._writer.run(lambda repository: repository.coverage_classifications())
213
+
214
+ async def record_coverage_classification(self, entry: CoverageClassification) -> bool:
215
+ return await self._writer.run(
216
+ lambda repository: repository.record_coverage_classification(entry)
217
+ )
218
+
219
+ async def clear_coverage_classification(self, company: str) -> bool:
220
+ return await self._writer.run(
221
+ lambda repository: repository.clear_coverage_classification(company)
222
+ )
223
+
224
+ async def record_sync_run(self, run: SyncRun) -> None:
225
+ await self._writer.run(lambda repository: repository.record_sync_run(run))
226
+
227
+ async def last_sync_at(self) -> datetime | None:
228
+ return await self._writer.run(lambda repository: repository.last_sync_at())
229
+
230
+ async def volume_history(self, limit: int) -> Mapping[str, list[VolumePoint]]:
231
+ return await self._writer.run(lambda repository: repository.volume_history(limit))
232
+
233
+ async def run_history(self, limit: int) -> list[SyncRun]:
234
+ return await self._writer.run(lambda repository: repository.run_history(limit))
235
+
236
+ async def all_visits(self) -> list[SourceVisit]:
237
+ return await self._writer.run(lambda repository: repository.all_visits())
238
+
239
+ async def integrity_findings(self) -> list[IntegrityFinding]:
240
+ return await self._writer.run(lambda repository: repository.integrity_findings())
241
+
242
+ async def composition(self, column: str) -> dict[str, int]:
243
+ return await self._writer.run(lambda repository: repository.composition(column))
244
+
245
+ async def stored_counts(self) -> dict[str, int]:
246
+ return await self._writer.run(lambda repository: repository.stored_counts())
247
+
248
+ async def schema_version(self) -> int:
249
+ return await self._writer.run(lambda repository: repository.schema_version())
stage/tui/__init__.py ADDED
File without changes
stage/tui/app.py ADDED
@@ -0,0 +1,82 @@
1
+ from contextlib import AsyncExitStack
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING
4
+
5
+ from textual.app import App
6
+ from textual.binding import Binding
7
+
8
+ if TYPE_CHECKING:
9
+ from stage.storage import AsyncRepository
10
+
11
+ from stage.tui.screens.postings import PostingsScreen
12
+ from stage.tui.screens.splash import SplashScreen
13
+
14
+ THEME_PATH = Path(__file__).parent / "theme.tcss"
15
+
16
+
17
+ class StageApp(App[None]):
18
+ CSS_PATH = THEME_PATH
19
+ TITLE = "Stage"
20
+ ALLOW_MAXIMIZE = False
21
+
22
+ BINDINGS = [
23
+ Binding("q", "quit", "quit"),
24
+ Binding("ctrl+t", "next_theme", "theme", show=False),
25
+ ]
26
+
27
+ def __init__(self, database: Path, summary: str = "") -> None:
28
+ super().__init__()
29
+ self.database = database
30
+ self.summary = summary
31
+ self._stack = AsyncExitStack()
32
+ self.repository: AsyncRepository | None = None
33
+
34
+ def action_next_theme(self) -> None:
35
+ from stage.tui.safe import told
36
+ from stage.tui.state import store_theme
37
+
38
+ names = sorted(self.available_themes)
39
+ index = names.index(self.theme) if self.theme in names else -1
40
+ self.theme = names[(index + 1) % len(names)]
41
+ store_theme(self.theme)
42
+ told(self, f"Theme is now {self.theme}")
43
+
44
+ async def on_mount(self) -> None:
45
+ from stage.storage import open_repository
46
+ from stage.tui.state import load_theme
47
+
48
+ chosen = load_theme()
49
+ if chosen in self.available_themes:
50
+ self.theme = chosen
51
+ self.repository = await self._stack.enter_async_context(open_repository(self.database))
52
+ self.push_screen(PostingsScreen())
53
+ if self.summary:
54
+ self.push_screen(SplashScreen(self.summary))
55
+
56
+ async def on_unmount(self) -> None:
57
+ await self._stack.aclose()
58
+
59
+
60
+ async def summarize(database: Path) -> str:
61
+ import sqlite3
62
+
63
+ from stage.companies import RegistryError, load_companies
64
+ from stage.domain import JobFilters
65
+ from stage.storage import open_repository
66
+
67
+ try:
68
+ employers = len(load_companies(None))
69
+ except RegistryError:
70
+ employers = 0
71
+ try:
72
+ async with open_repository(database) as repo:
73
+ postings = await repo.count_jobs(JobFilters(limit=1))
74
+ last = await repo.last_sync_at()
75
+ except (OSError, sqlite3.DatabaseError):
76
+ return "no database yet — run stage sync"
77
+ when = f"last sync {last.astimezone().strftime('%Y-%m-%d %H:%M')}" if last else "never synced"
78
+ return f"{postings:,} postings · {employers:,} employers · {when}"
79
+
80
+
81
+ def launch(database: Path, summary: str = "") -> None:
82
+ StageApp(database, summary).run()
stage/tui/help.py ADDED
@@ -0,0 +1,26 @@
1
+ from textual.widgets import Static
2
+
3
+
4
+ class HelpOverlay:
5
+ HELP_TEXT = ""
6
+
7
+ def dismiss_overlays(self) -> None:
8
+ return
9
+
10
+ def action_help(self) -> None:
11
+ panel = self.query_one("#help", Static) # type: ignore[attr-defined]
12
+ showing = not panel.has_class("visible")
13
+ if showing:
14
+ self.dismiss_overlays()
15
+ panel.update(self.HELP_TEXT)
16
+ panel.set_class(showing, "visible")
17
+
18
+ def close_help(self) -> bool:
19
+ panel = self.query_one("#help", Static) # type: ignore[attr-defined]
20
+ if panel.has_class("visible"):
21
+ panel.remove_class("visible")
22
+ return True
23
+ return False
24
+
25
+
26
+ __all__ = ["HelpOverlay"]
stage/tui/safe.py ADDED
@@ -0,0 +1,21 @@
1
+ from rich.markup import escape
2
+ from rich.text import Text
3
+
4
+ from stage.domain.text import sanitize
5
+
6
+
7
+ def cell(value: str, width: int = 0) -> Text:
8
+ clean = sanitize(value)
9
+ return Text(clean[:width] if width else clean, no_wrap=True)
10
+
11
+
12
+ def quoted(value: str, width: int = 0) -> str:
13
+ clean = sanitize(value)
14
+ return escape(clean[:width] if width else clean)
15
+
16
+
17
+ def told(screen: object, message: str, severity: str = "information") -> None:
18
+ screen.notify(sanitize(message), severity=severity, markup=False) # type: ignore[attr-defined]
19
+
20
+
21
+ __all__ = ["cell", "quoted", "told"]
File without changes
@@ -0,0 +1,186 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from textual import work
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.screen import Screen
7
+ from textual.widgets import DataTable, Footer, Header, Static
8
+
9
+ from stage.domain.text import sanitize
10
+ from stage.tui.help import HelpOverlay
11
+ from stage.tui.safe import cell, quoted, told
12
+
13
+ if TYPE_CHECKING:
14
+ from stage.storage import AsyncRepository
15
+
16
+ COLUMNS = ("State", "Company", "Platform", "Postings", "Last success")
17
+
18
+
19
+ HELP_TEXT = """[b]Board health[/b]
20
+ up down move between employers
21
+ e enable this employer
22
+ d disable it, twice to confirm
23
+ r reload from the database
24
+
25
+ [dim]? closes this escape goes back[/dim]"""
26
+
27
+
28
+ class BoardsScreen(HelpOverlay, Screen[None]):
29
+ HELP_TEXT = HELP_TEXT
30
+ BINDINGS = [
31
+ Binding("e", "enable", "enable"),
32
+ Binding("d", "disable", "disable"),
33
+ Binding("question_mark", "help", "keys"),
34
+ Binding("escape", "back", "back"),
35
+ Binding("r", "reload", "reload", show=False),
36
+ ]
37
+
38
+ def __init__(self) -> None:
39
+ super().__init__()
40
+ self._companies: tuple[str, ...] = ()
41
+ self._arming_disable: str | None = None
42
+
43
+ @property
44
+ def repository(self) -> "AsyncRepository | None":
45
+ from stage.tui.app import StageApp
46
+
47
+ app = self.app
48
+ return app.repository if isinstance(app, StageApp) else None
49
+
50
+ def compose(self) -> ComposeResult:
51
+ yield Header()
52
+ yield Static("", id="chips")
53
+ yield DataTable(id="results", cursor_type="row")
54
+ yield Static("", id="detail")
55
+ yield Static("", id="help")
56
+ yield Footer()
57
+
58
+ def on_mount(self) -> None:
59
+ self._mark_loading()
60
+ self.query_one("#results", DataTable).add_columns(*COLUMNS)
61
+ self.load()
62
+
63
+ def _mark_loading(self) -> None:
64
+ self.query_one("#chips", Static).update("[dim]loading…[/dim]")
65
+
66
+ def on_data_table_row_highlighted(self) -> None:
67
+ self._arming_disable = None
68
+
69
+ def selected_company(self) -> str | None:
70
+ table = self.query_one("#results", DataTable)
71
+ row = table.cursor_row
72
+ if not self._companies or row < 0 or row >= len(self._companies):
73
+ return None
74
+ return self._companies[row]
75
+
76
+ @work(exclusive=True)
77
+ async def load(self) -> None:
78
+ from stage.companies import RegistryError, load_companies
79
+ from stage.services.coverage import coverage
80
+
81
+ repo = self.repository
82
+ if repo is None:
83
+ return
84
+ try:
85
+ companies = load_companies(None)
86
+ except RegistryError as exc:
87
+ self.query_one("#detail", Static).update(f"[red]{quoted(str(exc))}[/red]")
88
+ return
89
+ report = await coverage(repo, companies)
90
+ table = self.query_one("#results", DataTable)
91
+ table.clear()
92
+ names = []
93
+ for row in report.rows:
94
+ seen = (
95
+ row.last_success_at.astimezone().strftime("%Y-%m-%d")
96
+ if row.last_success_at
97
+ else "—"
98
+ )
99
+ table.add_row(
100
+ cell(row.state.value),
101
+ cell(row.company, 26),
102
+ cell(row.platform),
103
+ cell(str(row.postings)),
104
+ cell(seen),
105
+ )
106
+ names.append(row.company)
107
+ listed = set(names)
108
+ for company in companies:
109
+ if company.enabled or company.name in listed:
110
+ continue
111
+ table.add_row(
112
+ cell("disabled"),
113
+ cell(company.name, 26),
114
+ cell(company.platform.value),
115
+ cell("0"),
116
+ cell("-"),
117
+ )
118
+ names.append(company.name)
119
+ self._companies = tuple(names)
120
+ self.query_one("#chips", Static).update(
121
+ f"[b]{report.enabled}[/b] enabled · [dim]{report.disabled} disabled · "
122
+ f"{len(report.gaps)} producing nothing[/dim]"
123
+ )
124
+ self.query_one("#detail", Static).update(
125
+ "[b]e[/b] enable [b]d[/b] disable, twice to confirm [b]?[/b] keys\n"
126
+ "[dim]A board producing nothing is either genuinely empty or a parser has "
127
+ "drifted. Check with stage canary before disabling.[/dim]"
128
+ )
129
+
130
+ def action_disable(self) -> None:
131
+ company = self.selected_company()
132
+ if company is None:
133
+ told(self, "Highlight a board first.", "warning")
134
+ return
135
+ if self._arming_disable != company:
136
+ self._arming_disable = company
137
+ told(self, f"Press d again to disable {quoted(company)}.", "warning")
138
+ return
139
+ self._arming_disable = None
140
+ self._set_enabled(False)
141
+
142
+ def action_enable(self) -> None:
143
+ self._arming_disable = None
144
+ self._set_enabled(True)
145
+
146
+ def _set_enabled(self, enabled: bool) -> None:
147
+ from dataclasses import replace
148
+
149
+ from stage.companies import RegistryError, update_registry
150
+ from stage.domain import Company
151
+
152
+ company = self.selected_company()
153
+ if company is None:
154
+ told(self, "Highlight a board first.", "warning")
155
+ return
156
+
157
+ def apply(rows: tuple[Company, ...]) -> tuple[list[Company], int]:
158
+ changed = 0
159
+ updated = []
160
+ for row in rows:
161
+ if row.name == company and row.enabled != enabled:
162
+ updated.append(replace(row, enabled=enabled))
163
+ changed += 1
164
+ continue
165
+ updated.append(row)
166
+ return updated, changed
167
+
168
+ try:
169
+ _, changed = update_registry(apply)
170
+ except (RegistryError, OSError) as exc:
171
+ told(self, f"Could not write the registry: {sanitize(str(exc))}", "error")
172
+ return
173
+ if not changed:
174
+ state = "enabled" if enabled else "disabled"
175
+ told(self, f"{sanitize(company)} is already {state}.", "warning")
176
+ return
177
+ told(self, f"{'Enabled' if enabled else 'Disabled'} {sanitize(company)}")
178
+ self.load()
179
+
180
+ def action_reload(self) -> None:
181
+ self.load()
182
+
183
+ def action_back(self) -> None:
184
+ if self.close_help():
185
+ return
186
+ self.dismiss(None)