atsfill 0.0.4.dev31__tar.gz → 0.0.4.dev32__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.
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/README.md +1 -1
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev32/src/atsfill/adapters/hireology.py +328 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/LICENSE +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev31 → atsfill-0.0.4.dev32}/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.
|
|
3
|
+
Version: 0.0.4.dev32
|
|
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
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
           
|
|
56
|
+
            
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| System | Account needed | `read` without a browser |
|
|
@@ -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
|
-
           
|
|
32
|
+
            
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
2
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
|
+
from atsfill.adapters.hireology import Hireology
|
|
3
4
|
from atsfill.adapters.jazzhr import JazzHR
|
|
4
5
|
from atsfill.adapters.lever import Lever
|
|
5
6
|
from atsfill.adapters.manatal import Manatal
|
|
@@ -22,6 +23,7 @@ ADAPTERS: list[Adapter] = [
|
|
|
22
23
|
Manatal(),
|
|
23
24
|
Paylocity(),
|
|
24
25
|
Workable(),
|
|
26
|
+
Hireology(),
|
|
25
27
|
Paycor(),
|
|
26
28
|
JazzHR(),
|
|
27
29
|
Lever(),
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Hireology (`careers.hireology.com/<careers site>/<job>/description`), as
|
|
2
|
+
docs/adapters/hireology.md observes.
|
|
3
|
+
|
|
4
|
+
The job page renders its application form from a public form API, which lists every field by
|
|
5
|
+
id, type and requirement. `read_api` fetches it over HTTP, `read_form` through the page's own
|
|
6
|
+
browser context, and both list the fields from it. Each field renders as one control whose id is
|
|
7
|
+
`<field id>-<n>`; nothing but the submit reaches Hireology.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import urllib.request
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from atsfill.core import browser
|
|
18
|
+
from atsfill.core.adapter import Unsupported
|
|
19
|
+
from atsfill.core.browser import Page, Timeout
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
|
|
22
|
+
HOST = "careers.hireology.com"
|
|
23
|
+
JOB = re.compile(r"^/[^/]+/(\d+)(?:/|$)")
|
|
24
|
+
API = "https://api.hireology.com/v2/public/application_forms/{job}"
|
|
25
|
+
APPLIED = re.compile(r"^https://api\.hireology\.com/v2/public/")
|
|
26
|
+
FORM = "#application"
|
|
27
|
+
SUBMIT = f"{FORM} #submit"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
30
|
+
SHOWN_TIMEOUT_MS = 2_000
|
|
31
|
+
YES_NO = ["Yes", "No"]
|
|
32
|
+
FILE_TYPES = [".pdf", ".doc", ".docx"]
|
|
33
|
+
MONTH = " (MM/YYYY)"
|
|
34
|
+
|
|
35
|
+
# The page's labels of the fields the API names without one; a custom question carries its own.
|
|
36
|
+
LABELS = {
|
|
37
|
+
"first_name": "First name",
|
|
38
|
+
"last_name": "Last name",
|
|
39
|
+
"email_address": "Email address",
|
|
40
|
+
"home_phone": "Phone number",
|
|
41
|
+
"sms_opt_in": "I would like to communicate with the hiring team via text message",
|
|
42
|
+
"street_address": "Address",
|
|
43
|
+
"city": "City",
|
|
44
|
+
"state_id": "State/Province",
|
|
45
|
+
"zip_code": "Zip/Postal Code",
|
|
46
|
+
"linkedin": "LinkedIn URL",
|
|
47
|
+
"candidate_referred": "Were you referred by anyone?",
|
|
48
|
+
"referred_by": "Name of person who referred you",
|
|
49
|
+
"resume": "Resume",
|
|
50
|
+
"cover": "Cover letter",
|
|
51
|
+
"desired_compensation": "Desired compensation",
|
|
52
|
+
"desired_compensation_unit": "Desired compensation type",
|
|
53
|
+
"start_new_position": "Timeframe to start new position",
|
|
54
|
+
"earliest_availability": "What is your earliest available start date?",
|
|
55
|
+
"career_objection": "What are your career objectives over the next 10 years?",
|
|
56
|
+
"non_complete_in_place": "Do you have a non-compete agreement in place with your current"
|
|
57
|
+
" employer?",
|
|
58
|
+
"non_complete_in_place_reason": "Non-compete agreement details",
|
|
59
|
+
"relocate": "Are you willing to relocate?",
|
|
60
|
+
"relocate_reason": "Relocation details",
|
|
61
|
+
"company_name": "Company name",
|
|
62
|
+
"company_website": "Company website",
|
|
63
|
+
"location_city": "City",
|
|
64
|
+
"location_state_id": "State",
|
|
65
|
+
"job_title_held": "Job title held",
|
|
66
|
+
"start_date": "Start date",
|
|
67
|
+
"end_date": "End date",
|
|
68
|
+
"current": "Current",
|
|
69
|
+
"supervisor_name": "Name of immediate supervisor",
|
|
70
|
+
"supervisor_title": "Supervisor's title",
|
|
71
|
+
"manage_others": "Did you manage others in this role?",
|
|
72
|
+
"manage_others_text": "If yes, please explain",
|
|
73
|
+
"position_likeness": "What did you like most about the position?",
|
|
74
|
+
"position_dislikeness": "What did you like least about this position and why did you leave"
|
|
75
|
+
" (or plan to leave)?",
|
|
76
|
+
"highest_grade": "Highest level of education completed",
|
|
77
|
+
"institute_name": "School name",
|
|
78
|
+
"graduation_year": "Graduation year",
|
|
79
|
+
"education_city": "City",
|
|
80
|
+
"education_state_id": "State",
|
|
81
|
+
"participation_extra_activities": "Did you participate in extracurricular activities?",
|
|
82
|
+
"extra_activities": "Extracurricular activities",
|
|
83
|
+
"part_time": "Did you work while in school?",
|
|
84
|
+
"part_time_text": "Work while in school",
|
|
85
|
+
"gained_academic_awards": "Did you receive academic honors or awards?",
|
|
86
|
+
"academic_awards": "Academic honors or awards",
|
|
87
|
+
"military": "Did you serve in any branch of the US Armed Services?",
|
|
88
|
+
"army_branch": "Branch",
|
|
89
|
+
"highest_rank": "Highest rank/grade",
|
|
90
|
+
"joining_date": "Date entered",
|
|
91
|
+
"discharge_date": "Date discharged",
|
|
92
|
+
"currently_serving": "Currently serving",
|
|
93
|
+
"honorable_discharge": "Did you receive an honorable discharge?",
|
|
94
|
+
"nature_of_duty": "What was the nature of your duties?",
|
|
95
|
+
"gender": "Gender",
|
|
96
|
+
"is_hispanic": "Are you Hispanic or Latino?",
|
|
97
|
+
"race": "Identify your race",
|
|
98
|
+
"veteran_status": "Veteran status",
|
|
99
|
+
"signature": "Signature",
|
|
100
|
+
}
|
|
101
|
+
# The page's heading of a section whose labels repeat others' ("City").
|
|
102
|
+
SECTIONS = {"experience": "Work history: ", "education": "Education: ", "military": "Military: "}
|
|
103
|
+
# A field the page shows only once another is answered: (that field, the answers that show it).
|
|
104
|
+
SHOWN_BY = {
|
|
105
|
+
"referred_by": ("candidate_referred", "is Yes"),
|
|
106
|
+
"non_complete_in_place_reason": ("non_complete_in_place", "is Yes"),
|
|
107
|
+
"relocate_reason": ("relocate", "is Yes"),
|
|
108
|
+
"extra_activities": ("participation_extra_activities", "is Yes"),
|
|
109
|
+
"part_time_text": ("part_time", "is Yes"),
|
|
110
|
+
"academic_awards": ("gained_academic_awards", "is Yes"),
|
|
111
|
+
**{
|
|
112
|
+
f: ("military", "is Yes")
|
|
113
|
+
for f in (
|
|
114
|
+
"army_branch",
|
|
115
|
+
"highest_rank",
|
|
116
|
+
"joining_date",
|
|
117
|
+
"discharge_date",
|
|
118
|
+
"currently_serving",
|
|
119
|
+
"honorable_discharge",
|
|
120
|
+
"nature_of_duty",
|
|
121
|
+
)
|
|
122
|
+
},
|
|
123
|
+
**{
|
|
124
|
+
f: ("highest_grade", "is not Did Not Complete High School")
|
|
125
|
+
for f in ("institute_name", "graduation_year", "education_city", "education_state_id")
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
TYPES = {
|
|
129
|
+
"text": FieldType.TEXT,
|
|
130
|
+
"email": FieldType.TEXT,
|
|
131
|
+
"textarea": FieldType.TEXT,
|
|
132
|
+
"month": FieldType.TEXT,
|
|
133
|
+
"tel": FieldType.PHONE,
|
|
134
|
+
"date": FieldType.DATE,
|
|
135
|
+
"select": FieldType.CHOICE,
|
|
136
|
+
"radio": FieldType.CHOICE,
|
|
137
|
+
"checkbox": FieldType.CHOICE,
|
|
138
|
+
"file": FieldType.FILE,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# The control a field renders as: the element whose id is `<field id>-<n>`, a radio group's
|
|
142
|
+
# and a month's wrapper included; `n` is 0, or 1 for the education entry.
|
|
143
|
+
_CONTROL = """(form, id) => {
|
|
144
|
+
const own = new RegExp(`^${id.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}-\\\\d+$`);
|
|
145
|
+
return [...form.querySelectorAll("[id]")].find(e => own.test(e.id))?.id ?? null;
|
|
146
|
+
}"""
|
|
147
|
+
|
|
148
|
+
# The form API's answer, fetched as the page fetches it.
|
|
149
|
+
_FETCH = """async url => {
|
|
150
|
+
const response = await fetch(url);
|
|
151
|
+
return response.ok ? {json: await response.json()} : {status: response.status};
|
|
152
|
+
}"""
|
|
153
|
+
|
|
154
|
+
# Picks a dropdown's option by its text: React keeps the value only from a change it sees.
|
|
155
|
+
_SELECT = """(el, text) => {
|
|
156
|
+
const option = [...el.options].find(o => o.text.trim() === text);
|
|
157
|
+
if (!option) return;
|
|
158
|
+
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(el, option.value);
|
|
159
|
+
el.dispatchEvent(new Event("change", {bubbles: true}));
|
|
160
|
+
}"""
|
|
161
|
+
|
|
162
|
+
# What each present control shows, by the field's API type.
|
|
163
|
+
_READ_BACK = """(form, kinds) => {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [id, kind] of Object.entries(kinds)) {
|
|
166
|
+
const own = new RegExp(`^${id.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}-\\\\d+$`);
|
|
167
|
+
const el = [...form.querySelectorAll("[id]")].find(e => own.test(e.id));
|
|
168
|
+
if (!el) continue;
|
|
169
|
+
if (kind === "radio") {
|
|
170
|
+
const checked = el.querySelector("input:checked");
|
|
171
|
+
out[id] = checked ? checked.closest("label").textContent.trim() : null;
|
|
172
|
+
} else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
|
|
173
|
+
else if (kind === "select") out[id] = el.value ? el.selectedOptions[0].text.trim() : null;
|
|
174
|
+
else if (kind === "month") {
|
|
175
|
+
const month = document.getElementById(`${el.id}-month`).value;
|
|
176
|
+
const year = document.getElementById(`${el.id}-year`).value;
|
|
177
|
+
out[id] = month || year ? `${month}/${year}` : null;
|
|
178
|
+
} else if (kind === "file") {
|
|
179
|
+
const shown = el.closest(".careers-file-input__container")
|
|
180
|
+
?.querySelector("[data-qa-id=careers-filename]");
|
|
181
|
+
out[id] = shown ? shown.textContent.trim() : null;
|
|
182
|
+
} else out[id] = el.value;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Hireology:
|
|
189
|
+
name = "hireology"
|
|
190
|
+
side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
|
|
191
|
+
|
|
192
|
+
def __init__(self) -> None:
|
|
193
|
+
self._kinds: dict[str, str] = {} # each field's API type, in the form's order
|
|
194
|
+
|
|
195
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
196
|
+
return _job(url) is not None
|
|
197
|
+
|
|
198
|
+
def posting(self, url: str) -> str:
|
|
199
|
+
job = _job(url)
|
|
200
|
+
return API.format(job=job) if job else url
|
|
201
|
+
|
|
202
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
203
|
+
if (job := _job(url)) is None:
|
|
204
|
+
return None
|
|
205
|
+
request = urllib.request.Request(API.format(job=job), headers={"User-Agent": "atsfill"})
|
|
206
|
+
try:
|
|
207
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
208
|
+
return [f for f, _ in _form(json.load(response))]
|
|
209
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
213
|
+
job = _job(page.url)
|
|
214
|
+
try:
|
|
215
|
+
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
216
|
+
except Timeout:
|
|
217
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
218
|
+
api = page.evaluate(_FETCH, API.format(job=job))
|
|
219
|
+
if "status" in api:
|
|
220
|
+
raise Unsupported(f"posting_gone: the form API answered {api['status']}")
|
|
221
|
+
form = _form(api["json"])
|
|
222
|
+
self._kinds = {field.id: kind for field, kind in form}
|
|
223
|
+
form_el = page.locator(FORM)
|
|
224
|
+
for field, _ in form:
|
|
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")
|
|
227
|
+
return [f for f, _ in form]
|
|
228
|
+
|
|
229
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
230
|
+
form = page.locator(FORM)
|
|
231
|
+
for field_id, kind in self._kinds.items(): # a question before the fields it shows
|
|
232
|
+
if field_id not in answers:
|
|
233
|
+
continue
|
|
234
|
+
answer = answers[field_id]
|
|
235
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
236
|
+
if field_id in SHOWN_BY: # rendered a moment after the answer that shows it
|
|
237
|
+
with suppress(Timeout):
|
|
238
|
+
form.locator(f"[id^='{field_id}-']").first.wait_for(
|
|
239
|
+
state="attached", timeout=SHOWN_TIMEOUT_MS
|
|
240
|
+
)
|
|
241
|
+
dom = form.evaluate(_CONTROL, field_id)
|
|
242
|
+
if dom is None:
|
|
243
|
+
continue # shown by another answer, which was not given
|
|
244
|
+
control = page.locator(f"[id='{dom}']")
|
|
245
|
+
match kind:
|
|
246
|
+
case "file":
|
|
247
|
+
control.set_input_files(value)
|
|
248
|
+
case "select":
|
|
249
|
+
control.evaluate(_SELECT, value)
|
|
250
|
+
case "radio":
|
|
251
|
+
browser.click(control.locator("label").filter(has_text=_exact(value)))
|
|
252
|
+
case "checkbox":
|
|
253
|
+
if control.is_checked() != (value == "Yes"):
|
|
254
|
+
browser.click(page.locator(f"label[for='{dom}']"))
|
|
255
|
+
case "month":
|
|
256
|
+
month, _, year = value.partition("/")
|
|
257
|
+
page.locator(f"[id='{dom}-month']").fill(month)
|
|
258
|
+
page.locator(f"[id='{dom}-year']").fill(year)
|
|
259
|
+
case _:
|
|
260
|
+
control.fill(value)
|
|
261
|
+
|
|
262
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
263
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
264
|
+
return {k: v for k, v in values.items() if v not in (None, "")}
|
|
265
|
+
|
|
266
|
+
def submit(self, page: Page) -> str | None:
|
|
267
|
+
"""Clicks submit; the evidence is the API's answer to the application."""
|
|
268
|
+
with page.expect_response(
|
|
269
|
+
lambda r: r.request.method == "POST" and APPLIED.match(r.url) is not None,
|
|
270
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
271
|
+
) as applied:
|
|
272
|
+
browser.click(page.locator(SUBMIT))
|
|
273
|
+
response = applied.value
|
|
274
|
+
if not response.ok:
|
|
275
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
276
|
+
return f"{response.url} {response.status}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _job(url: str) -> str | None:
|
|
280
|
+
parts = urlsplit(url)
|
|
281
|
+
if parts.hostname != HOST or not (match := JOB.match(parts.path)):
|
|
282
|
+
return None
|
|
283
|
+
return match[1]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
287
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
|
|
291
|
+
"""Each field of the form API's template with its API type; an entry the page can repeat
|
|
292
|
+
is read once, as its first."""
|
|
293
|
+
out = []
|
|
294
|
+
for section in api["template"]["sections"]:
|
|
295
|
+
for fieldset in section["fieldsets"]:
|
|
296
|
+
for entry in fieldset["fields"]:
|
|
297
|
+
out.append(
|
|
298
|
+
(_field(entry, SECTIONS.get(section["id"], "")), entry["attributes"]["type"])
|
|
299
|
+
)
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _field(entry: dict[str, Any], section: str) -> Field:
|
|
304
|
+
field_id, attributes = entry["id"], entry["attributes"]
|
|
305
|
+
kind = attributes["type"]
|
|
306
|
+
if kind not in TYPES:
|
|
307
|
+
raise Unsupported(f"form_shape: {field_id} is a {kind}")
|
|
308
|
+
label = (
|
|
309
|
+
section
|
|
310
|
+
+ (
|
|
311
|
+
LABELS.get(field_id) or attributes.get("label") or entry.get("label") or field_id
|
|
312
|
+
).strip()
|
|
313
|
+
)
|
|
314
|
+
required = attributes["required"] and kind != "checkbox" # a box left clear is an answer
|
|
315
|
+
options = [o["name"] for o in attributes["options"]] or None
|
|
316
|
+
if kind == "checkbox":
|
|
317
|
+
options = YES_NO
|
|
318
|
+
elif kind == "month":
|
|
319
|
+
label += MONTH
|
|
320
|
+
if shown_by := SHOWN_BY.get(field_id):
|
|
321
|
+
trigger, condition = shown_by
|
|
322
|
+
label += f" (asked when {section + LABELS[trigger]!r} {condition})"
|
|
323
|
+
required = False
|
|
324
|
+
max_length = attributes.get("maxlength") if TYPES[kind] is FieldType.TEXT else None
|
|
325
|
+
if kind == "month":
|
|
326
|
+
max_length = None
|
|
327
|
+
accept = FILE_TYPES if kind == "file" else None
|
|
328
|
+
return Field(field_id, label, TYPES[kind], required, options, max_length, accept)
|
|
@@ -216,7 +216,9 @@ kind = "ats"
|
|
|
216
216
|
share = 0.0101
|
|
217
217
|
account = "none"
|
|
218
218
|
hosting = ["hosted"]
|
|
219
|
+
adapter = "hireology"
|
|
219
220
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
221
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "87c2539" }
|
|
220
222
|
|
|
221
223
|
[paycor]
|
|
222
224
|
kind = "ats"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|