atsfill 0.0.3.dev11__tar.gz → 0.0.3.dev13__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 (24) hide show
  1. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/PKG-INFO +2 -2
  2. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/README.md +1 -1
  3. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/teamtailor.py +147 -47
  6. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/registry.toml +1 -0
  7. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/LICENSE +0 -0
  8. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/greenhouse.py +0 -0
  12. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/wttj.py +0 -0
  13. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/cli.py +0 -0
  14. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/__init__.py +0 -0
  15. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/accounts.py +0 -0
  16. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/adapter.py +0 -0
  17. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/attempts.py +0 -0
  18. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/browser.py +0 -0
  19. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/commands.py +0 -0
  20. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/matching.py +0 -0
  21. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/paths.py +0 -0
  22. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/registry.py +0 -0
  23. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/results.py +0 -0
  24. {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.3.dev11
3
+ Version: 0.0.3.dev13
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
- ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
56
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
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
- ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
32
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev11"
3
+ version = "0.0.3.dev13"
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.3.dev11"
3
+ version = "0.0.3.dev13"
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"
@@ -10,19 +10,27 @@ import time
10
10
  from typing import Any
11
11
  from urllib.parse import urlsplit
12
12
 
13
- from atsfill.core import browser
13
+ from atsfill.core import browser, matching
14
14
  from atsfill.core.adapter import Unsupported
15
15
  from atsfill.core.browser import Locator, Page
16
16
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
17
 
18
18
  FORM = "#job-application-form"
19
+ # A custom question, or a qualifying one: the rest of the form stays disabled until it is
20
+ # answered with the answer the employer requires.
21
+ QUESTION = ".question, [data-careersite--form-target=qualifyingAnswers]"
22
+ LOCKED = "[data-careersite--form-target=formContentWrapper].cursor-not-allowed"
23
+ LOCATION = "candidate[location][query]"
24
+ CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
19
25
  JOB = re.compile(r"/jobs/(\d+)[^/]*")
20
26
  SUBMIT_TIMEOUT_MS = 90_000
27
+ FOLLOW_UP_TIMEOUT_MS = 1_000
21
28
  UPLOAD_TIMEOUT_S = 60
22
29
  QUESTION_TYPES = {
23
30
  "text": FieldType.TEXT,
24
31
  "number": FieldType.TEXT,
25
32
  "date": FieldType.DATE,
33
+ "range": FieldType.CHOICE,
26
34
  "boolean": FieldType.CHOICE,
27
35
  "choice": FieldType.CHOICE,
28
36
  "choices": FieldType.MULTI_CHOICE,
@@ -32,14 +40,16 @@ STANDARD_TYPES = {
32
40
  "email": FieldType.TEXT,
33
41
  "textarea": FieldType.TEXT,
34
42
  "tel": FieldType.PHONE,
43
+ "location": FieldType.TYPEAHEAD,
35
44
  "checkbox": FieldType.CHOICE,
45
+ "choices": FieldType.MULTI_CHOICE,
36
46
  "file": FieldType.FILE,
37
47
  }
38
48
  CHECKBOX = ["Yes", "No"]
39
49
 
40
50
  # Every field of the form, described from the page. A custom question's id is
41
51
  # `question:<Teamtailor question id>`; the others are the input's name.
42
- _READ = """form => {
52
+ _READ = """(form, QUESTION) => {
43
53
  const label = el => {
44
54
  if (!el) return "";
45
55
  const copy = el.cloneNode(true);
@@ -47,45 +57,68 @@ _READ = """form => {
47
57
  return copy.textContent.replace(/\\s+/g, " ").trim();
48
58
  };
49
59
  const required = el => !!el?.querySelector("sup[data-asterisk]");
60
+ const PICKS = "input[type=radio], input[type=checkbox]";
61
+ const options = (box, inputs) => {
62
+ const item = value => box.querySelector(`[role^=menuitem][data-value="${value}"]`);
63
+ return [...inputs]
64
+ .map(e => label(e.labels?.[0]) || item(e.value)?.dataset.searchText || e.value);
65
+ };
50
66
  const fields = [];
51
67
  for (const el of form.querySelectorAll("input, textarea")) {
52
- if (el.closest(".question") || el.disabled) continue;
53
- const kind = el.tagName === "TEXTAREA" ? "textarea" : el.type;
68
+ if (el.closest(QUESTION) || el.disabled || fields.some(f => f.id === el.name)) continue;
69
+ const kind = el.tagName === "TEXTAREA" ? "textarea"
70
+ : el.name === "candidate[location][query]" ? "location"
71
+ : el.type === "checkbox" && el.name.endsWith("[]") ? "choices" : el.type;
54
72
  let id = el.name;
55
73
  if (kind === "file") id = `candidate[${el.id.replace(/^candidate_/, "")}]`;
56
- if (!["text", "email", "tel", "textarea", "checkbox", "file"].includes(kind) || !id) continue;
74
+ if (!["text", "email", "tel", "textarea", "checkbox", "choices", "file", "location"]
75
+ .includes(kind) || !id) continue;
57
76
  if (/linkedin|referrer|social_image/.test(id)) continue;
58
- const host = el.labels?.[0] ?? el.closest("[data-controller]")?.querySelector("label");
77
+ const box = kind === "choices" ? el.closest("fieldset") : null;
78
+ const host = box ? box.querySelector("legend")
79
+ : el.labels?.[0] ?? el.closest("[data-controller]")?.querySelector("label");
59
80
  fields.push({id, kind, label: label(host), required: required(host),
81
+ options: box ? options(box, form.querySelectorAll(`[name="${id}"]`)) : null,
60
82
  maxLength: el.maxLength > 0 ? el.maxLength : null,
61
83
  accept: el.accept ? el.accept.split(",") : null});
62
84
  }
63
- for (const q of form.querySelectorAll(".question")) {
85
+ for (const q of form.querySelectorAll(QUESTION)) {
64
86
  const qid = q.querySelector("input[name$='[question_id]']")?.value;
87
+ const qualifying = !q.matches(".question");
65
88
  const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
66
89
  const kind = /picked_video/.test(named?.name) ? "video"
67
90
  : q.querySelector("input[type=file]") && !named ? "file"
68
91
  : (named?.name.match(/\\]\\[(\\w+)\\](\\[\\])?$/)?.[1] ?? "unknown");
69
- const item = value => q.querySelector(`[role^=menuitem][data-value="${value}"]`);
70
- const options = [...q.querySelectorAll("input[type=radio], input[type=checkbox]")]
71
- .map(e => label(e.labels?.[0]) || item(e.value)?.dataset.searchText || e.value);
92
+ const picks = options(q, q.querySelectorAll(PICKS));
93
+ if (named?.type === "range") {
94
+ const step = Number(named.step) || 1;
95
+ for (let v = Number(named.min); v <= Number(named.max); v += step) picks.push(String(v));
96
+ }
72
97
  const menu = [...q.querySelectorAll("[role^=menuitem]")].map(e => e.dataset.searchText);
73
- fields.push({id: `question:${qid}`, kind, question: true,
74
- label: label(q.querySelector("legend, label")),
75
- required: q.dataset.questionMandatory === "true",
76
- options: options.length ? options : menu.length ? menu : null});
98
+ const parent = q.dataset.questionShowIfUuid
99
+ && form.querySelector(`[data-question-uuid="${q.dataset.questionShowIfUuid}"]`);
100
+ const shownBy = parent && [...parent.querySelectorAll(PICKS)]
101
+ .find(e => e.value === q.dataset.questionShowIfValue);
102
+ const when = shownBy && ` (asked when "${label(parent.querySelector("legend, label"))}" is`
103
+ + ` "${options(parent, [shownBy])[0]}")`;
104
+ fields.push({id: `question:${qid}`, kind, question: true, qualifying, conditional: !!parent,
105
+ label: label(q.querySelector("legend, label")) + (when || ""),
106
+ required: qualifying || (!parent && q.dataset.questionMandatory === "true"),
107
+ asked: q.dataset.questionMandatory === "true",
108
+ options: picks.length ? picks : menu.length ? menu : null});
77
109
  }
78
110
  return fields;
79
111
  }"""
80
112
 
81
113
  # What the form will send: each field's value, a choice as its option's text, a file as the
82
- # uploaded file's name.
83
- _READ_BACK = """form => {
114
+ # uploaded file's name, an address once a suggestion is picked.
115
+ _READ_BACK = """(form, QUESTION) => {
84
116
  const data = new FormData(form);
85
117
  const text = e => {
86
118
  const label = e.labels?.[0];
87
119
  if (label) return label.textContent.replace(/\\s+/g, " ").trim();
88
- const item = e.closest(".question")?.querySelector(`[role^=menuitem][data-value="${e.value}"]`);
120
+ const box = e.closest(`${QUESTION}, fieldset`);
121
+ const item = box?.querySelector(`[role^=menuitem][data-value="${e.value}"]`);
89
122
  return item?.dataset.searchText ?? e.value;
90
123
  };
91
124
  const file = url => url ? decodeURIComponent(new URL(url).pathname.split("/").pop()) : null;
@@ -93,14 +126,22 @@ _READ_BACK = """form => {
93
126
  for (const el of form.querySelectorAll("input[type=checkbox][name^='candidate['][value='1']")) {
94
127
  out[el.name] = data.getAll(el.name).includes("1") ? "Yes" : "No";
95
128
  }
129
+ for (const el of form.querySelectorAll("input[type=checkbox][name^='candidate['][name$='[]']")) {
130
+ out[el.name] = [...form.querySelectorAll(`[name="${el.name}"]:checked`)].map(text);
131
+ }
96
132
  for (const name of ["first_name", "last_name", "email", "phone"]) {
97
133
  out[`candidate[${name}]`] = data.get(`candidate[${name}]`);
98
134
  }
99
135
  for (const [name, value] of data) {
100
136
  if (/^candidate\\[\\w+_remote_url\\]$/.test(name)) out[name] = file(value);
101
- else if (/cover_letter/.test(name)) out[name] = value;
137
+ else if (/^candidate\\[uploads_attributes\\]\\[\\d+\\]\\[file_remote_url\\]$/.test(name)) {
138
+ out["candidate[file_remote_url]"] = file(value); // "Additional files", one per upload
139
+ } else if (/cover_letter/.test(name)) out[name] = value;
140
+ }
141
+ if (data.get("candidate[location][place_id]")) {
142
+ out["candidate[location][query]"] = data.get("candidate[location][query]");
102
143
  }
103
- for (const q of form.querySelectorAll(".question")) {
144
+ for (const q of form.querySelectorAll(QUESTION)) {
104
145
  const id = `question:${q.querySelector("input[name$='[question_id]']")?.value}`;
105
146
  const checked = [...q.querySelectorAll("input:checked")];
106
147
  const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
@@ -121,14 +162,17 @@ class Teamtailor:
121
162
 
122
163
  def __init__(self) -> None:
123
164
  self._fields: dict[str, Field] = {}
165
+ self._qualifying: set[str] = set()
166
+ self._follow_ups: dict[str, bool] = {} # shown by an earlier answer: required once shown
124
167
 
125
168
  def detect(self, url: str, page: Page | None) -> bool:
126
- """A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link."""
169
+ """A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
170
+ career site script."""
127
171
  if not JOB.search(urlsplit(url).path):
128
172
  return False
129
173
  if (urlsplit(url).hostname or "").endswith(".teamtailor.com"):
130
174
  return True
131
- return page is not None and page.locator("a[href*='//www.teamtailor.com']").count() > 0
175
+ return page is not None and page.locator(CAREER_SITE).count() > 0
132
176
 
133
177
  def posting(self, url: str) -> str:
134
178
  parts = urlsplit(url)
@@ -145,10 +189,19 @@ class Teamtailor:
145
189
  job = JOB.search(parts.path)
146
190
  assert job, page.url # detected on a job URL
147
191
  page.goto(f"{parts.scheme}://{parts.netloc}{parts.path[: job.end()]}/applications/new")
192
+ if urlsplit(page.url).hostname != parts.hostname:
193
+ raise Unsupported(f"form_shape: the job applies on another site ({page.url})")
148
194
  form = page.locator(FORM)
149
- form.wait_for()
150
- fields = []
151
- for raw in form.evaluate(_READ):
195
+ try:
196
+ form.wait_for()
197
+ except browser.Timeout:
198
+ raise Unsupported("posting_gone: no application form on the page") from None
199
+ fields, self._qualifying, self._follow_ups = [], set(), {}
200
+ for raw in form.evaluate(_READ, QUESTION):
201
+ if raw.get("qualifying"):
202
+ self._qualifying.add(raw["id"])
203
+ if raw.get("conditional"):
204
+ self._follow_ups[raw["id"]] = raw["asked"]
152
205
  field = _field(raw)
153
206
  if field is None and raw["required"]:
154
207
  raise Unsupported(f"form_shape: {raw['label']!r} is a {raw['kind']} question")
@@ -160,30 +213,22 @@ class Teamtailor:
160
213
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
161
214
  form = page.locator(FORM)
162
215
  _decline_cookies(page)
163
- for field_id, answer in answers.items():
164
- field = self._fields[field_id]
165
- host = self._host(form, field_id)
166
- match field.type, answer:
167
- case FieldType.FILE, str():
168
- host.locator("input[type=file]").set_input_files(answer)
169
- case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
170
- box = host.locator("input[type=checkbox]")
171
- if box.is_checked() != (answer == "Yes"):
172
- _click(page, page.locator(f"label[for='{box.get_attribute('id')}']"))
173
- case FieldType.CHOICE | FieldType.MULTI_CHOICE, _:
174
- for value in [answer] if isinstance(answer, str) else answer:
175
- _pick(page, host, value)
176
- case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
177
- box = host.locator("input:not([type=hidden]):not([type=search]), textarea")
178
- box.first.fill(answer)
179
- case kind, _:
180
- raise Unsupported(
181
- f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
182
- )
216
+ for field_id in [i for i in answers if i in self._qualifying]:
217
+ self._fill(form, field_id, answers[field_id])
218
+ if form.locator(LOCKED).count():
219
+ raise Unsupported("not_qualified: the qualifying answers leave the form locked")
220
+ for field_id in [i for i in answers if i not in self._qualifying]:
221
+ if field_id in self._follow_ups and not self._shown(form, field_id):
222
+ continue # not asked: the read-back leaves it out
223
+ self._fill(form, field_id, answers[field_id])
224
+ for field_id, required in self._follow_ups.items():
225
+ if required and field_id not in answers and self._shown(form, field_id):
226
+ label = self._fields[field_id].label
227
+ raise Unsupported(f"missing_answer: an earlier answer asks {label!r}")
183
228
  self._wait_for_uploads(form, answers)
184
229
 
185
230
  def read_back(self, page: Page) -> dict[str, Answer]:
186
- values = page.locator(FORM).evaluate(_READ_BACK)
231
+ values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
187
232
  return {k: v for k, v in values.items() if v not in (None, "", [])}
188
233
 
189
234
  def submit(self, page: Page) -> str | None:
@@ -191,13 +236,53 @@ class Teamtailor:
191
236
  page.wait_for_url(re.compile(r"/applications/\d+/thanks"), timeout=SUBMIT_TIMEOUT_MS)
192
237
  return page.url
193
238
 
239
+ def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
240
+ page, field, host = form.page, self._fields[field_id], self._host(form, field_id)
241
+ match field.type, answer:
242
+ case FieldType.FILE, str():
243
+ host.locator("input[type=file]").set_input_files(answer)
244
+ case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
245
+ box = host.locator("input[type=checkbox]")
246
+ if box.is_checked() != (answer == "Yes"):
247
+ _click(page, page.locator(f"label[for='{box.get_attribute('id')}']"))
248
+ case FieldType.CHOICE, str() if host.locator("input[type=range]").count():
249
+ host.locator("input[type=range]").fill(answer)
250
+ case FieldType.TYPEAHEAD, str():
251
+ _suggest(page, field, host, answer)
252
+ case FieldType.CHOICE | FieldType.MULTI_CHOICE, _:
253
+ for value in [answer] if isinstance(answer, str) else answer:
254
+ _pick(page, host, value)
255
+ trigger = host.locator("button[aria-haspopup][aria-expanded=true]")
256
+ if trigger.count(): # a multiple choice menu stays open over the next fields
257
+ _click(page, trigger.first)
258
+ case FieldType.TEXT, str() if host.locator("input[type=number]").count():
259
+ if re.fullmatch(r"-?\d+(\.\d+)?", answer): # else left empty: a mismatch
260
+ host.locator("input[type=number]").first.fill(answer)
261
+ case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
262
+ box = host.locator("input:not([type=hidden]):not([type=search]), textarea")
263
+ box.first.fill(answer)
264
+ case kind, _:
265
+ raise Unsupported(f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}")
266
+
267
+ def _shown(self, form: Locator, field_id: str) -> bool:
268
+ """Whether a follow-up question shows, once the answer before it has opened it."""
269
+ try:
270
+ self._host(form, field_id).wait_for(timeout=FOLLOW_UP_TIMEOUT_MS)
271
+ except browser.Timeout:
272
+ return False
273
+ return True
274
+
194
275
  def _host(self, form: Locator, field_id: str) -> Locator:
195
276
  """The element holding a field's inputs: its question, else its input's container."""
196
277
  if field_id.startswith("question:"):
197
278
  qid = field_id.partition(":")[2]
198
- return form.locator(".question").filter(
279
+ return form.locator(QUESTION).filter(
199
280
  has=form.page.locator(f"input[name$='[question_id]'][value='{qid}']")
200
281
  )
282
+ if field_id.endswith("[]"):
283
+ return form.locator("fieldset").filter(has=form.page.locator(f"[name='{field_id}']"))
284
+ if field_id == LOCATION:
285
+ return form.locator("[data-controller~='forms--inputs--location']")
201
286
  if field_id.endswith("_remote_url]"):
202
287
  dom_id = "candidate_" + field_id[len("candidate[") : -1]
203
288
  return form.locator("[data-controller~='forms--inputs--upload']").filter(
@@ -213,7 +298,7 @@ class Teamtailor:
213
298
  }
214
299
  deadline = time.monotonic() + UPLOAD_TIMEOUT_S
215
300
  while files and time.monotonic() < deadline:
216
- held = form.evaluate(_READ_BACK)
301
+ held = form.evaluate(_READ_BACK, QUESTION)
217
302
  if all(held.get(i) == name for i, name in files.items()):
218
303
  return
219
304
  form.page.wait_for_timeout(500)
@@ -255,6 +340,21 @@ def _click(page: Page, target: Locator) -> None:
255
340
  browser.click(target)
256
341
 
257
342
 
343
+ def _suggest(page: Page, field: Field, host: Locator, answer: str) -> None:
344
+ """Types the address key by key, which loads the suggestions, and picks the first that
345
+ matches it: the form keeps an address only once a suggestion is picked."""
346
+ box = host.locator("input[role=combobox]")
347
+ box.fill("")
348
+ box.press_sequentially(answer, delay=30)
349
+ options = host.locator("[role=option]")
350
+ options.first.wait_for()
351
+ for i in range(options.count()):
352
+ if matching.matches(field, answer, options.nth(i).get_attribute("data-description") or ""):
353
+ _click(page, options.nth(i))
354
+ return
355
+ raise Unsupported(f"form_shape: no suggestion for {answer!r} in {field.id}")
356
+
357
+
258
358
  def _pick(page: Page, question: Locator, value: str) -> None:
259
359
  """Checks the option whose text is `value`, a radio or checkbox label or a dropdown item."""
260
360
  label = question.locator("label:visible").filter(
@@ -27,6 +27,7 @@ kind = "ats"
27
27
  account = "none"
28
28
  hosting = ["hosted"]
29
29
  adapter = "teamtailor"
30
+ dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "91a0bba" }
30
31
 
31
32
  [greenhouse]
32
33
  kind = "ats"
File without changes