atsfill 0.0.4.dev135__tar.gz → 0.0.4.dev136__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 (57) hide show
  1. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/README.md +1 -1
  3. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/greenhouse.py +40 -5
  6. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/oracle.py +1 -1
  7. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/successfactors.py +1 -1
  8. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/adapter.py +2 -2
  9. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/otp.py +14 -7
  10. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/LICENSE +0 -0
  11. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/__init__.py +0 -0
  13. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/bamboohr.py +0 -0
  15. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/breezy.py +0 -0
  16. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/dayforce.py +0 -0
  17. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/eightfold.py +0 -0
  18. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/gupy.py +0 -0
  19. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/hireology.py +0 -0
  20. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/hiringthing.py +0 -0
  21. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/hrmdirect.py +0 -0
  22. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/isolvedhire.py +0 -0
  23. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/jazzhr.py +0 -0
  24. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/join.py +0 -0
  25. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/lever.py +0 -0
  26. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/manatal.py +0 -0
  27. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/paycor.py +0 -0
  28. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/paylocity.py +0 -0
  29. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/personio.py +0 -0
  30. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/phenom.py +0 -0
  31. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/pinpoint.py +0 -0
  32. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/recruitee.py +0 -0
  33. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/rippling.py +0 -0
  34. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/smartrecruiters.py +0 -0
  35. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/successfactors.js +0 -0
  36. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/taleo.py +0 -0
  37. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/teamtailor.py +0 -0
  38. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/ukg.py +0 -0
  39. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/workable.py +0 -0
  40. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/workday.py +0 -0
  41. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/wttj.py +0 -0
  42. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/adapters/zohorecruit.py +0 -0
  43. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/cli.py +0 -0
  44. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/__init__.py +0 -0
  45. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/accounts.py +0 -0
  46. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/attempts.py +0 -0
  47. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/browser.py +0 -0
  48. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/commands.py +0 -0
  49. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/conditions.py +0 -0
  50. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/html.py +0 -0
  51. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/matching.py +0 -0
  52. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/passwords.py +0 -0
  53. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/paths.py +0 -0
  54. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/registry.py +0 -0
  55. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/core/results.py +0 -0
  56. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/py.typed +0 -0
  57. {atsfill-0.0.4.dev135 → atsfill-0.0.4.dev136}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev135
3
+ Version: 0.0.4.dev136
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
@@ -137,7 +137,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
137
137
 
138
138
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
139
139
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
140
- When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: email_otp`.
140
+ When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: no_otp_command`.
141
141
  Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
142
142
 
143
143
  ## How it works
@@ -109,7 +109,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
109
109
 
110
110
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
111
111
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
112
- When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: email_otp`.
112
+ When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: no_otp_command`.
113
113
  Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
114
114
 
115
115
  ## How it works
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev135"
3
+ version = "0.0.4.dev136"
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.dev135"
3
+ version = "0.0.4.dev136"
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"
@@ -14,9 +14,9 @@ from contextlib import suppress
14
14
  from typing import Any
15
15
  from urllib.parse import parse_qs, urlencode, urlsplit
16
16
 
17
- from atsfill.core import browser
18
- from atsfill.core.adapter import Gap, Unsupported
19
- from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
17
+ from atsfill.core import browser, otp
18
+ from atsfill.core.adapter import Blocked, Gap, Unsupported
19
+ from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Page, Timeout
20
20
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
21
 
22
22
  HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
@@ -33,6 +33,7 @@ HYDRATE_TIMEOUT_MS = 15_000
33
33
  FORM_TIMEOUT_MS = 20_000
34
34
  ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
35
35
  SUBMIT_TIMEOUT_MS = 90_000
36
+ SUBMIT_POLL_MS = 500 # for the confirmation or the OTP step
36
37
  HYDRATE_POLL_MS = 500 # for a select's list to open, per click
37
38
  KEY_DELAY_MS = 20
