atsfill 0.0.5.dev143__tar.gz → 0.0.5.dev144__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.dev144}/PKG-INFO +2 -2
  2. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/README.md +1 -1
  3. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/cli.py +11 -2
  6. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/__init__.py +1 -1
  7. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/accounts.py +6 -0
  8. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/commands.py +119 -64
  9. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/results.py +8 -0
  10. atsfill-0.0.5.dev144/src/atsfill/core/snapshot.py +75 -0
  11. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/telemetry.py +85 -13
  12. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/LICENSE +0 -0
  13. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/__init__.py +0 -0
  14. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/__init__.py +0 -0
  15. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/join.py +0 -0
  28. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/oracle.py +0 -0
  31. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/paycor.py +0 -0
  32. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/paylocity.py +0 -0
  33. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/phenom.py +0 -0
  35. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/pinpoint.py +0 -0
  36. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/recruitee.py +0 -0
  37. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/rippling.py +0 -0
  38. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/smartrecruiters.py +0 -0
  39. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/successfactors.js +0 -0
  40. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/successfactors.py +0 -0
  41. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/taleo.py +0 -0
  42. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/ukg.py +0 -0
  44. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/workable.py +0 -0
  45. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/workday.py +0 -0
  46. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/wttj.py +0 -0
  47. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/adapters/zohorecruit.py +0 -0
  48. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/adapter.py +0 -0
  49. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/attempts.py +0 -0
  50. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/browser.py +0 -0
  51. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/conditions.py +0 -0
  52. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/html.py +0 -0
  53. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/matching.py +0 -0
  54. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/otp.py +0 -0
  55. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/passwords.py +0 -0
  56. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/paths.py +0 -0
  57. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/core/registry.py +0 -0
  58. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/src/atsfill/py.typed +0 -0
  59. {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev144}/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.dev144
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.dev144"
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.dev144"
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:
@@ -47,6 +47,12 @@ def email(user_dir: Path, adapter: str, scope: str | None) -> str | None:
47
47
  return emails.get(account_key(adapter, scope))
48
48
 
49
49
 
50
+ def emails(user_dir: Path) -> list[str]:
51
+ """Every account's email, for a snapshot to scrub."""
52
+ path = user_dir / EMAILS
53
+ return list(json.loads(path.read_text()).values()) if path.exists() else []
54
+
55
+
50
56
  def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> None:
51
57
  path = user_dir / EMAILS
52
58
  emails = json.loads(path.read_text()) if path.exists() else {}
