atsfill 0.0.8.dev223__tar.gz → 0.0.8.dev224__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.
Files changed (69) hide show
  1. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/PKG-INFO +1 -1
  2. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/pyproject.toml +1 -1
  3. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/adp.py +6 -2
  5. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/eightfold.py +9 -0
  6. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/gupy.py +18 -9
  7. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/jobaffinity.py +6 -2
  8. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/paycor.py +17 -6
  9. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/rippling.py +2 -2
  10. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/ukg.py +1 -1
  11. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/workable.py +12 -9
  12. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/cli.py +1 -1
  13. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/commands.py +13 -4
  14. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/matching.py +36 -21
  15. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/results.py +1 -0
  16. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/telemetry.py +1 -0
  17. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/LICENSE +0 -0
  18. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/README.md +0 -0
  19. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/__init__.py +0 -0
  20. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/__init__.py +0 -0
  21. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/adpmyjobs.py +0 -0
  22. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/ashby.py +0 -0
  23. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/bamboohr.py +0 -0
  24. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/breezy.py +0 -0
  25. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/dayforce.py +0 -0
  26. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/greenhouse.py +0 -0
  27. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/hireology.py +0 -0
  28. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/hiringthing.py +0 -0
  29. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/hrmdirect.py +0 -0
  30. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/isolvedhire.py +0 -0
  31. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/jazzhr.py +0 -0
  32. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/jobstreet.py +0 -0
  33. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/join.py +0 -0
  34. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/lever.py +0 -0
  35. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/manatal.py +0 -0
  36. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/oracle.py +0 -0
  37. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/paylocity.py +0 -0
  38. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/personio.py +0 -0
  39. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/phenom.py +0 -0
  40. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/pinpoint.py +0 -0
  41. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/recruitee.py +0 -0
  42. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/recruiterflow.py +0 -0
  43. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/smartrecruiters.py +0 -0
  44. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/successfactors.js +0 -0
  45. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/successfactors.py +0 -0
  46. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/taleo.py +0 -0
  47. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/teamtailor.py +0 -0
  48. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/welcomekit.py +0 -0
  49. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/wellfound.py +0 -0
  50. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/werecruit.py +0 -0
  51. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/workatastartup.py +0 -0
  52. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/workday.py +0 -0
  53. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/wttj.py +0 -0
  54. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/adapters/zohorecruit.py +0 -0
  55. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/__init__.py +0 -0
  56. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/accounts.py +0 -0
  57. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/adapter.py +0 -0
  58. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/attempts.py +0 -0
  59. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/browser.py +0 -0
  60. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/conditions.py +0 -0
  61. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/countries.py +0 -0
  62. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/html.py +0 -0
  63. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/otp.py +0 -0
  64. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/passwords.py +0 -0
  65. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/paths.py +0 -0
  66. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/registry.py +0 -0
  67. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/core/snapshot.py +0 -0
  68. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.8.dev223 → atsfill-0.0.8.dev224}/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.dev223
3
+ Version: 0.0.8.dev224
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.8.dev223"
3
+ version = "0.0.8.dev224"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.8.dev223"
3
+ version = "0.0.8.dev224"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- page.locator(NEXT).click()
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:
@@ -241,6 +241,15 @@ class Eightfold:
241
241
  continue
242
242
  _fill(page, control, answer)
243
243
  given[control.field.id] = answer
244
+ self._again(page, given)
245
+
246
+ def _again(self, page: Page, given: dict[str, Answer]) -> None:
247
+ """Fills once more an answered control a later render emptied."""
248
+ placed = [c for c in self._controls if c.dom and given.get(c.field.id)]
249
+ values = self._values(page, placed)
250
+ for control in placed:
251
+ if not values.get(control.field.id):
252
+ _fill(page, control, given[control.field.id])
244
253
 
245
254
  def shown_fields(self, page: Page) -> list[str]:
246
255
  """The questions rendered once filled, each control's by the longest question id its
@@ -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
- .find(e => e.name === title || e.id === `input-${title}`);
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 shown_fields(self, page: Page) -> list[str]:
234
- """Additional information as it was saved, and the questions the page renders: a text
235
- is known only by its title, which questions can share."""
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.values()]
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.first.fill(answer)
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
- request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
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.first.wait_for(state="attached")
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 = _job(page.url)
218
- assert job is not None
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" if not value else None
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`. Its `aria-checked` is the
365
- form's state: the hidden input it holds is remounted, and catches up, on a later render."""
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
- if option.get_attribute("aria-checked") == state:
368
- return
369
- browser.click(option)
370
- with suppress(Timeout):
371
- option.and_(option.page.locator(f"[aria-checked={state}]")).wait_for(
372
- timeout=CHECK_TIMEOUT_MS
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
- 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)
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.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
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.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
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
- return "required, so an empty list does not answer it" if form.asked(field) else None
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[str]:
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}: {problem}"
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