atsfill 0.0.4.dev132__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.
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/ukg.py +82 -18
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/accounts.py +9 -4
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/adapter.py +11 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/commands.py +18 -6
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/LICENSE +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/README.md +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev133}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev132 → 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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|