atsfill 0.0.4.dev81__tar.gz → 0.0.4.dev83__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 (48) hide show
  1. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/PKG-INFO +7 -2
  2. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/README.md +5 -1
  3. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/pyproject.toml +2 -1
  4. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/pyproject.toml.orig +2 -1
  5. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/paycor.py +1 -1
  6. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/wttj.py +8 -2
  7. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/cli.py +73 -16
  8. atsfill-0.0.4.dev83/src/atsfill/core/accounts.py +111 -0
  9. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/core/adapter.py +42 -3
  10. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/core/attempts.py +7 -7
  11. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/core/browser.py +1 -1
  12. atsfill-0.0.4.dev83/src/atsfill/core/commands.py +534 -0
  13. atsfill-0.0.4.dev83/src/atsfill/core/passwords.py +127 -0
  14. atsfill-0.0.4.dev83/src/atsfill/core/paths.py +97 -0
  15. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/core/results.py +18 -12
  16. atsfill-0.0.4.dev81/src/atsfill/core/accounts.py +0 -42
  17. atsfill-0.0.4.dev81/src/atsfill/core/commands.py +0 -315
  18. atsfill-0.0.4.dev81/src/atsfill/core/paths.py +0 -40
  19. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/LICENSE +0 -0
  20. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/__init__.py +0 -0
  21. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/__init__.py +0 -0
  22. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/ashby.py +0 -0
  23. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/bamboohr.py +0 -0
  24. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/breezy.py +0 -0
  25. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/dayforce.py +0 -0
  26. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/eightfold.py +0 -0
  27. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/greenhouse.py +0 -0
  28. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/hireology.py +0 -0
  29. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/hiringthing.py +0 -0
  30. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/jazzhr.py +0 -0
  31. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/lever.py +0 -0
  32. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/manatal.py +0 -0
  33. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/oracle.py +0 -0
  34. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/paylocity.py +0 -0
  35. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/personio.py +0 -0
  36. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/phenom.py +0 -0
  37. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/pinpoint.py +0 -0
  38. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/recruitee.py +0 -0
  39. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/rippling.py +0 -0
  40. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/smartrecruiters.py +0 -0
  41. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/teamtailor.py +0 -0
  42. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/workable.py +0 -0
  43. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/adapters/zohorecruit.py +0 -0
  44. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/core/__init__.py +0 -0
  45. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/core/matching.py +0 -0
  46. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/core/registry.py +0 -0
  47. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/py.typed +0 -0
  48. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev83}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev81
3
+ Version: 0.0.4.dev83
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
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Topic :: Office/Business
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Dist: camoufox==0.5.8
19
+ Requires-Dist: keyring==25.7.0
19
20
  Requires-Dist: packaging==26.3
20
21
  Requires-Python: >=3.14
21
22
  Project-URL: Homepage, https://github.com/shbernal/atsfill
@@ -119,7 +120,7 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
119
120
  4. Allow the system to submit in `~/.config/atsfill/config.toml`:
120
121
 
121
122
  ```toml
122
- submit = ["greenhouse"]
123
+ submit-allowed = ["greenhouse"]
123
124
  ```
124
125
 
125
126
  5. Send. A `submitted` result carries the evidence of the send:
@@ -132,6 +133,10 @@ A dry run is not invisible: some systems save answers or the resume as they are
132
133
  `atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
133
134
  A `send` through an adapter no send was verified on goes ahead with a `warning`.
134
135
 
136
+ One home can apply for more than one person: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
137
+ Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
138
+ Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
139
+
135
140
  ## How it works
136
141
 
137
142
  ```mermaid
@@ -94,7 +94,7 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
94
94
  4. Allow the system to submit in `~/.config/atsfill/config.toml`:
95
95
 
96
96
  ```toml
97
- submit = ["greenhouse"]
97
+ submit-allowed = ["greenhouse"]
98
98
  ```
99
99
 
100
100
  5. Send. A `submitted` result carries the evidence of the send:
@@ -107,6 +107,10 @@ A dry run is not invisible: some systems save answers or the resume as they are
107
107
  `atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
108
108
  A `send` through an adapter no send was verified on goes ahead with a `warning`.
109
109
 
110
+ One home can apply for more than one person: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
111
+ Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
112
+ Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
113
+
110
114
  ## How it works
111
115
 
112
116
  ```mermaid
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev81"
3
+ version = "0.0.4.dev83"
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"
@@ -28,6 +28,7 @@ classifiers = [
28
28
  ]
29
29
  dependencies = [
30
30
  "camoufox==0.5.8",
31
+ "keyring==25.7.0",
31
32
  "packaging==26.3",
32
33
  ]
