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,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
+ )