atsfill 0.0.3.dev4__tar.gz → 0.0.3.dev6__tar.gz
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.
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/PKG-INFO +8 -3
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/README.md +7 -2
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/cli.py +2 -1
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/browser.py +4 -2
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/commands.py +60 -25
- atsfill-0.0.3.dev6/src/atsfill/core/registry.py +215 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/results.py +7 -1
- atsfill-0.0.3.dev6/src/atsfill/registry.toml +41 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/LICENSE +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.3.
|
|
3
|
+
Version: 0.0.3.dev6
|
|
4
4
|
Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
|
|
5
5
|
Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
|
|
6
6
|
Author: shbernal
|
|
@@ -52,6 +52,10 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
52
52
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
|
+
<!-- badges -->
|
|
56
|
+
   
|
|
57
|
+
<!-- /badges -->
|
|
58
|
+
|
|
55
59
|
| System | Account needed | `read` without a browser |
|
|
56
60
|
| --- | --- | --- |
|
|
57
61
|
| Ashby | no | yes |
|
|
@@ -124,7 +128,8 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
|
|
|
124
128
|
```
|
|
125
129
|
|
|
126
130
|
A dry run is not invisible: some systems save answers or the resume as they are typed.
|
|
127
|
-
`atsfill status` lists each system's side effects
|
|
131
|
+
`atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
|
|
132
|
+
A `send` through an adapter no send was verified on goes ahead with a `warning`.
|
|
128
133
|
|
|
129
134
|
## How it works
|
|
130
135
|
|
|
@@ -147,7 +152,7 @@ The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error
|
|
|
147
152
|
## More
|
|
148
153
|
|
|
149
154
|
- [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
|
|
150
|
-
- [Hiring systems](docs/adapters/): each
|
|
155
|
+
- [Hiring systems](docs/adapters/README.md): every system mapped so far, in backlog order, and each one's form as observed.
|
|
151
156
|
- `atsfill --help` for every command and option.
|
|
152
157
|
|
|
153
158
|
[pypi]: https://pypi.org/project/atsfill/
|
|
@@ -28,6 +28,10 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
28
28
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
|
+
<!-- badges -->
|
|
32
|
+
   
|
|
33
|
+
<!-- /badges -->
|
|
34
|
+
|
|
31
35
|
| System | Account needed | `read` without a browser |
|
|
32
36
|
| --- | --- | --- |
|
|
33
37
|
| Ashby | no | yes |
|
|
@@ -100,7 +104,8 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
|
|
|
100
104
|
```
|
|
101
105
|
|
|
102
106
|
A dry run is not invisible: some systems save answers or the resume as they are typed.
|
|
103
|
-
`atsfill status` lists each system's side effects
|
|
107
|
+
`atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
|
|
108
|
+
A `send` through an adapter no send was verified on goes ahead with a `warning`.
|
|
104
109
|
|
|
105
110
|
## How it works
|
|
106
111
|
|
|
@@ -123,7 +128,7 @@ The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error
|
|
|
123
128
|
## More
|
|
124
129
|
|
|
125
130
|
- [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
|
|
126
|
-
- [Hiring systems](docs/adapters/): each
|
|
131
|
+
- [Hiring systems](docs/adapters/README.md): every system mapped so far, in backlog order, and each one's form as observed.
|
|
127
132
|
- `atsfill --help` for every command and option.
|
|
128
133
|
|
|
129
134
|
[pypi]: https://pypi.org/project/atsfill/
|
|
@@ -103,7 +103,8 @@ def run(args: argparse.Namespace) -> int:
|
|
|
103
103
|
case "status":
|
|
104
104
|
report = core.status(data_dir=args.data_dir)
|
|
105
105
|
emit(report)
|
|
106
|
-
|
|
106
|
+
signed_out = any(a.session == "signed_out" for a in report.adapters)
|
|
107
|
+
return 1 if signed_out or report.browser != report.browser_pinned else 0
|
|
107
108
|
case "install-browser":
|
|
108
109
|
return browser.install()
|
|
109
110
|
case "login":
|
|
@@ -22,6 +22,7 @@ from playwright.sync_api import Error as BrowserError
|
|
|
22
22
|
from playwright.sync_api import TimeoutError as Timeout
|
|
23
23
|
|
|
24
24
|
__all__ = [
|
|
25
|
+
"BUILD",
|
|
25
26
|
"BrowserError",
|
|
26
27
|
"Covered",
|
|
27
28
|
"Locator",
|
|
@@ -43,6 +44,7 @@ __all__ = [
|
|
|
43
44
|
MIN_SCREEN_HEIGHT = 1050 # leaves the window the 900px Greenhouse and Teamtailor need
|
|
44
45
|
HOST_OS = {"Linux": "linux", "Darwin": "macos", "Windows": "windows"}[platform.system()]
|
|
45
46
|
BUDGET = 180.0 # seconds per command, lock wait included
|
|
47
|
+
BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
class OutOfTime(Exception):
|
|
@@ -62,8 +64,8 @@ def version() -> str:
|
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
def install() -> int:
|
|
65
|
-
"""Fetches the browser build
|
|
66
|
-
return subprocess.call([sys.executable, "-m", "camoufox", "fetch"])
|
|
67
|
+
"""Fetches the pinned browser build."""
|
|
68
|
+
return subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
|
|
67
69
|
|
|
68
70
|
|
|
69
71
|
def identity(data_dir: Path) -> dict:
|
|
@@ -4,7 +4,7 @@ from importlib.metadata import version
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
from atsfill.adapters import ADAPTERS
|
|
7
|
-
from atsfill.core import accounts, attempts, browser, matching, paths
|
|
7
|
+
from atsfill.core import accounts, attempts, browser, matching, paths, registry
|
|
8
8
|
from atsfill.core.adapter import Adapter, Blocked, NeedsAccount, Unsupported
|
|
9
9
|
from atsfill.core.browser import Covered, OutOfTime, Page, open_page
|
|
10
10
|
from atsfill.core.results import (
|
|
@@ -30,12 +30,14 @@ def _login_required(url: str, adapter: Adapter) -> Result:
|
|
|
30
30
|
|
|
31
31
|
@contextmanager
|
|
32
32
|
def _opened(
|
|
33
|
-
data: Path, url: str, sessions: Sessions | None
|
|
33
|
+
data: Path, url: str, sessions: Sessions | None, detected: list[str]
|
|
34
34
|
) -> Generator[tuple[Page, Adapter] | Result]:
|
|
35
35
|
"""The loaded page and the adapter that claims it by its URL, else by its page; or the
|
|
36
36
|
result that ends the command first. An adapter that needs an account gets its session,
|
|
37
|
-
and only that adapter's (ADR 0007)."""
|
|
37
|
+
and only that adapter's (ADR 0007). The adapter's name goes in `detected` once known."""
|
|
38
38
|
adapter = _detect(url)
|
|
39
|
+
if adapter:
|
|
40
|
+
detected.append(adapter.name)
|
|
39
41
|
session = None
|
|
40
42
|
if isinstance(adapter, NeedsAccount):
|
|
41
43
|
session = (sessions or {}).get(adapter.name) or accounts.session_file(data, adapter.name)
|
|
@@ -44,7 +46,8 @@ def _opened(
|
|
|
44
46
|
return
|
|
45
47
|
with open_page(data, session) as page:
|
|
46
48
|
page.goto(url)
|
|
47
|
-
adapter
|
|
49
|
+
if adapter is None and (adapter := _detect(url, page)):
|
|
50
|
+
detected.append(adapter.name)
|
|
48
51
|
if adapter is None:
|
|
49
52
|
yield _no_adapter(url)
|
|
50
53
|
elif isinstance(adapter, NeedsAccount) and not (
|
|
@@ -77,16 +80,18 @@ def _stops(result: Result) -> Generator[None]:
|
|
|
77
80
|
result.status, result.reason = Status.BLOCKED, f"covered: {e}"
|
|
78
81
|
|
|
79
82
|
|
|
80
|
-
def _in_budget(url: str, command: Callable[[], Result]) -> Result:
|
|
83
|
+
def _in_budget(url: str, command: Callable[[list[str]], Result]) -> Result:
|
|
81
84
|
"""Runs a command to a status whatever happens: past the click it reports `unknown` itself,
|
|
82
|
-
before it an error the adapter did not expect means the page is not one it handles.
|
|
85
|
+
before it an error the adapter did not expect means the page is not one it handles. The
|
|
86
|
+
result keeps the adapter detected before the stop."""
|
|
87
|
+
detected: list[str] = []
|
|
83
88
|
try:
|
|
84
|
-
return command()
|
|
89
|
+
return command(detected)
|
|
85
90
|
except OutOfTime as e:
|
|
86
|
-
return Result(Status.BLOCKED, url,
|
|
91
|
+
return Result(Status.BLOCKED, url, (detected or [None])[-1], f"time_budget: {e}")
|
|
87
92
|
except Exception as e:
|
|
88
93
|
reason = f"adapter_error: {type(e).__name__}: {e}"
|
|
89
|
-
return Result(Status.UNSUPPORTED, url, reason
|
|
94
|
+
return Result(Status.UNSUPPORTED, url, (detected or [None])[-1], reason)
|
|
90
95
|
|
|
91
96
|
|
|
92
97
|
def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer]) -> Result:
|
|
@@ -104,8 +109,8 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer]) ->
|
|
|
104
109
|
return result
|
|
105
110
|
|
|
106
111
|
|
|
107
|
-
def _read_page(data: Path, url: str, sessions: Sessions | None) -> Result:
|
|
108
|
-
with _opened(data, url, sessions) as opened:
|
|
112
|
+
def _read_page(data: Path, url: str, sessions: Sessions | None, detected: list[str]) -> Result:
|
|
113
|
+
with _opened(data, url, sessions, detected) as opened:
|
|
109
114
|
if isinstance(opened, Result):
|
|
110
115
|
return opened
|
|
111
116
|
page, adapter = opened
|
|
@@ -122,8 +127,9 @@ def _form_command(
|
|
|
122
127
|
sessions: Sessions | None,
|
|
123
128
|
send: bool,
|
|
124
129
|
resend: bool,
|
|
130
|
+
detected: list[str],
|
|
125
131
|
) -> Result:
|
|
126
|
-
with _opened(data, url, sessions) as opened:
|
|
132
|
+
with _opened(data, url, sessions, detected) as opened:
|
|
127
133
|
if isinstance(opened, Result):
|
|
128
134
|
return opened
|
|
129
135
|
page, adapter = opened
|
|
@@ -138,6 +144,7 @@ def _form_command(
|
|
|
138
144
|
result = _fill(page, adapter, url, answers)
|
|
139
145
|
if not send or result.status is not Status.READY:
|
|
140
146
|
return result
|
|
147
|
+
result.warning = _unverified(adapter.name)
|
|
141
148
|
attempts.record(data, posting, attempts.PENDING, url=url)
|
|
142
149
|
try:
|
|
143
150
|
result.evidence = adapter.submit(page)
|
|
@@ -164,7 +171,7 @@ def read(url: str, *, data_dir: Path | None = None, sessions: Sessions | None =
|
|
|
164
171
|
result.fields = fields
|
|
165
172
|
return result
|
|
166
173
|
data = paths.data_dir(data_dir)
|
|
167
|
-
return _in_budget(url, lambda: _read_page(data, url, sessions))
|
|
174
|
+
return _in_budget(url, lambda seen: _read_page(data, url, sessions, seen))
|
|
168
175
|
|
|
169
176
|
|
|
170
177
|
def fill(
|
|
@@ -176,7 +183,7 @@ def fill(
|
|
|
176
183
|
) -> Result:
|
|
177
184
|
data = paths.data_dir(data_dir)
|
|
178
185
|
return _in_budget(
|
|
179
|
-
url, lambda: _form_command(data, url, answers, sessions,
|
|
186
|
+
url, lambda seen: _form_command(data, url, answers, sessions, False, False, seen)
|
|
180
187
|
)
|
|
181
188
|
|
|
182
189
|
|
|
@@ -190,10 +197,22 @@ def send(
|
|
|
190
197
|
) -> Result:
|
|
191
198
|
data = paths.data_dir(data_dir)
|
|
192
199
|
return _in_budget(
|
|
193
|
-
url, lambda: _form_command(data, url, answers, sessions,
|
|
200
|
+
url, lambda seen: _form_command(data, url, answers, sessions, True, resend, seen)
|
|
194
201
|
)
|
|
195
202
|
|
|
196
203
|
|
|
204
|
+
def _unverified(adapter: str) -> str | None:
|
|
205
|
+
"""The warning a send carries when no send through the adapter was verified, or the last
|
|
206
|
+
verified one fails now (ADR 0012)."""
|
|
207
|
+
system = registry.for_adapter(adapter)
|
|
208
|
+
if system and system.send_broken:
|
|
209
|
+
return f"send_broken: {_broken(system)}"
|
|
210
|
+
if system and system.send_verified:
|
|
211
|
+
return None
|
|
212
|
+
state = system.state if system else None
|
|
213
|
+
return f"not_send_verified: {adapter} is {state or 'not in the registry'}"
|
|
214
|
+
|
|
215
|
+
|
|
197
216
|
def _session_state(adapter: Adapter, data: Path) -> str | None:
|
|
198
217
|
if not isinstance(adapter, NeedsAccount):
|
|
199
218
|
return None
|
|
@@ -208,16 +227,32 @@ def status(*, data_dir: Path | None = None) -> StatusReport:
|
|
|
208
227
|
data_dir=data,
|
|
209
228
|
config=paths.config_file(),
|
|
210
229
|
browser=browser.version(),
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
230
|
+
browser_pinned=browser.BUILD,
|
|
231
|
+
adapters=[_adapter_state(a, data) for a in ADAPTERS],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _broken(system: registry.System) -> str:
|
|
236
|
+
broken, last = system.send_broken, system.send_verified
|
|
237
|
+
assert broken
|
|
238
|
+
since = f"send broken since {broken.date} ({broken.version}), {broken.reason}"
|
|
239
|
+
return since + (f"; last verified {last.date} ({last.version})" if last else "")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _adapter_state(adapter: Adapter, data: Path) -> AdapterState:
|
|
243
|
+
system = registry.for_adapter(adapter.name)
|
|
244
|
+
verified = system.state if system else None
|
|
245
|
+
v = getattr(system, verified) if system and verified else None
|
|
246
|
+
return AdapterState(
|
|
247
|
+
adapter.name,
|
|
248
|
+
submit_enabled=paths.submit_enabled(adapter.name),
|
|
249
|
+
side_effects=adapter.side_effects,
|
|
250
|
+
side_effects_acknowledged=paths.acknowledged(adapter.name),
|
|
251
|
+
verified=verified,
|
|
252
|
+
verified_version=v.version if v else None,
|
|
253
|
+
verified_release=registry.release(v.version, version("atsfill")) if v else None,
|
|
254
|
+
send_broken=_broken(system) if system and system.send_broken else None,
|
|
255
|
+
session=_session_state(adapter, data),
|
|
221
256
|
)
|
|
222
257
|
|
|
223
258
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The committed registry of hiring systems (ADR 0012)."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, fields
|
|
5
|
+
from datetime import date
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
|
|
9
|
+
KINDS = ("ats", "board", "aggregator", "custom")
|
|
10
|
+
ACCOUNTS = ("none", "to_send", "to_read") # also the backlog order (ADR 0013)
|
|
11
|
+
HOSTING = ("hosted", "embedded")
|
|
12
|
+
VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "unknown")
|
|
13
|
+
STEPS = ("posting", "form", "upload")
|
|
14
|
+
STATES = ("mapped", "dry_run_verified", "send_verified")
|
|
15
|
+
CAUSES = ("system", "code", "unknown")
|
|
16
|
+
RELEASES = ("this_release", "older", "newer")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Verification:
|
|
21
|
+
date: date
|
|
22
|
+
version: str # the release, compared with the installed one
|
|
23
|
+
by: str
|
|
24
|
+
commit: str | None = None # the exact code
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Broken:
|
|
29
|
+
"""A send that worked fails now (ADR 0012); `reason` names fields and steps, never postings."""
|
|
30
|
+
|
|
31
|
+
date: date
|
|
32
|
+
version: str
|
|
33
|
+
commit: str
|
|
34
|
+
by: str
|
|
35
|
+
reason: str
|
|
36
|
+
cause: str = "unknown"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Block:
|
|
41
|
+
vendor: str
|
|
42
|
+
signal: str
|
|
43
|
+
step: str
|
|
44
|
+
date: date
|
|
45
|
+
version: str
|
|
46
|
+
camoufox: str
|
|
47
|
+
browser: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class System:
|
|
52
|
+
source: str
|
|
53
|
+
kind: str
|
|
54
|
+
account: str
|
|
55
|
+
hosting: tuple[str, ...]
|
|
56
|
+
adapter: str | None = None
|
|
57
|
+
share: float | None = None
|
|
58
|
+
block: Block | None = None
|
|
59
|
+
mapped: Verification | None = None
|
|
60
|
+
dry_run_verified: Verification | None = None
|
|
61
|
+
send_verified: Verification | None = None # the last known good, kept through a break
|
|
62
|
+
send_broken: Broken | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def state(self) -> str | None:
|
|
66
|
+
"""The furthest state reached; each one implies the ones before it."""
|
|
67
|
+
return next((s for s in reversed(STATES) if getattr(self, s)), None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _one_of(source: str, key: str, value: object, allowed: tuple[str, ...]) -> None:
|
|
71
|
+
if value not in allowed:
|
|
72
|
+
raise ValueError(f"{source}: {key} = {value!r}, expected one of {', '.join(allowed)}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _system(source: str, entry: dict) -> System:
|
|
76
|
+
known = {f.name for f in fields(System)} - {"source"}
|
|
77
|
+
if unknown := entry.keys() - known:
|
|
78
|
+
raise ValueError(f"{source}: unknown keys {', '.join(sorted(unknown))}")
|
|
79
|
+
_one_of(source, "kind", entry.get("kind"), KINDS)
|
|
80
|
+
_one_of(source, "account", entry.get("account"), ACCOUNTS)
|
|
81
|
+
for h in entry.get("hosting", []):
|
|
82
|
+
_one_of(source, "hosting", h, HOSTING)
|
|
83
|
+
block = entry.get("block")
|
|
84
|
+
if block is not None:
|
|
85
|
+
_one_of(source, "block.vendor", block.get("vendor"), VENDORS)
|
|
86
|
+
_one_of(source, "block.step", block.get("step"), STEPS)
|
|
87
|
+
block = Block(**block)
|
|
88
|
+
states = {s: Verification(**entry[s]) for s in STATES if s in entry}
|
|
89
|
+
broken = entry.get("send_broken")
|
|
90
|
+
if broken is not None:
|
|
91
|
+
_one_of(source, "send_broken.cause", broken.get("cause", "unknown"), CAUSES)
|
|
92
|
+
broken = Broken(**broken)
|
|
93
|
+
return System(
|
|
94
|
+
source,
|
|
95
|
+
entry["kind"],
|
|
96
|
+
entry["account"],
|
|
97
|
+
tuple(entry.get("hosting", [])),
|
|
98
|
+
entry.get("adapter"),
|
|
99
|
+
entry.get("share"),
|
|
100
|
+
block,
|
|
101
|
+
**states,
|
|
102
|
+
send_broken=broken,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse(text: str) -> dict[str, System]:
|
|
107
|
+
return {source: _system(source, entry) for source, entry in tomllib.loads(text).items()}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@cache
|
|
111
|
+
def systems() -> dict[str, System]:
|
|
112
|
+
return parse(files("atsfill").joinpath("registry.toml").read_text())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _toml(value: object) -> str:
|
|
116
|
+
return str(value) if isinstance(value, date) else f'"{value}"'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def with_state(text: str, source: str, key: str, value: Verification | Broken) -> str:
|
|
120
|
+
"""The registry file's text with `key` of `source` set to `value`, the rest untouched.
|
|
121
|
+
Writing `send_verified` ends a `send_broken`."""
|
|
122
|
+
lines = text.splitlines()
|
|
123
|
+
start = lines.index(f"[{source}]")
|
|
124
|
+
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("[")), len(lines))
|
|
125
|
+
while lines[end - 1] == "":
|
|
126
|
+
end -= 1
|
|
127
|
+
gone = {key, "send_broken"} if key == "send_verified" else {key}
|
|
128
|
+
entry = lines[start:end]
|
|
129
|
+
at = next((i for i, x in enumerate(entry) if x.split(" =")[0] in gone), len(entry))
|
|
130
|
+
entry = [x for x in entry if x.split(" =")[0] not in gone]
|
|
131
|
+
pairs = ", ".join(
|
|
132
|
+
f"{f.name} = {_toml(getattr(value, f.name))}"
|
|
133
|
+
for f in fields(value)
|
|
134
|
+
if getattr(value, f.name) is not None
|
|
135
|
+
)
|
|
136
|
+
entry.insert(min(at, len(entry)), f"{key} = {{ {pairs} }}")
|
|
137
|
+
return "\n".join(lines[:start] + entry + lines[end:]) + "\n"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def release(verified: str, installed: str) -> str:
|
|
141
|
+
"""Whether a verified release is the installed one, `older` or `newer`."""
|
|
142
|
+
as_tuple = lambda v: tuple(int(n) for n in v.split("+")[0].split(".")) # noqa: E731
|
|
143
|
+
a, b = as_tuple(verified), as_tuple(installed)
|
|
144
|
+
return "this_release" if a == b else "older" if a < b else "newer"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def for_adapter(name: str) -> System | None:
|
|
148
|
+
return next((s for s in systems().values() if s.adapter == name), None)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _backlog_key(s: System) -> tuple:
|
|
152
|
+
return (s.block is not None, ACCOUNTS.index(s.account), -(s.share or 0), s.source)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _row(s: System) -> str:
|
|
156
|
+
share = f"{s.share:.1%}" if s.share is not None else ""
|
|
157
|
+
block = f"{s.block.vendor} at {s.block.step}" if s.block else ""
|
|
158
|
+
state = "send_broken" if s.send_broken else s.state or ""
|
|
159
|
+
if s.adapter:
|
|
160
|
+
state = f"[{state}]({s.adapter}.md)" if state else f"[adapter]({s.adapter}.md)"
|
|
161
|
+
cells = [s.source, s.kind, share, s.account, ", ".join(s.hosting), block, state]
|
|
162
|
+
return "| " + " | ".join(cells) + " |"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def backlog(registry: dict[str, System] | None = None) -> str:
|
|
166
|
+
"""`docs/adapters/README.md`: the systems in backlog order (ADR 0013)."""
|
|
167
|
+
registry = systems() if registry is None else registry
|
|
168
|
+
head = "| Source | Kind | Share | Account | Hosting | Block | State |\n" + "|---" * 7 + "|"
|
|
169
|
+
adapters = sorted(
|
|
170
|
+
(s for s in registry.values() if s.kind in ("ats", "board")), key=_backlog_key
|
|
171
|
+
)
|
|
172
|
+
others = sorted(
|
|
173
|
+
(s for s in registry.values() if s.kind not in ("ats", "board")), key=_backlog_key
|
|
174
|
+
)
|
|
175
|
+
text = [
|
|
176
|
+
"# Hiring systems",
|
|
177
|
+
"",
|
|
178
|
+
"Generated from [src/atsfill/registry.toml](../../src/atsfill/registry.toml) by"
|
|
179
|
+
" `uv run scripts/backlog.py`. The registry is described in"
|
|
180
|
+
" [ADR 0012](../adr/0012-systems-registry.md), the order in"
|
|
181
|
+
" [ADR 0013](../adr/0013-survey-and-adapter-rounds.md): no account first, blocked last,"
|
|
182
|
+
" then by share of postings.",
|
|
183
|
+
"",
|
|
184
|
+
head,
|
|
185
|
+
*map(_row, adapters),
|
|
186
|
+
]
|
|
187
|
+
if others:
|
|
188
|
+
text += ["", "Aggregators and company-built forms, counted and not adapted:", "", head]
|
|
189
|
+
text += map(_row, others)
|
|
190
|
+
return "\n".join(text) + "\n"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
BADGES = ("<!-- badges -->", "<!-- /badges -->")
|
|
194
|
+
COLORS = {"send_verified": "9ece6a", "dry_run_verified": "7aa2f7", "mapped": "a9b1d6"}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _badge(s: System) -> str:
|
|
198
|
+
if s.send_broken:
|
|
199
|
+
message, color = f"send broken {s.send_broken.date}", "f7768e"
|
|
200
|
+
elif s.state:
|
|
201
|
+
v: Verification = getattr(s, s.state)
|
|
202
|
+
message, color = f"{s.state.replace('_', ' ')} {v.date}", COLORS[s.state]
|
|
203
|
+
else:
|
|
204
|
+
message, color = "unverified", "565f89"
|
|
205
|
+
escape = lambda t: t.replace("-", "--").replace("_", "__").replace(" ", "%20") # noqa: E731
|
|
206
|
+
url = f"https://img.shields.io/badge/{escape(s.adapter or s.source)}-{escape(message)}-{color}"
|
|
207
|
+
return f""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def with_badges(readme: str, registry: dict[str, System] | None = None) -> str:
|
|
211
|
+
"""The README with one badge per adapter between its badge markers."""
|
|
212
|
+
registry = systems() if registry is None else registry
|
|
213
|
+
start, end = readme.index(BADGES[0]) + len(BADGES[0]), readme.index(BADGES[1])
|
|
214
|
+
badges = " ".join(_badge(s) for s in sorted(registry.values(), key=_backlog_key) if s.adapter)
|
|
215
|
+
return f"{readme[:start]}\n{badges}\n{readme[end:]}"
|
|
@@ -61,6 +61,7 @@ class Result:
|
|
|
61
61
|
checks: list[Check] = field(default_factory=list)
|
|
62
62
|
side_effects: list[SideEffect] = field(default_factory=list)
|
|
63
63
|
evidence: str | None = None
|
|
64
|
+
warning: str | None = None # `code: detail`, on a send that went ahead anyway
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
@dataclass
|
|
@@ -69,6 +70,10 @@ class AdapterState:
|
|
|
69
70
|
submit_enabled: bool
|
|
70
71
|
side_effects: list[SideEffect]
|
|
71
72
|
side_effects_acknowledged: bool
|
|
73
|
+
verified: str | None = None # mapped | dry_run_verified | send_verified (ADR 0012)
|
|
74
|
+
verified_version: str | None = None
|
|
75
|
+
verified_release: str | None = None # this_release | older | newer than the installed one
|
|
76
|
+
send_broken: str | None = None # since when and why, with the last verified send
|
|
72
77
|
session: str | None = None # signed_in | signed_out | missing, for a system with accounts
|
|
73
78
|
|
|
74
79
|
|
|
@@ -77,7 +82,8 @@ class StatusReport:
|
|
|
77
82
|
version: str
|
|
78
83
|
data_dir: Path
|
|
79
84
|
config: Path
|
|
80
|
-
browser: str
|
|
85
|
+
browser: str # the installed build
|
|
86
|
+
browser_pinned: str
|
|
81
87
|
adapters: list[AdapterState]
|
|
82
88
|
|
|
83
89
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# The hiring systems atsfill has mapped (ADR 0012), one table per freehire `source`.
|
|
2
|
+
# `docs/adapters/README.md` is generated from this file: `uv run scripts/backlog.py`.
|
|
3
|
+
#
|
|
4
|
+
# kind ats | board | aggregator | custom
|
|
5
|
+
# share fraction of freehire's postings, worldwide, from its `source` facet
|
|
6
|
+
# account none | to_send | to_read
|
|
7
|
+
# hosting hosted, embedded: where the form is found
|
|
8
|
+
# adapter the adapter's name, when there is one
|
|
9
|
+
# block vendor, signal, step (posting | form | upload), date, version, camoufox, browser
|
|
10
|
+
# mapped, dry_run_verified, send_verified
|
|
11
|
+
# date, version (the release), commit (the exact code), by; only the user sets
|
|
12
|
+
# send_verified
|
|
13
|
+
# send_broken
|
|
14
|
+
# date, version, commit, by, cause (system | code | unknown), reason: a send that
|
|
15
|
+
# worked fails now; send_verified stays as the last known good
|
|
16
|
+
|
|
17
|
+
[ashby]
|
|
18
|
+
kind = "ats"
|
|
19
|
+
account = "none"
|
|
20
|
+
hosting = ["hosted", "embedded"]
|
|
21
|
+
adapter = "ashby"
|
|
22
|
+
send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal", commit = "ff8ea46" }
|
|
23
|
+
send_broken = { date = 2026-10-19, version = "0.0.2", commit = "916319c", by = "shbernal", reason = "the résumé reads back empty after its upload", cause = "unknown" }
|
|
24
|
+
|
|
25
|
+
[teamtailor]
|
|
26
|
+
kind = "ats"
|
|
27
|
+
account = "none"
|
|
28
|
+
hosting = ["hosted"]
|
|
29
|
+
adapter = "teamtailor"
|
|
30
|
+
|
|
31
|
+
[greenhouse]
|
|
32
|
+
kind = "ats"
|
|
33
|
+
account = "none"
|
|
34
|
+
hosting = ["hosted", "embedded"]
|
|
35
|
+
adapter = "greenhouse"
|
|
36
|
+
|
|
37
|
+
[wttj]
|
|
38
|
+
kind = "board"
|
|
39
|
+
account = "to_send"
|
|
40
|
+
hosting = ["hosted"]
|
|
41
|
+
adapter = "wttj"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|