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/storage/writer.py
ADDED
|
@@ -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)
|