atsfill 0.0.4.dev134__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)
@@ -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("otp_rejected: 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"otp_rejected: 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():
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/commands.py CHANGED
@@ -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
 
atsfill/core/otp.py ADDED
@@ -0,0 +1,109 @@
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`: `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."""
59
+ current = _source.get()
60
+ asked = f"{step} sent an OTP to {email}"
61
+ if current is None or current.command is None:
62
+ adapter = current.adapter if current else "the adapter"
63
+ raise Blocked(f"no_otp_command: {asked}; no `otp-commands` entry for {adapter}")
64
+ otp = _wait(current, email, asked)
65
+ count = inputs.count()
66
+ if count > 1 and count != len(otp):
67
+ raise Blocked(
68
+ f"otp_rejected: {asked}; the OTP command printed {len(otp)} characters"
69
+ f" for {count} inputs"
70
+ )
71
+ if count > 1:
72
+ for box, character in zip(inputs.all(), otp, strict=True):
73
+ box.press_sequentially(character)
74
+ else:
75
+ inputs.fill("")
76
+ inputs.press_sequentially(otp)
77
+ current.since = _now()
78
+
79
+
80
+ def _wait(current: Source, email: str, asked: str) -> str:
81
+ """Runs the command until it prints an OTP, each run within the time left: one that waits
82
+ for the mail itself runs once."""
83
+ assert current.command is not None
84
+ since = current.since.isoformat(timespec="seconds").replace("+00:00", "Z")
85
+ filled = {"{email}": email, "{scope}": current.scope or "", "{since}": since}
86
+ args = shlex.split(current.command)
87
+ for placeholder, value in filled.items():
88
+ args = [a.replace(placeholder, value) for a in args]
89
+ deadline = time.monotonic() + OTP_TIMEOUT_S
90
+ while (left := deadline - time.monotonic()) > 0:
91
+ try:
92
+ run = subprocess.run(args, capture_output=True, text=True, timeout=left)
93
+ except subprocess.TimeoutExpired:
94
+ break
95
+ except OSError as e:
96
+ raise Blocked(
97
+ f"otp_not_received: {asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
98
+ ) from None
99
+ if run.returncode == 0 and (otp := next(iter(run.stdout.split()), "")):
100
+ return otp
101
+ time.sleep(min(POLL_S, max(0.0, deadline - time.monotonic())))
102
+ raise Blocked(
103
+ f"otp_not_received: {asked}; the OTP command `{args[0]}` printed none"
104
+ f" within {OTP_TIMEOUT_S}s"
105
+ )
106
+
107
+
108
+ def _now() -> datetime:
109
+ return datetime.now(UTC).replace(microsecond=0)
atsfill/registry.toml CHANGED
@@ -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" }
@@ -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.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,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: no_otp_command`.
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
@@ -5,17 +5,17 @@ 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
12
12
  atsfill/adapters/hrmdirect.py,sha256=rdmbArPgm0tx4mt4wHwEdgBe-HVgh52WI59BCksp3eM,22640
13
- atsfill/adapters/isolvedhire.py,sha256=lkrsssiesaMdG7LFryfoJOSnltoWtxgrEeT1Mn3M9e4,29402
13
+ atsfill/adapters/isolvedhire.py,sha256=3ZGvZ5saYWmJzkTuTeWwsBy2dajXCT6bZXN2XZfQqM8,29310
14
14
  atsfill/adapters/jazzhr.py,sha256=-e1Vk1S962Fuf5v3Org-Vh34CigoVNHgdYwDxyIEkqM,12446
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=swBUoPl83pK4AQFI-GZoG7ItDRV0mSLNjPcKwECFc1k,30818
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=xZR2o6vUTh6PMd2MWpJZgu6wQy0oDpS7-oOxIQ1Mxok,22702
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,21 +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
- atsfill/core/commands.py,sha256=kye7P-6WqXeDtx7SOGf-YvMFso-vl5ldz01i560CK0c,24676
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=4xnA1k2QeJMPyi66ZXZqmWCYuHkps-4gzJ0eY6lCal8,4332
46
47
  atsfill/core/passwords.py,sha256=yjwtS8vXHwOm7WZJs3iSjW1SNAjDX2GgZeAY6FShzm8,4583
47
48
  atsfill/core/paths.py,sha256=gYdwmriRdcqketR3SifCvi6CrdmVaQXMrVAfUcGM_KY,3683
48
49
  atsfill/core/registry.py,sha256=YvNqwK_zvwsLHFsfSMumeItK8HclTJ67kfCJpNwwDPo,9759
49
50
  atsfill/core/results.py,sha256=9IK4NAK8KmctILb96sFy63npQzM5ixOk1Oxn8IMNdsI,4297
50
51
  atsfill/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
- atsfill/registry.toml,sha256=4hiBHpYfLZAAZmZyAkzbv2Y9X5ZhmgGs-JntKDwdgiM,17071
52
- atsfill-0.0.4.dev134.dist-info/licenses/LICENSE,sha256=Vp06_Ly3myMdXM2y6piv0Z3AsuWOtCQtGQwbCSvSHTo,1065
53
- atsfill-0.0.4.dev134.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
54
- atsfill-0.0.4.dev134.dist-info/entry_points.txt,sha256=REry-6FNc5M2ZD7uzjcjN7lK_Jzrjr-C5pL3NFyupnM,46
55
- atsfill-0.0.4.dev134.dist-info/METADATA,sha256=jW3ntouBcHpSslldSyTFBAUfYMHbhsbsWKqEG72TwQA,11103
56
- atsfill-0.0.4.dev134.dist-info/RECORD,,
52
+ atsfill/registry.toml,sha256=9ywk1fq7dwkRftLa0mUh9DbGfjxJTjnu-TJmQ_sb_gk,17097
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