atsfill 0.0.3.dev3__tar.gz → 0.0.3.dev5__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 (24) hide show
  1. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/PKG-INFO +4 -3
  2. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/README.md +3 -2
  3. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/cli.py +2 -1
  6. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/browser.py +4 -2
  7. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/commands.py +27 -11
  8. atsfill-0.0.3.dev5/src/atsfill/core/registry.py +153 -0
  9. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/results.py +5 -1
  10. atsfill-0.0.3.dev5/src/atsfill/registry.toml +39 -0
  11. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/LICENSE +0 -0
  12. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/adapters/__init__.py +0 -0
  14. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/adapters/ashby.py +0 -0
  15. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/adapters/teamtailor.py +0 -0
  17. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/adapters/wttj.py +0 -0
  18. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/__init__.py +0 -0
  19. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/accounts.py +0 -0
  20. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/adapter.py +0 -0
  21. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/attempts.py +0 -0
  22. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/matching.py +0 -0
  23. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/core/paths.py +0 -0
  24. {atsfill-0.0.3.dev3 → atsfill-0.0.3.dev5}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.3.dev3
3
+ Version: 0.0.3.dev5
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
@@ -124,7 +124,8 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
124
124
  ```
125
125
 
126
126
  A dry run is not invisible: some systems save answers or the resume as they are typed.
127
- `atsfill status` lists each system's side effects and whether it is allowed to submit.
127
+ `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).
128
+ A `send` through an adapter no send was verified on goes ahead with a `warning`.
128
129
 
129
130
  ## How it works
130
131
 
@@ -147,7 +148,7 @@ The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error
147
148
  ## More
148
149
 
149
150
  - [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
150
- - [Hiring systems](docs/adapters/): each system's form as observed.
151
+ - [Hiring systems](docs/adapters/README.md): every system mapped so far, in backlog order, and each one's form as observed.
151
152
  - `atsfill --help` for every command and option.
152
153
 
153
154
  [pypi]: https://pypi.org/project/atsfill/
@@ -100,7 +100,8 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
100
100
  ```
101
101
 
102
102
  A dry run is not invisible: some systems save answers or the resume as they are typed.
103
- `atsfill status` lists each system's side effects and whether it is allowed to submit.
103
+ `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).
104
+ A `send` through an adapter no send was verified on goes ahead with a `warning`.
104
105
 
105
106
  ## How it works
106
107
 
@@ -123,7 +124,7 @@ The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error
123
124
  ## More
124
125
 
