atsfill 0.0.6.dev202__tar.gz → 0.0.6.dev204__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 (69) hide show
  1. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/PKG-INFO +1 -1
  2. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/pyproject.toml +1 -1
  3. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/phenom.py +47 -16
  5. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/commands.py +1 -1
  6. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/matching.py +12 -0
  7. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/LICENSE +0 -0
  8. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/README.md +0 -0
  9. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/__init__.py +0 -0
  11. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/adp.py +0 -0
  12. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/adpmyjobs.py +0 -0
  13. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/bamboohr.py +0 -0
  15. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/breezy.py +0 -0
  16. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/dayforce.py +0 -0
  17. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/eightfold.py +0 -0
  18. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/greenhouse.py +0 -0
  19. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/gupy.py +0 -0
  20. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/hireology.py +0 -0
  21. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/hiringthing.py +0 -0
  22. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/hrmdirect.py +0 -0
  23. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/isolvedhire.py +0 -0
  24. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/jazzhr.py +0 -0
  25. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/jobaffinity.py +0 -0
  26. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/jobstreet.py +0 -0
  27. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/join.py +0 -0
  28. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/oracle.py +0 -0
  31. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/paycor.py +0 -0
  32. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/paylocity.py +0 -0
  33. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/pinpoint.py +0 -0
  35. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/recruitee.py +0 -0
  36. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/recruiterflow.py +0 -0
  37. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/rippling.py +0 -0
  38. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/smartrecruiters.py +0 -0
  39. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/successfactors.js +0 -0
  40. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/successfactors.py +0 -0
  41. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/taleo.py +0 -0
  42. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/ukg.py +0 -0
  44. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/welcomekit.py +0 -0
  45. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/wellfound.py +0 -0
  46. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/werecruit.py +0 -0
  47. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/workable.py +0 -0
  48. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/workatastartup.py +0 -0
  49. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/workday.py +0 -0
  50. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/wttj.py +0 -0
  51. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/adapters/zohorecruit.py +0 -0
  52. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/cli.py +0 -0
  53. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/__init__.py +0 -0
  54. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/accounts.py +0 -0
  55. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/adapter.py +0 -0
  56. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/attempts.py +0 -0
  57. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/browser.py +0 -0
  58. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/conditions.py +0 -0
  59. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/countries.py +0 -0
  60. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/html.py +0 -0
  61. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/otp.py +0 -0
  62. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/passwords.py +0 -0
  63. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/paths.py +0 -0
  64. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/registry.py +0 -0
  65. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/results.py +0 -0
  66. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/snapshot.py +0 -0
  67. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/core/telemetry.py +0 -0
  68. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.6.dev202 → atsfill-0.0.6.dev204}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.6.dev202
3
+ Version: 0.0.6.dev204
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.6.dev202"
3
+ version = "0.0.6.dev204"
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.6.dev202"
3
+ version = "0.0.6.dev204"
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"
@@ -43,6 +43,8 @@ JOB = re.compile(r"^(/[a-z]{2,8}/[a-z]{2})/(job|\w*apply)(?:/|$)")
43
43
  APPLY = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/\w*apply$") # `apply`, `hvhapply` for hourly roles
44
44
  SEQ = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/job/([A-Za-z0-9]+)")
45
45
  CDN = "https://cdn-prod-static.phenompeople.com/CareerConnectResources"
46
+ RENDERER = "MULTI_PAGE_renderer" # the script of the page that has one, in its loader's data
47
+ MULTI = "multipage" # what `_phapp_of` adds to `phApp`
46
48
  FORM = "form.rjsf"
47
49
  NEXT = "button#next"
48
50
  TERMS = "terms" # a notice some sites' wizard opens on, its one button accepting it
@@ -110,6 +112,7 @@ TEMPLATE = re.compile(r"\{\{\s*(.*?)\s*\}\}")
110
112
  TAG = re.compile(r"<[^>]+>")
111
113
  PAGE_VALUE = re.compile(r"%%(phApp\.[\w.]+)%%")
112
114
  ANSWER = re.compile(r"%%formData\.(\w+)%%")
