atsfill 0.0.4.dev47__tar.gz → 0.0.4.dev48__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 (38) hide show
  1. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/PKG-INFO +4 -4
  2. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/README.md +3 -3
  3. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/ashby.py +2 -2
  6. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/bamboohr.py +4 -4
  7. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/breezy.py +3 -3
  8. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/dayforce.py +8 -4
  9. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/greenhouse.py +5 -5
  10. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/hireology.py +2 -2
  11. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/jazzhr.py +1 -1
  12. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/lever.py +1 -1
  13. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/manatal.py +2 -2
  14. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/oracle.py +10 -8
  15. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paycor.py +7 -7
  16. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paylocity.py +4 -4
  17. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/phenom.py +21 -21
  18. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/recruitee.py +3 -1
  19. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/smartrecruiters.py +5 -3
  20. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/teamtailor.py +10 -6
  21. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/workable.py +1 -1
  22. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/wttj.py +11 -7
  23. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/adapter.py +10 -2
  24. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/commands.py +27 -18
  25. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/registry.py +50 -47
  26. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/results.py +2 -2
  27. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/registry.toml +20 -20
  28. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/LICENSE +0 -0
  29. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/__init__.py +0 -0
  30. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/__init__.py +0 -0
  31. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/cli.py +0 -0
  32. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/__init__.py +0 -0
  33. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/accounts.py +0 -0
  34. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/attempts.py +0 -0
  35. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/browser.py +0 -0
  36. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/matching.py +0 -0
  37. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/paths.py +0 -0
  38. {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev47
3
+ Version: 0.0.4.dev48
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
@@ -139,9 +139,9 @@ flowchart LR
139
139
  CLI[atsfill CLI] --> Core
140
140
  Core -->|detect, read, fill, read back, submit| Adapter[adapter for the system]
141
141
  Adapter --> Browser[headless Camoufox]
142
- Core --> Gate{read-back matches,<br>submit enabled,<br>not already sent}
143
- Gate -->|yes| Submit[submit]
144
- Gate -->|no| Refused[refused]
142
+ Core --> SubmitGate{read-back matches,<br>submit enabled,<br>not already sent}
143
+ SubmitGate -->|yes| Submit[submit]
144
+ SubmitGate -->|no| Refused[refused]
145
145
  ```
146
146
 
147
147
  Each command launches its own headless Camoufox, a Firefox build patched to hide automation, with a fresh context and one saved device identity.
@@ -114,9 +114,9 @@ flowchart LR
114
114
  CLI[atsfill CLI] --> Core
115
115
  Core -->|detect, read, fill, read back, submit| Adapter[adapter for the system]
116
116
  Adapter --> Browser[headless Camoufox]
117
- Core --> Gate{read-back matches,<br>submit enabled,<br>not already sent}
118
- Gate -->|yes| Submit[submit]
119
- Gate -->|no| Refused[refused]
117
+ Core --> SubmitGate{read-back matches,<br>submit enabled,<br>not already sent}
118
+ SubmitGate -->|yes| Submit[submit]
119
+ SubmitGate -->|no| Refused[refused]
120
120
  ```
121
121
 
122
122
  Each command launches its own headless Camoufox, a Firefox build patched to hide automation, with a fresh context and one saved device identity.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev47"
3
+ version = "0.0.4.dev48"
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.dev47"
3
+ version = "0.0.4.dev48"
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"
@@ -166,7 +166,7 @@ class Ashby:
166
166
  self._saved(page, partial(upload.set_input_files, answer))
167
167
  case kind, _:
168
168
  raise Unsupported(
169
- f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
169
+ f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
170
170
  )
171
171
  self._answered = list(answers)
172
172
 
@@ -232,7 +232,7 @@ class Ashby:
232
232
  f = entry["field"]
233
233
  kind = TYPES.get(f["type"])
234
234
  if kind is None:
235
- raise Unsupported(f"form_shape: {f['path']} is an Ashby {f['type']} field")
235
+ raise Unsupported(f"unsupported_rendering: {f['path']} is an Ashby {f['type']} field")
236
236
  options = [v["label"] for v in f.get("selectableValues") or []] or None
237
237
  if f["type"] == "Boolean":
238
238
  options = ["Yes", "No"]
@@ -1,4 +1,4 @@
1
- """BambooHR (`<company>.bamboohr.com/careers/<job>`), as docs/adapters/bamboohr.md observes.
1
+ """BambooHR (`<employer>.bamboohr.com/careers/<job>`), as docs/adapters/bamboohr.md observes.
2
2
 
3
3
  The job page renders its application form, behind Apply for This Job, from a public JSON
4
4
  answer that lists every field with its label, requirement and options. `read_api` fetches it
@@ -198,7 +198,7 @@ class BambooHR:
198
198
  else form.locator(f"[name='{name}']")
199
199
  )
