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,225 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+ from datetime import UTC, datetime
4
+
5
+ from stage.dedup import resolve_duplicates
6
+ from stage.domain import IntegrityRepair, Job, PurgeResult, QuarantinedJob, RateState, RoleCategory
7
+ from stage.storage import AsyncRepository, SourceBatch
8
+
9
+ RESCREEN_LIMIT = 100_000
10
+ RESCREEN_PASSES = 8
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class RateStateView:
15
+ cleared: int
16
+ states: tuple[RateState, ...]
17
+ validators_cleared: int = 0
18
+
19
+
20
+ async def repair_integrity(repository: AsyncRepository) -> tuple[IntegrityRepair, ...]:
21
+ return tuple(await repository.repair_integrity())
22
+
23
+
24
+ async def purge_expired(repository: AsyncRepository, *, now: datetime | None = None) -> PurgeResult:
25
+ return await repository.purge(now or datetime.now(UTC))
26
+
27
+
28
+ async def preview_purge(repository: AsyncRepository, *, now: datetime | None = None) -> PurgeResult:
29
+ return await repository.preview_purge(now or datetime.now(UTC))
30
+
31
+
32
+ async def rate_state(
33
+ repository: AsyncRepository,
34
+ *,
35
+ bucket: str | None = None,
36
+ clear_all: bool = False,
37
+ clear_cache: str | None = None,
38
+ clear_cache_all: bool = False,
39
+ ) -> RateStateView:
40
+ cleared = 0
41
+ if clear_all:
42
+ cleared = await repository.clear_rate_state()
43
+ elif bucket is not None:
44
+ cleared = await repository.clear_rate_state(bucket)
45
+ validators = 0
46
+ if clear_cache_all:
47
+ validators = await repository.clear_validators()
48
+ elif clear_cache is not None:
49
+ validators = await repository.clear_validators(clear_cache)
50
+ states = await repository.load_rate_state()
51
+ return RateStateView(
52
+ cleared=cleared, states=tuple(states.values()), validators_cleared=validators
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class RescreenResult:
58
+ examined: int
59
+ quarantined: int
60
+ total: int = 0
61
+ updated: int = 0
62
+ released: int = 0
63
+ relabelled: int = 0
64
+ relocated: int = 0
65
+
66
+ @property
67
+ def changed(self) -> bool:
68
+ return bool(
69
+ self.updated or self.quarantined or self.released or self.relabelled or self.relocated
70
+ )
71
+
72
+ @property
73
+ def skipped(self) -> int:
74
+ return max(0, self.total - self.examined)
75
+
76
+
77
+ async def rescreen(repository: AsyncRepository, *, now: datetime | None = None) -> RescreenResult:
78
+ from stage.domain import JobFilters
79
+
80
+ moment = now or datetime.now(UTC)
81
+ examined = 0
82
+ quarantined = 0
83
+ updated = 0
84
+ total = await repository.count_jobs(JobFilters(status=None, limit=RESCREEN_LIMIT))
85
+
86
+ for pass_number in range(RESCREEN_PASSES):
87
+ stored = await repository.list_jobs(JobFilters(status=None, limit=RESCREEN_LIMIT))
88
+ if not pass_number:
89
+ examined = len(stored)
90
+ candidates, rejected = _reclassifications(stored) if stored else ((), ())
91
+ changed = tuple(
92
+ candidate for job, candidate in zip(stored, candidates, strict=True) if job != candidate
93
+ )
94
+ if not changed and not rejected:
95
+ break
96
+ rejected_ids = {entry.id for entry in rejected}
97
+ updates = tuple(entry for entry in changed if entry.id not in rejected_ids)
98
+ sources = {entry.source for entry in updates} | {entry.source for entry in rejected}
99
+ for source in sorted(sources):
100
+ await repository.apply_source_batch(
101
+ SourceBatch(
102
+ source=source,
103
+ run_started_at=moment,
104
+ jobs=tuple(entry for entry in updates if entry.source == source),
105
+ quarantined=tuple(entry for entry in rejected if entry.source == source),
106
+ )
107
+ )
108
+ quarantined += len(rejected)
109
+ updated += len(updates)
110
+ released, relabelled = await _release_reclassified_quarantine(repository, moment)
111
+ relocated = await repository.refresh_quarantine_locations(_resolved_place)
112
+ return RescreenResult(
113
+ examined=examined,
114
+ quarantined=quarantined,
115
+ total=total,
116
+ updated=updated,
117
+ released=released,
118
+ relabelled=relabelled,
119
+ relocated=relocated,
120
+ )
121
+
122
+
123
+ def _resolved_place(location_raw: str) -> tuple[str, str | None]:
124
+ from stage.normalize import resolve_location
125
+
126
+ resolved = resolve_location(location_raw)
127
+ scope = resolved.remote_scope
128
+ return resolved.bucket.value, None if scope is None else scope.value
129
+
130
+
131
+ def _reclassifications(
132
+ jobs: Sequence[Job],
133
+ ) -> tuple[tuple[Job, ...], tuple[QuarantinedJob, ...]]:
134
+ from dataclasses import replace
135
+
136
+ from stage.classify import (
137
+ classify_role,
138
+ screen_degree_scope,
139
+ screen_is_cs_role,
140
+ screen_is_internship,
141
+ screen_location,
142
+ )
143
+ from stage.classify.scope import to_quarantined
144
+ from stage.normalize import canonical_apply_url, resolve_location
145
+
146
+ candidates: list[Job] = []
147
+ rejected: list[QuarantinedJob] = []
148
+ for job in jobs:
149
+ location = resolve_location(job.location_raw)
150
+ title_role = classify_role(job.title_raw, job.description).role
151
+ candidate = replace(
152
+ job,
153
+ apply_url_canonical=canonical_apply_url(job.apply_url_raw),
154
+ location=location.bucket,
155
+ remote_scope=location.remote_scope,
156
+ role=title_role if title_role is not RoleCategory.UNKNOWN else job.role,
157
+ )
158
+ candidates.append(candidate)
159
+ rejection = (
160
+ screen_is_internship(candidate)
161
+ or screen_location(candidate)
162
+ or screen_degree_scope(candidate)
163
+ or screen_is_cs_role(candidate)
164
+ )
165
+ if rejection is not None:
166
+ rejected.append(to_quarantined(candidate, rejection))
167
+ return tuple(candidates), tuple(rejected)
168
+
169
+
170
+ async def _release_reclassified_quarantine(
171
+ repository: AsyncRepository,
172
+ moment: datetime,
173
+ ) -> tuple[int, int]:
174
+ from stage.domain import QuarantineFilters, RejectionReason
175
+ from stage.lexicon import fold
176
+ from stage.services.sync import normalize_batch
177
+
178
+ remaining = RESCREEN_LIMIT
179
+ entries: list[QuarantinedJob] = []
180
+ for reason in (
181
+ RejectionReason.UNKNOWN_CS_ROLE,
182
+ RejectionReason.NOT_A_CS_ROLE,
183
+ RejectionReason.OUT_OF_SCOPE_LOCATION,
184
+ ):
185
+ if not remaining:
186
+ break
187
+ found = await repository.list_quarantined(QuarantineFilters(reason=reason, limit=remaining))
188
+ entries.extend(found)
189
+ remaining -= len(found)
190
+ candidates = tuple(
191
+ Job(
192
+ id=entry.id,
193
+ source=entry.source,
194
+ company=entry.company,
195
+ title_raw=entry.title_raw,
196
+ title_normalized=fold(entry.title_raw),
197
+ apply_url_raw=entry.apply_url_raw,
198
+ description="",
199
+ first_seen=entry.first_seen,
200
+ last_seen=entry.last_seen,
201
+ location_raw=entry.location_raw,
202
+ location=entry.location,
203
+ remote_scope=entry.remote_scope,
204
+ )
205
+ for entry in entries
206
+ )
207
+ kept, rejected = normalize_batch(candidates)
208
+ known = {entry.id: entry for entry in entries}
209
+ sharpened = tuple(
210
+ entry
211
+ for entry in rejected
212
+ if (previous := known.get(entry.id)) is not None
213
+ and (previous.reason, previous.matched_phrase) != (entry.reason, entry.matched_phrase)
214
+ )
215
+ relabelled = await repository.relabel_quarantine(sharpened)
216
+ for source in sorted({entry.source for entry in kept}):
217
+ await repository.apply_source_batch(
218
+ SourceBatch(
219
+ source=source,
220
+ run_started_at=moment,
221
+ jobs=tuple(entry for entry in kept if entry.source == source),
222
+ resolve_duplicates=resolve_duplicates,
223
+ )
224
+ )
225
+ return len(kept), relabelled
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+
3
+ from stage.domain import QuarantinedJob, QuarantineFilters
4
+ from stage.storage import AsyncRepository
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class QuarantineListing:
9
+ entries: tuple[QuarantinedJob, ...]
10
+ total_matching: int
11
+ reason_counts: dict[str, int]
12
+
13
+
14
+ async def list_quarantined(
15
+ repository: AsyncRepository, filters: QuarantineFilters
16
+ ) -> QuarantineListing:
17
+ entries = await repository.list_quarantined(filters)
18
+ total = await repository.count_quarantined(filters)
19
+ counts = await repository.quarantine_reason_counts()
20
+ return QuarantineListing(entries=tuple(entries), total_matching=total, reason_counts=counts)
@@ -0,0 +1,86 @@
1
+ from dataclasses import dataclass, replace
2
+ from datetime import UTC, datetime, timedelta
3
+
4
+ from stage.domain import DEFAULT_WINDOW_DAYS, Job, JobFilters
5
+ from stage.storage import AsyncRepository
6
+ from stage.storage.search import search_terms
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class JobListing:
11
+ jobs: tuple[Job, ...]
12
+ total_matching: int
13
+ window_days: int | None
14
+ last_sync_at: datetime | None
15
+ query: str = ""
16
+ terms: tuple[str, ...] = ()
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class PostingDetail:
21
+ job: Job
22
+ duplicates: tuple[Job, ...]
23
+ canonical: Job | None
24
+
25
+
26
+ def _windowed(filters: JobFilters, window_days: int | None, now: datetime | None) -> JobFilters:
27
+ if window_days is None or filters.first_seen_after is not None:
28
+ return filters
29
+ cutoff = (now or datetime.now(UTC)) - timedelta(days=window_days)
30
+ return replace(filters, first_seen_after=cutoff)
31
+
32
+
33
+ async def list_jobs(
34
+ repository: AsyncRepository,
35
+ filters: JobFilters,
36
+ *,
37
+ window_days: int | None = DEFAULT_WINDOW_DAYS,
38
+ now: datetime | None = None,
39
+ ) -> JobListing:
40
+ effective = _windowed(filters, window_days, now)
41
+ jobs = await repository.list_jobs(effective)
42
+ total = await repository.count_jobs(effective)
43
+ last_sync = await repository.last_sync_at()
44
+ return JobListing(
45
+ jobs=tuple(jobs),
46
+ total_matching=total,
47
+ window_days=window_days,
48
+ last_sync_at=last_sync,
49
+ )
50
+
51
+
52
+ async def search_jobs(
53
+ repository: AsyncRepository,
54
+ query: str,
55
+ filters: JobFilters,
56
+ *,
57
+ window_days: int | None = None,
58
+ now: datetime | None = None,
59
+ ) -> JobListing:
60
+ effective = _windowed(filters, window_days, now)
61
+ terms = search_terms(query)
62
+ jobs = await repository.search_jobs(query, effective) if terms else []
63
+ total = await repository.count_search(query, effective) if terms else 0
64
+ return JobListing(
65
+ jobs=tuple(jobs),
66
+ total_matching=total,
67
+ window_days=window_days,
68
+ last_sync_at=await repository.last_sync_at(),
69
+ query=query,
70
+ terms=terms,
71
+ )
72
+
73
+
74
+ async def get_posting(repository: AsyncRepository, job_id: str) -> PostingDetail | None:
75
+ job = await repository.get_job(job_id)
76
+ if job is None:
77
+ return None
78
+ canonical = await repository.get_job(job.duplicate_of) if job.duplicate_of is not None else None
79
+ return PostingDetail(
80
+ job=job,
81
+ duplicates=tuple(await repository.duplicates_of(job.id)),
82
+ canonical=canonical,
83
+ )
84
+
85
+
86
+ __all__ = ["JobListing", "PostingDetail", "get_posting", "list_jobs", "search_jobs"]