atsfill 0.0.7.dev208__tar.gz → 0.0.7.dev211__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.7.dev208 → atsfill-0.0.7.dev211}/PKG-INFO +1 -1
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/pyproject.toml +1 -1
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/pyproject.toml.orig +1 -1
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/ashby.py +15 -3
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/join.py +19 -3
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/adapter.py +10 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/browser.py +9 -2
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/commands.py +47 -11
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/matching.py +34 -5
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/telemetry.py +2 -1
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/LICENSE +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/README.md +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/adp.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/wellfound.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/werecruit.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.7.dev208 → atsfill-0.0.7.dev211}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.7.
|
|
3
|
+
Version: 0.0.7.dev211
|
|
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
|
|
@@ -83,6 +83,7 @@ class Ashby:
|
|
|
83
83
|
self._loaded: Response | None = None # the ApiJobPosting response `open_form` caught
|
|
84
84
|
self._last_change = 0.0
|
|
85
85
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
86
|
+
self._answered: list[str] = []
|
|
86
87
|
|
|
87
88
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
88
89
|
"""`jobs.ashbyhq.com`, or a career page whose Ashby embed opens the `ashby_jid` job."""
|
|
@@ -148,6 +149,7 @@ class Ashby:
|
|
|
148
149
|
if job is None:
|
|
149
150
|
raise Unsupported("posting_gone")
|
|
150
151
|
self._forms = [job["applicationForm"], *job["surveyForms"]]
|
|
152
|
+
self._answered = []
|
|
151
153
|
page.wait_for_selector(".ashby-application-form-submit-button")
|
|
152
154
|
return [self._field(e) for e in self._entries()]
|
|
153
155
|
|
|
@@ -185,7 +187,7 @@ class Ashby:
|
|
|
185
187
|
raise Unsupported(
|
|
186
188
|
f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
187
189
|
)
|
|
188
|
-
self._answered
|
|
190
|
+
self._answered += answers
|
|
189
191
|
|
|
190
192
|
def _saved(self, page: Page, change: Callable[[], None]) -> None:
|
|
191
193
|
"""Makes the change, then waits for its save and any other request it set off. A
|
|
@@ -201,8 +203,8 @@ class Ashby:
|
|
|
201
203
|
while self._pending and time.monotonic() < deadline:
|
|
202
204
|
page.wait_for_timeout(SAVE_POLL_MS)
|
|
203
205
|
|
|
204
|
-
def
|
|
205
|
-
"""
|
|
206
|
+
def _settle(self, page: Page) -> None:
|
|
207
|
+
"""Until every answer is held and no request is in flight."""
|
|
206
208
|
deadline = time.monotonic() + READ_BACK_TIMEOUT_S
|
|
207
209
|
while time.monotonic() < deadline:
|
|
208
210
|
values = self._values()
|
|
@@ -210,6 +212,16 @@ class Ashby:
|
|
|
210
212
|
if quiet and all(values.get(i) is not None for i in self._answered):
|
|
211
213
|
break
|
|
212
214
|
page.wait_for_timeout(READ_BACK_POLL_MS)
|
|
215
|
+
|
|
216
|
+
def revealed(self, page: Page) -> list[Field]:
|
|
217
|
+
"""The fields of Ashby's last returned forms: only an answer's render holds a follow-up,
|
|
218
|
+
the posting's query carries no trace of it or of its condition."""
|
|
219
|
+
self._settle(page)
|
|
220
|
+
return [self._field(e) for e in self._entries()]
|
|
221
|
+
|
|
222
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
223
|
+
"""Ashby's last returned form, once settled."""
|
|
224
|
+
self._settle(page)
|
|
213
225
|
return {k: v for k, v in self._values().items() if v is not None}
|
|
214
226
|
|
|
215
227
|
def submit(self, page: Page) -> str | None:
|
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import re
|
|
13
13
|
import time
|
|
14
14
|
from collections.abc import Callable
|
|
15
|
+
from contextlib import suppress
|
|
15
16
|
from dataclasses import replace
|
|
16
17
|
from typing import Any
|
|
17
18
|
from urllib.parse import urlsplit
|
|
@@ -50,6 +51,7 @@ OPENING_TIMEOUT_S = 20
|
|
|
50
51
|
CONTINUE_TIMEOUT_S = 15
|
|
51
52
|
ADVANCE_TIMEOUT_MS = 3_000 # how long a clicked card takes to move the wizard on by itself
|
|
52
53
|
INPUT_TIMEOUT_MS = 3_000 # how long a step takes to show its own input
|
|
54
|
+
ANSWER_SHOWN = 200 # of an answer that is not the expected one, in the error
|
|
53
55
|
CLICKS = 3 # a click on a step still rendering is lost, on a card or on Continue
|
|
54
56
|
LEAVE_TIMEOUT_MS = 5_000 # how long Continue takes to leave a step, per click
|
|
55
57
|
POLL_MS = 250
|
|
@@ -80,6 +82,7 @@ LACKS = {
|
|
|
80
82
|
"candidate.phoneNumber": "a phone",
|
|
81
83
|
}
|
|
82
84
|
PDF = [".pdf", "application/pdf"]
|
|
85
|
+
REMOVE_FILE = "button:has(svg[name=TrashIcon])" # a stored file's Remove file, in any language
|
|
83
86
|
|
|
84
87
|
# The profile form's controls, by its field ids.
|
|
85
88
|
TEXTS = {
|
|
@@ -136,7 +139,11 @@ class Join:
|
|
|
136
139
|
side_effects = [
|
|
137
140
|
SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
|
|
138
141
|
SideEffect("profile", "hiring_system", "profile --answers saves the account profile"),
|
|
139
|
-
SideEffect(
|
|
142
|
+
SideEffect(
|
|
143
|
+
"upload",
|
|
144
|
+
"hiring_system",
|
|
145
|
+
"a file is stored as soon as it is chosen, replacing the one the account keeps",
|
|
146
|
+
),
|
|
140
147
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
141
148
|
]
|
|
142
149
|
|
|
@@ -215,7 +222,10 @@ class Join:
|
|
|
215
222
|
"query": QUESTIONS,
|
|
216
223
|
}
|
|
217
224
|
answered = page.evaluate(_FETCH, [f"{GRAPHQL}?operationName=JobScreeningQuestions", body])
|
|
218
|
-
|
|
225
|
+
listed = ((answered or {}).get("data") or {}).get("jobScreeningQuestions")
|
|
226
|
+
if not listed:
|
|
227
|
+
raise RuntimeError(f"JobScreeningQuestions answered {str(answered)[:ANSWER_SHOWN]}")
|
|
228
|
+
questions = listed["questions"]
|
|
219
229
|
self._questions = sorted(questions, key=lambda q: q.get("position") or 0)
|
|
220
230
|
browser.click(page.locator("[data-testid=ApplyButton]").locator("visible=true").first)
|
|
221
231
|
page.wait_for_url("**/apply/**", timeout=STEP_TIMEOUT_MS)
|
|
@@ -383,7 +393,13 @@ class Join:
|
|
|
383
393
|
def _file(self, page: Page, field_id: str, answer: Answer | None) -> None:
|
|
384
394
|
if not isinstance(answer, str):
|
|
385
395
|
return
|
|
386
|
-
page.locator("input[type=file]").
|
|
396
|
+
upload, stored = page.locator("input[type=file]"), page.locator(REMOVE_FILE)
|
|
397
|
+
with suppress(Timeout): # the step before's Remove file stays a moment
|
|
398
|
+
upload.first.wait_for(state="attached", timeout=INPUT_TIMEOUT_MS)
|
|
399
|
+
upload.or_(stored).first.wait_for(state="attached", timeout=STEP_TIMEOUT_MS)
|
|
400
|
+
if not upload.count(): # a file the account keeps: the answer replaces it
|
|
401
|
+
stored.click(timeout=STEP_TIMEOUT_MS)
|
|
402
|
+
upload.first.set_input_files(answer)
|
|
387
403
|
# once stored, the step shows the file in place of its input
|
|
388
404
|
page.wait_for_function(
|
|
389
405
|
"() => !document.querySelector('input[type=file]')", timeout=UPLOAD_TIMEOUT_MS
|
|
@@ -307,6 +307,16 @@ class ReadsByRequest(Adapter, Protocol):
|
|
|
307
307
|
...
|
|
308
308
|
|
|
309
309
|
|
|
310
|
+
@runtime_checkable
|
|
311
|
+
class RevealsFields(Adapter, Protocol):
|
|
312
|
+
"""An adapter whose system keeps a follow-up's condition to itself: no read can list the
|
|
313
|
+
field, only a saved answer shows it, so the fill finds it and answers it (ADR 0005)."""
|
|
314
|
+
|
|
315
|
+
def revealed(self, page: Page) -> list[Field]:
|
|
316
|
+
"""Every field the filled page shows, each as `browser_read` lists it."""
|
|
317
|
+
...
|
|
318
|
+
|
|
319
|
+
|
|
310
320
|
@runtime_checkable
|
|
311
321
|
class ListsShownFields(Adapter, Protocol):
|
|
312
322
|
"""An adapter whose form can show fields after an answer: once filled, it says which the
|
|
@@ -59,8 +59,10 @@ PROFILE_BUDGET = 300.0 # seconds for a `profile` run, which may save every prof
|
|
|
59
59
|
LOCK_POLL_S = 0.1
|
|
60
60
|
HTTP_TIMEOUT_S = 20 # per request to a hiring system's API
|
|
61
61
|
BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
|
|
62
|
-
BLOCKER = ADDONS_DIR / "UBO-
|
|
62
|
+
BLOCKER = ADDONS_DIR / "UBO-frozen" # Camoufox's uBlock Origin: annoyance lists on, updates off
|
|
63
63
|
BLOCKER_LISTS = "annoyances" # the group of uBlock Origin lists: cookie banners, pop-ups, widgets
|
|
64
|
+
# On, its first update, 37s into a launch, leaves the blocker hiding nothing for the rest of it.
|
|
65
|
+
BLOCKER_AUTO_UPDATE = "autoUpdate: true,"
|
|
64
66
|
# The adapters' filters for the banners the lists miss: a list of the blocker's, on like them.
|
|
65
67
|
FILTERS = "assets/atsfill/filters.txt"
|
|
66
68
|
INCLUDE = re.compile(r"^!#include (\S+)$", re.M)
|
|
@@ -107,7 +109,8 @@ def install() -> int:
|
|
|
107
109
|
def _build_blocker() -> None:
|
|
108
110
|
"""Copies Camoufox's uBlock Origin to `BLOCKER` with only its annoyance lists on, read
|
|
109
111
|
from disk: fetched by each launch, they would hold its first page for seconds, and each
|
|
110
|
-
list on is compiled by each launch before the blocker starts.
|
|
112
|
+
list on is compiled by each launch before the blocker starts. Its updates stay off: a fresh
|
|
113
|
+
profile would drop them with the browser anyway."""
|
|
111
114
|
ADDONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
112
115
|
with FileLock(ADDONS_DIR / f"{BLOCKER.name}.lock"):
|
|
113
116
|
build = BLOCKER.with_name(f"{BLOCKER.name}.build")
|
|
@@ -132,6 +135,10 @@ def _build_blocker() -> None:
|
|
|
132
135
|
(build / FILTERS).parent.mkdir()
|
|
133
136
|
(build / FILTERS).touch()
|
|
134
137
|
assets.write_text(json.dumps(entries))
|
|
138
|
+
settings = build / "js" / "background.js"
|
|
139
|
+
defaults = settings.read_text()
|
|
140
|
+
assert BLOCKER_AUTO_UPDATE in defaults # gone from a new uBlock Origin: find its new form
|
|
141
|
+
settings.write_text(defaults.replace(BLOCKER_AUTO_UPDATE, "autoUpdate: false,"))
|
|
135
142
|
shutil.rmtree(BLOCKER, ignore_errors=True)
|
|
136
143
|
build.rename(BLOCKER)
|
|
137
144
|
|
|
@@ -34,6 +34,7 @@ from atsfill.core.adapter import (
|
|
|
34
34
|
ProfileIncomplete,
|
|
35
35
|
ReadsByRequest,
|
|
36
36
|
Refused,
|
|
37
|
+
RevealsFields,
|
|
37
38
|
SendsToOpen,
|
|
38
39
|
SignedOut,
|
|
39
40
|
SignsIn,
|
|
@@ -353,6 +354,38 @@ def _shown_as_listed(
|
|
|
353
354
|
result.reason = f"mismatch: {', '.join(wrong)} shows though its condition is unmet"
|
|
354
355
|
|
|
355
356
|
|
|
357
|
+
def _no_field(result: Result, answers: dict[str, Answer]) -> bool:
|
|
358
|
+
"""Refuses an answer to no listed field. Ids come from `read`, so it says as much about the
|
|
359
|
+
adapter as about the answers: a form that changed, or a field read missed."""
|
|
360
|
+
listed = matching.known(result.fields, answers)
|
|
361
|
+
if unknown := [i for i in answers if i not in listed]:
|
|
362
|
+
result.status, result.reason = Status.REFUSED, f"unknown_field: {', '.join(unknown)}"
|
|
363
|
+
return bool(unknown)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _revealed(
|
|
367
|
+
page: Page, adapter: RevealsFields, result: Result, answers: dict[str, Answer]
|
|
368
|
+
) -> dict[str, Answer]:
|
|
369
|
+
"""Fills what the saved answers reveal, until they reveal nothing more, and returns the
|
|
370
|
+
answers the form took. A revealed field no answer takes refuses, naming what it asks, since
|
|
371
|
+
`fill` prints no fields (ADR 0005)."""
|
|
372
|
+
mine = matching.as_listed(result.fields, matching.known(result.fields, answers))
|
|
373
|
+
while new := [f for f in adapter.revealed(page) if f.id not in {g.id for g in result.fields}]:
|
|
374
|
+
_listed(result, result.fields + new)
|
|
375
|
+
mine = matching.as_listed(result.fields, matching.known(result.fields, answers))
|
|
376
|
+
if _unfillable(result, mine):
|
|
377
|
+
return mine
|
|
378
|
+
if problems := matching.problems(result.fields, mine):
|
|
379
|
+
problems += [f"{f.id} takes {f.options}" for f in new if f.options and f.id not in mine]
|
|
380
|
+
result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
|
|
381
|
+
return mine
|
|
382
|
+
left = [f for f in new if f.id not in mine]
|
|
383
|
+
result.warnings += [f"an answer showed {f.id} ({f.label}), left empty" for f in left]
|
|
384
|
+
adapter.fill(page, matching.to_fill(new, {f.id: mine[f.id] for f in new if f.id in mine}))
|
|
385
|
+
_no_field(result, answers)
|
|
386
|
+
return mine
|
|
387
|
+
|
|
388
|
+
|
|
356
389
|
def _no_adapter(url: str) -> Result:
|
|
357
390
|
return Result(Status.UNSUPPORTED, url, reason="no_adapter: no adapter for this hiring system")
|
|
358
391
|
|
|
@@ -420,17 +453,23 @@ def _fill(
|
|
|
420
453
|
result = _result(adapter, url, user)
|
|
421
454
|
with _stops(result):
|
|
422
455
|
_listed(result, _read_signed_in(page, adapter, mine, user, url))
|
|
423
|
-
|
|
456
|
+
held_back = isinstance(adapter, RevealsFields) # an answer may be to a field yet to show
|
|
457
|
+
given = matching.known(result.fields, answers) if held_back else answers
|
|
458
|
+
if _unfillable(result, given) or _no_field(result, given):
|
|
424
459
|
return result
|
|
425
|
-
|
|
460
|
+
given = matching.as_listed(result.fields, given)
|
|
461
|
+
if problems := matching.problems(result.fields, given):
|
|
426
462
|
result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
|
|
427
463
|
return result
|
|
428
|
-
result.warnings = matching.knockouts(result.fields,
|
|
429
|
-
adapter.fill(page, matching.to_fill(result.fields,
|
|
430
|
-
|
|
464
|
+
result.warnings = matching.knockouts(result.fields, given)
|
|
465
|
+
adapter.fill(page, matching.to_fill(result.fields, given))
|
|
466
|
+
if isinstance(adapter, RevealsFields):
|
|
467
|
+
given = _revealed(page, adapter, result, answers)
|
|
468
|
+
else:
|
|
469
|
+
_shown_as_listed(page, adapter, result, given)
|
|
431
470
|
if result.status is not Status.READY:
|
|
432
471
|
return result
|
|
433
|
-
result.checks = matching.check(result.fields,
|
|
472
|
+
result.checks = matching.check(result.fields, given, adapter.read_back(page))
|
|
434
473
|
if mismatched := [c.field_id for c in result.checks if not c.match]:
|
|
435
474
|
result.status, result.reason = Status.REFUSED, f"mismatch: {', '.join(mismatched)}"
|
|
436
475
|
return result
|
|
@@ -650,7 +689,7 @@ def _save_profile(
|
|
|
650
689
|
fields = adapter.profile_read(page)
|
|
651
690
|
_listed(result, result.fields + fields)
|
|
652
691
|
ids = {f.id for f in fields}
|
|
653
|
-
mine = {k: v for k, v in answers.items() if k in ids}
|
|
692
|
+
mine = matching.as_listed(fields, {k: v for k, v in answers.items() if k in ids})
|
|
654
693
|
step_result = Result(Status.READY, result.url, fields=fields)
|
|
655
694
|
if _unfillable(step_result, mine):
|
|
656
695
|
result.status, result.reason = step_result.status, step_result.reason
|
|
@@ -669,10 +708,7 @@ def _save_profile(
|
|
|
669
708
|
adapter.save(page)
|
|
670
709
|
result.saved.append(step)
|
|
671
710
|
result.checks += checks
|
|
672
|
-
|
|
673
|
-
if unknown := [k for k in answers if k not in listed]:
|
|
674
|
-
result.status = Status.REFUSED
|
|
675
|
-
result.reason = "invalid_answers: " + "; ".join(f"no field {k}" for k in unknown)
|
|
711
|
+
_no_field(result, answers)
|
|
676
712
|
|
|
677
713
|
|
|
678
714
|
def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
|
|
@@ -136,13 +136,42 @@ def _answer_problem(field: Field, answer: object, form: Form) -> str | None:
|
|
|
136
136
|
return None
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
def known(fields: list[Field], answers: dict[str, Answer]) -> dict[str, Answer]:
|
|
140
|
+
"""The answers to fields of the form."""
|
|
141
|
+
ids = {f.id for f in fields}
|
|
142
|
+
return {i: a for i, a in answers.items() if i in ids}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def as_listed(fields: list[Field], answers: dict[str, Answer]) -> dict[str, Answer]:
|
|
146
|
+
"""Each choice as its field spells the option, where only whitespace or Unicode form tells
|
|
147
|
+
them apart: a stray space in a page's option is not the answer's to copy (ADR 0005)."""
|
|
148
|
+
by_id = {f.id: f for f in fields}
|
|
149
|
+
|
|
150
|
+
def spelled(field: Field, value: str) -> str:
|
|
151
|
+
sets = [o for s in field.option_sets or [] for o in s.options]
|
|
152
|
+
options = [*(field.options or []), *sets]
|
|
153
|
+
if value in options:
|
|
154
|
+
return value
|
|
155
|
+
return next((o for o in options if _norm(o) == _norm(value)), value)
|
|
156
|
+
|
|
157
|
+
out = dict(answers)
|
|
158
|
+
for i, answer in answers.items():
|
|
159
|
+
field = by_id.get(i)
|
|
160
|
+
if not field or field.type not in (FieldType.CHOICE, FieldType.MULTI_CHOICE):
|
|
161
|
+
continue
|
|
162
|
+
if isinstance(answer, str):
|
|
163
|
+
out[i] = spelled(field, answer)
|
|
164
|
+
elif isinstance(answer, list) and all(isinstance(v, str) for v in answer):
|
|
165
|
+
out[i] = [spelled(field, v) for v in answer]
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
139
169
|
def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
140
|
-
"""What refuses
|
|
141
|
-
alternative answered, answers to follow-ups the answers keep hidden, bad values."""
|
|
170
|
+
"""What refuses answers to the form's fields before typing: missing required answers, more
|
|
171
|
+
than one alternative answered, answers to follow-ups the answers keep hidden, bad values."""
|
|
142
172
|
by_id = {f.id: f for f in fields}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
out += [
|
|
173
|
+
form = Form(fields, known(fields, answers))
|
|
174
|
+
out = [
|
|
146
175
|
f"no answer for required {f.id} ({f.label})"
|
|
147
176
|
for f in fields
|
|
148
177
|
if form.required(f) and f.prefilled is None and not f.unfillable and f.id not in answers
|
|
@@ -42,7 +42,8 @@ REPORT = (
|
|
|
42
42
|
"run `atsfill report` to send this run's URL and page, scrubbed of your answers,"
|
|
43
43
|
" to the maintainer: " + DOCS
|
|
44
44
|
)
|
|
45
|
-
# Reason codes that say nothing about the adapter: the user's input or a fact about the posting
|
|
45
|
+
# Reason codes that say nothing about the adapter: the user's input or a fact about the posting.
|
|
46
|
+
# `unknown_field` is not one: ids come from `read`, so it can be a field read missed.
|
|
46
47
|
EXCLUDED = (
|
|
47
48
|
"invalid_answers",
|
|
48
49
|
"answer_not_taken",
|
|
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
|
|
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
|
|
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
|
|
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
|