38
39
  API_TYPES = {
@@ -43,6 +44,11 @@ API_TYPES = {
43
44
  "multi_value_multi_select": FieldType.MULTI_CHOICE,
44
45
  }
45
46
  YES_NO = ["Yes", "No"]
47
+ CONFIRMATION = re.compile(r"/confirmation")
48
+ # The OTP step a submit can stop at: a box per character of the OTP it emails.
49
+ OTP_STEP = "fieldset#email-verification"
50
+ OTP_INPUTS = f"{OTP_STEP} input[id^='security-input-']"
51
+ OTP_ERROR = "#email-verification-error"
46
52
  # Sections the page adds or names its own way, or shows a question of only on another answer
47
53
  # (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
48
54
  UNMAPPED = ("education", "compliance", "demographic_questions")
@@ -234,8 +240,24 @@ class Greenhouse:
234
240
  return {k: v for k, v in values.items() if v not in (None, "", [])}
235
241
 
236
242
  def submit(self, page: Page) -> str | None:
237
- browser.click(page.locator(f"{FORM} button[type=submit]"))
238
- page.wait_for_url(re.compile(r"/confirmation"), timeout=SUBMIT_TIMEOUT_MS)
243
+ """Submits, then types the emailed OTP where the step asks for one and submits again.
244
+ The evidence is the confirmation page."""
245
+ button = page.locator(f"{FORM} button[type=submit]")
246
+ browser.click(button)
247
+ if not _confirmed(page, OTP_INPUTS):
248
+ email = page.locator(f"{FORM} {_by_id('email')}").input_value()
249
+ otp.enter(page.locator(OTP_INPUTS), email, "the submit")
250
+ browser.click(button)
251
+ try:
252
+ confirmed = _confirmed(page, OTP_ERROR)
253
+ except Timeout:
254
+ if not page.locator(f"{OTP_INPUTS}:visible").count():
255
+ raise
256
+ stayed = "unhandled_rendering: the submit stayed at the OTP step"
257
+ raise Unsupported(stayed) from None
258
+ if not confirmed:
259
+ message = page.locator(OTP_ERROR).first.inner_text().strip()
260
+ raise Blocked(f"otp_rejected: the submit did not take the OTP: {message}")
239
261
  return page.url
240
262
 
241
263
  def _field(self, page: Page, raw: dict[str, Any]) -> Field:
@@ -317,6 +339,19 @@ def _wait_until_live(page: Page) -> None:
317
339
  return
318
340
 
319
341
 
342
+ def _confirmed(page: Page, step: str) -> bool:
343
+ """True once the page reaches the confirmation, False once `step` shows instead."""
344
+ deadline = time.monotonic() + SUBMIT_TIMEOUT_MS / 1000
345
+ while time.monotonic() < deadline:
346
+ with suppress(BrowserError): # the page navigating away
347
+ if CONFIRMATION.search(page.url):
348
+ return True
349
+ if page.locator(f"{step}:visible").count():
350
+ return False
351
+ page.wait_for_timeout(SUBMIT_POLL_MS)
352
+ raise Timeout(f"no confirmation within {SUBMIT_TIMEOUT_MS} ms")
353
+
354
+
320
355
  def _board_job(url: str) -> tuple[str, str] | None:
321
356
  """The board and job id of a board URL: `/<board>/jobs/<id>` or `embed/job_app?for&token`."""
322
357
  parts = urlsplit(url)
@@ -376,7 +376,7 @@ class Oracle:
376
376
  timeout=STEP_TIMEOUT_MS
377
377
  )
378
378
  except browser.Timeout:
379
- raise Blocked("email_otp: the email step did not take the OTP") from None
379
+ raise Blocked("otp_rejected: the email step did not take the OTP") from None
380
380
  if page.locator(MANUALLY).count():
381
381
  _click(page, page.locator(MANUALLY).first)
382
382
  page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