33
34
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev81"
3
+ version = "0.0.4.dev83"
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"
@@ -21,6 +21,7 @@ classifiers = [
21
21
  ]
22
22
  dependencies = [
23
23
  "camoufox==0.5.8",
24
+ "keyring==25.7.0",
24
25
  "packaging==26.3",
25
26
  ]
26
27
 
@@ -259,7 +259,7 @@ class Paycor:
259
259
  named.nth(n).evaluate(_CHECK, option in labels)
260
260
 
261
261
  def _walk(self, page: Page, answers: dict[str, Answer] | None) -> None:
262
- """Opens the apply page and answers the pages before the form: the persona's picks
262
+ """Opens the apply page and answers the pages before the form: the Test user's picks
263
263
  when filling, the last option and Yes when only reading, which list the fields."""
264
264
  if self._start is None:
265
265
  raise RuntimeError("no Paycor job in the page")
@@ -57,7 +57,7 @@ _READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
57
57
 
58
58
  class WelcomeToTheJungle:
59
59
  name = "wttj"
60
- login_url = f"https://{HOST}/en/signin"
60
+ per_employer = False
61
61
  known_gaps: dict[str, Gap] = {}
62
62
  side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
63
63
 
@@ -75,7 +75,13 @@ class WelcomeToTheJungle:
75
75
  match = JOB.match(urlsplit(url).path)
76
76
  return f"https://{HOST}/companies/{match[1]}/jobs/{match[2]}" if match else url
77
77
 
78
- def signed_in(self, request: Request) -> bool:
78
+ def account_scope(self, url: str) -> None:
79
+ return None
80
+
81
+ def login_url(self, scope: str | None) -> str:
82
+ return f"https://{HOST}/en/signin"
83
+
84
+ def signed_in(self, request: Request, scope: str | None) -> bool:
79
85
  """`users/me` answers 200 for an account, 204 for a visitor."""
80
86
  return request.get(ME).status == 200
81
87
 
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
 
7
7
  from atsfill import core
8
8
  from atsfill.adapters import adapters
9
- from atsfill.core import browser
9
+ from atsfill.core import browser, paths
10
10
  from atsfill.core.adapter import NeedsAccount
11
11
  from atsfill.core.results import Result, SessionReport, Status, StatusReport
12
12
 
@@ -20,6 +20,22 @@ def session(value: str) -> tuple[str, Path]:
20
20
  return adapter, Path(path)
21
21
 
22
22
 
23
+ def user(value: str) -> str:
24
+ if not paths.USER_NAME.fullmatch(value):
25
+ raise argparse.ArgumentTypeError(f"lowercase letters, digits and -, got {value!r}")
26
+ return value
27
+
28
+
29
+ def with_user(command: argparse.ArgumentParser) -> argparse.ArgumentParser:
30
+ command.add_argument(
31
+ "--user",
32
+ type=user,
33
+ default=paths.DEFAULT_USER,
34
+ help="the user applications are sent for (default: %(default)s)",
35
+ )
36
+ return command
37
+
38
+
23
39
  HELP = {
24
40
  "read": "list a posting's form fields",
25
41
  "fill": "dry run: fill the form and read it back, never submit",
@@ -42,7 +58,9 @@ def parser() -> argparse.ArgumentParser:
42
58
  p.add_argument("--data-dir", type=Path, help="default: $ATSFILL_DATA_DIR, then the state dir")
43
59
  sub = p.add_subparsers(dest="command", required=True)
44
60
  for name in ("read", "fill", "send"):
45
- c = sub.add_parser(name, help=HELP[name], epilog=EPILOG, formatter_class=p.formatter_class)
61
+ c = with_user(
62
+ sub.add_parser(name, help=HELP[name], epilog=EPILOG, formatter_class=p.formatter_class)
63
+ )
46
64
  c.add_argument("url")
47
65
  c.add_argument(
48
66
  "--session",
@@ -65,11 +83,25 @@ def parser() -> argparse.ArgumentParser:
65
83
  sub.add_parser("status", help="version, paths, browser, and each adapter's state")
66
84
  sub.add_parser("install-browser", help="download the Camoufox build atsfill is pinned to")
67
85
  accounts = [a.name for a in adapters() if isinstance(a, NeedsAccount)]
68
- sub.add_parser(
69
- "login", help="sign in, in a visible window, to a system that needs an account"
70
- ).add_argument("adapter", choices=accounts)
71
- sub.add_parser("logout", help="delete a stored session").add_argument(
72
- "adapter", choices=accounts
86
+ for name, text in (
87
+ ("login", "sign in to a system that needs an account"),
88
+ ("logout", "delete a stored session"),
89
+ ):
90
+ c = with_user(sub.add_parser(name, help=text))
91
+ c.add_argument("adapter", choices=accounts)
92
+ c.add_argument("--url", help="a posting, for a system with an account per employer")
93
+ login = sub.choices["login"]
94
+ login.add_argument("--email", help="the account's email, remembered for it")
95
+ login.add_argument(
96
+ "--password", choices=["-"], help="read the password from stdin, for this sign-in only"
97
+ )
98
+ login.add_argument(
99
+ "--window", action="store_true", help="sign in in a visible window, by a person"
100
+ )
101
+ login.add_argument(
102
+ "--create",
103
+ action="store_true",
104
+ help="create the account with --email and a password atsfill generates and stores",
73
105
  )
74
106
  return p
75
107
 
@@ -85,35 +117,60 @@ def answers_file(path: str) -> dict:
85
117
 
86
118
 
87
119
  def run(args: argparse.Namespace) -> int:
120
+ try:
121
+ paths.config(getattr(args, "user", paths.DEFAULT_USER))
122
+ except paths.ConfigError as e:
123
+ print(f"atsfill: {e}", file=sys.stderr)
124
+ return 2
88
125
  match args.command:
89
126
  case "read":
90
- result = core.read(args.url, data_dir=args.data_dir, sessions=dict(args.session))
127
+ result = core.read(
128
+ args.url, user=args.user, data_dir=args.data_dir, sessions=dict(args.session)
129
+ )
91
130
  case "fill":
92
131
  result = core.fill(
93
- args.url, args.answers, data_dir=args.data_dir, sessions=dict(args.session)
132
+ args.url,
133
+ args.answers,
134
+ user=args.user,
135
+ data_dir=args.data_dir,
136
+ sessions=dict(args.session),
94
137
  )
95
138
  case "send":
96
139
  result = core.send(
97
140
  args.url,
98
141
  args.answers,
99
142
  resend=args.resend,
143
+ user=args.user,
100
144
  data_dir=args.data_dir,
101
145
  sessions=dict(args.session),
102
146
  )
103
147
  case "status":
104
148
  report = core.status(data_dir=args.data_dir)
105
149
  emit(report)
106
- signed_out = any(a.session == "signed_out" for a in report.adapters)
150
+ signed_out = any(s.session == "signed_out" for s in report.sessions)
107
151
  return 1 if signed_out or report.browser != report.browser_pinned else 0
108
152
  case "install-browser":
109
153
  return browser.install()
110
- case "login":
111
- session = core.login(args.adapter, data_dir=args.data_dir)
154
+ case "login" | "logout":
155
+ where = {"user": args.user, "url": args.url, "data_dir": args.data_dir}
156
+ try:
157
+ if args.command == "logout":
158
+ session = core.logout(args.adapter, **where)
159
+ else:
160
+ password = sys.stdin.readline().rstrip("\n") if args.password else None
161
+ session = core.login(
162
+ args.adapter,
163
+ email=args.email,
164
+ password=password,
165
+ window=args.window,
166
+ create=args.create,
167
+ **where,
168
+ )
169
+ except ValueError as e:
170
+ print(f"atsfill: {e}", file=sys.stderr)
171
+ return 2
112
172
  emit(session)
113
- return 0 if session.session == "signed_in" else 1
114
- case "logout":
115
- emit(core.logout(args.adapter, data_dir=args.data_dir))
116
- return 0
173
+ return 0 if args.command == "logout" or session.session == "signed_in" else 1
117
174
  emit(result)
118
175
  return 0 if result.status in OK else 1
119
176
 
@@ -0,0 +1,111 @@
1
+ """Stored sessions for the systems that need an account (ADRs 0007, 0015)."""
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from atsfill.core import browser
9
+ from atsfill.core.adapter import CreatesAccount, NeedsAccount, SignsIn, Unsupported
10
+ from atsfill.core.browser import Page
11
+ from atsfill.core.passwords import account_key
12
+
13
+ LOGIN_TIMEOUT_S = 600
14
+ POLL_MS = 1000
15
+ SCOPE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") # one path segment
16
+ EMAILS = "accounts.json" # in the user's directory, each account's email
17
+
18
+
19
+ def session_file(user_dir: Path, adapter: str, scope: str | None) -> Path:
20
+ """`sessions/<adapter>.json`, or `sessions/<adapter>/<scope>.json` for a scoped account."""
21
+ if scope is None:
22
+ return user_dir / "sessions" / f"{adapter}.json"
23
+ if not SCOPE.fullmatch(scope):
24
+ raise ValueError(f"{scope!r} is not an account scope")
25
+ return user_dir / "sessions" / adapter / f"{scope}.json"
26
+
27
+
28
+ def stored(user_dir: Path, adapter: str) -> list[tuple[str | None, Path]]:
29
+ """Every session the user stored for the adapter, with its scope."""
30
+ sessions = user_dir / "sessions"
31
+ whole = (
32
+ [(None, sessions / f"{adapter}.json")] if (sessions / f"{adapter}.json").exists() else []
33
+ )
34
+ return whole + [(p.stem, p) for p in sorted((sessions / adapter).glob("*.json"))]
35
+
36
+
37
+ def email(user_dir: Path, adapter: str, scope: str | None) -> str | None:
38
+ path = user_dir / EMAILS
39
+ emails = json.loads(path.read_text()) if path.exists() else {}
40
+ return emails.get(account_key(adapter, scope))
41
+
42
+
43
+ def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> None:
44
+ path = user_dir / EMAILS
45
+ emails = json.loads(path.read_text()) if path.exists() else {}
46
+ path.write_text(json.dumps(emails | {account_key(adapter, scope): address}, indent=2))
47
+
48
+
49
+ def sign_in(
50
+ page: Page,
51
+ adapter: SignsIn,
52
+ scope: str | None,
53
+ address: str,
54
+ password: str,
55
+ session: Path,
56
+ create: bool = False,
57
+ ) -> None:
58
+ """Signs in, or creates the account, from the system's login page and stores the session.
59
+ Raises what the adapter raises, without the password in it."""
60
+ page.goto(adapter.login_url(scope))
61
+ try:
62
+ if create:
63
+ assert isinstance(adapter, CreatesAccount)
64
+ adapter.sign_up(page, address, password)
65
+ else:
66
+ adapter.sign_in(page, address, password)
67
+ except Exception as e:
68
+ if password in str(e):
69
+ raise redacted(e, password) from None
70
+ raise
71
+ if not adapter.signed_in(page.request, scope):
72
+ raise Unsupported("unhandled_rendering: still signed out after signing in")
73
+ browser.save_session(page, session)
74
+
75
+
76
+ def redacted(error: Exception, password: str) -> Exception:
77
+ message = str(error).replace(password, "<password>")
78
+ try:
79
+ return type(error)(message)
80
+ except TypeError:
81
+ return RuntimeError(f"{type(error).__name__}: {message}")
82
+
83
+
84
+ def state(adapter: NeedsAccount, session: Path, scope: str | None) -> str:
85
+ """`signed_in` or `signed_out` from the system itself, `missing` with no session stored."""
86
+ if not session.exists():
87
+ return "missing"
88
+ with browser.session_request(session) as request:
89
+ return "signed_in" if adapter.signed_in(request, scope) else "signed_out"
90
+
91
+
92
+ def wait_for_person(
93
+ adapter: NeedsAccount, scope: str | None, data_dir: Path, session: Path
94
+ ) -> str | None:
95
+ """Waits for a person to sign in in the login window, then stores the session.
96
+
97
+ Returns the reason it did not: `window_closed` or `login_timeout`; None once stored.
98
+ """
99
+ with browser.login_window(data_dir, adapter.login_url(scope)) as page:
100
+ deadline = time.monotonic() + LOGIN_TIMEOUT_S
101
+ try:
102
+ while time.monotonic() < deadline:
103
+ if adapter.signed_in(page.request, scope):
104
+ browser.save_session(page, session)
105
+ return None
106
+ page.wait_for_timeout(POLL_MS)
107
+ except browser.BrowserError:
108
+ if page.is_closed():
109
+ return "window_closed"
110
+ raise
111
+ return "login_timeout"
@@ -38,6 +38,14 @@ def out_of_scope(field_id: str, what: str) -> str:
38
38
  return f"out_of_scope: {field_id} is {what}"
39
39
 
40
40
 
41
+ class Refused(Exception):
42
+ """The system turned down a sign-in or a sign-up for a reason it states:
43
+ `email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`.
44
+
45
+ The message is the result's reason, `code` or `code: detail`.
46
+ """
47
+
48
+
41
49
  class NotTaken(Exception):
42
50
  """The page would not take an answer the user gave: no suggestion or option holds it.
