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,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)
|
stage/services/query.py
ADDED
|
@@ -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"]
|