atsfill 0.0.4.dev81__tar.gz → 0.0.4.dev84__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 (49) hide show
  1. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/PKG-INFO +8 -3
  2. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/README.md +6 -2
  3. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/pyproject.toml +2 -1
  4. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/pyproject.toml.orig +2 -1
  5. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/paycor.py +1 -1
  6. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/wttj.py +8 -2
  7. atsfill-0.0.4.dev84/src/atsfill/cli.py +223 -0
  8. atsfill-0.0.4.dev84/src/atsfill/core/accounts.py +111 -0
  9. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/adapter.py +42 -3
  10. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/attempts.py +7 -7
  11. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/browser.py +1 -1
  12. atsfill-0.0.4.dev84/src/atsfill/core/commands.py +536 -0
  13. atsfill-0.0.4.dev84/src/atsfill/core/passwords.py +127 -0
  14. atsfill-0.0.4.dev84/src/atsfill/core/paths.py +97 -0
  15. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/results.py +19 -13
  16. atsfill-0.0.4.dev81/src/atsfill/cli.py +0 -127
  17. atsfill-0.0.4.dev81/src/atsfill/core/accounts.py +0 -42
  18. atsfill-0.0.4.dev81/src/atsfill/core/commands.py +0 -315
  19. atsfill-0.0.4.dev81/src/atsfill/core/paths.py +0 -40
  20. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/LICENSE +0 -0
  21. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/__init__.py +0 -0
  22. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/__init__.py +0 -0
  23. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/ashby.py +0 -0
  24. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/bamboohr.py +0 -0
  25. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/breezy.py +0 -0
  26. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/dayforce.py +0 -0
  27. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/eightfold.py +0 -0
  28. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/greenhouse.py +0 -0
  29. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/hireology.py +0 -0
  30. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/hiringthing.py +0 -0
  31. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/jazzhr.py +0 -0
  32. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/lever.py +0 -0
  33. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/manatal.py +0 -0
  34. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/oracle.py +0 -0
  35. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/paylocity.py +0 -0
  36. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/personio.py +0 -0
  37. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/phenom.py +0 -0
  38. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/pinpoint.py +0 -0
  39. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/recruitee.py +0 -0
  40. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/rippling.py +0 -0
  41. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/smartrecruiters.py +0 -0
  42. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/workable.py +0 -0
  44. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/zohorecruit.py +0 -0
  45. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/__init__.py +0 -0
  46. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/matching.py +0 -0
  47. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/registry.py +0 -0
  48. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/py.typed +0 -0
  49. {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/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.dev84
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:
@@ -130,7 +131,11 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
130
131
 
131
132
  A dry run is not invisible: some systems save answers or the resume as they are typed.
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
- A `send` through an adapter no send was verified on goes ahead with a `warning`.
134
+ A dry run or a `send` through an adapter no send was verified on carries a `warning`; the send goes ahead anyway.
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`.
134
139
 
135
140
  ## How it works
136
141
 
@@ -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:
@@ -105,7 +105,11 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
105
105
 
106
106
  A dry run is not invisible: some systems save answers or the resume as they are typed.
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
- A `send` through an adapter no send was verified on goes ahead with a `warning`.
108
+ A dry run or a `send` through an adapter no send was verified on carries a `warning`; the send goes ahead anyway.
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`.
109
113
 
110
114
  ## How it works
111
115
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev81"
3
+ version = "0.0.4.dev84"
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.dev84"
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
 
@@ -0,0 +1,223 @@
1
+ import argparse
2
+ import dataclasses
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from atsfill import core
8
+ from atsfill.adapters import adapters
9
+ from atsfill.core import browser, paths
10
+ from atsfill.core.adapter import NeedsAccount
11
+ from atsfill.core.results import Result, SessionReport, Status, StatusReport
12
+
13
+ OK = {Status.READY, Status.SUBMITTED}
14
+
15
+
16
+ def session(value: str) -> tuple[str, Path]:
17
+ adapter, sep, path = value.partition("=")
18
+ if not (sep and adapter and path):
19
+ raise argparse.ArgumentTypeError(f"expected <adapter>=<path>, got {value!r}")
20
+ return adapter, Path(path)
21
+
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
+
39
+ HELP = {
40
+ "read": "list a posting's form fields",
41
+ "fill": "dry run: fill the form and read it back, never submit",
42
+ "send": "fill, read back, and submit when every answer matches",
43
+ }
44
+ EPILOG = """\
45
+ A dry run is visible to the hiring system: some save each answer or the resume as it is
46
+ typed (`status` lists each adapter's side effects). A `send` that ends `submitted` is a real
47
+ application, final. Each command prints one JSON result on one line, without the keys that
48
+ are null, false or empty, and after `fill` and `send` only the checks that failed; `-v`
49
+ prints all of it. Exit 0 for ready and submitted, 1 for any other status, 2 for a usage
50
+ error."""
51
+
52
+
53
+ def parser() -> argparse.ArgumentParser:
54
+ p = argparse.ArgumentParser(
55
+ prog="atsfill",
56
+ description="Read, dry-run and submit job application forms on hiring systems.",
57
+ epilog=EPILOG,
58
+ formatter_class=argparse.RawDescriptionHelpFormatter,
59
+ )
60
+ p.add_argument("--data-dir", type=Path, help="default: $ATSFILL_DATA_DIR, then the state dir")
61
+ sub = p.add_subparsers(dest="command", required=True)
62
+ for name in ("read", "fill", "send"):
63
+ c = with_user(
64
+ sub.add_parser(name, help=HELP[name], epilog=EPILOG, formatter_class=p.formatter_class)
65
+ )
66
+ c.add_argument("url")
67
+ c.add_argument(
68
+ "--session",
69
+ type=session,
70
+ action="append",
71
+ default=[],
72
+ metavar="ADAPTER=PATH",
73
+ help="storage state for an adapter that needs an account",
74
+ )
75
+ if name != "read":
76
+ c.add_argument(
77
+ "--answers",
78
+ type=answers_file,
79
+ required=True,
80
+ help="JSON {field_id: value} file, - for stdin",
81
+ )
82
+ sub.choices["send"].add_argument(
83
+ "--resend", action="store_true", help="send even though an earlier send may have gone"
84
+ )
85
+ sub.add_parser("status", help="version, paths, browser, and each adapter's state")
86
+ sub.add_parser("install-browser", help="download the Camoufox build atsfill is pinned to")
87
+ accounts = [a.name for a in adapters() if isinstance(a, NeedsAccount)]
88
+ for name, text in (
89
+ ("login", "sign in to a system that needs an account"),
90
+ ("logout", "delete a stored session"),
91
+ ):
92
+ c = with_user(sub.add_parser(name, help=text))
93
+ c.add_argument("adapter", choices=accounts)
94
+ c.add_argument("--url", help="a posting, for a system with an account per employer")
95
+ login = sub.choices["login"]
96
+ login.add_argument("--email", help="the account's email, remembered for it")
97
+ login.add_argument(
98
+ "--password", choices=["-"], help="read the password from stdin, for this sign-in only"
99
+ )
100
+ login.add_argument(
101
+ "--window", action="store_true", help="sign in in a visible window, by a person"
102
+ )
103
+ login.add_argument(
104
+ "--create",
105
+ action="store_true",
106
+ help="create the account with --email and a password atsfill generates and stores",
107
+ )
108
+ for name in ("read", "fill", "send", "status", "login", "logout"):
109
+ sub.choices[name].add_argument(
110
+ "-v", "--verbose", action="store_true", help="the whole result, every key, indented"
111
+ )
112
+ return p
113
+
114
+
115
+ def answers_file(path: str) -> dict:
116
+ try:
117
+ answers = json.load(sys.stdin) if path == "-" else json.loads(Path(path).read_text())
118
+ except (OSError, json.JSONDecodeError) as e:
119
+ raise argparse.ArgumentTypeError(str(e)) from e
120
+ if not isinstance(answers, dict):
121
+ raise argparse.ArgumentTypeError("expected a JSON object {field_id: value}")
122
+ return answers
123
+
124
+
125
+ def run(args: argparse.Namespace) -> int:
126
+ try:
127
+ paths.config(getattr(args, "user", paths.DEFAULT_USER))
128
+ except paths.ConfigError as e:
129
+ print(f"atsfill: {e}", file=sys.stderr)
130
+ return 2
131
+ match args.command:
132
+ case "read":
133
+ result = core.read(
134
+ args.url, user=args.user, data_dir=args.data_dir, sessions=dict(args.session)
135
+ )
136
+ case "fill":
137
+ result = core.fill(
138
+ args.url,
139
+ args.answers,
140
+ user=args.user,
141
+ data_dir=args.data_dir,
142
+ sessions=dict(args.session),
143
+ )
144
+ case "send":
145
+ result = core.send(
146
+ args.url,
147
+ args.answers,
148
+ resend=args.resend,
149
+ user=args.user,
150
+ data_dir=args.data_dir,
151
+ sessions=dict(args.session),
152
+ )
153
+ case "status":
154
+ report = core.status(data_dir=args.data_dir)
155
+ emit(report, args.verbose)
156
+ signed_out = any(s.session == "signed_out" for s in report.sessions)
157
+ return 1 if signed_out or report.browser != report.browser_pinned else 0
158
+ case "install-browser":
159
+ return browser.install()
160
+ case "login" | "logout":
161
+ where = {"user": args.user, "url": args.url, "data_dir": args.data_dir}
162
+ try:
163
+ if args.command == "logout":
164
+ session = core.logout(args.adapter, **where)
165
+ else:
166
+ password = sys.stdin.readline().rstrip("\n") if args.password else None
167
+ session = core.login(
168
+ args.adapter,
169
+ email=args.email,
170
+ password=password,
171
+ window=args.window,
172
+ create=args.create,
173
+ **where,
174
+ )
175
+ except ValueError as e:
176
+ print(f"atsfill: {e}", file=sys.stderr)
177
+ return 2
178
+ emit(session, args.verbose)
179
+ return 0 if args.command == "logout" or session.session == "signed_in" else 1
180
+ emit(result, args.verbose, args.command)
181
+ return 0 if result.status in OK else 1
182
+
183
+
184
+ def emit(
185
+ value: Result | StatusReport | SessionReport, verbose: bool, command: str | None = None
186
+ ) -> None:
187
+ """Prints the whole value with `verbose`; else what its consumer acts on, on one line."""
188
+ data = dataclasses.asdict(value)
189
+ if verbose:
190
+ json.dump(data, sys.stdout, default=str, ensure_ascii=False, indent=2)
191
+ else:
192
+ if isinstance(value, Result):
193
+ data = _slim(data, command)
194
+ json.dump(_sparse(data), sys.stdout, default=str, ensure_ascii=False, separators=(",", ":"))
195
+ sys.stdout.write("\n")
196
+
197
+
198
+ def _slim(result: dict, command: str | None) -> dict:
199
+ """A form command's result without what the consumer already has: the URL it passed and,
200
+ after `read`, the fields, the side effects and each answer it gave."""
201
+ del result["url"]
202
+ if command != "read":
203
+ del result["fields"], result["side_effects"]
204
+ result["checks"] = [
205
+ {"field_id": c["field_id"], "read_back": c["read_back"]}
206
+ for c in result["checks"]
207
+ if not c["match"]
208
+ ]
209
+ return result
210
+
211
+
212
+ def _sparse(value: object) -> object:
213
+ """Drops the keys whose value is null, false or empty, at every depth."""
214
+ if isinstance(value, dict):
215
+ kept = {k: _sparse(v) for k, v in value.items()}
216
+ return {k: v for k, v in kept.items() if v not in (None, False, [], {})}
217
+ if isinstance(value, list):
218
+ return [_sparse(v) for v in value]
219
+ return value
220
+
221
+
222
+ def main() -> None:
223
+ sys.exit(run(parser().parse_args()))
@@ -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