43
51
 
@@ -78,15 +86,46 @@ class Adapter(Protocol):
78
86
 
79
87
  @runtime_checkable
80
88
  class NeedsAccount(Protocol):
81
- """An adapter whose system needs a signed-in session (ADR 0007)."""
89
+ """An adapter whose system needs a signed-in session, one per user and account scope
90
+ (ADR 0015)."""
82
91
 
83
- login_url: str
92
+ name: str
93
+ per_employer: bool # each employer keeps its own accounts, so a scope is always given
94
+
95
+ def account_scope(self, url: str) -> str | None:
96
+ """The account scope of a posting's URL: None where an account covers the whole system,
97
+ the employer where each keeps its own, the same for every URL of that employer."""
98
+ ...
84
99
 
85
- def signed_in(self, request: Request) -> bool:
100
+ def login_url(self, scope: str | None) -> str: ...
101
+
102
+ def signed_in(self, request: Request, scope: str | None) -> bool:
86
103
  """Whether the cookies `request` carries are signed in, from one request to the system."""
87
104
  ...
88
105
 
89
106
 
107
+ @runtime_checkable
108
+ class SignsIn(NeedsAccount, Protocol):
109
+ """An adapter atsfill can sign in through with an email and a password (ADR 0015)."""
110
+
111
+ def sign_in(self, page: Page, email: str, password: str) -> None:
112
+ """Signs in on the page `login_url` loaded. Raises `Blocked` for what the system waits
113
+ for (`email_link`, `email_otp`, `sms_otp`, `captcha`), `Refused` for what it turns down,
114
+ `Unsupported` for a page it does not know."""
115
+ ...
116
+
117
+
118
+ @runtime_checkable
119
+ class CreatesAccount(SignsIn, Protocol):
120
+ """An adapter atsfill can create an account through, where the system's sign-up allows it.
121
+ Account creation is a side effect it declares (ADR 0008)."""
122
+
123
+ def sign_up(self, page: Page, email: str, password: str) -> None:
124
+ """Creates the account from the page `login_url` loaded, signed in once it returns.
125
+ Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed."""
126
+ ...
127
+
128
+
90
129
  @runtime_checkable
