atsfill 0.0.10.dev237__tar.gz → 0.0.10.dev242__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.10.dev237 → atsfill-0.0.10.dev242}/PKG-INFO +1 -1
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/pyproject.toml +1 -1
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/pyproject.toml.orig +1 -1
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/adpmyjobs.py +55 -52
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/join.py +18 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/workatastartup.py +38 -9
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/adapter.py +24 -1
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/commands.py +7 -3
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/telemetry.py +13 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/LICENSE +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/README.md +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/adp.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/wellfound.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/werecruit.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.10.dev237 → atsfill-0.0.10.dev242}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.10.
|
|
3
|
+
Version: 0.0.10.dev242
|
|
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
|
|
@@ -26,6 +26,7 @@ from atsfill.core.adapter import (
|
|
|
26
26
|
SignedOut,
|
|
27
27
|
SignUpDetails,
|
|
28
28
|
Unsupported,
|
|
29
|
+
before_send,
|
|
29
30
|
known_gap,
|
|
30
31
|
)
|
|
31
32
|
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
@@ -124,58 +125,60 @@ class ADPMyJobs:
|
|
|
124
125
|
|
|
125
126
|
def open(self, page: Page) -> str | None:
|
|
126
127
|
"""Loads the posting signed in: Apply sends the application. One the account applied
|
|
127
|
-
to has its unfinished workflow continued, as My Jobs does
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
apply
|
|
177
|
-
|
|
178
|
-
|
|
128
|
+
to has its unfinished workflow continued, as My Jobs does: nothing is sent but by the
|
|
129
|
+
click on Apply."""
|
|
130
|
+
with before_send(): # all of it but the click, which sends
|
|
131
|
+
found = _posting(page.url)
|
|
132
|
+
if found is None:
|
|
133
|
+
raise RuntimeError(f"{page.url} is not an ADP MyJobs posting")
|
|
134
|
+
site, req = found
|
|
135
|
+
# Left before it settles, the first page's loader wedges the tab: no workflow shows
|
|
136
|
+
with suppress(Timeout):
|
|
137
|
+
page.wait_for_load_state("networkidle", timeout=POSTING_TIMEOUT_MS)
|
|
138
|
+
settings = _career_site(page, site)
|
|
139
|
+
self._site_id = str(settings["externalId"])
|
|
140
|
+
if not (settings.get("enabledFeatures") or {}).get("candidateApplicationWorkflow"):
|
|
141
|
+
detail = f"{site} continues each application on ADP Recruiting, after Apply"
|
|
142
|
+
raise Unsupported(known_gap(self, "recruiting_workflow", detail))
|
|
143
|
+
page.goto(f"https://{HOST}/{site}/cx/job-details?reqId={req}")
|
|
144
|
+
deadline = time.monotonic() + POSTING_TIMEOUT_MS / 1000
|
|
145
|
+
while not page.evaluate("() => !!sessionStorage.getItem('myJobsToken')"):
|
|
146
|
+
if urlsplit(page.url).path.endswith("/auth"):
|
|
147
|
+
raise SignedOut("login_required: the posting sent to the sign-in")
|
|
148
|
+
if time.monotonic() > deadline:
|
|
149
|
+
raise RuntimeError("the posting loaded without a session token")
|
|
150
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
151
|
+
with suppress(Timeout): # left with its requests in flight, the workflow can spin
|
|
152
|
+
page.wait_for_load_state("networkidle", timeout=POSTING_TIMEOUT_MS)
|
|
153
|
+
if application := self._application(page, req):
|
|
154
|
+
if (application.get("applicationStatusCode") or {}).get("label") != "CONTINUE":
|
|
155
|
+
raise Unsupported(f"already_sent: the account applied ({_status(application)})")
|
|
156
|
+
assignment = application["assignmentID"]
|
|
157
|
+
workflow = self._get(
|
|
158
|
+
page, f"{QUESTIONNAIRES}/WORKFLOW?assignmentID={assignment}&rmapp=forms"
|
|
159
|
+
)
|
|
160
|
+
component = next(
|
|
161
|
+
(
|
|
162
|
+
labels["component"]
|
|
163
|
+
for raw in workflow.get("questionSections") or []
|
|
164
|
+
if raw.get("completionStatusCode") != "completed"
|
|
165
|
+
and "component" in (labels := _labels(raw))
|
|
166
|
+
),
|
|
167
|
+
None,
|
|
168
|
+
)
|
|
169
|
+
if component is None:
|
|
170
|
+
raise Unsupported("already_sent: the account completed its application")
|
|
171
|
+
page.goto(
|
|
172
|
+
f"https://{HOST}/{site}/cx/app?reqId={req}&assignmentId={assignment}"
|
|
173
|
+
f"&workflowComponent={component}"
|
|
174
|
+
)
|
|
175
|
+
self._wait_for_section(page)
|
|
176
|
+
return None
|
|
177
|
+
apply = page.get_by_role("button", name="Apply", exact=True)
|
|
178
|
+
try:
|
|
179
|
+
apply.first.wait_for(timeout=max(0, deadline - time.monotonic()) * 1000)
|
|
180
|
+
except Timeout:
|
|
181
|
+
raise Unsupported("posting_gone: the posting shows no Apply") from None
|
|
179
182
|
apply.first.click()
|
|
180
183
|
deadline = time.monotonic() + POSTING_TIMEOUT_MS / 1000
|
|
181
184
|
while not (made := self._application(page, req)):
|
|
@@ -123,6 +123,18 @@ _CONTINUE = """(button, step) => {
|
|
|
123
123
|
return true;
|
|
124
124
|
}"""
|
|
125
125
|
|
|
126
|
+
# Whether the cards shown are the question's: its options (a choice), or under its text (a
|
|
127
|
+
# boolean, whose cards are in the job's language).
|
|
128
|
+
_OWN_CARDS = """([question, options]) => {
|
|
129
|
+
const cards = [...document.querySelectorAll("[role=radio]")].map(card => {
|
|
130
|
+
const lines = card.innerText.split("\\n").map(line => line.trim()).filter(Boolean);
|
|
131
|
+
return lines[1] ?? lines[0] ?? "";
|
|
132
|
+
});
|
|
133
|
+
return options
|
|
134
|
+
? cards.length === options.length && cards.every((card, i) => card === options[i])
|
|
135
|
+
: document.body.innerText.includes(question);
|
|
136
|
+
}"""
|
|
137
|
+
|
|
126
138
|
_CHECKED = """cards => cards.map(card => card.getAttribute("aria-checked") === "true")"""
|
|
127
139
|
|
|
128
140
|
# The ids of the inputs that are empty, of those given.
|
|
@@ -422,6 +434,12 @@ class Join:
|
|
|
422
434
|
match question["type"]:
|
|
423
435
|
case "BOOLEAN" | "SINGLE_CHOICE":
|
|
424
436
|
cards.first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
437
|
+
with suppress(Timeout): # the step before's cards stay a moment
|
|
438
|
+
page.wait_for_function(
|
|
439
|
+
_OWN_CARDS,
|
|
440
|
+
arg=[question["question"], question["options"]],
|
|
441
|
+
timeout=INPUT_TIMEOUT_MS,
|
|
442
|
+
)
|
|
425
443
|
# the cards are the options in order: a letter, the option, a description
|
|
426
444
|
texts = [_card(t) for t in cards.all_inner_texts()]
|
|
427
445
|
options = YES_NO if question["type"] == "BOOLEAN" else question["options"]
|
|
@@ -406,8 +406,10 @@ class Control:
|
|
|
406
406
|
match self.kind:
|
|
407
407
|
case "phone":
|
|
408
408
|
return form.locator("input[type=tel]")
|
|
409
|
-
case "country"
|
|
409
|
+
case "country":
|
|
410
410
|
return form.locator(f'input[aria-label="{self.where}"]')
|
|
411
|
+
case "combo":
|
|
412
|
+
return _in_entry(form, self.entry, f"input[@aria-label = '{self.where}']")
|
|
411
413
|
case "companies":
|
|
412
414
|
return form.locator('input[aria-label="Company name"]')
|
|
413
415
|
case "multi" | "skills":
|
|
@@ -497,8 +499,16 @@ STEPS: dict[str, list[Control]] = {
|
|
|
497
499
|
),
|
|
498
500
|
],
|
|
499
501
|
"experience": [
|
|
500
|
-
Control(
|
|
501
|
-
|
|
502
|
+
Control(
|
|
503
|
+
"work_employer",
|
|
504
|
+
"Work History: Employer Name",
|
|
505
|
+
"combo",
|
|
506
|
+
T,
|
|
507
|
+
False,
|
|
508
|
+
"Employer Name",
|
|
509
|
+
"work",
|
|
510
|
+
),
|
|
511
|
+
Control("work_title", "Work History: Title", "combo", T, False, "Title", "work"),
|
|
502
512
|
Control("work_location", "Work History: Location", "input", T, False, "location", "work"),
|
|
503
513
|
Control(
|
|
504
514
|
"work_from", "Work History: From (MM/YYYY)", "month", T, False, "start_date", "work"
|
|
@@ -517,7 +527,15 @@ STEPS: dict[str, list[Control]] = {
|
|
|
517
527
|
Is("work_current", ["No"]),
|
|
518
528
|
),
|
|
519
529
|
Control("work_summary", "Work History: Summary", "input", T, False, "summary", "work"),
|
|
520
|
-
Control(
|
|
530
|
+
Control(
|
|
531
|
+
"education_school",
|
|
532
|
+
"Education: School Name",
|
|
533
|
+
"combo",
|
|
534
|
+
T,
|
|
535
|
+
False,
|
|
536
|
+
"School Name",
|
|
537
|
+
"education",
|
|
538
|
+
),
|
|
521
539
|
Control(
|
|
522
540
|
"education_field",
|
|
523
541
|
"Education: Field of Study / Major",
|
|
@@ -525,8 +543,17 @@ STEPS: dict[str, list[Control]] = {
|
|
|
525
543
|
T,
|
|
526
544
|
False,
|
|
527
545
|
"Field of Study / Major",
|
|
546
|
+
"education",
|
|
547
|
+
),
|
|
548
|
+
Control(
|
|
549
|
+
"education_degree",
|
|
550
|
+
"Education: Degree type",
|
|
551
|
+
"combo",
|
|
552
|
+
T,
|
|
553
|
+
False,
|
|
554
|
+
"Degree type",
|
|
555
|
+
"education",
|
|
528
556
|
),
|
|
529
|
-
Control("education_degree", "Education: Degree type", "combo", T, False, "Degree type"),
|
|
530
557
|
Control(
|
|
531
558
|
"education_from",
|
|
532
559
|
"Education: From (MM/YYYY)",
|
|
@@ -719,16 +746,18 @@ def _pick(
|
|
|
719
746
|
box.press_sequentially(answer, delay=KEY_DELAY_MS)
|
|
720
747
|
options = page.locator("[role=option]:visible, li[id^=downshift-][id*=-item-]:visible")
|
|
721
748
|
field = Field(c.id, c.label, FieldType.TYPEAHEAD, False)
|
|
749
|
+
seen: list[str] = []
|
|
722
750
|
deadline = time.monotonic() + SUGGEST_TIMEOUT_S
|
|
723
751
|
while time.monotonic() < deadline:
|
|
724
|
-
for
|
|
725
|
-
|
|
752
|
+
seen = [o.inner_text().strip() for o in options.all()]
|
|
753
|
+
for i, text in enumerate(seen):
|
|
726
754
|
if text == answer if exact else matching.matches(field, answer, text):
|
|
727
|
-
|
|
755
|
+
options.nth(i).click()
|
|
728
756
|
return
|
|
729
757
|
page.wait_for_timeout(SUGGEST_POLL_MS)
|
|
730
758
|
if strict:
|
|
731
|
-
|
|
759
|
+
said = ", ".join(repr(t) for t in seen) or "none"
|
|
760
|
+
raise NotTaken(f"no suggestion for {answer!r} in {c.id}; suggestions: {said}")
|
|
732
761
|
box.press("Escape")
|
|
733
762
|
|
|
734
763
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import re
|
|
2
|
+
from collections.abc import Generator
|
|
3
|
+
from contextlib import contextmanager
|
|
2
4
|
from dataclasses import dataclass, fields
|
|
3
5
|
from enum import StrEnum
|
|
4
6
|
from typing import Protocol, Self, runtime_checkable
|
|
@@ -38,6 +40,26 @@ class Unsupported(Exception):
|
|
|
38
40
|
"""
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
class NotSent(Exception):
|
|
44
|
+
"""Raised by `SendsToOpen.open` around an error it knows came before anything was sent: the
|
|
45
|
+
core records no attempt and handles `cause` as it would without a send (ADR 0006)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, cause: Exception) -> None:
|
|
48
|
+
super().__init__(cause)
|
|
49
|
+
self.cause = cause
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def before_send() -> Generator[None]:
|
|
54
|
+
"""Wraps what `SendsToOpen.open` does before its click in `NotSent`."""
|
|
55
|
+
try:
|
|
56
|
+
yield
|
|
57
|
+
except SignedOut, Unsupported, Blocked:
|
|
58
|
+
raise
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise NotSent(e) from e
|
|
61
|
+
|
|
62
|
+
|
|
41
63
|
class Gap(StrEnum):
|
|
42
64
|
"""The code of a known gap (ADR 0014): wanted, not handled yet."""
|
|
43
65
|
|
|
@@ -284,7 +306,8 @@ class SendsToOpen(Adapter, Protocol):
|
|
|
284
306
|
def open(self, page: Page) -> str | None:
|
|
285
307
|
"""Opens the form from the loaded posting: sends the application where the account has
|
|
286
308
|
none for it, returning the evidence of that send; continues the one it has, returning
|
|
287
|
-
None. Raises `SignedOut`, `Unsupported` or `Blocked` only before sending
|
|
309
|
+
None. Raises `SignedOut`, `Unsupported` or `Blocked` only before sending, and wraps any
|
|
310
|
+
other error before it in `NotSent` (`before_send`)."""
|
|
288
311
|
...
|
|
289
312
|
|
|
290
313
|
|
|
@@ -29,6 +29,7 @@ from atsfill.core.adapter import (
|
|
|
29
29
|
KeepsProfile,
|
|
30
30
|
ListsShownFields,
|
|
31
31
|
NeedsAccount,
|
|
32
|
+
NotSent,
|
|
32
33
|
NotTaken,
|
|
33
34
|
OpensForm,
|
|
34
35
|
ProfileIncomplete,
|
|
@@ -246,8 +247,8 @@ def _read_signed_in(page: Page, adapter: Adapter, mine: Path, user: str, url: st
|
|
|
246
247
|
def _open(page: Page, adapter: SendsToOpen, mine: Path, user: str, url: str) -> None:
|
|
247
248
|
"""Opens a form whose system sends the application to show it, under a send's rules: the
|
|
248
249
|
adapter in `submit-allowed`, and the send recorded `opened`, which the send of that same
|
|
249
|
-
application does not count against (ADR 0006). An error
|
|
250
|
-
|
|
250
|
+
application does not count against (ADR 0006). An error the adapter does not say came
|
|
251
|
+
before the send (`NotSent`) may follow it: it is recorded `unknown`."""
|
|
251
252
|
if not paths.submit_enabled(adapter.name, user):
|
|
252
253
|
raise Refused(f"{_not_enabled(adapter, user)}; opening its form sends the application")
|
|
253
254
|
posting = adapter.posting(page.url)
|
|
@@ -255,6 +256,8 @@ def _open(page: Page, adapter: SendsToOpen, mine: Path, user: str, url: str) ->
|
|
|
255
256
|
evidence = adapter.open(page)
|
|
256
257
|
except SignedOut, Unsupported, Blocked:
|
|
257
258
|
raise
|
|
259
|
+
except NotSent as e:
|
|
260
|
+
raise e.cause from None
|
|
258
261
|
except Exception as e:
|
|
259
262
|
reason = f"error_after_click: {type(e).__name__}: {e}"
|
|
260
263
|
attempts.record(mine, posting, Status.UNKNOWN, reason=reason)
|
|
@@ -597,7 +600,8 @@ def _read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen
|
|
|
597
600
|
try:
|
|
598
601
|
_listed(result, fields)
|
|
599
602
|
except ValueError as e:
|
|
600
|
-
|
|
603
|
+
reason = f"adapter_error: {type(e).__name__}: {e}"
|
|
604
|
+
return Result(Status.UNSUPPORTED, url, adapter.name, reason)
|
|
601
605
|
_unfillable(result)
|
|
602
606
|
return result
|
|
603
607
|
return _in_budget(url, seen, lambda: _browser_read(data, user, url, sessions, seen))
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
12
13
|
import uuid
|
|
@@ -56,6 +57,10 @@ EXCLUDED = (
|
|
|
56
57
|
"external_step",
|
|
57
58
|
)
|
|
58
59
|
|
|
60
|
+
# Reason codes whose detail starts with the exception's class: `code: Class: message`.
|
|
61
|
+
ERRORS = ("error_after_click", "adapter_error")
|
|
62
|
+
CLASS = re.compile(r"[A-Z]\w*")
|
|
63
|
+
|
|
59
64
|
logging.getLogger("posthog").disabled = True
|
|
60
65
|
|
|
61
66
|
|
|
@@ -63,6 +68,13 @@ def _code(reason: str | None) -> str | None:
|
|
|
63
68
|
return reason.partition(":")[0] if reason else None
|
|
64
69
|
|
|
65
70
|
|
|
71
|
+
def _error(reason: str | None) -> str | None:
|
|
72
|
+
"""The exception's class in an error's reason, never its message."""
|
|
73
|
+
code, _, detail = (reason or "").partition(": ")
|
|
74
|
+
name = detail.partition(":")[0]
|
|
75
|
+
return name if code in ERRORS and CLASS.fullmatch(name) else None
|
|
76
|
+
|
|
77
|
+
|
|
66
78
|
def verdict(result: Result) -> str:
|
|
67
79
|
"""What the run says about its adapter: `pass`, `fail`, `excluded` or `unsupported`."""
|
|
68
80
|
if result.status in (Status.READY, Status.SUBMITTED):
|
|
@@ -135,6 +147,7 @@ def event(command: str, result: Result) -> dict:
|
|
|
135
147
|
"verdict": verdict(result),
|
|
136
148
|
"status": result.status.value,
|
|
137
149
|
"reason": _code(result.reason),
|
|
150
|
+
"error": _error(result.reason),
|
|
138
151
|
"refused": result.refused or None,
|
|
139
152
|
"version": version("atsfill"),
|
|
140
153
|
"posting": None if command == "profile" else posting_hash(_posting(result)),
|
|
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
|
|
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
|