atsfill 0.0.7.dev209__tar.gz → 0.0.7.dev212__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.7.dev209 → atsfill-0.0.7.dev212}/PKG-INFO +2 -2
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/pyproject.toml +2 -2
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/pyproject.toml.orig +2 -2
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/ashby.py +15 -3
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/adapter.py +10 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/browser.py +12 -13
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/commands.py +47 -11
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/matching.py +34 -5
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/telemetry.py +2 -1
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/LICENSE +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/README.md +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/adp.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/wellfound.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/werecruit.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.7.dev209 → atsfill-0.0.7.dev212}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.7.
|
|
3
|
+
Version: 0.0.7.dev212
|
|
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,7 +16,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Classifier: Topic :: Office/Business
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: camoufox==0.5.
|
|
19
|
+
Requires-Dist: camoufox==0.5.9b6
|
|
20
20
|
Requires-Dist: filelock==4.1.1
|
|
21
21
|
Requires-Dist: keyring==25.7.0
|
|
22
22
|
Requires-Dist: packaging==26.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.7.
|
|
3
|
+
version = "0.0.7.dev212"
|
|
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,7 +28,7 @@ classifiers = [
|
|
|
28
28
|
"Typing :: Typed",
|
|
29
29
|
]
|
|
30
30
|
dependencies = [
|
|
31
|
-
"camoufox==0.5.
|
|
31
|
+
"camoufox==0.5.9b6",
|
|
32
32
|
"filelock==4.1.1",
|
|
33
33
|
"keyring==25.7.0",
|
|
34
34
|
"packaging==26.3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.7.
|
|
3
|
+
version = "0.0.7.dev212"
|
|
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,7 +21,7 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"camoufox==0.5.
|
|
24
|
+
"camoufox==0.5.9b6",
|
|
25
25
|
"filelock==4.1.1",
|
|
26
26
|
"keyring==25.7.0",
|
|
27
27
|
"packaging==26.3",
|
|
@@ -83,6 +83,7 @@ class Ashby:
|
|
|
83
83
|
self._loaded: Response | None = None # the ApiJobPosting response `open_form` caught
|
|
84
84
|
self._last_change = 0.0
|
|
85
85
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
86
|
+
self._answered: list[str] = []
|
|
86
87
|
|
|
87
88
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
88
89
|
"""`jobs.ashbyhq.com`, or a career page whose Ashby embed opens the `ashby_jid` job."""
|
|
@@ -148,6 +149,7 @@ class Ashby:
|
|
|
148
149
|
if job is None:
|
|
149
150
|
raise Unsupported("posting_gone")
|
|
150
151
|
self._forms = [job["applicationForm"], *job["surveyForms"]]
|
|
152
|
+
self._answered = []
|
|
151
153
|
page.wait_for_selector(".ashby-application-form-submit-button")
|
|
152
154
|
return [self._field(e) for e in self._entries()]
|
|
153
155
|
|
|
@@ -185,7 +187,7 @@ class Ashby:
|
|
|
185
187
|
raise Unsupported(
|
|
186
188
|
f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
187
189
|
)
|
|
188
|
-
self._answered
|
|
190
|
+
self._answered += answers
|
|
189
191
|
|
|
190
192
|
def _saved(self, page: Page, change: Callable[[], None]) -> None:
|
|
191
193
|
"""Makes the change, then waits for its save and any other request it set off. A
|
|
@@ -201,8 +203,8 @@ class Ashby:
|
|
|
201
203
|
while self._pending and time.monotonic() < deadline:
|
|
202
204
|
page.wait_for_timeout(SAVE_POLL_MS)
|
|
203
205
|
|
|
204
|
-
def
|
|
205
|
-
"""
|
|
206
|
+
def _settle(self, page: Page) -> None:
|
|
207
|
+
"""Until every answer is held and no request is in flight."""
|
|
206
208
|
deadline = time.monotonic() + READ_BACK_TIMEOUT_S
|
|
207
209
|
while time.monotonic() < deadline:
|
|
208
210
|
values = self._values()
|
|
@@ -210,6 +212,16 @@ class Ashby:
|
|
|
210
212
|
if quiet and all(values.get(i) is not None for i in self._answered):
|
|
211
213
|
break
|
|
212
214
|
page.wait_for_timeout(READ_BACK_POLL_MS)
|
|
215
|
+
|
|
216
|
+
def revealed(self, page: Page) -> list[Field]:
|
|
217
|
+
"""The fields of Ashby's last returned forms: only an answer's render holds a follow-up,
|
|
218
|
+
the posting's query carries no trace of it or of its condition."""
|
|
219
|
+
self._settle(page)
|
|
220
|
+
return [self._field(e) for e in self._entries()]
|
|
221
|
+
|
|
222
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
223
|
+
"""Ashby's last returned form, once settled."""
|
|
224
|
+
self._settle(page)
|
|
213
225
|
return {k: v for k, v in self._values().items() if v is not None}
|
|
214
226
|
|
|
215
227
|
def submit(self, page: Page) -> str | None:
|
|
@@ -307,6 +307,16 @@ class ReadsByRequest(Adapter, Protocol):
|
|
|
307
307
|
...
|
|
308
308
|
|
|
309
309
|
|
|
310
|
+
@runtime_checkable
|
|
311
|
+
class RevealsFields(Adapter, Protocol):
|
|
312
|
+
"""An adapter whose system keeps a follow-up's condition to itself: no read can list the
|
|
313
|
+
field, only a saved answer shows it, so the fill finds it and answers it (ADR 0005)."""
|
|
314
|
+
|
|
315
|
+
def revealed(self, page: Page) -> list[Field]:
|
|
316
|
+
"""Every field the filled page shows, each as `browser_read` lists it."""
|
|
317
|
+
...
|
|
318
|
+
|
|
319
|
+
|
|
310
320
|
@runtime_checkable
|
|
311
321
|
class ListsShownFields(Adapter, Protocol):
|
|
312
322
|
"""An adapter whose form can show fields after an answer: once filled, it says which the
|
|
@@ -18,10 +18,10 @@ from urllib.parse import urljoin
|
|
|
18
18
|
from urllib.request import Request as HttpRequest
|
|
19
19
|
from urllib.request import urlopen
|
|
20
20
|
|
|
21
|
-
from camoufox.addons import
|
|
21
|
+
from camoufox.addons import DefaultAddons, get_addon_path
|
|
22
22
|
from camoufox.exceptions import CamoufoxNotInstalled
|
|
23
23
|
from camoufox.fingerprints import Screen, generate_fingerprint
|
|
24
|
-
from camoufox.pkgman import installed_verstr
|
|
24
|
+
from camoufox.pkgman import INSTALL_DIR, installed_verstr, launch_path
|
|
25
25
|
from camoufox.sync_api import Camoufox
|
|
26
26
|
from filelock import FileLock
|
|
27
27
|
from filelock import Timeout as LockTimeout
|
|
@@ -58,8 +58,8 @@ BUDGET = 180.0 # seconds per command, lock wait included
|
|
|
58
58
|
PROFILE_BUDGET = 300.0 # seconds for a `profile` run, which may save every profile step
|
|
59
59
|
LOCK_POLL_S = 0.1
|
|
60
60
|
HTTP_TIMEOUT_S = 20 # per request to a hiring system's API
|
|
61
|
-
BUILD = "
|
|
62
|
-
BLOCKER =
|
|
61
|
+
BUILD = "158.0.1-beta.41" # the Camoufox browser build, pinned so results compare across machines
|
|
62
|
+
BLOCKER = INSTALL_DIR / "atsfill" / f"UBO-{BUILD}" # the build's uBlock Origin, annoyance lists on
|
|
63
63
|
BLOCKER_LISTS = "annoyances" # the group of uBlock Origin lists: cookie banners, pop-ups, widgets
|
|
64
64
|
# The adapters' filters for the banners the lists miss: a list of the blocker's, on like them.
|
|
65
65
|
FILTERS = "assets/atsfill/filters.txt"
|
|
@@ -88,14 +88,13 @@ def version() -> str:
|
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
def install() -> int:
|
|
91
|
-
"""Fetches the pinned browser build unless it is installed, then the
|
|
92
|
-
loads, so that browsers started side by side do not each
|
|
91
|
+
"""Fetches the pinned browser build unless it is installed, then builds the blocker every
|
|
92
|
+
launch loads, so that browsers started side by side do not each build it."""
|
|
93
93
|
if version() != BUILD:
|
|
94
|
-
subprocess.call([sys.executable, "-m", "camoufox", "fetch"
|
|
94
|
+
subprocess.call([sys.executable, "-m", "camoufox", "fetch"]) # the package's paired build
|
|
95
95
|
if version() != BUILD: # `camoufox fetch` exits 0 when it fails
|
|
96
96
|
print(f"the Camoufox build {BUILD} is not installed", file=sys.stderr)
|
|
97
97
|
return 1
|
|
98
|
-
maybe_download_addons(list(DefaultAddons))
|
|
99
98
|
try:
|
|
100
99
|
_build_blocker()
|
|
101
100
|
except OSError as e:
|
|
@@ -107,12 +106,13 @@ def install() -> int:
|
|
|
107
106
|
def _build_blocker() -> None:
|
|
108
107
|
"""Copies Camoufox's uBlock Origin to `BLOCKER` with only its annoyance lists on, read
|
|
109
108
|
from disk: fetched by each launch, they would hold its first page for seconds, and each
|
|
110
|
-
list on is compiled by each launch before the blocker starts.
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
list on is compiled by each launch before the blocker starts. Camoufox's policy keeps it
|
|
110
|
+
from updating them: its first update, 37s into a launch, left it hiding nothing."""
|
|
111
|
+
BLOCKER.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
with FileLock(BLOCKER.with_name(f"{BLOCKER.name}.lock")):
|
|
113
113
|
build = BLOCKER.with_name(f"{BLOCKER.name}.build")
|
|
114
114
|
shutil.rmtree(build, ignore_errors=True)
|
|
115
|
-
shutil.copytree(get_addon_path(DefaultAddons.UBO.name), build)
|
|
115
|
+
shutil.copytree(get_addon_path(DefaultAddons.UBO.name, launch_path()), build)
|
|
116
116
|
assets = build / "assets" / "assets.json"
|
|
117
117
|
entries = json.loads(assets.read_text())
|
|
118
118
|
for key, entry in entries.items():
|
|
@@ -186,7 +186,6 @@ def lock(data_dir: Path, deadline: float) -> Generator[None]:
|
|
|
186
186
|
@contextmanager
|
|
187
187
|
def _launch(data_dir: Path, headless: bool) -> Generator[Browser]:
|
|
188
188
|
if not (BLOCKER / FILTERS).exists(): # installed before the blocker, or its filters, were
|
|
189
|
-
maybe_download_addons(list(DefaultAddons))
|
|
190
189
|
_build_blocker()
|
|
191
190
|
_write_filters()
|
|
192
191
|
with Camoufox(
|
|
@@ -34,6 +34,7 @@ from atsfill.core.adapter import (
|
|
|
34
34
|
ProfileIncomplete,
|
|
35
35
|
ReadsByRequest,
|
|
36
36
|
Refused,
|
|
37
|
+
RevealsFields,
|
|
37
38
|
SendsToOpen,
|
|
38
39
|
SignedOut,
|
|
39
40
|
SignsIn,
|
|
@@ -353,6 +354,38 @@ def _shown_as_listed(
|
|
|
353
354
|
result.reason = f"mismatch: {', '.join(wrong)} shows though its condition is unmet"
|
|
354
355
|
|
|
355
356
|
|
|
357
|
+
def _no_field(result: Result, answers: dict[str, Answer]) -> bool:
|
|
358
|
+
"""Refuses an answer to no listed field. Ids come from `read`, so it says as much about the
|
|
359
|
+
adapter as about the answers: a form that changed, or a field read missed."""
|
|
360
|
+
listed = matching.known(result.fields, answers)
|
|
361
|
+
if unknown := [i for i in answers if i not in listed]:
|
|
362
|
+
result.status, result.reason = Status.REFUSED, f"unknown_field: {', '.join(unknown)}"
|
|
363
|
+
return bool(unknown)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _revealed(
|
|
367
|
+
page: Page, adapter: RevealsFields, result: Result, answers: dict[str, Answer]
|
|
368
|
+
) -> dict[str, Answer]:
|
|
369
|
+
"""Fills what the saved answers reveal, until they reveal nothing more, and returns the
|
|
370
|
+
answers the form took. A revealed field no answer takes refuses, naming what it asks, since
|
|
371
|
+
`fill` prints no fields (ADR 0005)."""
|
|
372
|
+
mine = matching.as_listed(result.fields, matching.known(result.fields, answers))
|
|
373
|
+
while new := [f for f in adapter.revealed(page) if f.id not in {g.id for g in result.fields}]:
|
|
374
|
+
_listed(result, result.fields + new)
|
|
375
|
+
mine = matching.as_listed(result.fields, matching.known(result.fields, answers))
|
|
376
|
+
if _unfillable(result, mine):
|
|
377
|
+
return mine
|
|
378
|
+
if problems := matching.problems(result.fields, mine):
|
|
379
|
+
problems += [f"{f.id} takes {f.options}" for f in new if f.options and f.id not in mine]
|
|
380
|
+
result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
|
|
381
|
+
return mine
|
|
382
|
+
left = [f for f in new if f.id not in mine]
|
|
383
|
+
result.warnings += [f"an answer showed {f.id} ({f.label}), left empty" for f in left]
|
|
384
|
+
adapter.fill(page, matching.to_fill(new, {f.id: mine[f.id] for f in new if f.id in mine}))
|
|
385
|
+
_no_field(result, answers)
|
|
386
|
+
return mine
|
|
387
|
+
|
|
388
|
+
|
|
356
389
|
def _no_adapter(url: str) -> Result:
|
|
357
390
|
return Result(Status.UNSUPPORTED, url, reason="no_adapter: no adapter for this hiring system")
|
|
358
391
|
|
|
@@ -420,17 +453,23 @@ def _fill(
|
|
|
420
453
|
result = _result(adapter, url, user)
|
|
421
454
|
with _stops(result):
|
|
422
455
|
_listed(result, _read_signed_in(page, adapter, mine, user, url))
|
|
423
|
-
|
|
456
|
+
held_back = isinstance(adapter, RevealsFields) # an answer may be to a field yet to show
|
|
457
|
+
given = matching.known(result.fields, answers) if held_back else answers
|
|
458
|
+
if _unfillable(result, given) or _no_field(result, given):
|
|
424
459
|
return result
|
|
425
|
-
|
|
460
|
+
given = matching.as_listed(result.fields, given)
|
|
461
|
+
if problems := matching.problems(result.fields, given):
|
|
426
462
|
result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
|
|
427
463
|
return result
|
|
428
|
-
result.warnings = matching.knockouts(result.fields,
|
|
429
|
-
adapter.fill(page, matching.to_fill(result.fields,
|
|
430
|
-
|
|
464
|
+
result.warnings = matching.knockouts(result.fields, given)
|
|
465
|
+
adapter.fill(page, matching.to_fill(result.fields, given))
|
|
466
|
+
if isinstance(adapter, RevealsFields):
|
|
467
|
+
given = _revealed(page, adapter, result, answers)
|
|
468
|
+
else:
|
|
469
|
+
_shown_as_listed(page, adapter, result, given)
|
|
431
470
|
if result.status is not Status.READY:
|
|
432
471
|
return result
|
|
433
|
-
result.checks = matching.check(result.fields,
|
|
472
|
+
result.checks = matching.check(result.fields, given, adapter.read_back(page))
|
|
434
473
|
if mismatched := [c.field_id for c in result.checks if not c.match]:
|
|
435
474
|
result.status, result.reason = Status.REFUSED, f"mismatch: {', '.join(mismatched)}"
|
|
436
475
|
return result
|
|
@@ -650,7 +689,7 @@ def _save_profile(
|
|
|
650
689
|
fields = adapter.profile_read(page)
|
|
651
690
|
_listed(result, result.fields + fields)
|
|
652
691
|
ids = {f.id for f in fields}
|
|
653
|
-
mine = {k: v for k, v in answers.items() if k in ids}
|
|
692
|
+
mine = matching.as_listed(fields, {k: v for k, v in answers.items() if k in ids})
|
|
654
693
|
step_result = Result(Status.READY, result.url, fields=fields)
|
|
655
694
|
if _unfillable(step_result, mine):
|
|
656
695
|
result.status, result.reason = step_result.status, step_result.reason
|
|
@@ -669,10 +708,7 @@ def _save_profile(
|
|
|
669
708
|
adapter.save(page)
|
|
670
709
|
result.saved.append(step)
|
|
671
710
|
result.checks += checks
|
|
672
|
-
|
|
673
|
-
if unknown := [k for k in answers if k not in listed]:
|
|
674
|
-
result.status = Status.REFUSED
|
|
675
|
-
result.reason = "invalid_answers: " + "; ".join(f"no field {k}" for k in unknown)
|
|
711
|
+
_no_field(result, answers)
|
|
676
712
|
|
|
677
713
|
|
|
678
714
|
def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
|
|
@@ -136,13 +136,42 @@ def _answer_problem(field: Field, answer: object, form: Form) -> str | None:
|
|
|
136
136
|
return None
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
def known(fields: list[Field], answers: dict[str, Answer]) -> dict[str, Answer]:
|
|
140
|
+
"""The answers to fields of the form."""
|
|
141
|
+
ids = {f.id for f in fields}
|
|
142
|
+
return {i: a for i, a in answers.items() if i in ids}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def as_listed(fields: list[Field], answers: dict[str, Answer]) -> dict[str, Answer]:
|
|
146
|
+
"""Each choice as its field spells the option, where only whitespace or Unicode form tells
|
|
147
|
+
them apart: a stray space in a page's option is not the answer's to copy (ADR 0005)."""
|
|
148
|
+
by_id = {f.id: f for f in fields}
|
|
149
|
+
|
|
150
|
+
def spelled(field: Field, value: str) -> str:
|
|
151
|
+
sets = [o for s in field.option_sets or [] for o in s.options]
|
|
152
|
+
options = [*(field.options or []), *sets]
|
|
153
|
+
if value in options:
|
|
154
|
+
return value
|
|
155
|
+
return next((o for o in options if _norm(o) == _norm(value)), value)
|
|
156
|
+
|
|
157
|
+
out = dict(answers)
|
|
158
|
+
for i, answer in answers.items():
|
|
159
|
+
field = by_id.get(i)
|
|
160
|
+
if not field or field.type not in (FieldType.CHOICE, FieldType.MULTI_CHOICE):
|
|
161
|
+
continue
|
|
162
|
+
if isinstance(answer, str):
|
|
163
|
+
out[i] = spelled(field, answer)
|
|
164
|
+
elif isinstance(answer, list) and all(isinstance(v, str) for v in answer):
|
|
165
|
+
out[i] = [spelled(field, v) for v in answer]
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
139
169
|
def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
140
|
-
"""What refuses
|
|
141
|
-
alternative answered, answers to follow-ups the answers keep hidden, bad values."""
|
|
170
|
+
"""What refuses answers to the form's fields before typing: missing required answers, more
|
|
171
|
+
than one alternative answered, answers to follow-ups the answers keep hidden, bad values."""
|
|
142
172
|
by_id = {f.id: f for f in fields}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
out += [
|
|
173
|
+
form = Form(fields, known(fields, answers))
|
|
174
|
+
out = [
|
|
146
175
|
f"no answer for required {f.id} ({f.label})"
|
|
147
176
|
for f in fields
|
|
148
177
|
if form.required(f) and f.prefilled is None and not f.unfillable and f.id not in answers
|
|
@@ -42,7 +42,8 @@ REPORT = (
|
|
|
42
42
|
"run `atsfill report` to send this run's URL and page, scrubbed of your answers,"
|
|
43
43
|
" to the maintainer: " + DOCS
|
|
44
44
|
)
|
|
45
|
-
# Reason codes that say nothing about the adapter: the user's input or a fact about the posting
|
|
45
|
+
# Reason codes that say nothing about the adapter: the user's input or a fact about the posting.
|
|
46
|
+
# `unknown_field` is not one: ids come from `read`, so it can be a field read missed.
|
|
46
47
|
EXCLUDED = (
|
|
47
48
|
"invalid_answers",
|
|
48
49
|
"answer_not_taken",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|