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,120 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass, field
3
+
4
+ from stage.domain import (
5
+ Company,
6
+ CompanyFailed,
7
+ CompanyFinished,
8
+ CompanyUnchanged,
9
+ Platform,
10
+ )
11
+ from stage.storage import AsyncRepository
12
+
13
+ AKAMAI_PLATFORMS = frozenset({Platform.WORKDAY})
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class BoardProbe:
18
+ source: str
19
+ company: str
20
+ fetched: int = 0
21
+ error: str = ""
22
+ degraded: str = ""
23
+ unchanged: bool = False
24
+ unreachable: bool = False
25
+
26
+ @property
27
+ def is_failure(self) -> bool:
28
+ return bool(self.error) and not self.unreachable
29
+
30
+ @property
31
+ def is_unreachable(self) -> bool:
32
+ return bool(self.error) and self.unreachable
33
+
34
+ @property
35
+ def is_empty(self) -> bool:
36
+ return not self.error and not self.unchanged and self.fetched == 0
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class CanaryReport:
41
+ probes: tuple[BoardProbe, ...] = field(default_factory=tuple)
42
+ skipped_platforms: tuple[str, ...] = field(default_factory=tuple)
43
+
44
+ @property
45
+ def failures(self) -> tuple[BoardProbe, ...]:
46
+ return tuple(probe for probe in self.probes if probe.is_failure)
47
+
48
+ @property
49
+ def empties(self) -> tuple[BoardProbe, ...]:
50
+ return tuple(probe for probe in self.probes if probe.is_empty)
51
+
52
+ @property
53
+ def unreachable(self) -> tuple[BoardProbe, ...]:
54
+ return tuple(probe for probe in self.probes if probe.is_unreachable)
55
+
56
+ @property
57
+ def passed(self) -> bool:
58
+ return not (self.failures or self.empties)
59
+
60
+
61
+ def _probe_key(company: Company) -> tuple[str, str]:
62
+ if company.platform is Platform.CUSTOM_JSON and company.custom is not None:
63
+ return (company.platform.value, company.custom.fmt)
64
+ return (company.platform.value, "")
65
+
66
+
67
+ def select_probes(
68
+ companies: Sequence[Company], *, exclude: frozenset[Platform] = AKAMAI_PLATFORMS
69
+ ) -> tuple[list[Company], tuple[str, ...]]:
70
+ chosen: dict[tuple[str, str], Company] = {}
71
+ for company in sorted(
72
+ (entry for entry in companies if entry.enabled),
73
+ key=lambda entry: entry.registry_key,
74
+ ):
75
+ if company.platform in exclude:
76
+ continue
77
+ chosen.setdefault(_probe_key(company), company)
78
+ return (
79
+ [chosen[key] for key in sorted(chosen)],
80
+ tuple(sorted(platform.value for platform in exclude)),
81
+ )
82
+
83
+
84
+ async def canary(
85
+ repository: AsyncRepository,
86
+ companies: Sequence[Company],
87
+ *,
88
+ exclude: frozenset[Platform] = AKAMAI_PLATFORMS,
89
+ ) -> CanaryReport:
90
+ from stage.services.sync import sync
91
+
92
+ selected, skipped = select_probes(companies, exclude=exclude)
93
+ probes: dict[tuple[str, str], BoardProbe] = {}
94
+
95
+ async for event in sync(repository, selected):
96
+ if isinstance(event, CompanyFinished):
97
+ probes[(event.source, event.company)] = BoardProbe(
98
+ source=event.source,
99
+ company=event.company,
100
+ fetched=event.fetched,
101
+ degraded=event.degraded,
102
+ )
103
+ elif isinstance(event, CompanyFailed):
104
+ probes[(event.source, event.company)] = BoardProbe(
105
+ source=event.source,
106
+ company=event.company,
107
+ error=event.error,
108
+ unreachable=event.unreachable,
109
+ )
110
+ elif isinstance(event, CompanyUnchanged):
111
+ probes[(event.source, event.company)] = BoardProbe(
112
+ source=event.source, company=event.company, unchanged=True
113
+ )
114
+
115
+ return CanaryReport(
116
+ probes=tuple(probes[key] for key in sorted(probes)), skipped_platforms=skipped
117
+ )
118
+
119
+
120
+ __all__ = ["AKAMAI_PLATFORMS", "BoardProbe", "CanaryReport", "canary", "select_probes"]
@@ -0,0 +1,231 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from dataclasses import dataclass
3
+ from datetime import UTC, datetime
4
+
5
+ from stage.companies import board_label
6
+ from stage.domain import (
7
+ STALE_AFTER_DAYS,
8
+ Company,
9
+ CoverageClassification,
10
+ CoverageRow,
11
+ CoverageState,
12
+ RejectionReason,
13
+ SourceVisit,
14
+ UnregisteredCompany,
15
+ coverage_state,
16
+ )
17
+ from stage.lexicon import fold
18
+ from stage.services.discover import GENERIC_TOKENS, name_matches, name_tokens
19
+ from stage.storage import AsyncRepository
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class CoverageReport:
24
+ rows: tuple[CoverageRow, ...]
25
+ unregistered: tuple[UnregisteredCompany, ...]
26
+ classifications: tuple[CoverageClassification, ...]
27
+ contradictions: tuple[tuple[CoverageClassification, str], ...]
28
+ enabled: int
29
+ disabled: int
30
+ stale_after_days: int
31
+
32
+ @property
33
+ def gaps(self) -> tuple[CoverageRow, ...]:
34
+ return tuple(row for row in self.rows if row.is_gap)
35
+
36
+ @property
37
+ def producing(self) -> tuple[CoverageRow, ...]:
38
+ return tuple(row for row in self.rows if row.state is CoverageState.PRODUCING)
39
+
40
+
41
+ def _board_key(company: Company) -> str | None:
42
+ from stage.sources import adapter_for_platform
43
+
44
+ adapter = adapter_for_platform(company.platform)
45
+ if adapter is None:
46
+ return None
47
+ try:
48
+ return adapter.board_key(company)
49
+ except Exception:
50
+ return None
51
+
52
+
53
+ def _registry_rows(
54
+ companies: Sequence[Company],
55
+ postings: dict[str, int],
56
+ visits: dict[tuple[str, str], SourceVisit],
57
+ now: datetime,
58
+ stale_after_days: int,
59
+ ) -> tuple[CoverageRow, ...]:
60
+ rows: list[CoverageRow] = []
61
+ for company in companies:
62
+ board = _board_key(company)
63
+ if board is None:
64
+ rows.append(
65
+ CoverageRow(
66
+ company=company.name,
67
+ platform=company.platform.value,
68
+ board=board_label(company),
69
+ state=CoverageState.UNROUTABLE,
70
+ postings=0,
71
+ last_success_at=None,
72
+ consecutive_failures=0,
73
+ last_error="",
74
+ )
75
+ )
76
+ continue
77
+ source, _, _ = board.partition(":")
78
+ visit = visits.get((source, board))
79
+ count = postings.get(board, 0)
80
+ rows.append(
81
+ CoverageRow(
82
+ company=company.name,
83
+ platform=company.platform.value,
84
+ board=board,
85
+ state=coverage_state(
86
+ count,
87
+ visit is not None,
88
+ visit.last_success_at if visit else None,
89
+ visit.consecutive_failures if visit else 0,
90
+ now,
91
+ stale_after_days,
92
+ ),
93
+ postings=count,
94
+ last_success_at=visit.last_success_at if visit else None,
95
+ consecutive_failures=visit.consecutive_failures if visit else 0,
96
+ last_error=visit.last_error if visit else "",
97
+ )
98
+ )
99
+ return tuple(sorted(rows, key=lambda row: (row.state.value, row.company.lower())))
100
+
101
+
102
+ INTERNSHIP_EVIDENCE_REASONS = frozenset(
103
+ {
104
+ RejectionReason.OUT_OF_SCOPE_DEGREE.value,
105
+ RejectionReason.NOT_A_CS_ROLE.value,
106
+ RejectionReason.UNKNOWN_CS_ROLE.value,
107
+ }
108
+ )
109
+
110
+
111
+ def _unregistered(
112
+ companies: Sequence[Company],
113
+ seen: dict[str, dict[str, int]],
114
+ rejected: dict[str, dict[str, int]],
115
+ reasons: dict[str, dict[str, int]],
116
+ classifications: Sequence[CoverageClassification],
117
+ ) -> tuple[UnregisteredCompany, ...]:
118
+ exact_names = {name_tokens(company.name) for company in companies}
119
+ index: dict[str, list[str]] = {}
120
+ for company in companies:
121
+ for token in name_tokens(company.name) - GENERIC_TOKENS:
122
+ index.setdefault(token, []).append(company.name)
123
+
124
+ unknown: list[UnregisteredCompany] = []
125
+ for name in {**rejected, **seen}:
126
+ tokens = name_tokens(name)
127
+ if tokens in exact_names:
128
+ continue
129
+ nearby = {
130
+ candidate for token in tokens - GENERIC_TOKENS for candidate in index.get(token, ())
131
+ }
132
+ if any(name_matches(candidate, name) for candidate in nearby):
133
+ continue
134
+ if any(name_matches(entry.company, name) for entry in classifications):
135
+ continue
136
+ sources = seen.get(name, {})
137
+ rejections = reasons.get(name, {})
138
+ unknown.append(
139
+ UnregisteredCompany(
140
+ company=name,
141
+ sources=tuple(sorted(sources or rejected.get(name, {}))),
142
+ postings=sum(sources.values()),
143
+ quarantined=sum(rejected.get(name, {}).values()),
144
+ posts_internships=any(
145
+ count > 0
146
+ for reason, count in rejections.items()
147
+ if reason in INTERNSHIP_EVIDENCE_REASONS
148
+ ),
149
+ )
150
+ )
151
+ return tuple(
152
+ sorted(
153
+ unknown,
154
+ key=lambda row: (
155
+ -row.postings,
156
+ not row.posts_internships,
157
+ -row.quarantined,
158
+ row.company.lower(),
159
+ ),
160
+ )
161
+ )
162
+
163
+
164
+ REVIEW_STALE_DAYS = 30
165
+
166
+
167
+ def _contradiction(
168
+ record: CoverageClassification,
169
+ registered: Mapping[str, Company],
170
+ moment: datetime,
171
+ ) -> str:
172
+ company = registered.get(fold(record.company))
173
+ if company is not None and company.enabled:
174
+ return f"the registry now polls it on {company.platform.value}"
175
+ if company is not None:
176
+ return f"a disabled {company.platform.value} row exists for it"
177
+ age = (moment - record.checked_on).days
178
+ if age >= REVIEW_STALE_DAYS:
179
+ return f"the verdict is {age} days old and nothing has re-derived it"
180
+ return ""
181
+
182
+
183
+ def contradicted_reviews(
184
+ records: Sequence[CoverageClassification],
185
+ companies: Sequence[Company],
186
+ *,
187
+ now: datetime | None = None,
188
+ ) -> tuple[tuple[CoverageClassification, str], ...]:
189
+ moment = now or datetime.now(UTC)
190
+ registered = {fold(company.name): company for company in companies}
191
+ found = [
192
+ (record, reason)
193
+ for record in records
194
+ if (reason := _contradiction(record, registered, moment))
195
+ ]
196
+ return tuple(sorted(found, key=lambda pair: pair[0].company.casefold()))
197
+
198
+
199
+ async def coverage(
200
+ repository: AsyncRepository,
201
+ companies: Sequence[Company],
202
+ *,
203
+ now: datetime | None = None,
204
+ stale_after_days: int = STALE_AFTER_DAYS,
205
+ unregistered: bool = False,
206
+ ) -> CoverageReport:
207
+ moment = now or datetime.now(UTC)
208
+ enabled = [company for company in companies if company.enabled]
209
+ postings = await repository.board_counts()
210
+ visits = {(visit.source, visit.board): visit for visit in await repository.all_visits()}
211
+
212
+ seen = await repository.company_counts() if unregistered else {}
213
+ rejected = await repository.quarantine_company_counts() if unregistered else {}
214
+ reasons = await repository.quarantine_company_reasons() if unregistered else {}
215
+ classifications = await repository.coverage_classifications()
216
+ return CoverageReport(
217
+ contradictions=contradicted_reviews(classifications, companies, now=moment),
218
+ rows=_registry_rows(enabled, postings, visits, moment, stale_after_days),
219
+ unregistered=(
220
+ _unregistered(companies, seen, rejected, reasons, classifications)
221
+ if unregistered
222
+ else ()
223
+ ),
224
+ classifications=tuple(classifications),
225
+ enabled=len(enabled),
226
+ disabled=len(companies) - len(enabled),
227
+ stale_after_days=stale_after_days,
228
+ )
229
+
230
+
231
+ __all__ = ["CoverageReport", "contradicted_reviews", "coverage"]