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.
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/PKG-INFO +8 -3
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/README.md +6 -2
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/pyproject.toml +2 -1
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/pyproject.toml.orig +2 -1
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/paycor.py +1 -1
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/wttj.py +8 -2
- atsfill-0.0.4.dev84/src/atsfill/cli.py +223 -0
- atsfill-0.0.4.dev84/src/atsfill/core/accounts.py +111 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/adapter.py +42 -3
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/attempts.py +7 -7
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/browser.py +1 -1
- atsfill-0.0.4.dev84/src/atsfill/core/commands.py +536 -0
- atsfill-0.0.4.dev84/src/atsfill/core/passwords.py +127 -0
- atsfill-0.0.4.dev84/src/atsfill/core/paths.py +97 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/results.py +19 -13
- atsfill-0.0.4.dev81/src/atsfill/cli.py +0 -127
- atsfill-0.0.4.dev81/src/atsfill/core/accounts.py +0 -42
- atsfill-0.0.4.dev81/src/atsfill/core/commands.py +0 -315
- atsfill-0.0.4.dev81/src/atsfill/core/paths.py +0 -40
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/LICENSE +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev81 → atsfill-0.0.4.dev84}/src/atsfill/py.typed +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
+
"""An adapter whose system needs a signed-in session, one per user and account scope
|
|
90
|
+
(ADR 0015)."""
|
|
82
91
|
|
|
83
|
-
|
|
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
|
|
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
|
|
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(
|
|
13
|
-
path =
|
|
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(
|
|
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(
|
|
26
|
+
return last(user_dir, posting) in (Status.SUBMITTED, Status.UNKNOWN, PENDING)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def record(
|
|
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 (
|
|
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
|
|