atsfill 0.0.10.dev236__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.
Files changed (69) hide show
  1. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/PKG-INFO +2 -2
  2. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/pyproject.toml +4 -4
  3. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/pyproject.toml.orig +4 -4
  4. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/adpmyjobs.py +55 -52
  5. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/join.py +18 -0
  6. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/workatastartup.py +38 -9
  7. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/adapter.py +24 -1
  8. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/commands.py +7 -3
  9. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/telemetry.py +13 -0
  10. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/LICENSE +0 -0
  11. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/README.md +0 -0
  12. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/__init__.py +0 -0
  14. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/adp.py +0 -0
  15. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/jobaffinity.py +0 -0
  28. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/jobstreet.py +0 -0
  29. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/lever.py +0 -0
  30. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/manatal.py +0 -0
  31. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/oracle.py +0 -0
  32. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/paycor.py +0 -0
  33. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/paylocity.py +0 -0
  34. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/phenom.py +0 -0
  36. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/pinpoint.py +0 -0
  37. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/recruitee.py +0 -0
  38. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/recruiterflow.py +0 -0
  39. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/rippling.py +0 -0
  40. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/smartrecruiters.py +0 -0
  41. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/successfactors.js +0 -0
  42. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/successfactors.py +0 -0
  43. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/taleo.py +0 -0
  44. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/teamtailor.py +0 -0
  45. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/ukg.py +0 -0
  46. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/welcomekit.py +0 -0
  47. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/wellfound.py +0 -0
  48. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/werecruit.py +0 -0
  49. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/workable.py +0 -0
  50. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/workday.py +0 -0
  51. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/wttj.py +0 -0
  52. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/adapters/zohorecruit.py +0 -0
  53. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/cli.py +0 -0
  54. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/__init__.py +0 -0
  55. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/accounts.py +0 -0
  56. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/attempts.py +0 -0
  57. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/browser.py +0 -0
  58. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/conditions.py +0 -0
  59. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/countries.py +0 -0
  60. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/html.py +0 -0
  61. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/matching.py +0 -0
  62. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/otp.py +0 -0
  63. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/passwords.py +0 -0
  64. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/paths.py +0 -0
  65. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/registry.py +0 -0
  66. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/results.py +0 -0
  67. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/core/snapshot.py +0 -0
  68. {atsfill-0.0.10.dev236 → atsfill-0.0.10.dev242}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.10.dev236 → 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.dev236
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
@@ -20,7 +20,7 @@ Requires-Dist: camoufox==0.5.9
20
20
  Requires-Dist: filelock==4.2.10
21
21
  Requires-Dist: keyring==25.7.0
22
22
  Requires-Dist: packaging==26.3
23
- Requires-Dist: posthog==7.87.1
23
+ Requires-Dist: posthog==7.91.1
24
24
  Requires-Python: >=3.14
25
25
  Project-URL: Homepage, https://github.com/shbernal/atsfill
26
26
  Project-URL: Repository, https://github.com/shbernal/atsfill
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.10.dev236"
3
+ version = "0.0.10.dev242"
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"
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "filelock==4.2.10",
33
33
  "keyring==25.7.0",
34
34
  "packaging==26.3",
35
- "posthog==7.87.1",
35
+ "posthog==7.91.1",
36
36
  ]
37
37
 
38
38
  [[project.authors]]
@@ -56,8 +56,8 @@ commit-check = ["commit-check==2.20.5"]
56
56
  dev = [
57
57
  "pytest>=9.2.0",
58
58
  "pytest-xdist>=3.8.0",
59
- "ruff>=0.17.4",
60
- "ty==0.0.101",
59
+ "ruff>=0.17.5",
60
+ "ty==0.0.104",
61
61
  ]
62
62
 
63
63
  [tool.ruff]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.10.dev236"
3
+ version = "0.0.10.dev242"
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"
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "filelock==4.2.10",
26
26
  "keyring==25.7.0",
27
27
  "packaging==26.3",
