atsfill 0.0.5.dev171__tar.gz → 0.0.5.dev178__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.dev171 → atsfill-0.0.5.dev178}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/join.py +19 -13
  5. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/lever.py +2 -1
  6. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/rippling.py +7 -3
  7. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/successfactors.py +4 -0
  8. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/commands.py +29 -5
  9. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/LICENSE +0 -0
  10. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/README.md +0 -0
  11. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/__init__.py +0 -0
  13. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/adpmyjobs.py +0 -0
  14. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/ashby.py +0 -0
  15. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/bamboohr.py +0 -0
  16. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/breezy.py +0 -0
  17. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/dayforce.py +0 -0
  18. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/eightfold.py +0 -0
  19. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/greenhouse.py +0 -0
  20. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/gupy.py +0 -0
  21. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/hireology.py +0 -0
  22. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/hiringthing.py +0 -0
  23. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/hrmdirect.py +0 -0
  24. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/isolvedhire.py +0 -0
  25. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/jazzhr.py +0 -0
  26. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/jobaffinity.py +0 -0
  27. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/jobstreet.py +0 -0
  28. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/manatal.py +0 -0
  29. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/oracle.py +0 -0
  30. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/paycor.py +0 -0
  31. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/paylocity.py +0 -0
  32. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/personio.py +0 -0
  33. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/phenom.py +0 -0
  34. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/pinpoint.py +0 -0
  35. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/recruitee.py +0 -0
  36. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/recruiterflow.py +0 -0
  37. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/smartrecruiters.py +0 -0
  38. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/successfactors.js +0 -0
  39. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/taleo.py +0 -0
  40. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/teamtailor.py +0 -0
  41. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/ukg.py +0 -0
  42. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/welcomekit.py +0 -0
  43. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/workable.py +0 -0
  44. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/workatastartup.py +0 -0
  45. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/workday.py +0 -0
  46. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/wttj.py +0 -0
  47. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/adapters/zohorecruit.py +0 -0
  48. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/cli.py +0 -0
  49. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/__init__.py +0 -0
  50. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/accounts.py +0 -0
  51. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/adapter.py +0 -0
  52. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/attempts.py +0 -0
  53. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/browser.py +0 -0
  54. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/conditions.py +0 -0
  55. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/html.py +0 -0
  56. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/matching.py +0 -0
  57. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/otp.py +0 -0
  58. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/passwords.py +0 -0
  59. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/paths.py +0 -0
  60. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/registry.py +0 -0
  61. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/results.py +0 -0
  62. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/snapshot.py +0 -0
  63. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/core/telemetry.py +0 -0
  64. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/src/atsfill/py.typed +0 -0
  65. {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev178}/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.dev171
3
+ Version: 0.0.5.dev178
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.dev171"
3
+ version = "0.0.5.dev178"
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.dev171"
3
+ version = "0.0.5.dev178"
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"
@@ -58,8 +58,10 @@ QUESTIONS = """query JobScreeningQuestions($input: JobScreeningQuestionsInput!)
58
58
  }
59
59
  }"""
60
60
  YES_NO = ["Yes", "No"] # a boolean's cards, in this order, in the job's language
