atsfill 0.0.4.dev99__tar.gz → 0.0.4.dev100__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 (47) hide show
  1. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/bamboohr.py +26 -10
  5. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/oracle.py +12 -3
  6. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/paycor.py +48 -1
  7. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/phenom.py +73 -28
  8. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/cli.py +7 -2
  9. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/conditions.py +17 -4
  10. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/matching.py +26 -10
  11. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/results.py +9 -0
  12. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/LICENSE +0 -0
  13. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/README.md +0 -0
  14. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/__init__.py +0 -0
  15. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/__init__.py +0 -0
  16. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/ashby.py +0 -0
  17. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/hireology.py +0 -0
  22. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/hiringthing.py +0 -0
  23. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/jazzhr.py +0 -0
  24. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/lever.py +0 -0
  25. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/manatal.py +0 -0
  26. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/paylocity.py +0 -0
  27. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/personio.py +0 -0
  28. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/pinpoint.py +0 -0
  29. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/recruitee.py +0 -0
  30. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/rippling.py +0 -0
  31. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/smartrecruiters.py +0 -0
  32. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/teamtailor.py +0 -0
  33. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/workable.py +0 -0
  34. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/workday.py +0 -0
  35. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/wttj.py +0 -0
  36. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/adapters/zohorecruit.py +0 -0
  37. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/__init__.py +0 -0
  38. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/accounts.py +0 -0
  39. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/adapter.py +0 -0
  40. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/attempts.py +0 -0
  41. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/browser.py +0 -0
  42. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/commands.py +0 -0
  43. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/py.typed +0 -0
  47. {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev100}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev99
3
+ Version: 0.0.4.dev100
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.4.dev99"
3
+ version = "0.0.4.dev100"
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.4.dev99"
3
+ version = "0.0.4.dev100"
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"
@@ -17,7 +17,7 @@ from urllib.parse import urlsplit
17
17
  from atsfill.core import browser
18
18
  from atsfill.core.adapter import Blocked, Gap, Unsupported
19
19
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
20
- from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
20
+ from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
21
21
 
22
22
  HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
23
23
  JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
