atsfill 0.0.4.dev92__tar.gz → 0.0.4.dev93__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.dev92 → atsfill-0.0.4.dev93}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/eightfold.py +41 -47
  5. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/teamtailor.py +21 -16
  6. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/adapter.py +10 -0
  7. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/commands.py +59 -8
  8. atsfill-0.0.4.dev93/src/atsfill/core/conditions.py +92 -0
  9. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/matching.py +19 -4
  10. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/results.py +26 -0
  11. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/LICENSE +0 -0
  12. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/README.md +0 -0
  13. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/__init__.py +0 -0
  14. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/__init__.py +0 -0
  15. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/greenhouse.py +0 -0
  20. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/hireology.py +0 -0
  21. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/hiringthing.py +0 -0
  22. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/jazzhr.py +0 -0
  23. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/lever.py +0 -0
  24. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/manatal.py +0 -0
  25. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/oracle.py +0 -0
  26. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/paycor.py +0 -0
  27. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/paylocity.py +0 -0
  28. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/personio.py +0 -0
  29. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/phenom.py +0 -0
  30. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/pinpoint.py +0 -0
  31. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/recruitee.py +0 -0
  32. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/rippling.py +0 -0
  33. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/smartrecruiters.py +0 -0
  34. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/workable.py +0 -0
  35. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/workday.py +0 -0
  36. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/wttj.py +0 -0
  37. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/zohorecruit.py +0 -0
  38. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/cli.py +0 -0
  39. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/__init__.py +0 -0
  40. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/accounts.py +0 -0
  41. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/attempts.py +0 -0
  42. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/browser.py +0 -0
  43. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/py.typed +0 -0
  47. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/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.dev92
3
+ Version: 0.0.4.dev93
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.dev92"
3
+ version = "0.0.4.dev93"
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.dev92"
3
+ version = "0.0.4.dev93"
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"
@@ -2,10 +2,10 @@
2
2
  the employer's own domain), as docs/adapters/eightfold.md observes.
3
3
 
4
4
  A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
