atsfill 0.0.4.dev125__tar.gz → 0.0.4.dev127__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 (55) hide show
  1. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/README.md +1 -1
  3. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/hrmdirect.py +12 -4
  6. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/join.py +12 -4
  7. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/successfactors.py +12 -4
  8. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/taleo.py +3 -3
  9. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/ukg.py +4 -4
  10. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/workday.py +11 -3
  11. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/wttj.py +4 -4
  12. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/cli.py +14 -3
  13. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/accounts.py +11 -6
  14. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/adapter.py +51 -4
  15. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/commands.py +18 -13
  16. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/LICENSE +0 -0
  17. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/__init__.py +0 -0
  18. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/__init__.py +0 -0
  19. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/ashby.py +0 -0
  20. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/bamboohr.py +0 -0
  21. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/breezy.py +0 -0
  22. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/dayforce.py +0 -0
  23. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/eightfold.py +0 -0
  24. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/greenhouse.py +0 -0
  25. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/gupy.py +0 -0
  26. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/hireology.py +0 -0
  27. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/hiringthing.py +0 -0
  28. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/jazzhr.py +0 -0
  29. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/lever.py +0 -0
  30. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/manatal.py +0 -0
  31. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/oracle.py +0 -0
  32. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/paycor.py +0 -0
  33. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/paylocity.py +0 -0
  34. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/phenom.py +0 -0
  36. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/pinpoint.py +0 -0
  37. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/recruitee.py +0 -0
  38. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/rippling.py +0 -0
  39. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/smartrecruiters.py +0 -0
  40. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/successfactors.js +0 -0
  41. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/teamtailor.py +0 -0
  42. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/workable.py +0 -0
  43. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/adapters/zohorecruit.py +0 -0
  44. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/__init__.py +0 -0
  45. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/attempts.py +0 -0
  46. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/browser.py +0 -0
  47. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/conditions.py +0 -0
  48. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/html.py +0 -0
  49. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/matching.py +0 -0
  50. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/passwords.py +0 -0
  51. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/paths.py +0 -0
  52. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/registry.py +0 -0
  53. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/core/results.py +0 -0
  54. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/src/atsfill/py.typed +0 -0
  55. {atsfill-0.0.4.dev125 → atsfill-0.0.4.dev127}/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.dev125
3
+ Version: 0.0.4.dev127
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
- Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) 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`.
140
+ 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
141
 
142
142
  ## How it works
143
143
 
@@ -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
- Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) 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`.
112
+ 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
113
 
114
114
  ## How it works
115
115
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev125"
3
+ version = "0.0.4.dev127"
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.dev125"
3
+ version = "0.0.4.dev127"
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"
@@ -13,7 +13,15 @@ import urllib.parse
13
13
  from typing import Any
14
14
  from urllib.parse import parse_qs, urlsplit
15
15
 
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
16
+ from atsfill.core.adapter import (
17
+ Blocked,
18
+ Gap,
19
+ NotTaken,
20
+ Refused,
21
+ SignUpDetails,
22
+ Unsupported,
23
+ known_gap,
24
+ )
17
25
  from atsfill.core.browser import Locator, Page, Request, Timeout
18
26
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
27
 
@@ -88,7 +96,7 @@ _FIELDS = """form => [...form.querySelectorAll('.form-field')].flatMap(f => {
88
96
  class HRMDirect:
89
97
  name = "hrmdirect"
90
98
  per_employer = True
91
- asks_name = True
99
+ asks = {"name"}
92
100
  known_gaps = {"screening_first": Gap.FLOW, "sign_up_phone": Gap.FLOW}
93
101
  side_effects = [
94
102
  SideEffect(
@@ -174,7 +182,7 @@ class HRMDirect:
174
182
  except Timeout:
175
183
  raise Refused("wrong_password") from None
176
184
 
177
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
185
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
178
186
  """Fills the apply page's first step, which creates the account signed in and sets no