@@ -411,25 +411,41 @@ def _form(
411
411
  if key == "countryId" and country:
412
412
  by_id = {str(o["id"]): o["text"] for o in entry.get("options") or []}
413
413
  shown = by_id.get(str(country), country if country in by_id.values() else None)
414
+ option_sets = None
414
415
  if key == "state" and options:
415
- options = _states(states, country)
416
- if key == "state" and not options:
416
+ countries = result["formFields"].get("countryId") or {}
417
+ options, option_sets = None, _states(states, countries.get("options") or [])
418
+ if key == "state" and not option_sets:
417
419
  kind = "text"
418
420
  if kind == "veteran":
419
421
  extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
420
422
  options = list(VETERAN) + list(extra.values())
421
423
  accept = FILE_TYPES if kind == "file" else None
422
- field = Field(key, label, TYPES[kind], required, options, None, accept, default=shown)
424
+ field = Field(
425
+ key,
426
+ label,
427
+ TYPES[kind],
428
+ required,
429
+ options,
430
+ None,
431
+ accept,
432
+ default=shown,
433
+ option_sets=option_sets,
434
+ )
423
435
  out.append((field, (kind, name, extra)))
424
436
  return out
425
437
 
426
438
 
427
- def _states(states: dict[str, Any], default: str | None) -> list[str]:
428
- """The state lists of every country that has one, the default country's (an id) first:
429
- the list the page shows follows the country answered."""
430
- order = sorted(STATE_LISTS, key=lambda c: c != default)
431
- names = [s["full_name"] for c in order for s in (states.get(c) or {}).get("data") or []]
432
- return list(dict.fromkeys(names))
439
+ def _states(states: dict[str, Any], countries: list[dict[str, Any]]) -> list[OptionSet]:
440
+ """The state list of each country that has one, under that country as the form lists it;
441
+ any other country's state is typed."""
442
+ by_id = {str(o["id"]): o["text"] for o in countries}
443
+ return [
444
+ OptionSet(Is("countryId", [by_id[c]]), names)
445
+ for c in STATE_LISTS
446
+ if c in by_id
447
+ and (names := [s["full_name"] for s in (states.get(c) or {}).get("data") or []])
448
+ ]
433
449
 
434
450
 
435
451
  def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
18
18
  from atsfill.core import browser, matching
19
19
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
20
20
  from atsfill.core.browser import Locator, Page
21
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
+ from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
22
22
 
23
23
  JOB = re.compile(r"/hcmUI/CandidateExperience/[\w-]+/sites/[^/]+/job/(\d+)")
24
24
  EMAIL = "email"
@@ -517,15 +517,24 @@ def _click(page: Page, target: Locator) -> None:
517
517
  def _geography(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
518
518
  """Leaves one field of the address's geography (state, city, postal code), the most
519
519
  specific: its items name the levels above (`0200, Acton, ACT`), and picking one fills
520
- them. It is required when any level is."""
520
+ them. It is required when any level is, and a dependent list of the country."""
521
521
  levels = [(r, f) for r, f in described if r.get("geo") and f.id != COUNTRY]
522
522
  if len(levels) < 2 or not any(f.options for _, f in levels):
523
523
  return described
524
524
  parts = lambda f: max(len(o.split(",")) for o in f.options or [""]) # noqa: E731
525
525
  kept = max(levels, key=lambda rf: parts(rf[1]))
526
526
  required = any(f.required for _, f in levels)
527
+ asked = replace(kept[1], required=required)
528
+ country = next((f.default for _, f in described if f.id == COUNTRY), None)
529
+ if isinstance(country, str):
530
+ # its items are the country's the page shows; another's cost a request
531
+ asked = replace(
532
+ asked,
533
+ options=None,
534
+ option_sets=[OptionSet(Is(COUNTRY, [country]), asked.options or [])],
535
+ )
527
536
  return [
528
- (r, replace(f, required=required) if (r, f) == kept else f)
537
+ (r, asked if (r, f) == kept else f)
529
538
  for r, f in described
530
539
  if (r, f) == kept or (r, f) not in levels
531
540
  ]
@@ -7,13 +7,15 @@ to `ApplyJob.action` on Submit. `browser_read` walks those pages to the form, `f
7
7
  again with the answers.
8
8
  """
9
9
 
10
+ import json
10
11
  import re
12
+ from dataclasses import replace
11
13
  from typing import Any
12
14
  from urllib.parse import parse_qs, urlencode, urlsplit
13
15
 
14
16
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
15
17
  from atsfill.core.browser import Page
16
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
+ from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
17
19
 
18
20
  HOST = "recruitingbypaycor.com"
19
21
  CAREER = f"https://{HOST}/career/"
@@ -120,6 +122,20 @@ _HIDDEN = """name => {
120
122
  }"""
121
123
 
122
124
 
125
+ # Each country select with its state select, and the page scripts' text, which holds every
126
+ # country's state list (`var ca_country_states = [...]`).
127
+ _COUNTRIES = """() => ({
128
+ countries: [...document.querySelectorAll('#gnewtonForm select[data-state-id]')].map(c => ({
129
+ name: c.name,
130
+ state: document.getElementById(c.dataset.stateId)?.name,
131
+ options: [...c.options].filter(o => o.value)
132
+ .map(o => [o.value, o.textContent.replace(/\\s+/g, ' ').trim()]),
133
+ })),
134
+ scripts: [...document.scripts].map(s => s.textContent).join('\\n'),
135
+ })"""
136
+ STATE_LIST = re.compile(r"var (\w+)_country_states\s*=\s*(\[[^\]]*\])")
137
+
138
+
123
139
  def _ids(url: str) -> tuple[str, str] | None:
124
140
  """The client and job ids of a Paycor career URL, or of an employer page opening it."""
125
141
  query = parse_qs(urlsplit(url).query)
@@ -138,6 +154,36 @@ def _field(control: dict[str, Any]) -> Field:
138
154
  return Field(fid, label, KINDS[kind], control["required"], control["options"], control["max"])
139
155
 
140
156
 
157
+ def _dependent(controls: list[tuple[Field, str]], page: dict[str, Any]) -> list[tuple[Field, str]]:
158
+ """A state select under its country: the page's list for each country that has one, none
159
+ for any other, where the page disables the state and drops its `required`. Unanswered, the
160
+ country leaves the list the page starts with."""
161
+ lists = {
162
+ code: [s if isinstance(s, str) else s.get("value", "") for s in json.loads(names)]
163
+ for code, names in STATE_LIST.findall(page["scripts"])
164
+ }
165
+ under = {c["state"]: c for c in page["countries"] if c["state"]}
166
+ out = []
167
+ for field, kind in controls:
168
+ if (country := under.get(field.id)) and kind == "select":
169
+ sets = [OptionSet(Is(country["name"], negated=True), field.options or [])]
170
+ sets += [
171
+ OptionSet(Is(country["name"], [text]), lists[code.lower()])
172
+ for code, text in country["options"]
173
+ if code.lower() in lists
174
+ ]
175
+ having = [text for code, text in country["options"] if code.lower() in lists]
176
+ field = replace(
177
+ field,
178
+ required=False,
179
+ required_when=Is(country["name"], having) if field.required else None,
180
+ options=[],
181
+ option_sets=sets,
182
+ )
183
+ out.append((field, kind))
184
+ return out
185
+
186
+
141
187
  class Paycor:
142
188
  name = "paycor"
143
189
  known_gaps: dict[str, Gap] = {}
@@ -175,6 +221,7 @@ class Paycor:
175
221
  self._before, self._codes = [], {}
176
222
  self._walk(page, None)
177
223
  controls = [(_field(c), c["kind"]) for c in page.evaluate(_CONTROLS)]
224
+ controls = _dependent(controls, page.evaluate(_COUNTRIES))
178
225
  self._controls = [(f, kind) for f, kind in controls if not f.unfillable]
179
226
  return [f for f, _ in self._before] + [f for f, _ in controls]
180
227
 
@@ -25,7 +25,17 @@ from urllib.parse import parse_qs, urlsplit
25
25
  from atsfill.core import browser, matching
26
26
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
27
27
  from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Locator, Overlaid, Page, Timeout
28
- from atsfill.core.results import AllOf, Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
28
+ from atsfill.core.results import (
29
+ AllOf,
30
+ Answer,
31
+ AnyOf,
32
+ Condition,
33
+ Field,
34
+ FieldType,
35
+ Is,
36
+ OptionSet,
37
+ SideEffect,
38
+ )
29
39
 
30
40
  JOB = re.compile(r"^(/[a-z]{2,8}/[a-z]{2})/(job|\w*apply)(?:/|$)")
31
41
  APPLY = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/\w*apply$") # `apply`, `hvhapply` for hourly roles
@@ -648,6 +658,18 @@ class _Config:
648
658
  if isinstance(v, dict) and v.get("htmlType") == "sourceTypeSchema"),
649
659
  None,
650
660
  ) # fmt: skip
