atsfill 0.0.7.dev209__tar.gz → 0.0.7.dev212__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.7.dev209 → atsfill-0.0.7.dev212}/PKG-INFO +2 -2
  2. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/pyproject.toml +2 -2
  3. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/pyproject.toml.orig +2 -2
  4. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/ashby.py +15 -3
  5. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/adapter.py +10 -0
  6. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/browser.py +12 -13
  7. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/commands.py +47 -11
  8. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/matching.py +34 -5
  9. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/telemetry.py +2 -1
  10. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/LICENSE +0 -0
  11. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/README.md +0 -0
  12. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/__init__.py +0 -0
  14. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/adp.py +0 -0
  15. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/adpmyjobs.py +0 -0
  16. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/jobaffinity.py +0 -0
  28. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/jobstreet.py +0 -0
  29. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/join.py +0 -0
  30. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/lever.py +0 -0
  31. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/manatal.py +0 -0
  32. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/oracle.py +0 -0
  33. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/paycor.py +0 -0
  34. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/paylocity.py +0 -0
  35. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/personio.py +0 -0
  36. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/phenom.py +0 -0
  37. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/pinpoint.py +0 -0
  38. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/recruitee.py +0 -0
  39. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/recruiterflow.py +0 -0
  40. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/rippling.py +0 -0
  41. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/smartrecruiters.py +0 -0
  42. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/successfactors.js +0 -0
  43. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/successfactors.py +0 -0
  44. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/taleo.py +0 -0
  45. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/teamtailor.py +0 -0
  46. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/ukg.py +0 -0
  47. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/welcomekit.py +0 -0
  48. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/wellfound.py +0 -0
  49. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/werecruit.py +0 -0
  50. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/workable.py +0 -0
  51. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/workatastartup.py +0 -0
  52. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/workday.py +0 -0
  53. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/wttj.py +0 -0
  54. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/zohorecruit.py +0 -0
  55. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/cli.py +0 -0
  56. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/__init__.py +0 -0
  57. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/accounts.py +0 -0
  58. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/attempts.py +0 -0
  59. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/conditions.py +0 -0
  60. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/countries.py +0 -0
  61. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/html.py +0 -0
  62. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/otp.py +0 -0
  63. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/passwords.py +0 -0
  64. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/paths.py +0 -0
  65. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/registry.py +0 -0
  66. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/results.py +0 -0
  67. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/snapshot.py +0 -0
  68. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/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.dev209
3
+ Version: 0.0.7.dev212
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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Office/Business
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: camoufox==0.5.8
19
+ Requires-Dist: camoufox==0.5.9b6
20
20
  Requires-Dist: filelock==4.1.1
21
21
  Requires-Dist: keyring==25.7.0
22
22
  Requires-Dist: packaging==26.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.7.dev209"
3
+ version = "0.0.7.dev212"
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"
@@ -28,7 +28,7 @@ classifiers = [
28
28
  "Typing :: Typed",
29
29
  ]