@@ -279,7 +279,7 @@ class SuccessFactors:
279
279
  page.locator(PASSCODE).wait_for(state="hidden", timeout=SIGN_IN_TIMEOUT_MS)
280
280
  except Timeout:
281
281
  message = self._message(page)
282
- raise Blocked(f"email_otp: the sign-up did not take the OTP: {message}") from None
282
+ raise Blocked(f"otp_rejected: the sign-up did not take the OTP: {message}") from None
283
283
 
284
284
  def browser_read(self, page: Page) -> list[Field]:
285
285
  self._candidate(page)
@@ -120,8 +120,8 @@ class SignsIn(NeedsAccount, Protocol):
120
120
 
121
121
  def sign_in(self, page: Page, email: str, password: str) -> None:
122
122
  """Signs in on the page `login_url` loaded. Raises `Blocked` for what the system waits
123
- for (`email_link`, `email_otp`, `sms_otp`, `captcha`), `Refused` for what it turns down,
124
- `Unsupported` for a page it does not know."""
123
+ for (`email_link`, `email_otp`, `sms_otp`, `captcha`, the OTP command's reasons),
124
+ `Refused` for what it turns down, `Unsupported` for a page it does not know."""
125
125
  ...
126
126
 
127
127
 
@@ -53,17 +53,21 @@ def source(user: str, adapter: str, scope: str | None) -> Generator[None]:
53
53
 
54
54
  def enter(inputs: Locator, email: str, step: str) -> None:
55
55
  """Types the OTP emailed to `email` into the step's input, or one character into each of
56
- its inputs. Raises `Blocked` (`email_otp`) when there is no entry, or no OTP within
57
- `OTP_TIMEOUT_S`; `step` names where the OTP was asked, for the reason."""
56
+ its inputs. Raises `Blocked`: `no_otp_command` without an entry, `otp_not_received` when the
57
+ command prints none within `OTP_TIMEOUT_S`, `otp_rejected` when what it prints does not fit
58
+ the inputs; `step` names where the OTP was asked, for the reason."""
58
59
  current = _source.get()
59
- asked = f"email_otp: {step} sent an OTP to {email}"
60
+ asked = f"{step} sent an OTP to {email}"
60
61
  if current is None or current.command is None:
61
62
  adapter = current.adapter if current else "the adapter"
62
- raise Blocked(f"{asked}; no `otp-commands` entry for {adapter}")
63
+ raise Blocked(f"no_otp_command: {asked}; no `otp-commands` entry for {adapter}")
63
64
  otp = _wait(current, email, asked)
64
65
  count = inputs.count()
65
66
  if count > 1 and count != len(otp):
66
- raise Blocked(f"{asked}; the OTP command printed {len(otp)} characters for {count} inputs")
67
+ raise Blocked(
68
+ f"otp_rejected: {asked}; the OTP command printed {len(otp)} characters"
69
+ f" for {count} inputs"
70
+ )
67
71
  if count > 1:
68
72
  for box, character in zip(inputs.all(), otp, strict=True):
69
73
  box.press_sequentially(character)
@@ -90,12 +94,15 @@ def _wait(current: Source, email: str, asked: str) -> str:
90
94
  break
91
95
  except OSError as e:
92
96
  raise Blocked(
93
- f"{asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
97
+ f"otp_not_received: {asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
94
98
  ) from None
95
99
  if run.returncode == 0 and (otp := next(iter(run.stdout.split()), "")):
96
100
  return otp
97
101
  time.sleep(min(POLL_S, max(0.0, deadline - time.monotonic())))
98
- raise Blocked(f"{asked}; the OTP command `{args[0]}` printed none within {OTP_TIMEOUT_S}s")
102
+ raise Blocked(
103
+ f"otp_not_received: {asked}; the OTP command `{args[0]}` printed none"
104
+ f" within {OTP_TIMEOUT_S}s"
105
+ )
99
106
 
100
107
 
101
108
  def _now() -> datetime:
File without changes