661
+ if raw["parent"]: # the sources under each "how you heard" answer
662
+ scope = path.rpartition(".")[0]
663
+ at = f"{scope}.{raw['parent']}" if scope else raw["parent"]
664
+ raw["option_sets"] = [
665
+ OptionSet(Is(at, [self.text(parent.get("title"))]), _unique(
666
+ self.text(child.get("key"))
667
+ for child in parent.get("child") or []
668
+ if child.get("show") is not False
669
+ ))
670
+ for parent in u.get("data") or []
671
+ ] # fmt: skip
672
+ return None
651
673
  return _unique(
652
674
  self.text(child.get("key"))
653
675
  for parent in u.get("data") or []
@@ -672,7 +694,7 @@ class _Config:
672
694
  pairs = self.dependent_list(raw, u.get("dependentKey") or parents[0], api)
673
695
  elif html == "asyncselect" and api and api.get("payload"):
674
696
  pairs = self.loaded_list(u, api)
675
- elif table := self.source_table(path.rsplit(".", 1)[-1]):
697
+ elif table := self.source_table(raw):
676
698
  pairs = table
677
699
  else:
678
700
  values = p.get("enum") or get(p, "items.enum") or []
@@ -685,6 +707,8 @@ class _Config:
685
707
  listed = ([v for v, _ in pairs], [t for _, t in pairs])
686
708
  self.listed[path] = listed
687
709
  self.listed.setdefault(path.rsplit(".", 1)[-1], listed) # as a workflow names it
710
+ if raw.get("option_sets"): # a dependent list's, under its parents' answers before any
711
+ return None
688
712
  return _unique(t for _, t in pairs) or None
689
713
 
690
714
  def loaded_list(self, u: dict, api: dict) -> list[tuple[Any, str]]:
@@ -723,10 +747,10 @@ class _Config:
723
747
 
724
748
  def dependent_list(self, raw: dict, parent: str, request: dict) -> list[tuple[Any, str]]:
725
749
  """A dependent list's options under its parent's answer before any: the parent's
726
- default, else the job's country where the parent lists it, else its first option. The
727
- label names that answer."""
728
- scope = raw["id"].rpartition(".")[0] # a parent in the same entry, else at the top
729
- at = f"{scope}.{parent}" if f"{scope}.{parent}" in self.labels else parent
750
+ default, else the job's country where the parent lists it, else its first option. Its
751
+ one option set is keyed to that answer, and to another parent's where the page holds
752
+ one; under any other answer its options are unknown, each set costing a request."""
753
+ at = self.scoped(raw, parent)
730
754
  value, shown = self.prefilled(parent)
731
755
  values, texts = self.listed.get(at) or self.listed.get(parent) or ([], [])
732
756
  country = re.sub(r"\W", "", self.ddo.get("jobCountry") or "").lower()
@@ -744,8 +768,11 @@ class _Config:
744
768
  if value is None:
745
769
  return []
746
770
  payload = request.get("payload") or ""
771
+ when: list[Condition] = [Is(at, [shown])]
747
772
  for name in ANSWER.findall(payload):
748
- held = value if name == parent else self.prefilled(name)[0]
773
+ held, held_shown = (value, shown) if name == parent else self.prefilled(name)
774
+ if name != parent and held is not None:
775
+ when.append(Is(self.scoped(raw, name), [held_shown]))
749
776
  payload = payload.replace(f"%%formData.{name}%%", str(held or ""))
750
777
  data = get(self.call(payload), "applyGetReferences.data")
751
778
  if isinstance(data, dict):
@@ -756,41 +783,55 @@ class _Config:
756
783
  if isinstance(d, dict)
757
784
  ]
758
785
  if pairs:
759
- raw["label"] += f" (options for {self.labels.get(at, parent)!r} {shown!r})"
786
+ options = _unique(t for _, t in pairs if t)
787
+ raw["option_sets"] = [OptionSet(when[0] if len(when) == 1 else AllOf(when), options)]
760
788
  raw["parent"] = at
761
789
  return pairs
762
790
 
763
- def source_table(self, name: str) -> list[tuple[Any, str]]:
791
+ def source_table(self, raw: dict) -> list[tuple[Any, str]]:
764
792
  """A list Phenom's applicant-source workflow fills from its own table: the sources,
765
- or the sub-sources under every source, in order."""
793
+ or the sub-sources under every source, in order. A table keyed by the value of the
794
+ field the workflow follows is a dependent list: one option set per source."""
766
795
  for flow in self.config.get("workflow") or []:
767
796
  nodes = flow.get("workflows") or []
768
- if name not in _targets(nodes):
797
+ if raw["id"].rsplit(".", 1)[-1] not in _targets(nodes):
769
798
  continue
770
799
  for node in nodes:
771
800
  for table in (
772
801
  get(node, "data.config") or [] if node.get("type") == "temp_data" else []
773
802
  ):
774
803
  data = table.get("data") if table.get("key") == "jsonData" else None
775
- rows = (
776
- data
777
- if isinstance(data, list)
778
- else [
779
- row
780
- for rows in (data or {}).values()
781
- if isinstance(rows, list)
782
- for row in rows
804
+ groups = data if isinstance(data, dict) else {None: data}
805
+ pairs = {
806
+ key: [
807
+ (r.get("value"), self.text(r.get("display")))
808
+ for r in rows
809
+ if isinstance(r, dict)
783
810
  ]
784
- )
785
- pairs = [
786
- (r.get("value"), self.text(r.get("display")))
787
- for r in rows
788
- if isinstance(r, dict)
789
- ]
790
- if pairs:
791
- return pairs
811
+ for key, rows in groups.items()
812
+ if isinstance(rows, list)
813
+ }
814
+ if not any(pairs.values()):
815
+ continue
816
+ parent = get(flow, "params.field")
817
+ at = parent and self.scoped(raw, parent)
818
+ values, texts = self.listed.get(at or "") or ([], [])
819
+ shown = dict(zip(map(str, values), texts, strict=False))
820
+ if isinstance(data, dict) and shown:
821
+ raw["option_sets"] = [
822
+ OptionSet(Is(at, [shown[key]]), _unique(t for _, t in rows if t))
823
+ for key, rows in pairs.items()
824
+ if key in shown
825
+ ]
826
+ raw["parent"] = at
827
+ return [pair for rows in pairs.values() for pair in rows]
792
828
  return []
793
829
 
830
+ def scoped(self, raw: dict, name: str) -> str:
831
+ """A field `raw` names: in the same entry, else at the top."""
832
+ scope = raw["id"].rpartition(".")[0]
833
+ return f"{scope}.{name}" if f"{scope}.{name}" in self.labels else name
834
+
794
835
  def prefilled(self, name: str) -> tuple[Any, str]:
795
836
  """The answer a list holds before any: the page's value it names, else its default."""
796
837
  prop = get(self.config, f"schema.properties.{name}") or {}
@@ -1133,7 +1174,10 @@ def _field(raw: dict[str, Any]) -> Field:
1133
1174
  if kind == "checkbox": # an optional box starts clear
1134
1175
  options = YES_NO if raw["required"] else NO_YES
1135
1176
  label = raw["label"] + {"month": MONTH, "year": YEAR}.get(kind, "")
1136
- field_type = FieldType.TYPEAHEAD if kind == "typeahead" and not options else TYPES[kind]
1177
+ sets = raw.get("option_sets")
1178
+ field_type = (
1179
+ FieldType.TYPEAHEAD if kind == "typeahead" and not options and not sets else TYPES[kind]
1180
+ )
1137
1181
  return Field(
1138
1182
  raw["id"],
1139
1183
  label,
@@ -1145,6 +1189,7 @@ def _field(raw: dict[str, Any]) -> Field:
1145
1189
  default=raw.get("default"),
1146
1190
  shown_when=raw.get("when"),
1147
1191
  required_when=raw.get("required_when"),
1192
+ option_sets=sets,
1148
1193
  )
1149
1194
 
1150
1195
 
@@ -212,10 +212,15 @@ def _slim(result: dict, command: str | None) -> dict:
212
212
 
213
213
 
214
214
  def _sparse(value: object) -> object:
215
- """Drops the keys whose value is null, false or empty, at every depth."""
215
+ """Drops the keys whose value is null, false or empty, at every depth; empty `options`
216
+ stay, nothing to choose, where null is unknown."""
216
217
  if isinstance(value, dict):
217
218
  kept = {k: _sparse(v) for k, v in value.items()}
218
- return {k: v for k, v in kept.items() if v not in (None, False, [], {})}
219
+ return {
220
+ k: v
221
+ for k, v in kept.items()
222
+ if v not in (None, False, [], {}) or (k == "options" and v == [])
223
+ }
219
224
  if isinstance(value, list):
220
225
  return [_sparse(v) for v in value]
221
226
  return value
@@ -1,6 +1,6 @@
1
1
  """Whether a field shows, and whether it is required, from the answers alone (ADR 0005)."""
2
2
 
3
- from atsfill.core.results import AllOf, Answer, AnyOf, Condition, Field, Is
3
+ from atsfill.core.results import AllOf, Answer, AnyOf, Condition, Field, Is, OptionSet
4
4
 
5
5
 
6
6
  def _parents(condition: Condition) -> list[str]:
@@ -24,17 +24,22 @@ def alternatives(fields: list[Field]) -> dict[str, list[Field]]:
24
24
 
25
25
  def faults(fields: list[Field]) -> list[str]:
26
26
  """What makes the fields' conditions unusable: a field they name that is not listed, a
27
- field shown by its own answer through any chain, a group of alternatives with one member or
28
- members disagreeing on `required`."""
27
+ field shown by its own answer through any chain, options that follow their own field, a
28
+ group of alternatives with one member or members disagreeing on `required`."""
29
29
  by_id = {f.id: f for f in fields}
30
30
  out = [
31
31
  f"{f.id}: its condition names no field {p}"
32
32
  for f in fields
33
- for c in (f.shown_when, f.required_when)
33
+ for c in (f.shown_when, f.required_when, *(s.when for s in f.option_sets or []))
34
34
  if c
35
35
  for p in _parents(c)
36
36
  if p not in by_id
37
37
  ]
38
+ out += [
39
+ f"{f.id}: its options follow its own answer"
40
+ for f in fields
41
+ if any(f.id in _parents(s.when) for s in f.option_sets or [])
42
+ ]
38
43
 
39
44
  def loops(field_id: str, seen: tuple[str, ...]) -> bool:
40
45
  if field_id in seen:
@@ -94,6 +99,14 @@ class Form:
94
99
  when = field.required_when
95
100
  return field.required or (when is not None and self.holds(when))
96
101
 
102
+ def option_set(self, field: Field) -> OptionSet | None:
103
+ """The first of a dependent list's sets that holds under the answers."""
104
+ return next((s for s in field.option_sets or [] if self.holds(s.when)), None)
105
+
106
+ def options(self, field: Field) -> list[str] | None:
107
+ held = self.option_set(field)
108
+ return field.options if held is None else held.options
109
+
97
110
  def _value(self, field_id: str) -> Answer | None:
98
111
  field = self.by_id[field_id]
99
112
  if not self.shown(field):
@@ -13,8 +13,16 @@ def _norm(value: str) -> str:
13
13
  return unicodedata.normalize("NFC", value).strip()
14
14
 
15
15
 
16
- def _in_options(field: Field, values: list[str]) -> bool:
17
- return field.options is None or all(v in field.options for v in values)
16
+ def _options(field: Field, form: Form | None) -> list[str] | None:
17
+ """The options an answer is one of; without the form, a dependent list's are unknown."""
18
+ if form is not None:
19
+ return form.options(field)
20
+ return None if field.option_sets else field.options
21
+
22
+
23
+ def _in_options(field: Field, values: list[str], form: Form | None) -> bool:
24
+ options = _options(field, form)
25
+ return options is None or all(v in options for v in values)
18
26
 
19
27
 
20
28
  def _picked_from(answer: str, suggestion: str) -> bool:
@@ -32,14 +40,16 @@ def _absolute_url(value: str) -> bool:
32
40
  return parts.scheme in ("http", "https") and bool(parts.netloc)
33
41
 
34
42
 
35
- def matches(field: Field, answer: Answer, read_back: Answer | None) -> bool:
43
+ def matches(
44
+ field: Field, answer: Answer, read_back: Answer | None, form: Form | None = None
45
+ ) -> bool:
36
46
  if read_back is None:
37
47
  return False
38
48
  match field.type:
39
49
  case FieldType.MULTI_CHOICE:
40
50
  a, r = [answer] if isinstance(answer, str) else answer, read_back
41
51
  r = [r] if isinstance(r, str) else r
42
- return sorted(a) == sorted(r) and _in_options(field, a)
52
+ return sorted(a) == sorted(r) and _in_options(field, a, form)
43
53
  if not isinstance(answer, str) or not isinstance(read_back, str):
44
54
  return False
45
55
  match field.type:
@@ -48,7 +58,7 @@ def matches(field: Field, answer: Answer, read_back: Answer | None) -> bool:
48
58
  case FieldType.DATE:
49
59
  return answer == read_back
50
60
  case FieldType.CHOICE:
51
- return answer == read_back and _in_options(field, [answer])
61
+ return answer == read_back and _in_options(field, [answer], form)
52
62
  case FieldType.TYPEAHEAD:
53
63
  return _picked_from(answer, read_back)
54
64
  case FieldType.FILE:
@@ -65,7 +75,7 @@ def check(
65
75
  value is a mismatch too, its condition read wrong."""
66
76
  by_id, form = {f.id: f for f in fields}, Form(fields, answers)
67
77
  checks = [
68
- Check(f.id, a, read_back.get(f.id), matches(f, a, read_back.get(f.id)))
78
+ Check(f.id, a, read_back.get(f.id), matches(f, a, read_back.get(f.id), form))
69
79
  for f_id, a in answers.items()
70
80
  if (f := by_id.get(f_id))
71
81
  ]
@@ -76,7 +86,7 @@ def check(
76
86
  ]
77
87
 
78
88
 
79
- def _answer_problem(field: Field, answer: object) -> str | None:
89
+ def _answer_problem(field: Field, answer: object, form: Form) -> str | None:
80
90
  if field.type is FieldType.MULTI_CHOICE:
81
91
  if not (isinstance(answer, list) and all(isinstance(v, str) for v in answer)):
82
92
  return "a multi_choice answer is a list of strings"
@@ -85,8 +95,12 @@ def _answer_problem(field: Field, answer: object) -> str | None:
85
95
  return f"a {field.type} answer is a string"
86
96
  else:
87
97
  values = [answer]
88
- if field.type in (FieldType.CHOICE, FieldType.MULTI_CHOICE) and not _in_options(field, values):
89
- return f"{answer!r} is not one of {field.options}"
98
+ if field.type in (FieldType.CHOICE, FieldType.MULTI_CHOICE) and not _in_options(
99
+ field, values, form
100
+ ):
101
+ held = form.option_set(field)
102
+ under = f", the options when {describe(held.when, form.by_id)}" if held else ""
103
+ return f"{answer!r} is not one of {form.options(field)}{under}"
90
104
  length = len(_norm(values[0]))
91
105
  if (
92
106
  field.type in (FieldType.TEXT, FieldType.URL)
@@ -138,6 +152,8 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
138
152
  out += [
139
153
  f"{i}: {problem}"
140
154
  for i, a in answers.items()
141
- if i in by_id and not by_id[i].unfillable and (problem := _answer_problem(by_id[i], a))
155
+ if i in by_id
156
+ and not by_id[i].unfillable
157
+ and (problem := _answer_problem(by_id[i], a, form))
142
158
  ]
143
159
  return out
@@ -50,6 +50,14 @@ class AllOf:
50
50
  type Condition = Is | AnyOf | AllOf
51
51
 
52
52
 
53
+ @dataclass(frozen=True)
54
+ class OptionSet:
55
+ """A dependent list's options while `when` holds; `[]` leaves nothing to choose."""
56
+
57
+ when: Condition
58
+ options: list[str]
59
+
60
+
53
61
  @dataclass(frozen=True)
54
62
  class Field:
55
63
  id: str
@@ -65,6 +73,7 @@ class Field:
65
73
  shown_when: Condition | None = None # a follow-up: shown only when it holds (ADR 0005)
66
74
  required_when: Condition | None = None # required once shown when it holds, if not always
67
75
  one_of: str | None = None # an alternative: one of the fields with this key answers (ADR 0005)
76
+ option_sets: list[OptionSet] | None = None # a dependent list's; `options` where none holds
68
77
 
69
78
 
70
79
  @dataclass(frozen=True)
File without changes
File without changes