atsfill 0.0.4.dev44__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.dev44 → atsfill-0.0.4.dev48}/PKG-INFO +6 -6
  2. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/README.md +5 -5
  3. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/pyproject.toml +2 -3
  4. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/pyproject.toml.orig +2 -5
  5. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/__init__.py +2 -0
  6. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/ashby.py +2 -2
  7. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/bamboohr.py +4 -4
  8. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/breezy.py +3 -6
  9. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/dayforce.py +8 -7
  10. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/greenhouse.py +5 -5
  11. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/hireology.py +2 -2
  12. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/jazzhr.py +1 -4
  13. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/lever.py +1 -4
  14. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/manatal.py +2 -2
  15. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/oracle.py +10 -11
  16. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paycor.py +7 -10
  17. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paylocity.py +4 -4
  18. atsfill-0.0.4.dev48/src/atsfill/adapters/phenom.py +1353 -0
  19. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/recruitee.py +3 -4
  20. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/smartrecruiters.py +5 -6
  21. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/teamtailor.py +10 -9
  22. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/workable.py +1 -1
  23. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/wttj.py +11 -10
  24. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/adapter.py +19 -6
  25. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/browser.py +5 -3
  26. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/commands.py +29 -18
  27. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/registry.py +69 -48
  28. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/results.py +2 -2
  29. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/registry.toml +24 -19
  30. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/LICENSE +0 -0
  31. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/__init__.py +0 -0
  32. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/cli.py +0 -0
  33. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/__init__.py +0 -0
  34. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/accounts.py +0 -0
  35. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/attempts.py +0 -0
  36. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/matching.py +0 -0
  37. {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/paths.py +0 -0
  38. {atsfill-0.0.4.dev44 → 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.dev44
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
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
54
54
  ## Supported hiring systems
55
55
 
56
56
  <!-- badges -->
57
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
57
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
58
58
  <!-- /badges -->
59
59
 
60
60
  | System | Account needed | `read` without a browser |
@@ -89,7 +89,7 @@ uvx atsfill install-browser
89
89
 
90
90
  This downloads the Camoufox browser build atsfill is pinned to: 1.3 GB, about 2.5 GB once unpacked.
91
91
 
92
- Every push to `main` that passes CI is also published as a dev release, listed on GitHub as the [`next` pre-release](https://github.com/shbernal/atsfill/releases/tag/next); `uvx --prerelease allow atsfill` runs the newest.
92
+ Every push to `main` that passes CI is also published as a dev release; `uvx --prerelease allow atsfill` runs the newest.
93
93
 
94
94
  ## Quickstart
95
95
 
@@ -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.
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
29
29
  ## Supported hiring systems
30
30
 
31
31
  <!-- badges -->
32
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -64,7 +64,7 @@ uvx atsfill install-browser
64
64
 
65
65
  This downloads the Camoufox browser build atsfill is pinned to: 1.3 GB, about 2.5 GB once unpacked.
66
66
 
67
- Every push to `main` that passes CI is also published as a dev release, listed on GitHub as the [`next` pre-release](https://github.com/shbernal/atsfill/releases/tag/next); `uvx --prerelease allow atsfill` runs the newest.
67
+ Every push to `main` that passes CI is also published as a dev release; `uvx --prerelease allow atsfill` runs the newest.
68
68
 
69
69
  ## Quickstart
70
70
 
@@ -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.dev44"
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"
@@ -70,8 +70,7 @@ select = [
70
70
 
71
71
  [tool.pytest.ini_options]
72
72
  testpaths = ["tests"]
73
- markers = ["live: drives a real hiring system page with data from .env and .tmp/; opt-in with -m live"]
74
- addopts = "-m 'not live' -n auto --dist loadscope"
73
+ addopts = "-n auto --dist loadscope"
75
74
 
76
75
  [[tool.uv.index]]
77
76
  name = "testpypi"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev44"
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"
@@ -53,10 +53,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
53
53
 
54
54
  [tool.pytest.ini_options]
55
55
  testpaths = ["tests"]
56
- markers = [
57
- "live: drives a real hiring system page with data from .env and .tmp/; opt-in with -m live",
58
- ]
59
- addopts = "-m 'not live' -n auto --dist loadscope"
56
+ addopts = "-n auto --dist loadscope"
60
57
 
61
58
  [[tool.uv.index]]
62
59
  name = "testpypi"
@@ -10,6 +10,7 @@ from atsfill.adapters.manatal import Manatal
10
10
  from atsfill.adapters.oracle import Oracle
11
11
  from atsfill.adapters.paycor import Paycor
12
12
  from atsfill.adapters.paylocity import Paylocity
13
+ from atsfill.adapters.phenom import Phenom
13
14
  from atsfill.adapters.recruitee import Recruitee
14
15
  from atsfill.adapters.smartrecruiters import SmartRecruiters
15
16
  from atsfill.adapters.teamtailor import Teamtailor
@@ -35,4 +36,5 @@ ADAPTERS: list[Adapter] = [
35
36
  Recruitee(),
36
37
  Breezy(),
37
38
  BambooHR(),
39
+ Phenom(), # last: it claims a URL by its path alone
38
40
  ]
@@ -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
@@ -232,9 +232,6 @@ class Breezy:
232
232
  match = JOB.match(parts.path)
233
233
  return f"https://{parts.hostname}/p/{match[1]}" if match else url
234
234
 
235
- def read_api(self, url: str) -> list[Field] | None:
236
- return None # the form's configuration is only in the page
237
-
238
235
  def read_form(self, page: Page) -> list[Field]:
239
236
  if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
240
237
  page.goto(f"{self.posting(page.url)}/apply")
@@ -349,7 +346,7 @@ def _field(raw: dict[str, Any]) -> Field | None:
349
346
  kind = raw["kind"]
350
347
  if kind == "video":
351
348
  if raw["required"]:
352
- 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")
353
350
  return None
354
351
  label = LABELS.get(raw["id"]) or raw.get("label", "")
355
352
  if "part" in raw:
@@ -192,9 +192,6 @@ class Dayforce:
192
192
  found = _job(url)
193
193
  return f"https://{HOST}/{found[1]}/{found[2]}/jobs/{found[3]}" if found else url
194
194
 
195
- def read_api(self, url: str) -> list[Field] | None:
196
- return None # the template leaves out the options of the country and contact dropdowns
197
-
198
195
  def read_form(self, page: Page) -> list[Field]:
199
196
  found = _job(page.url)
200
197
  if found is None:
@@ -284,7 +281,7 @@ class Dayforce:
284
281
  key, kind = PERSONAL_FIELDS.get(f["fieldName"], (None, None))
285
282
  if key is None or kind is None:
286
283
  if f["isRequired"]:
287
- raise Unsupported(f"form_shape: personal field {f['fieldName']!r}")
284
+ raise Unsupported(f"unsupported_rendering: personal field {f['fieldName']!r}")
288
285
  continue
289
286
  options = {
290
287
  "candidateSource": sources,
@@ -324,7 +321,9 @@ class Dayforce:
324
321
  and ref not in CANDIDATE_INFO
325
322
  and section.get("isRequired")
326
323
  ):
327
- 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
+ )
328
327
  self._sections = [
329
328
  s for s in sections if s.get("questionnaire") and s.get("xRefCode") != "SUBMIT"
330
329
  ]
@@ -552,7 +551,7 @@ def _entry_fields(
552
551
  key, kind = keys.get(re.sub(r"\s", "", f["fieldName"]).upper(), (None, None))
553
552
  if key is None or kind is None:
554
553
  if f["isRequired"] and section["isRequired"]:
555
- raise Unsupported(f"form_shape: {prefix} field {f['fieldName']!r}")
554
+ raise Unsupported(f"unsupported_rendering: {prefix} field {f['fieldName']!r}")
556
555
  continue
557
556
  required = bool(f["isRequired"] and section["isRequired"])
558
557
  options = NO_YES if kind == "boolean" else None
@@ -597,7 +596,9 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
597
596
  label = _plain(question["displayName"])
598
597
  if kind is None:
599
598
  if question["isRequired"]:
600
- 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
+ )
601
602
  return []
602
603
  field_id = f"q{question['questionId']}"
603
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
  + (
@@ -134,9 +134,6 @@ class JazzHR:
134
134
  job = _job(url)
135
135
  return f"https://{job[0]}/apply/{job[1]}" if job else url
136
136
 
137
- def read_api(self, url: str) -> list[Field] | None:
138
- return None
139
-
140
137
  def read_form(self, page: Page) -> list[Field]:
141
138
  form = page.locator(FORM)
142
139
  try:
@@ -241,7 +238,7 @@ def _field(raw: dict[str, Any]) -> Field:
241
238
  case "consent":
242
239
  field_type, options = FieldType.CHOICE, YES_NO
243
240
  case _:
244
- raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
241
+ raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
245
242
  accept = RESUME_TYPES if kind == "file" else None
246
243
  return Field(
247
244
  raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
@@ -132,9 +132,6 @@ class Lever:
132
132
  parts, job = urlsplit(url), _job(url)
133
133
  return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
134
134
 
135
- def read_api(self, url: str) -> list[Field] | None:
136
- return None # Lever's postings API leaves the application's questions out
137
-
138
135
  def read_form(self, page: Page) -> list[Field]:
139
136
  if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
140
137
  page.goto(f"{self.posting(page.url)}/apply")
@@ -208,7 +205,7 @@ def _job(url: str) -> tuple[str, str] | None:
208
205
  def _field(raw: dict[str, Any]) -> Field:
209
206
  kind = raw["kind"]
210
207
  if kind not in KINDS:
211
- raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
208
+ raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
212
209
  options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
213
210
  if kind in ("radio", "checkboxes"):
214
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
 
@@ -259,9 +259,6 @@ class Oracle:
259
259
  assert match, url
260
260
  return f"https://{parts.hostname}/hcmUI/CandidateExperience/job/{match[1]}"
261
261
 
262
- def read_api(self, url: str) -> list[Field] | None:
263
- return None # the form's metadata is served only past the email step
264
-
265
262
  def read_form(self, page: Page) -> list[Field]:
266
263
  parts = urlsplit(page.url)
267
264
  match = JOB.search(parts.path)
@@ -361,7 +358,7 @@ class Oracle:
361
358
  except browser.Timeout:
362
359
  if page.locator(CAPTCHA).count():
363
360
  raise Blocked("captcha: the email step asks for an hCaptcha") from None
364
- 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
365
362
  if page.locator(CODE_PAGE).count():
366
363
  raise Blocked(
367
364
  f"emailed_code: {email} is known to this employer's Oracle, which emails it a"
@@ -374,7 +371,7 @@ class Oracle:
374
371
  """The field a raw description is, None for one atsfill leaves out."""
375
372
  if raw["kind"] == "tiles":
376
373
  if raw["required"]:
377
- raise Unsupported(f"form_shape: {raw['label']!r} needs entries added")
374
+ raise Unsupported(f"unsupported_rendering: {raw['label']!r} needs entries added")
378
375
  return None
379
376
  if raw["kind"] == "email":
380
377
  self._kinds[EMAIL] = "email"
@@ -391,7 +388,7 @@ class Oracle:
391
388
  """The innermost row holding the field."""
392
389
  selector = page.evaluate(_ROW, field_id)
393
390
  if selector is None:
394
- 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")
395
392
  return page.locator(".input-row").filter(has=page.locator(selector)).last
396
393
 
397
394
  def _options(self, page: Page, field_id: str) -> list[str]:
@@ -457,7 +454,9 @@ class Oracle:
457
454
  _choose(page, boxes.nth(1), str(int(day)), exact=True)
458
455
  _choose(page, boxes.nth(2), year, exact=True)
459
456
  case _:
460
- 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
+ )
461
460
 
462
461
  def _phone(self, page: Page, row: Locator, answer: str) -> None:
463
462
  """Picks the country code the number starts with, the longest that fits, then types
@@ -471,7 +470,7 @@ class Oracle:
471
470
  )
472
471
  prefix = next((c for c in codes if digits.startswith(c)), None)
473
472
  if prefix is None:
474
- raise Unsupported(f"form_shape: no country code for {answer!r}")
473
+ raise NotTaken(f"no country code for {answer!r}")
475
474
  _choose(page, code, f"+{prefix}", code=True)
476
475
  number = row.locator("input[type=tel]")
477
476
  number.fill(digits[len(prefix) :])
@@ -544,7 +543,7 @@ def _go_to(page: Page, number: int) -> None:
544
543
  )
545
544
  _settle(page)
546
545
  return
547
- 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")
548
547
 
549
548
 
550
549
  def _upload(field_id: str) -> str:
@@ -614,7 +613,7 @@ def _choose(
614
613
  box.press("Escape")
615
614
  if field is not None:
616
615
  break # a search lists nothing untyped
617
- 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")
618
617
 
619
618
 
620
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)
@@ -160,9 +160,6 @@ class Paycor:
160
160
  job = (query.get("id") or query.get("gni") or [url])[0]
161
161
  return f"{CAREER}JobIntroduction.action?id={job}"
162
162
 
163
- def read_api(self, url: str) -> list[Field] | None:
164
- return None # the form is behind the pages before it, posted in a session
165
-
166
163
  def read_form(self, page: Page) -> list[Field]:
167
164
  self._before, self._codes = [], {}
168
165
  self._walk(page, None)
@@ -210,7 +207,7 @@ class Paycor:
210
207
  named.fill(answer)
211
208
  named.dispatch_event("change")
212
209
  case "select", str() if named.is_disabled():
213
- 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")
214
211
  case "select", str():
215
212
  named.select_option(label=answer)
216
213
  case "radio", str():
@@ -224,7 +221,7 @@ class Paycor:
224
221
  self._pick(named, field, answer)
225
222
  case _:
226
223
  raise Unsupported(
227
- 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}"
228
225
  )
229
226
 
230
227
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -268,7 +265,7 @@ class Paycor:
268
265
  """Opens the apply page and answers the pages before the form: the persona's picks
269
266
  when filling, the last option and Yes when only reading, which list the fields."""
270
267
  if self._start is None:
271
- raise Unsupported("form_shape: no Paycor job in the page")
268
+ raise Unsupported("unsupported_flow: no Paycor job in the page")
272
269
  page.goto(self._start, wait_until="domcontentloaded")
273
270
  for _ in range(STEPS):
274
271
  page.wait_for_load_state("domcontentloaded")
@@ -289,11 +286,11 @@ class Paycor:
289
286
  elif "no longer active" in (page.text_content("body") or ""):
290
287
  raise Unsupported("posting_gone: this job is no longer active")
291
288
  else:
292
- 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})")
293
290
  # The form's third-party scripts (Dropbox, Google) can hold its `load` back.
294
291
  with page.expect_navigation(wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS):
295
292
  button.click(no_wait_after=True)
296
- raise Unsupported("form_shape: no form after the pages before it")
293
+ raise Unsupported("unsupported_flow: no form after the pages before it")
297
294
 
298
295
  def _eeo(self, page: Page, answers: dict[str, Answer] | None) -> None:
299
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),