28
- "posthog==7.87.1",
28
+ "posthog==7.91.1",
29
29
  ]
30
30
 
31
31
  [project.urls]
@@ -50,8 +50,8 @@ commit-check = [
50
50
  dev = [
51
51
  "pytest>=9.2.0",
52
52
  "pytest-xdist>=3.8.0",
53
- "ruff>=0.17.4",
54
- "ty==0.0.101",
53
+ "ruff>=0.17.5",
54
+ "ty==0.0.104",
55
55
  ]
56
56
 
57
57
  [tool.ruff]
@@ -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
- found = _posting(page.url)
129
- if found is None:
130
- raise RuntimeError(f"{page.url} is not an ADP MyJobs posting")
131
- site, req = found
132
- # Left before it settles, the first page's loader wedges the tab: no workflow shows in it
133
- with suppress(Timeout):
134
- page.wait_for_load_state("networkidle", timeout=POSTING_TIMEOUT_MS)
135
- settings = _career_site(page, site)
136
- self._site_id = str(settings["externalId"])
137
- if not (settings.get("enabledFeatures") or {}).get("candidateApplicationWorkflow"):
138
- detail = f"{site} continues each application on ADP Recruiting, after Apply"
139
- raise Unsupported(known_gap(self, "recruiting_workflow", detail))
140
- page.goto(f"https://{HOST}/{site}/cx/job-details?reqId={req}")
141
- deadline = time.monotonic() + POSTING_TIMEOUT_MS / 1000
142
- while not page.evaluate("() => !!sessionStorage.getItem('myJobsToken')"):
143
- if urlsplit(page.url).path.endswith("/auth"):
144
- raise SignedOut("login_required: the posting sent to the sign-in")
145
- if time.monotonic() > deadline:
146
- raise RuntimeError("the posting loaded without a session token")
147
- page.wait_for_timeout(SETTLE_MS)
148
- with suppress(Timeout): # left with its requests in flight, the workflow can spin
149
- page.wait_for_load_state("networkidle", timeout=POSTING_TIMEOUT_MS)
150
- if application := self._application(page, req):
151
- if (application.get("applicationStatusCode") or {}).get("label") != "CONTINUE":
152
- raise Unsupported(f"already_sent: the account applied ({_status(application)})")
153
- assignment = application["assignmentID"]
154
- workflow = self._get(
155
- page, f"{QUESTIONNAIRES}/WORKFLOW?assignmentID={assignment}&rmapp=forms"
156
- )
157
- component = next(
158
- (
159
- labels["component"]
160
- for raw in workflow.get("questionSections") or []
161
- if raw.get("completionStatusCode") != "completed"
162
- and "component" in (labels := _labels(raw))
163
- ),
164
- None,
165
- )
166
- if component is None:
167
- raise Unsupported("already_sent: the account completed its application")
168
- page.goto(
169
- f"https://{HOST}/{site}/cx/app?reqId={req}&assignmentId={assignment}"
170
- f"&workflowComponent={component}"
171
- )
172
- self._wait_for_section(page)
173
- return None
174
- apply = page.get_by_role("button", name="Apply", exact=True)
175
- try:
176
- apply.first.wait_for(timeout=max(0, deadline - time.monotonic()) * 1000)
177
- except Timeout:
178
- raise Unsupported("posting_gone: the posting shows no Apply") from None
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" | "combo":
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("work_employer", "Work History: Employer Name", "combo", T, False, "Employer Name"),
501
- Control("work_title", "Work History: Title", "combo", T, False, "Title"),
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("education_school", "Education: School Name", "combo", T, False, "School Name"),
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 option in options.all():
725
- text = option.inner_text().strip()
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
- option.click()
755
+ options.nth(i).click()
728
756
  return
729
757
  page.wait_for_timeout(SUGGEST_POLL_MS)
730
758
  if strict:
731
- raise NotTaken(f"no suggestion for {answer!r} in {c.id}")
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 past the adapter's own stops may
250
- follow the send: it is recorded `unknown`."""
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
- return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
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