atsfill 0.0.4.dev49__tar.gz → 0.0.4.dev51__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.dev49 → atsfill-0.0.4.dev51}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/ashby.py +11 -6
  5. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/bamboohr.py +25 -9
  6. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/breezy.py +9 -9
  7. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/dayforce.py +31 -25
  8. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/greenhouse.py +6 -2
  9. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/hireology.py +17 -10
  10. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/jazzhr.py +6 -2
  11. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/lever.py +4 -2
  12. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/manatal.py +19 -11
  13. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/oracle.py +9 -9
  14. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/paycor.py +20 -22
  15. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/paylocity.py +46 -15
  16. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/phenom.py +23 -16
  17. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/recruitee.py +7 -8
  18. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/smartrecruiters.py +40 -15
  19. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/teamtailor.py +24 -29
  20. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/workable.py +52 -46
  21. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/wttj.py +17 -17
  22. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/adapter.py +27 -2
  23. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/commands.py +15 -0
  24. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/matching.py +7 -2
  25. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/registry.py +25 -9
  26. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/results.py +1 -0
  27. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/registry.toml +3 -1
  28. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/LICENSE +0 -0
  29. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/README.md +0 -0
  30. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/__init__.py +0 -0
  31. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/adapters/__init__.py +0 -0
  32. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/cli.py +0 -0
  33. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/__init__.py +0 -0
  34. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/accounts.py +0 -0
  35. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/attempts.py +0 -0
  36. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/browser.py +0 -0
  37. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/src/atsfill/core/paths.py +0 -0
  38. {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev51}/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.dev49
3
+ Version: 0.0.4.dev51
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.4.dev49"
3
+ version = "0.0.4.dev51"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev49"
3
+ version = "0.0.4.dev51"
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"
@@ -12,7 +12,7 @@ from functools import partial
12
12
  from typing import Any
13
13
  from urllib.parse import parse_qs, urlsplit
14
14
 
15
- from atsfill.core.adapter import Blocked, Unsupported
15
+ from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
16
16
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Response, Timeout
17
17
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
18
 
@@ -67,6 +67,7 @@ def _op(response: Response) -> str:
67
67
 
68
68
  class Ashby:
69
69
  name = "ashby"
