atsfill 0.0.5.dev143__tar.gz → 0.0.5.dev145__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 (59) hide show
  1. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/PKG-INFO +2 -2
  2. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/README.md +1 -1
  3. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/cli.py +11 -2
  6. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/__init__.py +1 -1
  7. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/accounts.py +22 -5
  8. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/adapter.py +27 -1
  9. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/commands.py +171 -93
  10. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/results.py +9 -1
  11. atsfill-0.0.5.dev145/src/atsfill/core/snapshot.py +75 -0
  12. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/telemetry.py +85 -13
  13. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/LICENSE +0 -0
  14. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/__init__.py +0 -0
  15. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/__init__.py +0 -0
  16. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/ashby.py +0 -0
  17. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/bamboohr.py +0 -0
  18. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/breezy.py +0 -0
  19. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/dayforce.py +0 -0
  20. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/eightfold.py +0 -0
  21. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/greenhouse.py +0 -0
  22. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/gupy.py +0 -0
  23. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hireology.py +0 -0
  24. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hiringthing.py +0 -0
  25. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hrmdirect.py +0 -0
  26. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/isolvedhire.py +0 -0
  27. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/jazzhr.py +0 -0
  28. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/join.py +0 -0
  29. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/lever.py +0 -0
  30. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/manatal.py +0 -0
  31. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/oracle.py +0 -0
  32. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/paycor.py +0 -0
  33. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/paylocity.py +0 -0
  34. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/phenom.py +0 -0
  36. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/pinpoint.py +0 -0
  37. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/recruitee.py +0 -0
  38. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/rippling.py +0 -0
  39. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/smartrecruiters.py +0 -0
  40. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/successfactors.js +0 -0
  41. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/successfactors.py +0 -0
  42. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/taleo.py +0 -0
  43. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/teamtailor.py +0 -0
  44. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/ukg.py +0 -0
  45. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/workable.py +0 -0
  46. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/workday.py +0 -0
  47. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/wttj.py +0 -0
  48. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/zohorecruit.py +0 -0
  49. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/attempts.py +0 -0
  50. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/browser.py +0 -0
  51. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/conditions.py +0 -0
  52. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/html.py +0 -0
  53. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/matching.py +0 -0
  54. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/otp.py +0 -0
  55. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/passwords.py +0 -0
  56. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/paths.py +0 -0
  57. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/registry.py +0 -0
  58. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/py.typed +0 -0
  59. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.5.dev143
3
+ Version: 0.0.5.dev145
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
@@ -153,7 +153,7 @@ The core owns every rule about when a send is allowed; an adapter only knows its
153
153
  Every result has a `status` (`ready`, `refused`, `submitted`, `blocked`, `unknown`, `unsupported`) and, when it is not clean, a `reason` code.
154
154
  The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error.
155
155
 
156
- Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs): [what is sent and how to turn it off](docs/telemetry.md).
156
+ Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs); `atsfill report` sends a failed run's URL and page, scrubbed of your answers, when you ask: [what is sent and how to turn it off](docs/telemetry.md).
157
157
 
158
158
  ## More
159
159
 
@@ -124,7 +124,7 @@ The core owns every rule about when a send is allowed; an adapter only knows its
124
124
  Every result has a `status` (`ready`, `refused`, `submitted`, `blocked`, `unknown`, `unsupported`) and, when it is not clean, a `reason` code.
125
125
  The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error.
126
126
 
127
- Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs): [what is sent and how to turn it off](docs/telemetry.md).
127
+ Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs); `atsfill report` sends a failed run's URL and page, scrubbed of your answers, when you ask: [what is sent and how to turn it off](docs/telemetry.md).
128
128
 
129
129
  ## More
130
130
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev143"
3
+ version = "0.0.5.dev145"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev143"
3
+ version = "0.0.5.dev145"
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"
@@ -8,7 +8,7 @@ from atsfill import core
8
8
  from atsfill.adapters import adapters
9
9
  from atsfill.core import browser, paths
10
10
  from atsfill.core.adapter import NeedsAccount, SignUpDetails
11
- from atsfill.core.results import Result, SessionReport, Status, StatusReport
11
+ from atsfill.core.results import ReportResult, Result, SessionReport, Status, StatusReport
12
12
 
13
13
  OK = {Status.READY, Status.SUBMITTED}
14
14
 
@@ -83,6 +83,9 @@ def parser() -> argparse.ArgumentParser:
83
83
  "--resend", action="store_true", help="send even though an earlier send may have gone"
84
84
  )
85
85
  sub.add_parser("status", help="version, paths, browser, and each adapter's state")
86
+ sub.add_parser(
87
+ "report", help="send the last failed run's URL and page, scrubbed, to the maintainer"
88
+ )
86
89
  sub.add_parser("install-browser", help="download the Camoufox build atsfill is pinned to")
87
90
  accounts = [a.name for a in adapters() if isinstance(a, NeedsAccount)]
