atsfill 0.0.8.dev223__tar.gz → 0.0.8.dev225__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.8.dev223 → atsfill-0.0.8.dev225}/PKG-INFO +1 -1
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/pyproject.toml +1 -1
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/pyproject.toml.orig +1 -1
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/adp.py +6 -2
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/eightfold.py +32 -2
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/gupy.py +18 -9
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/jobaffinity.py +6 -2
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/paycor.py +17 -6
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/rippling.py +2 -2
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/ukg.py +1 -1
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/workable.py +12 -9
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/cli.py +1 -1
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/commands.py +13 -4
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/matching.py +36 -21
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/results.py +1 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/telemetry.py +1 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/LICENSE +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/README.md +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/wellfound.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/werecruit.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev225}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.8.
|
|
3
|
+
Version: 0.0.8.dev225
|
|
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
|
|
@@ -584,8 +584,12 @@ def _wait_for_step(page: Page, step: Step) -> None:
|
|
|
584
584
|
|
|
585
585
|
|
|
586
586
|
def _next(page: Page, step: Step) -> None:
|
|
587
|
-
"""Next, once the step is left; the page's errors where it stays.
|
|
588
|
-
|
|
587
|
+
"""Next, once the step is left; the page's errors where it stays. It is disabled while a
|
|
588
|
+
résumé uploads, past the moment the step lists the file."""
|
|
589
|
+
try:
|
|
590
|
+
page.locator(NEXT).click(timeout=UPLOAD_TIMEOUT_MS)
|
|
591
|
+
except Timeout:
|
|
592
|
+
raise NotTaken(f"{step.name}: Next stayed disabled: {_errors(page)}") from None
|
|
589
593
|
deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
|
|
590
594
|
while step.marker and page.locator(step.marker).first.count():
|
|
591
595
|
if time.monotonic() > deadline:
|
|
@@ -36,6 +36,7 @@ PICKER = "[data-testid=picker-dialog]"
|
|
|
36
36
|
FORM_TIMEOUT_MS = 30_000
|
|
37
37
|
SHOWN_TIMEOUT_MS = 2_000
|
|
38
38
|
OPTIONS_TIMEOUT_MS = 5_000
|
|
39
|
+
FOCUS_TIMEOUT_MS = 500
|
|
39
40
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
40
41
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
41
42
|
PARSE_SETTLE_MS = 2_000
|
|
@@ -241,6 +242,15 @@ class Eightfold:
|
|
|
241
242
|
continue
|
|
242
243
|
_fill(page, control, answer)
|
|
243
244
|
given[control.field.id] = answer
|
|
245
|
+
self._again(page, given)
|
|
246
|
+
|
|
247
|
+
def _again(self, page: Page, given: dict[str, Answer]) -> None:
|
|
248
|
+
"""Fills once more an answered control a later render emptied."""
|
|
249
|
+
placed = [c for c in self._controls if c.dom and given.get(c.field.id)]
|
|
250
|
+
values = self._values(page, placed)
|
|
251
|
+
for control in placed:
|
|
252
|
+
if not values.get(control.field.id):
|
|
253
|
+
_fill(page, control, given[control.field.id])
|
|
244
254
|
|
|
245
255
|
def shown_fields(self, page: Page) -> list[str]:
|
|
246
256
|
"""The questions rendered once filled, each control's by the longest question id its
|
|
@@ -470,18 +480,38 @@ def _pick(page: Page, combo, value: str, exact: bool) -> None:
|
|
|
470
480
|
if not option.count():
|
|
471
481
|
page.keyboard.press("Escape")
|
|
472
482
|
raise NotTaken(f"no option {value!r}")
|
|
483
|
+
_choose(page, combo, option)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _choose(page: Page, combo, option) -> None:
|
|
487
|
+
"""Clicks a combobox's option and waits for its list to close and the combobox to take the
|
|
488
|
+
focus back: typed before that, the next answer lands in the combobox."""
|
|
489
|
+
listbox = page.locator(f"[id={json.dumps(combo.get_attribute('aria-controls'))}]")
|
|
473
490
|
option.click()
|
|
491
|
+
with suppress(Timeout):
|
|
492
|
+
listbox.wait_for(state="hidden", timeout=FOCUS_TIMEOUT_MS)
|
|
493
|
+
page.wait_for_function(
|
|
494
|
+
"el => document.activeElement === el",
|
|
495
|
+
arg=combo.element_handle(),
|
|
496
|
+
timeout=FOCUS_TIMEOUT_MS,
|
|
497
|
+
)
|
|
474
498
|
|
|
475
499
|
|
|
476
500
|
def _phone(page: Page, box, value: str) -> None:
|
|
477
501
|
"""Picks the country code the number starts with, the longest that fits, then types the
|
|
478
|
-
rest of the number. Where several countries share it, its main country.
|
|
502
|
+
rest of the number. Where several countries share it, its main country. One already showing
|
|
503
|
+
a code that fits stays: clicking the selected option clears it."""
|
|
479
504
|
digits = re.sub(r"\D", "", value)
|
|
480
505
|
combo = box.locator("input[role=combobox]").first
|
|
481
506
|
number = box.locator("input:not([role=combobox])").first
|
|
482
507
|
if not value.startswith("+"):
|
|
483
508
|
number.fill(value)
|
|
484
509
|
return
|
|
510
|
+
if (held := DIAL.search(combo.input_value())) and digits.startswith(
|
|
511
|
+
code := held[1].replace(" ", "")
|
|
512
|
+
):
|
|
513
|
+
number.fill(digits[len(code) :])
|
|
514
|
+
return
|
|
485
515
|
_click(combo)
|
|
486
516
|
options = page.locator(f"[id={json.dumps(combo.get_attribute('aria-controls'))}] [role=option]")
|
|
487
517
|
options.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
@@ -495,7 +525,7 @@ def _phone(page: Page, box, value: str) -> None:
|
|
|
495
525
|
number.fill(digits)
|
|
496
526
|
return
|
|
497
527
|
_, _, i, code = max(fits)
|
|
498
|
-
options.nth(-i)
|
|
528
|
+
_choose(page, combo, options.nth(-i))
|
|
499
529
|
number.fill(digits[len(code) :])
|
|
500
530
|
|
|
501
531
|
|
|
@@ -77,7 +77,7 @@ _READ_ADDITIONAL = """() => {
|
|
|
77
77
|
}"""
|
|
78
78
|
|
|
79
79
|
# The employer's questions, by question id: the checked labels, or the text typed.
|
|
80
|
-
_READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title]) => {
|
|
80
|
+
_READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title, nth]) => {
|
|
81
81
|
const radios = [...document.querySelectorAll(`input[name="question-${id}"]`)];
|
|
82
82
|
const boxes = [...document.querySelectorAll(`input[name^="checkbox-${id}-"]`)];
|
|
83
83
|
const picked = [...radios, ...boxes].filter(e => e.checked)
|
|
@@ -85,7 +85,7 @@ _READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title]) => {
|
|
|
85
85
|
if (boxes.length) return [`question-${id}`, picked];
|
|
86
86
|
if (radios.length) return [`question-${id}`, picked[0] ?? null];
|
|
87
87
|
const text = [...document.querySelectorAll("textarea, input[type=text]")]
|
|
88
|
-
.
|
|
88
|
+
.filter(e => e.name === title || e.id === `input-${title}`)[nth];
|
|
89
89
|
return [`question-${id}`, text?.value ?? null];
|
|
90
90
|
}))"""
|
|
91
91
|
|
|
@@ -228,16 +228,25 @@ class Gupy:
|
|
|
228
228
|
page.locator(QUESTIONS).first.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
229
229
|
for field_id, answer in answers.items():
|
|
230
230
|
if (question := self._questions.get(field_id)) is not None:
|
|
231
|
-
_answer(page, self._fields[field_id], question, answer)
|
|
231
|
+
_answer(page, self._fields[field_id], question, answer, self._nth(field_id))
|
|
232
232
|
|
|
233
|
-
def
|
|
234
|
-
"""
|
|
235
|
-
|
|
233
|
+
def _by_title(self) -> dict[str, list[str]]:
|
|
234
|
+
"""A text is known only by its title, which questions can share: the page's controls of
|
|
235
|
+
one title are taken as its questions, the answered ones first."""
|
|
236
236
|
by_title: dict[str, list[str]] = {}
|
|
237
237
|
for field_id, question in self._questions.items():
|
|
238
238
|
by_title.setdefault(question["title"], []).append(field_id)
|
|
239
239
|
for ids in by_title.values():
|
|
240
240
|
ids.sort(key=lambda i: i not in self._given)
|
|
241
|
+
return by_title
|
|
242
|
+
|
|
243
|
+
def _nth(self, field_id: str) -> int:
|
|
244
|
+
"""Which of the controls titled as its question is this field's."""
|
|
245
|
+
return self._by_title()[self._questions[field_id]["title"]].index(field_id)
|
|
246
|
+
|
|
247
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
248
|
+
"""Additional information as it was saved, and the questions the page renders."""
|
|
249
|
+
by_title = self._by_title()
|
|
241
250
|
shown = list(self._additional_shown)
|
|
242
251
|
for control in page.evaluate(_SHOWN_QUESTIONS) if page.locator(QUESTIONS).count() else []:
|
|
243
252
|
ids = by_title.get(control.removeprefix("input-"))
|
|
@@ -252,7 +261,7 @@ class Gupy:
|
|
|
252
261
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
253
262
|
"""Additional information as it was saved, or as the page holds it; the questions'."""
|
|
254
263
|
additional = self._additional or self._read_additional(page)
|
|
255
|
-
ids = [[q["questionId"], q["title"]] for q in self._questions.
|
|
264
|
+
ids = [[q["questionId"], q["title"], self._nth(i)] for i, q in self._questions.items()]
|
|
256
265
|
values = page.evaluate(_READ_QUESTIONS, ids) if page.locator(QUESTIONS).count() else {}
|
|
257
266
|
return {**additional, **{k: v for k, v in values.items() if v not in (None, "", [])}}
|
|
258
267
|
|
|
@@ -346,7 +355,7 @@ def _set(page: Page, field: Field, field_id: str, answer: Answer) -> None:
|
|
|
346
355
|
raise Unsupported(f"unhandled_rendering: cannot fill {field_id}, a {kind}")
|
|
347
356
|
|
|
348
357
|
|
|
349
|
-
def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer) -> None:
|
|
358
|
+
def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer, nth: int) -> None:
|
|
350
359
|
qid = question["questionId"]
|
|
351
360
|
match field.type, answer:
|
|
352
361
|
case FieldType.CHOICE, str():
|
|
@@ -361,7 +370,7 @@ def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer)
|
|
|
361
370
|
target = page.locator(f'textarea[name="{_css(title)}"], input[name="{_css(title)}"]')
|
|
362
371
|
if not target.count():
|
|
363
372
|
target = page.locator(f'[id="input-{_css(title)}"]')
|
|
364
|
-
target.
|
|
373
|
+
target.nth(nth).fill(answer)
|
|
365
374
|
case kind, _:
|
|
366
375
|
raise Unsupported(f"unhandled_rendering: cannot fill question-{qid}, a {kind}")
|
|
367
376
|
|
|
@@ -17,7 +17,7 @@ from urllib.parse import parse_qs, urljoin, urlsplit
|
|
|
17
17
|
|
|
18
18
|
from atsfill.core import html
|
|
19
19
|
from atsfill.core.adapter import Gap, Unsupported, known_gap
|
|
20
|
-
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
20
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout, machine_locale
|
|
21
21
|
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
22
22
|
|
|
23
23
|
ORIGIN = "https://jobaffinity.fr"
|
|
@@ -32,6 +32,7 @@ UPLOAD_TIMEOUT_MS = 30_000
|
|
|
32
32
|
ANALYSIS_TIMEOUT_MS = 30_000
|
|
33
33
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
34
34
|
YES_NO = ["Yes", "No"]
|
|
35
|
+
BROWSER_LANGUAGE = "en-US" # what the browser sends on a machine with no locale
|
|
35
36
|
CONSENT = "gdpr_agreement"
|
|
36
37
|
AVAILABILITY, AVAILABLE_FROM, FROM_DATE = "availability", "availability_date", "date"
|
|
37
38
|
DMY = "JJ/MM/AAAA" # the hint under the older form's text date input
|
|
@@ -223,7 +224,10 @@ def _token(url: str) -> str | None:
|
|
|
223
224
|
|
|
224
225
|
|
|
225
226
|
def _get(url: str) -> str:
|
|
226
|
-
|
|
227
|
+
"""The page as the browser gets it: the server localizes the country list."""
|
|
228
|
+
language = machine_locale() or BROWSER_LANGUAGE
|
|
229
|
+
headers = {"User-Agent": "atsfill", "Accept-Language": language}
|
|
230
|
+
request = urllib.request.Request(url, headers=headers)
|
|
227
231
|
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
228
232
|
return response.read().decode()
|
|
229
233
|
|
|
@@ -14,7 +14,7 @@ from typing import Any
|
|
|
14
14
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
15
15
|
|
|
16
16
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
17
|
-
from atsfill.core.browser import Page
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
18
18
|
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
19
19
|
|
|
20
20
|
HOST = "recruitingbypaycor.com"
|
|
@@ -43,6 +43,7 @@ KINDS = {
|
|
|
43
43
|
}
|
|
44
44
|
STEPS = 6 # pages before the form: the EEO introduction, its two pages, the questions
|
|
45
45
|
PAGE_TIMEOUT_MS = 30_000
|
|
46
|
+
CONTROL_TIMEOUT_MS = 10_000 # a radio stays disabled until the answer it hangs on is taken
|
|
46
47
|
UPLOAD_TIMEOUT_MS = 60_000
|
|
47
48
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
48
49
|
|
|
@@ -255,11 +256,7 @@ class Paycor:
|
|
|
255
256
|
case "select", str():
|
|
256
257
|
named.select_option(label=answer)
|
|
257
258
|
case "radio", str():
|
|
258
|
-
named.
|
|
259
|
-
page.wait_for_function(
|
|
260
|
-
"n => [...document.getElementsByName(n)].some(e => !e.disabled)",
|
|
261
|
-
arg=field.id,
|
|
262
|
-
)
|
|
259
|
+
self._until_enabled(page, named, field.id)
|
|
263
260
|
self._pick(named, field, [answer])
|
|
264
261
|
case "checkbox", list():
|
|
265
262
|
self._pick(named, field, answer)
|
|
@@ -268,6 +265,20 @@ class Paycor:
|
|
|
268
265
|
f"unhandled_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
|
|
269
266
|
)
|
|
270
267
|
|
|
268
|
+
def _until_enabled(self, page: Page, radios: Locator, field_id: str) -> None:
|
|
269
|
+
"""Waits for a radio group the page enables on an earlier answer: the opt-in, on a phone
|
|
270
|
+
it takes."""
|
|
271
|
+
try:
|
|
272
|
+
radios.first.wait_for(state="attached", timeout=CONTROL_TIMEOUT_MS)
|
|
273
|
+
page.wait_for_function(
|
|
274
|
+
"n => [...document.getElementsByName(n)].some(e => !e.disabled)",
|
|
275
|
+
arg=field_id,
|
|
276
|
+
timeout=CONTROL_TIMEOUT_MS,
|
|
277
|
+
)
|
|
278
|
+
except Timeout:
|
|
279
|
+
detail = f"{field_id} stays disabled: the page refused an answer before it"
|
|
280
|
+
raise NotTaken(detail) from None
|
|
281
|
+
|
|
271
282
|
def shown_fields(self, page: Page) -> list[str]:
|
|
272
283
|
"""The form's controls; the answers to the pages before it are hidden inputs by now."""
|
|
273
284
|
return [c["id"] for c in page.evaluate(_CONTROLS)]
|
|
@@ -214,8 +214,8 @@ class Rippling:
|
|
|
214
214
|
return None
|
|
215
215
|
|
|
216
216
|
def browser_read(self, page: Page) -> list[Field]:
|
|
217
|
-
job
|
|
218
|
-
|
|
217
|
+
if (job := _job(page.url)) is None: # a closed posting redirects to its board
|
|
218
|
+
raise Unsupported(f"posting_gone: redirected to {page.url}")
|
|
219
219
|
apply = APPLY.format(board=job[0], job=job[1])
|
|
220
220
|
if urlsplit(page.url).path != urlsplit(apply).path:
|
|
221
221
|
page.goto(apply)
|
|
@@ -514,7 +514,7 @@ def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
|
|
|
514
514
|
if kind == "file":
|
|
515
515
|
return Field(field_id, label, FieldType.FILE, raw["required"], accept=raw["accept"])
|
|
516
516
|
if raw.get("readonly"): # the account's name, changed on its profile only
|
|
517
|
-
reason = f"unsupported_rendering: {field_id} is read-only"
|
|
517
|
+
reason = f"unsupported_rendering: {field_id} is read-only"
|
|
518
518
|
return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
|
|
519
519
|
unfillable=reason) # fmt: skip
|
|
520
520
|
options = raw["options"] if kind in ("select", "radio") else None
|
|
@@ -28,6 +28,7 @@ UPLOADED = "[data-action=delete]" # an uploading file shows its name and an abo
|
|
|
28
28
|
FORM_TIMEOUT_MS = 30_000
|
|
29
29
|
OPTIONS_TIMEOUT_MS = 5_000
|
|
30
30
|
CHECK_TIMEOUT_MS = 3_000
|
|
31
|
+
CHECK_CLICKS = 3 # a form can lose a click on an option
|
|
31
32
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
32
33
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
33
34
|
KEY_DELAY_MS = 20
|
|
@@ -361,16 +362,18 @@ def _pick(page: Page, field: Locator, role: str, value: str) -> None:
|
|
|
361
362
|
|
|
362
363
|
|
|
363
364
|
def _check(option: Locator, checked: bool) -> None:
|
|
364
|
-
"""Clicks a radio or checkbox unless it already is `checked
|
|
365
|
-
|
|
365
|
+
"""Clicks a radio or checkbox unless it already is `checked`, again where the form lost the
|
|
366
|
+
click, as it can the first of a page. Its `aria-checked` is the form's state: the hidden
|
|
367
|
+
input it holds is remounted, and catches up, on a later render."""
|
|
366
368
|
state = "true" if checked else "false"
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
369
|
+
for _ in range(CHECK_CLICKS):
|
|
370
|
+
if option.get_attribute("aria-checked") == state:
|
|
371
|
+
return
|
|
372
|
+
browser.click(option)
|
|
373
|
+
with suppress(Timeout):
|
|
374
|
+
option.and_(option.page.locator(f"[aria-checked={state}]")).wait_for(
|
|
375
|
+
timeout=CHECK_TIMEOUT_MS
|
|
376
|
+
)
|
|
374
377
|
|
|
375
378
|
|
|
376
379
|
def _options(page: Page, field_id: str) -> list[str]:
|
|
@@ -240,7 +240,7 @@ def emit(
|
|
|
240
240
|
def _slim(result: dict, command: str | None) -> dict:
|
|
241
241
|
"""A form command's result without what the consumer already has: the URL it passed and,
|
|
242
242
|
after `read`, the fields, the side effects and each answer it gave."""
|
|
243
|
-
del result["url"]
|
|
243
|
+
del result["url"], result["refused"]
|
|
244
244
|
if command != "read":
|
|
245
245
|
del result["fields"], result["side_effects"]
|
|
246
246
|
result["checks"] = [
|
|
@@ -354,6 +354,12 @@ def _shown_as_listed(
|
|
|
354
354
|
result.reason = f"mismatch: {', '.join(wrong)} shows though its condition is unmet"
|
|
355
355
|
|
|
356
356
|
|
|
357
|
+
def _refuse(result: Result, problems: list[matching.Problem], *more: str) -> None:
|
|
358
|
+
result.status = Status.REFUSED
|
|
359
|
+
result.reason = "invalid_answers: " + "; ".join([*problems, *more])
|
|
360
|
+
result.refused = [f"{p.field_id}: {p.rule}" for p in problems]
|
|
361
|
+
|
|
362
|
+
|
|
357
363
|
def _no_field(result: Result, answers: dict[str, Answer]) -> bool:
|
|
358
364
|
"""Refuses an answer to no listed field. Ids come from `read`, so it says as much about the
|
|
359
365
|
adapter as about the answers: a form that changed, or a field read missed."""
|
|
@@ -376,8 +382,11 @@ def _revealed(
|
|
|
376
382
|
if _unfillable(result, mine):
|
|
377
383
|
return mine
|
|
378
384
|
if problems := matching.problems(result.fields, mine):
|
|
379
|
-
|
|
380
|
-
|
|
385
|
+
_refuse(
|
|
386
|
+
result,
|
|
387
|
+
problems,
|
|
388
|
+
*[f"{f.id} takes {f.options}" for f in new if f.options and f.id not in mine],
|
|
389
|
+
)
|
|
381
390
|
return mine
|
|
382
391
|
left = [f for f in new if f.id not in mine]
|
|
383
392
|
result.warnings += [f"an answer showed {f.id} ({f.label}), left empty" for f in left]
|
|
@@ -459,7 +468,7 @@ def _fill(
|
|
|
459
468
|
return result
|
|
460
469
|
given = matching.as_listed(result.fields, given)
|
|
461
470
|
if problems := matching.problems(result.fields, given):
|
|
462
|
-
result
|
|
471
|
+
_refuse(result, problems)
|
|
463
472
|
return result
|
|
464
473
|
result.warnings = matching.knockouts(result.fields, given)
|
|
465
474
|
adapter.fill(page, matching.to_fill(result.fields, given))
|
|
@@ -695,7 +704,7 @@ def _save_profile(
|
|
|
695
704
|
result.status, result.reason = step_result.status, step_result.reason
|
|
696
705
|
return
|
|
697
706
|
if problems := matching.problems(fields, mine):
|
|
698
|
-
result
|
|
707
|
+
_refuse(result, problems)
|
|
699
708
|
return
|
|
700
709
|
checks = matching.check(fields, mine, adapter.profile_read_back(page))
|
|
701
710
|
if not all(c.match for c in checks):
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import re
|
|
4
4
|
import unicodedata
|
|
5
5
|
from pathlib import Path, PurePath
|
|
6
|
+
from typing import Self
|
|
6
7
|
from urllib.parse import urlsplit
|
|
7
8
|
|
|
8
9
|
from atsfill.core.conditions import Form, describe
|
|
@@ -10,6 +11,19 @@ from atsfill.core.countries import ALPHA_3
|
|
|
10
11
|
from atsfill.core.results import Answer, Check, Field, FieldType
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
class Problem(str):
|
|
15
|
+
"""What refuses an answer, in words that can quote it. `field_id` and `rule` never do: the
|
|
16
|
+
id is a listed field's and the rule one of this module's, so telemetry can carry them."""
|
|
17
|
+
|
|
18
|
+
field_id: str
|
|
19
|
+
rule: str
|
|
20
|
+
|
|
21
|
+
def __new__(cls, field_id: str, rule: str, text: str) -> Self:
|
|
22
|
+
problem = super().__new__(cls, text)
|
|
23
|
+
problem.field_id, problem.rule = field_id, rule
|
|
24
|
+
return problem
|
|
25
|
+
|
|
26
|
+
|
|
13
27
|
def _norm(value: str) -> str:
|
|
14
28
|
return unicodedata.normalize("NFC", value).strip()
|
|
15
29
|
|
|
@@ -97,15 +111,18 @@ def check(
|
|
|
97
111
|
]
|
|
98
112
|
|
|
99
113
|
|
|
100
|
-
def _answer_problem(field: Field, answer: object, form: Form) -> str | None:
|
|
114
|
+
def _answer_problem(field: Field, answer: object, form: Form) -> tuple[str, str] | None:
|
|
115
|
+
"""The rule an answer breaks, and how."""
|
|
101
116
|
if field.type is FieldType.MULTI_CHOICE:
|
|
102
117
|
if not (isinstance(answer, list) and all(isinstance(v, str) for v in answer)):
|
|
103
|
-
return "a multi_choice answer is a list of strings"
|
|
118
|
+
return "shape", "a multi_choice answer is a list of strings"
|
|
104
119
|
if not answer:
|
|
105
|
-
|
|
120
|
+
if form.asked(field):
|
|
121
|
+
return "missing", "required, so an empty list does not answer it"
|
|
122
|
+
return None
|
|
106
123
|
values = answer
|
|
107
124
|
elif not isinstance(answer, str):
|
|
108
|
-
return f"a {field.type} answer is a string"
|
|
125
|
+
return "shape", f"a {field.type} answer is a string"
|
|
109
126
|
else:
|
|
110
127
|
values = [answer]
|
|
111
128
|
if field.type in (FieldType.CHOICE, FieldType.MULTI_CHOICE) and not _in_options(
|
|
@@ -113,26 +130,26 @@ def _answer_problem(field: Field, answer: object, form: Form) -> str | None:
|
|
|
113
130
|
):
|
|
114
131
|
held = form.option_set(field)
|
|
115
132
|
under = f", the options when {describe(held.when, form.by_id)}" if held else ""
|
|
116
|
-
return f"{answer!r} is not one of {form.options(field)}{under}"
|
|
133
|
+
return "not_an_option", f"{answer!r} is not one of {form.options(field)}{under}"
|
|
117
134
|
length = len(_norm(values[0]))
|
|
118
135
|
if (
|
|
119
136
|
field.type in (FieldType.TEXT, FieldType.URL)
|
|
120
137
|
and field.max_length
|
|
121
138
|
and length > field.max_length
|
|
122
139
|
):
|
|
123
|
-
return f"{length} characters, over the limit of {field.max_length}"
|
|
140
|
+
return "too_long", f"{length} characters, over the limit of {field.max_length}"
|
|
124
141
|
if field.type is FieldType.TEXT and field.min_length and length < field.min_length:
|
|
125
|
-
return f"{length} characters, under the minimum of {field.min_length}"
|
|
142
|
+
return "too_short", f"{length} characters, under the minimum of {field.min_length}"
|
|
126
143
|
if field.type is FieldType.URL and not _absolute_url(values[0]):
|
|
127
|
-
return "a url answer is an absolute http(s) URL"
|
|
144
|
+
return "shape", "a url answer is an absolute http(s) URL"
|
|
128
145
|
if field.type is FieldType.DATE and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", values[0]):
|
|
129
|
-
return "a date answer is YYYY-MM-DD"
|
|
146
|
+
return "shape", "a date answer is YYYY-MM-DD"
|
|
130
147
|
if field.type is FieldType.FILE:
|
|
131
148
|
path = Path(values[0])
|
|
132
149
|
if not path.is_absolute():
|
|
133
|
-
return "a file answer is an absolute path"
|
|
150
|
+
return "shape", "a file answer is an absolute path"
|
|
134
151
|
if not path.is_file():
|
|
135
|
-
return f"no file at {path}"
|
|
152
|
+
return "no_file", f"no file at {path}"
|
|
136
153
|
return None
|
|
137
154
|
|
|
138
155
|
|
|
@@ -166,39 +183,37 @@ def as_listed(fields: list[Field], answers: dict[str, Answer]) -> dict[str, Answ
|
|
|
166
183
|
return out
|
|
167
184
|
|
|
168
185
|
|
|
169
|
-
def problems(fields: list[Field], answers: dict[str, Answer]) -> list[
|
|
186
|
+
def problems(fields: list[Field], answers: dict[str, Answer]) -> list[Problem]:
|
|
170
187
|
"""What refuses answers to the form's fields before typing: missing required answers, more
|
|
171
188
|
than one alternative answered, answers to follow-ups the answers keep hidden, bad values."""
|
|
172
189
|
by_id = {f.id: f for f in fields}
|
|
173
190
|
form = Form(fields, known(fields, answers))
|
|
174
191
|
out = [
|
|
175
|
-
f"no answer for required {f.id} ({f.label})"
|
|
192
|
+
Problem(f.id, "missing", f"no answer for required {f.id} ({f.label})")
|
|
176
193
|
for f in fields
|
|
177
194
|
if form.required(f) and f.prefilled is None and not f.unfillable and f.id not in answers
|
|
178
195
|
]
|
|
179
196
|
for key, members in form.groups.items():
|
|
180
197
|
given = [m.id for m in members if m.id in answers]
|
|
181
198
|
if len(given) > 1:
|
|
182
|
-
out.append(f"{key}: one of {', '.join(given)}, not both")
|
|
199
|
+
out.append(Problem(key, "both", f"{key}: one of {', '.join(given)}, not both"))
|
|
183
200
|
elif form.group_required(key) and not given and all(m.prefilled is None for m in members):
|
|
184
201
|
labels = " or ".join(dict.fromkeys(m.label for m in members))
|
|
185
|
-
out.append(f"no answer for required {key} ({labels})")
|
|
202
|
+
out.append(Problem(key, "missing", f"no answer for required {key} ({labels})"))
|
|
186
203
|
out += [
|
|
187
|
-
f"{i}: not asked, it shows when {describe(f.shown_when, by_id)}"
|
|
204
|
+
Problem(i, "not_asked", f"{i}: not asked, it shows when {describe(f.shown_when, by_id)}")
|
|
188
205
|
for i in answers
|
|
189
206
|
if (f := by_id.get(i)) and f.shown_when and not form.shown(f)
|
|
190
207
|
]
|
|
191
208
|
out += [
|
|
192
|
-
f"{i}: cannot be filled ({f.unfillable})"
|
|
209
|
+
Problem(i, "unfillable", f"{i}: cannot be filled ({f.unfillable})")
|
|
193
210
|
for i in answers
|
|
194
211
|
if (f := by_id.get(i)) and f.unfillable
|
|
195
212
|
]
|
|
196
213
|
out += [
|
|
197
|
-
f"{i}: {
|
|
214
|
+
Problem(i, broken[0], f"{i}: {broken[1]}")
|
|
198
215
|
for i, a in answers.items()
|
|
199
|
-
if i in by_id
|
|
200
|
-
and not by_id[i].unfillable
|
|
201
|
-
and (problem := _answer_problem(by_id[i], a, form))
|
|
216
|
+
if i in by_id and not by_id[i].unfillable and (broken := _answer_problem(by_id[i], a, form))
|
|
202
217
|
]
|
|
203
218
|
return out
|
|
204
219
|
|
|
@@ -108,6 +108,7 @@ class Result:
|
|
|
108
108
|
notice: str | None = None # telemetry's, the first time it is on (ADR 0016)
|
|
109
109
|
report: str | None = None # how to send a failed run's report, when it was not sent
|
|
110
110
|
warnings: list[str] = field(default_factory=list) # the knockout answers a fill gave
|
|
111
|
+
refused: list[str] = field(default_factory=list) # telemetry's `field_id: rule` per problem
|
|
111
112
|
|
|
112
113
|
|
|
113
114
|
@dataclass
|
|
@@ -134,6 +134,7 @@ def event(command: str, result: Result) -> dict:
|
|
|
134
134
|
"verdict": verdict(result),
|
|
135
135
|
"status": result.status.value,
|
|
136
136
|
"reason": _code(result.reason),
|
|
137
|
+
"refused": result.refused or None,
|
|
137
138
|
"version": version("atsfill"),
|
|
138
139
|
"posting": None if command == "profile" else posting_hash(_posting(result)),
|
|
139
140
|
"source": source,
|
|
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
|