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
stage/classify/role.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
|
|
4
|
+
from stage.domain import RoleCategory
|
|
5
|
+
from stage.lexicon import fold, role_lexicon, source_role_categories
|
|
6
|
+
|
|
7
|
+
GENERAL_CS = RoleCategory.GENERAL_CS.value
|
|
8
|
+
SWE = RoleCategory.SWE.value
|
|
9
|
+
TECHNOLOGY_NOT_DISCIPLINE = frozenset({"gpu", "fpga", "soc", "asic", "rtos", "cuda"})
|
|
10
|
+
OUTRANKED_BY_A_LEADING_QUALIFIER = (SWE,)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@lru_cache(maxsize=1)
|
|
14
|
+
def _by_first_token() -> dict[str, tuple[tuple[str, str], ...]]:
|
|
15
|
+
index: dict[str, list[tuple[str, str]]] = {}
|
|
16
|
+
for category, phrases in role_lexicon().items():
|
|
17
|
+
for phrase in phrases:
|
|
18
|
+
index.setdefault(phrase.split(" ", 1)[0], []).append((category, phrase))
|
|
19
|
+
return {token: tuple(entries) for token, entries in index.items()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class RoleVerdict:
|
|
24
|
+
role: RoleCategory = RoleCategory.UNKNOWN
|
|
25
|
+
matched: tuple[str, ...] = ()
|
|
26
|
+
ambiguous: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _leading_specialist(folded: str, hits: dict[str, list[str]]) -> str | None:
|
|
30
|
+
outranked = [category for category in OUTRANKED_BY_A_LEADING_QUALIFIER if category in hits]
|
|
31
|
+
if not outranked or len(hits) < 2:
|
|
32
|
+
return None
|
|
33
|
+
if len(outranked) > 1:
|
|
34
|
+
return min(outranked, key=lambda c: min(folded.find(p) for p in hits[c]))
|
|
35
|
+
generic = min(folded.find(phrase) for category in outranked for phrase in hits[category])
|
|
36
|
+
ahead = {
|
|
37
|
+
category: min(
|
|
38
|
+
folded.find(phrase) for phrase in phrases if phrase not in TECHNOLOGY_NOT_DISCIPLINE
|
|
39
|
+
)
|
|
40
|
+
for category, phrases in hits.items()
|
|
41
|
+
if category not in outranked
|
|
42
|
+
and any(phrase not in TECHNOLOGY_NOT_DISCIPLINE for phrase in phrases)
|
|
43
|
+
}
|
|
44
|
+
ahead = {category: at for category, at in ahead.items() if 0 <= at < generic}
|
|
45
|
+
if len(ahead) != 1:
|
|
46
|
+
return None
|
|
47
|
+
return next(iter(ahead))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def classify_role(title: str, description: str = "", source_category: str = "") -> RoleVerdict:
|
|
51
|
+
index = _by_first_token()
|
|
52
|
+
declared = source_role_categories().get(fold(source_category)) if source_category else None
|
|
53
|
+
for text in (title, description):
|
|
54
|
+
folded = fold(text)
|
|
55
|
+
if not folded:
|
|
56
|
+
continue
|
|
57
|
+
padded = f" {folded} "
|
|
58
|
+
hits: dict[str, list[str]] = {}
|
|
59
|
+
for token in set(folded.split()):
|
|
60
|
+
for category, phrase in index.get(token, ()):
|
|
61
|
+
if f" {phrase} " in padded:
|
|
62
|
+
hits.setdefault(category, []).append(phrase)
|
|
63
|
+
if not hits:
|
|
64
|
+
continue
|
|
65
|
+
if len(hits) > 1:
|
|
66
|
+
hits.pop(GENERAL_CS, None)
|
|
67
|
+
hits = {
|
|
68
|
+
category: sorted(set(found), key=len, reverse=True) for category, found in hits.items()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
leading = _leading_specialist(folded, hits) if text is title else None
|
|
72
|
+
if leading is not None:
|
|
73
|
+
matched = tuple(sorted({phrase for phrases in hits.values() for phrase in phrases}))
|
|
74
|
+
return RoleVerdict(role=RoleCategory(leading), matched=matched)
|
|
75
|
+
|
|
76
|
+
best = max(len(phrases[0]) for phrases in hits.values())
|
|
77
|
+
winners = [category for category, phrases in hits.items() if len(phrases[0]) == best]
|
|
78
|
+
matched = tuple(sorted({phrase for phrases in hits.values() for phrase in phrases}))
|
|
79
|
+
if len(winners) != 1:
|
|
80
|
+
winner = min(
|
|
81
|
+
winners,
|
|
82
|
+
key=lambda category: (
|
|
83
|
+
min(folded.find(phrase) for phrase in hits[category] if len(phrase) == best),
|
|
84
|
+
category,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
return RoleVerdict(role=RoleCategory(winner), matched=matched)
|
|
88
|
+
return RoleVerdict(role=RoleCategory(winners[0]), matched=matched)
|
|
89
|
+
if declared is not None:
|
|
90
|
+
return RoleVerdict(role=RoleCategory(declared), matched=(fold(source_category),))
|
|
91
|
+
return RoleVerdict()
|
stage/classify/scope.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from stage.domain import Job, LocationBucket, QuarantinedJob, RejectionReason
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class Rejection:
|
|
8
|
+
reason: RejectionReason
|
|
9
|
+
matched_phrase: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def screen_is_internship(job: Job) -> Rejection | None:
|
|
13
|
+
from stage.classify.internship import screen_internship
|
|
14
|
+
|
|
15
|
+
verdict = screen_internship(job.title_raw, job.signals.employment_type, job.signals.category)
|
|
16
|
+
if verdict.is_internship:
|
|
17
|
+
return None
|
|
18
|
+
return Rejection(
|
|
19
|
+
reason=RejectionReason.NOT_AN_INTERNSHIP,
|
|
20
|
+
matched_phrase=verdict.disqualified_by or "no internship marker in title",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def screen_location(job: Job) -> Rejection | None:
|
|
25
|
+
if job.location is not LocationBucket.INTERNATIONAL:
|
|
26
|
+
return None
|
|
27
|
+
return Rejection(
|
|
28
|
+
reason=RejectionReason.OUT_OF_SCOPE_LOCATION,
|
|
29
|
+
matched_phrase=job.location_raw or "outside Canada and the United States",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_quarantined(job: Job, rejection: Rejection) -> QuarantinedJob:
|
|
34
|
+
return QuarantinedJob(
|
|
35
|
+
id=job.id,
|
|
36
|
+
source=job.source,
|
|
37
|
+
company=job.company,
|
|
38
|
+
title_raw=job.title_raw,
|
|
39
|
+
reason=rejection.reason,
|
|
40
|
+
first_seen=job.first_seen,
|
|
41
|
+
last_seen=job.last_seen,
|
|
42
|
+
apply_url_raw=job.apply_url_raw,
|
|
43
|
+
location_raw=job.location_raw,
|
|
44
|
+
location=job.location,
|
|
45
|
+
remote_scope=job.remote_scope,
|
|
46
|
+
matched_phrase=rejection.matched_phrase,
|
|
47
|
+
)
|
stage/cli/__init__.py
ADDED
|
File without changes
|
stage/cli/app.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from stage.cli.options import (
|
|
8
|
+
WORD,
|
|
9
|
+
DatabaseOption,
|
|
10
|
+
InvalidOptionError,
|
|
11
|
+
RegistryOption,
|
|
12
|
+
_adopt_unregistered,
|
|
13
|
+
_count,
|
|
14
|
+
_lock_path,
|
|
15
|
+
_parse_enum,
|
|
16
|
+
_print_failure,
|
|
17
|
+
_require_enum,
|
|
18
|
+
app,
|
|
19
|
+
run_async,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command(
|
|
24
|
+
help="Find the job-board platform a company uses", rich_help_panel="Registry and maintenance"
|
|
25
|
+
)
|
|
26
|
+
def discover(
|
|
27
|
+
companies: Annotated[
|
|
28
|
+
list[str] | None,
|
|
29
|
+
typer.Argument(metavar="NAME...", click_type=WORD, help="Company names to probe"),
|
|
30
|
+
] = None,
|
|
31
|
+
url: Annotated[
|
|
32
|
+
str | None,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--url",
|
|
35
|
+
metavar="URL",
|
|
36
|
+
help="Extract the platform from a careers URL without fetching that URL",
|
|
37
|
+
),
|
|
38
|
+
] = None,
|
|
39
|
+
name: Annotated[
|
|
40
|
+
str | None,
|
|
41
|
+
typer.Option("--name", metavar="NAME", help="Company name to show with --url"),
|
|
42
|
+
] = None,
|
|
43
|
+
platform: Annotated[
|
|
44
|
+
list[str] | None,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--platform", metavar="PLATFORM", help="Probe only these platform adapters; repeatable"
|
|
47
|
+
),
|
|
48
|
+
] = None,
|
|
49
|
+
exclude: Annotated[
|
|
50
|
+
list[str] | None,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--exclude",
|
|
53
|
+
metavar="NAME",
|
|
54
|
+
help="Skip these platform adapters while probing; repeatable",
|
|
55
|
+
),
|
|
56
|
+
] = None,
|
|
57
|
+
only: Annotated[
|
|
58
|
+
list[str] | None,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--company",
|
|
61
|
+
metavar="NAME",
|
|
62
|
+
help="With --verify, recheck only these registry companies; repeatable",
|
|
63
|
+
),
|
|
64
|
+
] = None,
|
|
65
|
+
expect_size: Annotated[
|
|
66
|
+
str | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--expect-size",
|
|
69
|
+
metavar="SIZE",
|
|
70
|
+
help="Optional employer-size hint: startup, mid, large",
|
|
71
|
+
),
|
|
72
|
+
] = None,
|
|
73
|
+
request_log: Annotated[
|
|
74
|
+
Path | None,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--request-log",
|
|
77
|
+
metavar="FILE",
|
|
78
|
+
help="Write outbound HTTP requests to this JSON Lines file",
|
|
79
|
+
),
|
|
80
|
+
] = None,
|
|
81
|
+
scheduled_progress: Annotated[
|
|
82
|
+
Path | None,
|
|
83
|
+
typer.Option("--scheduled-progress", hidden=True),
|
|
84
|
+
] = None,
|
|
85
|
+
verify: Annotated[
|
|
86
|
+
bool,
|
|
87
|
+
typer.Option(
|
|
88
|
+
"--verify",
|
|
89
|
+
help="Recheck existing registry companies",
|
|
90
|
+
),
|
|
91
|
+
] = False,
|
|
92
|
+
unregistered: Annotated[
|
|
93
|
+
bool,
|
|
94
|
+
typer.Option(
|
|
95
|
+
"--unregistered",
|
|
96
|
+
help="Probe companies found in feeds but missing from the registry",
|
|
97
|
+
),
|
|
98
|
+
] = False,
|
|
99
|
+
direct_only: Annotated[
|
|
100
|
+
bool,
|
|
101
|
+
typer.Option(
|
|
102
|
+
"--direct-only",
|
|
103
|
+
help="With --unregistered, verify only ATS links already present in feed applications",
|
|
104
|
+
),
|
|
105
|
+
] = False,
|
|
106
|
+
adopt_unnamed: Annotated[
|
|
107
|
+
bool,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--adopt-unnamed",
|
|
110
|
+
help="With --direct-only, also adopt boards whose platform publishes no board name",
|
|
111
|
+
),
|
|
112
|
+
] = False,
|
|
113
|
+
limit: Annotated[
|
|
114
|
+
int,
|
|
115
|
+
typer.Option(
|
|
116
|
+
"--limit",
|
|
117
|
+
metavar="N",
|
|
118
|
+
click_type=_count(1, 2000),
|
|
119
|
+
help="Maximum unregistered company names to probe",
|
|
120
|
+
),
|
|
121
|
+
] = 40,
|
|
122
|
+
show_all: Annotated[
|
|
123
|
+
bool,
|
|
124
|
+
typer.Option("--all", help="List every result instead of the first few"),
|
|
125
|
+
] = False,
|
|
126
|
+
db: DatabaseOption = None,
|
|
127
|
+
apply: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--apply",
|
|
131
|
+
help="Write results to the registry; requires --verify or --unregistered",
|
|
132
|
+
),
|
|
133
|
+
] = False,
|
|
134
|
+
registry: RegistryOption = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
from contextlib import ExitStack
|
|
137
|
+
|
|
138
|
+
from stage.cli.logfile import open_request_log
|
|
139
|
+
from stage.cli.render import failure, plain, render_discovery, terminal
|
|
140
|
+
from stage.cli.runlock import AnotherRunInProgressError, single_run
|
|
141
|
+
from stage.cli.schedule_state import ScheduleStateWriter
|
|
142
|
+
from stage.companies import RegistryError
|
|
143
|
+
from stage.companies import load_companies as load_registry
|
|
144
|
+
from stage.domain import DiscoveryEvent, DiscoveryFinished, EmployerSize, Platform
|
|
145
|
+
from stage.services.discover import NoMatchingCompanyError
|
|
146
|
+
|
|
147
|
+
console = terminal()
|
|
148
|
+
scheduled_state = (
|
|
149
|
+
ScheduleStateWriter.open(scheduled_progress, "discover")
|
|
150
|
+
if scheduled_progress is not None
|
|
151
|
+
else None
|
|
152
|
+
)
|
|
153
|
+
progress = scheduled_state.discovery_event if scheduled_state is not None else None
|
|
154
|
+
|
|
155
|
+
if url is not None and companies:
|
|
156
|
+
console.print("[red]Pass either company names or --url, not both.[/red]")
|
|
157
|
+
raise typer.Exit(code=2)
|
|
158
|
+
if verify and unregistered:
|
|
159
|
+
console.print("[red]Pass either --verify or --unregistered, not both.[/red]")
|
|
160
|
+
raise typer.Exit(code=2)
|
|
161
|
+
if direct_only and not unregistered:
|
|
162
|
+
console.print("[red]--direct-only requires --unregistered.[/red]")
|
|
163
|
+
raise typer.Exit(code=2)
|
|
164
|
+
if adopt_unnamed and not direct_only:
|
|
165
|
+
console.print(
|
|
166
|
+
"[red]--adopt-unnamed requires --direct-only[/red] — the token must come from an "
|
|
167
|
+
"apply URL."
|
|
168
|
+
)
|
|
169
|
+
raise typer.Exit(code=2)
|
|
170
|
+
if apply and not (verify or unregistered):
|
|
171
|
+
console.print("[red]--apply only means something with --verify or --unregistered.[/red]")
|
|
172
|
+
raise typer.Exit(code=2)
|
|
173
|
+
if platform and exclude:
|
|
174
|
+
console.print("[red]Pass either --platform or --exclude, not both.[/red]")
|
|
175
|
+
raise typer.Exit(code=2)
|
|
176
|
+
if not verify and not unregistered and url is None and not companies:
|
|
177
|
+
console.print(
|
|
178
|
+
"[red]Nothing to discover.[/red] Pass a company name, a careers page with "
|
|
179
|
+
"[bold]--url[/bold], or [bold]--verify[/bold] to re-probe the registry."
|
|
180
|
+
)
|
|
181
|
+
raise typer.Exit(code=2)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
size = _parse_enum(expect_size, EmployerSize, "--expect-size")
|
|
185
|
+
platforms = (
|
|
186
|
+
[_require_enum(value, Platform, "--platform") for value in platform]
|
|
187
|
+
if platform
|
|
188
|
+
else None
|
|
189
|
+
)
|
|
190
|
+
excluded = (
|
|
191
|
+
[_require_enum(value, Platform, "--exclude") for value in exclude] if exclude else None
|
|
192
|
+
)
|
|
193
|
+
except InvalidOptionError as exc:
|
|
194
|
+
_print_failure(exc)
|
|
195
|
+
raise typer.Exit(code=2) from exc
|
|
196
|
+
|
|
197
|
+
async def run() -> object:
|
|
198
|
+
from datetime import UTC, datetime
|
|
199
|
+
|
|
200
|
+
from stage.services.discover import probe_companies, resolve_careers_url
|
|
201
|
+
|
|
202
|
+
today = datetime.now(UTC).date()
|
|
203
|
+
with ExitStack() as stack:
|
|
204
|
+
stream = (
|
|
205
|
+
stack.enter_context(open_request_log(request_log))
|
|
206
|
+
if request_log is not None
|
|
207
|
+
else None
|
|
208
|
+
)
|
|
209
|
+
if verify:
|
|
210
|
+
from stage.companies import load_companies, update_registry
|
|
211
|
+
from stage.services.discover import apply_verification, verify_registry
|
|
212
|
+
|
|
213
|
+
rows = checked if checked is not None else load_companies(registry)
|
|
214
|
+
outcome = await render_discovery(
|
|
215
|
+
console,
|
|
216
|
+
verify_registry(rows, platforms=platforms, excluded=excluded, only=only),
|
|
217
|
+
verified_on=today,
|
|
218
|
+
request_log=stream,
|
|
219
|
+
collect=True,
|
|
220
|
+
progress=progress,
|
|
221
|
+
)
|
|
222
|
+
if apply and isinstance(outcome, DiscoveryFinished):
|
|
223
|
+
|
|
224
|
+
def update(
|
|
225
|
+
existing: tuple[Any, ...],
|
|
226
|
+
) -> tuple[tuple[Any, ...], tuple[int, int]]:
|
|
227
|
+
updated, ok, off = apply_verification(existing, outcome, today)
|
|
228
|
+
return updated, (ok, off)
|
|
229
|
+
|
|
230
|
+
target, (ok, off) = update_registry(update, registry)
|
|
231
|
+
console.print(
|
|
232
|
+
plain(
|
|
233
|
+
f"\napplied — {ok} row(s) verified, {off} disabled, written to {target}"
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return isinstance(outcome, DiscoveryFinished) and bool(outcome.matched)
|
|
237
|
+
if url is not None:
|
|
238
|
+
|
|
239
|
+
async def once() -> AsyncIterator[DiscoveryEvent]:
|
|
240
|
+
yield resolve_careers_url(url)
|
|
241
|
+
|
|
242
|
+
return await render_discovery(console, once(), display_name=name, progress=progress)
|
|
243
|
+
|
|
244
|
+
if unregistered:
|
|
245
|
+
adopted = await _adopt_unregistered(
|
|
246
|
+
console,
|
|
247
|
+
registry=registry,
|
|
248
|
+
db=db,
|
|
249
|
+
platforms=platforms,
|
|
250
|
+
excluded=excluded,
|
|
251
|
+
size=size,
|
|
252
|
+
limit=limit,
|
|
253
|
+
direct_only=direct_only,
|
|
254
|
+
adopt_unnamed=adopt_unnamed,
|
|
255
|
+
apply_rows=apply,
|
|
256
|
+
today=today,
|
|
257
|
+
stream=stream,
|
|
258
|
+
progress=progress,
|
|
259
|
+
show_all=show_all,
|
|
260
|
+
)
|
|
261
|
+
if scheduled_state is not None:
|
|
262
|
+
scheduled_state.heartbeat()
|
|
263
|
+
return adopted
|
|
264
|
+
|
|
265
|
+
events = probe_companies(
|
|
266
|
+
companies or [], platforms=platforms, excluded=excluded, size=size
|
|
267
|
+
)
|
|
268
|
+
return await render_discovery(
|
|
269
|
+
console, events, verified_on=today, request_log=stream, progress=progress
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
checked = None
|
|
273
|
+
if verify:
|
|
274
|
+
try:
|
|
275
|
+
checked = load_registry(registry)
|
|
276
|
+
except RegistryError as exc:
|
|
277
|
+
console.print(failure(exc))
|
|
278
|
+
raise typer.Exit(code=2) from exc
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
if url is not None:
|
|
282
|
+
resolved = run_async(run())
|
|
283
|
+
else:
|
|
284
|
+
with single_run("discover", _lock_path(db)):
|
|
285
|
+
resolved = run_async(run())
|
|
286
|
+
except AnotherRunInProgressError as exc:
|
|
287
|
+
if scheduled_state is not None:
|
|
288
|
+
scheduled_state.blocked(str(exc))
|
|
289
|
+
console.print(failure(exc))
|
|
290
|
+
raise typer.Exit(code=2) from exc
|
|
291
|
+
except (RegistryError, NoMatchingCompanyError) as exc:
|
|
292
|
+
console.print(failure(exc))
|
|
293
|
+
raise typer.Exit(code=2) from exc
|
|
294
|
+
raise typer.Exit(code=0 if resolved or unregistered else 1)
|