atsfill 0.0.5.dev144__tar.gz → 0.0.5.dev145__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 (59) hide show
  1. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/accounts.py +16 -5
  5. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/adapter.py +27 -1
  6. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/commands.py +53 -30
  7. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/results.py +1 -1
  8. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/LICENSE +0 -0
  9. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/README.md +0 -0
  10. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/__init__.py +0 -0
  12. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/bamboohr.py +0 -0
  14. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/breezy.py +0 -0
  15. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/dayforce.py +0 -0
  16. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/eightfold.py +0 -0
  17. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/greenhouse.py +0 -0
  18. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/gupy.py +0 -0
  19. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hireology.py +0 -0
  20. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hiringthing.py +0 -0
  21. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hrmdirect.py +0 -0
  22. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/isolvedhire.py +0 -0
  23. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/jazzhr.py +0 -0
  24. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/join.py +0 -0
  25. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/lever.py +0 -0
  26. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/manatal.py +0 -0
  27. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/oracle.py +0 -0
  28. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/paycor.py +0 -0
  29. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/paylocity.py +0 -0
  30. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/personio.py +0 -0
  31. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/phenom.py +0 -0
  32. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/pinpoint.py +0 -0
  33. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/recruitee.py +0 -0
  34. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/rippling.py +0 -0
  35. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/smartrecruiters.py +0 -0
  36. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/successfactors.js +0 -0
  37. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/successfactors.py +0 -0
  38. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/taleo.py +0 -0
  39. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/teamtailor.py +0 -0
  40. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/ukg.py +0 -0
  41. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/workable.py +0 -0
  42. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/workday.py +0 -0
  43. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/wttj.py +0 -0
  44. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/adapters/zohorecruit.py +0 -0
  45. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/cli.py +0 -0
  46. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/__init__.py +0 -0
  47. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/attempts.py +0 -0
  48. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/browser.py +0 -0
  49. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/conditions.py +0 -0
  50. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/html.py +0 -0
  51. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/matching.py +0 -0
  52. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/otp.py +0 -0
  53. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/passwords.py +0 -0
  54. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/paths.py +0 -0
  55. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/registry.py +0 -0
  56. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/snapshot.py +0 -0
  57. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/core/telemetry.py +0 -0
  58. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/src/atsfill/py.typed +0 -0
  59. {atsfill-0.0.5.dev144 → atsfill-0.0.5.dev145}/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.dev144
3
+ Version: 0.0.5.dev145
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.dev144"
3
+ version = "0.0.5.dev145"
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.dev144"
3
+ version = "0.0.5.dev145"
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"
@@ -8,9 +8,11 @@ from pathlib import Path
8
8
  from atsfill.core import browser
9
9
  from atsfill.core.adapter import (
10
10
  CreatesAccount,
11
+ CreatesAccountByOtp,
11
12
  FinishesSignUp,
12
13
  NeedsAccount,
13
14
  SignsIn,
15
+ SignsInByOtp,
14
16
  SignUpDetails,
15
17
  Unsupported,
16
18
  )
@@ -61,21 +63,30 @@ def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> N
61
63
 
62
64
  def sign_in(
63
65
  page: Page,
64
- adapter: SignsIn,
66
+ adapter: SignsIn | SignsInByOtp,
65
67
  scope: str | None,
66
68
  address: str,
67
- password: str,
69
+ password: str | None,
68
70
  session: Path,
69
71
  details: SignUpDetails | None = None,
70
72
  create: bool = False,
71
73
  ) -> None:
72
74
  """Signs in from the system's login page and stores the session: with `create`, creates
73
75
  the account from the user's sign-up `details`; without, `details` finish a sign-up the
74
- account was left in, where the adapter knows that step.
76
+ account was left in, where the adapter knows that step. An account without a password
77
+ (`password` None) signs in by OTP, and creates the same way.
75
78
  Raises what the adapter raises, without the password in it."""
76
79
  page.goto(adapter.login_url(scope))
77
80
  try:
78
- if create:
81
+ if isinstance(adapter, SignsInByOtp):
82
+ if create:
83
+ assert isinstance(adapter, CreatesAccountByOtp) and details is not None
84
+ adapter.sign_up_by_otp(page, address, details)
85
+ else:
86
+ adapter.sign_in_by_otp(page, address)
87
+ elif password is None:
88
+ raise AssertionError(f"{adapter.name} signs in with a password")
89
+ elif create:
79
90
  assert isinstance(adapter, CreatesAccount) and details is not None
80
91
  adapter.sign_up(page, address, password, details)
81
92
  elif details is not None and isinstance(adapter, FinishesSignUp):