115
+ PLACEHOLDER = re.compile(r"(please )?(choose|select)( (one|an?)\b.*)?$", re.IGNORECASE)
113
116
  SEARCHING = re.compile(r"Searching", re.IGNORECASE) # a typeahead's menu before its results
114
117
  ACCEPTED = re.compile(r"\b(DOCX?|PDF|TXT|HTML?|RTF)\b", re.IGNORECASE)
115
118
 
@@ -216,12 +219,14 @@ _APPLY_LINK = """seq => {
216
219
  return own ? own.href : null;
217
220
  }"""
218
221
 
219
- # The errors a step shows, each with its field's control id.
222
+ # The errors a step shows, each with its field's control id and whether the page hides it.
220
223
  _ERRORS = """form => [...form.querySelectorAll('.error-detail li, .text-danger, .error-message')]
221
224
  .filter(e => e.innerText.trim())
222
225
  .map(e => {
223
226
  const control = e.closest('.form-group')?.querySelector('input, select, textarea');
224
- return [control?.id || control?.name || '?', e.innerText.trim()];
227
+ const hidden = !!control && !/^(checkbox|radio)$/.test(control.type)
228
+ && !control.getClientRects().length;
229
+ return [control?.id || control?.name || '?', e.innerText.trim(), hidden];
225
230
  }).slice(0, 4)"""
226
231
 
227
232
 
@@ -264,6 +269,7 @@ class Phenom:
264
269
  known_gaps = {
265
270
  "first_step": Gap.FLOW,
266
271
  "long_wizard": Gap.FLOW,
272
+ "hidden_required": Gap.FLOW,
267
273
  "calendar_without_month": Gap.RENDERING,
268
274
  }
