atsfill 0.0.3.dev6__tar.gz → 0.0.3.dev7__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.dev6 → atsfill-0.0.3.dev7}/PKG-INFO +2 -2
  2. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/README.md +1 -1
  3. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/ashby.py +43 -16
  6. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/registry.toml +2 -2
  7. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/LICENSE +0 -0
  8. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/greenhouse.py +0 -0
  11. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/teamtailor.py +0 -0
  12. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/wttj.py +0 -0
  13. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/cli.py +0 -0
  14. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/__init__.py +0 -0
  15. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/accounts.py +0 -0
  16. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/adapter.py +0 -0
  17. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/attempts.py +0 -0
  18. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/browser.py +0 -0
  19. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/commands.py +0 -0
  20. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/matching.py +0 -0
  21. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/paths.py +0 -0
  22. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/registry.py +0 -0
  23. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/results.py +0 -0
  24. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/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.dev6
3
+ Version: 0.0.3.dev7
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
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
53
53
  ## Supported hiring systems
54
54
 
55
55
  <!-- badges -->
56
- ![ashby: send broken 2026-10-19](https://img.shields.io/badge/ashby-send%20broken%202026--10--19-f7768e) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
56
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
57
57
  <!-- /badges -->
58
58
 
59
59
  | System | Account needed | `read` without a browser |
@@ -29,7 +29,7 @@ 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
- ![ashby: send broken 2026-10-19](https://img.shields.io/badge/ashby-send%20broken%202026--10--19-f7768e) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
32
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev6"
3
+ version = "0.0.3.dev7"
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.dev6"
3
+ version = "0.0.3.dev7"
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,12 +1,14 @@
1
1
  """Ashby (`jobs.ashbyhq.com`, and career pages embedding it), as docs/adapters/ashby.md observes.
2
2
 
3
- Every change posts `ApiSetFormValue`, whose response holds the form as Ashby will receive it:
4
- that is the read-back, kept per form since a survey answer returns the survey alone.
3
+ Every change posts a mutation whose response holds the form as Ashby will receive it: that is
4
+ the read-back, kept per form since a survey answer returns the survey alone.
5
5
  """
6
6
 
7
7
  import json
8
8
  import time
9
9
  import urllib.request
10
+ from collections.abc import Callable
11
+ from functools import partial
10
12
  from typing import Any
11
13
  from urllib.parse import parse_qs, urlsplit
12
14
 
@@ -31,6 +33,7 @@ TYPES = {
31
33
  "String": FieldType.TEXT,
32
34
  "Email": FieldType.TEXT,
33
35
  "LongText": FieldType.TEXT,
36
+ "Url": FieldType.TEXT,
34
37
  "Date": FieldType.DATE,
35
38
  "Number": FieldType.TEXT,
36
39
  "Phone": FieldType.PHONE,
@@ -42,6 +45,7 @@ TYPES = {
42
45
  }
43
46
  SETTLE_S = 1.5
44
47
  READ_BACK_TIMEOUT_S = 30
48
+ SAVE_TIMEOUT_MS = 30_000
45
49
  SUBMIT_TIMEOUT_MS = 90_000
46
50
 
47
51
 
@@ -70,7 +74,7 @@ class Ashby:
70
74
 
71
75
  def __init__(self) -> None:
72
76
  self._forms: list[dict[str, Any]] = [] # the application form, then each survey
73
- self._pending = 0
77
+ self._pending: set[Any] = set() # GraphQL requests in flight, since `read_form`
74
78
  self._last_change = 0.0
75
79
  self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
76
80
 
@@ -132,6 +136,7 @@ class Ashby:
132
136
  return [self._field(e) for e in self._entries()]
133
137
 
134
138
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
139
+ """One save at a time: saves in flight together overwrite each other at Ashby."""
135
140
  fields = {e["field"]["path"]: self._field(e) for e in self._entries()}
136
141
  for field_id, answer in answers.items():
137
142
  entry = page.locator(f'[data-field-path="{field_id}"]')
@@ -140,30 +145,49 @@ class Ashby:
140
145
  case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
141
146
  box = entry.locator("input, textarea").first
142
147
  box.fill(answer)
143
- box.blur()
148
+ self._saved(page, box.blur)
149
+ case FieldType.CHOICE, str() if entry.locator("input[role=combobox]").count():
150
+ entry.locator("input[role=combobox]").fill(answer)
151
+ option = page.get_by_role("option", name=answer, exact=True)
152
+ self._saved(page, option.click)
144
153
  case FieldType.CHOICE, str():
145
- entry.get_by_text(answer, exact=True).click()
154
+ self._saved(page, entry.get_by_text(answer, exact=True).click)
146
155
  case FieldType.MULTI_CHOICE, list():
147
156
  for value in answer:
148
- entry.get_by_text(value, exact=True).click()
157
+ self._saved(page, entry.get_by_text(value, exact=True).click)
149
158
  case FieldType.TYPEAHEAD, str():
150
- box = entry.locator("input[role=combobox]")
151
- box.fill(answer)
152
- page.get_by_role("option").filter(has_text=answer).first.click()
159
+ entry.locator("input[role=combobox]").fill(answer)
160
+ option = page.get_by_role("option").filter(has_text=answer).first
161
+ self._saved(page, option.click)
153
162
  case FieldType.FILE, str():
154
- entry.locator("input[type=file]").set_input_files(answer)
163
+ upload = entry.locator("input[type=file]")
164
+ self._saved(page, partial(upload.set_input_files, answer))
155
165
  case kind, _:
156
166
  raise Unsupported(
157
167
  f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
158
168
  )
159
169
  self._answered = list(answers)
160
170
 
171
+ def _saved(self, page: Page, change: Callable[[], None]) -> None:
172
+ """Makes the change, then waits for its save and any other request it set off. A
173
+ change Ashby does not save shows in the read-back."""
174
+ try:
175
+ with page.expect_response(
176
+ lambda r: _op(r).startswith("ApiSetFormValue"), timeout=SAVE_TIMEOUT_MS
177
+ ):
178
+ change()
179
+ except Timeout:
180
+ return
181
+ deadline = time.monotonic() + SAVE_TIMEOUT_MS / 1000
182
+ while self._pending and time.monotonic() < deadline:
183
+ page.wait_for_timeout(100)
184
+
161
185
  def read_back(self, page: Page) -> dict[str, Answer]:
162
186
  """Ashby's last returned form, once every answer is held and no request is in flight."""
163
187
  deadline = time.monotonic() + READ_BACK_TIMEOUT_S
164
188
  while time.monotonic() < deadline:
165
189
  values = self._values()
166
- quiet = self._pending == 0 and time.monotonic() - self._last_change > SETTLE_S
190
+ quiet = not self._pending and time.monotonic() - self._last_change > SETTLE_S
167
191
  if quiet and all(values.get(i) is not None for i in self._answered):
168
192
  break
169
193
  page.wait_for_timeout(250)
@@ -248,15 +272,18 @@ class Ashby:
248
272
 
249
273
  def _on_request(self, request: Any) -> None:
250
274
  if "/api/non-user-graphql" in request.url:
251
- self._pending += 1
275
+ self._pending.add(request)
252
276
 
253
277
  def _on_finished(self, request: Any) -> None:
254
278
  if "/api/non-user-graphql" in request.url:
255
- self._pending -= 1
279
+ self._pending.discard(request)
256
280
  self._last_change = time.monotonic()
257
281
 
258
282
  def _on_response(self, response: Response) -> None:
259
- if _op(response) == "ApiSetFormValue" and response.ok:
260
- render = response.json()["data"]["setFormValue"]
261
- if render:
283
+ """Any response holding a form render: a value goes to `ApiSetFormValue`, a file to
284
+ `ApiSetFormValueToFile` once its upload ends, after saves made later."""
285
+ if not _op(response).startswith("ApiSetFormValue") or not response.ok:
286
+ return
287
+ for render in (response.json().get("data") or {}).values():
288
+ if isinstance(render, dict) and "sections" in render:
262
289
  self._replace(render)
@@ -19,8 +19,8 @@ kind = "ats"
19
19
  account = "none"
20
20
  hosting = ["hosted", "embedded"]
21
21
  adapter = "ashby"
22
- send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal", commit = "ff8ea46" }
23
- send_broken = { date = 2026-10-19, version = "0.0.2", commit = "916319c", by = "shbernal", reason = "the résumé reads back empty after its upload", cause = "unknown" }
22
+ send_verified = { date = 2026-10-19, version = "0.0.2", by = "shbernal", commit = "7622376" }
23
+ dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c230c5e" }
24
24
 
25
25
  [teamtailor]
26
26
  kind = "ats"
File without changes