5
- sections of questions by id, type, requirement and options, and the questions another one's
6
- answer shows. `read_api` fetches it over HTTP, `read_form` through the page's browser context,
7
- and both list the fields from it. Each question renders as a control whose element id is its
8
- section's title and its id, with every other character an underscore.
5
+ sections of questions by id, type, requirement and options, and the questions whose answer
6
+ shows each follow-up. `read_api` fetches it over HTTP, `read_form` through the page's browser
7
+ context, and both list the fields from it. Each question renders as a control whose element id
8
+ is its section's title and its id, with every other character an underscore.
9
9
  """
10
10
 
11
11
  import http.cookiejar
@@ -13,15 +13,15 @@ import json
13
13
  import re
14
14
  import urllib.request
15
15
  from contextlib import suppress
16
- from dataclasses import dataclass, field, replace
16
+ from dataclasses import dataclass, replace
17
17
  from datetime import date, datetime
18
18
  from typing import Any
19
19
  from urllib.parse import parse_qs, urlencode, urlsplit
20
20
 
21
- from atsfill.core import browser
21
+ from atsfill.core import browser, conditions
22
22
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
23
23
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
24
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
24
+ from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
25
25
 
26
26
  HOST = re.compile(r"^[\w-]+\.eightfold\.ai$")
27
27
  JOB = re.compile(r"^/careers/job/(\d+)")
@@ -33,7 +33,6 @@ CSRF = re.compile(r'<meta name="_csrf" content="([^"]+)"')
33
33
  SUBMIT = "[data-test-id=submitApplicationButton]"
34
34
  AGREE = "#confirmUploadResume" # the privacy dialog a resume upload opens
35
35
  PICKER = "[data-testid=picker-dialog]"
36
- MAX_DEPTH = 5 # the deepest chain of questions rendering one another that a label names"
37
36
  FORM_TIMEOUT_MS = 30_000
38
37
  SHOWN_TIMEOUT_MS = 2_000
39
38
  OPTIONS_TIMEOUT_MS = 5_000
@@ -131,8 +130,6 @@ class _Control:
131
130
  field: Field
132
131
  kind: str
133
132
  ends: list[str] # what the control's element id ends with
134
- # the questions whose answer renders it, each with the answers that do (any when empty)
135
- parents: list[tuple[str, list[str]]] = field(default_factory=list)
136
133
  resume: bool = False
137
134
  dom: str | None = None
138
135
 
@@ -152,6 +149,7 @@ class Eightfold:
152
149
 
153
150
  def __init__(self) -> None:
154
151
  self._controls: list[_Control] = [] # in the form's order
152
+ self._fields: list[Field] = []
155
153
 
156
154
  def detect(self, url: str, page: Page | None) -> bool:
157
155
  if _pid(url) is None:
@@ -210,14 +208,14 @@ class Eightfold:
210
208
  control.dom = doms.get(control.field.id)
211
209
  if control.field.unfillable:
212
210
  continue
213
- if control.dom is None and not control.parents:
211
+ if control.dom is None and not control.field.shown_when:
214
212
  reason = f"unhandled_rendering: {control.field.id} is not on the page"
215
213
  control.field = replace(control.field, unfillable=reason)
216
214
  elif control.dom is not None and _prefilled(page, control):
217
215
  control.field = replace(control.field, prefilled=True)
218
- fields = [c.field for c in self._controls]
216
+ self._fields = [c.field for c in self._controls]
219
217
  self._controls = [c for c in self._controls if not c.field.unfillable]
220
- return fields
218
+ return self._fields
221
219
 
222
220
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
223
221
  # the resume first: what its parse finds is written over the fields filled before it
@@ -226,8 +224,8 @@ class Eightfold:
226
224
  for control in order:
227
225
  if (answer := answers.get(control.field.id)) is None:
228
226
  continue
229
- if control.dom is None: # rendered once a question that shows it is answered
230
- if not any(_shows(given.get(p), values) for p, values in control.parents):
227
+ if control.dom is None: # a follow-up, rendered once its condition holds
228
+ if not conditions.Form(self._fields, given).shown(control.field):
231
229
  continue
232
230
  with suppress(Timeout):
233
231
  _box_of(page, control).wait_for(timeout=SHOWN_TIMEOUT_MS)
@@ -320,21 +318,23 @@ def _plain(label: str) -> str:
320
318
  return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", label)).strip()
321
319
 
322
320
 
323
- def _label(q: dict[str, Any], by_id: dict[str, dict[str, Any]], depth: int = 0) -> str:
324
- """The question's text; one some of a parent's answers render says so, naming the first
325
- parent by its own label, so that a parent asked for other answers names it apart."""
321
+ def _label(q: dict[str, Any]) -> str:
326
322
  label = _plain(q.get("label") or "")
327
323
  if (q.get("textOptions") or {}).get("type") == "number": # a number input takes no text
328
324
  label += " (a number)"
329
- first = (q.get("parentDependencies") or [{}])[0]
330
- if not first.get("values") or depth > MAX_DEPTH:
331
- return label
332
- parent = by_id.get(str(first["questionId"]), {})
333
- labels = {str(c["value"]): c["label"] for c in _choices(parent)}
334
- shown = " or ".join(labels.get(str(v), str(v)) for v in first["values"])
335
- named = _label(parent, by_id, depth + 1)
336
- quote = '"' if "'" in named else "'" # never escaped: the outermost is the last
337
- return f"{label} (asked when {quote}{named}{quote} is {shown})"
325
+ return label
326
+
327
+
328
+ def _shown_when(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> Condition | None:
329
+ """What renders a follow-up: any of its parents' answers it lists, any answer where it
330
+ lists none."""
331
+ parents: list[Condition] = []
332
+ for dependency in q.get("parentDependencies") or []:
333
+ parent = by_id.get(str(dependency["questionId"]), {})
334
+ labels = {str(c["value"]): c["label"] for c in _choices(parent)}
335
+ values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
336
+ parents.append(Is(str(dependency["questionId"]), values or None))
337
+ return parents[0] if len(parents) == 1 else AnyOf(parents) if parents else None
338
338
 
339
339
 
340
340
  def _choices(q: dict[str, Any]) -> list[dict[str, Any]]:
@@ -356,24 +356,17 @@ def _form(data: dict[str, Any]) -> list[_Control]:
356
356
 
357
357
 
358
358
  def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
359
- qid, label = str(q["questionId"]), _label(q, by_id)
359
+ qid, label, shown_when = str(q["questionId"]), _label(q), _shown_when(q, by_id)
360
360
  kind = KINDS.get(q.get("type", ""))
361
361
  choices = _choices(q)
362
362
  if kind == "select" and not choices and (q.get("selectOptions") or {}).get("apiEndpoint"):
363
363
  kind = "lookup" # options load once the question it depends on is answered
364
364
  required = bool(q.get("required"))
365
- parents = []
366
- for dependency in q.get("parentDependencies") or []:
367
- parent = by_id.get(str(dependency["questionId"]), {})
368
- labels = {str(c["value"]): c["label"] for c in _choices(parent)}
369
- values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
370
- parents.append((str(dependency["questionId"]), values))
371
- if parents and parents[0][1]: # asked for some of its parent's answers only
372
- required = False
373
365
  ends = list(dict.fromkeys([_norm(qid), qid]))
374
366
  if kind is None:
375
367
  reason = f"unhandled_rendering: {qid} is a question of type {q.get('type')}"
376
- return _Control(Field(qid, label, FieldType.TEXT, required, unfillable=reason), "", ends)
368
+ unfillable = Field(qid, label, FieldType.TEXT, required, unfillable=reason)
369
+ return _Control(replace(unfillable, shown_when=shown_when), "", ends)
377
370
  options = [c["label"] for c in choices] or None
378
371
  accept = None
379
372
  if kind == "file":
@@ -381,8 +374,17 @@ def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
381
374
  types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
382
375
  accept = [t.strip() for t in types if t.strip()] or None
383
376
  today = bool((q.get("dateOptions") or {}).get("today")) # today's date, read-only
384
- question = Field(qid, label, TYPES[kind], required, options, accept=accept, prefilled=today)
385
- return _Control(question, kind, ends, parents, q.get("type") == "resumeUpload")
377
+ question = Field(
378
+ qid,
379
+ label,
380
+ TYPES[kind],
381
+ required,
382
+ options,
383
+ accept=accept,
384
+ prefilled=today,
385
+ shown_when=shown_when,
386
+ )
387
+ return _Control(question, kind, ends, q.get("type") == "resumeUpload")
386
388
 
387
389
 
388
390
  def _find(page: Page, control: _Control) -> str | None:
@@ -533,13 +535,5 @@ def _iso(shown: str) -> str:
533
535
  return shown
534
536
 
535
537
 
536
- def _shows(answer: Answer | None, values: list[str]) -> bool:
537
- """Whether a parent's answer renders its dependent: any answer when `values` is empty."""
538
- if answer in (None, "", []):
539
- return False
540
- picks = [answer] if isinstance(answer, str) else answer
541
- return not values or any(p in values for p in picks)
542
-
543
-
544
538
  def _exact(value: str) -> re.Pattern[str]:
545
539
  return re.compile(rf"^\s*{re.escape(value)}\s*$")
@@ -9,6 +9,7 @@ video Teamtailor holds under the id the form keeps.
9
9
  import re
10
10
  import time
11
11
  import unicodedata
12
+ from dataclasses import replace
12
13
  from pathlib import PurePath
13
14
  from typing import Any
14
15
  from urllib.parse import urlsplit
@@ -16,7 +17,7 @@ from urllib.parse import urlsplit
16
17
  from atsfill.core import browser, matching
17
18
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
18
19
  from atsfill.core.browser import Locator, Page, Response
19
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
20
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
20
21
 
21
22
  FORM = "#job-application-form"
22
23
  # A custom question, or a qualifying one: the rest of the form stays disabled until it is
@@ -73,6 +74,7 @@ _READ = """(form, QUESTION) => {
73
74
  const fields = [];
74
75
  for (const el of form.querySelectorAll("input, textarea")) {
75
76
  if (el.closest(QUESTION) || el.disabled || fields.some(f => f.id === el.name)) continue;
77
+ if (el.type !== "file" && /_remote_url\\]$/.test(el.name)) continue; // an upload's address
76
78
  const kind = el.tagName === "TEXTAREA" ? "textarea"
77
79
  : el.name === "candidate[location][query]" ? "location"
78
80
  : el.type === "checkbox" && el.name.endsWith("[]") ? "choices" : el.type;
@@ -106,12 +108,13 @@ _READ = """(form, QUESTION) => {
106
108
  && form.querySelector(`[data-question-uuid="${q.dataset.questionShowIfUuid}"]`);
107
109
  const shownBy = parent && [...parent.querySelectorAll(PICKS)]
108
110
  .find(e => e.value === q.dataset.questionShowIfValue);
109
- const when = shownBy && ` (asked when "${label(parent.querySelector("legend, label"))}" is`
110
- + ` "${options(parent, [shownBy])[0]}")`;
111
- fields.push({id: `question:${qid}`, kind, question: true, qualifying, conditional: !!parent,
112
- label: label(q.querySelector("legend, label")) + (when || ""),
113
- required: qualifying || (!parent && q.dataset.questionMandatory === "true"),
114
- asked: q.dataset.questionMandatory === "true",
111
+ const parentId = parent?.querySelector("input[name$='[question_id]']")?.value;
112
+ fields.push({id: `question:${qid}`, kind, question: true, qualifying,
113
+ shownBy: parent && shownBy
114
+ ? {id: `question:${parentId}`, option: options(parent, [shownBy])[0]} : null,
115
+ visible: q.checkVisibility(),
116
+ label: label(q.querySelector("legend, label")),
117
+ required: qualifying || q.dataset.questionMandatory === "true",
115
118
  accept: kind === "video"
116
119
  ? q.querySelector("input[type=file]")?.accept.split(",").filter(Boolean) : null,
117
120
  options: picks.length ? picks : menu.length ? menu : null});
@@ -176,7 +179,7 @@ class Teamtailor:
176
179
  def __init__(self) -> None:
177
180
  self._fields: dict[str, Field] = {}
178
181
  self._qualifying: set[str] = set()
179
- self._follow_ups: dict[str, bool] = {} # shown by an earlier answer: required once shown
182
+ self._follow_ups: set[str] = set()
180
183
  self._uploads: dict[str, str] = {} # file field id: the answer's file name
181
184
  self._videos: dict[str, tuple[str, str]] = {} # video field id: its video id, its URL
182
185
 
@@ -208,13 +211,15 @@ class Teamtailor:
208
211
  form.wait_for()
209
212
  except browser.Timeout:
210
213
  raise Unsupported("posting_gone: no application form on the page") from None
211
- fields, self._qualifying, self._follow_ups = [], set(), {}
214
+ fields, self._qualifying, self._follow_ups = [], set(), set()
212
215
  for raw in form.evaluate(_READ, QUESTION):
213
216
  if raw.get("qualifying"):
214
217
  self._qualifying.add(raw["id"])
215
- if raw.get("conditional"):
216
- self._follow_ups[raw["id"]] = raw["asked"]
217
- fields.append(self._field(raw))
218
+ field = self._field(raw)
219
+ if shown_by := raw.get("shownBy"): # a follow-up of an earlier question
220
+ self._follow_ups.add(raw["id"])
221
+ field = replace(field, shown_when=Is(shown_by["id"], [shown_by["option"]]))
222
+ fields.append(field)
218
223
  self._fields = {f.id: f for f in fields}
219
224
  return fields
220
225
 
@@ -229,10 +234,6 @@ class Teamtailor:
229
234
  if field_id in self._follow_ups and not self._shown(form, field_id):
230
235
  continue # not asked: the read-back leaves it out
231
236
  self._fill(form, field_id, answers[field_id])
232
- for field_id, required in self._follow_ups.items():
233
- if required and field_id not in answers and self._shown(form, field_id):
234
- label = self._fields[field_id].label
235
- raise Unsupported(f"missing_answer: an earlier answer asks {label!r}")
236
237
  self._uploads = {
237
238
  i: PurePath(a).name
238
239
  for i, a in answers.items()
@@ -240,6 +241,10 @@ class Teamtailor:
240
241
  }
241
242
  self._wait_for_uploads(form)
242
243
 
244
+ def shown_fields(self, page: Page) -> list[str]:
245
+ raws = page.locator(FORM).evaluate(_READ, QUESTION)
246
+ return [raw["id"] for raw in raws if raw.get("visible", True)]
247
+
243
248
  def read_back(self, page: Page) -> dict[str, Answer]:
244
249
  values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
245
250
  for field_id, (video_id, url) in self._videos.items():
@@ -143,3 +143,13 @@ class ReadsOverHttp(Adapter, Protocol):
143
143
  def read_api(self, url: str) -> list[Field] | None:
144
144
  """The same fields as `read_form`; None when it cannot for this URL."""
145
145
  ...
146
+
147
+
148
+ @runtime_checkable
149
+ class ListsShownFields(Adapter, Protocol):
150
+ """An adapter whose form can show fields after an answer: once filled, it says which the
151
+ page shows, so that one `read_form` did not list stops the fill (ADR 0005)."""
152
+
153
+ def shown_fields(self, page: Page) -> list[str]:
154
+ """The ids of the fields the filled page shows, each as `read_form` would name it."""
155
+ ...
@@ -5,11 +5,21 @@ from importlib.metadata import version
5
5
  from pathlib import Path
6
6
 
7
7
  from atsfill.adapters import adapters
8
- from atsfill.core import accounts, attempts, browser, matching, passwords, paths, registry
8
+ from atsfill.core import (
9
+ accounts,
10
+ attempts,
11
+ browser,
12
+ conditions,
13
+ matching,
14
+ passwords,
15
+ paths,
16
+ registry,
17
+ )
9
18
  from atsfill.core.adapter import (
10
19
  Adapter,
11
20
  Blocked,
12
21
  CreatesAccount,
22
+ ListsShownFields,
13
23
  NeedsAccount,
14
24
  NotTaken,
15
25
  ReadsOverHttp,
@@ -21,6 +31,7 @@ from atsfill.core.browser import OutOfTime, Overlaid, Page, open_page
21
31
  from atsfill.core.results import (
22
32
  AdapterStatus,
23
33
  Answer,
34
+ Field,
24
35
  Result,
25
36
  SessionReport,
26
37
  Status,
@@ -160,17 +171,51 @@ def _result(adapter: Adapter, url: str, user: str) -> Result:
160
171
  return Result(Status.READY, url, adapter.name, side_effects=shown)
161
172
 
162
173
 
163
- def _unfillable(result: Result) -> bool:
174
+ def _unfillable(result: Result, answers: dict[str, Answer] | None = None) -> bool:
164
175
  """Stops the form on a required field atsfill cannot fill; an optional one stays listed,
165
- left empty (ADR 0014)."""
176
+ left empty (ADR 0014). A follow-up counts once the answers show it and require it, and
177
+ without answers never."""
178
+ form = conditions.Form(result.fields, answers or {})
166
179
  field = next(
167
- (f for f in result.fields if f.unfillable and f.required and not f.prefilled), None
180
+ (
181
+ f
182
+ for f in result.fields
183
+ if f.unfillable
184
+ and not f.prefilled
185
+ and (form.required(f) if answers is not None else f.required and not f.shown_when)
186
+ ),
187
+ None,
168
188
  )
169
189
  if field:
170
190
  result.status, result.reason = Status.UNSUPPORTED, field.unfillable
171
191
  return field is not None
172
192
 
173
193
 
194
+ def _listed(result: Result, fields: list[Field]) -> None:
195
+ """Sets the fields `read_form` or `read_api` listed; their conditions must name listed
196
+ fields, never a loop."""
197
+ if faults := conditions.faults(fields):
198
+ raise ValueError("; ".join(faults))
199
+ result.fields = fields
200
+
201
+
202
+ def _shown_as_listed(
203
+ page: Page, adapter: Adapter, result: Result, answers: dict[str, Answer]
204
+ ) -> None:
205
+ """Stops a fill that showed a field `read_form` did not list, or a follow-up whose
206
+ condition the answers leave unmet: that condition was read wrong (ADR 0005)."""
207
+ if not isinstance(adapter, ListsShownFields):
208
+ return
209
+ by_id, form = {f.id: f for f in result.fields}, conditions.Form(result.fields, answers)
210
+ shown = adapter.shown_fields(page)
211
+ if new := [i for i in shown if i not in by_id]:
212
+ result.status = Status.UNSUPPORTED
213
+ result.reason = f"unhandled_rendering: an answer showed {', '.join(new)}, not listed"
214
+ elif wrong := [i for i in shown if not form.shown(by_id[i])]:
215
+ result.status = Status.REFUSED
216
+ result.reason = f"mismatch: {', '.join(wrong)} shows though its condition is unmet"
217
+
218
+
174
219
  def _no_adapter(url: str) -> Result:
175
220
  return Result(Status.UNSUPPORTED, url, reason="no_adapter: no adapter for this hiring system")
176
221
 
@@ -210,13 +255,16 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
210
255
  """Fills and reads back; `ready` when every answer reads back as given."""
211
256
  result = _result(adapter, url, user)
212
257
  with _stops(result):
213
- result.fields = adapter.read_form(page)
214
- if _unfillable(result):
258
+ _listed(result, adapter.read_form(page))
259
+ if _unfillable(result, answers):
215
260
  return result
216
261
  if problems := matching.problems(result.fields, answers):
217
262
  result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
218
263
  return result
219
264
  adapter.fill(page, answers)
265
+ _shown_as_listed(page, adapter, result, answers)
266
+ if result.status is not Status.READY:
267
+ return result
220
268
  result.checks = matching.check(result.fields, answers, adapter.read_back(page))
221
269
  if mismatched := [c.field_id for c in result.checks if not c.match]:
222
270
  result.status, result.reason = Status.REFUSED, f"mismatch: {', '.join(mismatched)}"
@@ -232,7 +280,7 @@ def _read_page(
232
280
  page, adapter = opened
233
281
  result = _result(adapter, url, user)
234
282
  with _stops(result):
235
- result.fields = adapter.read_form(page)
283
+ _listed(result, adapter.read_form(page))
236
284
  _unfillable(result)
237
285
  return result
238
286
 
@@ -301,7 +349,10 @@ def read(
301
349
  continue
302
350
  if (fields := adapter.read_api(url)) is not None:
303
351
  result = _result(adapter, url, user)
304
- result.fields = fields
352
+ try:
353
+ _listed(result, fields)
354
+ except ValueError as e:
355
+ return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
305
356
  _unfillable(result)
306
357
  return result
307
358
  data = paths.data_dir(data_dir)
@@ -0,0 +1,92 @@
1
+ """Whether a field shows, and whether it is required, from the answers alone (ADR 0005)."""
2
+
3
+ from atsfill.core.results import AllOf, Answer, AnyOf, Condition, Field, Is
4
+
5
+
6
+ def _parents(condition: Condition) -> list[str]:
7
+ match condition:
8
+ case Is():
9
+ return [condition.field_id]
10
+ case AnyOf():
11
+ return [p for c in condition.any_of for p in _parents(c)]
12
+ case AllOf():
13
+ return [p for c in condition.all_of for p in _parents(c)]
14
+
15
+
16
+ def faults(fields: list[Field]) -> list[str]:
17
+ """What makes the fields' conditions unusable: a field they name that is not listed, or a
18
+ field shown by its own answer, through any chain."""
19
+ by_id = {f.id: f for f in fields}
20
+ out = [
21
+ f"{f.id}: its condition names no field {p}"
22
+ for f in fields
23
+ for c in (f.shown_when, f.required_when)
24
+ if c
25
+ for p in _parents(c)
26
+ if p not in by_id
27
+ ]
28
+
29
+ def loops(field_id: str, seen: tuple[str, ...]) -> bool:
30
+ if field_id in seen:
31
+ return True
32
+ field = by_id.get(field_id)
33
+ parents = _parents(field.shown_when) if field and field.shown_when else []
34
+ return any(loops(p, (*seen, field_id)) for p in parents)
35
+
36
+ out += [f"{f.id}: shown by its own answer" for f in fields if loops(f.id, ())]
37
+ return out
38
+
39
+
40
+ class Form:
41
+ """The fields under one set of answers: an answer to a field that stays hidden counts as
42
+ none."""
43
+
44
+ def __init__(self, fields: list[Field], answers: dict[str, Answer]) -> None:
45
+ self.by_id = {f.id: f for f in fields}
46
+ self.answers = answers
47
+
48
+ def holds(self, condition: Condition) -> bool:
49
+ match condition:
50
+ case Is():
51
+ value = self._value(condition.field_id)
52
+ if condition.values is None:
53
+ return (value is not None) != condition.negated
54
+ if value is None:
55
+ return False
56
+ picks = [value] if isinstance(value, str) else value
57
+ return any(p in condition.values for p in picks) != condition.negated
58
+ case AnyOf():
59
+ return any(self.holds(c) for c in condition.any_of)
60
+ case AllOf():
61
+ return all(self.holds(c) for c in condition.all_of)
62
+
63
+ def shown(self, field: Field) -> bool:
64
+ return field.shown_when is None or self.holds(field.shown_when)
65
+
66
+ def required(self, field: Field) -> bool:
67
+ """Required as the form asks it: shown, and always required or required by an answer."""
68
+ if not self.shown(field):
69
+ return False
70
+ when = field.required_when
71
+ return field.required or (when is not None and self.holds(when))
72
+
73
+ def _value(self, field_id: str) -> Answer | None:
74
+ field = self.by_id[field_id]
75
+ value = self.answers.get(field_id)
76
+ return value if value not in (None, "", []) and self.shown(field) else None
77
+
78
+
79
+ def describe(condition: Condition, by_id: dict[str, Field]) -> str:
80
+ """The condition in words, each field by its label: `"Visa" is "Yes"`."""
81
+ match condition:
82
+ case Is():
83
+ label = by_id[condition.field_id].label if condition.field_id in by_id else ""
84
+ name = f'"{label or condition.field_id}"'
85
+ if condition.values is None:
86
+ return f"{name} is {'not ' if condition.negated else ''}answered"
87
+ values = " or ".join(f'"{v}"' for v in condition.values)
88
+ return f"{name} is {'not ' if condition.negated else ''}{values}"
89
+ case AnyOf():
90
+ return " or ".join(f"({describe(c, by_id)})" for c in condition.any_of)
91
+ case AllOf():
92
+ return " and ".join(f"({describe(c, by_id)})" for c in condition.all_of)
@@ -5,6 +5,7 @@ import unicodedata
5
5
  from pathlib import Path, PurePath
6
6
  from urllib.parse import urlsplit
7
7
 
8
+ from atsfill.core.conditions import Form, describe
8
9
  from atsfill.core.results import Answer, Check, Field, FieldType
9
10
 
10
11
 
@@ -60,12 +61,19 @@ def matches(field: Field, answer: Answer, read_back: Answer | None) -> bool:
60
61
  def check(
61
62
  fields: list[Field], answers: dict[str, Answer], read_back: dict[str, Answer]
62
63
  ) -> list[Check]:
63
- by_id = {f.id: f for f in fields}
64
- return [
64
+ """Each answer against its read-back; a follow-up the answers keep hidden that reads back a
65
+ value is a mismatch too, its condition read wrong."""
66
+ by_id, form = {f.id: f for f in fields}, Form(fields, answers)
67
+ checks = [
65
68
  Check(f.id, a, read_back.get(f.id), matches(f, a, read_back.get(f.id)))
66
69
  for f_id, a in answers.items()
67
70
  if (f := by_id.get(f_id))
68
71
  ]
72
+ return checks + [
73
+ Check(f.id, None, read_back[f.id], False)
74
+ for f in fields
75
+ if not form.shown(f) and f.id not in answers and read_back.get(f.id) not in (None, "", [])
76
+ ]
69
77
 
70
78
 
71
79
  def _answer_problem(field: Field, answer: object) -> str | None:
@@ -100,13 +108,20 @@ def _answer_problem(field: Field, answer: object) -> str | None:
100
108
 
101
109
 
102
110
  def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
103
- """What refuses a fill before typing: unknown ids, missing required answers, bad values."""
111
+ """What refuses a fill before typing: unknown ids, missing required answers, answers to
112
+ follow-ups the answers keep hidden, bad values."""
104
113
  by_id = {f.id: f for f in fields}
105
114
  out = [f"no field {i}" for i in answers if i not in by_id]
115
+ form = Form(fields, {i: a for i, a in answers.items() if i in by_id})
106
116
  out += [
107
117
  f"no answer for required {f.id} ({f.label})"
108
118
  for f in fields
109
- if f.required and not f.prefilled and not f.unfillable and f.id not in answers
119
+ if form.required(f) and not f.prefilled and not f.unfillable and f.id not in answers
120
+ ]
121
+ out += [
122
+ f"{i}: not asked, it shows when {describe(f.shown_when, by_id)}"
123
+ for i in answers
124
+ if (f := by_id.get(i)) and f.shown_when and not form.shown(f)
110
125
  ]
111
126
  out += [
112
127
  f"{i}: cannot be filled ({f.unfillable})"
@@ -26,6 +26,30 @@ class FieldType(StrEnum):
26
26
  type Answer = str | list[str]
27
27
 
28
28
 
29
+ @dataclass(frozen=True)
30
+ class Is:
31
+ """Holds when the field's answer is one of `values` (one of them ticked, for a
32
+ multi_choice), or, without `values`, when the field is answered. Against values, it never
33
+ holds for an unanswered field, negated or not."""
34
+
35
+ field_id: str
36
+ values: list[str] | None = None
37
+ negated: bool = False
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AnyOf:
42
+ any_of: list[Condition]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class AllOf:
47
+ all_of: list[Condition]
48
+
49
+
50
+ type Condition = Is | AnyOf | AllOf
51
+
52
+
29
53
  @dataclass(frozen=True)
30
54
  class Field:
31
55
  id: str
@@ -37,6 +61,8 @@ class Field:
37
61
  accept: list[str] | None = None
38
62
  prefilled: bool = False # holds a value before any answer, from the account profile
39
63
  unfillable: str | None = None # why atsfill cannot fill it, a reason (ADR 0014)
64
+ shown_when: Condition | None = None # a follow-up: shown only when it holds (ADR 0005)
65
+ required_when: Condition | None = None # required once shown when it holds, if not always
40
66
 
41
67
 
42
68
  @dataclass(frozen=True)
File without changes
File without changes