269
275
  side_effects = [
@@ -493,6 +499,7 @@ def _phapp_of(html: str) -> dict[str, Any]:
493
499
  ph = _literal(html, "var phApp = phApp || ")
494
500
  ph["ddo"] = _literal(html, "phApp.ddo = ")
495
501
  ph["sessionParams"] = _literal(html, "phApp.sessionParams = ")
502
+ ph[MULTI] = RENDERER in html
496
503
  return ph
497
504
 
498
505
 
@@ -515,13 +522,11 @@ def _read(
515
522
  ph: dict[str, Any], fetch: Callable[[str], Any], post: Post
516
523
  ) -> tuple[list[Field], list[dict[str, Any]]]:
517
524
  """The fields of every step, from the configuration the page names, and their raw form."""
518
- ats = (get(ph, "ddo.response.response.applyATS") or "").lower()
519
- base = f"{CDN}/{ph['refNum']}/{ats}/external/careersite/singlepage"
520
- config = fetch(f"{base}/schema/form_schema.json")
525
+ config, bundles = _configuration(ph, fetch)
521
526
  texts: dict[str, str] = {}
522
527
  for bundle in ("default", ph.get("locale") or "en_us"):
523
528
  with suppress(Exception): # a site has one bundle or both
524
- texts |= fetch(f"{base}/bundles/Messages_{bundle}.json")
529
+ texts |= fetch(f"{bundles}/Messages_{bundle}.json")
525
530
  reader = _Config(ph, config, texts, post)
526
531
  raws = [raw for step in reader.steps() for raw in step]
527
532
  if not raws:
@@ -534,6 +539,27 @@ def _read(
534
539
  return ([resume] if resume else []) + [_field(raw) for raw in raws], raws
535
540
 
536
541
 
542
+ def _configuration(ph: dict[str, Any], fetch: Callable[[str], Any]) -> tuple[dict[str, Any], str]:
543
+ """The configuration the page's renderer loads, and where its bundles are: a site on the
544
+ multi-page renderer keeps a stale `singlepage` one, and its form under the page's own name
545
+ where its `multipage` path has none."""
546
+ site = f"{CDN}/{ph['refNum']}"
547
+ ats = (get(ph, "ddo.response.response.applyATS") or "").lower()
548
+ base = f"{site}/{ats}/external/careersite/{'multipage' if ph.get(MULTI) else 'singlepage'}"
549
+ try:
550
+ return fetch(f"{base}/schema/form_schema.json"), f"{base}/bundles"
551
+ except Exception:
552
+ if not ph.get(MULTI):
553
+ raise
554
+ names = [
555
+ get(node, "data.page.schemaname")
556
+ for node in fetch(f"{site}/schema/apply/MULTI_PAGE_form_schema.json")
557
+ if node.get("type") == "page"
558
+ ]
559
+ form = next((name for name in names if name != TERMS), TERMS)
560
+ return fetch(f"{site}/schema/apply/multipage/{form}.json"), f"{site}/bundles/apply"
561
+
562
+
537
563
  class _Config:
538
564
  """The wizard's steps, each its fields as raw dicts, from the employer's configuration:
539
565
  a react-jsonschema-form schema and uiSchema with Phenom's widgets, rules on the job's data,
@@ -703,7 +729,7 @@ class _Config:
703
729
  "datasourcepath": get(u, "ui:options.datasourcepath"),
704
730
  "displayTextField": get(u, "ui:options.dataFieldMapKey") or "label",
705
731
  }
706
- raw["options"] = _unique(t for _, t in self.loaded_list(listing, api)) or None
732
+ raw["options"] = _choices(t for _, t in self.loaded_list(listing, api)) or None
707
733
  if kind == "select" and re.fullmatch(r"\w+", path): # a default, the job's country
708
734
  raw["default"] = self.prefilled(path)[1] or None
709
735
  if kind in ("date", "month", "year"):
@@ -730,7 +756,7 @@ class _Config:
730
756
  scope = path.rpartition(".")[0]
731
757
  at = f"{scope}.{raw['parent']}" if scope else raw["parent"]
732
758
  raw["option_sets"] = [
733
- OptionSet(Is(at, [self.text(parent.get("title"))]), _unique(
759
+ OptionSet(Is(at, [self.text(parent.get("title"))]), _choices(
734
760
  self.text(child.get("key"))
735
761
  for child in parent.get("child") or []
736
762
  if child.get("show") is not False
@@ -738,7 +764,7 @@ class _Config:
738
764
  for parent in u.get("data") or []
739
765
  ] # fmt: skip
740
766
  return None
741
- return _unique(
767
+ return _choices(
742
768
  self.text(child.get("key"))
743
769
  for parent in u.get("data") or []
744
770
  for child in parent.get("child") or []
@@ -750,7 +776,7 @@ class _Config:
750
776
  if isinstance(v, dict) and v.get("htmlType") == "dependent_utm"),
751
777
  None,
752
778
  ) # fmt: skip
753
- return _unique(self.text(parent.get("title")) for parent in tree or [])
779
+ return _choices(self.text(parent.get("title")) for parent in tree or [])
754
780
  if kind == "radio" and not p.get("enum"):
755
781
  return YES_NO
756
782
  api = u.get("apiConfig") or get(u, "ui:options.apiConfig")
@@ -777,7 +803,7 @@ class _Config:
777
803
  self.listed.setdefault(path.rsplit(".", 1)[-1], listed) # as a workflow names it
778
804
  if raw.get("option_sets"): # a dependent list's, under its parents' answers before any
779
805
  return None
780
- return _unique(t for _, t in pairs) or None
806
+ return _choices(t for _, t in pairs) or None
781
807
 
782
808
  def loaded_list(self, u: dict, api: dict) -> list[tuple[Any, str]]:
783
809
  """A list its own loader serves, by the uiSchema's names for the response's parts."""
@@ -851,7 +877,7 @@ class _Config:
851
877
  if isinstance(d, dict)
852
878
  ]
853
879
  if pairs:
854
- options = _unique(t for _, t in pairs if t)
880
+ options = _choices(t for _, t in pairs if t)
855
881
  raw["option_sets"] = [OptionSet(when[0] if len(when) == 1 else AllOf(when), options)]
856
882
  raw["parent"] = at
857
883
  return pairs
@@ -887,7 +913,7 @@ class _Config:
887
913
  shown = dict(zip(map(str, values), texts, strict=False))
888
914
  if isinstance(data, dict) and shown:
889
915
  raw["option_sets"] = [
890
- OptionSet(Is(at, [shown[key]]), _unique(t for _, t in rows if t))
916
+ OptionSet(Is(at, [shown[key]]), _choices(t for _, t in rows if t))
891
917
  for key, rows in pairs.items()
892
918
  if key in shown
893
919
  ]
@@ -1291,8 +1317,10 @@ def _json_text(value: Any) -> str:
1291
1317
  return json.dumps("" if value is None else str(value))[1:-1]
1292
1318
 
1293
1319
 
1294
- def _unique(items: Iterable[str]) -> list[str]:
1295
- return list(dict.fromkeys(i for i in items if i))
1320
+ def _choices(items: Iterable[str]) -> list[str]:
1321
+ """A list's options, once each, without an entry that only asks to pick one: an employer's
1322
+ bundle can label a real value so, which no one means to answer."""
1323
+ return list(dict.fromkeys(i for i in items if i and not PLACEHOLDER.match(i)))
1296
1324
 
1297
1325
 
1298
1326
  def _seq(url: str) -> str | None:
@@ -1388,7 +1416,10 @@ def _next(page: Page, number: int) -> None:
1388
1416
  raise NotTaken(f"step {number}: {detail}")
1389
1417
  raise RuntimeError(f"step {number} did not pass: {code}: {detail}")
1390
1418
  if errors := page.locator(FORM).evaluate(_ERRORS) if page.locator(FORM).count() else []:
1391
- shown = "; ".join(f"{dom}: {message}" for dom, message in errors)
1419
+ if hidden := next((dom for dom, _, hides in errors if hides), None):
1420
+ detail = f"step {number} requires {hidden}, which the page hides"
1421
+ raise Unsupported(known_gap(Phenom(), "hidden_required", detail))
1422
+ shown = "; ".join(f"{dom}: {message}" for dom, message, _ in errors)
1392
1423
  raise RuntimeError(f"step {number} did not pass: {shown}")
1393
1424
  raise RuntimeError(f"step {number} did not pass in time")
1394
1425
  finally:
@@ -426,7 +426,7 @@ def _fill(
426
426
  result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
427
427
  return result
428
428
  result.warnings = matching.knockouts(result.fields, answers)
429
- adapter.fill(page, answers)
429
+ adapter.fill(page, matching.to_fill(result.fields, answers))
430
430
  _shown_as_listed(page, adapter, result, answers)
431
431
  if result.status is not Status.READY:
432
432
  return result
@@ -51,6 +51,9 @@ def _absolute_url(value: str) -> bool:
51
51
  def matches(
52
52
  field: Field, answer: Answer, read_back: Answer | None, form: Form | None = None
53
53
  ) -> bool:
54
+ if field.type is FieldType.MULTI_CHOICE and answer == []:
55
+ # a cleared list: a read-back may leave out a field that holds nothing
56
+ return read_back in (None, "", [])
54
57
  if read_back is None:
55
58
  return False
56
59
  match field.type:
@@ -98,6 +101,8 @@ def _answer_problem(field: Field, answer: object, form: Form) -> str | None:
98
101
  if field.type is FieldType.MULTI_CHOICE:
99
102
  if not (isinstance(answer, list) and all(isinstance(v, str) for v in answer)):
100
103
  return "a multi_choice answer is a list of strings"
104
+ if not answer:
105
+ return "required, so an empty list does not answer it" if form.asked(field) else None
101
106
  values = answer
102
107
  elif not isinstance(answer, str):
103
108
  return f"a {field.type} answer is a string"
@@ -169,6 +174,13 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
169
174
  return out
170
175
 
171
176
 
177
+ def to_fill(fields: list[Field], answers: dict[str, Answer]) -> dict[str, Answer]:
178
+ """The answers an adapter fills, after `problems` passed them: an empty list clears what a
179
+ field holds, so one that holds nothing has nothing to fill."""
180
+ by_id = {f.id: f for f in fields}
181
+ return {i: a for i, a in answers.items() if a != [] or by_id[i].prefilled or by_id[i].default}
182
+
183
+
172
184
  def knockouts(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
173
185
  """The answers that end the application: never refused, as one can be the truthful one."""
174
186
  by_id = {f.id: f for f in fields}
File without changes
File without changes