atsfill 0.0.4.dev135__py3-none-any.whl → 0.0.4.dev136__py3-none-any.whl

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.
@@ -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)
atsfill/core/adapter.py CHANGED
@@ -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
 
atsfill/core/otp.py CHANGED
@@ -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:
@@ -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
@@ -5,7 +5,7 @@ atsfill/adapters/bamboohr.py,sha256=PhqZ9i9Exx8DJxVxRpZSrn0qssGGz5-K9TNlkBj_Auc,
5
5
  atsfill/adapters/breezy.py,sha256=k3QzNstaRMBajN7lpTn-Ou0ytDwpS49nreJvreCf5mk,16895
6
6
  atsfill/adapters/dayforce.py,sha256=SdLIsprkQu43CHz6dAUl2dgAPvQGOaAoy47uOhmpjGk,45460
7
7
  atsfill/adapters/eightfold.py,sha256=sqhYMbkH_7XMNgkV5WhYqm6uYEYsynhgNOgfXOVsU1A,23208
8
- atsfill/adapters/greenhouse.py,sha256=z9eCdk0XfFbgv5y60QE_kax0eM1bc4UyM6xPnJnhhOE,19088
8
+ atsfill/adapters/greenhouse.py,sha256=Hs8S1m9yZ4GkScy_DNLiG76cNiKoXwTlJK9DIUdPZtg,20848
9
9
  atsfill/adapters/gupy.py,sha256=TbOma3TE0xhYEtj_SIcS6ijdEc-W8rdLWS3iAonKrFY,14336
10
10
  atsfill/adapters/hireology.py,sha256=caBaVKdlrHVUWX0dPnCPKliRaC4-StX9eMDfV1adYzk,15198
11
11
  atsfill/adapters/hiringthing.py,sha256=m13Huwwai699MjR6PmFbeJsQ4X9lwlCblYEPHjnEbMw,13994
@@ -15,7 +15,7 @@ atsfill/adapters/jazzhr.py,sha256=-e1Vk1S962Fuf5v3Org-Vh34CigoVNHgdYwDxyIEkqM,12
15
15
  atsfill/adapters/join.py,sha256=hTRChCJYl-q5pJwoWGhH-cSwsxmPWZZVCR2BMqdPeF0,17857
16
16
  atsfill/adapters/lever.py,sha256=GdM9epgL_39XIddcYZ-oQk3GafGlp98JSlLNRElCRVw,10715
17
17
  atsfill/adapters/manatal.py,sha256=mE4lelGl5mI6jSwAnkAL45SzzGTsdwGGADaT-HftCoY,16651
18
- atsfill/adapters/oracle.py,sha256=nqlCSxhPrTcsQW41A5umqhgVcxAcDaqtR7bnsxjFwMQ,31620
18
+ atsfill/adapters/oracle.py,sha256=crGDbL6Nq_kkV379w4HeLQDkmx67do_HI6skEU6BPwo,31623
19
19
  atsfill/adapters/paycor.py,sha256=k91QAI8K1a1kM-LGV-dH6G9V8DcKrhKz91aHpCAnMWc,18488
20
20
  atsfill/adapters/paylocity.py,sha256=_gjocnMpG_sDwgVPAc5MwOrtgRD4aGUBPoTy6Oot8mU,29465
21
21
  atsfill/adapters/personio.py,sha256=Bquk6KRBJSBeQTkdZVT7QRcsuWQ3Qdl4DDNNmf2M3Ew,9870
@@ -25,7 +25,7 @@ atsfill/adapters/recruitee.py,sha256=QdabmZ4ncW-BJUWip9GGf74Kr081akwLISPVg5d3dFQ
25
25
  atsfill/adapters/rippling.py,sha256=YK3LJiDWvfvcICCbOckPZxYMTI4OY_BhBQ5UZXXt64w,20451
26
26
  atsfill/adapters/smartrecruiters.py,sha256=UmY3x97XZZ8lxFUZ0X8U8fLif_0eoMcn_ry4xEaocyE,22895
27
27
  atsfill/adapters/successfactors.js,sha256=5hghe7rzO4rDcP4ti-m3wQB64udXjCqHAsWFl7FbfqI,3823
28
- atsfill/adapters/successfactors.py,sha256=GyOia1CVlzXMectbSCCWiyD7qLzpqJF6216rUZXS-hI,23408
28
+ atsfill/adapters/successfactors.py,sha256=OKpadH9pT-iMr1eVxAvZt22YYeo9orewQ8SyGG65roY,23411
29
29
  atsfill/adapters/taleo.py,sha256=g9SompeJ6lrQqH5LIa75VHUdJKpaXYKzAI-g53qd_V4,27898
30
30
  atsfill/adapters/teamtailor.py,sha256=_mdL2Mu9RSXfJTqMHdS110zQ9u7MBeAOMRgq-Ysxi0U,26928