125
126
  - [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
126
- - [Hiring systems](docs/adapters/): each system's form as observed.
127
+ - [Hiring systems](docs/adapters/README.md): every system mapped so far, in backlog order, and each one's form as observed.
127
128
  - `atsfill --help` for every command and option.
128
129
 
129
130
  [pypi]: https://pypi.org/project/atsfill/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev3"
3
+ version = "0.0.3.dev5"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev3"
3
+ version = "0.0.3.dev5"
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"
@@ -103,7 +103,8 @@ def run(args: argparse.Namespace) -> int:
103
103
  case "status":
104
104
  report = core.status(data_dir=args.data_dir)
105
105
  emit(report)
106
- return 1 if any(a.session == "signed_out" for a in report.adapters) else 0
106
+ signed_out = any(a.session == "signed_out" for a in report.adapters)
107
+ return 1 if signed_out or report.browser != report.browser_pinned else 0
107
108
  case "install-browser":
108
109
  return browser.install()
109
110
  case "login":
@@ -22,6 +22,7 @@ from playwright.sync_api import Error as BrowserError
22
22
  from playwright.sync_api import TimeoutError as Timeout
23
23
 
24
24
  __all__ = [
25
+ "BUILD",
25
26
  "BrowserError",
26
27
  "Covered",
27
28
  "Locator",
@@ -43,6 +44,7 @@ __all__ = [
43
44
  MIN_SCREEN_HEIGHT = 1050 # leaves the window the 900px Greenhouse and Teamtailor need
44
45
  HOST_OS = {"Linux": "linux", "Darwin": "macos", "Windows": "windows"}[platform.system()]
45
46
  BUDGET = 180.0 # seconds per command, lock wait included
47
+ BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
46
48
 
47
49
 
48
50
  class OutOfTime(Exception):
@@ -62,8 +64,8 @@ def version() -> str:
62
64
 
63
65
 
64
66
  def install() -> int:
65
- """Fetches the browser build the pinned camoufox release pairs with."""
66
- return subprocess.call([sys.executable, "-m", "camoufox", "fetch"])
67
+ """Fetches the pinned browser build."""
68
+ return subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
67
69
 
68
70
 
69
71
  def identity(data_dir: Path) -> dict:
@@ -4,7 +4,7 @@ from importlib.metadata import version
4
4
  from pathlib import Path
5
5
 
6
6
  from atsfill.adapters import ADAPTERS
7
- from atsfill.core import accounts, attempts, browser, matching, paths
7
+ from atsfill.core import accounts, attempts, browser, matching, paths, registry
8
8
  from atsfill.core.adapter import Adapter, Blocked, NeedsAccount, Unsupported
9
9
  from atsfill.core.browser import Covered, OutOfTime, Page, open_page
10
10
  from atsfill.core.results import (
@@ -138,6 +138,7 @@ def _form_command(
138
138
  result = _fill(page, adapter, url, answers)
139
139
  if not send or result.status is not Status.READY:
140
140
  return result
141
+ result.warning = _unverified(adapter.name)
141
142
  attempts.record(data, posting, attempts.PENDING, url=url)
142
143
  try:
143
144
  result.evidence = adapter.submit(page)
@@ -194,6 +195,15 @@ def send(
194
195
  )
195
196
 
196
197
 
198
+ def _unverified(adapter: str) -> str | None:
199
+ """The warning a send carries when no send through the adapter was verified (ADR 0012)."""
200
+ system = registry.for_adapter(adapter)
201
+ if system and system.send_verified:
202
+ return None
203
+ state = system.state if system else None
204
+ return f"not_send_verified: {adapter} is {state or 'not in the registry'}"
205
+
206
+
197
207
  def _session_state(adapter: Adapter, data: Path) -> str | None:
198
208
  if not isinstance(adapter, NeedsAccount):
199
209
  return None
@@ -208,16 +218,22 @@ def status(*, data_dir: Path | None = None) -> StatusReport:
208
218
  data_dir=data,
209
219
  config=paths.config_file(),
210
220
  browser=browser.version(),
211
- adapters=[
212
- AdapterState(
213
- a.name,
214
- submit_enabled=paths.submit_enabled(a.name),
215
- side_effects=a.side_effects,
216
- side_effects_acknowledged=paths.acknowledged(a.name),
217
- session=_session_state(a, data),
218
- )
219
- for a in ADAPTERS
220
- ],
221
+ browser_pinned=browser.BUILD,
222
+ adapters=[_adapter_state(a, data) for a in ADAPTERS],
223
+ )
224
+
225
+
226
+ def _adapter_state(adapter: Adapter, data: Path) -> AdapterState:
227
+ system = registry.for_adapter(adapter.name)
228
+ verified = system.state if system else None
229
+ return AdapterState(
230
+ adapter.name,
231
+ submit_enabled=paths.submit_enabled(adapter.name),
232
+ side_effects=adapter.side_effects,
233
+ side_effects_acknowledged=paths.acknowledged(adapter.name),
234
+ verified=verified,
235
+ verified_version=getattr(system, verified).version if system and verified else None,
236
+ session=_session_state(adapter, data),
221
237
  )
222
238
 
223
239
 
@@ -0,0 +1,153 @@
1
+ """The committed registry of hiring systems (ADR 0012)."""
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass, fields
5
+ from datetime import date
6
+ from functools import cache
7
+ from importlib.resources import files
8
+
9
+ KINDS = ("ats", "board", "aggregator", "custom")
10
+ ACCOUNTS = ("none", "to_send", "to_read") # also the backlog order (ADR 0013)
11
+ HOSTING = ("hosted", "embedded")
12
+ VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "unknown")
13
+ STEPS = ("posting", "form", "upload")
14
+ STATES = ("mapped", "dry_run_verified", "send_verified")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Verification:
19
+ date: date
20
+ version: str
21
+ by: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Block:
26
+ vendor: str
27
+ signal: str
28
+ step: str
29
+ date: date
30
+ version: str
31
+ camoufox: str
32
+ browser: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class System:
37
+ source: str
38
+ kind: str
39
+ account: str
40
+ hosting: tuple[str, ...]
41
+ adapter: str | None = None
42
+ share: float | None = None
43
+ block: Block | None = None
44
+ mapped: Verification | None = None
45
+ dry_run_verified: Verification | None = None
46
+ send_verified: Verification | None = None
47
+
48
+ @property
49
+ def state(self) -> str | None:
50
+ """The furthest state reached; each one implies the ones before it."""
51
+ return next((s for s in reversed(STATES) if getattr(self, s)), None)
52
+
53
+
54
+ def _one_of(source: str, key: str, value: object, allowed: tuple[str, ...]) -> None:
55
+ if value not in allowed:
56
+ raise ValueError(f"{source}: {key} = {value!r}, expected one of {', '.join(allowed)}")
57
+
58
+
59
+ def _system(source: str, entry: dict) -> System:
60
+ known = {f.name for f in fields(System)} - {"source"}
61
+ if unknown := entry.keys() - known:
62
+ raise ValueError(f"{source}: unknown keys {', '.join(sorted(unknown))}")
63
+ _one_of(source, "kind", entry.get("kind"), KINDS)
64
+ _one_of(source, "account", entry.get("account"), ACCOUNTS)
65
+ for h in entry.get("hosting", []):
66
+ _one_of(source, "hosting", h, HOSTING)
67
+ block = entry.get("block")
68
+ if block is not None:
69
+ _one_of(source, "block.vendor", block.get("vendor"), VENDORS)
70
+ _one_of(source, "block.step", block.get("step"), STEPS)
71
+ block = Block(**block)
72
+ states = {s: Verification(**entry[s]) for s in STATES if s in entry}
73
+ return System(
74
+ source,
75
+ entry["kind"],
76
+ entry["account"],
77
+ tuple(entry.get("hosting", [])),
78
+ entry.get("adapter"),
79
+ entry.get("share"),
80
+ block,
81
+ **states,
82
+ )
83
+
84
+
85
+ def parse(text: str) -> dict[str, System]:
86
+ return {source: _system(source, entry) for source, entry in tomllib.loads(text).items()}
87
+
88
+
89
+ @cache
90
+ def systems() -> dict[str, System]:
91
+ return parse(files("atsfill").joinpath("registry.toml").read_text())
92
+
93
+
94
+ def with_state(text: str, source: str, state: str, v: Verification) -> str:
95
+ """The registry file's text with `state` of `source` set to `v`, the rest untouched."""
96
+ lines = text.splitlines()
97
+ start = lines.index(f"[{source}]")
98
+ end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("[")), len(lines))
99
+ while lines[end - 1] == "":
100
+ end -= 1
101
+ entry = f'{state} = {{ date = {v.date}, version = "{v.version}", by = "{v.by}" }}'
102
+ at = next((i for i in range(start, end) if lines[i].startswith(f"{state} =")), None)
103
+ if at is None:
104
+ lines.insert(end, entry)
105
+ else:
106
+ lines[at] = entry
107
+ return "\n".join(lines) + "\n"
108
+
109
+
110
+ def for_adapter(name: str) -> System | None:
111
+ return next((s for s in systems().values() if s.adapter == name), None)
112
+
113
+
114
+ def _backlog_key(s: System) -> tuple:
115
+ return (s.block is not None, ACCOUNTS.index(s.account), -(s.share or 0), s.source)
116
+
117
+
118
+ def _row(s: System) -> str:
119
+ share = f"{s.share:.1%}" if s.share is not None else ""
120
+ block = f"{s.block.vendor} at {s.block.step}" if s.block else ""
121
+ state = s.state or ""
122
+ if s.adapter:
123
+ state = f"[{state}]({s.adapter}.md)" if state else f"[adapter]({s.adapter}.md)"
124
+ cells = [s.source, s.kind, share, s.account, ", ".join(s.hosting), block, state]
125
+ return "| " + " | ".join(cells) + " |"
126
+
127
+
128
+ def backlog(registry: dict[str, System] | None = None) -> str:
129
+ """`docs/adapters/README.md`: the systems in backlog order (ADR 0013)."""
130
+ registry = systems() if registry is None else registry
131
+ head = "| Source | Kind | Share | Account | Hosting | Block | State |\n" + "|---" * 7 + "|"
132
+ adapters = sorted(
133
+ (s for s in registry.values() if s.kind in ("ats", "board")), key=_backlog_key
134
+ )
135
+ others = sorted(
136
+ (s for s in registry.values() if s.kind not in ("ats", "board")), key=_backlog_key
137
+ )
138
+ text = [
139
+ "# Hiring systems",
140
+ "",
141
+ "Generated from [src/atsfill/registry.toml](../../src/atsfill/registry.toml) by"
142
+ " `uv run scripts/backlog.py`. The registry is described in"
143
+ " [ADR 0012](../adr/0012-systems-registry.md), the order in"
144
+ " [ADR 0013](../adr/0013-survey-and-adapter-rounds.md): no account first, blocked last,"
145
+ " then by share of postings.",
146
+ "",
147
+ head,
148
+ *map(_row, adapters),
149
+ ]
150
+ if others:
151
+ text += ["", "Aggregators and company-built forms, counted and not adapted:", "", head]
152
+ text += map(_row, others)
153
+ return "\n".join(text) + "\n"
@@ -61,6 +61,7 @@ class Result:
61
61
  checks: list[Check] = field(default_factory=list)
62
62
  side_effects: list[SideEffect] = field(default_factory=list)
63
63
  evidence: str | None = None
64
+ warning: str | None = None # `code: detail`, on a send that went ahead anyway
64
65
 
65
66
 
66
67
  @dataclass
@@ -69,6 +70,8 @@ class AdapterState:
69
70
  submit_enabled: bool
70
71
  side_effects: list[SideEffect]
71
72
  side_effects_acknowledged: bool
73
+ verified: str | None = None # mapped | dry_run_verified | send_verified (ADR 0012)
74
+ verified_version: str | None = None
72
75
  session: str | None = None # signed_in | signed_out | missing, for a system with accounts
73
76
 
74
77
 
@@ -77,7 +80,8 @@ class StatusReport:
77
80
  version: str
78
81
  data_dir: Path
79
82
  config: Path
80
- browser: str
83
+ browser: str # the installed build
84
+ browser_pinned: str
81
85
  adapters: list[AdapterState]
82
86
 
83
87
 
@@ -0,0 +1,39 @@
1
+ # The hiring systems atsfill has mapped (ADR 0012), one table per freehire `source`.
2
+ # `docs/adapters/README.md` is generated from this file: `uv run scripts/backlog.py`.
3
+ #
4
+ # kind ats | board | aggregator | custom
5
+ # share fraction of freehire's postings, worldwide, from its `source` facet
6
+ # account none | to_send | to_read
7
+ # hosting hosted, embedded: where the form is found
8
+ # adapter the adapter's name, when there is one
9
+ # block vendor, signal, step (posting | form | upload), date, version, camoufox, browser
10
+ # mapped, dry_run_verified, send_verified
11
+ # date, version, by; only the user sets send_verified
12
+
13
+ [ashby]
14
+ kind = "ats"
15
+ account = "none"
16
+ hosting = ["hosted", "embedded"]
17
+ adapter = "ashby"
18
+ send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
19
+
20
+ [teamtailor]
21
+ kind = "ats"
22
+ account = "none"
23
+ hosting = ["hosted"]
24
+ adapter = "teamtailor"
25
+ send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
26
+
27
+ [greenhouse]
28
+ kind = "ats"
29
+ account = "none"
30
+ hosting = ["hosted", "embedded"]
31
+ adapter = "greenhouse"
32
+ send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
33
+
34
+ [wttj]
35
+ kind = "board"
36
+ account = "to_send"
37
+ hosting = ["hosted"]
38
+ adapter = "wttj"
39
+ send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
File without changes