91
130
  class ReadsOverHttp(Adapter, Protocol):
92
131
  """An adapter that can list a form's fields over HTTP, without a browser (ADR 0011)."""
@@ -1,4 +1,4 @@
1
- """The double-send guard: `attempts.jsonl` in the data directory (ADR 0006)."""
1
+ """The double-send guard: `attempts.jsonl` in the user's directory (ADRs 0006, 0015)."""
2
2
 
3
3
  import json
4
4
  from datetime import UTC, datetime
@@ -9,8 +9,8 @@ from atsfill.core.results import Status
9
9
  PENDING = "clicked" # appended before the click; a later line records the outcome
10
10
 
11
11
 
12
- def last(data_dir: Path, posting: str) -> str | None:
13
- path = data_dir / "attempts.jsonl"
12
+ def last(user_dir: Path, posting: str) -> str | None:
13
+ path = user_dir / "attempts.jsonl"
14
14
  if not path.exists():
15
15
  return None
16
16
  outcomes = [
@@ -21,12 +21,12 @@ def last(data_dir: Path, posting: str) -> str | None:
21
21
  return outcomes[-1] if outcomes else None
22
22
 
23
23
 
24
- def blocks_send(data_dir: Path, posting: str) -> bool:
24
+ def blocks_send(user_dir: Path, posting: str) -> bool:
25
25
  """A last attempt that was clicked without an outcome counts as `unknown`."""
26
- return last(data_dir, posting) in (Status.SUBMITTED, Status.UNKNOWN, PENDING)
26
+ return last(user_dir, posting) in (Status.SUBMITTED, Status.UNKNOWN, PENDING)
27
27
 
28
28
 
29
- def record(data_dir: Path, posting: str, status: str, **extra: str | None) -> None:
29
+ def record(user_dir: Path, posting: str, status: str, **extra: str | None) -> None:
30
30
  line = {"at": datetime.now(UTC).isoformat(), "posting": posting, "status": status, **extra}
31
- with (data_dir / "attempts.jsonl").open("a") as f:
31
+ with (user_dir / "attempts.jsonl").open("a") as f:
32
32
  f.write(json.dumps(line, ensure_ascii=False) + "\n")
@@ -179,7 +179,7 @@ def login_window(data_dir: Path, url: str, headless: bool = False) -> Generator[
179
179
 
180
180
 
181
181
  def save_session(page: Page, path: Path) -> None:
182
- path.parent.mkdir(mode=0o700, exist_ok=True)
182
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
183
183
  path.touch(mode=0o600)
184
184
  page.context.storage_state(path=path)
185
185