31
31
  atsfill/adapters/ukg.py,sha256=_EDl5Murb0dFBqGu48V_mYOjguHWDZ9PwzIZSD43QjY,26890
@@ -36,22 +36,22 @@ atsfill/adapters/zohorecruit.py,sha256=pbUdOcNxzieKkhlh4n55Gw9pDL5J1RSWzRxX5py9g
36
36
  atsfill/cli.py,sha256=6fkFNmRfDDJq7hzPL_xwkIURKPdn6W-e6xozAUruMbc,9125
37
37
  atsfill/core/__init__.py,sha256=kf56t1lsXLgAlmOjFOP1ukJtClHRjcw0UT_fMdWgspI,353
38
38
  atsfill/core/accounts.py,sha256=N35vfukcPoCbp5UH4h6PZ13Kr4XOB81sdIQmvzhelSU,4568
39
- atsfill/core/adapter.py,sha256=Kqzb3X0ZzXwNihRnoQB6g87ygmTCYJ0qW93Y1_AkP5M,8283
39
+ atsfill/core/adapter.py,sha256=QpTdbn7gfIFnGzkZODP4Kz6GNa2F-xi36RM6jLKdOVE,8310
40
40
  atsfill/core/attempts.py,sha256=DLDnRifse9P7loSH2ufVyclZzLohiHsUMRDUVZpKOfM,1144
41
41
  atsfill/core/browser.py,sha256=BJTP8rTIECEFO8SKQLLCI3EzYrtgFaDrgddXW1fY3po,14113
42
42
  atsfill/core/commands.py,sha256=jxXIgzeSWPLvl_IKGrvTmiFB5-LqZ4gfG0G-cn2x5P4,24988
43
43
  atsfill/core/conditions.py,sha256=MChl2G-C6CbbCT10iEXqz-1nat0QlVuy9ZbxszCi0dE,5590
44
44
  atsfill/core/html.py,sha256=rM62-8T-5ifknXExLMq6cKdoKFEGcmqqdDUqvstsb4U,2399
45
45
  atsfill/core/matching.py,sha256=C8U4eTKq6_he23Ri8DbV63GHwc8OeiRdonvhOiGZt58,6425
46
- atsfill/core/otp.py,sha256=XKIm38R-GYbSukbTqU3soH0fgKEso8Zomh7XNAtWWlU,4113
46
+ atsfill/core/otp.py,sha256=4xnA1k2QeJMPyi66ZXZqmWCYuHkps-4gzJ0eY6lCal8,4332
47
47
  atsfill/core/passwords.py,sha256=yjwtS8vXHwOm7WZJs3iSjW1SNAjDX2GgZeAY6FShzm8,4583
48
48
  atsfill/core/paths.py,sha256=gYdwmriRdcqketR3SifCvi6CrdmVaQXMrVAfUcGM_KY,3683
49
49
  atsfill/core/registry.py,sha256=YvNqwK_zvwsLHFsfSMumeItK8HclTJ67kfCJpNwwDPo,9759
50
50
  atsfill/core/results.py,sha256=9IK4NAK8KmctILb96sFy63npQzM5ixOk1Oxn8IMNdsI,4297
51
51
  atsfill/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
52
  atsfill/registry.toml,sha256=9ywk1fq7dwkRftLa0mUh9DbGfjxJTjnu-TJmQ_sb_gk,17097
53
- atsfill-0.0.4.dev135.dist-info/licenses/LICENSE,sha256=Vp06_Ly3myMdXM2y6piv0Z3AsuWOtCQtGQwbCSvSHTo,1065
54
- atsfill-0.0.4.dev135.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
55
- atsfill-0.0.4.dev135.dist-info/entry_points.txt,sha256=REry-6FNc5M2ZD7uzjcjN7lK_Jzrjr-C5pL3NFyupnM,46
56
- atsfill-0.0.4.dev135.dist-info/METADATA,sha256=go6ZWqYuJ_7GSJNOlwFnhympUrv0jebO-hjrYWzQUsU,11365
57
- atsfill-0.0.4.dev135.dist-info/RECORD,,
53
+ atsfill-0.0.4.dev136.dist-info/licenses/LICENSE,sha256=Vp06_Ly3myMdXM2y6piv0Z3AsuWOtCQtGQwbCSvSHTo,1065
54
+ atsfill-0.0.4.dev136.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
55
+ atsfill-0.0.4.dev136.dist-info/entry_points.txt,sha256=REry-6FNc5M2ZD7uzjcjN7lK_Jzrjr-C5pL3NFyupnM,46
56
+ atsfill-0.0.4.dev136.dist-info/METADATA,sha256=nZzada4Hb8mq7YyZiFaRcfkEson2JEBAYZBWl9o8KtE,11370
57
+ atsfill-0.0.4.dev136.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.13.5
2
+ Generator: uv 0.13.6
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any