atsfill 0.0.4.dev134__tar.gz → 0.0.4.dev135__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.dev134 → atsfill-0.0.4.dev135}/PKG-INFO +2 -1
  2. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/README.md +1 -0
  3. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/isolvedhire.py +0 -1
  6. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/oracle.py +27 -13
  7. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/successfactors.py +18 -2
  8. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/commands.py +18 -11
  9. atsfill-0.0.4.dev135/src/atsfill/core/otp.py +102 -0
  10. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/registry.toml +1 -1
  11. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/LICENSE +0 -0
  12. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/__init__.py +0 -0
  14. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/ashby.py +0 -0
  15. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/bamboohr.py +0 -0
  16. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/breezy.py +0 -0
  17. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/dayforce.py +0 -0
  18. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/eightfold.py +0 -0
  19. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/greenhouse.py +0 -0
  20. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/gupy.py +0 -0
  21. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hireology.py +0 -0
  22. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hiringthing.py +0 -0
  23. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hrmdirect.py +0 -0
  24. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/jazzhr.py +0 -0
  25. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/join.py +0 -0
  26. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/lever.py +0 -0
  27. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/manatal.py +0 -0
  28. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/paycor.py +0 -0
  29. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/paylocity.py +0 -0
  30. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/personio.py +0 -0
  31. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/phenom.py +0 -0
  32. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/pinpoint.py +0 -0
  33. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/recruitee.py +0 -0
  34. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/rippling.py +0 -0
  35. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/smartrecruiters.py +0 -0
  36. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/successfactors.js +0 -0
  37. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/taleo.py +0 -0
  38. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/teamtailor.py +0 -0
  39. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/ukg.py +0 -0
  40. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/workable.py +0 -0
  41. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/workday.py +0 -0
  42. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/wttj.py +0 -0
  43. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/adapters/zohorecruit.py +0 -0
  44. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/cli.py +0 -0
  45. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/__init__.py +0 -0
  46. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/accounts.py +0 -0
  47. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/adapter.py +0 -0
  48. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/attempts.py +0 -0
  49. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/browser.py +0 -0
  50. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/conditions.py +0 -0
  51. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/html.py +0 -0
  52. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/matching.py +0 -0
  53. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/passwords.py +0 -0
  54. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/paths.py +0 -0
  55. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/registry.py +0 -0
  56. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/src/atsfill/core/results.py +0 -0
  57. {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev135}/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.dev134
3
+ Version: 0.0.4.dev135
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,6 +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
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`.
141
142
 
142
143
  ## How it works
@@ -109,6 +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
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`.
113
114
 
114
115
  ## How it works
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev134"
3
+ version = "0.0.4.dev135"
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.dev134"
3
+ version = "0.0.4.dev135"
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"
@@ -173,7 +173,6 @@ class IsolvedHire:
173
173
  per_employer = False
174
174
  asks = {"name", "phone", "street", "city", "state", "zip", "country"}
175
175
  known_gaps = {
176
- "sign_up_otp": Gap.FLOW, # why the sign-up goes through the sign-in, not Apply now
177
176
  "entry_list": Gap.RENDERING,
178
177
  "drawn_signature": Gap.RENDERING,
179
178
  }
@@ -2,8 +2,9 @@
2
2
  as docs/adapters/oracle.md observes.
3
3
 
4
4
  The form opens only past an email step, and an email the employer's Oracle already knows gets an