179
187
  password: the email that follows holds a link to set one, `blocked: email_link`."""
180
188
  _to_apply(page)
@@ -189,7 +197,7 @@ class HRMDirect:
189
197
  )
190
198
  if page.locator(RECAPTCHA).count():
191
199
  raise Blocked("captcha: a reCAPTCHA box keeps the sign-up's Continue disabled")
192
- first, _, last = (name or "").strip().partition(" ")
200
+ first, _, last = (details.name or "").strip().partition(" ")
193
201
  form = page.locator(IDENTITY)
194
202
  form.locator("input.first-name").fill(first)
195
203
  form.locator("input.last-name").fill(last or first)
@@ -13,7 +13,15 @@ from typing import Any
13
13
  from urllib.parse import urlsplit
14
14
 
15
15
  from atsfill.core import browser
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
16
+ from atsfill.core.adapter import (
17
+ Blocked,
18
+ Gap,
19
+ NotTaken,
20
+ Refused,
21
+ SignUpDetails,
22
+ Unsupported,
23
+ known_gap,
24
+ )
17
25
  from atsfill.core.browser import Locator, Page, Request, Response, Timeout
18
26
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
27
 
@@ -66,7 +74,7 @@ _REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
66
74
  class Join:
67
75
  name = "join"
68
76
  per_employer = False
69
- asks_name = True # the account's first and last name
77
+ asks = {"name"} # the account's first and last name
70
78
  known_gaps = {"profile_incomplete": Gap.FLOW}
71
79
  side_effects = [
72
80
  SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
@@ -110,10 +118,10 @@ class Join:
110
118
  if urlsplit(page.url).path == UNVERIFIED:
111
119
  raise Blocked("email_link: the account is not confirmed")
112
120
 
113
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
121
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
114
122
  """Creates the account, signed in, then waits for its confirmation link: until it is