88
91
  for name, text in (
@@ -169,6 +172,10 @@ def run(args: argparse.Namespace) -> int:
169
172
  return 1 if signed_out or report.browser != report.browser_pinned else 0
170
173
  case "install-browser":
171
174
  return browser.install()
175
+ case "report":
176
+ sent = core.report(data_dir=args.data_dir)
177
+ emit(sent, False)
178
+ return 0 if sent.status == "sent" else 1
172
179
  case "login" | "logout":
173
180
  where = {"user": args.user, "url": args.url, "data_dir": args.data_dir}
174
181
  try:
@@ -197,7 +204,9 @@ def run(args: argparse.Namespace) -> int:
197
204
 
198
205
 
199
206
  def emit(
200
- value: Result | StatusReport | SessionReport, verbose: bool, command: str | None = None
207
+ value: Result | StatusReport | SessionReport | ReportResult,
208
+ verbose: bool,
209
+ command: str | None = None,
201
210
  ) -> None:
202
211
  """Prints the whole value with `verbose`; else what its consumer acts on, on one line."""
203
212
  data = dataclasses.asdict(value)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
- __all__ = ["fill", "login", "logout", "read", "send", "status"]
5
+ __all__ = ["fill", "login", "logout", "read", "report", "send", "status"]
6
6
 
7
7
 
8
8
  def __getattr__(name: str) -> Any:
@@ -8,9 +8,11 @@ from pathlib import Path
8
8
  from atsfill.core import browser
9
9
  from atsfill.core.adapter import (
10
10
  CreatesAccount,
11
+ CreatesAccountByOtp,
11
12
  FinishesSignUp,
12
13
  NeedsAccount,
13
14
  SignsIn,
15
+ SignsInByOtp,
14
16
  SignUpDetails,
15
17
  Unsupported,
16
18
  )
@@ -47,6 +49,12 @@ def email(user_dir: Path, adapter: str, scope: str | None) -> str | None:
47
49
  return emails.get(account_key(adapter, scope))
48
50
 
49
51
 
52
+ def emails(user_dir: Path) -> list[str]:
53
+ """Every account's email, for a snapshot to scrub."""
54
+ path = user_dir / EMAILS
55
+ return list(json.loads(path.read_text()).values()) if path.exists() else []
56
+
57
+
50
58
  def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> None:
51
59
  path = user_dir / EMAILS
52
60
  emails = json.loads(path.read_text()) if path.exists() else {}
@@ -55,21 +63,30 @@ def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> N
55
63
 
56
64
  def sign_in(
57
65
  page: Page,
58
- adapter: SignsIn,
66
+ adapter: SignsIn | SignsInByOtp,
59
67
  scope: str | None,
60
68
  address: str,
61
- password: str,
69
+ password: str | None,
62
70
  session: Path,
63
71
  details: SignUpDetails | None = None,
64
72
  create: bool = False,
65
73
  ) -> None:
66
74
  """Signs in from the system's login page and stores the session: with `create`, creates
67
75
  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.
76
+ account was left in, where the adapter knows that step. An account without a password
77
+ (`password` None) signs in by OTP, and creates the same way.
69
78
  Raises what the adapter raises, without the password in it."""
70
79
  page.goto(adapter.login_url(scope))
71
80
  try:
72
- if create:
81
+ if isinstance(adapter, SignsInByOtp):
82
+ if create:
83
+ assert isinstance(adapter, CreatesAccountByOtp) and details is not None
84
+ adapter.sign_up_by_otp(page, address, details)
85
+ else:
86
+ adapter.sign_in_by_otp(page, address)
87
+ elif password is None:
88
+ raise AssertionError(f"{adapter.name} signs in with a password")
89
+ elif create:
73
90
  assert isinstance(adapter, CreatesAccount) and details is not None
74
91
  adapter.sign_up(page, address, password, details)
75
92
  elif details is not None and isinstance(adapter, FinishesSignUp):
@@ -77,7 +94,7 @@ def sign_in(
77
94
  else:
78
95
  adapter.sign_in(page, address, password)
79
96
  except Exception as e:
80
- if password in str(e):
97
+ if password and password in str(e):
81
98
  raise redacted(e, password) from None
82
99
  raise
83
100
  if not adapter.signed_in(page.request, scope):
@@ -42,7 +42,7 @@ def out_of_scope(field_id: str, what: str) -> str:
42
42
 
43
43
  class Refused(Exception):
44
44
  """The system turned down a sign-in or a sign-up for a reason it states:
45
- `email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`.
45
+ `email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`, `no_account`.
46
46
 
47
47
  The message is the result's reason, `code` or `code: detail`.
48
48
  """
@@ -125,6 +125,18 @@ class SignsIn(NeedsAccount, Protocol):
125
125
  ...
126
126
 
127
127
 
128
+ @runtime_checkable
129
+ class SignsInByOtp(NeedsAccount, Protocol):
130
+ """An adapter atsfill can sign in through with an email and the OTP its system emails each
131
+ time, an account without a password (ADR 0015)."""
132
+
133
+ def sign_in_by_otp(self, page: Page, email: str) -> None:
134
+ """Signs in on the page `login_url` loaded, typing the OTP with `otp.enter`. Raises as
135
+ `sign_in` does, and `Refused("no_account")` before the click that would turn an unknown
136
+ email's sign-in into a sign-up."""
137
+ ...
138
+
139
+
128
140
  ADDRESS = ("street", "city", "state", "zip", "country")
129
141
  PHONE = re.compile(r"\+[1-9][0-9]{6,14}") # E.164
130
142
  COUNTRY = re.compile(r"[A-Z]{2}") # ISO 3166 alpha-2
@@ -183,6 +195,20 @@ class CreatesAccount(SignsIn, Protocol):
183
195
  ...
184
196
 
185
197
 
198
+ @runtime_checkable
199
+ class CreatesAccountByOtp(SignsInByOtp, Protocol):
200
+ """An adapter atsfill can create an account without a password through. Account creation is
201
+ a side effect it declares (ADR 0008)."""
202
+
203
+ asks: set[str] # the `SignUpDetails` fields its sign-up requires
204
+
205
+ def sign_up_by_otp(self, page: Page, email: str, details: SignUpDetails) -> None:
206
+ """Creates the account from the page `login_url` loaded, or signs in where the email
207
+ has one, signed in once it returns. Raises as `sign_in_by_otp` does, `no_account` aside;
208
+ the details are given as `sign_up` gives them."""
209
+ ...
210
+
211
+
186
212
  @runtime_checkable
187
213
  class FinishesSignUp(CreatesAccount, Protocol):
188
214
  """An adapter whose system can keep an account half made: signed up, but a step the
@@ -1,6 +1,7 @@
1
1
  import shlex
2
2
  from collections.abc import Callable, Generator
3
- from contextlib import contextmanager
3
+ from contextlib import contextmanager, suppress
4
+ from dataclasses import dataclass
4
5
  from importlib.metadata import version
5
6
  from pathlib import Path
6
7
 
@@ -15,18 +16,21 @@ from atsfill.core import (
15
16
  passwords,
16
17
  paths,
17
18
  registry,
19
+ snapshot,
18
20
  telemetry,
19
21
  )
20
22
  from atsfill.core.adapter import (
21
23
  Adapter,
22
24
  Blocked,
23
25
  CreatesAccount,
26
+ CreatesAccountByOtp,
24
27
  ListsShownFields,
25
28
  NeedsAccount,
26
29
  NotTaken,
27
30
  ReadsByRequest,
28
31
  Refused,
29
32
  SignsIn,
33
+ SignsInByOtp,
30
34
  SignUpDetails,
31
35
  Unsupported,
32
36
  )
@@ -35,6 +39,7 @@ from atsfill.core.results import (
35
39
  AdapterStatus,
36
40
  Answer,
37
41
  Field,
42
+ ReportResult,
38
43
  Result,
39
44
  SessionReport,
40
45
  Status,
@@ -44,18 +49,26 @@ from atsfill.core.results import (
44
49
  type Sessions = dict[str, Path]
45
50
 
46
51
 
52
+ @dataclass
53
+ class Seen:
54
+ """What a command saw before it ended, for a result it did not get to make."""
55
+
56
+ adapter: str | None = None # the adapter detected
57
+ page: tuple[str, str] | None = None # a failed run's page, for its report (ADR 0016)
58
+
59
+
47
60
  def _detect(candidates: list[Adapter], url: str, page: Page | None = None) -> Adapter | None:
48
61
  return next((a for a in candidates if a.detect(url, page)), None)
49
62
 
50
63
 
51
- def login_command(adapter: str, user: str, url: str | None) -> str:
64
+ def login_command(adapter: str, user: str, url: str | None, *more: str) -> str:
52
65
  """The `login` command line for one account: its user, and a posting where it is scoped."""
53
66
  command = ["atsfill", "login", adapter]
54
67
  if user != paths.DEFAULT_USER:
55
68
  command += ["--user", user]
56
69
  if url:
57
70
  command += ["--url", url]
58
- return shlex.join(command)
71
+ return shlex.join([*command, *more])
59
72
 
60
73
 
61
74
  def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
@@ -63,45 +76,57 @@ def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
63
76
  return Result(Status.BLOCKED, url, adapter.name, reason=f"login_required: run `{command}`")
64
77
 
65
78
 
66
- type Credentials = tuple[str, str] # email, password
79
+ type Credentials = tuple[str, str | None] # email, password: None for an account without one
67
80
  # Refusals the page states before any account exists: the generated password goes.
68
81
  NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule", "sign_up_details"}
69
82
 
70
83
 
71
- def _email_link(reason: str | None, email: str, command: str) -> str | None:
72
- """Names the address a verification link went to and the command to run once clicked."""
73
- if reason and reason.split(":")[0] == "email_link":
84
+ def _next_step(
85
+ reason: str | None, email: str, adapter: str, user: str, url: str | None
86
+ ) -> str | None:
87
+ """Names the command to run next: once the verification link sent to the address is
88
+ clicked, or `--create` for an email the system has no account for."""
89
+ code = (reason or "").split(":")[0]
90
+ if code == "email_link":
91
+ command = login_command(adapter, user, url)
74
92
  return f"email_link: click the link sent to {email}, then run `{command}`"
93
+ if code == "no_account":
94
+ command = login_command(adapter, user, url, "--email", email, "--create")
95
+ return f"no_account: {email} has no {adapter} account; run `{command}`"
75
96
  return reason
76
97
 
77
98
 
78
99
  def _credentials(
79
100
  adapter: NeedsAccount, user: str, mine: Path, scope: str | None, given: str | None = None
80
101
  ) -> Credentials | None:
81
- """The account's email and a password from its password source, where atsfill can sign in
102
+ """The account's email and a password from its password source, or no password for an
103
+ account without one when the user has an OTP command for it, where atsfill can sign in
82
104
  through the adapter; None otherwise."""
83
- if not isinstance(adapter, SignsIn):
105
+ if not isinstance(adapter, (SignsIn, SignsInByOtp)):
84
106
  return None
85
107
  if not (email := accounts.email(mine, adapter.name, scope)):
86
108
  return None
109
+ if isinstance(adapter, SignsInByOtp):
110
+ return (email, None) if otp.command(user, adapter.name) else None
87
111
  password = passwords.password(user, mine, adapter.name, scope, email, given)
88
112
  return (email, password) if password else None
89
113
 
90
114
 
91
115
  @contextmanager
92
116
  def _opened(
93
- data: Path, user: str, url: str, sessions: Sessions | None, detected: list[str]
117
+ data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen
94
118
  ) -> Generator[tuple[Page, Adapter] | Result]:
95
119
  """The loaded page and the adapter that claims it by its URL, else by the URL a redirect
96
120
  lands on, else by its page; or the result that ends the command first. An adapter that
97
121
  needs an account gets the user's session, and only that adapter's; signed out with a
98
- password source, atsfill signs in again (ADR 0015). The adapter's name goes in `detected`
122
+ password source, or an OTP command for an account without a password, atsfill signs in
123
+ again (ADR 0015). The adapter's name goes in `seen`
99
124
  once known. The adapters are fresh: none keeps state from another
100
125
  posting (a survey or the adapter gate runs many commands in one process)."""
101
126
  candidates = adapters()
102
127
  adapter = _detect(candidates, url)
103
128
  if adapter:
104
- detected.append(adapter.name)
129
+ seen.adapter = adapter.name
105
130
  mine = paths.user_dir(data, user)
106
131
  session, credentials = None, None
107
132
  if isinstance(adapter, NeedsAccount):
@@ -129,7 +154,7 @@ def _opened(
129
154
  if adapter is None and (
130
155
  adapter := _detect(candidates, page.url) or _detect(candidates, url, page)
131
156
  ):
132
- detected.append(adapter.name)
157
+ seen.adapter = adapter.name
133
158
  if adapter is None:
134
159
  yield _no_adapter(url)
135
160
  return
@@ -151,8 +176,8 @@ def _sign_in_again(
151
176
  url: str,
152
177
  credentials: Credentials | None,
153
178
  ) -> Result | None:
154
- """Signs in with the account's password source, stores the session and loads the posting
155
- again; the result that ends the command instead, `login_required` without a source."""
179
+ """Signs in as `_credentials` allows, stores the session and loads the posting again; the
180
+ result that ends the command instead, `login_required` where it does not."""
156
181
  result = Result(Status.BLOCKED, url, adapter.name)
157
182
  with _stops(result):
158
183
  scope = adapter.account_scope(url)
@@ -161,14 +186,15 @@ def _sign_in_again(
161
186
  except passwords.PasswordError as e:
162
187
  result.reason = f"password_source: {e}"
163
188
  return result
164
- if credentials is None or not isinstance(adapter, SignsIn):
189
+ if credentials is None or not isinstance(adapter, (SignsIn, SignsInByOtp)):
165
190
  return _login_required(url, adapter, user)
166
191
  session = accounts.session_file(mine, adapter.name, scope)
167
192
  accounts.sign_in(page, adapter, scope, *credentials, session)
168
193
  page.goto(url)
169
194
  return None
170
- command = login_command(adapter.name, user, url if adapter.per_employer else None)
171
- result.reason = _email_link(result.reason, credentials[0] if credentials else "", command)
195
+ email = credentials[0] if credentials else ""
196
+ scoped = url if adapter.per_employer else None
197
+ result.reason = _next_step(result.reason, email, adapter.name, user, scoped)
172
198
  return result
173
199
 
174
200
 
@@ -247,18 +273,41 @@ def _stops(result: Result) -> Generator[None]:
247
273
  result.status, result.reason = Status.BLOCKED, f"overlaid: {e}"
248
274
 
249
275
 
250
- def _in_budget(url: str, command: Callable[[list[str]], Result]) -> Result:
276
+ def _in_budget(url: str, seen: Seen, command: Callable[[], Result]) -> Result:
251
277
  """Runs a command to a status whatever happens: past the click it reports `unknown` itself,
252
278
  before it an error the adapter did not expect means the page is not one it handles. The
253
279
  result keeps the adapter detected before the stop."""
254
- detected: list[str] = []
255
280
  try:
256
- return command(detected)
281
+ return command()
257
282
  except OutOfTime as e:
258
- return Result(Status.BLOCKED, url, (detected or [None])[-1], f"time_budget: {e}")
283
+ return Result(Status.BLOCKED, url, seen.adapter, f"time_budget: {e}")
259
284
  except Exception as e:
260
285
  reason = f"adapter_error: {type(e).__name__}: {e}"
261
- return Result(Status.UNSUPPORTED, url, (detected or [None])[-1], reason)
286
+ return Result(Status.UNSUPPORTED, url, seen.adapter, reason)
287
+
288
+
289
+ def _kept(page: Page, seen: Seen, run: Callable[[], Result]) -> Result:
290
+ """The run's result; a failed run's page goes in `seen`, values stripped, when telemetry
291
+ is on."""
292
+ try:
293
+ result = run()
294
+ except Exception:
295
+ _capture(page, seen)
296
+ raise
297
+ if telemetry.verdict(result) == "fail":
298
+ _capture(page, seen)
299
+ return result
300
+
301
+
302
+ def _capture(page: Page, seen: Seen) -> None:
303
+ with suppress(Exception): # a page the budget closed has nothing to keep
304
+ if telemetry.level() != "off":
305
+ seen.page = snapshot.capture(page)
306
+
307
+
308
+ def _secrets(data: Path, user: str, answers: dict[str, Answer], result: Result) -> set[str]:
309
+ emails = accounts.emails(paths.user_dir(data, user))
310
+ return snapshot.secrets(answers, result.fields, emails)
262
311
 
263
312
 
264
313
  def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], user: str) -> Result:
@@ -281,18 +330,20 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
281
330
  return result
282
331
 
283
332
 
284
- def _browser_read(
285
- data: Path, user: str, url: str, sessions: Sessions | None, detected: list[str]
286
- ) -> Result:
287
- with _opened(data, user, url, sessions, detected) as opened:
333
+ def _browser_read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
334
+ with _opened(data, user, url, sessions, seen) as opened:
288
335
  if isinstance(opened, Result):
289
336
  return opened
290
337
  page, adapter = opened
291
- result = _result(adapter, url, user)
292
- with _stops(result):
293
- _listed(result, adapter.browser_read(page))
294
- _unfillable(result)
295
- return result
338
+
339
+ def run() -> Result:
340
+ result = _result(adapter, url, user)
341
+ with _stops(result):
342
+ _listed(result, adapter.browser_read(page))
343
+ _unfillable(result)
344
+ return result
345
+
346
+ return _kept(page, seen, run)
296
347
 
297
348
 
298
349
  def _form_command(
@@ -303,46 +354,59 @@ def _form_command(
303
354
  sessions: Sessions | None,
304
355
  send: bool,
305
356
  resend: bool,
306
- detected: list[str],
357
+ seen: Seen,
307
358
  ) -> Result:
308
359
  mine = paths.user_dir(data, user) # before the page takes the lock its first use needs
309
- with _opened(data, user, url, sessions, detected) as opened:
360
+ with _opened(data, user, url, sessions, seen) as opened:
310
361
  if isinstance(opened, Result):
311
362
  return opened
312
363
  page, adapter = opened
313
- posting = adapter.posting(page.url) # past any redirect a link goes through
314
- if send and not paths.submit_enabled(adapter.name, user):
315
- where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
316
- reason = (
317
- f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
318
- f" in {paths.config_file()}"
319
- )
320
- return Result(Status.REFUSED, url, adapter.name, reason=reason)
321
- if send and not resend and attempts.blocks_send(mine, posting):
322
- last = attempts.last(mine, posting)
323
- reason = f"already_sent: last attempt ended {last}; pass --resend"
324
- return Result(Status.REFUSED, url, adapter.name, reason=reason)
325
- result = _fill(page, adapter, url, answers, user)
326
- if result.status is not Status.READY:
327
- return result
328
- if not send:
329
- return result
330
- attempts.record(mine, posting, attempts.PENDING, url=url)
331
- try:
332
- result.evidence = adapter.submit(page)
333
- if result.evidence:
334
- result.status = Status.SUBMITTED
335
- else:
336
- result.status, result.reason = Status.UNKNOWN, "no_evidence"
337
- except Blocked as e:
338
- result.status, result.reason = Status.BLOCKED, str(e)
339
- except Exception as e: # after the click, nothing tells a failed send from a sent one
340
- result.status = Status.UNKNOWN
341
- result.reason = f"error_after_click: {type(e).__name__}: {e}"
342
- attempts.record(
343
- mine, posting, result.status, evidence=result.evidence, reason=result.reason
364
+ return _kept(
365
+ page, seen, lambda: _on_page(page, adapter, mine, user, url, answers, send, resend)
344
366
  )
367
+
368
+
369
+ def _on_page(
370
+ page: Page,
371
+ adapter: Adapter,
372
+ mine: Path,
373
+ user: str,
374
+ url: str,
375
+ answers: dict[str, Answer],
376
+ send: bool,
377
+ resend: bool,
378
+ ) -> Result:
379
+ posting = adapter.posting(page.url) # past any redirect a link goes through
380
+ if send and not paths.submit_enabled(adapter.name, user):
381
+ where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
382
+ reason = (
383
+ f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
384
+ f" in {paths.config_file()}"
385
+ )
386
+ return Result(Status.REFUSED, url, adapter.name, reason=reason)
387
+ if send and not resend and attempts.blocks_send(mine, posting):
388
+ last = attempts.last(mine, posting)
389
+ reason = f"already_sent: last attempt ended {last}; pass --resend"
390
+ return Result(Status.REFUSED, url, adapter.name, reason=reason)
391
+ result = _fill(page, adapter, url, answers, user)
392
+ if result.status is not Status.READY:
393
+ return result
394
+ if not send:
345
395
  return result
396
+ attempts.record(mine, posting, attempts.PENDING, url=url)
397
+ try:
398
+ result.evidence = adapter.submit(page)
399
+ if result.evidence:
400
+ result.status = Status.SUBMITTED
401
+ else:
402
+ result.status, result.reason = Status.UNKNOWN, "no_evidence"
403
+ except Blocked as e:
404
+ result.status, result.reason = Status.BLOCKED, str(e)
405
+ except Exception as e: # after the click, nothing tells a failed send from a sent one
406
+ result.status = Status.UNKNOWN
407
+ result.reason = f"error_after_click: {type(e).__name__}: {e}"
408
+ attempts.record(mine, posting, result.status, evidence=result.evidence, reason=result.reason)
409
+ return result
346
410
 
347
411
 
348
412
  def read(
@@ -354,11 +418,12 @@ def read(
354
418
  ) -> Result:
355
419
  """Lists the form's fields by a request read when an adapter has one (ADR 0011), else by a
356
420
  browser read."""
357
- data = paths.data_dir(data_dir)
358
- return telemetry.sent("read", _read(data, user, url, sessions), data)
421
+ data, seen = paths.data_dir(data_dir), Seen()
422
+ result = _read(data, user, url, sessions, seen)
423
+ return telemetry.sent("read", result, data, seen.page, _secrets(data, user, {}, result))
359
424
 
360
425
 
361
- def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
426
+ def _read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
362
427
  for adapter in adapters():
363
428
  if not isinstance(adapter, ReadsByRequest):
364
429
  continue
@@ -370,7 +435,7 @@ def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
370
435
  return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
371
436
  _unfillable(result)
372
437
  return result
373
- return _in_budget(url, lambda seen: _browser_read(data, user, url, sessions, seen))
438
+ return _in_budget(url, seen, lambda: _browser_read(data, user, url, sessions, seen))
374
439
 
375
440
 
376
441
  def fill(
@@ -381,11 +446,11 @@ def fill(
381
446
  data_dir: Path | None = None,
382
447
  sessions: Sessions | None = None,
383
448
  ) -> Result:
384
- data = paths.data_dir(data_dir)
449
+ data, seen = paths.data_dir(data_dir), Seen()
385
450
  result = _in_budget(
386
- url, lambda seen: _form_command(data, user, url, answers, sessions, False, False, seen)
451
+ url, seen, lambda: _form_command(data, user, url, answers, sessions, False, False, seen)
387
452
  )
388
- return telemetry.sent("fill", result, data)
453
+ return telemetry.sent("fill", result, data, seen.page, _secrets(data, user, answers, result))
389
454
 
390
455
 
391
456
  def send(
@@ -397,11 +462,16 @@ def send(
397
462
  data_dir: Path | None = None,
398
463
  sessions: Sessions | None = None,
399
464
  ) -> Result:
400
- data = paths.data_dir(data_dir)
465
+ data, seen = paths.data_dir(data_dir), Seen()
401
466
  result = _in_budget(
402
- url, lambda seen: _form_command(data, user, url, answers, sessions, True, resend, seen)
467
+ url, seen, lambda: _form_command(data, user, url, answers, sessions, True, resend, seen)
403
468
  )
404
- return telemetry.sent("send", result, data)
469
+ return telemetry.sent("send", result, data, seen.page, _secrets(data, user, answers, result))
470
+
471
+
472
+ def report(*, data_dir: Path | None = None) -> ReportResult:
473
+ """Sends the last failed run's URL and scrubbed page, once (ADR 0016)."""
474
+ return telemetry.report(paths.data_dir(data_dir))
405
475
 
406
476
 
407
477
  def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
@@ -478,7 +548,9 @@ def login(
478
548
  creates the account with a password atsfill generates and stores first (a side effect),
479
549
  giving the sign-up the user's `details`; with a password already stored, the account may
480
550
  exist, so it signs in with that password and keeps the account's email instead, the
481
- `details` finishing a sign-up the account was left in."""
551
+ `details` finishing a sign-up the account was left in. An account without a password signs
552
+ in through the user's OTP command in place of a password source, and is created the same
553
+ way."""
482
554
  account, data = _account(adapter), paths.data_dir(data_dir)
483
555
  scope = _scope(account, url)
484
556
  mine = paths.user_dir(data, user)
@@ -488,8 +560,11 @@ def login(
488
560
  raise ValueError("creating an account needs --email")
489
561
  if details and not create:
490
562
  raise ValueError("--details goes with --create")
563
+ by_otp = isinstance(account, SignsInByOtp)
564
+ if password and by_otp:
565
+ raise ValueError(f"{adapter} signs in with an emailed OTP, not a password")
491
566
  try:
492
- kept = create and passwords.stored(mine, adapter, scope) is not None
567
+ kept = create and not by_otp and passwords.stored(mine, adapter, scope) is not None
493
568
  except passwords.PasswordError as e:
494
569
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
495
570
  return report
@@ -523,11 +598,8 @@ def login(
523
598
  )
524
599
  else:
525
600
  result = _headless(data, user, account, scope, credentials, session, details)
526
- command = login_command(adapter, user, url)
527
- report.status, report.reason = (
528
- result.status,
529
- _email_link(result.reason, credentials[0], command),
530
- )
601
+ report.status = result.status
602
+ report.reason = _next_step(result.reason, credentials[0], adapter, user, url)
531
603
  if report.reason is None:
532
604
  report.session = report.status = "signed_in"
533
605
  return report
@@ -544,8 +616,9 @@ def _create(
544
616
  ) -> SessionReport:
545
617
  """Creates the account: refused before the browser opens when `details` lack one the
546
618
  sign-up asks; the generated password is stored before the click, and deleted only on a
547
- refusal the page states before any account exists."""
548
- if not isinstance(account, CreatesAccount):
619
+ refusal the page states before any account exists. An account without a password gets
620
+ none."""
621
+ if not isinstance(account, (CreatesAccount, CreatesAccountByOtp)):
549
622
  report.status = Status.UNSUPPORTED
550
623
  report.reason = f"no_sign_up: atsfill cannot create a {account.name} account"
551
624
  return report
@@ -553,9 +626,10 @@ def _create(
553
626
  report.status = Status.REFUSED
554
627
  report.reason = f"sign_up_details: no {', '.join(missing)}"
555
628
  return report
556
- password = passwords.generate()
629
+ password = None if isinstance(account, CreatesAccountByOtp) else passwords.generate()
557
630
  try:
558
- passwords.store(mine, account.name, report.scope, password)
631
+ if password:
632
+ passwords.store(mine, account.name, report.scope, password)
559
633
  except passwords.PasswordError as e:
560
634
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
561
635
  return report
@@ -563,10 +637,14 @@ def _create(
563
637
  result = _headless(
564
638
  data, report.user, account, report.scope, credentials, report.path, details, create=True
565
639
  )
566
- command = login_command(account.name, report.user, url)
567
- report.status, report.reason = result.status, _email_link(result.reason, email, command)
568
- report.password = "stored"
569
- if result.status is Status.REFUSED and (result.reason or "").split(":")[0] in NO_ACCOUNT:
640
+ report.status = result.status
641
+ report.reason = _next_step(result.reason, email, account.name, report.user, url)
642
+ report.password = "stored" if password else "none"
643
+ if (
644
+ password
645
+ and result.status is Status.REFUSED
646
+ and (result.reason or "").split(":")[0] in NO_ACCOUNT
647
+ ):
570
648
  passwords.delete(mine, account.name, report.scope)
571
649
  report.password = "deleted"
572
650
  if report.reason is None:
@@ -586,16 +664,16 @@ def _headless(
586
664
  ) -> Result:
587
665
  """Signs in, or creates the account with `create`, through the adapter, an OTP it meets
588
666
  from the user's `otp-commands` entry: `ready` once the session is stored."""
589
- assert isinstance(account, SignsIn)
667
+ assert isinstance(account, (SignsIn, SignsInByOtp))
590
668
  url = account.login_url(scope)
591
669
 
592
- def run(_: list[str]) -> Result:
670
+ def run() -> Result:
593
671
  result = Result(Status.READY, url, account.name)
594
672
  with open_page(data, None) as page, _stops(result), otp.source(user, account.name, scope):
595
673
  accounts.sign_in(page, account, scope, *credentials, session, details, create)
596
674
  return result
597
675
 
598
- return _in_budget(url, run)
676
+ return _in_budget(url, Seen(), run)
599
677
 
600
678
 
601
679
  def logout(
@@ -102,6 +102,14 @@ class Result:
102
102
  side_effects: list[SideEffect] = field(default_factory=list)
103
103
  evidence: str | None = None
104
104
  notice: str | None = None # telemetry's, the first time it is on (ADR 0016)
105
+ report: str | None = None # how to send a failed run's report, when it was not sent
106
+
107
+
108
+ @dataclass
109
+ class ReportResult:
110
+ status: str # sent | refused | blocked
111
+ reason: str | None = None
112
+ url: str | None = None # the failed run's posting
105
113
 
106
114
 
107
115
  @dataclass
@@ -113,7 +121,7 @@ class SessionReport:
113
121
  path: Path
114
122
  status: str | None = None # a login's outcome: signed_in | refused | blocked | unsupported
115
123
  reason: str | None = None # why a login stored nothing, `code` or `code: detail`
116
- password: str | None = None # after `--create`: stored | deleted, or kept
124
+ password: str | None = None # after `--create`: stored | deleted | kept, or none (OTP only)
117
125
 
118
126
 
119
127
  @dataclass
@@ -0,0 +1,75 @@
1
+ """A failed run's page, for `detailed` telemetry and `atsfill report` (ADR 0016): the DOM
2
+ without scripts and styles, every value a field holds stripped in the page, then every answer,
3
+ uploaded file name and account email replaced, here in the core, before it is kept."""
4
+
5
+ import html
6
+ import re
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+
10
+ from atsfill.core.browser import Page
11
+ from atsfill.core.results import Answer, Field
12
+
13
+ MAX_BYTES = 800 * 1024 # the SDK drops an event above 900 KiB; room for its other properties
14
+ MIN_SECRET = 3 # shorter answers ("No", "5") would wipe the page's own words, and tell little
15
+ NAME_WORDS = 4 # an answer of up to this many words is scrubbed word by word too
16
+ SCRUBBED = "[scrubbed]"
17
+ TRUNCATED = "<!-- truncated -->"
18
+ EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
19
+
20
+ # The page and the failing step's subtree, the smallest element holding every form control.
21
+ CLEAN = """() => {
22
+ const root = document.documentElement.cloneNode(true);
23
+ root.querySelectorAll("script, style, noscript, template, svg, link, meta").forEach(
24
+ e => e.remove());
25
+ for (const e of root.querySelectorAll("*")) {
26
+ for (const a of ["value", "checked", "selected", "aria-valuenow", "aria-valuetext"])
27
+ e.removeAttribute(a);
28
+ }
29
+ root.querySelectorAll("textarea, [contenteditable]").forEach(e => { e.textContent = ""; });
30
+ const controls = [...root.querySelectorAll(
31
+ "input, select, textarea, button, [role=combobox], [role=textbox], [contenteditable]")];
32
+ let step = controls[0] ?? root.querySelector("body") ?? root;
33
+ while (!controls.every(c => step.contains(c))) step = step.parentElement;
34
+ return [root.outerHTML, step.outerHTML];
35
+ }"""
36
+
37
+
38
+ def capture(page: Page) -> tuple[str, str]:
39
+ """The page and its failing step, values stripped, not yet scrubbed of secrets."""
40
+ whole, step = page.evaluate(CLEAN)
41
+ return whole, step
42
+
43
+
44
+ def secrets(answers: dict[str, Answer], fields: Iterable[Field], emails: Iterable[str]) -> set[str]:
45
+ """What may not leave the machine: each answer, a file answer's name and stem, each
46
+ prefilled value and each account email; each word of a short one, a name the page may
47
+ show in part."""
48
+ out = set(emails)
49
+ values = [*answers.values(), *(f.prefilled for f in fields if f.prefilled)]
50
+ for value in values:
51
+ for v in [value] if isinstance(value, str) else value:
52
+ out |= {v, Path(v).name, Path(v).stem} if "/" in v or "\\" in v else {v}
53
+ if len(words := v.split()) <= NAME_WORDS:
54
+ out |= set(words)
55
+ return {s.strip() for s in out if len(s.strip()) >= MIN_SECRET}
56
+
57
+
58
+ def scrub(text: str, secrets: Iterable[str]) -> str:
59
+ """The text with each secret, as typed or HTML-escaped, and every email address replaced."""
60
+ forms = {f for s in secrets for f in (s, html.escape(s), html.escape(s, quote=False))}
61
+ if forms:
62
+ alternatives = "|".join(re.escape(f) for f in sorted(forms, key=len, reverse=True))
63
+ whole_word = re.compile(rf"(?<![^\W_])(?:{alternatives})(?![^\W_])", re.IGNORECASE)
64
+ text = whole_word.sub(SCRUBBED, text)
65
+ return EMAIL.sub(SCRUBBED, text)
66
+
67
+
68
+ def kept(captured: tuple[str, str], secrets: Iterable[str]) -> str:
69
+ """The scrubbed page, or its failing step when the page is over the limit, cut at it."""
70
+ whole, step = (scrub(c, secrets) for c in captured)
71
+ for snapshot in (whole, step):
72
+ if len(snapshot.encode()) <= MAX_BYTES:
73
+ return snapshot
74
+ cut = step.encode()[: MAX_BYTES - len(TRUNCATED)].decode(errors="ignore")
75
+ return cut + TRUNCATED
@@ -1,6 +1,7 @@
1
1
  """Anonymous telemetry (ADR 0016): one `run` event per `read`, `fill` and `send`, built from
2
- the command's result. Never in the way: any failure is dropped and the result goes back as it
3
- came, but for the first-run notice."""
2
+ the command's result, and for a failed run a `report` event with its URL and scrubbed page, at
3
+ `detailed` or by `atsfill report`. Never in the way: any failure is dropped and the result goes
4
+ back as it came, but for the notices."""
4
5
 
5
6
  import hashlib
6
7
  import json
@@ -9,6 +10,7 @@ import os
9
10
  import subprocess
10
11
  import sys
11
12
  import uuid
13
+ from collections.abc import Iterable
12
14
  from contextlib import suppress
13
15
  from functools import cache
14
16
  from importlib.metadata import PackageNotFoundError, distribution, version
@@ -18,8 +20,8 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
18
20
  from posthog import Posthog
19
21
 
20
22
  from atsfill.adapters import adapters
21
- from atsfill.core import paths, registry
22
- from atsfill.core.results import Result, Status
23
+ from atsfill.core import paths, registry, snapshot
24
+ from atsfill.core.results import ReportResult, Result, Status
23
25
 
24
26
  PROJECT_KEY = "phc_xmrdiXySbbZCdUzdbdNUtjMco3rLL2Ko5j4hFrX9fUQ8" # ingestion only, safe to ship
25
27
  HOST = "https://eu.i.posthog.com"
@@ -29,10 +31,16 @@ FLUSH_TIMEOUT_SECONDS = 2 # the SDK's shutdown() waits without one
29
31
  GIT_TIMEOUT_SECONDS = 2
30
32
  ROOT = Path(__file__).parents[3] # the checkout, for an editable install
31
33
  NOTICE_FILE = "telemetry-notice" # in the data directory, once the notice was shown
34
+ FAILED_RUN = "failed-run.json" # in the data directory, the last failed run's report event
35
+ DOCS = "https://github.com/shbernal/atsfill/blob/main/docs/telemetry.md"
32
36
  NOTICE = (
33
37
  "telemetry: atsfill sends an anonymous event per read, fill and send, no answers or URLs;"
34
38
  ' set telemetry = "off" in {config} or DO_NOT_TRACK=1 to stop it:'
35
- " https://github.com/shbernal/atsfill/blob/main/docs/telemetry.md"
39
+ " " + DOCS
40
+ )
41
+ REPORT = (
42
+ "run `atsfill report` to send this run's URL and page, scrubbed of your answers,"
43
+ " to the maintainer: " + DOCS
36
44
  )
37
45
  # Reason codes that say nothing about the adapter: the user's input or a fact about the posting
38
46
  EXCLUDED = (
@@ -132,17 +140,23 @@ def event(command: str, result: Result) -> dict:
132
140
  return {k: v for k, v in properties.items() if v is not None}
133
141
 
134
142
 
135
- def _capture(properties: dict) -> None:
143
+ def _capture(events: list[tuple[str, dict]]) -> None:
144
+ """Sends the events; raises when the SDK reports a batch it could not send."""
145
+ failed: list[Exception] = []
136
146
  client = Posthog(
137
147
  PROJECT_KEY,
138
148
  host=HOST,
139
149
  timeout=REQUEST_TIMEOUT_SECONDS,
140
150
  max_retries=MAX_RETRIES,
141
151
  disable_geoip=True,
152
+ on_error=lambda e, _: failed.append(e),
142
153
  )
143
- properties = {**properties, "$process_person_profile": False}
144
- client.capture("run", distinct_id=str(uuid.uuid4()), properties=properties)
154
+ for name, properties in events:
155
+ properties = {**properties, "$process_person_profile": False}
156
+ client.capture(name, distinct_id=str(uuid.uuid4()), properties=properties)
145
157
  client.flush(timeout_seconds=FLUSH_TIMEOUT_SECONDS)
158
+ if failed:
159
+ raise failed[0]
146
160
 
147
161
 
148
162
  def _notice(data: Path) -> str | None:
@@ -154,10 +168,68 @@ def _notice(data: Path) -> str | None:
154
168
  return NOTICE.format(config=paths.config_file())
155
169
 
156
170
 
157
- def sent(command: str, result: Result, data: Path) -> Result:
158
- """The result, its event sent at `basic` and `detailed`."""
171
+ def _report(
172
+ run: dict, result: Result, captured: tuple[str, str] | None, secrets: Iterable[str]
173
+ ) -> dict:
174
+ """The `report` event: the run's, plus its URL, its reason's detail and its page, each
175
+ scrubbed of the secrets."""
176
+ detail = (result.reason or "").partition(":")[2].strip()
177
+ report = {
178
+ **run,
179
+ "url": result.url,
180
+ "detail": snapshot.scrub(detail, secrets) or None,
181
+ "snapshot": snapshot.kept(captured, secrets) if captured else None,
182
+ }
183
+ return {k: v for k, v in report.items() if v is not None}
184
+
185
+
186
+ def sent(
187
+ command: str,
188
+ result: Result,
189
+ data: Path,
190
+ captured: tuple[str, str] | None = None,
191
+ secrets: Iterable[str] = (),
192
+ ) -> Result:
193
+ """The result, its event sent at `basic` and `detailed`. A failed run is kept for `atsfill
194
+ report`, and its report sent along at `detailed`, else named in the result."""
159
195
  with suppress(Exception): # never in the way
160
- if level() != "off":
161
- result.notice = _notice(data)
162
- _capture(event(command, result))
196
+ if (at := level()) == "off":
197
+ return result
198
+ result.notice = _notice(data)
199
+ run = event(command, result)
200
+ events = [("run", run | {"url": result.url} if at == "detailed" else run)]
201
+ if verdict(result) == "fail":
202
+ report = _report(run, result, captured, secrets)
203
+ (data / FAILED_RUN).write_text(json.dumps({"event": report, "reported": False}))
204
+ if at == "detailed":
205
+ events.append(("report", report))
206
+ else:
207
+ result.report = REPORT
208
+ _capture(events)
209
+ if len(events) > 1:
210
+ _mark_reported(data)
163
211
  return result
212
+
213
+
214
+ def _mark_reported(data: Path) -> None:
215
+ path = data / FAILED_RUN
216
+ path.write_text(json.dumps(json.loads(path.read_text()) | {"reported": True}))
217
+
218
+
219
+ def report(data: Path) -> ReportResult:
220
+ """Sends the last failed run's report, once, whatever the level but `off`."""
221
+ if level() == "off":
222
+ return ReportResult("refused", "telemetry_off: telemetry is off, nothing is sent")
223
+ path = data / FAILED_RUN
224
+ if not path.exists():
225
+ return ReportResult("refused", "no_failed_run: no failed run to report")
226
+ kept = json.loads(path.read_text())
227
+ report, url = kept["event"], kept["event"]["url"]
228
+ if kept["reported"]:
229
+ return ReportResult("refused", "already_reported: the last failed run was sent", url)
230
+ try:
231
+ _capture([("report", report)])
232
+ except Exception as e:
233
+ return ReportResult("blocked", f"not_sent: {type(e).__name__}: {e}", url)
234
+ _mark_reported(data)
235
+ return ReportResult("sent", url=url)
File without changes