atsfill 0.0.4.dev126__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.
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/README.md +1 -1
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/hrmdirect.py +12 -4
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/join.py +12 -4
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/successfactors.py +12 -4
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/taleo.py +3 -3
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/ukg.py +4 -4
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/workday.py +11 -3
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/wttj.py +4 -4
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/cli.py +14 -3
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/accounts.py +11 -6
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/adapter.py +51 -4
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/commands.py +18 -13
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/LICENSE +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev127}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev126 → 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.
|
|
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 `--
|
|
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 `--
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
name: str | None = None,
|
|
62
|
+
details: SignUpDetails | None = None,
|
|
58
63
|
) -> None:
|
|
59
|
-
"""Signs in, or creates the account
|
|
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
|
|
69
|
+
if details is not None:
|
|
65
70
|
assert isinstance(adapter, CreatesAccount)
|
|
66
|
-
adapter.sign_up(page, address, password,
|
|
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
|
-
|
|
177
|
+
asks: set[str] # the `SignUpDetails` fields its sign-up requires
|
|
132
178
|
|
|
133
|
-
def sign_up(self, page: Page, email: str, password: str,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
545
|
+
email: str,
|
|
546
|
+
details: SignUpDetails,
|
|
543
547
|
data: Path,
|
|
544
548
|
) -> SessionReport:
|
|
545
|
-
"""Creates the account:
|
|
546
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|