atsfill 0.0.4.dev130__tar.gz → 0.0.4.dev133__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 (56) hide show
  1. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/isolvedhire.py +2 -1
  5. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/ukg.py +82 -18
  6. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/accounts.py +9 -4
  7. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/adapter.py +11 -0
  8. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/commands.py +18 -6
  9. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/LICENSE +0 -0
  10. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/README.md +0 -0
  11. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/__init__.py +0 -0
  13. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/bamboohr.py +0 -0
  15. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/breezy.py +0 -0
  16. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/dayforce.py +0 -0
  17. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/eightfold.py +0 -0
  18. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/greenhouse.py +0 -0
  19. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/gupy.py +0 -0
  20. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/hireology.py +0 -0
  21. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/hiringthing.py +0 -0
  22. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/hrmdirect.py +0 -0
  23. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/jazzhr.py +0 -0
  24. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/join.py +0 -0
  25. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/lever.py +0 -0
  26. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/manatal.py +0 -0
  27. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/oracle.py +0 -0
  28. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/paycor.py +0 -0
  29. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/paylocity.py +0 -0
  30. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/personio.py +0 -0
  31. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/phenom.py +0 -0
  32. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/pinpoint.py +0 -0
  33. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/recruitee.py +0 -0
  34. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/rippling.py +0 -0
  35. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/smartrecruiters.py +0 -0
  36. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/successfactors.js +0 -0
  37. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/successfactors.py +0 -0
  38. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/taleo.py +0 -0
  39. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/teamtailor.py +0 -0
  40. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/workable.py +0 -0
  41. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/workday.py +0 -0
  42. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/wttj.py +0 -0
  43. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/adapters/zohorecruit.py +0 -0
  44. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/cli.py +0 -0
  45. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/__init__.py +0 -0
  46. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/attempts.py +0 -0
  47. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/browser.py +0 -0
  48. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/conditions.py +0 -0
  49. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/html.py +0 -0
  50. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/matching.py +0 -0
  51. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/passwords.py +0 -0
  52. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/paths.py +0 -0
  53. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/registry.py +0 -0
  54. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/core/results.py +0 -0
  55. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/src/atsfill/py.typed +0 -0
  56. {atsfill-0.0.4.dev130 → atsfill-0.0.4.dev133}/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.dev130
3
+ Version: 0.0.4.dev133
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.4.dev130"
3
+ version = "0.0.4.dev133"
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.dev130"
3
+ version = "0.0.4.dev133"
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"
@@ -239,7 +239,8 @@ class IsolvedHire:
239
239
  if country not in _texts(form.locator("#country_id option")):
240
240
  raise Refused(f"sign_up_details: no country {country} in the sign-up's list")
241
241
  form.locator("#country_id").select_option(label=country)
242
- state = form.locator("#state_id option", has_text=re.compile(rf"^\s*{details.state}\s*$"))
242
+ named = re.compile(rf"^\s*{re.escape(details.state or '')}\s*$", re.I)
243
+ state = form.locator("#state_id option", has_text=named)
243
244
  try:
244
245
  state.first.wait_for(state="attached", timeout=LIST_TIMEOUT_MS)
245
246
  except Timeout:
@@ -35,6 +35,8 @@ SETTLE_MS = 500
35
35
  YES_NO = ["Yes", "No"]
36
36
  DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
37
37
  COUNTRY, STATE = "Country", "State"
38
+ HISPANIC = "HispanicOrigin" # race (`EthnicOrigin`) is asked when not Hispanic or Latino
39
+ DECLINE_BOX = "input[type=checkbox][data-automation$='-decline-checkbox']"
38
40
  DAYS, DOCUMENTS = "availability_days", "documents"
39
41
 
40
42
  # Auth0's error codes on the sign-in and sign-up pages, by what atsfill raises
@@ -66,10 +68,25 @@ _READ = """root => {
66
68
  return el.closest(".form-group")?.querySelector("label");
67
69
  };
68
70
  const required = (el, label) => el.required || !!label?.classList.contains("required");
71
+ const select = (el, field) => {
72
+ field.kind = "select";
73
+ field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
74
+ field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
75
+ const box = el.closest(".form-group")?.querySelector(
76
+ "input[type=checkbox][data-automation$='-decline-checkbox']");
77
+ if (box) {
78
+ field.decline = clean(box.closest("label")?.querySelector("span:not(.sr-only)")
79
+ ?.textContent);
80
+ field.options.push(field.decline);
81
+ if (box.checked) field.value = field.decline;
82
+ }
83
+ return field;
84
+ };
69
85
  const out = [], seen = new Set();
70
86
  for (const el of root.querySelectorAll("input, select, textarea")) {
71
87
  const type = (el.type || "").toLowerCase();
72
88
  if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
89
+ if (el.matches("[data-automation$='-decline-checkbox']")) continue; // its select's option
73
90
  if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
74
91
  const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
75
92
  if (!id || seen.has(id)) continue;
@@ -79,11 +96,8 @@ _READ = """root => {
79
96
  kind: type, options: [], value: el.value,
80
97
  readonly: el.readOnly || el.disabled, maxLength: el.maxLength > 0 ?
81
98
  el.maxLength : null};