61
- FILES = {"cv": "cv"} # a file field, by its step
62
- # A question of a class has a step of its own; the others are `question-<n>`, in order.
61
+ FILES = {"cv": "cv", "coverLetter": "coverLetter"} # a file field, by its step
62
+ # The documents a job asks, by their type in the wizard's `documentsConfig`
63
+ DOCUMENTS = {"CV": ("cv", "CV"), "COVER_LETTER": ("coverLetter", "Cover letter")}
64
+ # A question of a class has a step of its own; the others are `question-<position>`.
63
65
  CLASS_STEPS = {
64
66
  "workAuthorization": "WORK_AUTHORIZATION",
65
67
  "salaryExpectation": "SALARY_EXPECTATION",
@@ -200,11 +202,6 @@ class Join:
200
202
  answered = page.evaluate(_FETCH, [f"{GRAPHQL}?operationName=JobScreeningQuestions", body])
201
203
  questions = answered["data"]["jobScreeningQuestions"]["questions"]
202
204
  self._questions = sorted(questions, key=lambda q: q.get("position") or 0)
203
- fields = [
204
- Field("cv", "CV", FieldType.FILE, True, accept=PDF),
205
- *(_question(q) for q in self._questions),
206
- ]
207
- self._fields = {f.id: f for f in fields if not f.unfillable}
208
205
  browser.click(page.locator("[data-testid=ApplyButton]").locator("visible=true").first)
209
206
  page.wait_for_url("**/apply/**", timeout=STEP_TIMEOUT_MS)
210
207
  # the wizard opens on `authentication`, and leaves it once it knows the account
@@ -216,6 +213,16 @@ class Join:
216
213
  page.wait_for_timeout(POLL_MS)
217
214
  if _step(page) == "authentication":
218
215
  raise Blocked("login_required: run `atsfill login join`")
216
+ # the wizard's page names the documents the job asks, each required or optional
217
+ raw = page.locator("script#__NEXT_DATA__").text_content() or "{}"
218
+ state = json.loads(raw)["props"]["pageProps"].get("initialState") or {}
219
+ documents = (state.get("job") or {}).get("documentsConfig") or [{"type": "CV"}]
220
+ fields = [
221
+ *(Field(*DOCUMENTS[d["type"]], FieldType.FILE, d.get("constraint") != "OPTIONAL",
222
+ accept=PDF) for d in documents if d["type"] in DOCUMENTS),
223
+ *(_question(q) for q in self._questions),
224
+ ] # fmt: skip
225
+ self._fields = {f.id: f for f in fields if not f.unfillable}
219
226
  return fields
220
227
 
221
228
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
@@ -232,8 +239,6 @@ class Join:
232
239
  field_id = f"question.{question['id']}"
233
240
  if field_id in self._fields:
234
241
  self._question(page, question, answers.get(field_id))
235
- elif step == "coverLetter": # asked by some jobs only: left without one
236
- pass
237
242
  elif step in PROFILE_STEPS:
238
243
  try: # shown when the job requires what the account profile lacks
239
244
  _next(page, step)
@@ -252,10 +257,11 @@ class Join:
252
257
  if kind := CLASS_STEPS.get(step):
253
258
  return next((q for q in self._questions if q.get("questionClass") == kind), None)
254
259
  if match := re.fullmatch(r"question-(\d+)", step):
255
- plain = [q for q in self._questions if not q.get("questionClass")]
256
- if int(match[1]) > len(plain):
257
- raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
258
- return plain[int(match[1]) - 1]
260
+ position = int(match[1])
261
+ plain = (q for q in self._questions if not q.get("questionClass"))
262
+ if question := next((q for q in plain if q.get("position") == position), None):
263
+ return question
264
+ raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
259
265
  return None
260
266
 
261
267
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -19,6 +19,7 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
19
  HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
20
20
  JOB = re.compile(r"^/([^/]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b")
21
21
  FORM = "form#application-form"
22
+ CAPTCHA = "iframe[src*='hcaptcha.com']" # hidden until hCaptcha asks the visitor a challenge
22
23
  FORM_TIMEOUT_MS = 30_000
23
24
  UPLOAD_TIMEOUT_MS = 30_000
24
25
  SUGGEST_TIMEOUT_MS = 10_000
@@ -188,7 +189,7 @@ class Lever:
188
189
  try:
189
190
  page.wait_for_url(re.compile(r"/thanks\b"), timeout=SUBMIT_TIMEOUT_MS)
190
191
  except Timeout:
191
- if page.locator("iframe[src*='hcaptcha'][title*='challenge']").is_visible():
192
+ if page.locator(CAPTCHA).locator("visible=true").count():
192
193
  raise Blocked("captcha: hCaptcha asked for a challenge") from None
193
194
  raise
194
195
  return page.url
@@ -137,9 +137,13 @@ _SHOWN = """(root, [id, nth, testids]) => {
137
137
  if (el.getAttribute("role") === "group")
138
138
  return {kind: "checkbox", value: [...el.querySelectorAll("[role=checkbox]")]
139
139
  .filter(e => e.getAttribute("aria-checked") === "true").map(text)};
140
- if (el.querySelector("input[type=file]"))
141
- return {kind: "file", value: [...el.querySelectorAll("[data-testid=chip] [id$=-content]")]
142
- .map(text)[0] ?? null};
140
+ if (el.querySelector("input[type=file]")) {
141
+ // the chip shortens a long name; the upload's status line keeps it whole
142
+ const status = [...el.querySelectorAll("[role=status]")]
143
+ .map(e => text(e).match(/^File (.+) uploaded successfully$/)).find(m => m);
144
+ const chip = el.querySelector("[data-testid=chip] [id$=-content]");
145
+ return {kind: "file", value: status ? status[1] : chip ? text(chip) : null};
146
+ }
143
147
  if (el.querySelector("[data-input=month]")) {
144
148
  const part = p => el.querySelector(`[data-input=${p}]`).value;
145
149
  const [y, m, d] = [part("year"), part("month"), part("day")];
@@ -173,6 +173,10 @@ class SuccessFactors:
173
173
 
174
174
  def sign_in(self, page: Page, email: str, password: str) -> None:
175
175
  self._candidate(page)
176
+ if page.locator(QUICK_APPLY).count(): # its sign-in is a dialog, beside its sign-up
177
+ raise Unsupported(
178
+ known_gap(self, "quick_apply_sign_up", "the application is a Quick Apply page")
179
+ )
176
180
  if not page.locator(SIGN_IN).count():
177
181
  page.locator(SIGN_IN_LINK).first.click()
178
182
  page.locator(SIGN_IN).wait_for(timeout=LANDING_TIMEOUT_MS)
@@ -98,6 +98,9 @@ def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
98
98
  type Credentials = tuple[str, str | None] # email, password: None for an account without one
99
99
  # Refusals the page states before any account exists: the generated password goes.
100
100
  NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule", "sign_up_details"}
101
+ # A sign-in's refusals that find no account with the password: a stored one may be from a
102
+ # sign-up blocked before the account existed.
103
+ NOT_SIGNED_UP = {"wrong_password", "no_account"}
101
104
 
102
105
 
103
106
  def _next_step(
@@ -750,10 +753,11 @@ def login(
750
753
  if password and by_otp:
751
754
  raise ValueError(f"{adapter} signs in with an emailed OTP, not a password")
752
755
  try:
753
- kept = create and not by_otp and passwords.stored(mine, adapter, scope) is not None
756
+ stored = passwords.stored(mine, adapter, scope) if create and not by_otp else None
754
757
  except passwords.PasswordError as e:
755
758
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
756
759
  return report
760
+ kept = stored is not None
757
761
  if email and not kept:
758
762
  accounts.remember(mine, adapter, scope, email)
759
763
  if create and not kept:
@@ -784,6 +788,23 @@ def login(
784
788
  )
785
789
  else:
786
790
  result = _headless(data, user, account, scope, credentials, session, details)
791
+ if (
792
+ kept
793
+ and isinstance(account, CreatesAccount)
794
+ and (result.reason or "").split(":")[0] in NOT_SIGNED_UP
795
+ and credentials[1] == stored
796
+ ):
797
+ # a sign-up blocked before the account existed: sign up again, same password
798
+ return _create(
799
+ report,
800
+ account,
801
+ url,
802
+ mine,
803
+ credentials[0],
804
+ details or SignUpDetails(),
805
+ data,
806
+ kept=credentials[1],
807
+ )
787
808
  report.status = result.status
788
809
  report.reason = _next_step(result.reason, credentials[0], adapter, user, url)
789
810
  if report.reason is None:
@@ -799,10 +820,12 @@ def _create(
799
820
  email: str,
800
821
  details: SignUpDetails,
801
822
  data: Path,
823
+ kept: str | None = None,
802
824
  ) -> SessionReport:
803
825
  """Creates the account: refused before the browser opens when `details` lack one the
804
826
  sign-up asks; the generated password is stored before the click, and deleted only on a
805
- refusal the page states before any account exists. An account without a password gets
827
+ refusal the page states before any account exists. A `kept` password, stored by an
828
+ earlier sign-up, is used again and never deleted. An account without a password gets
806
829
  none."""
807
830
  if not isinstance(account, (CreatesAccount, CreatesAccountByOtp)):
808
831
  report.status = Status.UNSUPPORTED
@@ -812,9 +835,9 @@ def _create(
812
835
  report.status = Status.REFUSED
813
836
  report.reason = f"sign_up_details: no {', '.join(missing)}"
814
837
  return report
815
- password = None if isinstance(account, CreatesAccountByOtp) else passwords.generate()
838
+ password = kept or (None if isinstance(account, CreatesAccountByOtp) else passwords.generate())
816
839
  try:
817
- if password:
840
+ if password and not kept:
818
841
  passwords.store(mine, account.name, report.scope, password)
819
842
  except passwords.PasswordError as e:
820
843
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
@@ -825,9 +848,10 @@ def _create(
825
848
  )
826
849
  report.status = result.status
827
850
  report.reason = _next_step(result.reason, email, account.name, report.user, url)
828
- report.password = "stored" if password else "none"
851
+ report.password = "kept" if kept else "stored" if password else "none"
829
852
  if (
830
853
  password
854
+ and not kept
831
855
  and result.status is Status.REFUSED
832
856
  and (result.reason or "").split(":")[0] in NO_ACCOUNT
833
857
  ):
File without changes
File without changes