atsfill 0.0.5.dev140__tar.gz → 0.0.5.dev142__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.5.dev140 → atsfill-0.0.5.dev142}/PKG-INFO +7 -5
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/README.md +5 -4
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/pyproject.toml +2 -1
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/pyproject.toml.orig +2 -1
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/cli.py +2 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/commands.py +11 -29
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/paths.py +8 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/registry.py +43 -100
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/results.py +2 -5
- atsfill-0.0.5.dev142/src/atsfill/core/telemetry.py +163 -0
- atsfill-0.0.5.dev142/src/atsfill/registry.toml +398 -0
- atsfill-0.0.5.dev140/src/atsfill/registry.toml +0 -442
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/LICENSE +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev140 → atsfill-0.0.5.dev142}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev142
|
|
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
|
|
@@ -20,6 +20,7 @@ Requires-Dist: camoufox==0.5.8
|
|
|
20
20
|
Requires-Dist: filelock==4.1.1
|
|
21
21
|
Requires-Dist: keyring==25.7.0
|
|
22
22
|
Requires-Dist: packaging==26.3
|
|
23
|
+
Requires-Dist: posthog==7.78.0
|
|
23
24
|
Requires-Python: >=3.14
|
|
24
25
|
Project-URL: Homepage, https://github.com/shbernal/atsfill
|
|
25
26
|
Project-URL: Repository, https://github.com/shbernal/atsfill
|
|
@@ -57,10 +58,10 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
58
|
## Supported hiring systems
|
|
58
59
|
|
|
59
60
|
<!-- badges -->
|
|
60
|
-
                             