5
- emailed OTP instead. Reading passes the step with a throwaway address; filling starts over with
6
- the answer's. Oracle keeps a draft of the application from then on, autosaved as fields change.
5
+ emailed OTP first, typed in by the core. Reading passes the step with a throwaway address;
6
+ filling starts over with the answer's. Oracle keeps a draft of the application from then on,
7
+ autosaved as fields change.
7
8
  """
8
9
 
9
10
  import re
@@ -15,7 +16,7 @@ from pathlib import PurePath
15
16
  from typing import Any
16
17
  from urllib.parse import urlsplit
17
18
 
18
- from atsfill.core import browser, matching
19
+ from atsfill.core import browser, matching, otp
19
20
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
20
21
  from atsfill.core.browser import Locator, Page
21
22
  from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
@@ -32,9 +33,12 @@ PAGINATION = ".apply-flow-pagination__button"
32
33
  PAGE_NUMBERS = ".apply-flow-navigation-pages__link:visible"
33
34
  LOADING = ".app-loading-spinner, .cx-select--loading, .apply-flow-block .skeleton"
34
35
  OTP_PAGE = ".apply-flow-pin-code-required"
36
+ PIN = f"{OTP_PAGE} .pin-code-input__input" # one per digit
37
+ VERIFY = f"{OTP_PAGE} button[type=submit]"
35
38
  CAPTCHA = "iframe[src*='hcaptcha.com']"
36
39
  MANUALLY = ".quick-apply-flow__manually--link:visible" # past an offer to import a profile
37
40
  ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-name"
41
+ REMOVE = ".attachment-upload-button__filled .attachment-upload-button__bottom-button"
38
42
  STEP_TIMEOUT_MS = 30_000
39
43
  LIST_TIMEOUT_MS = 3_000
40
44
  SEARCH_TIMEOUT_MS = 6_000 # for a typed search's results
@@ -356,21 +360,26 @@ class Oracle:
356
360
  if "--checked" not in (box.get_attribute("class") or ""):
357
361
  _click(page, box)
358
362
  _click(page, page.locator(NEXT).first)
359
- opened = page.locator(f"apply-flow-block, {OTP_PAGE}, {MANUALLY}")
360
363
  try:
361
- opened.first.wait_for(timeout=STEP_TIMEOUT_MS)
362
- if page.locator(MANUALLY).count():
363
- _click(page, page.locator(MANUALLY).first)
364
- page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
364
+ page.locator(f"apply-flow-block, {OTP_PAGE}, {MANUALLY}").first.wait_for(
365
+ timeout=STEP_TIMEOUT_MS
366
+ )
365
367
  except browser.Timeout:
366
368
  if page.locator(CAPTCHA).count():
367
369
  raise Blocked("captcha: the email step asks for an hCaptcha") from None
368
370
  raise RuntimeError("the email step did not open the form") from None
369
- if page.locator(OTP_PAGE).count():
370
- raise Blocked(
371
- f"email_otp: {email} is known to this employer's Oracle, which emails it an"
372
- " OTP before it shows the form"
373
- )
371
+ if page.locator(OTP_PAGE).count(): # an address this employer's Oracle knows
372
+ otp.enter(page.locator(PIN), email, "the email step")
373
+ _click(page, page.locator(VERIFY))
374
+ try:
375
+ page.locator(f"apply-flow-block, {MANUALLY}").first.wait_for(
376
+ timeout=STEP_TIMEOUT_MS
377
+ )
378
+ except browser.Timeout:
379
+ raise Blocked("email_otp: the email step did not take the OTP") from None
380
+ if page.locator(MANUALLY).count():
381
+ _click(page, page.locator(MANUALLY).first)
382
+ page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
374
383
  page.locator(f"{PAGINATION}:visible").first.wait_for(timeout=STEP_TIMEOUT_MS)
375
384
  _settle(page)
376
385
 
@@ -428,6 +437,11 @@ class Oracle:
428
437
  return
429
438
  if kind == "file":
430
439
  assert isinstance(answer, str)
440
+ # a draft from an earlier visit keeps its file, the input gone until it is removed
441
+ remove = page.locator(f"{_upload(field_id)} {REMOVE}")
442
+ if remove.count():
443
+ _click(page, remove.first)
444
+ self._file_input(page, field_id).wait_for(state="attached", timeout=STEP_TIMEOUT_MS)
431
445
  self._file_input(page, field_id).set_input_files(answer)
432
446
  return
433
447
  row = self._row(page, field_id)
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from typing import Any
14
14
  from urllib.parse import parse_qs, urlsplit
15
15
 
16
+ from atsfill.core import otp
16
17
  from atsfill.core.adapter import (
17
18
  Blocked,
18
19
  Gap,
@@ -26,7 +27,9 @@ from atsfill.core.browser import Locator, Page, PageRequest, Request, Timeout
26
27
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
27
28
 
28
29
  JOB = re.compile(r"^(?:/[^/]+)?/job/[^/]+/(?P<id>\d{5,})/?$")
29
- DC_HOST = re.compile(r"^career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)$")
30
+ DC_HOST = re.compile(
31
+ r"^(?:career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)|career-[a-z]+\d*\.ns2cloud\.com)$"
32
+ )
30
33
  APPLY = "a[href*='/talentcommunity/apply/']"
31
34
  COMPANY_COOKIE = "careerSiteCompanyId"
32
35
  PROFILE = "/career?company={company}&career_ns=job_listing_summary&navBarLevel=MY_PROFILE"
@@ -43,6 +46,7 @@ COUNTRY = "fbclc_country" # its options' values are ISO 3166 alpha-2 codes
43
46
  PHONE, CALLING_CODE = "#fbclc_phoneNumber", "#fbclc_ituCode" # `CA`: `Canada (+1)`
44
47
  SIGN_UP = "#fbclc_userName"
45
48
  PASSCODE = "#passcode" # an OTP some sign-ups ask before the account exists
49
+ PASSCODE_STEP = "#otpContainer"
46
50
  QUICK_APPLY = "#fbqa_apply"
47
51
  FORM = ".RCMFormField"
48
52
  LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
@@ -243,7 +247,9 @@ class SuccessFactors:
243
247
  if page.locator(FORM).count():
244
248
  return
245
249
  if page.locator(f"{PASSCODE}:visible").count():
246
- raise Blocked("email_otp: the sign-up sent a passcode to the email")
250
+ self._passcode(page, email)
251
+ deadline = time.monotonic() + SIGN_IN_TIMEOUT_MS / 1000
252
+ continue
247
253
  if not page.locator(f"{SIGN_UP}:visible").count():
248
254
  break
249
255
  if self._message(page, REFUSAL):
@@ -265,6 +271,16 @@ class SuccessFactors:
265
271
  return
266
272
  raise Unsupported(f"unhandled_rendering: the sign-up led to {urlsplit(page.url).path}")
267
273
 
274
+ def _passcode(self, page: Page, email: str) -> None:
275
+ """Types the passcode the sign-up emailed; the account exists once it is taken."""
276
+ otp.enter(page.locator(PASSCODE), email, "the sign-up")
277
+ page.locator(PASSCODE_STEP).get_by_role("button").first.click()
278
+ try:
279
+ page.locator(PASSCODE).wait_for(state="hidden", timeout=SIGN_IN_TIMEOUT_MS)
280
+ except Timeout:
281
+ message = self._message(page)
282
+ raise Blocked(f"email_otp: the sign-up did not take the OTP: {message}") from None
283
+
268
284
  def browser_read(self, page: Page) -> list[Field]:
269
285
  self._candidate(page)
270
286
  if not page.locator(FORM).count():
@@ -11,6 +11,7 @@ from atsfill.core import (
11
11
  browser,
12
12
  conditions,
13
13
  matching,
14
+ otp,
14
15
  passwords,
15
16
  paths,
16
17
  registry,
@@ -130,12 +131,15 @@ def _opened(
130
131
  detected.append(adapter.name)
131
132
  if adapter is None:
132
133
  yield _no_adapter(url)
133
- elif isinstance(adapter, NeedsAccount) and not (
134
- session and adapter.signed_in(page.request, adapter.account_scope(url))
135
- ):
136
- yield _sign_in_again(page, adapter, user, mine, url, credentials) or (page, adapter)
137
- else:
138
- yield page, adapter
134
+ return
135
+ scope = adapter.account_scope(url) if isinstance(adapter, NeedsAccount) else None
136
+ with otp.source(user, adapter.name, scope):
137
+ if isinstance(adapter, NeedsAccount) and not (
138
+ session and adapter.signed_in(page.request, scope)
139
+ ):
140
+ yield _sign_in_again(page, adapter, user, mine, url, credentials) or (page, adapter)
141
+ else:
142
+ yield page, adapter
139
143
 
140
144
 
141
145
  def _sign_in_again(
@@ -536,7 +540,7 @@ def login(
536
540
  accounts.wait_for_person(account, scope, data, session),
537
541
  )
538
542
  else:
539
- result = _headless(data, account, scope, credentials, session, details)
543
+ result = _headless(data, user, account, scope, credentials, session, details)
540
544
  command = login_command(adapter, user, url)
541
545
  report.status, report.reason = (
542
546
  result.status,
@@ -574,7 +578,9 @@ def _create(
574
578
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
575
579
  return report
576
580
  credentials = (email, password)
577
- result = _headless(data, account, report.scope, credentials, report.path, details, create=True)
581
+ result = _headless(
582
+ data, report.user, account, report.scope, credentials, report.path, details, create=True
583
+ )
578
584
  command = login_command(account.name, report.user, url)
579
585
  report.status, report.reason = result.status, _email_link(result.reason, email, command)
580
586
  report.password = "stored"
@@ -588,6 +594,7 @@ def _create(
588
594
 
589
595
  def _headless(
590
596
  data: Path,
597
+ user: str,
591
598
  account: NeedsAccount,
592
599
  scope: str | None,
593
600
  credentials: Credentials,
@@ -595,14 +602,14 @@ def _headless(
595
602
  details: SignUpDetails | None = None,
596
603
  create: bool = False,
597
604
  ) -> Result:
598
- """Signs in, or creates the account with `create`, through the adapter: `ready` once the
599
- session is stored."""
605
+ """Signs in, or creates the account with `create`, through the adapter, an OTP it meets
606
+ from the user's `otp-commands` entry: `ready` once the session is stored."""
600
607
  assert isinstance(account, SignsIn)