30
30
  dependencies = [
31
- "camoufox==0.5.8",
31
+ "camoufox==0.5.9b6",
32
32
  "filelock==4.1.1",
33
33
  "keyring==25.7.0",
34
34
  "packaging==26.3",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.7.dev209"
3
+ version = "0.0.7.dev212"
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"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "camoufox==0.5.8",
24
+ "camoufox==0.5.9b6",
25
25
  "filelock==4.1.1",
26
26
  "keyring==25.7.0",
27
27
  "packaging==26.3",
@@ -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 = list(answers)
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 read_back(self, page: Page) -> dict[str, Answer]:
205
- """Ashby's last returned form, once every answer is held and no request is in flight."""
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:
@@ -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
@@ -18,10 +18,10 @@ from urllib.parse import urljoin
18
18
  from urllib.request import Request as HttpRequest
19
19
  from urllib.request import urlopen
20
20
 
21
- from camoufox.addons import ADDONS_DIR, DefaultAddons, get_addon_path, maybe_download_addons
21
+ from camoufox.addons import DefaultAddons, get_addon_path
22
22
  from camoufox.exceptions import CamoufoxNotInstalled
23
23
  from camoufox.fingerprints import Screen, generate_fingerprint
24
- from camoufox.pkgman import installed_verstr
24
+ from camoufox.pkgman import INSTALL_DIR, installed_verstr, launch_path
25
25
  from camoufox.sync_api import Camoufox
26
26
  from filelock import FileLock
27
27
  from filelock import Timeout as LockTimeout
@@ -58,8 +58,8 @@ BUDGET = 180.0 # seconds per command, lock wait included
58
58
  PROFILE_BUDGET = 300.0 # seconds for a `profile` run, which may save every profile step
59
59
  LOCK_POLL_S = 0.1
60
60
  HTTP_TIMEOUT_S = 20 # per request to a hiring system's API
61
- BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
62
- BLOCKER = ADDONS_DIR / "UBO-annoyances" # Camoufox's uBlock Origin, its annoyance lists on
61
+ BUILD = "158.0.1-beta.41" # the Camoufox browser build, pinned so results compare across machines
62
+ BLOCKER = INSTALL_DIR / "atsfill" / f"UBO-{BUILD}" # the build's uBlock Origin, annoyance lists on
63
63
  BLOCKER_LISTS = "annoyances" # the group of uBlock Origin lists: cookie banners, pop-ups, widgets
64
64
  # The adapters' filters for the banners the lists miss: a list of the blocker's, on like them.
65
65
  FILTERS = "assets/atsfill/filters.txt"
@@ -88,14 +88,13 @@ def version() -> str:
88
88
 
89
89
 
90
90
  def install() -> int:
91
- """Fetches the pinned browser build unless it is installed, then the add-ons every launch
92
- loads, so that browsers started side by side do not each download them."""
91
+ """Fetches the pinned browser build unless it is installed, then builds the blocker every
92
+ launch loads, so that browsers started side by side do not each build it."""
93
93
  if version() != BUILD:
94
- subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
94
+ subprocess.call([sys.executable, "-m", "camoufox", "fetch"]) # the package's paired build
95
95
  if version() != BUILD: # `camoufox fetch` exits 0 when it fails
96
96
  print(f"the Camoufox build {BUILD} is not installed", file=sys.stderr)
97
97
  return 1
98
- maybe_download_addons(list(DefaultAddons))
99
98
  try:
100
99
  _build_blocker()
101
100
  except OSError as e:
@@ -107,12 +106,13 @@ def install() -> int:
107
106
  def _build_blocker() -> None:
108
107
  """Copies Camoufox's uBlock Origin to `BLOCKER` with only its annoyance lists on, read
109
108
  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."""
111
- ADDONS_DIR.mkdir(parents=True, exist_ok=True)
112
- with FileLock(ADDONS_DIR / f"{BLOCKER.name}.lock"):
109
+ list on is compiled by each launch before the blocker starts. Camoufox's policy keeps it
110
+ from updating them: its first update, 37s into a launch, left it hiding nothing."""
111
+ BLOCKER.parent.mkdir(parents=True, exist_ok=True)
112
+ with FileLock(BLOCKER.with_name(f"{BLOCKER.name}.lock")):
113
113
  build = BLOCKER.with_name(f"{BLOCKER.name}.build")
114
114
  shutil.rmtree(build, ignore_errors=True)
115
- shutil.copytree(get_addon_path(DefaultAddons.UBO.name), build)
115
+ shutil.copytree(get_addon_path(DefaultAddons.UBO.name, launch_path()), build)
116
116
  assets = build / "assets" / "assets.json"
117
117
  entries = json.loads(assets.read_text())
118
118
  for key, entry in entries.items():
@@ -186,7 +186,6 @@ def lock(data_dir: Path, deadline: float) -> Generator[None]:
186
186
  @contextmanager
187
187
  def _launch(data_dir: Path, headless: bool) -> Generator[Browser]:
188
188
  if not (BLOCKER / FILTERS).exists(): # installed before the blocker, or its filters, were
189
- maybe_download_addons(list(DefaultAddons))
190
189
  _build_blocker()
191
190
  _write_filters()
192
191
  with Camoufox(
@@ -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
- if _unfillable(result, answers):
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
- if problems := matching.problems(result.fields, answers):
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, answers)
429
- adapter.fill(page, matching.to_fill(result.fields, answers))
430
- _shown_as_listed(page, adapter, result, answers)
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, answers, adapter.read_back(page))
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
- listed = {f.id for f in result.fields}
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 a fill before typing: unknown ids, missing required answers, more than one
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
- out = [f"no field {i}" for i in answers if i not in by_id]
144
- form = Form(fields, {i: a for i, a in answers.items() if i in by_id})
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