|
|
61
62
|
<!-- /badges -->
|
|
62
63
|
|
|
63
|
-
Each badge is a hiring system atsfill has an adapter for
|
|
64
|
+
Each badge is a hiring system atsfill has an adapter for: `supported` once a dry run passed on five employers, with its date.
|
|
64
65
|
[The systems](docs/adapters/README.md) lists which ones need an account, to read the form or only to send it.
|
|
65
66
|
Any other system returns `unsupported`, so the consumer can fall back to driving a browser itself.
|
|
66
67
|
|
|
@@ -127,8 +128,7 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
|
|
|
127
128
|
```
|
|
128
129
|
|
|
129
130
|
A dry run is not invisible: some systems save answers or the resume as they are typed.
|
|
130
|
-
`atsfill status` lists each system's side effects, whether it is allowed to submit, and
|
|
131
|
-
A dry run or a `send` through an adapter no send was verified on carries a `warning`; the send goes ahead anyway.
|
|
131
|
+
`atsfill status` lists each system's side effects, whether it is allowed to submit, and its scope: [the systems](docs/adapters/README.md).
|
|
132
132
|
|
|
133
133
|
One home can send for more than one user: `--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)).
|
|
134
134
|
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.
|
|
@@ -153,6 +153,8 @@ 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).
|
|
157
|
+
|
|
156
158
|
## More
|
|
157
159
|
|
|
158
160
|
- [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
|
|
@@ -29,10 +29,10 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                             
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
|
-
Each badge is a hiring system atsfill has an adapter for
|
|
35
|
+
Each badge is a hiring system atsfill has an adapter for: `supported` once a dry run passed on five employers, with its date.
|
|
36
36
|
[The systems](docs/adapters/README.md) lists which ones need an account, to read the form or only to send it.
|
|
37
37
|
Any other system returns `unsupported`, so the consumer can fall back to driving a browser itself.
|
|
38
38
|
|
|
@@ -99,8 +99,7 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
|
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
A dry run is not invisible: some systems save answers or the resume as they are typed.
|
|
102
|
-
`atsfill status` lists each system's side effects, whether it is allowed to submit, and
|
|
103
|
-
A dry run or a `send` through an adapter no send was verified on carries a `warning`; the send goes ahead anyway.
|
|
102
|
+
`atsfill status` lists each system's side effects, whether it is allowed to submit, and its scope: [the systems](docs/adapters/README.md).
|
|
104
103
|
|
|
105
104
|
One home can send for more than one user: `--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)).
|
|
106
105
|
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.
|
|
@@ -125,6 +124,8 @@ The core owns every rule about when a send is allowed; an adapter only knows its
|
|
|
125
124
|
Every result has a `status` (`ready`, `refused`, `submitted`, `blocked`, `unknown`, `unsupported`) and, when it is not clean, a `reason` code.
|
|
126
125
|
The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error.
|
|
127
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).
|
|
128
|
+
|
|
128
129
|
## More
|
|
129
130
|
|
|
130
131
|
- [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.5.
|
|
3
|
+
version = "0.0.5.dev142"
|
|
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"
|
|
@@ -32,6 +32,7 @@ dependencies = [
|
|
|
32
32
|
"filelock==4.1.1",
|
|
33
33
|
"keyring==25.7.0",
|
|
34
34
|
"packaging==26.3",
|
|
35
|
+
"posthog==7.78.0",
|
|
35
36
|
]
|
|
36
37
|
|
|
37
38
|
[[project.authors]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.5.
|
|
3
|
+
version = "0.0.5.dev142"
|
|
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"
|
|
@@ -25,6 +25,7 @@ dependencies = [
|
|
|
25
25
|
"filelock==4.1.1",
|
|
26
26
|
"keyring==25.7.0",
|
|
27
27
|
"packaging==26.3",
|
|
28
|
+
"posthog==7.78.0",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.urls]
|
|
@@ -190,6 +190,8 @@ def run(args: argparse.Namespace) -> int:
|
|
|
190
190
|
return 2
|
|
191
191
|
emit(session, args.verbose)
|
|
192
192
|
return 0 if args.command == "logout" or session.session == "signed_in" else 1
|
|
193
|
+
if result.notice:
|
|
194
|
+
print(f"atsfill: {result.notice}", file=sys.stderr)
|
|
193
195
|
emit(result, args.verbose, args.command)
|
|
194
196
|
return 0 if result.status in OK else 1
|
|
195
197
|
|
|
@@ -15,6 +15,7 @@ from atsfill.core import (
|
|
|
15
15
|
passwords,
|
|
16
16
|
paths,
|
|
17
17
|
registry,
|
|
18
|
+
telemetry,
|
|
18
19
|
)
|
|
19
20
|
from atsfill.core.adapter import (
|
|
20
21
|
Adapter,
|
|
@@ -324,7 +325,6 @@ def _form_command(
|
|
|
324
325
|
result = _fill(page, adapter, url, answers, user)
|
|
325
326
|
if result.status is not Status.READY:
|
|
326
327
|
return result
|
|
327
|
-
result.warning = _unverified(adapter.name)
|
|
328
328
|
if not send:
|
|
329
329
|
return result
|
|
330
330
|
attempts.record(mine, posting, attempts.PENDING, url=url)
|
|
@@ -354,6 +354,11 @@ def read(
|
|
|
354
354
|
) -> Result:
|
|
355
355
|
"""Lists the form's fields by a request read when an adapter has one (ADR 0011), else by a
|
|
356
356
|
browser read."""
|
|
357
|
+
data = paths.data_dir(data_dir)
|
|
358
|
+
return telemetry.sent("read", _read(data, user, url, sessions), data)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
|
|
357
362
|
for adapter in adapters():
|
|
358
363
|
if not isinstance(adapter, ReadsByRequest):
|
|
359
364
|
continue
|
|
@@ -365,7 +370,6 @@ def read(
|
|
|
365
370
|
return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
|
|
366
371
|
_unfillable(result)
|
|
367
372
|
return result
|
|
368
|
-
data = paths.data_dir(data_dir)
|
|
369
373
|
return _in_budget(url, lambda seen: _browser_read(data, user, url, sessions, seen))
|
|
370
374
|
|
|
371
375
|
|
|
@@ -378,9 +382,10 @@ def fill(
|
|
|
378
382
|
sessions: Sessions | None = None,
|
|
379
383
|
) -> Result:
|
|
380
384
|
data = paths.data_dir(data_dir)
|
|
381
|
-
|
|
385
|
+
result = _in_budget(
|
|
382
386
|
url, lambda seen: _form_command(data, user, url, answers, sessions, False, False, seen)
|
|
383
387
|
)
|
|
388
|
+
return telemetry.sent("fill", result, data)
|
|
384
389
|
|
|
385
390
|
|
|
386
391
|
def send(
|
|
@@ -393,21 +398,10 @@ def send(
|
|
|
393
398
|
sessions: Sessions | None = None,
|
|
394
399
|
) -> Result:
|
|
395
400
|
data = paths.data_dir(data_dir)
|
|
396
|
-
|
|
401
|
+
result = _in_budget(
|
|
397
402
|
url, lambda seen: _form_command(data, user, url, answers, sessions, True, resend, seen)
|
|
398
403
|
)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def _unverified(adapter: str) -> str | None:
|
|
402
|
-
"""The warning a ready dry run or a send carries when no send through the adapter was
|
|
403
|
-
verified, or the last verified one fails now (ADR 0012)."""
|
|
404
|
-
source = registry.for_adapter(adapter)
|
|
405
|
-
if source and source.send_broken:
|
|
406
|
-
return f"send_broken: {_broken(source)}"
|
|
407
|
-
if source and source.send_verified:
|
|
408
|
-
return None
|
|
409
|
-
verification = source.verification if source else None
|
|
410
|
-
return f"not_send_verified: {adapter} is {verification or 'not in the registry'}"
|
|
404
|
+
return telemetry.sent("send", result, data)
|
|
411
405
|
|
|
412
406
|
|
|
413
407
|
def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
|
|
@@ -439,26 +433,14 @@ def status(*, data_dir: Path | None = None) -> StatusReport:
|
|
|
439
433
|
)
|
|
440
434
|
|
|
441
435
|
|
|
442
|
-
def _broken(source: registry.Source) -> str:
|
|
443
|
-
broken, last = source.send_broken, source.send_verified
|
|
444
|
-
assert broken
|
|
445
|
-
since = f"send broken since {broken.date} ({broken.version}), {broken.reason}"
|
|
446
|
-
return since + (f"; last verified {last.date} ({last.version})" if last else "")
|
|
447
|
-
|
|
448
|
-
|
|
449
436
|
def _adapter_status(adapter: Adapter, users: list[str]) -> AdapterStatus:
|
|
450
437
|
source = registry.for_adapter(adapter.name)
|
|
451
|
-
verified = source.verification if source else None
|
|
452
|
-
v = getattr(source, verified) if source and verified else None
|
|
453
438
|
return AdapterStatus(
|
|
454
439
|
adapter.name,
|
|
455
440
|
submit_allowed=[u for u in users if paths.submit_enabled(adapter.name, u)],
|
|
456
441
|
side_effects=adapter.side_effects,
|
|
457
442
|
side_effects_muted=[u for u in users if paths.muted(adapter.name, u)],
|
|
458
|
-
|
|
459
|
-
verified_version=v.version if v else None,
|
|
460
|
-
verified_release=registry.release(v.version, version("atsfill")) if v else None,
|
|
461
|
-
send_broken=_broken(source) if source and source.send_broken else None,
|
|
443
|
+
scope=source.scope.value if source else None,
|
|
462
444
|
)
|
|
463
445
|
|
|
464
446
|
|
|
@@ -12,6 +12,7 @@ TEST_USER = "test"
|
|
|
12
12
|
USER_NAME = re.compile(r"[a-z0-9-]+")
|
|
13
13
|
RENAMED = {"submit": "submit-allowed", "ack_side_effects": "mute-side-effect-warnings"}
|
|
14
14
|
MOVED = ("sessions", "attempts.jsonl") # a data directory from before users, the default's
|
|
15
|
+
TELEMETRY = ("off", "basic", "detailed") # the first is no telemetry (ADR 0016)
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class ConfigError(Exception):
|
|
@@ -72,6 +73,8 @@ def _config() -> dict:
|
|
|
72
73
|
for old, new in RENAMED.items():
|
|
73
74
|
if old in section:
|
|
74
75
|
raise ConfigError(f"`{old}` in {path} is now `{new}`")
|
|
76
|
+
if (level := whole.get("telemetry", "basic")) not in TELEMETRY:
|
|
77
|
+
raise ConfigError(f"telemetry = {level!r} in {path}: one of {', '.join(TELEMETRY)}")
|
|
75
78
|
return whole
|
|
76
79
|
|
|
77
80
|
|
|
@@ -84,6 +87,11 @@ def config(user: str) -> dict:
|
|
|
84
87
|
return whole.get("users", {}).get(user, {})
|
|
85
88
|
|
|
86
89
|
|
|
90
|
+
def telemetry() -> str:
|
|
91
|
+
"""The home's telemetry level, a top-level key: `basic` unless the config says otherwise."""
|
|
92
|
+
return _config().get("telemetry", "basic")
|
|
93
|
+
|
|
94
|
+
|
|
87
95
|
def submit_enabled(adapter: str, user: str) -> bool:
|
|
88
96
|
"""Whether the user's `submit-allowed` lists the adapter: there is no other way to enable
|
|
89
97
|
it."""
|
|
@@ -1,61 +1,32 @@
|
|
|
1
1
|
"""The committed registry of freehire sources (ADR 0012)."""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
3
4
|
import tomllib
|
|
4
5
|
from dataclasses import dataclass, fields
|
|
5
6
|
from datetime import date
|
|
6
7
|
from functools import cache
|
|
7
8
|
from importlib.resources import files
|
|
8
|
-
|
|
9
|
-
from packaging.version import Version
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
10
|
|
|
11
11
|
KINDS = ("ats", "board", "aggregator", "custom")
|
|
12
12
|
ACCOUNTS = ("none", "to_send", "to_read") # also the backlog order (ADR 0013)
|
|
13
13
|
HOSTING = ("hosted", "embedded")
|
|
14
14
|
VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "hCaptcha", "unknown")
|
|
15
15
|
STEPS = ("posting", "form", "upload")
|
|
16
|
-
|
|
17
|
-
CAUSES = ("system", "code", "unknown")
|
|
18
|
-
RELEASES = ("this_release", "older", "newer")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass(frozen=True)
|
|
22
|
-
class Verification:
|
|
23
|
-
date: date
|
|
24
|
-
version: str # the release, compared with the installed one
|
|
25
|
-
by: str
|
|
26
|
-
commit: str | None = None # the exact code
|
|
16
|
+
SCOPES = ("out_of_scope", "in_scope", "supported")
|
|
27
17
|
|
|
28
18
|
|
|
29
19
|
@dataclass(frozen=True)
|
|
30
|
-
class
|
|
31
|
-
"""
|
|
20
|
+
class Scope:
|
|
21
|
+
"""Where a source stands (ADR 0016). `supported`: a dev dry run passed at `commit`, never
|
|
22
|
+
demoted. `in_scope`: wanted, its `reason` the known gap a round stopped at, if one did.
|
|
23
|
+
`out_of_scope`: never filled, it needs the user live (ADR 0014). A `reason` names fields
|
|
24
|
+
and steps, never postings."""
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
commit: str
|
|
36
|
-
|
|
37
|
-
reason: str
|
|
38
|
-
cause: str = "unknown"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass(frozen=True)
|
|
42
|
-
class Shortfall:
|
|
43
|
-
"""A round that ended without an adapter passing the adapter gate (ADR 0013): still wanted.
|
|
44
|
-
`reason` names the known gap that stopped it, never postings."""
|
|
45
|
-
|
|
46
|
-
date: date
|
|
47
|
-
version: str
|
|
48
|
-
commit: str
|
|
49
|
-
by: str
|
|
50
|
-
reason: str
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@dataclass(frozen=True)
|
|
54
|
-
class OutOfScope:
|
|
55
|
-
"""A source atsfill will never fill: it needs the user live (ADR 0014)."""
|
|
56
|
-
|
|
57
|
-
reason: str
|
|
58
|
-
date: date
|
|
26
|
+
value: str = "in_scope"
|
|
27
|
+
date: datetime.date | None = None
|
|
28
|
+
commit: str | None = None
|
|
29
|
+
reason: str | None = None
|
|
59
30
|
|
|
60
31
|
|
|
61
32
|
@dataclass(frozen=True)
|
|
@@ -76,20 +47,11 @@ class Source:
|
|
|
76
47
|
account: str
|
|
77
48
|
hosting: tuple[str, ...]
|
|
78
49
|
adapter: str | None = None
|
|
50
|
+
hosts: tuple[str, ...] = () # a source without an adapter: where its postings are
|
|
79
51
|
share: float | None = None
|
|
80
52
|
links_to: tuple[str, ...] = () # an aggregator's: the sources its postings link to
|
|
81
53
|
block: Block | None = None
|
|
82
|
-
|
|
83
|
-
dry_run_verified: Verification | None = None
|
|
84
|
-
send_verified: Verification | None = None # the last known good, kept through a break
|
|
85
|
-
send_broken: Broken | None = None
|
|
86
|
-
unsupported: Shortfall | None = None
|
|
87
|
-
out_of_scope: OutOfScope | None = None
|
|
88
|
-
|
|
89
|
-
@property
|
|
90
|
-
def verification(self) -> str | None:
|
|
91
|
-
"""The furthest verification reached; each one implies the ones before it."""
|
|
92
|
-
return next((s for s in reversed(VERIFICATIONS) if getattr(self, s)), None)
|
|
54
|
+
scope: Scope = Scope()
|
|
93
55
|
|
|
94
56
|
|
|
95
57
|
def _one_of(name: str, key: str, value: object, allowed: tuple[str, ...]) -> None:
|
|
@@ -112,26 +74,19 @@ def _source(name: str, entry: dict) -> Source:
|
|
|
112
74
|
_one_of(name, "block.vendor", block.get("vendor"), VENDORS)
|
|
113
75
|
_one_of(name, "block.step", block.get("step"), STEPS)
|
|
114
76
|
block = Block(**block)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if broken is not None:
|
|
118
|
-
_one_of(name, "send_broken.cause", broken.get("cause", "unknown"), CAUSES)
|
|
119
|
-
broken = Broken(**broken)
|
|
120
|
-
unsupported = entry.get("unsupported")
|
|
121
|
-
out_of_scope = entry.get("out_of_scope")
|
|
77
|
+
scope = dict(entry.get("scope", {"is": "in_scope"}))
|
|
78
|
+
_one_of(name, "scope.is", scope.get("is"), SCOPES)
|
|
122
79
|
return Source(
|
|
123
80
|
name,
|
|
124
81
|
entry["kind"],
|
|
125
82
|
entry["account"],
|
|
126
83
|
tuple(entry.get("hosting", [])),
|
|
127
84
|
entry.get("adapter"),
|
|
85
|
+
tuple(entry.get("hosts", [])),
|
|
128
86
|
entry.get("share"),
|
|
129
87
|
tuple(entry.get("links_to", [])),
|
|
130
88
|
block,
|
|
131
|
-
**
|
|
132
|
-
send_broken=broken,
|
|
133
|
-
unsupported=Shortfall(**unsupported) if unsupported is not None else None,
|
|
134
|
-
out_of_scope=OutOfScope(**out_of_scope) if out_of_scope is not None else None,
|
|
89
|
+
Scope(scope.pop("is"), **scope),
|
|
135
90
|
)
|
|
136
91
|
|
|
137
92
|
|
|
@@ -148,52 +103,47 @@ def _toml(value: object) -> str:
|
|
|
148
103
|
return str(value) if isinstance(value, date) else f'"{value}"'
|
|
149
104
|
|
|
150
105
|
|
|
151
|
-
def
|
|
152
|
-
text
|
|
153
|
-
) -> str:
|
|
154
|
-
"""The registry file's text with `key` of `source` set to `value`, the rest untouched.
|
|
155
|
-
Writing `send_verified` ends a `send_broken`."""
|
|
106
|
+
def with_scope(text: str, source: str, scope: Scope) -> str:
|
|
107
|
+
"""The registry file's text with `source`'s scope set, the rest untouched."""
|
|
156
108
|
lines = text.splitlines()
|
|
157
109
|
start = lines.index(f"[{source}]")
|
|
158
110
|
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("[")), len(lines))
|
|
159
111
|
while lines[end - 1] == "":
|
|
160
112
|
end -= 1
|
|
161
|
-
|
|
162
|
-
entry = lines[start:end]
|
|
163
|
-
at = next((i for i, x in enumerate(entry) if x.split(" =")[0] in gone), len(entry))
|
|
164
|
-
entry = [x for x in entry if x.split(" =")[0] not in gone]
|
|
113
|
+
entry = [x for x in lines[start:end] if not x.startswith("scope =")]
|
|
165
114
|
pairs = ", ".join(
|
|
166
|
-
f"{f.name} = {_toml(getattr(
|
|
167
|
-
for f in fields(
|
|
168
|
-
if getattr(
|
|
115
|
+
f"{'is' if f.name == 'value' else f.name} = {_toml(getattr(scope, f.name))}"
|
|
116
|
+
for f in fields(scope)
|
|
117
|
+
if getattr(scope, f.name) is not None
|
|
169
118
|
)
|
|
170
|
-
entry.
|
|
119
|
+
entry.append(f"scope = {{ {pairs} }}")
|
|
171
120
|
return "\n".join(lines[:start] + entry + lines[end:]) + "\n"
|
|
172
121
|
|
|
173
122
|
|
|
174
|
-
def release(verified: str, installed: str) -> str:
|
|
175
|
-
"""Whether a verified release is the installed one, `older` or `newer`."""
|
|
176
|
-
a, b = (Version(Version(v).public) for v in (verified, installed))
|
|
177
|
-
return "this_release" if a == b else "older" if a < b else "newer"
|
|
178
|
-
|
|
179
|
-
|
|
180
123
|
def for_adapter(name: str) -> Source | None:
|
|
181
124
|
return next((s for s in sources().values() if s.adapter == name), None)
|
|
182
125
|
|
|
183
126
|
|
|
127
|
+
def for_url(url: str) -> Source | None:
|
|
128
|
+
"""The source whose `hosts` a URL is on, a subdomain included."""
|
|
129
|
+
host = urlsplit(url).hostname or ""
|
|
130
|
+
on = lambda h: host == h or host.endswith(f".{h}") # noqa: E731
|
|
131
|
+
return next((s for s in sources().values() if any(map(on, s.hosts))), None)
|
|
132
|
+
|
|
133
|
+
|
|
184
134
|
def _backlog_key(s: Source) -> tuple:
|
|
185
|
-
stopped = s.block is not None or s.
|
|
135
|
+
stopped = s.block is not None or s.scope.reason is not None
|
|
186
136
|
return (stopped, ACCOUNTS.index(s.account), -(s.share or 0), s.name)
|
|
187
137
|
|
|
188
138
|
|
|
189
139
|
def _row(s: Source) -> str:
|
|
190
140
|
share = f"{s.share:.1%}" if s.share is not None else ""
|
|
191
141
|
block = f"{s.block.vendor} at {s.block.step}" if s.block else ""
|
|
192
|
-
v =
|
|
193
|
-
if s.
|
|
194
|
-
v
|
|
142
|
+
v = s.scope.value.replace("_", " ")
|
|
143
|
+
if s.scope.reason:
|
|
144
|
+
v += f": {s.scope.reason}"
|
|
195
145
|
if s.adapter:
|
|
196
|
-
v = f"[{v}]({s.adapter}.md)"
|
|
146
|
+
v = f"[{v}]({s.adapter}.md)"
|
|
197
147
|
kind = f"{s.kind} → {', '.join(s.links_to)}" if s.links_to else s.kind
|
|
198
148
|
cells = [s.name, kind, share, s.account, ", ".join(s.hosting), block, v]
|
|
199
149
|
return "| " + " | ".join(cells) + " |"
|
|
@@ -202,9 +152,9 @@ def _row(s: Source) -> str:
|
|
|
202
152
|
def backlog(registry: dict[str, Source] | None = None) -> str:
|
|
203
153
|
"""`docs/adapters/README.md`: the systems in backlog order (ADR 0013)."""
|
|
204
154
|
registry = sources() if registry is None else registry
|
|
205
|
-
columns = ("Source", "Kind", "Share", "Account", "Hosting", "Block", "
|
|
155
|
+
columns = ("Source", "Kind", "Share", "Account", "Hosting", "Block", "Scope")
|
|
206
156
|
head = f"| {' | '.join(columns)} |\n" + "|---" * len(columns) + "|"
|
|
207
|
-
wanted = lambda s: s.kind in ("ats", "board") and
|
|
157
|
+
wanted = lambda s: s.kind in ("ats", "board") and s.scope.value != "out_of_scope" # noqa: E731
|
|
208
158
|
adapters = sorted((s for s in registry.values() if wanted(s)), key=_backlog_key)
|
|
209
159
|
others = sorted((s for s in registry.values() if not wanted(s)), key=_backlog_key)
|
|
210
160
|
text = [
|
|
@@ -214,7 +164,7 @@ def backlog(registry: dict[str, Source] | None = None) -> str:
|
|
|
214
164
|
" `uv run python -m scripts.backlog`. The registry is described in"
|
|
215
165
|
" [ADR 0012](../adr/0012-systems-registry.md), the order in"
|
|
216
166
|
" [ADR 0013](../adr/0013-survey-and-adapter-rounds.md): no account first, blocked and"
|
|
217
|
-
"
|
|
167
|
+
" stopped by a known gap last, then by share of postings.",
|
|
218
168
|
"",
|
|
219
169
|
f"The registry covers {sum(s.share or 0 for s in registry.values()):.1%} of postings.",
|
|
220
170
|
"",
|
|
@@ -234,19 +184,12 @@ def backlog(registry: dict[str, Source] | None = None) -> str:
|
|
|
234
184
|
|
|
235
185
|
|
|
236
186
|
BADGES = ("<!-- badges -->", "<!-- /badges -->")
|
|
237
|
-
COLORS = {"
|
|
187
|
+
COLORS = {"supported": "9ece6a", "in_scope": "e0af68", "out_of_scope": "565f89"}
|
|
238
188
|
|
|
239
189
|
|
|
240
190
|
def _badge(s: Source) -> str:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
elif s.unsupported:
|
|
244
|
-
message, color = f"unsupported {s.unsupported.date}", "e0af68"
|
|
245
|
-
elif s.verification:
|
|
246
|
-
v: Verification = getattr(s, s.verification)
|
|
247
|
-
message, color = f"{s.verification.replace('_', ' ')} {v.date}", COLORS[s.verification]
|
|
248
|
-
else:
|
|
249
|
-
message, color = "unverified", "565f89"
|
|
191
|
+
message = " ".join(str(x) for x in (s.scope.value.replace("_", " "), s.scope.date) if x)
|
|
192
|
+
color = COLORS[s.scope.value]
|
|
250
193
|
escape = lambda t: t.replace("-", "--").replace("_", "__").replace(" ", "%20") # noqa: E731
|
|
251
194
|
url = f"https://img.shields.io/badge/{escape(s.adapter or s.name)}-{escape(message)}-{color}"
|
|
252
195
|
return f""
|
|
@@ -101,7 +101,7 @@ class Result:
|
|
|
101
101
|
checks: list[Check] = field(default_factory=list)
|
|
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
105
|
|
|
106
106
|
|
|
107
107
|
@dataclass
|
|
@@ -122,10 +122,7 @@ class AdapterStatus:
|
|
|
122
122
|
submit_allowed: list[str] # the users whose config lets it submit
|
|
123
123
|
side_effects: list[SideEffect]
|
|
124
124
|
side_effects_muted: list[str] # the users who muted them
|
|
125
|
-
|
|
126
|
-
verified_version: str | None = None
|
|
127
|
-
verified_release: str | None = None # this_release | older | newer than the installed one
|
|
128
|
-
send_broken: str | None = None # since when and why, with the last verified send
|
|
125
|
+
scope: str | None = None # supported | in_scope | out_of_scope, None when not in the registry
|
|
129
126
|
|
|
130
127
|
|
|
131
128
|
@dataclass
|