atsfill 0.0.6.dev203__tar.gz → 0.0.6.dev205__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.6.dev203 → atsfill-0.0.6.dev205}/PKG-INFO +1 -1
  2. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/pyproject.toml +1 -1
  3. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/join.py +50 -11
  5. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/phenom.py +47 -16
  6. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/LICENSE +0 -0
  7. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/README.md +0 -0
  8. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/adp.py +0 -0
  11. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/adpmyjobs.py +0 -0
  12. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/bamboohr.py +0 -0
  14. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/breezy.py +0 -0
  15. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/dayforce.py +0 -0
  16. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/eightfold.py +0 -0
  17. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/greenhouse.py +0 -0
  18. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/gupy.py +0 -0
  19. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/hireology.py +0 -0
  20. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/hiringthing.py +0 -0
  21. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/hrmdirect.py +0 -0
  22. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/isolvedhire.py +0 -0
  23. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/jazzhr.py +0 -0
  24. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/jobaffinity.py +0 -0
  25. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/jobstreet.py +0 -0
  26. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/lever.py +0 -0
  27. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/manatal.py +0 -0
  28. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/oracle.py +0 -0
  29. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/paycor.py +0 -0
  30. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/paylocity.py +0 -0
  31. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/personio.py +0 -0
  32. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/pinpoint.py +0 -0
  33. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/recruitee.py +0 -0
  34. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/recruiterflow.py +0 -0
  35. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/rippling.py +0 -0
  36. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/smartrecruiters.py +0 -0
  37. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/successfactors.js +0 -0
  38. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/successfactors.py +0 -0
  39. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/taleo.py +0 -0
  40. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/teamtailor.py +0 -0
  41. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/ukg.py +0 -0
  42. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/welcomekit.py +0 -0
  43. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/wellfound.py +0 -0
  44. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/werecruit.py +0 -0
  45. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/workable.py +0 -0
  46. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/workatastartup.py +0 -0
  47. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/workday.py +0 -0
  48. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/wttj.py +0 -0
  49. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/adapters/zohorecruit.py +0 -0
  50. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/cli.py +0 -0
  51. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/__init__.py +0 -0
  52. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/accounts.py +0 -0
  53. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/adapter.py +0 -0
  54. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/attempts.py +0 -0
  55. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/browser.py +0 -0
  56. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/commands.py +0 -0
  57. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/conditions.py +0 -0
  58. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/countries.py +0 -0
  59. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/html.py +0 -0
  60. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/matching.py +0 -0
  61. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/otp.py +0 -0
  62. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/passwords.py +0 -0
  63. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/paths.py +0 -0
  64. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/registry.py +0 -0
  65. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/results.py +0 -0
  66. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/snapshot.py +0 -0
  67. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/core/telemetry.py +0 -0
  68. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.6.dev203 → atsfill-0.0.6.dev205}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.6.dev203
3
+ Version: 0.0.6.dev205
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.6.dev203"
3
+ version = "0.0.6.dev205"
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.6.dev203"
3
+ version = "0.0.6.dev205"
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"
@@ -48,6 +48,10 @@ SUBMIT_TIMEOUT_MS = 60_000
48
48
  BUTTON_TIMEOUT_MS = 2_000
49
49
  OPENING_TIMEOUT_S = 20
50
50
  CONTINUE_TIMEOUT_S = 15
51
+ ADVANCE_TIMEOUT_MS = 3_000 # how long a clicked card takes to move the wizard on by itself
52
+ INPUT_TIMEOUT_MS = 3_000 # how long a step takes to show its own input
53
+ CLICKS = 3 # a click on a step still rendering is lost, on a card or on Continue
54
+ LEAVE_TIMEOUT_MS = 5_000 # how long Continue takes to leave a step, per click
51
55
  POLL_MS = 250
52
56
  MAX_STEPS = 60
53
57
  MAX_MONTHS = 36 # how far the calendar is paged for a date
@@ -107,6 +111,17 @@ _SHOWN = """root => {
107
111
  return p ? p.textContent.trim() : "";
108
112
  }"""