200
200
  if not present.count():
201
- raise Unsupported(f"form_shape: {field.id} is not on the page")
201
+ raise Unsupported(f"unsupported_flow: {field.id} is not on the page")
202
202
  return [f for f, _ in fields]
203
203
 
204
204
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
@@ -388,7 +388,7 @@ def _form(
388
388
  if not entry: # a demographic question the employer does not ask
389
389
  continue
390
390
  if key not in STANDARD:
391
- raise Unsupported(f"form_shape: {key} is a field it does not know")
391
+ raise Unsupported(f"unsupported_rendering: {key} is a field it does not know")
392
392
  kind, name = STANDARD[key]
393
393
  label, required = entry["label"].strip(), entry["isRequired"]
394
394
  options = [o["text"] for o in entry.get("options") or []] or None
@@ -418,7 +418,7 @@ def _states(states: dict[str, Any], default: str | None) -> list[str]:
418
418
  def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
419
419
  qid, kind_api = question["id"], question["type"]
420
420
  if kind_api not in CUSTOM:
421
- raise Unsupported(f"form_shape: question {qid} is a {kind_api}")
421
+ raise Unsupported(f"unsupported_rendering: question {qid} is a {kind_api}")
422
422
  kind = CUSTOM[kind_api]
423
423
  name = f"customQuestionAnswers.{kind_api}_{qid}"
424
424
  label, required = question["question"].strip(), question["isRequired"]
@@ -1,4 +1,4 @@
1
- """Breezy HR (`<company>.breezy.hr/p/<position>`, or a company's own domain), as
1
+ """Breezy HR (`<employer>.breezy.hr/p/<position>`, or an employer's own domain), as
2
2
  docs/adapters/breezy.md observes.
3
3
 
4
4
  Two page engines, an AngularJS one and a React one, render the same markup: named controls
@@ -219,7 +219,7 @@ class Breezy:
219
219
  self._kinds: dict[str, str] = {} # how the page renders each field
220
220
 
221
221
  def detect(self, url: str, page: Page | None) -> bool:
222
- """A `*.breezy.hr` position, or one on a company's domain that serves Breezy's portal."""
222
+ """A `*.breezy.hr` position, or one on an employer's domain that serves Breezy's portal."""
223
223
  parts = urlsplit(url)
224
224
  if not JOB.match(parts.path):
225
225
  return False
@@ -346,7 +346,7 @@ def _field(raw: dict[str, Any]) -> Field | None:
346
346
  kind = raw["kind"]
347
347
  if kind == "video":
348
348
  if raw["required"]:
349
- raise Unsupported(f"form_shape: {raw['label']!r} asks for a recorded video")
349
+ raise Unsupported(f"unsupported_rendering: {raw['label']!r} asks for a recorded video")
350
350
  return None
351
351
  label = LABELS.get(raw["id"]) or raw.get("label", "")
352
352
  if "part" in raw:
@@ -281,7 +281,7 @@ class Dayforce:
281
281
  key, kind = PERSONAL_FIELDS.get(f["fieldName"], (None, None))
282
282
  if key is None or kind is None:
283
283
  if f["isRequired"]:
284
- raise Unsupported(f"form_shape: personal field {f['fieldName']!r}")
284
+ raise Unsupported(f"unsupported_rendering: personal field {f['fieldName']!r}")
285
285
  continue
286
286
  options = {
287
287
  "candidateSource": sources,
@@ -321,7 +321,9 @@ class Dayforce:
321
321
  and ref not in CANDIDATE_INFO
322
322
  and section.get("isRequired")
323
323
  ):
324
- raise Unsupported(f"form_shape: {section['displayName']!r} section is required")
324
+ raise Unsupported(
325
+ f"unsupported_rendering: {section['displayName']!r} section is required"
326
+ )
325
327
  self._sections = [
326
328
  s for s in sections if s.get("questionnaire") and s.get("xRefCode") != "SUBMIT"
327
329
  ]
@@ -549,7 +551,7 @@ def _entry_fields(
549
551
  key, kind = keys.get(re.sub(r"\s", "", f["fieldName"]).upper(), (None, None))
550
552
  if key is None or kind is None:
551
553
  if f["isRequired"] and section["isRequired"]:
552
- raise Unsupported(f"form_shape: {prefix} field {f['fieldName']!r}")
554
+ raise Unsupported(f"unsupported_rendering: {prefix} field {f['fieldName']!r}")
553
555
  continue
554
556
  required = bool(f["isRequired"] and section["isRequired"])
555
557
  options = NO_YES if kind == "boolean" else None
@@ -594,7 +596,9 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
594
596
  label = _plain(question["displayName"])
595
597
  if kind is None:
596
598
  if question["isRequired"]:
597
- raise Unsupported(f"form_shape: {label!r} has answer type {question['answerType']}")
599
+ raise Unsupported(
600
+ f"unsupported_rendering: {label!r} has answer type {question['answerType']}"
601
+ )
598
602
  return []
599
603
  field_id = f"q{question['questionId']}"
600
604
  options = None
@@ -1,4 +1,4 @@
1
- """Greenhouse (`job-boards.greenhouse.io`, `boards.greenhouse.io`, their EU hosts, and company
1
+ """Greenhouse (`job-boards.greenhouse.io`, `boards.greenhouse.io`, their EU hosts, and employer
2
2
  pages embedding them), as docs/adapters/greenhouse.md observes.
3
3
 
4
4
  Nothing but an attached file reaches Greenhouse before submit, so the read-back is the page:
@@ -128,11 +128,11 @@ class Greenhouse:
128
128
  def __init__(self) -> None:
129
129
  self._fields: dict[str, Field] = {}
130
130
  self._kinds: dict[str, str] = {} # how the page renders each field
131
- self._embed: str | None = None # the job_app URL a company page embeds
131
+ self._embed: str | None = None # the job_app URL an employer page embeds
132
132
 
133
133
  def detect(self, url: str, page: Page | None) -> bool:
134
- """A board URL, or a company page whose `#grnhse_iframe` embeds a job application."""
135
- self._embed = None # one process detects many postings: a survey, the gate
134
+ """A board URL, or an employer page whose `#grnhse_iframe` embeds a job application."""
135
+ self._embed = None # one process detects many postings: a survey, the adapter gate
136
136
  if _board_job(url):
137
137
  return True
138
138
  if page is None:
@@ -242,7 +242,7 @@ class Greenhouse:
242
242
  else:
243
243
  field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
244
244
  case _: # a radio input, not seen on a posting yet
245
- raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
245
+ raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
246
246
  return Field(
247
247
  raw["id"],
248
248
  raw["label"],
@@ -223,7 +223,7 @@ class Hireology:
223
223
  form_el = page.locator(FORM)
224
224
  for field, _ in form:
225
225
  if field.id not in SHOWN_BY and form_el.evaluate(_CONTROL, field.id) is None:
226
- raise Unsupported(f"form_shape: {field.id} is not on the page")
226
+ raise Unsupported(f"unsupported_flow: {field.id} is not on the page")
227
227
  return [f for f, _ in form]
228
228
 
229
229
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
@@ -304,7 +304,7 @@ def _field(entry: dict[str, Any], section: str) -> Field:
304
304
  field_id, attributes = entry["id"], entry["attributes"]
305
305
  kind = attributes["type"]
306
306
  if kind not in TYPES:
307
- raise Unsupported(f"form_shape: {field_id} is a {kind}")
307
+ raise Unsupported(f"unsupported_rendering: {field_id} is a {kind}")
308
308
  label = (
309
309
  section
310
310
  + (
@@ -238,7 +238,7 @@ def _field(raw: dict[str, Any]) -> Field:
238
238
  case "consent":
239
239
  field_type, options = FieldType.CHOICE, YES_NO
240
240
  case _:
241
- raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
241
+ raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
242
242
  accept = RESUME_TYPES if kind == "file" else None
243
243
  return Field(
244
244
  raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
@@ -205,7 +205,7 @@ def _job(url: str) -> tuple[str, str] | None:
205
205
  def _field(raw: dict[str, Any]) -> Field:
206
206
  kind = raw["kind"]
207
207
  if kind not in KINDS:
208
- raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
208
+ raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
209
209
  options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
210
210
  if kind in ("radio", "checkboxes"):
211
211
  options = raw["options"]
@@ -112,7 +112,7 @@ def _field(entry: dict[str, Any], options: list[str] | None) -> Field | None:
112
112
  fid, label, required = str(entry["id"]), entry["label"], bool(entry["is_required"])
113
113
  match kind:
114
114
  case "entries" if required:
115
- raise Unsupported(f"form_shape: {entry['field_type']} entries are required")
115
+ raise Unsupported(f"unsupported_rendering: {entry['field_type']} entries are required")
116
116
  case "entries":
117
117
  return None
118
118
  case "file":
@@ -245,7 +245,7 @@ class Manatal:
245
245
  named.evaluate(_CHECK, answer == "Yes")
246
246
  case kind, _:
247
247
  raise Unsupported(
248
- f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
248
+ f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
249
249
  )
250
250
 
251
251
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -16,7 +16,7 @@ from typing import Any
16
16
  from urllib.parse import urlsplit
17
17
 
18
18
  from atsfill.core import browser, matching
19
- from atsfill.core.adapter import Blocked, Unsupported
19
+ from atsfill.core.adapter import Blocked, NotTaken, Unsupported
20
20
  from atsfill.core.browser import Locator, Page
21
21
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
22
22
 
@@ -358,7 +358,7 @@ class Oracle:
358
358
  except browser.Timeout:
359
359
  if page.locator(CAPTCHA).count():
360
360
  raise Blocked("captcha: the email step asks for an hCaptcha") from None
361
- raise Unsupported("form_shape: the email step did not open the form") from None
361
+ raise Unsupported("unsupported_flow: the email step did not open the form") from None
362
362
  if page.locator(CODE_PAGE).count():
363
363
  raise Blocked(
364
364
  f"emailed_code: {email} is known to this employer's Oracle, which emails it a"
@@ -371,7 +371,7 @@ class Oracle:
371
371
  """The field a raw description is, None for one atsfill leaves out."""
372
372
  if raw["kind"] == "tiles":
373
373
  if raw["required"]:
374
- raise Unsupported(f"form_shape: {raw['label']!r} needs entries added")
374
+ raise Unsupported(f"unsupported_rendering: {raw['label']!r} needs entries added")
375
375
  return None
376
376
  if raw["kind"] == "email":
377
377
  self._kinds[EMAIL] = "email"
@@ -388,7 +388,7 @@ class Oracle:
388
388
  """The innermost row holding the field."""
389
389
  selector = page.evaluate(_ROW, field_id)
390
390
  if selector is None:
391
- raise Unsupported(f"form_shape: {field_id} left the form while it was filled")
391
+ raise Unsupported(f"unsupported_flow: {field_id} left the form while it was filled")
392
392
  return page.locator(".input-row").filter(has=page.locator(selector)).last
393
393
 
394
394
  def _options(self, page: Page, field_id: str) -> list[str]:
@@ -454,7 +454,9 @@ class Oracle:
454
454
  _choose(page, boxes.nth(1), str(int(day)), exact=True)
455
455
  _choose(page, boxes.nth(2), year, exact=True)
456
456
  case _:
457
- raise Unsupported(f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}")
457
+ raise Unsupported(
458
+ f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
459
+ )
458
460
 
459
461
  def _phone(self, page: Page, row: Locator, answer: str) -> None:
460
462
  """Picks the country code the number starts with, the longest that fits, then types
@@ -468,7 +470,7 @@ class Oracle:
468
470
  )
469
471
  prefix = next((c for c in codes if digits.startswith(c)), None)
470
472
  if prefix is None:
471
- raise Unsupported(f"form_shape: no country code for {answer!r}")
473
+ raise NotTaken(f"no country code for {answer!r}")
472
474
  _choose(page, code, f"+{prefix}", code=True)
473
475
  number = row.locator("input[type=tel]")
474
476
  number.fill(digits[len(prefix) :])
@@ -541,7 +543,7 @@ def _go_to(page: Page, number: int) -> None:
541
543
  )
542
544
  _settle(page)
543
545
  return
544
- raise Unsupported(f"form_shape: page {number} of the form did not open")
546
+ raise Unsupported(f"unsupported_flow: page {number} of the form did not open")
545
547
 
546
548
 
547
549
  def _upload(field_id: str) -> str:
@@ -611,7 +613,7 @@ def _choose(
611
613
  box.press("Escape")
612
614
  if field is not None:
613
615
  break # a search lists nothing untyped
614
- raise Unsupported(f"form_shape: no item for {answer!r} in a dropdown")
616
+ raise NotTaken(f"no item for {answer!r} in a dropdown")
615
617
 
616
618
 
617
619
  def _field(raw: dict[str, Any]) -> Field:
@@ -11,7 +11,7 @@ import re
11
11
  from typing import Any
12
12
  from urllib.parse import parse_qs, urlencode, urlsplit
13
13
 
14
- from atsfill.core.adapter import Blocked, Unsupported
14
+ from atsfill.core.adapter import Blocked, NotTaken, Unsupported
15
15
  from atsfill.core.browser import Page
16
16
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
17
 
@@ -142,7 +142,7 @@ class Paycor:
142
142
 
143
143
  def detect(self, url: str, page: Page | None) -> bool:
144
144
  """A `recruitingbypaycor.com/career` job URL, or an employer page that embeds one."""
145
- self._start = None # one process detects many postings: a survey, the gate
145
+ self._start = None # one process detects many postings: a survey, the adapter gate
146
146
  parts = urlsplit(url)
147
147
  if parts.hostname == HOST and (ids := _ids(url)):
148
148
  self._start = self._apply(*ids)
@@ -207,7 +207,7 @@ class Paycor:
207
207
  named.fill(answer)
208
208
  named.dispatch_event("change")
209
209
  case "select", str() if named.is_disabled():
210
- raise Unsupported(f"form_shape: {field.id} has no options for that country")
210
+ raise NotTaken(f"{field.id} has no options for that country")
211
211
  case "select", str():
212
212
  named.select_option(label=answer)
213
213
  case "radio", str():
@@ -221,7 +221,7 @@ class Paycor:
221
221
  self._pick(named, field, answer)
222
222
  case _:
223
223
  raise Unsupported(
224
- f"form_shape: cannot fill {field.id}, a {kind}, with {answer!r}"
224
+ f"unsupported_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
225
225
  )
226
226
 
227
227
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -265,7 +265,7 @@ class Paycor:
265
265
  """Opens the apply page and answers the pages before the form: the persona's picks
266
266
  when filling, the last option and Yes when only reading, which list the fields."""
267
267
  if self._start is None:
268
- raise Unsupported("form_shape: no Paycor job in the page")
268
+ raise Unsupported("unsupported_flow: no Paycor job in the page")
269
269
  page.goto(self._start, wait_until="domcontentloaded")
270
270
  for _ in range(STEPS):
271
271
  page.wait_for_load_state("domcontentloaded")
@@ -286,11 +286,11 @@ class Paycor:
286
286
  elif "no longer active" in (page.text_content("body") or ""):
287
287
  raise Unsupported("posting_gone: this job is no longer active")
288
288
  else:
289
- raise Unsupported(f"form_shape: an unknown page before the form ({page.url})")
289
+ raise Unsupported(f"unsupported_flow: an unknown page before the form ({page.url})")
290
290
  # The form's third-party scripts (Dropbox, Google) can hold its `load` back.
291
291
  with page.expect_navigation(wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS):
292
292
  button.click(no_wait_after=True)
293
- raise Unsupported("form_shape: no form after the pages before it")
293
+ raise Unsupported("unsupported_flow: no form after the pages before it")
294
294
 
295
295
  def _eeo(self, page: Page, answers: dict[str, Answer] | None) -> None:
296
296
  for name in page.locator(f"{EEO_FORM} input[type=radio]").evaluate_all(
@@ -405,7 +405,7 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
405
405
  count = custom.get("referencesRequiredCount") or 1
406
406
  vocabulary = SECTIONS.get(name)
407
407
  if vocabulary is None:
408
- raise Unsupported(f"form_shape: unknown section {name!r}")
408
+ raise Unsupported(f"unsupported_rendering: unknown section {name!r}")
409
409
  included = {f["name"] for f in section["fields"] if f["isIncluded"]}
410
410
  for n in range(count):
411
411
  prefix = f"Reference {n + 1}: " if name == "references" else ""
@@ -418,8 +418,8 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
418
418
  if specs is None:
419
419
  if entries and field["isRequired"] and field["name"] not in OPTIONAL_ONLY:
420
420
  raise Unsupported(
421
- f"form_shape: {_plain(field['displayName'])!r} ({field['name']}) "
422
- "is not handled"
421
+ "unsupported_rendering: "
422
+ f"{_plain(field['displayName'])!r} ({field['name']}) is not handled"
423
423
  )
424
424
  continue
425
425
  if SHOWN_WITH.get(field["name"], field["name"]) not in included:
@@ -437,7 +437,7 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
437
437
  elif kind == "text":
438
438
  kind, answers, dom = "screener_text", None, f"screener-question-{i}-textarea"
439
439
  else:
440
- raise Unsupported(f"form_shape: a {kind} screener question")
440
+ raise Unsupported(f"unsupported_rendering: a {kind} screener question")
441
441
  field_id = f"screener.{question['screenerQuestionId']}"
442
442
  out.append((Field(field_id, _plain(question["title"]), TYPES[kind],
443
443
  bool(question.get("isRequired")), answers),
@@ -22,7 +22,7 @@ from typing import Any
22
22
  from urllib.parse import parse_qs, urlsplit
23
23
 
24
24
  from atsfill.core import browser, matching
25
- from atsfill.core.adapter import Unsupported
25
+ from atsfill.core.adapter import NotTaken, Unsupported
26
26
  from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Covered, Locator, Page, Timeout
27
27
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
28
28
 
@@ -311,7 +311,7 @@ class Phenom:
311
311
  try:
312
312
  ph = _phapp_of(page.content()) # the page's scripts' own world is out of reach
313
313
  except ValueError as e:
314
- raise Unsupported(f"form_shape: {e}") from None
314
+ raise Unsupported(f"unsupported_flow: {e}") from None
315
315
  token = get(ph, "sessionParams.csrfToken") or ""
316
316
  try:
317
317
  fields, raws = _read(
@@ -320,7 +320,7 @@ class Phenom:
320
320
  lambda body: page.evaluate(_FETCH, ["/applySubmit", body, token]),
321
321
  )
322
322
  except (BrowserError, ValueError, KeyError) as e:
323
- raise Unsupported(f"form_shape: no configuration for the wizard ({e})") from None
323
+ raise Unsupported(f"unsupported_flow: no configuration for the wizard ({e})") from None
324
324
  self._raws = {raw["id"]: raw for raw in raws}
325
325
  return fields
326
326
 
@@ -348,7 +348,7 @@ class Phenom:
348
348
  if not _has_next(page):
349
349
  return
350
350
  _next(page, number)
351
- raise Unsupported(f"form_shape: the wizard has more than {MAX_STEPS} steps")
351
+ raise Unsupported(f"unsupported_flow: the wizard has more than {MAX_STEPS} steps")
352
352
 
353
353
  def read_back(self, page: Page) -> dict[str, Answer]:
354
354
  return self._kept
@@ -381,7 +381,7 @@ class Phenom:
381
381
  except Timeout:
382
382
  step = parse_qs(urlsplit(page.url).query).get("stepname", ["none"])[0]
383
383
  raise Unsupported(
384
- f"form_shape: no application step on the page (step {step})"
384
+ f"unsupported_flow: no application step on the page (step {step})"
385
385
  ) from None
386
386
 
387
387
  def _step(self, page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
@@ -442,7 +442,7 @@ def _read(
442
442
  reader = _Config(ph, config, texts, post)
443
443
  raws = [raw for step in reader.steps() for raw in step]
444
444
  if not raws:
445
- raise Unsupported("form_shape: the wizard shows no fields")
445
+ raise Unsupported("unsupported_flow: the wizard shows no fields")
446
446
  resume = reader.resume()
447
447
  return ([resume] if resume else []) + [_field(raw) for raw in raws], raws
448
448
 
@@ -816,7 +816,7 @@ class _Config:
816
816
  }
817
817
  response = get(self.call(payload, **extra), "applyGetReferences")
818
818
  if not isinstance(response, dict):
819
- raise Unsupported("form_shape: the job's questionnaire did not load")
819
+ raise Unsupported("unsupported_flow: the job's questionnaire did not load")
820
820
  part = response.get("data") if isinstance(get(response, "data.schema"), dict) else response
821
821
  if not isinstance(get(part, "schema"), dict):
822
822
  return []
@@ -1063,9 +1063,9 @@ def _apply_url(page: Page) -> str:
1063
1063
  time.sleep(SETTLE_S)
1064
1064
  target = urlsplit(href or "")
1065
1065
  if target.hostname and target.hostname != parts.hostname:
1066
- raise Unsupported(f"other_system: the application is on {target.hostname}")
1066
+ raise Unsupported(f"external_step: the application is on {target.hostname}")
1067
1067
  if href and not APPLY.match(target.path):
1068
- raise Unsupported(f"form_shape: the Apply link leads to {target.path}")
1068
+ raise Unsupported(f"unsupported_flow: the Apply link leads to {target.path}")
1069
1069
  if href:
1070
1070
  return f"{parts.scheme}://{parts.netloc}{target.path}?jobSeqNo={seq}"
1071
1071
  return f"{parts.scheme}://{parts.netloc}{prefix[1]}/apply?jobSeqNo={_seq(page.url)}"
@@ -1107,8 +1107,8 @@ def _next(page: Page, number: int) -> None:
1107
1107
  return
1108
1108
  if errors := page.locator(FORM).evaluate(_ERRORS) if page.locator(FORM).count() else []:
1109
1109
  shown = "; ".join(f"{dom}: {message}" for dom, message in errors)
1110
- raise Unsupported(f"form_shape: step {number} did not pass: {shown}")
1111
- raise Unsupported(f"form_shape: step {number} did not pass in time")
1110
+ raise Unsupported(f"unsupported_flow: step {number} did not pass: {shown}")
1111
+ raise Unsupported(f"unsupported_flow: step {number} did not pass in time")
1112
1112
 
1113
1113
 
1114
1114
  def _click(page: Page, target: Locator) -> None:
@@ -1159,11 +1159,11 @@ def _pick_day(page: Page, dom: str, iso: str) -> None:
1159
1159
  by_year = [s for s in lists if any(o.isdigit() for o in s.evaluate(OPTIONS))]
1160
1160
  by_month = [s for s in lists if s not in by_year]
1161
1161
  if not by_year or not by_month or not by_year[0].evaluate(_SELECT, str(year)):
1162
- raise Unsupported(f"form_shape: {dom} does not list the year {year}")
1162
+ raise NotTaken(f"{dom} does not list the year {year}")
1163
1163
  names = by_month[0].evaluate(OPTIONS)
1164
1164
  by_month[0].evaluate(_SELECT, names[month - 1 + len(names) - 12])
1165
1165
  elif not picker.locator(".react-datepicker__current-month").count():
1166
- raise Unsupported(f"form_shape: {dom} has a calendar without its month")
1166
+ raise Unsupported(f"unsupported_rendering: {dom} has a calendar without its month")
1167
1167
  else:
1168
1168
  for _ in range(MAX_MONTH_STEPS):
1169
1169
  shown = picker.locator(".react-datepicker__current-month").inner_text().split()
@@ -1173,12 +1173,12 @@ def _pick_day(page: Page, dom: str, iso: str) -> None:
1173
1173
  step = "next" if at < (year, month) else "previous"
1174
1174
  _click(page, picker.locator(f".react-datepicker__navigation--{step}"))
1175
1175
  else:
1176
- raise Unsupported(f"form_shape: {dom} does not reach {iso}")
1176
+ raise NotTaken(f"{dom} does not reach {iso}")
1177
1177
  cell = picker.locator(
1178
1178
  f".react-datepicker__day--{day:03d}:not(.react-datepicker__day--outside-month)"
1179
1179
  ).first
1180
1180
  if "--disabled" in (cell.get_attribute("class") or ""):
1181
- raise Unsupported(f"form_shape: {dom} does not take {iso}")
1181
+ raise NotTaken(f"{dom} does not take {iso}")
1182
1182
  _click(page, cell)
1183
1183
 
1184
1184
 
@@ -1190,7 +1190,7 @@ def _page_to(page: Page, picker: Locator, header: str, year: int, dom: str) -> N
1190
1190
  return
1191
1191
  step = "next" if year > shown else "previous"
1192
1192
  _click(page, picker.locator(f".react-datepicker__navigation--{step}"))
1193
- raise Unsupported(f"form_shape: {dom} does not reach the year {year}")
1193
+ raise NotTaken(f"{dom} does not reach the year {year}")
1194
1194
 
1195
1195
 
1196
1196
  def _date_tokens(form: str) -> list[str]:
@@ -1236,7 +1236,7 @@ def _upload(page: Page, path: str) -> None:
1236
1236
  deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
1237
1237
  while not uploaded and not page.evaluate(_SHOWS, name):
1238
1238
  if time.monotonic() > deadline:
1239
- raise Unsupported("form_shape: the resume upload did not show the file")
1239
+ raise Unsupported("unsupported_rendering: the resume upload did not show the file")
1240
1240
  time.sleep(UPLOAD_POLL_S)
1241
1241
  deadline, quiet_since = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000, time.monotonic()
1242
1242
  while time.monotonic() < deadline and time.monotonic() - quiet_since < QUIET_S:
@@ -1270,7 +1270,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
1270
1270
  break
1271
1271
  parent = raw.get("parent")
1272
1272
  if attempt or not parent or not (held := form.locator(f"[id='{parent}']")).count():
1273
- raise Unsupported(f"form_shape: {raw['id']} has no option {_one(answer)!r}")
1273
+ raise NotTaken(f"{raw['id']} has no option {_one(answer)!r}")
1274
1274
  held.evaluate(_SELECT, held.evaluate("el => el.selectedOptions[0]?.text.trim()"))
1275
1275
  case "typeahead": # a list, or suggestions for what is typed
1276
1276
  _click(page, control)
@@ -1297,7 +1297,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
1297
1297
  case "radio": # its label wraps it or names it by `for`
1298
1298
  page.evaluate(_COOKIES)
1299
1299
  if not form.evaluate(_RADIO, [raw["id"], _one(answer)]):
1300
- raise Unsupported(f"form_shape: {raw['id']} has no option {_one(answer)!r}")
1300
+ raise NotTaken(f"{raw['id']} has no option {_one(answer)!r}")
1301
1301
  case "checkbox":
1302
1302
  if control.is_checked() != (_one(answer) == "Yes"):
1303
1303
  control.evaluate("el => el.click()") # the box is drawn over; React sees a click
@@ -1321,7 +1321,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
1321
1321
  picker = page.locator(PICKER).last
1322
1322
  if picker.locator("select.range-select").count():
1323
1323
  if not picker.locator("select.range-select").evaluate(_SELECT, str(int(year))):
1324
- raise Unsupported(f"form_shape: {dom} does not list the year {year}")
1324
+ raise NotTaken(f"{dom} does not list the year {year}")
1325
1325
  else:
1326
1326
  _page_to(page, picker, ".react-datepicker-year-header", int(year), dom)
1327
1327
  _click(page, picker.locator(".react-datepicker__month-text").nth(int(month) - 1))
@@ -1340,7 +1340,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
1340
1340
  step = "next" if year > first else "previous"
1341
1341
  _click(page, picker.locator(f".react-datepicker__navigation--{step}"))
1342
1342
  else:
1343
- raise Unsupported(f"form_shape: {dom} does not list the year {year}")
1343
+ raise NotTaken(f"{dom} does not list the year {year}")
1344
1344
  case "phone": # a national number goes without the code the form asks on its own
1345
1345
  digits = re.sub(r"\D", "", _one(answer))
1346
1346
  dial = form.evaluate(_DIAL) if raw.get("national") else None
@@ -141,7 +141,9 @@ class Recruitee:
141
141
  for raw in form.evaluate(_READ):
142
142
  if raw["kind"] == "video": # recorded in the page, or uploaded to a video service
143
143
  if raw["required"]:
144
- raise Unsupported(f"form_shape: {raw['label']!r} is a video question")
144
+ raise Unsupported(
145
+ f"unsupported_rendering: {raw['label']!r} is a video question"
146
+ )
145
147
  continue
146
148
  fields.append(_field(raw))
147
149
  self._kinds[raw["id"]] = raw["kind"]
@@ -136,7 +136,9 @@ class SmartRecruiters:
136
136
  _decline_cookies(page)
137
137
  for section in ENTRIES:
138
138
  if page.locator(f"{section} [data-test=section-required-mark]").count():
139
- raise Unsupported(f"form_shape: {section.removeprefix('oc-')} entries are required")
139
+ raise Unsupported(
140
+ f"unsupported_rendering: {section.removeprefix('oc-')} entries are required"
141
+ )
140
142
  context = page.evaluate(_CONTEXT) or {}
141
143
  screening = context.get("screeningConfiguration") or {}
142
144
  self._questions = {q["id"]: q for q in screening.get("questions") or []}
@@ -208,7 +210,7 @@ class SmartRecruiters:
208
210
  if kind == "currency":
209
211
  field_type = FieldType.TEXT
210
212
  if field_type is None or kind in OPTIONAL_ONLY:
211
- raise Unsupported(f"form_shape: {label!r} is a {kind} question")
213
+ raise Unsupported(f"unsupported_rendering: {label!r} is a {kind} question")
212
214
  options = [v["label"] for v in entry["values"]] or None
213
215
  if kind == "checkbox":
214
216
  options = YES_NO
@@ -254,7 +256,7 @@ class SmartRecruiters:
254
256
 
255
257
 
256
258
  def _key(url: str) -> str | None:
257
- """`https://jobs.smartrecruiters.com/<company>/<job id>`, or the One Click form's URL."""
259
+ """`https://jobs.smartrecruiters.com/<employer>/<job id>`, or the One Click form's URL."""
258
260
  parts = urlsplit(url)
259
261
  if parts.hostname not in HOSTS:
260
262
  return None