82
- if (el.tagName === "SELECT") {
83
- field.kind = "select";
84
- field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
85
- field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
86
- } else if (el.tagName === "TEXTAREA") field.kind = "text";
99
+ if (el.tagName === "SELECT") select(el, field);
100
+ else if (el.tagName === "TEXTAREA") field.kind = "text";
87
101
  else if (type === "radio") {
88
102
  const all = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)];
89
103
  field.options = all.map(r => clean(r.closest("label")?.textContent));
@@ -109,6 +123,17 @@ _READ = """root => {
109
123
  !!label.classList?.contains("required"),
110
124
  options: [], value: box.getAttribute("value") || ""});
111
125
  }
126
+ const race = root.querySelector("select#EthnicOrigin");
127
+ const hispanic = root.querySelector("select#HispanicOrigin");
128
+ if (race && hispanic && out.some(f => f.id === "HispanicOrigin")) {
129
+ const kept = out.findIndex(f => f.id === "EthnicOrigin");
130
+ if (kept >= 0) out.splice(kept, 1);
131
+ const label = labelOf(race);
132
+ out.splice(out.findIndex(f => f.id === "HispanicOrigin") + 1, 0, select(race, {
133
+ id: race.id, label: clean(label?.textContent), required: required(race, label),
134
+ readonly: false, maxLength: null,
135
+ shownWhen: clean([...hispanic.options].find(o => o.value === "No")?.textContent)}));
136
+ }
112
137
  const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
113
138
  if (days.length) {
114
139
  const label = document.getElementById("AvailabilityDaysQuestion");
@@ -151,11 +176,19 @@ _READ_BACK = """(root, kinds) => {
151
176
  .find(r => r.checked);
152
177
  out[id] = checked ? clean(checked.closest("label")?.textContent) : null;
153
178
  } else {
154
- const el = [...root.querySelectorAll(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`)]
155
- .find(shown);
179
+ const named = CSS.escape(id);
180
+ const el = [...root.querySelectorAll(
181
+ `[id="${named}"], [name="${named}"], [data-automation="${named}"]`)].find(shown);
156
182
  if (!el) continue;
157
- if (kind === "select") out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
158
- else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
183
+ const box = el.closest(".form-group")?.querySelector(
184
+ "input[type=checkbox][data-automation$='-decline-checkbox']");
185
+ if (kind === "select" && box?.checked) {
186
+ out[id] = clean(box.closest("label")?.querySelector("span:not(.sr-only)")?.textContent);
187
+ } else if (kind === "select") {
188
+ out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
189
+ } else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
190
+ // a Numeric question's autoNumeric input groups its number: 50,000
191
+ else if (el.dataset.automation === "numeric-response") out[id] = el.value.replace(/,/g, "");
159
192
  else out[id] = el.value;
160
193
  }
161
194
  }
@@ -228,6 +261,7 @@ class UKG:
228
261
  def __init__(self) -> None:
229
262
  self._boards: dict[str, Board] = {} # by tenant, the last posting's
230
263
  self._kinds: dict[str, str] = {}
264
+ self._declines: dict[str, str] = {} # a select's decline box's label, by the select
231
265
 
232
266
  def detect(self, url: str, page: Page | None) -> bool:
233
267
  return board(url) is not None
@@ -266,13 +300,18 @@ class UKG:
266
300
  ) # fmt: skip
267
301
 
268
302
  def sign_in(self, page: Page, email: str, password: str) -> None:
269
- page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
270
- page.locator("input#username").fill(email)
271
- page.locator("input#password").fill(password)
272
- page.locator("button[type=submit][name=action][data-action-button-primary]").click()
273
- _left_sign_in(page)
303
+ _sign_in(page, email, password)
274
304
  if "/AuthCode/Register" in page.url:
275
- raise Unsupported("unhandled_rendering: the tenant asks the account's name")
305
+ raise Refused(
306
+ "sign_up_details: the account was left at the tenant's details step: "
307
+ "`login --create --details` finishes it"
308
+ )
309
+
310
+ def finish_sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
311
+ """Signs in and, where the account was left without its tenant details, gives them."""
312
+ _sign_in(page, email, password)
313
+ if "/AuthCode/Register" in page.url:
314
+ _register(page, details)
276
315
 
277
316
  def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
278
317
  page.locator("a[href*='/u/signup']").first.click()
@@ -302,7 +341,9 @@ class UKG:
302
341
  raws = page.locator(FORM).evaluate(_READ)
303
342
  required_resume = '"IsResumeRequired":true' in page.content()
304
343
  states = None
305
- if page.locator(f"{FORM} select#{STATE}").count():
344
+ # with the address optional, the contact information is the account's, a read-only
345
+ # panel whose Country and State are in a closed edit dialog
346
+ if page.locator(f"{FORM} select#{STATE}").count() and any(r["id"] == COUNTRY for r in raws):
306
347
  states = page.locator(FORM).evaluate(_STATES, f"{found.base}/Candidate/GetStates")
307
348
  if not any(r["id"] == STATE for r in raws): # shown once a country is picked
308
349
  label = page.locator(f"{FORM} label[for={STATE}]").first
@@ -313,6 +354,7 @@ class UKG:
313
354
  "required": "required" in (label.get_attribute("class") or ""),
314
355
  }) # fmt: skip
315
356
  self._kinds = {raw["id"]: raw["kind"] for raw in raws}
357
+ self._declines = {raw["id"]: raw["decline"] for raw in raws if raw.get("decline")}
316
358
  fields = []
317
359
  for raw in raws:
318
360
  if raw["id"] == DOCUMENTS:
@@ -328,6 +370,11 @@ class UKG:
328
370
  values = [answer] if isinstance(answer, str) else answer
329
371
  root = page.locator(FORM)
330
372
  match kind:
373
+ case "select" if field_id in self._declines:
374
+ declined = values[0] == self._declines[field_id]
375
+ _decline_box(root, field_id).set_checked(declined)
376
+ if not declined:
377
+ _control(root, field_id).select_option(label=values[0])
331
378
  case "select":
332
379
  control = _control(root, field_id)
333
380
  control.select_option(label=values[0])
@@ -366,6 +413,14 @@ class UKG:
366
413
  return page.url
367
414
 
368
415
 
416
+ def _sign_in(page: Page, email: str, password: str) -> None:
417
+ page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
418
+ page.locator("input#username").fill(email)
419
+ page.locator("input#password").fill(password)
420
+ page.locator("button[type=submit][name=action][data-action-button-primary]").click()
421
+ _left_sign_in(page)
422
+
423
+
369
424
  def _left_sign_in(page: Page) -> None:
370
425
  """Waits to leave the sign-in host, or for the error the page shows."""
371
426
  deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
@@ -441,12 +496,13 @@ def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
441
496
  return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
442
497
  unfillable=reason) # fmt: skip
443
498
  options = raw["options"] if kind in ("select", "radio") else None
499
+ shown_when = Is(HISPANIC, [raw["shownWhen"]]) if raw.get("shownWhen") else None
444
500
  if field_id == STATE and states is not None:
445
501
  sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
446
502
  return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
447
503
  return Field(
448
504
  field_id, label, KINDS[kind], raw["required"], options, raw.get("maxLength"),
449
- default=value if kind in ("date", "select") else None,
505
+ default=value if kind in ("date", "select") else None, shown_when=shown_when,
450
506
  ) # fmt: skip
451
507
 
452
508
 
@@ -459,7 +515,15 @@ def _wait_for_states(page: Page) -> None:
459
515
 
460
516
 
461
517
  def _control(root: Locator, field_id: str) -> Locator:
462
- return root.locator(f'[id="{field_id}"]:visible, [name="{field_id}"]:visible').first
518
+ named = f'[id="{field_id}"]:visible, [name="{field_id}"]:visible'
519
+ return root.locator(f'{named}, [data-automation="{field_id}"]:visible').first
520
+
521
+
522
+ def _decline_box(root: Locator, field_id: str) -> Locator:
523
+ """The "I decline to say" box under an equal-opportunity question's select: ticked, it
524
+ clears the select."""
525
+ group = "xpath=ancestor::div[contains(@class,'form-group')][1]"
526
+ return _control(root, field_id).locator(group).locator(DECLINE_BOX)
463
527
 
464
528
 
465
529
  def _option(root: Locator, name: str, text: str) -> Locator:
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from atsfill.core import browser
9
9
  from atsfill.core.adapter import (
10
10
  CreatesAccount,
11
+ FinishesSignUp,
11
12
  NeedsAccount,
12
13
  SignsIn,
13
14
  SignUpDetails,
@@ -60,15 +61,19 @@ def sign_in(
60
61
  password: str,
61
62
  session: Path,
62
63
  details: SignUpDetails | None = None,
64
+ create: bool = False,
63
65
  ) -> None:
64
- """Signs in, or creates the account from the user's sign-up `details` when given, from the
65
- system's login page and stores the session.
66
+ """Signs in from the system's login page and stores the session: with `create`, creates
67
+ the account from the user's sign-up `details`; without, `details` finish a sign-up the
68
+ account was left in, where the adapter knows that step.
66
69
  Raises what the adapter raises, without the password in it."""
67
70
  page.goto(adapter.login_url(scope))
68
71
  try:
69
- if details is not None:
70
- assert isinstance(adapter, CreatesAccount)
72
+ if create:
73
+ assert isinstance(adapter, CreatesAccount) and details is not None
71
74
  adapter.sign_up(page, address, password, details)
75
+ elif details is not None and isinstance(adapter, FinishesSignUp):
76
+ adapter.finish_sign_up(page, address, password, details)
72
77
  else:
73
78
  adapter.sign_in(page, address, password)
74
79
  except Exception as e:
@@ -183,6 +183,17 @@ class CreatesAccount(SignsIn, Protocol):
183
183
  ...
184
184
 
185
185
 
186
+ @runtime_checkable
187
+ class FinishesSignUp(CreatesAccount, Protocol):
188
+ """An adapter whose system can keep an account half made: signed up, but a step the
189
+ sign-up shows after it left unfinished, which every sign-in then shows again."""
190
+
191
+ def finish_sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
192
+ """Signs in as `sign_in` does and, where the system shows the unfinished step, gives it
193
+ `details` as `sign_up` would."""
194
+ ...
195
+
196
+
186
197
  @runtime_checkable
187
198
  class ReadsByRequest(Adapter, Protocol):
188
199
  """An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
@@ -491,7 +491,8 @@ def login(
491
491
  `email` is remembered for the account; `password` is used for this sign-in only. `create`
492
492
  creates the account with a password atsfill generates and stores first (a side effect),
493
493
  giving the sign-up the user's `details`; with a password already stored, the account may
494
- exist, so it signs in with that password and keeps the account's email instead."""
494
+ exist, so it signs in with that password and keeps the account's email instead, the
495
+ `details` finishing a sign-up the account was left in."""
495
496
  account, data = _account(adapter), paths.data_dir(data_dir)
496
497
  scope = _scope(account, url)
497
498
  mine = paths.user_dir(data, user)
@@ -513,6 +514,15 @@ def login(
513
514
  return _create(report, account, url, mine, email, details or SignUpDetails(), data)
514
515
  if kept:
515
516
  report.password = "kept"
517
+ # the details finish a sign-up the account may have been left in
518
+ if (
519
+ details
520
+ and isinstance(account, CreatesAccount)
521
+ and (missing := details.missing(account.asks))
522
+ ):
523
+ report.status = Status.REFUSED
524
+ report.reason = f"sign_up_details: no {', '.join(missing)}"
525
+ return report
516
526
  if password and not (isinstance(account, SignsIn) and accounts.email(mine, adapter, scope)):
517
527
  raise ValueError(f"signing in to {adapter} with a password needs --email")
518
528
  try:
@@ -526,7 +536,7 @@ def login(
526
536
  accounts.wait_for_person(account, scope, data, session),
527
537
  )
528
538
  else:
529
- result = _headless(data, account, scope, credentials, session)
539
+ result = _headless(data, account, scope, credentials, session, details)
530
540
  command = login_command(adapter, user, url)
531
541
  report.status, report.reason = (
532
542
  result.status,
@@ -563,7 +573,8 @@ def _create(
563
573
  except passwords.PasswordError as e:
564
574
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
565
575
  return report
566
- result = _headless(data, account, report.scope, (email, password), report.path, details)
576
+ credentials = (email, password)
577
+ result = _headless(data, account, report.scope, credentials, report.path, details, create=True)
567
578
  command = login_command(account.name, report.user, url)
568
579
  report.status, report.reason = result.status, _email_link(result.reason, email, command)
569
580
  report.password = "stored"
@@ -582,16 +593,17 @@ def _headless(
582
593
  credentials: Credentials,
583
594
  session: Path,
584
595
  details: SignUpDetails | None = None,
596
+ create: bool = False,
585
597
  ) -> Result:
586
- """Signs in, or creates the account, through the adapter: `ready` once the session is
587
- stored."""
598
+ """Signs in, or creates the account with `create`, through the adapter: `ready` once the
599
+ session is stored."""
588
600
  assert isinstance(account, SignsIn)
589
601
  url = account.login_url(scope)
590
602
 
591
603
  def run(_: list[str]) -> Result:
592
604
  result = Result(Status.READY, url, account.name)
593
605
  with open_page(data, None) as page, _stops(result):
594
- accounts.sign_in(page, account, scope, *credentials, session, details)
606
+ accounts.sign_in(page, account, scope, *credentials, session, details, create)
595
607
  return result
596
608
 
597
609
  return _in_budget(url, run)
File without changes
File without changes