atsfill 0.0.3.dev5__tar.gz → 0.0.3.dev7__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.dev5 → atsfill-0.0.3.dev7}/PKG-INFO +5 -1
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/README.md +4 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/ashby.py +43 -16
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/commands.py +35 -16
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/registry.py +73 -11
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/results.py +2 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/registry.toml +7 -5
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/LICENSE +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/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.dev7
|
|
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 |
|
|
@@ -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 |
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""Ashby (`jobs.ashbyhq.com`, and career pages embedding it), as docs/adapters/ashby.md observes.
|
|
2
2
|
|
|
3
|
-
Every change posts
|
|
4
|
-
|
|
3
|
+
Every change posts a mutation whose response holds the form as Ashby will receive it: that is
|
|
4
|
+
the read-back, kept per form since a survey answer returns the survey alone.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import time
|
|
9
9
|
import urllib.request
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from functools import partial
|
|
10
12
|
from typing import Any
|
|
11
13
|
from urllib.parse import parse_qs, urlsplit
|
|
12
14
|
|
|
@@ -31,6 +33,7 @@ TYPES = {
|
|
|
31
33
|
"String": FieldType.TEXT,
|
|
32
34
|
"Email": FieldType.TEXT,
|
|
33
35
|
"LongText": FieldType.TEXT,
|
|
36
|
+
"Url": FieldType.TEXT,
|
|
34
37
|
"Date": FieldType.DATE,
|
|
35
38
|
"Number": FieldType.TEXT,
|
|
36
39
|
"Phone": FieldType.PHONE,
|
|
@@ -42,6 +45,7 @@ TYPES = {
|
|
|
42
45
|
}
|
|
43
46
|
SETTLE_S = 1.5
|
|
44
47
|
READ_BACK_TIMEOUT_S = 30
|
|
48
|
+
SAVE_TIMEOUT_MS = 30_000
|
|
45
49
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
46
50
|
|
|
47
51
|
|
|
@@ -70,7 +74,7 @@ class Ashby:
|
|
|
70
74
|
|
|
71
75
|
def __init__(self) -> None:
|
|
72
76
|
self._forms: list[dict[str, Any]] = [] # the application form, then each survey
|
|
73
|
-
self._pending =
|
|
77
|
+
self._pending: set[Any] = set() # GraphQL requests in flight, since `read_form`
|
|
74
78
|
self._last_change = 0.0
|
|
75
79
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
76
80
|
|
|
@@ -132,6 +136,7 @@ class Ashby:
|
|
|
132
136
|
return [self._field(e) for e in self._entries()]
|
|
133
137
|
|
|
134
138
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
139
|
+
"""One save at a time: saves in flight together overwrite each other at Ashby."""
|
|
135
140
|
fields = {e["field"]["path"]: self._field(e) for e in self._entries()}
|
|
136
141
|
for field_id, answer in answers.items():
|
|
137
142
|
entry = page.locator(f'[data-field-path="{field_id}"]')
|
|
@@ -140,30 +145,49 @@ class Ashby:
|
|
|
140
145
|
case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
|
|
141
146
|
box = entry.locator("input, textarea").first
|
|
142
147
|
box.fill(answer)
|
|
143
|
-
box.blur
|
|
148
|
+
self._saved(page, box.blur)
|
|
149
|
+
case FieldType.CHOICE, str() if entry.locator("input[role=combobox]").count():
|
|
150
|
+
entry.locator("input[role=combobox]").fill(answer)
|
|
151
|
+
option = page.get_by_role("option", name=answer, exact=True)
|
|
152
|
+
self._saved(page, option.click)
|
|
144
153
|
case FieldType.CHOICE, str():
|
|
145
|
-
entry.get_by_text(answer, exact=True).click
|
|
154
|
+
self._saved(page, entry.get_by_text(answer, exact=True).click)
|
|
146
155
|
case FieldType.MULTI_CHOICE, list():
|
|
147
156
|
for value in answer:
|
|
148
|
-
entry.get_by_text(value, exact=True).click
|
|
157
|
+
self._saved(page, entry.get_by_text(value, exact=True).click)
|
|
149
158
|
case FieldType.TYPEAHEAD, str():
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
entry.locator("input[role=combobox]").fill(answer)
|
|
160
|
+
option = page.get_by_role("option").filter(has_text=answer).first
|
|
161
|
+
self._saved(page, option.click)
|
|
153
162
|
case FieldType.FILE, str():
|
|
154
|
-
entry.locator("input[type=file]")
|
|
163
|
+
upload = entry.locator("input[type=file]")
|
|
164
|
+
self._saved(page, partial(upload.set_input_files, answer))
|
|
155
165
|
case kind, _:
|
|
156
166
|
raise Unsupported(
|
|
157
167
|
f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
158
168
|
)
|
|
159
169
|
self._answered = list(answers)
|
|
160
170
|
|
|
171
|
+
def _saved(self, page: Page, change: Callable[[], None]) -> None:
|
|
172
|
+
"""Makes the change, then waits for its save and any other request it set off. A
|
|
173
|
+
change Ashby does not save shows in the read-back."""
|
|
174
|
+
try:
|
|
175
|
+
with page.expect_response(
|
|
176
|
+
lambda r: _op(r).startswith("ApiSetFormValue"), timeout=SAVE_TIMEOUT_MS
|
|
177
|
+
):
|
|
178
|
+
change()
|
|
179
|
+
except Timeout:
|
|
180
|
+
return
|
|
181
|
+
deadline = time.monotonic() + SAVE_TIMEOUT_MS / 1000
|
|
182
|
+
while self._pending and time.monotonic() < deadline:
|
|
183
|
+
page.wait_for_timeout(100)
|
|
184
|
+
|
|
161
185
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
162
186
|
"""Ashby's last returned form, once every answer is held and no request is in flight."""
|
|
163
187
|
deadline = time.monotonic() + READ_BACK_TIMEOUT_S
|
|
164
188
|
while time.monotonic() < deadline:
|
|
165
189
|
values = self._values()
|
|
166
|
-
quiet = self._pending
|
|
190
|
+
quiet = not self._pending and time.monotonic() - self._last_change > SETTLE_S
|
|
167
191
|
if quiet and all(values.get(i) is not None for i in self._answered):
|
|
168
192
|
break
|
|
169
193
|
page.wait_for_timeout(250)
|
|
@@ -248,15 +272,18 @@ class Ashby:
|
|
|
248
272
|
|
|
249
273
|
def _on_request(self, request: Any) -> None:
|
|
250
274
|
if "/api/non-user-graphql" in request.url:
|
|
251
|
-
self._pending
|
|
275
|
+
self._pending.add(request)
|
|
252
276
|
|
|
253
277
|
def _on_finished(self, request: Any) -> None:
|
|
254
278
|
if "/api/non-user-graphql" in request.url:
|
|
255
|
-
self._pending
|
|
279
|
+
self._pending.discard(request)
|
|
256
280
|
self._last_change = time.monotonic()
|
|
257
281
|
|
|
258
282
|
def _on_response(self, response: Response) -> None:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
283
|
+
"""Any response holding a form render: a value goes to `ApiSetFormValue`, a file to
|
|
284
|
+
`ApiSetFormValueToFile` once its upload ends, after saves made later."""
|
|
285
|
+
if not _op(response).startswith("ApiSetFormValue") or not response.ok:
|
|
286
|
+
return
|
|
287
|
+
for render in (response.json().get("data") or {}).values():
|
|
288
|
+
if isinstance(render, dict) and "sections" in render:
|
|
262
289
|
self._replace(render)
|
|
@@ -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
|
|
@@ -165,7 +171,7 @@ def read(url: str, *, data_dir: Path | None = None, sessions: Sessions | None =
|
|
|
165
171
|
result.fields = fields
|
|
166
172
|
return result
|
|
167
173
|
data = paths.data_dir(data_dir)
|
|
168
|
-
return _in_budget(url, lambda: _read_page(data, url, sessions))
|
|
174
|
+
return _in_budget(url, lambda seen: _read_page(data, url, sessions, seen))
|
|
169
175
|
|
|
170
176
|
|
|
171
177
|
def fill(
|
|
@@ -177,7 +183,7 @@ def fill(
|
|
|
177
183
|
) -> Result:
|
|
178
184
|
data = paths.data_dir(data_dir)
|
|
179
185
|
return _in_budget(
|
|
180
|
-
url, lambda: _form_command(data, url, answers, sessions,
|
|
186
|
+
url, lambda seen: _form_command(data, url, answers, sessions, False, False, seen)
|
|
181
187
|
)
|
|
182
188
|
|
|
183
189
|
|
|
@@ -191,13 +197,16 @@ def send(
|
|
|
191
197
|
) -> Result:
|
|
192
198
|
data = paths.data_dir(data_dir)
|
|
193
199
|
return _in_budget(
|
|
194
|
-
url, lambda: _form_command(data, url, answers, sessions,
|
|
200
|
+
url, lambda seen: _form_command(data, url, answers, sessions, True, resend, seen)
|
|
195
201
|
)
|
|
196
202
|
|
|
197
203
|
|
|
198
204
|
def _unverified(adapter: str) -> str | None:
|
|
199
|
-
"""The warning a send carries when no send through the adapter was verified
|
|
205
|
+
"""The warning a send carries when no send through the adapter was verified, or the last
|
|
206
|
+
verified one fails now (ADR 0012)."""
|
|
200
207
|
system = registry.for_adapter(adapter)
|
|
208
|
+
if system and system.send_broken:
|
|
209
|
+
return f"send_broken: {_broken(system)}"
|
|
201
210
|
if system and system.send_verified:
|
|
202
211
|
return None
|
|
203
212
|
state = system.state if system else None
|
|
@@ -223,16 +232,26 @@ def status(*, data_dir: Path | None = None) -> StatusReport:
|
|
|
223
232
|
)
|
|
224
233
|
|
|
225
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
|
+
|
|
226
242
|
def _adapter_state(adapter: Adapter, data: Path) -> AdapterState:
|
|
227
243
|
system = registry.for_adapter(adapter.name)
|
|
228
244
|
verified = system.state if system else None
|
|
245
|
+
v = getattr(system, verified) if system and verified else None
|
|
229
246
|
return AdapterState(
|
|
230
247
|
adapter.name,
|
|
231
248
|
submit_enabled=paths.submit_enabled(adapter.name),
|
|
232
249
|
side_effects=adapter.side_effects,
|
|
233
250
|
side_effects_acknowledged=paths.acknowledged(adapter.name),
|
|
234
251
|
verified=verified,
|
|
235
|
-
verified_version=
|
|
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,
|
|
236
255
|
session=_session_state(adapter, data),
|
|
237
256
|
)
|
|
238
257
|
|
|
@@ -12,13 +12,28 @@ HOSTING = ("hosted", "embedded")
|
|
|
12
12
|
VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "unknown")
|
|
13
13
|
STEPS = ("posting", "form", "upload")
|
|
14
14
|
STATES = ("mapped", "dry_run_verified", "send_verified")
|
|
15
|
+
CAUSES = ("system", "code", "unknown")
|
|
16
|
+
RELEASES = ("this_release", "older", "newer")
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
@dataclass(frozen=True)
|
|
18
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
|
+
|
|
19
31
|
date: date
|
|
20
32
|
version: str
|
|
33
|
+
commit: str
|
|
21
34
|
by: str
|
|
35
|
+
reason: str
|
|
36
|
+
cause: str = "unknown"
|
|
22
37
|
|
|
23
38
|
|
|
24
39
|
@dataclass(frozen=True)
|
|
@@ -43,7 +58,8 @@ class System:
|
|
|
43
58
|
block: Block | None = None
|
|
44
59
|
mapped: Verification | None = None
|
|
45
60
|
dry_run_verified: Verification | None = None
|
|
46
|
-
send_verified: Verification | None = None
|
|
61
|
+
send_verified: Verification | None = None # the last known good, kept through a break
|
|
62
|
+
send_broken: Broken | None = None
|
|
47
63
|
|
|
48
64
|
@property
|
|
49
65
|
def state(self) -> str | None:
|
|
@@ -70,6 +86,10 @@ def _system(source: str, entry: dict) -> System:
|
|
|
70
86
|
_one_of(source, "block.step", block.get("step"), STEPS)
|
|
71
87
|
block = Block(**block)
|
|
72
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)
|
|
73
93
|
return System(
|
|
74
94
|
source,
|
|
75
95
|
entry["kind"],
|
|
@@ -79,6 +99,7 @@ def _system(source: str, entry: dict) -> System:
|
|
|
79
99
|
entry.get("share"),
|
|
80
100
|
block,
|
|
81
101
|
**states,
|
|
102
|
+
send_broken=broken,
|
|
82
103
|
)
|
|
83
104
|
|
|
84
105
|
|
|
@@ -91,20 +112,36 @@ def systems() -> dict[str, System]:
|
|
|
91
112
|
return parse(files("atsfill").joinpath("registry.toml").read_text())
|
|
92
113
|
|
|
93
114
|
|
|
94
|
-
def
|
|
95
|
-
|
|
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`."""
|
|
96
122
|
lines = text.splitlines()
|
|
97
123
|
start = lines.index(f"[{source}]")
|
|
98
124
|
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("[")), len(lines))
|
|
99
125
|
while lines[end - 1] == "":
|
|
100
126
|
end -= 1
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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"
|
|
108
145
|
|
|
109
146
|
|
|
110
147
|
def for_adapter(name: str) -> System | None:
|
|
@@ -118,7 +155,7 @@ def _backlog_key(s: System) -> tuple:
|
|
|
118
155
|
def _row(s: System) -> str:
|
|
119
156
|
share = f"{s.share:.1%}" if s.share is not None else ""
|
|
120
157
|
block = f"{s.block.vendor} at {s.block.step}" if s.block else ""
|
|
121
|
-
state = s.state or ""
|
|
158
|
+
state = "send_broken" if s.send_broken else s.state or ""
|
|
122
159
|
if s.adapter:
|
|
123
160
|
state = f"[{state}]({s.adapter}.md)" if state else f"[adapter]({s.adapter}.md)"
|
|
124
161
|
cells = [s.source, s.kind, share, s.account, ", ".join(s.hosting), block, state]
|
|
@@ -151,3 +188,28 @@ def backlog(registry: dict[str, System] | None = None) -> str:
|
|
|
151
188
|
text += ["", "Aggregators and company-built forms, counted and not adapted:", "", head]
|
|
152
189
|
text += map(_row, others)
|
|
153
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:]}"
|
|
@@ -72,6 +72,8 @@ class AdapterState:
|
|
|
72
72
|
side_effects_acknowledged: bool
|
|
73
73
|
verified: str | None = None # mapped | dry_run_verified | send_verified (ADR 0012)
|
|
74
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
|
|
75
77
|
session: str | None = None # signed_in | signed_out | missing, for a system with accounts
|
|
76
78
|
|
|
77
79
|
|
|
@@ -8,32 +8,34 @@
|
|
|
8
8
|
# adapter the adapter's name, when there is one
|
|
9
9
|
# block vendor, signal, step (posting | form | upload), date, version, camoufox, browser
|
|
10
10
|
# mapped, dry_run_verified, send_verified
|
|
11
|
-
# date, version, by; only the user sets
|
|
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
|
|
12
16
|
|
|
13
17
|
[ashby]
|
|
14
18
|
kind = "ats"
|
|
15
19
|
account = "none"
|
|
16
20
|
hosting = ["hosted", "embedded"]
|
|
17
21
|
adapter = "ashby"
|
|
18
|
-
send_verified = { date = 2026-10-
|
|
22
|
+
send_verified = { date = 2026-10-19, version = "0.0.2", by = "shbernal", commit = "7622376" }
|
|
23
|
+
dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c230c5e" }
|
|
19
24
|
|
|
20
25
|
[teamtailor]
|
|
21
26
|
kind = "ats"
|
|
22
27
|
account = "none"
|
|
23
28
|
hosting = ["hosted"]
|
|
24
29
|
adapter = "teamtailor"
|
|
25
|
-
send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
|
|
26
30
|
|
|
27
31
|
[greenhouse]
|
|
28
32
|
kind = "ats"
|
|
29
33
|
account = "none"
|
|
30
34
|
hosting = ["hosted", "embedded"]
|
|
31
35
|
adapter = "greenhouse"
|
|
32
|
-
send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
|
|
33
36
|
|
|
34
37
|
[wttj]
|
|
35
38
|
kind = "board"
|
|
36
39
|
account = "to_send"
|
|
37
40
|
hosting = ["hosted"]
|
|
38
41
|
adapter = "wttj"
|
|
39
|
-
send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
|
|
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
|
|
File without changes
|