109
113
 
114
+ # Continue, clicked in one go with the check that the wizard is still on the step: the footer's
115
+ # last button on `review` is Confirm & apply, the send, and a step can leave on its own.
116
+ _CONTINUE = """(button, step) => {
117
+ const on = location.pathname.replace(/\\/$/, "").split("/").pop() === step;
118
+ if (!on || button.type === "submit") return false;
119
+ button.click();
120
+ return true;
121
+ }"""
122
+
123
+ _CHECKED = """cards => cards.map(card => card.getAttribute("aria-checked") === "true")"""
124
+
110
125
  # The ids of the inputs that are empty, of those given.
111
126
  _EMPTY = (
112
127
  """ids => ids.filter(id => { const e = document.getElementById(id); return e && !e.value; })"""
@@ -387,10 +402,10 @@ class Join:
387
402
  if isinstance(answer, str):
388
403
  if answer not in options or options.index(answer) >= len(texts):
389
404
  raise NotTaken(f"no option {answer!r} in {field_id}: {texts}")
390
- card = cards.nth(options.index(answer))
391
- if card.get_attribute("aria-checked") != "true":
392
- card.click()
393
- checked = [c.get_attribute("aria-checked") == "true" for c in cards.all()]
405
+ if _take(page, cards, options.index(answer)):
406
+ self._seen[field_id] = answer # the wizard took it and moved on
407
+ return
408
+ checked = cards.evaluate_all(_CHECKED)
394
409
  if True in checked and checked.index(True) < len(options):
395
410
  self._seen[field_id] = options[checked.index(True)]
396
411
  case "DATE":
@@ -402,7 +417,9 @@ class Join:
402
417
  case _ if isinstance(answer, str):
403
418
  name = f"screeningQuestions.answer-{question['id']}"
404
419
  target = page.locator(f'[name="{name}"], [id="{name}"], #salaryExpectationAmount')
405
- if not target.count():
420
+ try: # the step before's input stays a moment after the URL has changed
421
+ target.first.wait_for(timeout=INPUT_TIMEOUT_MS)
422
+ except Timeout:
406
423
  target = page.locator(
407
424
  "textarea:visible, input:visible:not([type=file]):not([role=combobox])"
408
425
  )
@@ -419,19 +436,41 @@ def _continue(page: Page) -> Locator:
419
436
  return page.locator("button:visible:has(> [data-button-label])").last
420
437
 
421
438
 
439
+ def _take(page: Page, cards: Locator, index: int) -> bool:
440
+ """Clicks a card until it is checked, and says whether the wizard then moved on by itself."""
441
+ on = page.url
442
+ for _ in range(CLICKS):
443
+ if cards.evaluate_all(_CHECKED)[index]:
444
+ break
445
+ cards.nth(index).click()
446
+ try:
447
+ page.wait_for_url(lambda url: url != on, timeout=ADVANCE_TIMEOUT_MS)
448
+ except Timeout:
449
+ continue
450
+ return True
451
+ return False
452
+
453
+
422
454
  def _next(page: Page, step: str) -> None:
455
+ """Leaves the step with Continue, unless the wizard has already left it."""
423
456
  button = _continue(page)
424
457
  deadline = time.monotonic() + CONTINUE_TIMEOUT_S # an upload enables it once stored
425
- while button.is_disabled() and time.monotonic() < deadline:
458
+ while _step(page) == step and button.is_disabled() and time.monotonic() < deadline:
426
459
  page.wait_for_timeout(POLL_MS)
460
+ if _step(page) != step:
461
+ return
427
462
  if button.is_disabled():
428
463
  raise NotTaken(f"the {step} step keeps Continue disabled: a required answer is missing")
429
464
  on = page.url
430
- try:
431
- button.click(timeout=STEP_TIMEOUT_MS)
432
- page.wait_for_url(lambda url: url != on, timeout=STEP_TIMEOUT_MS)
433
- except Timeout:
434
- raise RuntimeError(f"Continue on the {step} step went nowhere") from None
465
+ for _ in range(CLICKS):
466
+ try:
467
+ if not button.evaluate(_CONTINUE, step, timeout=STEP_TIMEOUT_MS):
468
+ return # the wizard left the step meanwhile
469
+ page.wait_for_url(lambda url: url != on, timeout=LEAVE_TIMEOUT_MS)
470
+ except Timeout:
471
+ continue
472
+ return
473
+ raise RuntimeError(f"Continue on the {step} step went nowhere")
435
474
 
436
475
 
437
476
  def _question(q: dict[str, Any]) -> Field:
@@ -43,6 +43,8 @@ JOB = re.compile(r"^(/[a-z]{2,8}/[a-z]{2})/(job|\w*apply)(?:/|$)")
43
43
  APPLY = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/\w*apply$") # `apply`, `hvhapply` for hourly roles
44
44
  SEQ = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/job/([A-Za-z0-9]+)")
45
45
  CDN = "https://cdn-prod-static.phenompeople.com/CareerConnectResources"
46
+ RENDERER = "MULTI_PAGE_renderer" # the script of the page that has one, in its loader's data
47
+ MULTI = "multipage" # what `_phapp_of` adds to `phApp`
46
48
  FORM = "form.rjsf"
47
49
  NEXT = "button#next"
48
50
  TERMS = "terms" # a notice some sites' wizard opens on, its one button accepting it
@@ -110,6 +112,7 @@ TEMPLATE = re.compile(r"\{\{\s*(.*?)\s*\}\}")
110
112
  TAG = re.compile(r"<[^>]+>")
111
113
  PAGE_VALUE = re.compile(r"%%(phApp\.[\w.]+)%%")
112
114
  ANSWER = re.compile(r"%%formData\.(\w+)%%")
115
+ PLACEHOLDER = re.compile(r"(please )?(choose|select)( (one|an?)\b.*)?$", re.IGNORECASE)
113
116
  SEARCHING = re.compile(r"Searching", re.IGNORECASE) # a typeahead's menu before its results
114
117
  ACCEPTED = re.compile(r"\b(DOCX?|PDF|TXT|HTML?|RTF)\b", re.IGNORECASE)
115
118
 
@@ -216,12 +219,14 @@ _APPLY_LINK = """seq => {
216
219
  return own ? own.href : null;
217
220
  }"""
218
221
 
219
- # The errors a step shows, each with its field's control id.
222
+ # The errors a step shows, each with its field's control id and whether the page hides it.
220
223
  _ERRORS = """form => [...form.querySelectorAll('.error-detail li, .text-danger, .error-message')]
221
224
  .filter(e => e.innerText.trim())
222
225
  .map(e => {
223
226
  const control = e.closest('.form-group')?.querySelector('input, select, textarea');
224
- return [control?.id || control?.name || '?', e.innerText.trim()];
227
+ const hidden = !!control && !/^(checkbox|radio)$/.test(control.type)
228
+ && !control.getClientRects().length;
229
+ return [control?.id || control?.name || '?', e.innerText.trim(), hidden];
225
230
  }).slice(0, 4)"""
226
231
 
227
232
 
@@ -264,6 +269,7 @@ class Phenom:
264
269
  known_gaps = {
265
270
  "first_step": Gap.FLOW,
266
271
  "long_wizard": Gap.FLOW,
272
+ "hidden_required": Gap.FLOW,
267
273
  "calendar_without_month": Gap.RENDERING,
268
274
  }
269
275
  side_effects = [
@@ -493,6 +499,7 @@ def _phapp_of(html: str) -> dict[str, Any]:
493
499
  ph = _literal(html, "var phApp = phApp || ")
494
500
  ph["ddo"] = _literal(html, "phApp.ddo = ")
495
501
  ph["sessionParams"] = _literal(html, "phApp.sessionParams = ")
502
+ ph[MULTI] = RENDERER in html
496
503
  return ph
497
504
 
498
505
 
@@ -515,13 +522,11 @@ def _read(
515
522
  ph: dict[str, Any], fetch: Callable[[str], Any], post: Post
516
523
  ) -> tuple[list[Field], list[dict[str, Any]]]:
517
524
  """The fields of every step, from the configuration the page names, and their raw form."""
518
- ats = (get(ph, "ddo.response.response.applyATS") or "").lower()
519
- base = f"{CDN}/{ph['refNum']}/{ats}/external/careersite/singlepage"
520
- config = fetch(f"{base}/schema/form_schema.json")
525
+ config, bundles = _configuration(ph, fetch)
521
526
  texts: dict[str, str] = {}
522
527
  for bundle in ("default", ph.get("locale") or "en_us"):
523
528
  with suppress(Exception): # a site has one bundle or both
524
- texts |= fetch(f"{base}/bundles/Messages_{bundle}.json")
529
+ texts |= fetch(f"{bundles}/Messages_{bundle}.json")
525
530
  reader = _Config(ph, config, texts, post)
526
531
  raws = [raw for step in reader.steps() for raw in step]
527
532
  if not raws:
@@ -534,6 +539,27 @@ def _read(
534
539
  return ([resume] if resume else []) + [_field(raw) for raw in raws], raws
535
540
 
536
541
 
542
+ def _configuration(ph: dict[str, Any], fetch: Callable[[str], Any]) -> tuple[dict[str, Any], str]:
543
+ """The configuration the page's renderer loads, and where its bundles are: a site on the
544
+ multi-page renderer keeps a stale `singlepage` one, and its form under the page's own name
545
+ where its `multipage` path has none."""
546
+ site = f"{CDN}/{ph['refNum']}"
547
+ ats = (get(ph, "ddo.response.response.applyATS") or "").lower()
548
+ base = f"{site}/{ats}/external/careersite/{'multipage' if ph.get(MULTI) else 'singlepage'}"
549
+ try:
550
+ return fetch(f"{base}/schema/form_schema.json"), f"{base}/bundles"
551
+ except Exception:
552
+ if not ph.get(MULTI):
553
+ raise
554
+ names = [
555
+ get(node, "data.page.schemaname")
556
+ for node in fetch(f"{site}/schema/apply/MULTI_PAGE_form_schema.json")
557
+ if node.get("type") == "page"
558
+ ]
559
+ form = next((name for name in names if name != TERMS), TERMS)
560
+ return fetch(f"{site}/schema/apply/multipage/{form}.json"), f"{site}/bundles/apply"
561
+
562
+
537
563
  class _Config:
538
564
  """The wizard's steps, each its fields as raw dicts, from the employer's configuration:
539
565
  a react-jsonschema-form schema and uiSchema with Phenom's widgets, rules on the job's data,
@@ -703,7 +729,7 @@ class _Config:
703
729
  "datasourcepath": get(u, "ui:options.datasourcepath"),
704
730
  "displayTextField": get(u, "ui:options.dataFieldMapKey") or "label",
705
731
  }
706
- raw["options"] = _unique(t for _, t in self.loaded_list(listing, api)) or None
732
+ raw["options"] = _choices(t for _, t in self.loaded_list(listing, api)) or None
707
733
  if kind == "select" and re.fullmatch(r"\w+", path): # a default, the job's country
708
734
  raw["default"] = self.prefilled(path)[1] or None
709
735
  if kind in ("date", "month", "year"):
@@ -730,7 +756,7 @@ class _Config:
730
756
  scope = path.rpartition(".")[0]
731
757
  at = f"{scope}.{raw['parent']}" if scope else raw["parent"]
732
758
  raw["option_sets"] = [
733
- OptionSet(Is(at, [self.text(parent.get("title"))]), _unique(
759
+ OptionSet(Is(at, [self.text(parent.get("title"))]), _choices(
734
760
  self.text(child.get("key"))
735
761
  for child in parent.get("child") or []
736
762
  if child.get("show") is not False
@@ -738,7 +764,7 @@ class _Config:
738
764
  for parent in u.get("data") or []
739
765
  ] # fmt: skip
740
766
  return None
741
- return _unique(
767
+ return _choices(
742
768
  self.text(child.get("key"))
743
769
  for parent in u.get("data") or []
744
770
  for child in parent.get("child") or []
@@ -750,7 +776,7 @@ class _Config:
750
776
  if isinstance(v, dict) and v.get("htmlType") == "dependent_utm"),
751
777
  None,
752
778
  ) # fmt: skip
753
- return _unique(self.text(parent.get("title")) for parent in tree or [])
779
+ return _choices(self.text(parent.get("title")) for parent in tree or [])
754
780
  if kind == "radio" and not p.get("enum"):
755
781
  return YES_NO
756
782
  api = u.get("apiConfig") or get(u, "ui:options.apiConfig")
@@ -777,7 +803,7 @@ class _Config:
777
803
  self.listed.setdefault(path.rsplit(".", 1)[-1], listed) # as a workflow names it
778
804
  if raw.get("option_sets"): # a dependent list's, under its parents' answers before any
779
805
  return None
780
- return _unique(t for _, t in pairs) or None
806
+ return _choices(t for _, t in pairs) or None
781
807
 
782
808
  def loaded_list(self, u: dict, api: dict) -> list[tuple[Any, str]]:
783
809
  """A list its own loader serves, by the uiSchema's names for the response's parts."""
@@ -851,7 +877,7 @@ class _Config:
851
877
  if isinstance(d, dict)
852
878
  ]
853
879
  if pairs:
854
- options = _unique(t for _, t in pairs if t)
880
+ options = _choices(t for _, t in pairs if t)
855
881
  raw["option_sets"] = [OptionSet(when[0] if len(when) == 1 else AllOf(when), options)]
856
882
  raw["parent"] = at
857
883
  return pairs
@@ -887,7 +913,7 @@ class _Config:
887
913
  shown = dict(zip(map(str, values), texts, strict=False))
888
914
  if isinstance(data, dict) and shown:
889
915
  raw["option_sets"] = [
890
- OptionSet(Is(at, [shown[key]]), _unique(t for _, t in rows if t))
916
+ OptionSet(Is(at, [shown[key]]), _choices(t for _, t in rows if t))
891
917
  for key, rows in pairs.items()
892
918
  if key in shown
893
919
  ]
@@ -1291,8 +1317,10 @@ def _json_text(value: Any) -> str:
1291
1317
  return json.dumps("" if value is None else str(value))[1:-1]
1292
1318
 
1293
1319
 
1294
- def _unique(items: Iterable[str]) -> list[str]:
1295
- return list(dict.fromkeys(i for i in items if i))
1320
+ def _choices(items: Iterable[str]) -> list[str]:
1321
+ """A list's options, once each, without an entry that only asks to pick one: an employer's
1322
+ bundle can label a real value so, which no one means to answer."""
1323
+ return list(dict.fromkeys(i for i in items if i and not PLACEHOLDER.match(i)))
1296
1324
 
1297
1325
 
1298
1326
  def _seq(url: str) -> str | None:
@@ -1388,7 +1416,10 @@ def _next(page: Page, number: int) -> None:
1388
1416
  raise NotTaken(f"step {number}: {detail}")
1389
1417
  raise RuntimeError(f"step {number} did not pass: {code}: {detail}")
1390
1418
  if errors := page.locator(FORM).evaluate(_ERRORS) if page.locator(FORM).count() else []:
1391
- shown = "; ".join(f"{dom}: {message}" for dom, message in errors)
1419
+ if hidden := next((dom for dom, _, hides in errors if hides), None):
1420
+ detail = f"step {number} requires {hidden}, which the page hides"
1421
+ raise Unsupported(known_gap(Phenom(), "hidden_required", detail))
1422
+ shown = "; ".join(f"{dom}: {message}" for dom, message, _ in errors)
1392
1423
  raise RuntimeError(f"step {number} did not pass: {shown}")
1393
1424
  raise RuntimeError(f"step {number} did not pass in time")
1394
1425
  finally:
File without changes
File without changes