@@ -83,7 +94,7 @@ def sign_in(
83
94
  else:
84
95
  adapter.sign_in(page, address, password)
85
96
  except Exception as e:
86
- if password in str(e):
97
+ if password and password in str(e):
87
98
  raise redacted(e, password) from None
88
99
  raise
89
100
  if not adapter.signed_in(page.request, scope):
@@ -42,7 +42,7 @@ def out_of_scope(field_id: str, what: str) -> str:
42
42
 
43
43
  class Refused(Exception):
44
44
  """The system turned down a sign-in or a sign-up for a reason it states:
45
- `email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`.
45
+ `email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`, `no_account`.
46
46
 
47
47
  The message is the result's reason, `code` or `code: detail`.
48
48
  """
@@ -125,6 +125,18 @@ class SignsIn(NeedsAccount, Protocol):
125
125
  ...
126
126
 
127
127
 
128
+ @runtime_checkable
129
+ class SignsInByOtp(NeedsAccount, Protocol):
130
+ """An adapter atsfill can sign in through with an email and the OTP its system emails each
131
+ time, an account without a password (ADR 0015)."""
132
+
133
+ def sign_in_by_otp(self, page: Page, email: str) -> None:
134
+ """Signs in on the page `login_url` loaded, typing the OTP with `otp.enter`. Raises as
135
+ `sign_in` does, and `Refused("no_account")` before the click that would turn an unknown
136
+ email's sign-in into a sign-up."""
137
+ ...
138
+
139
+
128
140
  ADDRESS = ("street", "city", "state", "zip", "country")
129
141
  PHONE = re.compile(r"\+[1-9][0-9]{6,14}") # E.164
130
142
  COUNTRY = re.compile(r"[A-Z]{2}") # ISO 3166 alpha-2
@@ -183,6 +195,20 @@ class CreatesAccount(SignsIn, Protocol):
183
195
  ...
184
196
 
185
197
 
198
+ @runtime_checkable
199
+ class CreatesAccountByOtp(SignsInByOtp, Protocol):
200
+ """An adapter atsfill can create an account without a password through. Account creation is
201
+ a side effect it declares (ADR 0008)."""
202
+
203
+ asks: set[str] # the `SignUpDetails` fields its sign-up requires
204
+
205
+ def sign_up_by_otp(self, page: Page, email: str, details: SignUpDetails) -> None:
206
+ """Creates the account from the page `login_url` loaded, or signs in where the email
207
+ has one, signed in once it returns. Raises as `sign_in_by_otp` does, `no_account` aside;
208
+ the details are given as `sign_up` gives them."""
209
+ ...
210
+
211
+
186
212
  @runtime_checkable
187
213
  class FinishesSignUp(CreatesAccount, Protocol):
188
214
  """An adapter whose system can keep an account half made: signed up, but a step the
@@ -23,12 +23,14 @@ from atsfill.core.adapter import (
23
23
  Adapter,
24
24
  Blocked,
25
25
  CreatesAccount,
26
+ CreatesAccountByOtp,
26
27
  ListsShownFields,
27
28
  NeedsAccount,
28
29
  NotTaken,
29
30
  ReadsByRequest,
30
31
  Refused,
31
32
  SignsIn,
33
+ SignsInByOtp,
32
34
  SignUpDetails,
33
35
  Unsupported,
34
36
  )
@@ -59,14 +61,14 @@ def _detect(candidates: list[Adapter], url: str, page: Page | None = None) -> Ad
59
61
  return next((a for a in candidates if a.detect(url, page)), None)
60
62
 
61
63
 
62
- def login_command(adapter: str, user: str, url: str | None) -> str:
64
+ def login_command(adapter: str, user: str, url: str | None, *more: str) -> str:
63
65
  """The `login` command line for one account: its user, and a posting where it is scoped."""
64
66
  command = ["atsfill", "login", adapter]
65
67
  if user != paths.DEFAULT_USER:
66
68
  command += ["--user", user]
67
69
  if url:
68
70
  command += ["--url", url]
69
- return shlex.join(command)
71
+ return shlex.join([*command, *more])
70
72
 
71
73
 
72
74
  def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
@@ -74,27 +76,38 @@ def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
74
76
  return Result(Status.BLOCKED, url, adapter.name, reason=f"login_required: run `{command}`")
75
77
 
76
78
 
77
- type Credentials = tuple[str, str] # email, password
79
+ type Credentials = tuple[str, str | None] # email, password: None for an account without one
78
80
  # Refusals the page states before any account exists: the generated password goes.
79
81
  NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule", "sign_up_details"}
80
82
 
81
83
 
82
- def _email_link(reason: str | None, email: str, command: str) -> str | None:
83
- """Names the address a verification link went to and the command to run once clicked."""
84
- if reason and reason.split(":")[0] == "email_link":
84
+ def _next_step(
85
+ reason: str | None, email: str, adapter: str, user: str, url: str | None
86
+ ) -> str | None:
87
+ """Names the command to run next: once the verification link sent to the address is
88
+ clicked, or `--create` for an email the system has no account for."""
89
+ code = (reason or "").split(":")[0]
90
+ if code == "email_link":
91
+ command = login_command(adapter, user, url)
85
92
  return f"email_link: click the link sent to {email}, then run `{command}`"
93
+ if code == "no_account":
94
+ command = login_command(adapter, user, url, "--email", email, "--create")
95
+ return f"no_account: {email} has no {adapter} account; run `{command}`"
86
96
  return reason
87
97
 
88
98
 
89
99
  def _credentials(
90
100
  adapter: NeedsAccount, user: str, mine: Path, scope: str | None, given: str | None = None
91
101
  ) -> Credentials | None:
92
- """The account's email and a password from its password source, where atsfill can sign in
102
+ """The account's email and a password from its password source, or no password for an
103
+ account without one when the user has an OTP command for it, where atsfill can sign in
93
104
  through the adapter; None otherwise."""
94
- if not isinstance(adapter, SignsIn):
105
+ if not isinstance(adapter, (SignsIn, SignsInByOtp)):
95
106
  return None
96
107
  if not (email := accounts.email(mine, adapter.name, scope)):
97
108
  return None
109
+ if isinstance(adapter, SignsInByOtp):
110
+ return (email, None) if otp.command(user, adapter.name) else None
98
111
  password = passwords.password(user, mine, adapter.name, scope, email, given)
99
112
  return (email, password) if password else None
100
113
 
@@ -106,7 +119,8 @@ def _opened(
106
119
  """The loaded page and the adapter that claims it by its URL, else by the URL a redirect
107
120
  lands on, else by its page; or the result that ends the command first. An adapter that
108
121
  needs an account gets the user's session, and only that adapter's; signed out with a
109
- password source, atsfill signs in again (ADR 0015). The adapter's name goes in `seen`
122
+ password source, or an OTP command for an account without a password, atsfill signs in
123
+ again (ADR 0015). The adapter's name goes in `seen`
110
124
  once known. The adapters are fresh: none keeps state from another
111
125
  posting (a survey or the adapter gate runs many commands in one process)."""
112
126
  candidates = adapters()
@@ -162,8 +176,8 @@ def _sign_in_again(
162
176
  url: str,
163
177
  credentials: Credentials | None,
164
178
  ) -> Result | None:
165
- """Signs in with the account's password source, stores the session and loads the posting
166
- again; the result that ends the command instead, `login_required` without a source."""
179
+ """Signs in as `_credentials` allows, stores the session and loads the posting again; the
180
+ result that ends the command instead, `login_required` where it does not."""
167
181
  result = Result(Status.BLOCKED, url, adapter.name)
168
182
  with _stops(result):
169
183
  scope = adapter.account_scope(url)
@@ -172,14 +186,15 @@ def _sign_in_again(
172
186
  except passwords.PasswordError as e:
173
187
  result.reason = f"password_source: {e}"
174
188
  return result
175
- if credentials is None or not isinstance(adapter, SignsIn):
189
+ if credentials is None or not isinstance(adapter, (SignsIn, SignsInByOtp)):
176
190
  return _login_required(url, adapter, user)
177
191
  session = accounts.session_file(mine, adapter.name, scope)
178
192
  accounts.sign_in(page, adapter, scope, *credentials, session)
179
193
  page.goto(url)
180
194
  return None
181
- command = login_command(adapter.name, user, url if adapter.per_employer else None)
182
- result.reason = _email_link(result.reason, credentials[0] if credentials else "", command)
195
+ email = credentials[0] if credentials else ""
196
+ scoped = url if adapter.per_employer else None
197
+ result.reason = _next_step(result.reason, email, adapter.name, user, scoped)
183
198
  return result
184
199
 
185
200
 
@@ -533,7 +548,9 @@ def login(
533
548
  creates the account with a password atsfill generates and stores first (a side effect),
534
549
  giving the sign-up the user's `details`; with a password already stored, the account may
535
550
  exist, so it signs in with that password and keeps the account's email instead, the
536
- `details` finishing a sign-up the account was left in."""
551
+ `details` finishing a sign-up the account was left in. An account without a password signs
552
+ in through the user's OTP command in place of a password source, and is created the same
553
+ way."""
537
554
  account, data = _account(adapter), paths.data_dir(data_dir)
538
555
  scope = _scope(account, url)
539
556
  mine = paths.user_dir(data, user)
@@ -543,8 +560,11 @@ def login(
543
560
  raise ValueError("creating an account needs --email")
544
561
  if details and not create:
545
562
  raise ValueError("--details goes with --create")
563
+ by_otp = isinstance(account, SignsInByOtp)
564
+ if password and by_otp:
565
+ raise ValueError(f"{adapter} signs in with an emailed OTP, not a password")
546
566
  try:
547
- kept = create and passwords.stored(mine, adapter, scope) is not None
567
+ kept = create and not by_otp and passwords.stored(mine, adapter, scope) is not None
548
568
  except passwords.PasswordError as e:
549
569
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
550
570
  return report
@@ -578,11 +598,8 @@ def login(
578
598
  )
579
599
  else:
580
600
  result = _headless(data, user, account, scope, credentials, session, details)
581
- command = login_command(adapter, user, url)
582
- report.status, report.reason = (
583
- result.status,
584
- _email_link(result.reason, credentials[0], command),
585
- )
601
+ report.status = result.status
602
+ report.reason = _next_step(result.reason, credentials[0], adapter, user, url)
586
603
  if report.reason is None:
587
604
  report.session = report.status = "signed_in"
588
605
  return report
@@ -599,8 +616,9 @@ def _create(
599
616
  ) -> SessionReport:
600
617
  """Creates the account: refused before the browser opens when `details` lack one the
601
618
  sign-up asks; the generated password is stored before the click, and deleted only on a
602
- refusal the page states before any account exists."""
603
- if not isinstance(account, CreatesAccount):
619
+ refusal the page states before any account exists. An account without a password gets
620
+ none."""
621
+ if not isinstance(account, (CreatesAccount, CreatesAccountByOtp)):
604
622
  report.status = Status.UNSUPPORTED
605
623
  report.reason = f"no_sign_up: atsfill cannot create a {account.name} account"
606
624
  return report
@@ -608,9 +626,10 @@ def _create(
608
626
  report.status = Status.REFUSED
609
627
  report.reason = f"sign_up_details: no {', '.join(missing)}"
610
628
  return report
611
- password = passwords.generate()
629
+ password = None if isinstance(account, CreatesAccountByOtp) else passwords.generate()
612
630
  try:
613
- passwords.store(mine, account.name, report.scope, password)
631
+ if password:
632
+ passwords.store(mine, account.name, report.scope, password)
614
633
  except passwords.PasswordError as e:
615
634
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
616
635
  return report
@@ -618,10 +637,14 @@ def _create(
618
637
  result = _headless(
619
638
  data, report.user, account, report.scope, credentials, report.path, details, create=True
620
639
  )
621
- command = login_command(account.name, report.user, url)
622
- report.status, report.reason = result.status, _email_link(result.reason, email, command)
623
- report.password = "stored"
624
- if result.status is Status.REFUSED and (result.reason or "").split(":")[0] in NO_ACCOUNT:
640
+ report.status = result.status
641
+ report.reason = _next_step(result.reason, email, account.name, report.user, url)
642
+ report.password = "stored" if password else "none"
643
+ if (
644
+ password
645
+ and result.status is Status.REFUSED
646
+ and (result.reason or "").split(":")[0] in NO_ACCOUNT
647
+ ):
625
648
  passwords.delete(mine, account.name, report.scope)
626
649
  report.password = "deleted"
627
650
  if report.reason is None:
@@ -641,7 +664,7 @@ def _headless(
641
664
  ) -> Result:
642
665
  """Signs in, or creates the account with `create`, through the adapter, an OTP it meets
643
666
  from the user's `otp-commands` entry: `ready` once the session is stored."""
644
- assert isinstance(account, SignsIn)
667
+ assert isinstance(account, (SignsIn, SignsInByOtp))
645
668
  url = account.login_url(scope)
646
669
 
647
670
  def run() -> Result:
@@ -121,7 +121,7 @@ class SessionReport:
121
121
  path: Path
122
122
  status: str | None = None # a login's outcome: signed_in | refused | blocked | unsupported
123
123
  reason: str | None = None # why a login stored nothing, `code` or `code: detail`
124
- password: str | None = None # after `--create`: stored | deleted, or kept
124
+ password: str | None = None # after `--create`: stored | deleted | kept, or none (OTP only)
125
125
 
126
126
 
127
127
  @dataclass
File without changes
File without changes