atsfill 0.0.5.dev182__tar.gz → 0.0.5.dev186__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 (65) hide show
  1. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/adpmyjobs.py +5 -5
  5. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/jobstreet.py +35 -17
  6. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/phenom.py +29 -2
  7. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/workatastartup.py +20 -9
  8. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/commands.py +1 -0
  9. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/matching.py +12 -0
  10. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/results.py +2 -0
  11. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/LICENSE +0 -0
  12. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/README.md +0 -0
  13. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/__init__.py +0 -0
  14. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/__init__.py +0 -0
  15. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/jobaffinity.py +0 -0
  28. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/join.py +0 -0
  29. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/lever.py +0 -0
  30. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/manatal.py +0 -0
  31. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/oracle.py +0 -0
  32. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/paycor.py +0 -0
  33. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/paylocity.py +0 -0
  34. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/pinpoint.py +0 -0
  36. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/recruitee.py +0 -0
  37. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/recruiterflow.py +0 -0
  38. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/rippling.py +0 -0
  39. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/smartrecruiters.py +0 -0
  40. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/successfactors.js +0 -0
  41. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/successfactors.py +0 -0
  42. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/taleo.py +0 -0
  43. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/teamtailor.py +0 -0
  44. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/ukg.py +0 -0
  45. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/welcomekit.py +0 -0
  46. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/workable.py +0 -0
  47. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/workday.py +0 -0
  48. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/wttj.py +0 -0
  49. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/zohorecruit.py +0 -0
  50. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/cli.py +0 -0
  51. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/__init__.py +0 -0
  52. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/accounts.py +0 -0
  53. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/adapter.py +0 -0
  54. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/attempts.py +0 -0
  55. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/browser.py +0 -0
  56. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/conditions.py +0 -0
  57. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/html.py +0 -0
  58. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/otp.py +0 -0
  59. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/passwords.py +0 -0
  60. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/paths.py +0 -0
  61. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/registry.py +0 -0
  62. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/snapshot.py +0 -0
  63. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/telemetry.py +0 -0
  64. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/py.typed +0 -0
  65. {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.5.dev182
3
+ Version: 0.0.5.dev186
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev182"
3
+ version = "0.0.5.dev186"
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.5.dev182"
3
+ version = "0.0.5.dev186"
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"
@@ -408,15 +408,15 @@ class ADPMyJobs:
408
408
  every = len(chosen) == len(labels)
409
409
  incoming.setdefault(target, []).append(Is(ids[qid], None if every else chosen))
410
410
  label = _text(raw.get("questionDescription") or qid)
411
+ knockouts = [labels[a["answerId"]] for a in answers if a.get("knockoutRule")] or None
411
412
  match raw["questionType"]:
412
413
  case "Instructions":
413
414
  continue
414
- case "Single choice":
415
+ case "Single choice" | "Multiple choice" as kind:
415
416
  self._codes[ids[qid]] = labels
416
- f = Field(ids[qid], label, FieldType.CHOICE, True, list(labels.values()))
417
- case "Multiple choice":
418
- self._codes[ids[qid]] = labels
419
- f = Field(ids[qid], label, FieldType.MULTI_CHOICE, True, list(labels.values()))
417
+ type_ = FieldType.CHOICE if kind == "Single choice" else FieldType.MULTI_CHOICE
418
+ options = list(labels.values())
419
+ f = Field(ids[qid], label, type_, True, options, knockouts=knockouts)
420
420
  case "Free form":
421
421
  f = Field(ids[qid], label, FieldType.TEXT, True)
422
422
  case "Calendar":
@@ -34,6 +34,8 @@ APPLY = "[data-automation=job-detail-apply]"
34
34
  CONTINUE = "[data-testid=continue-button]"
35
35
  SUBMIT = "[data-testid=review-submit-application]"
36
36
  SAVE_DETAILS = "[data-automation=personal-details-save-button]"
37
+ RESUME_FILE = "input[type=file][id^=resume]"
38
+ LETTER_FILE = "input[type=file][id^=coverLetter]"
37
39
  CHALLENGE = "Just a moment..." # Cloudflare's interstitial: it passes on its own, or on a reload
38
40
  CHALLENGE_TRIES = 2
39
41
  SIGN_IN_TIMEOUT_MS = 90_000 # SEEK's sign-in takes up to ~50 s to show its form
@@ -53,6 +55,7 @@ SUGGEST_TIMEOUT_S = 8
53
55
  POLL_MS = 250
54
56
  KEY_DELAY_MS = 40
55
57
  DETAILS = ("firstName", "lastName", "currentLocation", "phone")
58
+ DOCUMENTS = ("resume", "coverLetter", "coverLetterFile")
56
59
  VISIBILITY = ["Public", "Standard", "Limited", "Hidden"]
57
60
 
58
61
  _STEP_ONE = """() => {
@@ -62,7 +65,7 @@ _STEP_ONE = """() => {
62
65
  .find(e => e.checked);
63
66
  const label = e => e && document.querySelector(`label[for="${e.id}"]`)?.innerText.trim();
64
67
  const resume = picked('document-select');
65
- const letter = document.querySelector('[data-testid=coverLetter-method-change]');
68
+ const method = picked('coverLetter-method');
66
69
  const phone = value('#phoneNumber');
67
70
  return {
68
71
  firstName: value('#firstName'), lastName: value('#lastName'),
@@ -70,7 +73,8 @@ _STEP_ONE = """() => {
70
73
  phone: phone && code?.value
71
74
  ? `${code.options[code.selectedIndex].text.match(/\\+\\d+/)?.[0] ?? ''}${phone}` : null,
72
75
  resume: resume && resume.value !== 'dont-include' ? label(resume) : null,
73
- coverLetter: letter?.checked ? value('[data-testid=coverLetterTextInput]') : null,
76
+ coverLetter: method?.value === 'change' ? value('[data-testid=coverLetterTextInput]') : null,
77
+ coverLetterFile: method?.value === 'upload' ? label(method) : null,
74
78
  };
75
79
  }"""
76
80
 
@@ -198,7 +202,7 @@ class Jobstreet:
198
202
  self._read |= _personal_details(page, self._fields, answers)
199
203
  _documents_fill(page, answers)
200
204
  documents = page.evaluate(_STEP_ONE)
201
- self._read |= {k: documents[k] for k in ("resume", "coverLetter") if documents[k]}
205
+ self._read |= {k: documents[k] for k in DOCUMENTS if documents[k]}
202
206
  _continue(page)
203
207
  if page.url.split("?")[0].endswith("/role-requirements"):
204
208
  for field_id, answer in answers.items():
@@ -316,14 +320,12 @@ def _operations(response: Response) -> dict[str, Any]:
316
320
 
317
321
  def _documents(page: Page, viewer: dict[str, Any]) -> list[Field]:
318
322
  """The first step: the profile's personal details, then the resume and the cover letter,
319
- optional where the page offers to leave them out."""
323
+ optional where the page offers to leave them out. The cover letter is written or uploaded,
324
+ two alternatives."""
320
325
  details = viewer["personalDetails"]
321
326
  code = (details.get("countryCallingCode") or {}).get("countryCallingCode", "")
322
327
  location = (viewer.get("currentLocation2") or {}).get("description")
323
- resume = page.locator("input[type=file][id^=resume]")
324
- accept: list[str] | None = None
325
- if resume.count():
326
- accept = [str(a) for a in (resume.get_attribute("accept") or "").split(",")]
328
+ letter = not page.locator("[data-testid=coverLetter-method-none]").count()
327
329
  return [
328
330
  Field("firstName", "First name", FieldType.TEXT, True,
329
331
  prefilled=details.get("firstName") or None),
@@ -333,12 +335,21 @@ def _documents(page: Page, viewer: dict[str, Any]) -> list[Field]:
333
335
  Field("phone", "Phone number", FieldType.PHONE, False,
334
336
  prefilled=f"{code}{details['phoneNumber']}" if details.get("phoneNumber") else None),
335
337
  Field("resume", "Resumé", FieldType.FILE,
336
- not page.locator("input[type=radio][value=dont-include]").count(), accept=accept),
337
- Field("coverLetter", "Cover letter", FieldType.TEXT,
338
- not page.locator("[data-testid=coverLetter-method-none]").count()),
338
+ not page.locator("input[type=radio][value=dont-include]").count(),
339
+ accept=_accept(page, RESUME_FILE)),
340
+ Field("coverLetter", "Cover letter", FieldType.TEXT, letter, one_of="coverLetter"),
341
+ Field("coverLetterFile", "Cover letter", FieldType.FILE, letter,
342
+ accept=_accept(page, LETTER_FILE), one_of="coverLetter"),
339
343
  ] # fmt: skip
340
344
 
341
345
 
346
+ def _accept(page: Page, selector: str) -> list[str] | None:
347
+ upload = page.locator(selector)
348
+ if not upload.count():
349
+ return None
350
+ return [str(a) for a in (upload.get_attribute("accept") or "").split(",")]
351
+
352
+
342
353
  def _question(raw: dict[str, Any]) -> Field | None:
343
354
  field_id, label = f"question.{raw['id']}", raw["text"]
344
355
  options = [o["text"] for o in raw.get("options") or []]
@@ -404,20 +415,27 @@ def _phone(page: Page, answer: str) -> None:
404
415
 
405
416
  def _documents_fill(page: Page, answers: dict[str, Answer]) -> None:
406
417
  """Uploads the resume, which the page then picks, or leaves it out; the same for the cover
407
- letter, written in its box."""
418
+ letter, uploaded or written in its box."""
408
419
  if (resume := answers.get("resume")) is not None:
409
- page.locator("input[type=file][id^=resume]").set_input_files(str(resume))
410
- name = str(resume).rsplit("/", 1)[-1]
411
- page.locator("label", has_text=name).first.wait_for(timeout=SAVE_TIMEOUT_MS)
420
+ _upload(page, RESUME_FILE, str(resume))
412
421
  elif (none := page.locator("input[type=radio][value=dont-include]")).count():
413
422
  none.check(force=True)
414
- if (letter := answers.get("coverLetter")) is not None:
415
- page.locator("[data-testid=coverLetter-method-change]").check(force=True)
423
+ if (letter := answers.get("coverLetterFile")) is not None:
424
+ _upload(page, LETTER_FILE, str(letter))
425
+ elif (letter := answers.get("coverLetter")) is not None:
426
+ page.locator("input[type=radio][data-testid=coverLetter-method-change]").check(force=True)
416
427
  page.locator("[data-testid=coverLetterTextInput]").fill(str(letter))
417
428
  elif (none := page.locator("[data-testid=coverLetter-method-none]")).count():
418
429
  none.check(force=True)
419
430
 
420
431
 
432
+ def _upload(page: Page, selector: str, path: str) -> None:
433
+ """Sets the file, then waits for the radio the page adds and picks, labelled with its name."""
434
+ page.locator(selector).set_input_files(path)
435
+ name = path.rsplit("/", 1)[-1]
436
+ page.locator("label", has_text=name).first.wait_for(timeout=SAVE_TIMEOUT_MS)
437
+
438
+
421
439
  def _answer(page: Page, field: Field, answer: Answer) -> None:
422
440
  name = f"questionnaire.{field.id.removeprefix('question.')}"
423
441
  select = page.locator(f'select[name="{name}"]')
@@ -44,6 +44,7 @@ SEQ = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/job/([A-Za-z0-9]+)")
44
44
  CDN = "https://cdn-prod-static.phenompeople.com/CareerConnectResources"
45
45
  FORM = "form.rjsf"
46
46
  NEXT = "button#next"
47
+ TERMS = "terms" # a notice some sites' wizard opens on, its one button accepting it
47
48
  SUBMIT = "button#submit, button.btn-submit, button[type=submit].btn-navigate:not(#next)"
48
49
  RESUME = ".resume-upload-wrapper input[type=file]"
49
50
  RESUME_ID = "resume"
@@ -264,6 +265,11 @@ class Phenom:
264
265
  SideEffect(
265
266
  "upload", "hiring_system", "the resume is uploaded to Phenom and parsed at once"
266
267
  ),
268
+ SideEffect(
269
+ "fill",
270
+ "hiring_system",
271
+ "an employer notice the wizard opens on (`page=terms`) is accepted to reach the form",
272
+ ),
267
273
  SideEffect(
268
274
  "fill",
269
275
  "hiring_system",
@@ -355,6 +361,7 @@ class Phenom:
355
361
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
356
362
  page.context.clear_cookies()
357
363
  self._open(page, fresh=True)
364
+ self._accept_terms(page)
358
365
  self._kept, self._shown = {}, {}
359
366
  if RESUME_ID in answers and page.locator(RESUME).count():
360
367
  _upload(page, _one(answers[RESUME_ID]))
@@ -414,17 +421,31 @@ class Phenom:
414
421
  if fresh or not page.url.startswith(self._apply):
415
422
  page.goto(self._apply, wait_until="domcontentloaded")
416
423
  _check_open(page)
424
+ self._wait_for_step(page)
425
+
426
+ def _wait_for_step(self, page: Page) -> None:
417
427
  try:
418
428
  page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]").first.wait_for(
419
429
  state="attached", timeout=FORM_TIMEOUT_MS
420
430
  )
421
431
  except Timeout:
422
- query = parse_qs(urlsplit(page.url).query)
423
- if step := (query.get("stepname") or query.get("page") or [None])[0]:
432
+ if step := _step_name(page.url):
424
433
  gap = known_gap(self, "first_step", f"the wizard opens on {step!r}")
425
434
  raise Unsupported(gap) from None
426
435
  raise RuntimeError("no application step on the page") from None
427
436
 
437
+ def _accept_terms(self, page: Page) -> None:
438
+ """Accepts the terms some sites' wizard opens on, a step with no field before the form."""
439
+ if _step_name(page.url) != TERMS:
440
+ return
441
+ _click(page, page.locator(FORM).locator(SUBMIT).first)
442
+ try:
443
+ page.wait_for_url(lambda url: _step_name(url) != TERMS, timeout=STEP_TIMEOUT_MS)
444
+ except Timeout:
445
+ gap = known_gap(self, "first_step", "the wizard stays on its terms once accepted")
446
+ raise Unsupported(gap) from None
447
+ self._wait_for_step(page)
448
+
428
449
  def _step(self, page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
429
450
  """The answered fields the current step shows, in the configuration's order."""
430
451
  shown = {r["id"]: r for r in page.locator(FORM).evaluate(_SCAN)}
@@ -1237,6 +1258,12 @@ def _seq(url: str) -> str | None:
1237
1258
  return parse_qs(parts.query).get("jobSeqNo", [None])[0]
1238
1259
 
1239
1260
 
1261
+ def _step_name(url: str) -> str | None:
1262
+ """The wizard's step as its URL names it (`stepname=`, `page=`), if it does."""
1263
+ query = parse_qs(urlsplit(url).query)
1264
+ return (query.get("stepname") or query.get("page") or [None])[0]
1265
+
1266
+
1240
1267
  def _apply_url(page: Page) -> str:
1241
1268
  """The wizard's URL: the page's, or where the job page's Apply link leads; the wizard's
1242
1269
  own when no link shows, which `_open` then finds or not."""
@@ -390,6 +390,8 @@ class Control:
390
390
  return form.locator("input[type=tel]")
391
391
  case "country" | "combo":
392
392
  return form.locator(f'input[aria-label="{self.where}"]')
393
+ case "companies":
394
+ return form.locator('input[aria-label="Company name"]')
393
395
  case "multi" | "skills":
394
396
  return form.get_by_label(self.where, exact=False)
395
397
  case "month": # the selects' names carry the entry's id: <id>_start_date_month
@@ -429,15 +431,7 @@ STEPS: dict[str, list[Control]] = {
429
431
  Control("phone_number", "Phone Number", "phone", FieldType.PHONE, False),
430
432
  Control("share", "What is your job search status?", "radio", C, True),
431
433
  Control("yc_affiliation", "Have you worked at a YC company?", "radio", C, True),
432
- Control(
433
- "hidden_companies",
434
- "YC companies to be hidden from",
435
- "combo",
436
- T,
437
- False,
438
- "Company name",
439
- unfillable="unhandled_rendering: a list of YC companies to be hidden from",
440
- ),
434
+ Control("hidden_companies", "YC companies to be hidden from", "companies", M, False),
441
435
  ],
442
436
  "location": [
443
437
  Control("city_current", "What city do you live in?", "place", TA, True),
@@ -625,6 +619,8 @@ def _value(form: Locator, c: Control) -> Answer | None:
625
619
  return target.evaluate(_SINGLE_VALUE) or None
626
620
  case "multi":
627
621
  return target.evaluate(_CHIPS)
622
+ case "companies":
623
+ return target.evaluate(_COMPANIES)
628
624
  case "skills":
629
625
  return form.evaluate(_SKILLS)
630
626
  return target.input_value()
@@ -640,6 +636,8 @@ _CHIPS = """e => {
640
636
  const box = e.closest("[id$=__select]") ?? e.parentElement.parentElement.parentElement;
641
637
  return [...box.querySelectorAll("[aria-label^='Remove ']")].map(c => c.ariaLabel.slice(7));
642
638
  }"""
639
+ _COMPANIES = """e => [...e.parentElement.previousElementSibling.querySelectorAll(
640
+ "span[role=button] + span")].map(s => s.textContent.trim())"""
643
641
  _SKILLS = """form => {
644
642
  const groups = {};
645
643
  for (const r of form.querySelectorAll("input[type=radio][name^=top_skills_]")) {
@@ -684,6 +682,8 @@ def _profile_fill(page: Page, form: Locator, c: Control, answer: Answer) -> None
684
682
  form.locator(f'[aria-label="Remove {chip}"]').click()
685
683
  for item in [a for a in answer if a not in have]:
686
684
  _pick(page, c, target, item, strict=True, exact=True)
685
+ case "companies", list():
686
+ _companies(page, target, c, answer)
687
687
  case "skills", list():
688
688
  _skills(page, form, target, c, answer)
689
689
  case kind, _:
@@ -715,6 +715,17 @@ def _pick(
715
715
  box.press("Escape")
716
716
 
717
717
 
718
+ def _companies(page: Page, box: Locator, c: Control, answer: list[str]) -> None:
719
+ """Removes the companies the answer leaves out, then searches each other one and picks the
720
+ result named exactly as it: a nearest name would hide the profile from another company."""
721
+ chips = box.locator("xpath=../preceding-sibling::div[1]//div[span[@role='button']]")
722
+ while extra := [i for i, h in enumerate(box.evaluate(_COMPANIES)) if h not in answer]:
723
+ chips.nth(extra[0]).locator("span[role=button]").click()
724
+ have = box.evaluate(_COMPANIES)
725
+ for company in [a for a in answer if a not in have]:
726
+ _pick(page, c, box, company, strict=True, exact=True)
727
+
728
+
718
729
  def _skills(page: Page, form: Locator, box: Locator, c: Control, answer: list[str]) -> None:
719
730
  """Removes the skills the answer leaves out, adds the others, then picks each level."""
720
731
  wanted: dict[str, str] = {}
@@ -424,6 +424,7 @@ def _fill(
424
424
  if problems := matching.problems(result.fields, answers):
425
425
  result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
426
426
  return result
427
+ result.warnings = matching.knockouts(result.fields, answers)
427
428
  adapter.fill(page, answers)
428
429
  _shown_as_listed(page, adapter, result, answers)
429
430
  if result.status is not Status.READY:
@@ -159,3 +159,15 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
159
159
  and (problem := _answer_problem(by_id[i], a, form))
160
160
  ]
161
161
  return out
162
+
163
+
164
+ def knockouts(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
165
+ """The answers that end the application: never refused, as one can be the truthful one."""
166
+ by_id = {f.id: f for f in fields}
167
+ return [
168
+ f"{i}: {', '.join(map(repr, chosen))} ends the application ({f.label})"
169
+ for i, a in answers.items()
170
+ if (f := by_id.get(i))
171
+ and f.knockouts
172
+ and (chosen := [v for v in ([a] if isinstance(a, str) else a) if v in f.knockouts])
173
+ ]
@@ -75,6 +75,7 @@ class Field:
75
75
  one_of: str | None = None # an alternative: one of the fields with this key answers (ADR 0005)
76
76
  option_sets: list[OptionSet] | None = None # a dependent list's; `options` where none holds
77
77
  min_length: int | None = None # a text the system refuses shorter
78
+ knockouts: list[str] | None = None # the options whose choice ends the application (ADR 0005)
78
79
 
79
80
 
80
81
  @dataclass(frozen=True)
@@ -106,6 +107,7 @@ class Result:
106
107
  unloaded: list[str] = field(default_factory=list) # steps a profile read could not load yet
107
108
  notice: str | None = None # telemetry's, the first time it is on (ADR 0016)
108
109
  report: str | None = None # how to send a failed run's report, when it was not sent
110
+ warnings: list[str] = field(default_factory=list) # the knockout answers a fill gave
109
111
 
110
112
 
111
113
  @dataclass
File without changes
File without changes