601
608
  url = account.login_url(scope)
602
609
 
603
610
  def run(_: list[str]) -> Result:
604
611
  result = Result(Status.READY, url, account.name)
605
- with open_page(data, None) as page, _stops(result):
612
+ with open_page(data, None) as page, _stops(result), otp.source(user, account.name, scope):
606
613
  accounts.sign_in(page, account, scope, *credentials, session, details, create)
607
614
  return result
608
615
 
@@ -0,0 +1,102 @@
1
+ """OTPs a hiring system emails before it goes on (ADR 0015): the user's `otp-commands` entry
2
+ gives each one, and atsfill types it where the adapter found the OTP step's input. The core
3
+ sets the source for the command; an adapter only calls `enter`."""
4
+
5
+ import shlex
6
+ import subprocess
7
+ import time
8
+ from collections.abc import Generator
9
+ from contextlib import contextmanager
10
+ from contextvars import ContextVar
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+
14
+ from atsfill.core import paths
15
+ from atsfill.core.adapter import Blocked
16
+ from atsfill.core.browser import Locator
17
+
18
+ OTP_TIMEOUT_S = 90 # from the step showing to the OTP typed in: a mail takes seconds
19
+ POLL_S = 3 # between runs of a command that has no OTP yet
20
+ EVERY_ADAPTER = "all" # the `otp-commands` key for the adapters without their own
21
+
22
+
23
+ @dataclass
24
+ class Source:
25
+ """The user's `otp-commands` entry for the adapter, None without one, and when the last
26
+ OTP was asked for: an OTP from before is not this one."""
27
+
28
+ adapter: str
29
+ command: str | None
30
+ scope: str | None
31
+ since: datetime
32
+
33
+
34
+ _source: ContextVar[Source | None] = ContextVar("otp_source", default=None)
35
+
36
+
37
+ def command(user: str, adapter: str) -> str | None:
38
+ """The user's `otp-commands` entry for the adapter, else for every adapter."""
39
+ entries = paths.config(user).get("otp-commands", {})
40
+ return entries.get(adapter) or entries.get(EVERY_ADAPTER)
41
+
42
+
43
+ @contextmanager
44
+ def source(user: str, adapter: str, scope: str | None) -> Generator[None]:
45
+ """Makes the user's entry the source of every OTP the adapter meets within, the first not
46
+ older than now."""
47
+ token = _source.set(Source(adapter, command(user, adapter), scope, _now()))
48
+ try:
49
+ yield
50
+ finally:
51
+ _source.reset(token)
52
+
53
+
54
+ def enter(inputs: Locator, email: str, step: str) -> None:
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."""
58
+ current = _source.get()
59
+ asked = f"email_otp: {step} sent an OTP to {email}"
60
+ if current is None or current.command is None:
61
+ adapter = current.adapter if current else "the adapter"
62
+ raise Blocked(f"{asked}; no `otp-commands` entry for {adapter}")
63
+ otp = _wait(current, email, asked)
64
+ count = inputs.count()
65
+ if count > 1 and count != len(otp):
66
+ raise Blocked(f"{asked}; the OTP command printed {len(otp)} characters for {count} inputs")
67
+ if count > 1:
68
+ for box, character in zip(inputs.all(), otp, strict=True):
69
+ box.press_sequentially(character)
70
+ else:
71
+ inputs.fill("")
72
+ inputs.press_sequentially(otp)
73
+ current.since = _now()
74
+
75
+
76
+ def _wait(current: Source, email: str, asked: str) -> str:
77
+ """Runs the command until it prints an OTP, each run within the time left: one that waits
78
+ for the mail itself runs once."""
79
+ assert current.command is not None
80
+ since = current.since.isoformat(timespec="seconds").replace("+00:00", "Z")
81
+ filled = {"{email}": email, "{scope}": current.scope or "", "{since}": since}
82
+ args = shlex.split(current.command)
83
+ for placeholder, value in filled.items():
84
+ args = [a.replace(placeholder, value) for a in args]
85
+ deadline = time.monotonic() + OTP_TIMEOUT_S
86
+ while (left := deadline - time.monotonic()) > 0:
87
+ try:
88
+ run = subprocess.run(args, capture_output=True, text=True, timeout=left)
89
+ except subprocess.TimeoutExpired:
90
+ break
91
+ except OSError as e:
92
+ raise Blocked(
93
+ f"{asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
94
+ ) from None
95
+ if run.returncode == 0 and (otp := next(iter(run.stdout.split()), "")):
96
+ return otp
97
+ 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")
99
+
100
+
101
+ def _now() -> datetime:
102
+ return datetime.now(UTC).replace(microsecond=0)
@@ -327,7 +327,7 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
327
327
  [isolvedhire]
328
328
  kind = "ats"
329
329
  share = 0.0047
330
- account = "to_read" # one account for the whole system, signed in per employer's site; Apply now verifies the email or phone with a one-time code, the sign-in's sign-up sets a password instead
330
+ account = "to_read" # one account for the whole system, signed in per employer's site; Apply now verifies the email or phone with an OTP and asks the same details as the sign-in's sign-up, which sets a password instead
331
331
  hosting = ["hosted"]
332
332
  adapter = "isolvedhire"
333
333
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
File without changes