115
123
  clicked the wizard does not open."""
116
- first, _, last = (name or "").strip().rpartition(" ")
124
+ first, _, last = (details.name or "").strip().rpartition(" ")
117
125
  if not first or not last:
118
126
  raise Unsupported("unhandled_rendering: the sign-up asks a first and a last name")
119
127
  if _email_step(page, email):
@@ -13,7 +13,15 @@ from pathlib import Path
13
13
  from typing import Any
14
14
  from urllib.parse import urlsplit
15
15
 
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
16
+ from atsfill.core.adapter import (
17
+ Blocked,
18
+ Gap,
19
+ NotTaken,
20
+ Refused,
21
+ SignUpDetails,
22
+ Unsupported,
23
+ known_gap,
24
+ )
17
25
  from atsfill.core.browser import Locator, Page, Request, Timeout
18
26
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
27
 
@@ -81,7 +89,7 @@ def _company(request: Request) -> list[tuple[str, str]]:
81
89
  class SuccessFactors:
82
90
  name = "successfactors"
83
91
  per_employer = True
84
- asks_name = True
92
+ asks = {"name"}
85
93
  known_gaps = {"quick_apply_sign_up": Gap.FLOW, "sign_up_fields": Gap.FLOW}
86
94
  side_effects = [
87
95
  SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
@@ -137,7 +145,7 @@ class SuccessFactors:
137
145
  raise Blocked(f"email_link: {message}") from None
138
146
  raise Refused(f"wrong_password: {message}") from None
139
147
 
140
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
148
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
141
149
  self._candidate(page)
142
150
  if page.locator(SIGN_IN).count():
143
151
  page.locator(REGISTER).first.click()
@@ -152,7 +160,7 @@ class SuccessFactors:
152
160
  ) from None
153
161
  register.first.click()
154
162
  page.locator(SIGN_UP).wait_for(timeout=LANDING_TIMEOUT_MS)
155
- first, _, last = (name or "").strip().partition(" ")
163
+ first, _, last = (details.name or "").strip().partition(" ")
156
164
  for selector, value in [
157
165
  (SIGN_UP, email),
158
166
  ("#fbclc_emailConf", email),
@@ -15,7 +15,7 @@ from dataclasses import dataclass
15
15
  from typing import Any
16
16
  from urllib.parse import parse_qs, quote, urlsplit
17
17
 
18
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
18
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, SignUpDetails, Unsupported
19
19
  from atsfill.core.browser import Page, Request, Timeout
20
20
  from atsfill.core.results import Answer, AnyOf, Field, FieldType, Is, OptionSet, SideEffect
21
21
 
@@ -209,7 +209,7 @@ def posting(url: str) -> Posting | None:
209
209
  class Taleo:
210
210
  name = "taleo"
211
211
  per_employer = True
212
- asks_name = False
212
+ asks: set[str] = set()
213
213
  known_gaps = {LOCATION_GAP: Gap.RENDERING, DATE_PICKER_GAP: Gap.RENDERING}
214
214
  side_effects = [
215
215
  SideEffect("sign_up", "employer", "the account is created in the employer's zone"),
@@ -264,7 +264,7 @@ class Taleo:
264
264
  page.locator(f"#{FORM_ID}-login-defaultCmd").click()
265
265
  _left_sign_in(page)
266
266
 
267
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
267
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
268
268
  page.locator(f"#{FORM_ID}-login-register").click()
269
269
  page.wait_for_load_state()
270
270
  accept = page.locator(f"#{FORM_ID}-StatementBeforeAuthentificationContent-ContinueButton")
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from typing import Any
14
14
  from urllib.parse import parse_qs, quote, urlsplit
15
15
 
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
16
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, SignUpDetails, Unsupported
17
17
  from atsfill.core.browser import Locator, Page, Request, Timeout
18
18
  from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
19
19
 
@@ -218,7 +218,7 @@ def board(url: str) -> Board | None:
218
218
  class UKG:
219
219
  name = "ukg"
220
220
  per_employer = True
221
- asks_name = True
221
+ asks = {"name"}
222
222
  known_gaps = {PHONE_GAP: Gap.FLOW}
223
223
  side_effects = [
224
224
  SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
@@ -275,7 +275,7 @@ class UKG:
275
275
  if "/AuthCode/Register" in page.url:
276
276
  raise Unsupported("unhandled_rendering: the tenant asks the account's name")
277
277
 
278
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
278
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
279
279
  page.locator("a[href*='/u/signup']").first.click()
280
280
  page.locator("input#email").wait_for(timeout=FORM_TIMEOUT_MS)
281
281
  page.locator("input#email").fill(email)
@@ -284,7 +284,7 @@ class UKG:
284
284
  _left_sign_in(page)
285
285
  if "/AuthCode/Register" not in page.url:
286
286
  return
287
- _register(page, name or "")
287
+ _register(page, details.name or "")
288
288
 
289
289
  def browser_read(self, page: Page) -> list[Field]:
290
290
  found = board(page.url)
@@ -17,7 +17,15 @@ from typing import Any
17
17
  from urllib.parse import urlsplit
18
18
 
19
19
  from atsfill.core import matching
20
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
20
+ from atsfill.core.adapter import (
21
+ Blocked,
22
+ Gap,
23
+ NotTaken,
24
+ Refused,
25
+ SignUpDetails,
26
+ Unsupported,
27
+ known_gap,
28
+ )
21
29
  from atsfill.core.browser import Locator, Page, PageRequest, Request, Response, Timeout
22
30
  from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
23
31
 
@@ -187,7 +195,7 @@ def _profile_hosts(request: Request, scope: str | None) -> list[str]:
187
195
  class Workday:
188
196
  name = "workday"
189
197
  per_employer = True
190
- asks_name = False
198
+ asks: set[str] = set()
191
199
  known_gaps = {
192
200
  "experience_entries": Gap.FLOW,
193
201
  "long_wizard": Gap.FLOW,
@@ -244,7 +252,7 @@ class Workday:
244
252
  _email_form(page)
245
253
  _submit(page, "signInSubmitButton", email, password)
246
254
 
247
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
255
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
248
256
  _email_form(page)
249
257
  page.locator("[data-automation-id=createAccountLink]").click()
250
258
  page.locator("[data-automation-id=verifyPassword]").fill(password)
@@ -12,7 +12,7 @@ from typing import Any
12
12
  from urllib.parse import urlsplit
13
13
 
14
14
  from atsfill.core import browser, matching
15
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
15
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, SignUpDetails, Unsupported
16
16
  from atsfill.core.browser import Locator, Page, PageRequest, Request, Response, Timeout
17
17
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
18
 
@@ -64,7 +64,7 @@ _READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
64
64
  class WelcomeToTheJungle:
65
65
  name = "wttj"
66
66
  per_employer = False
67
- asks_name = True # the profile's first name
67
+ asks = {"name"} # the profile's first name
68
68
  known_gaps: dict[str, Gap] = {}
69
69
  side_effects = [
70
70
  SideEffect("sign_up", "hiring_system", "login --create creates the account"),
@@ -98,11 +98,11 @@ class WelcomeToTheJungle:
98
98
  def sign_in(self, page: Page, email: str, password: str) -> None:
99
99
  _account_form(page, "sign-in-form", {"email": email, "password": password}, "/sessions")
100
100
 
101
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
101
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
102
102
  """Creates the account, signed in, then waits for its confirmation link: clicking it
