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
|
@@ -0,0 +1,209 @@
|
|
|
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, TabbedContent, TabPane
|
|
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
|
+
PAGE = 200
|
|
17
|
+
UNREGISTERED_COLUMNS = ("Employer", "Postings", "Quarantined", "Internships", "Sources")
|
|
18
|
+
QUARANTINE_COLUMNS = ("Reason", "Company", "Title", "Source")
|
|
19
|
+
|
|
20
|
+
DISPOSITIONS: tuple[tuple[str, str, str], ...] = (
|
|
21
|
+
("a", "adapter-candidate", "worth a dedicated adapter"),
|
|
22
|
+
("j", "custom-json-candidate", "reachable with a custom board"),
|
|
23
|
+
("k", "feed-only", "covered well enough through a feed"),
|
|
24
|
+
("u", "unavailable", "no public board to poll"),
|
|
25
|
+
("d", "deferred", "revisit later"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
HELP_TEXT = """[b]Review[/b]
|
|
30
|
+
up down move between rows
|
|
31
|
+
tab switch tabs
|
|
32
|
+
|
|
33
|
+
[b]File an employer[/b]
|
|
34
|
+
a worth a dedicated adapter
|
|
35
|
+
j reachable with custom json
|
|
36
|
+
k covered through a feed
|
|
37
|
+
u no public board to poll
|
|
38
|
+
d revisit later
|
|
39
|
+
x undo the last call
|
|
40
|
+
|
|
41
|
+
[b]Other[/b]
|
|
42
|
+
m load more
|
|
43
|
+
r reload from the database
|
|
44
|
+
|
|
45
|
+
[dim]? closes this escape goes back[/dim]"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReviewScreen(HelpOverlay, Screen[None]):
|
|
49
|
+
HELP_TEXT = HELP_TEXT
|
|
50
|
+
BINDINGS = [
|
|
51
|
+
Binding("a", "classify('adapter-candidate')", "adapter"),
|
|
52
|
+
Binding("j", "classify('custom-json-candidate')", "custom json"),
|
|
53
|
+
Binding("k", "classify('feed-only')", "feed only"),
|
|
54
|
+
Binding("x", "unclassify", "undo"),
|
|
55
|
+
Binding("question_mark", "help", "keys"),
|
|
56
|
+
Binding("escape", "back", "back"),
|
|
57
|
+
Binding("u", "classify('unavailable')", "unavailable", show=False),
|
|
58
|
+
Binding("d", "classify('deferred')", "defer", show=False),
|
|
59
|
+
Binding("r", "reload", "reload", show=False),
|
|
60
|
+
Binding("m", "more", "load more", show=False),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self._limit = PAGE
|
|
66
|
+
self._employers: tuple[str, ...] = ()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def repository(self) -> "AsyncRepository | None":
|
|
70
|
+
from stage.tui.app import StageApp
|
|
71
|
+
|
|
72
|
+
app = self.app
|
|
73
|
+
return app.repository if isinstance(app, StageApp) else None
|
|
74
|
+
|
|
75
|
+
def compose(self) -> ComposeResult:
|
|
76
|
+
yield Header()
|
|
77
|
+
yield Static("", id="chips")
|
|
78
|
+
with TabbedContent(id="tabs"):
|
|
79
|
+
with TabPane("Unregistered", id="tab-unregistered"):
|
|
80
|
+
yield DataTable(id="unregistered", cursor_type="row")
|
|
81
|
+
with TabPane("Quarantine", id="tab-quarantine"):
|
|
82
|
+
yield DataTable(id="quarantine", cursor_type="row")
|
|
83
|
+
yield Static("", id="detail")
|
|
84
|
+
yield Static("", id="help")
|
|
85
|
+
yield Footer()
|
|
86
|
+
|
|
87
|
+
def on_mount(self) -> None:
|
|
88
|
+
self._mark_loading()
|
|
89
|
+
self.query_one("#unregistered", DataTable).add_columns(*UNREGISTERED_COLUMNS)
|
|
90
|
+
self.query_one("#quarantine", DataTable).add_columns(*QUARANTINE_COLUMNS)
|
|
91
|
+
self.load()
|
|
92
|
+
|
|
93
|
+
def _mark_loading(self) -> None:
|
|
94
|
+
self.query_one("#chips", Static).update("[dim]loading…[/dim]")
|
|
95
|
+
|
|
96
|
+
def selected_employer(self) -> str | None:
|
|
97
|
+
table = self.query_one("#unregistered", DataTable)
|
|
98
|
+
row = table.cursor_row
|
|
99
|
+
if not self._employers or row < 0 or row >= len(self._employers):
|
|
100
|
+
return None
|
|
101
|
+
return self._employers[row]
|
|
102
|
+
|
|
103
|
+
@work(exclusive=True)
|
|
104
|
+
async def load(self) -> None:
|
|
105
|
+
from stage.companies import RegistryError, load_companies
|
|
106
|
+
from stage.domain import QuarantineFilters
|
|
107
|
+
from stage.services.coverage import coverage
|
|
108
|
+
from stage.services.quarantine import list_quarantined
|
|
109
|
+
|
|
110
|
+
repo = self.repository
|
|
111
|
+
if repo is None:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
companies = load_companies(None)
|
|
115
|
+
except RegistryError as exc:
|
|
116
|
+
self.query_one("#detail", Static).update(f"[red]{quoted(str(exc))}[/red]")
|
|
117
|
+
return
|
|
118
|
+
report = await coverage(repo, companies, unregistered=True)
|
|
119
|
+
listing = await list_quarantined(repo, QuarantineFilters(limit=self._limit))
|
|
120
|
+
|
|
121
|
+
rows = report.unregistered[: self._limit]
|
|
122
|
+
self._employers = tuple(entry.company for entry in rows)
|
|
123
|
+
unregistered = self.query_one("#unregistered", DataTable)
|
|
124
|
+
unregistered.clear()
|
|
125
|
+
for entry in rows:
|
|
126
|
+
unregistered.add_row(
|
|
127
|
+
cell(entry.company, 34),
|
|
128
|
+
cell(str(entry.postings)),
|
|
129
|
+
cell(str(entry.quarantined)),
|
|
130
|
+
cell("yes" if entry.posts_internships else "-"),
|
|
131
|
+
cell(", ".join(entry.sources) or "-", 24),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
quarantine = self.query_one("#quarantine", DataTable)
|
|
135
|
+
quarantine.clear()
|
|
136
|
+
for row in listing.entries:
|
|
137
|
+
quarantine.add_row(
|
|
138
|
+
cell(row.reason.value, 22),
|
|
139
|
+
cell(row.company, 22),
|
|
140
|
+
cell(row.title_raw, 38),
|
|
141
|
+
cell(row.source, 14),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.query_one("#chips", Static).update(
|
|
145
|
+
f"[b]{len(report.unregistered)}[/b] unregistered · "
|
|
146
|
+
f"[b]{listing.total_matching}[/b] quarantined "
|
|
147
|
+
f"[dim](showing {len(listing.entries)}, {len(report.classifications)} reviewed)[/dim]"
|
|
148
|
+
)
|
|
149
|
+
self._render_help()
|
|
150
|
+
|
|
151
|
+
def _render_help(self) -> None:
|
|
152
|
+
keys = " ".join(f"[b]{key}[/b] {label}" for key, label, _ in DISPOSITIONS)
|
|
153
|
+
self.query_one("#detail", Static).update(
|
|
154
|
+
f"{keys} [b]x[/b] undo\n[dim]Acts on the highlighted employer. "
|
|
155
|
+
"m loads more quarantined rows.[/dim]"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@work(exclusive=True)
|
|
159
|
+
async def action_classify(self, disposition: str) -> None:
|
|
160
|
+
from datetime import UTC, datetime
|
|
161
|
+
|
|
162
|
+
from stage.domain import CoverageClassification, CoverageDisposition
|
|
163
|
+
|
|
164
|
+
repo = self.repository
|
|
165
|
+
company = self.selected_employer()
|
|
166
|
+
if repo is None or company is None:
|
|
167
|
+
told(self, "Highlight an unregistered employer first.", "warning")
|
|
168
|
+
return
|
|
169
|
+
note = next(
|
|
170
|
+
(why for _, label, why in DISPOSITIONS if label == disposition),
|
|
171
|
+
"reviewed from the interactive browser",
|
|
172
|
+
)
|
|
173
|
+
entry = CoverageClassification(
|
|
174
|
+
company=company,
|
|
175
|
+
disposition=CoverageDisposition(disposition),
|
|
176
|
+
note=note,
|
|
177
|
+
checked_on=datetime.now(UTC),
|
|
178
|
+
)
|
|
179
|
+
replaced = await repo.record_coverage_classification(entry)
|
|
180
|
+
verb = "Updated" if replaced else "Recorded"
|
|
181
|
+
told(self, f"{verb} {sanitize(company)} as {disposition}")
|
|
182
|
+
self.load()
|
|
183
|
+
|
|
184
|
+
@work(exclusive=True)
|
|
185
|
+
async def action_unclassify(self) -> None:
|
|
186
|
+
repo = self.repository
|
|
187
|
+
company = self.selected_employer()
|
|
188
|
+
if repo is None or company is None:
|
|
189
|
+
told(self, "Highlight an employer first.", "warning")
|
|
190
|
+
return
|
|
191
|
+
removed = await repo.clear_coverage_classification(company)
|
|
192
|
+
if not removed:
|
|
193
|
+
told(self, f"{sanitize(company)} had no recorded decision.", "warning")
|
|
194
|
+
return
|
|
195
|
+
told(self, f"Cleared the decision for {sanitize(company)}")
|
|
196
|
+
self.load()
|
|
197
|
+
|
|
198
|
+
def action_more(self) -> None:
|
|
199
|
+
self._limit += PAGE
|
|
200
|
+
self.load()
|
|
201
|
+
|
|
202
|
+
def action_reload(self) -> None:
|
|
203
|
+
self._limit = PAGE
|
|
204
|
+
self.load()
|
|
205
|
+
|
|
206
|
+
def action_back(self) -> None:
|
|
207
|
+
if self.close_help():
|
|
208
|
+
return
|
|
209
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.containers import Center, Middle
|
|
3
|
+
from textual.screen import Screen
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
from stage.banner import COMPACT, MIN_WIDE
|
|
7
|
+
from stage.banner import WIDE as BANNER
|
|
8
|
+
from stage.banner import block as _block
|
|
9
|
+
|
|
10
|
+
DISMISS_AFTER = 1.2
|
|
11
|
+
|
|
12
|
+
__all__ = ["BANNER", "COMPACT", "DISMISS_AFTER", "MIN_WIDE", "SplashScreen", "_block"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SplashScreen(Screen[None]):
|
|
16
|
+
def __init__(self, summary: str, *, dismiss_after: float | None = DISMISS_AFTER) -> None:
|
|
17
|
+
super().__init__()
|
|
18
|
+
self._summary = summary
|
|
19
|
+
self._dismiss_after = dismiss_after
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
art = _block(BANNER if self.app.size.width >= MIN_WIDE else COMPACT)
|
|
23
|
+
hint = self._summary if self._dismiss_after else f"{self._summary}\n\npress any key"
|
|
24
|
+
with Middle(), Center():
|
|
25
|
+
yield Static(art, id="splash-art")
|
|
26
|
+
yield Static(hint, id="splash-stats")
|
|
27
|
+
|
|
28
|
+
def on_mount(self) -> None:
|
|
29
|
+
if self._dismiss_after is not None:
|
|
30
|
+
self.set_timer(self._dismiss_after, self._done)
|
|
31
|
+
|
|
32
|
+
def on_key(self) -> None:
|
|
33
|
+
self._done()
|
|
34
|
+
|
|
35
|
+
def _done(self) -> None:
|
|
36
|
+
if self.is_current:
|
|
37
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from textual import work
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import VerticalScroll
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import Footer, Header, Static
|
|
10
|
+
|
|
11
|
+
from stage.tui.help import HelpOverlay
|
|
12
|
+
from stage.tui.safe import quoted
|
|
13
|
+
|
|
14
|
+
SAMPLE = 5000
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from stage.domain import Job
|
|
18
|
+
from stage.storage import AsyncRepository
|
|
19
|
+
|
|
20
|
+
BAR_WIDTH = 28
|
|
21
|
+
TOP_N = 12
|
|
22
|
+
TREND_DAYS = 30
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def trend(jobs: "tuple[Job, ...]", days: int = TREND_DAYS) -> Counter[str]:
|
|
26
|
+
from datetime import UTC, datetime, timedelta
|
|
27
|
+
|
|
28
|
+
cutoff = datetime.now(UTC) - timedelta(days=days)
|
|
29
|
+
return Counter(
|
|
30
|
+
job.first_seen.astimezone().strftime("%m-%d") for job in jobs if job.first_seen >= cutoff
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def bars(counts: Counter[str], width: int = BAR_WIDTH, top: int = TOP_N) -> str:
|
|
35
|
+
if not counts:
|
|
36
|
+
return "[dim]nothing recorded yet[/dim]"
|
|
37
|
+
ranked = counts.most_common(top)
|
|
38
|
+
highest = ranked[0][1] or 1
|
|
39
|
+
label_width = max(len(name) for name, _ in ranked)
|
|
40
|
+
return "\n".join(
|
|
41
|
+
f"{quoted(name):<{label_width}} [b]{'#' * round(width * value / highest)}[/b] {value}"
|
|
42
|
+
for name, value in ranked
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StatsScreen(HelpOverlay, Screen[None]):
|
|
47
|
+
HELP_TEXT = """[b]Statistics[/b]
|
|
48
|
+
r reload from the database
|
|
49
|
+
|
|
50
|
+
[dim]? closes this escape goes back[/dim]"""
|
|
51
|
+
BINDINGS = [
|
|
52
|
+
Binding("question_mark", "help", "keys"),
|
|
53
|
+
Binding("escape", "back", "back"),
|
|
54
|
+
Binding("r", "reload", "reload", show=False),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def repository(self) -> "AsyncRepository | None":
|
|
59
|
+
from stage.tui.app import StageApp
|
|
60
|
+
|
|
61
|
+
app = self.app
|
|
62
|
+
return app.repository if isinstance(app, StageApp) else None
|
|
63
|
+
|
|
64
|
+
def compose(self) -> ComposeResult:
|
|
65
|
+
yield Header()
|
|
66
|
+
yield VerticalScroll(Static("[dim]loading…[/dim]", id="stats-grid"))
|
|
67
|
+
yield Static("", id="help")
|
|
68
|
+
yield Footer()
|
|
69
|
+
|
|
70
|
+
def on_mount(self) -> None:
|
|
71
|
+
self._mark_loading()
|
|
72
|
+
self.load()
|
|
73
|
+
|
|
74
|
+
def _mark_loading(self) -> None:
|
|
75
|
+
self.query_one("#stats-grid", Static).update("[dim]loading…[/dim]")
|
|
76
|
+
|
|
77
|
+
@work(exclusive=True)
|
|
78
|
+
async def load(self) -> None:
|
|
79
|
+
from stage.domain import JobFilters
|
|
80
|
+
from stage.services.query import list_jobs
|
|
81
|
+
|
|
82
|
+
repo = self.repository
|
|
83
|
+
if repo is None:
|
|
84
|
+
return
|
|
85
|
+
listing = await list_jobs(repo, JobFilters(limit=SAMPLE), window_days=None)
|
|
86
|
+
reasons = await repo.quarantine_reason_counts()
|
|
87
|
+
self.query_one("#stats-grid", Static).update(
|
|
88
|
+
self._summary(listing.jobs, reasons, listing.total_matching)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _summary(self, jobs: "tuple[Job, ...]", reasons: dict[str, int], total: int) -> str:
|
|
92
|
+
if not jobs:
|
|
93
|
+
return "[dim]No postings yet. Run stage sync first.[/dim]"
|
|
94
|
+
blocks = (
|
|
95
|
+
("Postings by role", Counter(job.role.value for job in jobs)),
|
|
96
|
+
("Postings by location", Counter(job.location.value for job in jobs)),
|
|
97
|
+
("Postings by term", Counter(job.term for job in jobs)),
|
|
98
|
+
("Top employers", Counter(job.company for job in jobs)),
|
|
99
|
+
("Postings by source", Counter(job.source for job in jobs)),
|
|
100
|
+
("Quarantined by reason", Counter(reasons)),
|
|
101
|
+
)
|
|
102
|
+
seen = trend(jobs)
|
|
103
|
+
scope = (
|
|
104
|
+
f"[b]{total}[/b] postings\n"
|
|
105
|
+
if len(jobs) >= total
|
|
106
|
+
else f"[b]{total}[/b] postings, measured on the most recent {len(jobs)}\n"
|
|
107
|
+
)
|
|
108
|
+
parts = [scope]
|
|
109
|
+
parts.extend(f"\n[b]{title}[/b]\n{bars(counts)}" for title, counts in blocks)
|
|
110
|
+
if seen:
|
|
111
|
+
ordered = Counter(dict(sorted(seen.items())))
|
|
112
|
+
parts.append(
|
|
113
|
+
f"\n[b]First seen over the last {TREND_DAYS} days[/b]\n"
|
|
114
|
+
f"{bars(ordered, top=TREND_DAYS)}"
|
|
115
|
+
)
|
|
116
|
+
return "\n".join(parts)
|
|
117
|
+
|
|
118
|
+
def action_reload(self) -> None:
|
|
119
|
+
self.load()
|
|
120
|
+
|
|
121
|
+
def action_back(self) -> None:
|
|
122
|
+
if self.close_help():
|
|
123
|
+
return
|
|
124
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,194 @@
|
|
|
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, ProgressBar, Static
|
|
8
|
+
|
|
9
|
+
from stage.tui.help import HelpOverlay
|
|
10
|
+
from stage.tui.safe import cell, quoted
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from stage.domain import SyncEvent
|
|
14
|
+
from stage.storage import AsyncRepository
|
|
15
|
+
|
|
16
|
+
MAX_WARNINGS = 40
|
|
17
|
+
BAR_WIDTH = 22
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def source_bar(done: int, total: int, width: int = BAR_WIDTH) -> str:
|
|
21
|
+
if total <= 0:
|
|
22
|
+
return "-" * width
|
|
23
|
+
filled = round(width * min(done, total) / total)
|
|
24
|
+
return "#" * filled + "-" * (width - filled)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SyncScreen(HelpOverlay, Screen[None]):
|
|
28
|
+
HELP_TEXT = """[b]Sync[/b]
|
|
29
|
+
s start a sync
|
|
30
|
+
x cancel the run in progress
|
|
31
|
+
|
|
32
|
+
[dim]? closes this escape goes back[/dim]"""
|
|
33
|
+
BINDINGS = [
|
|
34
|
+
Binding("s", "start", "start"),
|
|
35
|
+
Binding("x", "cancel", "cancel"),
|
|
36
|
+
Binding("question_mark", "help", "keys"),
|
|
37
|
+
Binding("escape", "back", "back"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, dry_run: bool = False) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.dry_run = dry_run
|
|
43
|
+
self._sources: dict[str, tuple[int, int]] = {}
|
|
44
|
+
self._warnings: list[str] = []
|
|
45
|
+
self._done = 0
|
|
46
|
+
self._planned = 0
|
|
47
|
+
self._fetched = 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def repository(self) -> "AsyncRepository | None":
|
|
51
|
+
from stage.tui.app import StageApp
|
|
52
|
+
|
|
53
|
+
app = self.app
|
|
54
|
+
return app.repository if isinstance(app, StageApp) else None
|
|
55
|
+
|
|
56
|
+
def compose(self) -> ComposeResult:
|
|
57
|
+
yield Header()
|
|
58
|
+
yield Static("", id="chips")
|
|
59
|
+
yield ProgressBar(total=100, show_eta=False, id="overall")
|
|
60
|
+
yield DataTable(id="sources", cursor_type="row")
|
|
61
|
+
yield Static("", id="warnings")
|
|
62
|
+
yield Static("", id="detail")
|
|
63
|
+
yield Static("", id="help")
|
|
64
|
+
yield Footer()
|
|
65
|
+
|
|
66
|
+
def on_mount(self) -> None:
|
|
67
|
+
table = self.query_one("#sources", DataTable)
|
|
68
|
+
table.add_columns("Source", "Progress", "Done")
|
|
69
|
+
table.focus()
|
|
70
|
+
self.query_one("#chips", Static).update(
|
|
71
|
+
"[dim]Press s to start a sync, x to cancel, esc to go back.[/dim]"
|
|
72
|
+
)
|
|
73
|
+
self.query_one("#detail", Static).update(
|
|
74
|
+
"[dim]A sync fetches every enabled board, classifies what it finds, and "
|
|
75
|
+
"stores the postings that qualify.[/dim]"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def running(self) -> bool:
|
|
80
|
+
from textual.worker import WorkerState
|
|
81
|
+
|
|
82
|
+
return any(
|
|
83
|
+
worker.name == "run_sync" and worker.state is WorkerState.RUNNING
|
|
84
|
+
for worker in self.workers
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def action_start(self) -> None:
|
|
88
|
+
if self.running:
|
|
89
|
+
self.notify("A sync is already running.", severity="warning")
|
|
90
|
+
return
|
|
91
|
+
self._sources.clear()
|
|
92
|
+
self._warnings.clear()
|
|
93
|
+
self._done = self._planned = self._fetched = 0
|
|
94
|
+
self.run_sync()
|
|
95
|
+
|
|
96
|
+
def action_cancel(self) -> None:
|
|
97
|
+
if not self.running:
|
|
98
|
+
self.notify("Nothing to cancel.", severity="warning")
|
|
99
|
+
return
|
|
100
|
+
self.workers.cancel_all()
|
|
101
|
+
self.query_one("#chips", Static).update("[yellow]Sync cancelled.[/yellow]")
|
|
102
|
+
|
|
103
|
+
def action_back(self) -> None:
|
|
104
|
+
if self.close_help():
|
|
105
|
+
return
|
|
106
|
+
self.workers.cancel_all()
|
|
107
|
+
self.dismiss(None)
|
|
108
|
+
|
|
109
|
+
@work(exclusive=True)
|
|
110
|
+
async def run_sync(self) -> None:
|
|
111
|
+
from stage.companies import RegistryError, load_companies
|
|
112
|
+
from stage.services.sync import sync
|
|
113
|
+
|
|
114
|
+
repo = self.repository
|
|
115
|
+
if repo is None:
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
companies = load_companies(None)
|
|
119
|
+
except RegistryError as exc:
|
|
120
|
+
self.query_one("#chips", Static).update(f"[red]{quoted(str(exc))}[/red]")
|
|
121
|
+
return
|
|
122
|
+
async for event in sync(repo, companies, dry_run=self.dry_run):
|
|
123
|
+
self._absorb(event)
|
|
124
|
+
|
|
125
|
+
def _absorb(self, event: "SyncEvent") -> None:
|
|
126
|
+
from stage.domain import (
|
|
127
|
+
CompanyDeferred,
|
|
128
|
+
CompanyFailed,
|
|
129
|
+
CompanyFinished,
|
|
130
|
+
CompanyUnchanged,
|
|
131
|
+
SourceBlocked,
|
|
132
|
+
SourceFailed,
|
|
133
|
+
SourceStarted,
|
|
134
|
+
SyncFinished,
|
|
135
|
+
SyncStarted,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if isinstance(event, SyncStarted):
|
|
139
|
+
self._planned = event.companies
|
|
140
|
+
self.query_one("#overall", ProgressBar).update(total=max(event.companies, 1))
|
|
141
|
+
elif isinstance(event, SourceStarted):
|
|
142
|
+
self._sources[event.source] = (0, event.companies)
|
|
143
|
+
elif isinstance(
|
|
144
|
+
event, CompanyFinished | CompanyUnchanged | CompanyFailed | CompanyDeferred
|
|
145
|
+
):
|
|
146
|
+
done, total = self._sources.get(event.source, (0, 0))
|
|
147
|
+
self._sources[event.source] = (done + 1, total)
|
|
148
|
+
self._done += 1
|
|
149
|
+
if isinstance(event, CompanyFinished):
|
|
150
|
+
self._fetched += event.fetched
|
|
151
|
+
elif isinstance(event, CompanyFailed):
|
|
152
|
+
self._note(quoted(f"{event.source}/{event.company} - {event.error}"))
|
|
153
|
+
self.query_one("#overall", ProgressBar).update(progress=self._done)
|
|
154
|
+
elif isinstance(event, SourceBlocked | SourceFailed):
|
|
155
|
+
self._note(quoted(f"{event.source} - {getattr(event, 'reason', 'failed')}"))
|
|
156
|
+
elif isinstance(event, SyncFinished):
|
|
157
|
+
self._finish(event)
|
|
158
|
+
return
|
|
159
|
+
self._paint()
|
|
160
|
+
|
|
161
|
+
def _note(self, message: str) -> None:
|
|
162
|
+
self._warnings.append(message)
|
|
163
|
+
del self._warnings[:-MAX_WARNINGS]
|
|
164
|
+
|
|
165
|
+
def _paint(self) -> None:
|
|
166
|
+
table = self.query_one("#sources", DataTable)
|
|
167
|
+
table.clear()
|
|
168
|
+
for name, (done, total) in sorted(self._sources.items()):
|
|
169
|
+
mark = " ok" if total and done >= total else ""
|
|
170
|
+
table.add_row(cell(name, 20), source_bar(done, total), f"{done}/{total}{mark}")
|
|
171
|
+
self.query_one("#chips", Static).update(
|
|
172
|
+
f"[b]{self._done}[/b] of {self._planned} boards · "
|
|
173
|
+
f"[b]{self._fetched}[/b] postings fetched"
|
|
174
|
+
)
|
|
175
|
+
if self._warnings:
|
|
176
|
+
shown = "\n".join(f" {line}" for line in self._warnings[-8:])
|
|
177
|
+
self.query_one("#warnings", Static).update(
|
|
178
|
+
f"\n[yellow]Warnings ({len(self._warnings)})[/yellow]\n{shown}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _finish(self, event: object) -> None:
|
|
182
|
+
added = getattr(event, "added", 0)
|
|
183
|
+
updated = getattr(event, "updated", 0)
|
|
184
|
+
quarantined = getattr(event, "quarantined", 0)
|
|
185
|
+
outcome = getattr(getattr(event, "outcome", None), "value", "finished")
|
|
186
|
+
tone = {"success": "green", "partial": "yellow"}.get(outcome, "red")
|
|
187
|
+
self._paint()
|
|
188
|
+
self.query_one("#chips", Static).update(
|
|
189
|
+
f"[{tone}]{outcome}[/{tone}] — [b]{added}[/b] added, {updated} updated, "
|
|
190
|
+
f"{quarantined} quarantined"
|
|
191
|
+
)
|
|
192
|
+
self.query_one("#detail", Static).update(
|
|
193
|
+
"[dim]Press escape to go back to the postings browser, or s to sync again.[/dim]"
|
|
194
|
+
)
|