@@ -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,6 +16,7 @@ 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 (
@@ -35,6 +37,7 @@ from atsfill.core.results import (
35
37
  AdapterStatus,
36
38
  Answer,
37
39
  Field,
40
+ ReportResult,
38
41
  Result,
39
42
  SessionReport,
40
43
  Status,
@@ -44,6 +47,14 @@ from atsfill.core.results import (
44
47
  type Sessions = dict[str, Path]
45
48
 
46
49
 
50
+ @dataclass
51
+ class Seen:
52
+ """What a command saw before it ended, for a result it did not get to make."""
53
+
54
+ adapter: str | None = None # the adapter detected
55
+ page: tuple[str, str] | None = None # a failed run's page, for its report (ADR 0016)
56
+
57
+
47
58
  def _detect(candidates: list[Adapter], url: str, page: Page | None = None) -> Adapter | None:
48
59
  return next((a for a in candidates if a.detect(url, page)), None)
49
60
 
@@ -90,18 +101,18 @@ def _credentials(
90
101
 
91
102
  @contextmanager
92
103
  def _opened(
93
- data: Path, user: str, url: str, sessions: Sessions | None, detected: list[str]
104
+ data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen
94
105
  ) -> Generator[tuple[Page, Adapter] | Result]:
95
106
  """The loaded page and the adapter that claims it by its URL, else by the URL a redirect
96
107
  lands on, else by its page; or the result that ends the command first. An adapter that
97
108
  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`
109
+ password source, atsfill signs in again (ADR 0015). The adapter's name goes in `seen`
99
110
  once known. The adapters are fresh: none keeps state from another
100
111
  posting (a survey or the adapter gate runs many commands in one process)."""
101
112
  candidates = adapters()
102
113
  adapter = _detect(candidates, url)
103
114
  if adapter:
104
- detected.append(adapter.name)
115
+ seen.adapter = adapter.name
105
116
  mine = paths.user_dir(data, user)
106
117
  session, credentials = None, None
107
118
  if isinstance(adapter, NeedsAccount):
@@ -129,7 +140,7 @@ def _opened(
129
140
  if adapter is None and (
130
141
  adapter := _detect(candidates, page.url) or _detect(candidates, url, page)
131
142
  ):
132
- detected.append(adapter.name)
143
+ seen.adapter = adapter.name
133
144
  if adapter is None:
134
145
  yield _no_adapter(url)
135
146
  return
@@ -247,18 +258,41 @@ def _stops(result: Result) -> Generator[None]:
247
258
  result.status, result.reason = Status.BLOCKED, f"overlaid: {e}"
248
259
 
249
260
 
250
- def _in_budget(url: str, command: Callable[[list[str]], Result]) -> Result:
261
+ def _in_budget(url: str, seen: Seen, command: Callable[[], Result]) -> Result:
251
262
  """Runs a command to a status whatever happens: past the click it reports `unknown` itself,
252
263
  before it an error the adapter did not expect means the page is not one it handles. The
253
264
  result keeps the adapter detected before the stop."""
254
- detected: list[str] = []
255
265
  try:
256
- return command(detected)
266
+ return command()
257
267
  except OutOfTime as e:
258
- return Result(Status.BLOCKED, url, (detected or [None])[-1], f"time_budget: {e}")
268
+ return Result(Status.BLOCKED, url, seen.adapter, f"time_budget: {e}")
259
269
  except Exception as e:
260
270
  reason = f"adapter_error: {type(e).__name__}: {e}"
261
- return Result(Status.UNSUPPORTED, url, (detected or [None])[-1], reason)
271
+ return Result(Status.UNSUPPORTED, url, seen.adapter, reason)
272
+
273
+
274
+ def _kept(page: Page, seen: Seen, run: Callable[[], Result]) -> Result:
275
+ """The run's result; a failed run's page goes in `seen`, values stripped, when telemetry
276
+ is on."""
277
+ try:
278
+ result = run()
279
+ except Exception:
280
+ _capture(page, seen)
281
+ raise
282
+ if telemetry.verdict(result) == "fail":
283
+ _capture(page, seen)
284
+ return result
285
+
286
+
287
+ def _capture(page: Page, seen: Seen) -> None:
288
+ with suppress(Exception): # a page the budget closed has nothing to keep
289
+ if telemetry.level() != "off":
290
+ seen.page = snapshot.capture(page)
291
+
292
+
293
+ def _secrets(data: Path, user: str, answers: dict[str, Answer], result: Result) -> set[str]:
294
+ emails = accounts.emails(paths.user_dir(data, user))
295
+ return snapshot.secrets(answers, result.fields, emails)
262
296
 
263
297
 
264
298
  def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], user: str) -> Result:
@@ -281,18 +315,20 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
281
315
  return result
282
316
 
283
317
 
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:
318
+ def _browser_read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
319
+ with _opened(data, user, url, sessions, seen) as opened:
288
320
  if isinstance(opened, Result):
289
321
  return opened
290
322
  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
323
+
324
+ def run() -> Result:
325
+ result = _result(adapter, url, user)
326
+ with _stops(result):
327
+ _listed(result, adapter.browser_read(page))
328
+ _unfillable(result)
329
+ return result
330
+
331
+ return _kept(page, seen, run)
296
332
 
297
333
 
298
334
  def _form_command(
@@ -303,46 +339,59 @@ def _form_command(
303
339
  sessions: Sessions | None,
304
340
  send: bool,
305
341
  resend: bool,
306
- detected: list[str],
342
+ seen: Seen,
307
343
  ) -> Result:
308
344
  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:
345
+ with _opened(data, user, url, sessions, seen) as opened:
310
346
  if isinstance(opened, Result):
311
347
  return opened
312
348
  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
349
+ return _kept(
350
+ page, seen, lambda: _on_page(page, adapter, mine, user, url, answers, send, resend)
351
+ )
352
+
353
+
354
+ def _on_page(
355
+ page: Page,
356
+ adapter: Adapter,
357
+ mine: Path,
358
+ user: str,
359
+ url: str,
360
+ answers: dict[str, Answer],
361
+ send: bool,
362
+ resend: bool,
363
+ ) -> Result:
364
+ posting = adapter.posting(page.url) # past any redirect a link goes through
365
+ if send and not paths.submit_enabled(adapter.name, user):
366
+ where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
367
+ reason = (
368
+ f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
369
+ f" in {paths.config_file()}"
344
370
  )
371
+ return Result(Status.REFUSED, url, adapter.name, reason=reason)
372
+ if send and not resend and attempts.blocks_send(mine, posting):
373
+ last = attempts.last(mine, posting)
374
+ reason = f"already_sent: last attempt ended {last}; pass --resend"
375
+ return Result(Status.REFUSED, url, adapter.name, reason=reason)
376
+ result = _fill(page, adapter, url, answers, user)
377
+ if result.status is not Status.READY:
378
+ return result
379
+ if not send:
345
380
  return result
381
+ attempts.record(mine, posting, attempts.PENDING, url=url)
382
+ try:
383
+ result.evidence = adapter.submit(page)
384
+ if result.evidence:
385
+ result.status = Status.SUBMITTED
386
+ else:
387
+ result.status, result.reason = Status.UNKNOWN, "no_evidence"
388
+ except Blocked as e:
389
+ result.status, result.reason = Status.BLOCKED, str(e)
390
+ except Exception as e: # after the click, nothing tells a failed send from a sent one
391
+ result.status = Status.UNKNOWN
392
+ result.reason = f"error_after_click: {type(e).__name__}: {e}"
393
+ attempts.record(mine, posting, result.status, evidence=result.evidence, reason=result.reason)
394
+ return result
346
395
 
347
396
 
348
397
  def read(
@@ -354,11 +403,12 @@ def read(
354
403
  ) -> Result:
355
404
  """Lists the form's fields by a request read when an adapter has one (ADR 0011), else by a
356
405
  browser read."""
357
- data = paths.data_dir(data_dir)
358
- return telemetry.sent("read", _read(data, user, url, sessions), data)
406
+ data, seen = paths.data_dir(data_dir), Seen()
407
+ result = _read(data, user, url, sessions, seen)
408
+ return telemetry.sent("read", result, data, seen.page, _secrets(data, user, {}, result))
359
409
 
360
410
 
361
- def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
411
+ def _read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
362
412
  for adapter in adapters():
363
413
  if not isinstance(adapter, ReadsByRequest):
364
414
  continue
@@ -370,7 +420,7 @@ def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
370
420
  return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
371
421
  _unfillable(result)
372
422
  return result
373
- return _in_budget(url, lambda seen: _browser_read(data, user, url, sessions, seen))
423
+ return _in_budget(url, seen, lambda: _browser_read(data, user, url, sessions, seen))
374
424
 
375
425
 
376
426
  def fill(
@@ -381,11 +431,11 @@ def fill(
381
431
  data_dir: Path | None = None,
382
432
  sessions: Sessions | None = None,
383
433
  ) -> Result:
384
- data = paths.data_dir(data_dir)
434
+ data, seen = paths.data_dir(data_dir), Seen()
385
435
  result = _in_budget(
386
- url, lambda seen: _form_command(data, user, url, answers, sessions, False, False, seen)
436
+ url, seen, lambda: _form_command(data, user, url, answers, sessions, False, False, seen)
387
437
  )
388
- return telemetry.sent("fill", result, data)
438
+ return telemetry.sent("fill", result, data, seen.page, _secrets(data, user, answers, result))
389
439
 
390
440
 
391
441
  def send(
@@ -397,11 +447,16 @@ def send(
397
447
  data_dir: Path | None = None,
398
448
  sessions: Sessions | None = None,
399
449
  ) -> Result:
400
- data = paths.data_dir(data_dir)
450
+ data, seen = paths.data_dir(data_dir), Seen()
401
451
  result = _in_budget(
402
- url, lambda seen: _form_command(data, user, url, answers, sessions, True, resend, seen)
452
+ url, seen, lambda: _form_command(data, user, url, answers, sessions, True, resend, seen)
403
453
  )
404
- return telemetry.sent("send", result, data)
454
+ return telemetry.sent("send", result, data, seen.page, _secrets(data, user, answers, result))
455
+
456
+
457
+ def report(*, data_dir: Path | None = None) -> ReportResult:
458
+ """Sends the last failed run's URL and scrubbed page, once (ADR 0016)."""
459
+ return telemetry.report(paths.data_dir(data_dir))
405
460
 
406
461
 
407
462
  def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
@@ -589,13 +644,13 @@ def _headless(
589
644
  assert isinstance(account, SignsIn)
590
645
  url = account.login_url(scope)
591
646
 
592
- def run(_: list[str]) -> Result:
647
+ def run() -> Result:
593
648
  result = Result(Status.READY, url, account.name)
594
649
  with open_page(data, None) as page, _stops(result), otp.source(user, account.name, scope):
595
650
  accounts.sign_in(page, account, scope, *credentials, session, details, create)
596
651
  return result
597
652
 
598
- return _in_budget(url, run)
653
+ return _in_budget(url, Seen(), run)
599
654
 
600
655
 
601
656
  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
@@ -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