103
103
  signs every session out."""
104
104
  page.goto(SIGN_UP)
105
- values = {"firstname": name or "", "email": email, "password": password}
105
+ values = {"firstname": details.name or "", "email": email, "password": password}
106
106
  _account_form(page, "sign-up-form", values, "/registrations")
107
107
  raise Blocked("email_link: the sign-up sent a confirmation link")
108
108
 
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from atsfill import core
8
8
  from atsfill.adapters import adapters
9
9
  from atsfill.core import browser, paths
10
- from atsfill.core.adapter import NeedsAccount
10
+ from atsfill.core.adapter import NeedsAccount, SignUpDetails
11
11
  from atsfill.core.results import Result, SessionReport, Status, StatusReport
12
12
 
13
13
  OK = {Status.READY, Status.SUBMITTED}
@@ -105,7 +105,11 @@ def parser() -> argparse.ArgumentParser:
105
105
  action="store_true",
106
106
  help="create the account with --email and a password atsfill generates and stores",
107
107
  )
108
- login.add_argument("--name", help="the user's name, for a sign-up that asks it")
108
+ login.add_argument(
109
+ "--details",
110
+ type=details_file,
111
+ help="a JSON file of the user's sign-up details (name, phone, address), for --create",
112
+ )
109
113
  for name in ("read", "fill", "send", "status", "login", "logout"):
110
114
  sub.choices[name].add_argument(
111
115
  "-v", "--verbose", action="store_true", help="the whole result, every key, indented"
@@ -123,6 +127,13 @@ def answers_file(path: str) -> dict:
123
127
  return answers
124
128
 
125
129
 
130
+ def details_file(path: str) -> SignUpDetails:
131
+ try:
132
+ return SignUpDetails.parse(json.loads(Path(path).read_text()))
133
+ except (OSError, ValueError) as e:
134
+ raise argparse.ArgumentTypeError(str(e)) from e
135
+
136
+
126
137
  def run(args: argparse.Namespace) -> int:
127
138
  try:
128
139
  paths.config(getattr(args, "user", paths.DEFAULT_USER))
@@ -171,7 +182,7 @@ def run(args: argparse.Namespace) -> int:
171
182
  password=password,
172
183
  window=args.window,
173
184
  create=args.create,
174
- name=args.name,
185
+ details=args.details,
175
186
  **where,
176
187
  )
177
188
  except ValueError as e:
@@ -6,7 +6,13 @@ import time
6
6
  from pathlib import Path
7
7
 
8
8
  from atsfill.core import browser
9
- from atsfill.core.adapter import CreatesAccount, NeedsAccount, SignsIn, Unsupported
9
+ from atsfill.core.adapter import (
10
+ CreatesAccount,
11
+ NeedsAccount,
12
+ SignsIn,
13
+ SignUpDetails,
14
+ Unsupported,
15
+ )
10
16
  from atsfill.core.browser import Page
11
17
  from atsfill.core.passwords import account_key
12
18
 
@@ -53,17 +59,16 @@ def sign_in(
53
59
  address: str,
54
60
  password: str,
55
61
  session: Path,
56
- create: bool = False,
57
- name: str | None = None,
62
+ details: SignUpDetails | None = None,
58
63
  ) -> None:
59
- """Signs in, or creates the account (with the user's `name` where it asks one), from the
64
+ """Signs in, or creates the account from the user's sign-up `details` when given, from the
60
65
  system's login page and stores the session.
61
66
  Raises what the adapter raises, without the password in it."""
62
67
  page.goto(adapter.login_url(scope))
63
68
  try:
64
- if create:
69
+ if details is not None:
65
70
  assert isinstance(adapter, CreatesAccount)
66
- adapter.sign_up(page, address, password, name)
71
+ adapter.sign_up(page, address, password, details)
67
72
  else:
68
73
  adapter.sign_in(page, address, password)
69
74
  except Exception as e:
@@ -1,5 +1,7 @@
1
+ import re
2
+ from dataclasses import dataclass, fields
1
3
  from enum import StrEnum
2
- from typing import Protocol, runtime_checkable
4
+ from typing import Protocol, Self, runtime_checkable
3
5
 
4
6
  from atsfill.core.browser import Page, Request
5
7
  from atsfill.core.results import Answer, Field, SideEffect
@@ -123,16 +125,61 @@ class SignsIn(NeedsAccount, Protocol):
123
125
  ...
124
126
 
125
127
 
128
+ ADDRESS = ("street", "city", "state", "zip", "country")
129
+ PHONE = re.compile(r"\+[1-9][0-9]{6,14}") # E.164
130
+ COUNTRY = re.compile(r"[A-Z]{2}") # ISO 3166 alpha-2
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class SignUpDetails:
135
+ """What a sign-up asks of the user besides the email and the password, from the file
136
+ `login --create --details` takes (ADR 0015). atsfill stores none of it."""
137
+
138
+ name: str | None = None
139
+ phone: str | None = None
140
+ street: str | None = None
141
+ city: str | None = None
142
+ state: str | None = None
143
+ zip: str | None = None
144
+ country: str | None = None
145
+
146
+ @classmethod
147
+ def parse(cls, data: object) -> Self:
148
+ """From `{"name", "phone", "address": {"street", "city", "state", "zip", "country"}}`,
149
+ every key optional. Raises `ValueError` naming what is wrong."""
150
+ if not isinstance(data, dict):
151
+ raise ValueError("sign-up details: expected a JSON object")
152
+ address = data.get("address", {})
153
+ if not isinstance(address, dict):
154
+ raise ValueError("sign-up details: address: expected a JSON object")
155
+ if unknown := (data.keys() - {"name", "phone", "address"}) | (address.keys() - {*ADDRESS}):
156
+ raise ValueError(f"sign-up details: unknown keys {', '.join(sorted(unknown))}")
157
+ flat = {k: v for k, v in data.items() if k != "address"} | address
158
+ for key, value in flat.items():
159
+ if not isinstance(value, str) or not value.strip():
160
+ raise ValueError(f"sign-up details: {key}: expected a non-empty string")
161
+ if "phone" in flat and not PHONE.fullmatch(flat["phone"]):
162
+ raise ValueError("sign-up details: phone: expected E.164, as +15551234567")
163
+ if "country" in flat and not COUNTRY.fullmatch(flat["country"]):
164
+ raise ValueError("sign-up details: country: expected ISO 3166 alpha-2, as US")
165
+ return cls(**flat)
166
+
167
+ def missing(self, asks: set[str]) -> list[str]:
168
+ """The details `asks` names that these lack, in the file's order."""
169
+ return [f.name for f in fields(self) if f.name in asks and getattr(self, f.name) is None]
170
+
171
+
126
172
  @runtime_checkable
127
173
  class CreatesAccount(SignsIn, Protocol):
128
174
  """An adapter atsfill can create an account through, where the system's sign-up allows it.
129
175
  Account creation is a side effect it declares (ADR 0008)."""
130
176
 
131
- asks_name: bool # the sign-up asks the user's name
177
+ asks: set[str] # the `SignUpDetails` fields its sign-up requires
132
178
 
133
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
179
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
134
180
  """Creates the account from the page `login_url` loaded, signed in once it returns.
135
- Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed."""
181
+ Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed. The
182
+ details in `asks` are given; optional consents the sign-up offers stay declined."""
136
183
  ...
137
184
 
138
185
 
@@ -25,6 +25,7 @@ from atsfill.core.adapter import (
25
25
  ReadsByRequest,
26
26
  Refused,
27
27
  SignsIn,
28
+ SignUpDetails,
28
29
  Unsupported,
29
30
  )
30
31
  from atsfill.core.browser import OutOfTime, Overlaid, Page, open_page
@@ -481,7 +482,7 @@ def login(
481
482
  password: str | None = None,
482
483
  window: bool = False,
483
484
  create: bool = False,
484
- name: str | None = None,
485
+ details: SignUpDetails | None = None,
485
486
  data_dir: Path | None = None,
486
487
  ) -> SessionReport:
487
488
  """Signs in with the account's password source, headless, and stores the session; without
@@ -489,7 +490,7 @@ def login(
489
490
 
490
491
  `email` is remembered for the account; `password` is used for this sign-in only. `create`
491
492
  creates the account with a password atsfill generates and stores first (a side effect),
492
- giving `name` where the sign-up asks it; with a password already stored, the account may
493
+ giving the sign-up the user's `details`; with a password already stored, the account may
493
494
  exist, so it signs in with that password and keeps the account's email instead."""
494
495
  account, data = _account(adapter), paths.data_dir(data_dir)
495
496
  scope = _scope(account, url)
@@ -498,6 +499,8 @@ def login(
498
499
  report = SessionReport(user, adapter, scope, "missing", session)
499
500
  if create and not email:
500
501
  raise ValueError("creating an account needs --email")
502
+ if details and not create:
503
+ raise ValueError("--details goes with --create")
501
504
  try:
502
505
  kept = create and passwords.stored(mine, adapter, scope) is not None
503
506
  except passwords.PasswordError as e:
@@ -507,7 +510,7 @@ def login(
507
510
  accounts.remember(mine, adapter, scope, email)
508
511
  if create and not kept:
509
512
  assert email
510
- return _create(report, account, url, mine, (email, name), data)
513
+ return _create(report, account, url, mine, email, details or SignUpDetails(), data)
511
514
  if kept:
512
515
  report.password = "kept"
513
516
  if password and not (isinstance(account, SignsIn) and accounts.email(mine, adapter, scope)):
@@ -539,25 +542,28 @@ def _create(
539
542
  account: NeedsAccount,
540
543
  url: str | None,
541
544
  mine: Path,
542
- who: tuple[str, str | None],
545
+ email: str,
546
+ details: SignUpDetails,
543
547
  data: Path,
544
548
  ) -> SessionReport:
545
- """Creates the account: the generated password is stored before the click, and deleted
546
- only on a refusal the page states before any account exists."""
549
+ """Creates the account: refused before the browser opens when `details` lack one the
550
+ sign-up asks; the generated password is stored before the click, and deleted only on a
551
+ refusal the page states before any account exists."""
547
552
  if not isinstance(account, CreatesAccount):
548
553
  report.status = Status.UNSUPPORTED
549
554
  report.reason = f"no_sign_up: atsfill cannot create a {account.name} account"
550
555
  return report
551
- email, name = who
552
- if account.asks_name and not name:
553
- raise ValueError(f"creating a {account.name} account needs --name")
556
+ if missing := details.missing(account.asks):
557
+ report.status = Status.REFUSED
558
+ report.reason = f"sign_up_details: no {', '.join(missing)}"
559
+ return report
554
560
  password = passwords.generate()
555
561
  try:
556
562
  passwords.store(mine, account.name, report.scope, password)
557
563
  except passwords.PasswordError as e:
558
564
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
559
565
  return report
560
- result = _headless(data, account, report.scope, (email, password), report.path, True, name)
566
+ result = _headless(data, account, report.scope, (email, password), report.path, details)
561
567
  command = login_command(account.name, report.user, url)
562
568
  report.status, report.reason = result.status, _email_link(result.reason, email, command)
563
569
  report.password = "stored"
@@ -575,8 +581,7 @@ def _headless(
575
581
  scope: str | None,
576
582
  credentials: Credentials,
577
583
  session: Path,
578
- create: bool = False,
579
- name: str | None = None,
584
+ details: SignUpDetails | None = None,
580
585
  ) -> Result:
581
586
  """Signs in, or creates the account, through the adapter: `ready` once the session is
582
587
  stored."""
@@ -586,7 +591,7 @@ def _headless(
586
591
  def run(_: list[str]) -> Result:
587
592
  result = Result(Status.READY, url, account.name)
588
593
  with open_page(data, None) as page, _stops(result):
589
- accounts.sign_in(page, account, scope, *credentials, session, create, name)
594
+ accounts.sign_in(page, account, scope, *credentials, session, details)
590
595
  return result
591
596
 
592
597
  return _in_budget(url, run)
File without changes