70
+ known_gaps = {"education_history": Gap.RENDERING}
70
71
  side_effects = [
71
72
  SideEffect(
72
73
  "fill", "hiring_system", "each answer and the resume are saved to Ashby as typed"
@@ -166,7 +167,7 @@ class Ashby:
166
167
  self._saved(page, partial(upload.set_input_files, answer))
167
168
  case kind, _:
168
169
  raise Unsupported(
169
- f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
170
+ f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
170
171
  )
171
172
  self._answered = list(answers)
172
173
 
@@ -227,16 +228,20 @@ class Ashby:
227
228
  if not e.get("isHidden")
228
229
  ]
229
230
 
230
- @staticmethod
231
- def _field(entry: dict[str, Any]) -> Field:
231
+ def _field(self, entry: dict[str, Any]) -> Field:
232
232
  f = entry["field"]
233
+ path, title, required = f["path"], f["title"], bool(entry["isRequired"])
234
+ if f["type"] == "EducationHistory":
235
+ gap = known_gap(self, "education_history", f"{path} is a list of schools")
236
+ return Field(path, title, FieldType.TEXT, required, unfillable=gap)
233
237
  kind = TYPES.get(f["type"])
234
238
  if kind is None:
235
- raise Unsupported(f"unsupported_rendering: {f['path']} is an Ashby {f['type']} field")
239
+ reason = f"unhandled_rendering: {path} is an Ashby {f['type']} field"
240
+ return Field(path, title, FieldType.TEXT, required, unfillable=reason)
236
241
  options = [v["label"] for v in f.get("selectableValues") or []] or None
237
242
  if f["type"] == "Boolean":
238
243
  options = ["Yes", "No"]
239
- return Field(f["path"], f["title"], kind, bool(entry["isRequired"]), options)
244
+ return Field(path, title, kind, required, options)
240
245
 
241
246
  def _values(self) -> dict[str, Answer | None]:
242
247
  return {e["field"]["path"]: self._value(e) for e in self._entries()}
@@ -10,11 +10,12 @@ import json
10
10
  import re
11
11
  import urllib.request
12
12
  from contextlib import suppress
13
+ from dataclasses import replace
13
14
  from typing import Any
14
15
  from urllib.parse import urlsplit
15
16
 
16
17
  from atsfill.core import browser
17
- from atsfill.core.adapter import Blocked, Unsupported
18
+ from atsfill.core.adapter import Blocked, Gap, Unsupported
18
19
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
19
20
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
20
21
 
@@ -140,6 +141,7 @@ _READ_BACK = """(form, specs) => {
140
141
 
141
142
  class BambooHR:
142
143
  name = "bamboohr"
144
+ known_gaps: dict[str, Gap] = {}
143
145
  side_effects = [
144
146
  SideEffect("upload", "hiring_system", "an attached file is stored by BambooHR at once"),
145
147
  SideEffect("submit", "employer", "the application reaches the employer"),
@@ -186,10 +188,11 @@ class BambooHR:
186
188
  country = form.locator("select[name='countryId.value']")
187
189
  default = (_shown(country) or "") if country.count() else None
188
190
  fields = _form(detail["json"], states, default)
189
- self._specs = {f.id: spec for f, spec in fields}
191
+ out = []
190
192
  for field, (kind, name, _) in fields:
191
- if field.id.endswith("_other"):
192
- continue # shown once its question is answered Other
193
+ if field.unfillable or field.id.endswith("_other"):
194
+ out.append(field) # an `_other` shows once its question is answered Other
195
+ continue
193
196
  present = (
194
197
  form.locator("input[placeholder*=yyyy]")
195
198
  if kind == "date"
@@ -198,8 +201,14 @@ class BambooHR:
198
201
  else form.locator(f"[name='{name}']")
199
202
  )
200
203
  if not present.count():
201
- raise Unsupported(f"unsupported_flow: {field.id} is not on the page")
202
- return [f for f, _ in fields]
204
+ field = replace(
205
+ field, unfillable=f"unhandled_rendering: {field.id} is not on the page"
206
+ )
207
+ out.append(field)
208
+ self._specs = {
209
+ f.id: spec for f, (_, spec) in zip(out, fields, strict=True) if not f.unfillable
210
+ }
211
+ return out
203
212
 
204
213
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
205
214
  form = page.locator(FORM)
@@ -388,7 +397,12 @@ def _form(
388
397
  if not entry: # a demographic question the employer does not ask
389
398
  continue
390
399
  if key not in STANDARD:
391
- raise Unsupported(f"unsupported_rendering: {key} is a field it does not know")
400
+ reason = f"unhandled_rendering: {key} is a field it does not know"
401
+ meta = entry if isinstance(entry, dict) else {}
402
+ label, required = str(meta.get("label") or key).strip(), bool(meta.get("isRequired"))
403
+ field = Field(key, label, FieldType.TEXT, required, unfillable=reason)
404
+ out.append((field, ("text", None, None)))
405
+ continue
392
406
  kind, name = STANDARD[key]
393
407
  label, required = entry["label"].strip(), entry["isRequired"]
394
408
  options = [o["text"] for o in entry.get("options") or []] or None
@@ -417,11 +431,13 @@ def _states(states: dict[str, Any], default: str | None) -> list[str]:
417
431
 
418
432
  def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
419
433
  qid, kind_api = question["id"], question["type"]
434
+ label, required = question["question"].strip(), question["isRequired"]
420
435
  if kind_api not in CUSTOM:
421
- raise Unsupported(f"unsupported_rendering: question {qid} is a {kind_api}")
436
+ reason = f"unhandled_rendering: question {qid} is a {kind_api}"
437
+ field = Field(f"question_{qid}", label, FieldType.TEXT, required, unfillable=reason)
438
+ return [(field, ("text", None, None))]
422
439
  kind = CUSTOM[kind_api]
423
440
  name = f"customQuestionAnswers.{kind_api}_{qid}"
424
- label, required = question["question"].strip(), question["isRequired"]
425
441
  options, extra = None, None
426
442
  if kind_api == "yes_no" or kind == "checkbox":
427
443
  options = YES_NO
@@ -13,7 +13,7 @@ from typing import Any
13
13
  from urllib.parse import urlsplit
14
14
 
15
15
  from atsfill.core import browser
16
- from atsfill.core.adapter import Unsupported
16
+ from atsfill.core.adapter import Gap, Unsupported, out_of_scope
17
17
  from atsfill.core.browser import Locator, Page, Timeout
18
18
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
19
 
@@ -95,7 +95,7 @@ ENTRIES = {
95
95
  }
96
96
 
97
97
  # The fields in page order, one per control name, with its heading's text and how it renders;
98
- # a question the adapter cannot fill is kept as its kind, to be refused when required.
98
+ # a live video question is kept as the kind `video`.
99
99
  _READ = """form => {
100
100
  const text = el => {
101
101
  if (!el) return "";
@@ -210,6 +210,7 @@ _FILE_SHOWN = """e => {
210
210
 
211
211
  class Breezy:
212
212
  name = "breezy"
213
+ known_gaps: dict[str, Gap] = {}
213
214
  side_effects = [
214
215
  SideEffect("upload", "hiring_system", "an attached file is uploaded to Breezy at once"),
215
216
  SideEffect("submit", "employer", "the application reaches the employer"),
@@ -242,8 +243,9 @@ class Breezy:
242
243
  raise Unsupported("posting_gone: no application form on the page") from None
243
244
  fields, self._kinds = [], {}
244
245
  for raw in form.evaluate(_READ):
245
- if (field := _field(raw)) is not None:
246
- fields.append(field)
246
+ field = _field(raw)
247
+ fields.append(field)
248
+ if not field.unfillable:
247
249
  self._kinds[field.id] = raw["kind"]
248
250
  return fields + self._entries(form)
249
251
 
@@ -341,13 +343,11 @@ class Breezy:
341
343
  return f"{response.url} {response.status}"
342
344
 
343
345
 
344
- def _field(raw: dict[str, Any]) -> Field | None:
345
- """The field, or None for one the page asks optionally and atsfill cannot fill."""
346
+ def _field(raw: dict[str, Any]) -> Field:
346
347
  kind = raw["kind"]
347
348
  if kind == "video":
348
- if raw["required"]:
349
- raise Unsupported(f"unsupported_rendering: {raw['label']!r} asks for a recorded video")
350
- return None
349
+ reason = out_of_scope(raw["id"], "a video recorded live")
350
+ return Field(raw["id"], raw["label"], FieldType.FILE, raw["required"], unfillable=reason)
351
351
  label = LABELS.get(raw["id"]) or raw.get("label", "")
352
352
  if "part" in raw:
353
353
  label = (
@@ -16,7 +16,7 @@ from contextlib import suppress
16
16
  from typing import Any
17
17
  from urllib.parse import urlsplit
18
18
 
19
- from atsfill.core.adapter import Unsupported
19
+ from atsfill.core.adapter import Gap, Unsupported, known_gap
20
20
  from atsfill.core.browser import Locator, Page, Timeout
21
21
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
22
22
 
@@ -167,6 +167,7 @@ _CLICK = """([selector, n, text]) => {
167
167
 
168
168
  class Dayforce:
169
169
  name = "dayforce"
170
+ known_gaps = {"references": Gap.RENDERING}
170
171
  side_effects = [
171
172
  SideEffect(
172
173
  "load",
@@ -212,8 +213,8 @@ class Dayforce:
212
213
  accepted = [f".{a.strip()}" for a in accept.split(",") if a.strip()] or None
213
214
  fields[i] = _with(field, accept=accepted)
214
215
  for prefix, _ in ENTRIES.values(): # an entry's controls show once it is added
215
- texts = [i for i, f in enumerate(fields)
216
- if f.id.startswith(f"{prefix}.") and f.type is FieldType.TEXT] # fmt: skip
216
+ texts = [i for i, f in enumerate(fields) if f.id.startswith(f"{prefix}.")
217
+ and f.type is FieldType.TEXT and not f.unfillable] # fmt: skip
217
218
  if texts and page.locator(f"[test-id=add-{prefix.lower()}-record]").count():
218
219
  base = _open_entry(page, prefix)
219
220
  for i in texts:
@@ -280,8 +281,9 @@ class Dayforce:
280
281
  for f in _visible(personal):
281
282
  key, kind = PERSONAL_FIELDS.get(f["fieldName"], (None, None))
282
283
  if key is None or kind is None:
283
- if f["isRequired"]:
284
- raise Unsupported(f"unsupported_rendering: personal field {f['fieldName']!r}")
284
+ reason = f"unhandled_rendering: personal field {f['fieldName']!r}"
285
+ fields.append(Field(f["fieldName"], f["displayName"], FieldType.TEXT,
286
+ bool(f["isRequired"]), unfillable=reason)) # fmt: skip
285
287
  continue
286
288
  options = {
287
289
  "candidateSource": sources,
@@ -316,14 +318,17 @@ class Dayforce:
316
318
  ]
317
319
  for section in sections:
318
320
  ref = section.get("xRefCode")
319
- if (
320
- not section.get("questionnaire")
321
- and ref not in CANDIDATE_INFO
322
- and section.get("isRequired")
323
- ):
324
- raise Unsupported(
325
- f"unsupported_rendering: {section['displayName']!r} section is required"
326
- )
321
+ if section.get("questionnaire") or ref in CANDIDATE_INFO:
322
+ continue
323
+ name = section["displayName"]
324
+ if ref == "REFERENCES":
325
+ field_id = "references"
326
+ reason = known_gap(self, "references", f"{name!r} needs references added")
327
+ else:
328
+ field_id = f"section.{ref}"
329
+ reason = f"unhandled_rendering: {name!r} section {ref}"
330
+ fields.append(Field(field_id, name, FieldType.TEXT, bool(section.get("isRequired")),
331
+ unfillable=reason)) # fmt: skip
327
332
  self._sections = [
328
333
  s for s in sections if s.get("questionnaire") and s.get("xRefCode") != "SUBMIT"
329
334
  ]
@@ -407,7 +412,9 @@ class Dayforce:
407
412
  def _fill_entry(self, page: Page, prefix: str, answers: dict[str, Answer]) -> None:
408
413
  """Adds one entry to a history section and fills it."""
409
414
  base = _open_entry(page, prefix)
410
- fields = [f for f in self._fields.values() if f.id.startswith(f"{prefix}.")]
415
+ fields = [
416
+ f for f in self._fields.values() if f.id.startswith(f"{prefix}.") and not f.unfillable
417
+ ]
411
418
  for field in fields:
412
419
  if field.id in answers:
413
420
  _fill_control(page, _id(base + field.id.split(".")[1]), field, answers[field.id])
@@ -432,7 +439,7 @@ class Dayforce:
432
439
  ) -> None:
433
440
  field_id = f"q{question['questionId']}"
434
441
  field = self._fields.get(field_id)
435
- if field is None:
442
+ if field is None or field.unfillable:
436
443
  return
437
444
  target = _question_target(page, base, question)
438
445
  if target is None:
@@ -549,13 +556,14 @@ def _entry_fields(
549
556
  fields = []
550
557
  for f in _visible(section):
551
558
  key, kind = keys.get(re.sub(r"\s", "", f["fieldName"]).upper(), (None, None))
559
+ required = bool(f["isRequired"] and section["isRequired"])
560
+ label = f"{section['displayName']}: {f['displayName']}"
552
561
  if key is None or kind is None:
553
- if f["isRequired"] and section["isRequired"]:
554
- raise Unsupported(f"unsupported_rendering: {prefix} field {f['fieldName']!r}")
562
+ reason = f"unhandled_rendering: {prefix} field {f['fieldName']!r}"
563
+ field_id = f"{prefix}.{f['fieldName']}"
564
+ fields.append(Field(field_id, label, FieldType.TEXT, required, unfillable=reason))
555
565
  continue
556
- required = bool(f["isRequired"] and section["isRequired"])
557
566
  options = NO_YES if kind == "boolean" else None
558
- label = f"{section['displayName']}: {f['displayName']}"
559
567
  fields.append(Field(f"{prefix}.{key}", label, ENTRY_KINDS[kind], required, options))
560
568
  return fields
561
569
 
@@ -594,13 +602,11 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
594
602
  label = _plain(question.get("description") or "")
595
603
  if not label or len(label) > LONG_LABEL:
596
604
  label = _plain(question["displayName"])
597
- if kind is None:
598
- if question["isRequired"]:
599
- raise Unsupported(
600
- f"unsupported_rendering: {label!r} has answer type {question['answerType']}"
601
- )
602
- return []
603
605
  field_id = f"q{question['questionId']}"
606
+ if kind is None:
607
+ reason = f"unhandled_rendering: {field_id} has answer type {question['answerType']}"
608
+ required = bool(question["isRequired"])
609
+ return [Field(field_id, label, FieldType.TEXT, required, unfillable=reason)]
604
610
  options = None
605
611
  if question["answerType"] == CHECKBOX:
606
612
  options = YES_NO
@@ -14,7 +14,7 @@ from typing import Any
14
14
  from urllib.parse import parse_qs, urlencode, urlsplit
15
15
 
16
16
  from atsfill.core import browser
17
- from atsfill.core.adapter import Unsupported
17
+ from atsfill.core.adapter import Gap, Unsupported
18
18
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
19
19
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
20
20
 
@@ -120,6 +120,7 @@ _READ_BACK = """(form, ids) => {
120
120
 
121
121
  class Greenhouse:
122
122
  name = "greenhouse"
123
+ known_gaps: dict[str, Gap] = {}
123
124
  side_effects = [
124
125
  SideEffect("upload", "hiring_system", "an attached file is stored by Greenhouse at once"),
125
126
  SideEffect("submit", "employer", "the application reaches the employer"),
@@ -242,7 +243,10 @@ class Greenhouse:
242
243
  else:
243
244
  field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
244
245
  case _: # a radio input, not seen on a posting yet
245
- raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
246
+ reason = f"unhandled_rendering: {raw['id']} is a {kind}"
247
+ return Field(
248
+ raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason
249
+ )
246
250
  return Field(
247
251
  raw["id"],
248
252
  raw["label"],
@@ -11,11 +11,12 @@ import json
11
11
  import re
12
12
  import urllib.request
13
13
  from contextlib import suppress
14
+ from dataclasses import replace
14
15
  from typing import Any
15
16
  from urllib.parse import urlsplit
16
17
 
17
18
  from atsfill.core import browser
18
- from atsfill.core.adapter import Unsupported
19
+ from atsfill.core.adapter import Gap, Unsupported
19
20
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
20
21
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
22
 
@@ -187,6 +188,7 @@ _READ_BACK = """(form, kinds) => {
187
188
 
188
189
  class Hireology:
189
190
  name = "hireology"
191
+ known_gaps: dict[str, Gap] = {}
190
192
  side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
191
193
 
192
194
  def __init__(self) -> None:
@@ -218,13 +220,15 @@ class Hireology:
218
220
  api = page.evaluate(_FETCH, API.format(job=job))
219
221
  if "status" in api:
220
222
  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"unsupported_flow: {field.id} is not on the page")
227
- return [f for f, _ in form]
223
+ form_el, out = page.locator(FORM), []
224
+ for field, kind in _form(api["json"]):
225
+ skip = field.unfillable or field.id in SHOWN_BY
226
+ if not skip and form_el.evaluate(_CONTROL, field.id) is None:
227
+ reason = f"unhandled_rendering: {field.id} is not on the page"
228
+ field = replace(field, unfillable=reason)
229
+ out.append((field, kind))
230
+ self._kinds = {field.id: kind for field, kind in out if not field.unfillable}
231
+ return [f for f, _ in out]
228
232
 
229
233
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
230
234
  form = page.locator(FORM)
@@ -303,14 +307,17 @@ def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
303
307
  def _field(entry: dict[str, Any], section: str) -> Field:
304
308
  field_id, attributes = entry["id"], entry["attributes"]
305
309
  kind = attributes["type"]
306
- if kind not in TYPES:
307
- raise Unsupported(f"unsupported_rendering: {field_id} is a {kind}")
308
310
  label = (
309
311
  section
310
312
  + (
311
313
  LABELS.get(field_id) or attributes.get("label") or entry.get("label") or field_id
312
314
  ).strip()
313
315
  )
316
+ if kind not in TYPES:
317
+ reason = f"unhandled_rendering: {field_id} is a {kind}"
318
+ return Field(
319
+ field_id, label, FieldType.TEXT, bool(attributes.get("required")), unfillable=reason
320
+ )
314
321
  required = attributes["required"] and kind != "checkbox" # a box left clear is an answer
315
322
  options = [o["name"] for o in attributes["options"]] or None
316
323
  if kind == "checkbox":
@@ -12,7 +12,7 @@ from typing import Any
12
12
  from urllib.parse import urlsplit
13
13
 
14
14
  from atsfill.core import browser
15
- from atsfill.core.adapter import Blocked, Unsupported
15
+ from atsfill.core.adapter import Blocked, Gap, Unsupported
16
16
  from atsfill.core.browser import Page, Timeout
17
17
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
18
 
@@ -122,6 +122,7 @@ _READ_BACK = """(form, fields) => {
122
122
 
123
123
  class JazzHR:
124
124
  name = "jazzhr"
125
+ known_gaps: dict[str, Gap] = {}
125
126
  side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
126
127
 
127
128
  def __init__(self) -> None:
@@ -238,7 +239,10 @@ def _field(raw: dict[str, Any]) -> Field:
238
239
  case "consent":
239
240
  field_type, options = FieldType.CHOICE, YES_NO
240
241
  case _:
241
- raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
242
+ reason = f"unhandled_rendering: {raw['id']} is a {kind}"
243
+ return Field(
244
+ raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason
245
+ )
242
246
  accept = RESUME_TYPES if kind == "file" else None
243
247
  return Field(
244
248
  raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
@@ -12,7 +12,7 @@ from typing import Any
12
12
  from urllib.parse import urlsplit
13
13
 
14
14
  from atsfill.core import browser
15
- from atsfill.core.adapter import Blocked, Unsupported
15
+ from atsfill.core.adapter import Blocked, Gap, Unsupported
16
16
  from atsfill.core.browser import Locator, Page, Timeout
17
17
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
18
 
@@ -117,6 +117,7 @@ _READ_BACK = """(form, kinds) => {
117
117
 
118
118
  class Lever:
119
119
  name = "lever"
120
+ known_gaps: dict[str, Gap] = {}
120
121
  side_effects = [
121
122
  SideEffect("upload", "hiring_system", "the resume is uploaded to Lever and parsed at once"),
122
123
  SideEffect("submit", "employer", "the application reaches the employer"),
@@ -205,7 +206,8 @@ def _job(url: str) -> tuple[str, str] | None:
205
206
  def _field(raw: dict[str, Any]) -> Field:
206
207
  kind = raw["kind"]
207
208
  if kind not in KINDS:
208
- raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
209
+ reason = f"unhandled_rendering: {raw['id']} is a {kind}"
210
+ return Field(raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason)
209
211
  options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
210
212
  if kind in ("radio", "checkboxes"):
211
213
  options = raw["options"]
@@ -11,7 +11,7 @@ from collections.abc import Callable
11
11
  from typing import Any
12
12
  from urllib.parse import urlsplit
13
13
 
14
- from atsfill.core.adapter import Blocked, Unsupported
14
+ from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
15
15
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page
16
16
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
17
 
@@ -33,7 +33,7 @@ LISTS: dict[str, tuple[str, Callable[[dict[str, Any]], str]]] = {
33
33
  "languages": ("languages/", lambda n: n["name"]),
34
34
  "industries": ("clients/{client}/industries/", lambda n: n["name"]),
35
35
  }
36
- ENTRIES = ("educations", "experiences") # a sub-form of entries: left out when optional
36
+ ENTRIES = ("educations", "experiences") # a sub-form of entries
37
37
  SHORT = {"full_name", "email", "phone_number", "city", "address", "latest_degree",
38
38
  "latest_university", "current_company", "current_department", "current_position",
39
39
  "current_benefits", "expected_benefits", "skype", "contact"} # fmt: skip
@@ -106,15 +106,11 @@ def _kind(entry: dict[str, Any]) -> str:
106
106
  return "text"
107
107
 
108
108
 
109
- def _field(entry: dict[str, Any], options: list[str] | None) -> Field | None:
110
- """The field an entry of the endpoint lists, None for optional entries."""
109
+ def _field(entry: dict[str, Any], options: list[str] | None) -> Field:
110
+ """The field an entry of the endpoint lists, a sub-form of entries aside."""
111
111
  kind, slug = _kind(entry), entry["client_field_slug"]
112
112
  fid, label, required = str(entry["id"]), entry["label"], bool(entry["is_required"])
113
113
  match kind:
114
- case "entries" if required:
115
- raise Unsupported(f"unsupported_rendering: {entry['field_type']} entries are required")
116
- case "entries":
117
- return None
118
114
  case "file":
119
115
  accept = RESUME_TYPES if entry["field_type"] == "resume" else None
120
116
  return Field(fid, label, FieldType.FILE, required, accept=accept)
@@ -157,6 +153,7 @@ def _options(entry: dict[str, Any], client: str) -> list[str] | None:
157
153
 
158
154
  class Manatal:
159
155
  name = "manatal"
156
+ known_gaps = {"entries": Gap.RENDERING}
160
157
  side_effects = [
161
158
  SideEffect("submit", "hiring_system", "the resume and attachments are uploaded"),
162
159
  SideEffect("submit", "employer", "the application reaches the employer"),
@@ -185,7 +182,7 @@ class Manatal:
185
182
  entries = _get(f"jobs/{job.group(1)}/application-form/")
186
183
  terms = bool(TERMS_REQUIRED.search(html))
187
184
  return self._fields(entries, terms, lambda e: _options(e, client))
188
- except OSError, ValueError, KeyError, TypeError, Unsupported:
185
+ except OSError, ValueError, KeyError, TypeError:
189
186
  return None
190
187
 
191
188
  def read_form(self, page: Page) -> list[Field]:
@@ -245,7 +242,7 @@ class Manatal:
245
242
  named.evaluate(_CHECK, answer == "Yes")
246
243
  case kind, _:
247
244
  raise Unsupported(
248
- f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
245
+ f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
249
246
  )
250
247
 
251
248
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -274,11 +271,22 @@ class Manatal:
274
271
  terms: bool,
275
272
  options: Callable[[dict[str, Any]], list[str] | None],
276
273
  ) -> list[Field]:
277
- fields = [f for e in entries if (f := _field(e, options(e))) is not None]
274
+ fields = [
275
+ self._entries_field(e) if _kind(e) == "entries" else _field(e, options(e))
276
+ for e in entries
277
+ ]
278
278
  if terms:
279
279
  fields.append(Field(TERMS, TERMS_LABEL, FieldType.CHOICE, True, YES_NO))
280
280
  return fields
281
281
 
282
+ def _entries_field(self, entry: dict[str, Any]) -> Field:
283
+ """An "Add Education" or "Add Experience" sub-form of entries."""
284
+ fid = str(entry["id"])
285
+ gap = known_gap(self, "entries", f"{fid} is a list of {entry['field_type']}")
286
+ return Field(
287
+ fid, entry["label"], FieldType.TEXT, bool(entry["is_required"]), unfillable=gap
288
+ )
289
+
282
290
  @staticmethod
283
291
  def _shown(page: Page, entry: dict[str, Any]) -> list[str] | None:
284
292
  """The options the page shows for a choice, without the empty placeholder."""
@@ -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, NotTaken, Unsupported
19
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
20
20
  from atsfill.core.browser import Locator, Page
21
21
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
22
22
 
@@ -230,6 +230,7 @@ _READ_BACK = """(ids) => {
230
230
 
231
231
  class Oracle:
232
232
  name = "oracle"
233
+ known_gaps = {"entries": Gap.RENDERING}
233
234
  side_effects = [
234
235
  SideEffect(
235
236
  "load",
@@ -358,10 +359,10 @@ class Oracle:
358
359
  except browser.Timeout:
359
360
  if page.locator(CAPTCHA).count():
360
361
  raise Blocked("captcha: the email step asks for an hCaptcha") from None
361
- raise Unsupported("unsupported_flow: the email step did not open the form") from None
362
+ raise RuntimeError("the email step did not open the form") from None
362
363
  if page.locator(CODE_PAGE).count():
363
364
  raise Blocked(
364
- f"emailed_code: {email} is known to this employer's Oracle, which emails it a"
365
+ f"email_code: {email} is known to this employer's Oracle, which emails it a"
365
366
  " code before it shows the form"
366
367
  )
367
368
  page.locator(f"{PAGINATION}:visible").first.wait_for(timeout=STEP_TIMEOUT_MS)
@@ -370,9 +371,8 @@ class Oracle:
370
371
  def _described(self, page: Page, raw: dict[str, Any]) -> Field | None:
371
372
  """The field a raw description is, None for one atsfill leaves out."""
372
373
  if raw["kind"] == "tiles":
373
- if raw["required"]:
374
- raise Unsupported(f"unsupported_rendering: {raw['label']!r} needs entries added")
375
- return None
374
+ gap = known_gap(self, "entries", f"{raw['label']!r} needs entries added")
375
+ return Field(raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=gap)
376
376
  if raw["kind"] == "email":
377
377
  self._kinds[EMAIL] = "email"
378
378
  return None
@@ -388,7 +388,7 @@ class Oracle:
388
388
  """The innermost row holding the field."""
389
389
  selector = page.evaluate(_ROW, field_id)
390
390
  if selector is None:
391
- raise Unsupported(f"unsupported_flow: {field_id} left the form while it was filled")
391
+ raise RuntimeError(f"{field_id} left the form while it was filled")
392
392
  return page.locator(".input-row").filter(has=page.locator(selector)).last
393
393
 
394
394
  def _options(self, page: Page, field_id: str) -> list[str]:
@@ -455,7 +455,7 @@ class Oracle:
455
455
  _choose(page, boxes.nth(2), year, exact=True)
456
456
  case _:
457
457
  raise Unsupported(
458
- f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
458
+ f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
459
459
  )
460
460
 
461
461
  def _phone(self, page: Page, row: Locator, answer: str) -> None:
@@ -543,7 +543,7 @@ def _go_to(page: Page, number: int) -> None:
543
543
  )
544
544
  _settle(page)
545
545
  return
546
- raise Unsupported(f"unsupported_flow: page {number} of the form did not open")
546
+ raise RuntimeError(f"page {number} of the form did not open")
547
547
 
548
548
 
549
549
  def _upload(field_id: str) -> str: