atsfill 0.0.4.dev99__tar.gz → 0.0.4.dev101__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.dev99 → atsfill-0.0.4.dev101}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/bamboohr.py +26 -10
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/oracle.py +12 -3
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/paycor.py +48 -1
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/phenom.py +73 -28
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/smartrecruiters.py +7 -1
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/cli.py +7 -2
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/conditions.py +17 -4
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/matching.py +26 -10
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/results.py +9 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/LICENSE +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/README.md +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev99 → atsfill-0.0.4.dev101}/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.dev101
|
|
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
|
|
@@ -17,7 +17,7 @@ from urllib.parse import urlsplit
|
|
|
17
17
|
from atsfill.core import browser
|
|
18
18
|
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
19
19
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
20
|
-
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
21
21
|
|
|
22
22
|
HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
|
|
23
23
|
JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
|
|
@@ -411,25 +411,41 @@ def _form(
|
|
|
411
411
|
if key == "countryId" and country:
|
|
412
412
|
by_id = {str(o["id"]): o["text"] for o in entry.get("options") or []}
|
|
413
413
|
shown = by_id.get(str(country), country if country in by_id.values() else None)
|
|
414
|
+
option_sets = None
|
|
414
415
|
if key == "state" and options:
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
countries = result["formFields"].get("countryId") or {}
|
|
417
|
+
options, option_sets = None, _states(states, countries.get("options") or [])
|
|
418
|
+
if key == "state" and not option_sets:
|
|
417
419
|
kind = "text"
|
|
418
420
|
if kind == "veteran":
|
|
419
421
|
extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
|
|
420
422
|
options = list(VETERAN) + list(extra.values())
|
|
421
423
|
accept = FILE_TYPES if kind == "file" else None
|
|
422
|
-
field = Field(
|
|
424
|
+
field = Field(
|
|
425
|
+
key,
|
|
426
|
+
label,
|
|
427
|
+
TYPES[kind],
|
|
428
|
+
required,
|
|
429
|
+
options,
|
|
430
|
+
None,
|
|
431
|
+
accept,
|
|
432
|
+
default=shown,
|
|
433
|
+
option_sets=option_sets,
|
|
434
|
+
)
|
|
423
435
|
out.append((field, (kind, name, extra)))
|
|
424
436
|
return out
|
|
425
437
|
|
|
426
438
|
|
|
427
|
-
def _states(states: dict[str, Any],
|
|
428
|
-
"""The state
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
439
|
+
def _states(states: dict[str, Any], countries: list[dict[str, Any]]) -> list[OptionSet]:
|
|
440
|
+
"""The state list of each country that has one, under that country as the form lists it;
|
|
441
|
+
any other country's state is typed."""
|
|
442
|
+
by_id = {str(o["id"]): o["text"] for o in countries}
|
|
443
|
+
return [
|
|
444
|
+
OptionSet(Is("countryId", [by_id[c]]), names)
|
|
445
|
+
for c in STATE_LISTS
|
|
446
|
+
if c in by_id
|
|
447
|
+
and (names := [s["full_name"] for s in (states.get(c) or {}).get("data") or []])
|
|
448
|
+
]
|
|
433
449
|
|
|
434
450
|
|
|
435
451
|
def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
|
|
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
|
|
|
18
18
|
from atsfill.core import browser, matching
|
|
19
19
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
|
|
20
20
|
from atsfill.core.browser import Locator, Page
|
|
21
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
22
22
|
|
|
23
23
|
JOB = re.compile(r"/hcmUI/CandidateExperience/[\w-]+/sites/[^/]+/job/(\d+)")
|
|
24
24
|
EMAIL = "email"
|
|
@@ -517,15 +517,24 @@ def _click(page: Page, target: Locator) -> None:
|
|
|
517
517
|
def _geography(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
|
|
518
518
|
"""Leaves one field of the address's geography (state, city, postal code), the most
|
|
519
519
|
specific: its items name the levels above (`0200, Acton, ACT`), and picking one fills
|
|
520
|
-
them. It is required when any level is."""
|
|
520
|
+
them. It is required when any level is, and a dependent list of the country."""
|
|
521
521
|
levels = [(r, f) for r, f in described if r.get("geo") and f.id != COUNTRY]
|
|
522
522
|
if len(levels) < 2 or not any(f.options for _, f in levels):
|
|
523
523
|
return described
|
|
524
524
|
parts = lambda f: max(len(o.split(",")) for o in f.options or [""]) # noqa: E731
|
|
525
525
|
kept = max(levels, key=lambda rf: parts(rf[1]))
|
|
526
526
|
required = any(f.required for _, f in levels)
|
|
527
|
+
asked = replace(kept[1], required=required)
|
|
528
|
+
country = next((f.default for _, f in described if f.id == COUNTRY), None)
|
|
529
|
+
if isinstance(country, str):
|
|
530
|
+
# its items are the country's the page shows; another's cost a request
|
|
531
|
+
asked = replace(
|
|
532
|
+
asked,
|
|
533
|
+
options=None,
|
|
534
|
+
option_sets=[OptionSet(Is(COUNTRY, [country]), asked.options or [])],
|
|
535
|
+
)
|
|
527
536
|
return [
|
|
528
|
-
(r,
|
|
537
|
+
(r, asked if (r, f) == kept else f)
|
|
529
538
|
for r, f in described
|
|
530
539
|
if (r, f) == kept or (r, f) not in levels
|
|
531
540
|
]
|
|
@@ -7,13 +7,15 @@ to `ApplyJob.action` on Submit. `browser_read` walks those pages to the form, `f
|
|
|
7
7
|
again with the answers.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import re
|
|
12
|
+
from dataclasses import replace
|
|
11
13
|
from typing import Any
|
|
12
14
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
13
15
|
|
|
14
16
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
15
17
|
from atsfill.core.browser import Page
|
|
16
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
17
19
|
|
|
18
20
|
HOST = "recruitingbypaycor.com"
|
|
19
21
|
CAREER = f"https://{HOST}/career/"
|
|
@@ -120,6 +122,20 @@ _HIDDEN = """name => {
|
|
|
120
122
|
}"""
|
|
121
123
|
|
|
122
124
|
|
|
125
|
+
# Each country select with its state select, and the page scripts' text, which holds every
|
|
126
|
+
# country's state list (`var ca_country_states = [...]`).
|
|
127
|
+
_COUNTRIES = """() => ({
|
|
128
|
+
countries: [...document.querySelectorAll('#gnewtonForm select[data-state-id]')].map(c => ({
|
|
129
|
+
name: c.name,
|
|
130
|
+
state: document.getElementById(c.dataset.stateId)?.name,
|
|
131
|
+
options: [...c.options].filter(o => o.value)
|
|
132
|
+
.map(o => [o.value, o.textContent.replace(/\\s+/g, ' ').trim()]),
|
|
133
|
+
})),
|
|
134
|
+
scripts: [...document.scripts].map(s => s.textContent).join('\\n'),
|
|
135
|
+
})"""
|
|
136
|
+
STATE_LIST = re.compile(r"var (\w+)_country_states\s*=\s*(\[[^\]]*\])")
|
|
137
|
+
|
|
138
|
+
|
|
123
139
|
def _ids(url: str) -> tuple[str, str] | None:
|
|
124
140
|
"""The client and job ids of a Paycor career URL, or of an employer page opening it."""
|
|
125
141
|
query = parse_qs(urlsplit(url).query)
|
|
@@ -138,6 +154,36 @@ def _field(control: dict[str, Any]) -> Field:
|
|
|
138
154
|
return Field(fid, label, KINDS[kind], control["required"], control["options"], control["max"])
|
|
139
155
|
|
|
140
156
|
|
|
157
|
+
def _dependent(controls: list[tuple[Field, str]], page: dict[str, Any]) -> list[tuple[Field, str]]:
|
|
158
|
+
"""A state select under its country: the page's list for each country that has one, none
|
|
159
|
+
for any other, where the page disables the state and drops its `required`. Unanswered, the
|
|
160
|
+
country leaves the list the page starts with."""
|
|
161
|
+
lists = {
|
|
162
|
+
code: [s if isinstance(s, str) else s.get("value", "") for s in json.loads(names)]
|
|
163
|
+
for code, names in STATE_LIST.findall(page["scripts"])
|
|
164
|
+
}
|
|
165
|
+
under = {c["state"]: c for c in page["countries"] if c["state"]}
|
|
166
|
+
out = []
|
|
167
|
+
for field, kind in controls:
|
|
168
|
+
if (country := under.get(field.id)) and kind == "select":
|
|
169
|
+
sets = [OptionSet(Is(country["name"], negated=True), field.options or [])]
|
|
170
|
+
sets += [
|
|
171
|
+
OptionSet(Is(country["name"], [text]), lists[code.lower()])
|
|
172
|
+
for code, text in country["options"]
|
|
173
|
+
if code.lower() in lists
|
|
174
|
+
]
|
|
175
|
+
having = [text for code, text in country["options"] if code.lower() in lists]
|
|
176
|
+
field = replace(
|
|
177
|
+
field,
|
|
178
|
+
required=False,
|
|
179
|
+
required_when=Is(country["name"], having) if field.required else None,
|
|
180
|
+
options=[],
|
|
181
|
+
option_sets=sets,
|
|
182
|
+
)
|
|
183
|
+
out.append((field, kind))
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
141
187
|
class Paycor:
|
|
142
188
|
name = "paycor"
|
|
143
189
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -175,6 +221,7 @@ class Paycor:
|
|
|
175
221
|
self._before, self._codes = [], {}
|
|
176
222
|
self._walk(page, None)
|
|
177
223
|
controls = [(_field(c), c["kind"]) for c in page.evaluate(_CONTROLS)]
|
|
224
|
+
controls = _dependent(controls, page.evaluate(_COUNTRIES))
|
|
178
225
|
self._controls = [(f, kind) for f, kind in controls if not f.unfillable]
|
|
179
226
|
return [f for f, _ in self._before] + [f for f, _ in controls]
|
|
180
227
|
|
|
@@ -25,7 +25,17 @@ from urllib.parse import parse_qs, urlsplit
|
|
|
25
25
|
from atsfill.core import browser, matching
|
|
26
26
|
from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
|
|
27
27
|
from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Locator, Overlaid, Page, Timeout
|
|
28
|
-
from atsfill.core.results import
|
|
28
|
+
from atsfill.core.results import (
|
|
29
|
+
AllOf,
|
|
30
|
+
Answer,
|
|
31
|
+
AnyOf,
|
|
32
|
+
Condition,
|
|
33
|
+
Field,
|
|
34
|
+
FieldType,
|
|
35
|
+
Is,
|
|
36
|
+
OptionSet,
|
|
37
|
+
SideEffect,
|
|
38
|
+
)
|
|
29
39
|
|
|
30
40
|
JOB = re.compile(r"^(/[a-z]{2,8}/[a-z]{2})/(job|\w*apply)(?:/|$)")
|
|
31
41
|
APPLY = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/\w*apply$") # `apply`, `hvhapply` for hourly roles
|
|
@@ -648,6 +658,18 @@ class _Config:
|
|
|
648
658
|
if isinstance(v, dict) and v.get("htmlType") == "sourceTypeSchema"),
|
|
649
659
|
None,
|
|
650
660
|
) # fmt: skip
|
|
661
|
+
if raw["parent"]: # the sources under each "how you heard" answer
|
|
662
|
+
scope = path.rpartition(".")[0]
|
|
663
|
+
at = f"{scope}.{raw['parent']}" if scope else raw["parent"]
|
|
664
|
+
raw["option_sets"] = [
|
|
665
|
+
OptionSet(Is(at, [self.text(parent.get("title"))]), _unique(
|
|
666
|
+
self.text(child.get("key"))
|
|
667
|
+
for child in parent.get("child") or []
|
|
668
|
+
if child.get("show") is not False
|
|
669
|
+
))
|
|
670
|
+
for parent in u.get("data") or []
|
|
671
|
+
] # fmt: skip
|
|
672
|
+
return None
|
|
651
673
|
return _unique(
|
|
652
674
|
self.text(child.get("key"))
|
|
653
675
|
for parent in u.get("data") or []
|
|
@@ -672,7 +694,7 @@ class _Config:
|
|
|
672
694
|
pairs = self.dependent_list(raw, u.get("dependentKey") or parents[0], api)
|
|
673
695
|
elif html == "asyncselect" and api and api.get("payload"):
|
|
674
696
|
pairs = self.loaded_list(u, api)
|
|
675
|
-
elif table := self.source_table(
|
|
697
|
+
elif table := self.source_table(raw):
|
|
676
698
|
pairs = table
|
|
677
699
|
else:
|
|
678
700
|
values = p.get("enum") or get(p, "items.enum") or []
|
|
@@ -685,6 +707,8 @@ class _Config:
|
|
|
685
707
|
listed = ([v for v, _ in pairs], [t for _, t in pairs])
|
|
686
708
|
self.listed[path] = listed
|
|
687
709
|
self.listed.setdefault(path.rsplit(".", 1)[-1], listed) # as a workflow names it
|
|
710
|
+
if raw.get("option_sets"): # a dependent list's, under its parents' answers before any
|
|
711
|
+
return None
|
|
688
712
|
return _unique(t for _, t in pairs) or None
|
|
689
713
|
|
|
690
714
|
def loaded_list(self, u: dict, api: dict) -> list[tuple[Any, str]]:
|
|
@@ -723,10 +747,10 @@ class _Config:
|
|
|
723
747
|
|
|
724
748
|
def dependent_list(self, raw: dict, parent: str, request: dict) -> list[tuple[Any, str]]:
|
|
725
749
|
"""A dependent list's options under its parent's answer before any: the parent's
|
|
726
|
-
default, else the job's country where the parent lists it, else its first option.
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
at =
|
|
750
|
+
default, else the job's country where the parent lists it, else its first option. Its
|
|
751
|
+
one option set is keyed to that answer, and to another parent's where the page holds
|
|
752
|
+
one; under any other answer its options are unknown, each set costing a request."""
|
|
753
|
+
at = self.scoped(raw, parent)
|
|
730
754
|
value, shown = self.prefilled(parent)
|
|
731
755
|
values, texts = self.listed.get(at) or self.listed.get(parent) or ([], [])
|
|
732
756
|
country = re.sub(r"\W", "", self.ddo.get("jobCountry") or "").lower()
|
|
@@ -744,8 +768,11 @@ class _Config:
|
|
|
744
768
|
if value is None:
|
|
745
769
|
return []
|
|
746
770
|
payload = request.get("payload") or ""
|
|
771
|
+
when: list[Condition] = [Is(at, [shown])]
|
|
747
772
|
for name in ANSWER.findall(payload):
|
|
748
|
-
held = value if name == parent else self.prefilled(name)
|
|
773
|
+
held, held_shown = (value, shown) if name == parent else self.prefilled(name)
|
|
774
|
+
if name != parent and held is not None:
|
|
775
|
+
when.append(Is(self.scoped(raw, name), [held_shown]))
|
|
749
776
|
payload = payload.replace(f"%%formData.{name}%%", str(held or ""))
|
|
750
777
|
data = get(self.call(payload), "applyGetReferences.data")
|
|
751
778
|
if isinstance(data, dict):
|
|
@@ -756,41 +783,55 @@ class _Config:
|
|
|
756
783
|
if isinstance(d, dict)
|
|
757
784
|
]
|
|
758
785
|
if pairs:
|
|
759
|
-
|
|
786
|
+
options = _unique(t for _, t in pairs if t)
|
|
787
|
+
raw["option_sets"] = [OptionSet(when[0] if len(when) == 1 else AllOf(when), options)]
|
|
760
788
|
raw["parent"] = at
|
|
761
789
|
return pairs
|
|
762
790
|
|
|
763
|
-
def source_table(self,
|
|
791
|
+
def source_table(self, raw: dict) -> list[tuple[Any, str]]:
|
|
764
792
|
"""A list Phenom's applicant-source workflow fills from its own table: the sources,
|
|
765
|
-
or the sub-sources under every source, in order.
|
|
793
|
+
or the sub-sources under every source, in order. A table keyed by the value of the
|
|
794
|
+
field the workflow follows is a dependent list: one option set per source."""
|
|
766
795
|
for flow in self.config.get("workflow") or []:
|
|
767
796
|
nodes = flow.get("workflows") or []
|
|
768
|
-
if
|
|
797
|
+
if raw["id"].rsplit(".", 1)[-1] not in _targets(nodes):
|
|
769
798
|
continue
|
|
770
799
|
for node in nodes:
|
|
771
800
|
for table in (
|
|
772
801
|
get(node, "data.config") or [] if node.get("type") == "temp_data" else []
|
|
773
802
|
):
|
|
774
803
|
data = table.get("data") if table.get("key") == "jsonData" else None
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
if isinstance(rows, list)
|
|
782
|
-
for row in rows
|
|
804
|
+
groups = data if isinstance(data, dict) else {None: data}
|
|
805
|
+
pairs = {
|
|
806
|
+
key: [
|
|
807
|
+
(r.get("value"), self.text(r.get("display")))
|
|
808
|
+
for r in rows
|
|
809
|
+
if isinstance(r, dict)
|
|
783
810
|
]
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
811
|
+
for key, rows in groups.items()
|
|
812
|
+
if isinstance(rows, list)
|
|
813
|
+
}
|
|
814
|
+
if not any(pairs.values()):
|
|
815
|
+
continue
|
|
816
|
+
parent = get(flow, "params.field")
|
|
817
|
+
at = parent and self.scoped(raw, parent)
|
|
818
|
+
values, texts = self.listed.get(at or "") or ([], [])
|
|
819
|
+
shown = dict(zip(map(str, values), texts, strict=False))
|
|
820
|
+
if isinstance(data, dict) and shown:
|
|
821
|
+
raw["option_sets"] = [
|
|
822
|
+
OptionSet(Is(at, [shown[key]]), _unique(t for _, t in rows if t))
|
|
823
|
+
for key, rows in pairs.items()
|
|
824
|
+
if key in shown
|
|
825
|
+
]
|
|
826
|
+
raw["parent"] = at
|
|
827
|
+
return [pair for rows in pairs.values() for pair in rows]
|
|
792
828
|
return []
|
|
793
829
|
|
|
830
|
+
def scoped(self, raw: dict, name: str) -> str:
|
|
831
|
+
"""A field `raw` names: in the same entry, else at the top."""
|
|
832
|
+
scope = raw["id"].rpartition(".")[0]
|
|
833
|
+
return f"{scope}.{name}" if f"{scope}.{name}" in self.labels else name
|
|
834
|
+
|
|
794
835
|
def prefilled(self, name: str) -> tuple[Any, str]:
|
|
795
836
|
"""The answer a list holds before any: the page's value it names, else its default."""
|
|
796
837
|
prop = get(self.config, f"schema.properties.{name}") or {}
|
|
@@ -1133,7 +1174,10 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
1133
1174
|
if kind == "checkbox": # an optional box starts clear
|
|
1134
1175
|
options = YES_NO if raw["required"] else NO_YES
|
|
1135
1176
|
label = raw["label"] + {"month": MONTH, "year": YEAR}.get(kind, "")
|
|
1136
|
-
|
|
1177
|
+
sets = raw.get("option_sets")
|
|
1178
|
+
field_type = (
|
|
1179
|
+
FieldType.TYPEAHEAD if kind == "typeahead" and not options and not sets else TYPES[kind]
|
|
1180
|
+
)
|
|
1137
1181
|
return Field(
|
|
1138
1182
|
raw["id"],
|
|
1139
1183
|
label,
|
|
@@ -1145,6 +1189,7 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
1145
1189
|
default=raw.get("default"),
|
|
1146
1190
|
shown_when=raw.get("when"),
|
|
1147
1191
|
required_when=raw.get("required_when"),
|
|
1192
|
+
option_sets=sets,
|
|
1148
1193
|
)
|
|
1149
1194
|
|
|
1150
1195
|
|
|
@@ -35,6 +35,7 @@ KEY_DELAY_MS = 20
|
|
|
35
35
|
LOCATION_KEY_DELAY_MS = 30
|
|
36
36
|
NEXT_TRIES = 3
|
|
37
37
|
YES_NO = ["Yes", "No"]
|
|
38
|
+
NUMBER = re.compile(r"-?\d+([.,]\d+)?") # what an `input[type=number]` takes
|
|
38
39
|
|
|
39
40
|
# The profile page: (field id, the input, the element carrying `label` and `required`).
|
|
40
41
|
PROFILE = [
|
|
@@ -221,6 +222,8 @@ class SmartRecruiters:
|
|
|
221
222
|
if kind in SKIPPED:
|
|
222
223
|
return []
|
|
223
224
|
label = _plain(question["label"])
|
|
225
|
+
if kind == "currency": # an `input[type=number]`, its currency beside
|
|
226
|
+
label += " (a number)"
|
|
224
227
|
shown_when = self._shown_when(question["id"])
|
|
225
228
|
required = bool(question["required"])
|
|
226
229
|
unfillable = None
|
|
@@ -453,7 +456,10 @@ def _fill_question(
|
|
|
453
456
|
continue
|
|
454
457
|
option.click()
|
|
455
458
|
case _:
|
|
456
|
-
_inputs(box, entry, question)
|
|
459
|
+
target = _inputs(box, entry, question)
|
|
460
|
+
if target.get_attribute("type") == "number" and not NUMBER.fullmatch(values[0]):
|
|
461
|
+
return # a number input holds no text: it reads back empty
|
|
462
|
+
target.fill(values[0])
|
|
457
463
|
|
|
458
464
|
|
|
459
465
|
def _read_question(box: Locator, question: dict[str, Any], entry: dict[str, Any]) -> Answer | None:
|
|
@@ -212,10 +212,15 @@ def _slim(result: dict, command: str | None) -> dict:
|
|
|
212
212
|
|
|
213
213
|
|
|
214
214
|
def _sparse(value: object) -> object:
|
|
215
|
-
"""Drops the keys whose value is null, false or empty, at every depth
|
|
215
|
+
"""Drops the keys whose value is null, false or empty, at every depth; empty `options`
|
|
216
|
+
stay, nothing to choose, where null is unknown."""
|
|
216
217
|
if isinstance(value, dict):
|
|
217
218
|
kept = {k: _sparse(v) for k, v in value.items()}
|
|
218
|
-
return {
|
|
219
|
+
return {
|
|
220
|
+
k: v
|
|
221
|
+
for k, v in kept.items()
|
|
222
|
+
if v not in (None, False, [], {}) or (k == "options" and v == [])
|
|
223
|
+
}
|
|
219
224
|
if isinstance(value, list):
|
|
220
225
|
return [_sparse(v) for v in value]
|
|
221
226
|
return value
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Whether a field shows, and whether it is required, from the answers alone (ADR 0005)."""
|
|
2
2
|
|
|
3
|
-
from atsfill.core.results import AllOf, Answer, AnyOf, Condition, Field, Is
|
|
3
|
+
from atsfill.core.results import AllOf, Answer, AnyOf, Condition, Field, Is, OptionSet
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def _parents(condition: Condition) -> list[str]:
|
|
@@ -24,17 +24,22 @@ def alternatives(fields: list[Field]) -> dict[str, list[Field]]:
|
|
|
24
24
|
|
|
25
25
|
def faults(fields: list[Field]) -> list[str]:
|
|
26
26
|
"""What makes the fields' conditions unusable: a field they name that is not listed, a
|
|
27
|
-
field shown by its own answer through any chain,
|
|
28
|
-
members disagreeing on `required`."""
|
|
27
|
+
field shown by its own answer through any chain, options that follow their own field, a
|
|
28
|
+
group of alternatives with one member or members disagreeing on `required`."""
|
|
29
29
|
by_id = {f.id: f for f in fields}
|
|
30
30
|
out = [
|
|
31
31
|
f"{f.id}: its condition names no field {p}"
|
|
32
32
|
for f in fields
|
|
33
|
-
for c in (f.shown_when, f.required_when)
|
|
33
|
+
for c in (f.shown_when, f.required_when, *(s.when for s in f.option_sets or []))
|
|
34
34
|
if c
|
|
35
35
|
for p in _parents(c)
|
|
36
36
|
if p not in by_id
|
|
37
37
|
]
|
|
38
|
+
out += [
|
|
39
|
+
f"{f.id}: its options follow its own answer"
|
|
40
|
+
for f in fields
|
|
41
|
+
if any(f.id in _parents(s.when) for s in f.option_sets or [])
|
|
42
|
+
]
|
|
38
43
|
|
|
39
44
|
def loops(field_id: str, seen: tuple[str, ...]) -> bool:
|
|
40
45
|
if field_id in seen:
|
|
@@ -94,6 +99,14 @@ class Form:
|
|
|
94
99
|
when = field.required_when
|
|
95
100
|
return field.required or (when is not None and self.holds(when))
|
|
96
101
|
|
|
102
|
+
def option_set(self, field: Field) -> OptionSet | None:
|
|
103
|
+
"""The first of a dependent list's sets that holds under the answers."""
|
|
104
|
+
return next((s for s in field.option_sets or [] if self.holds(s.when)), None)
|
|
105
|
+
|
|
106
|
+
def options(self, field: Field) -> list[str] | None:
|
|
107
|
+
held = self.option_set(field)
|
|
108
|
+
return field.options if held is None else held.options
|
|
109
|
+
|
|
97
110
|
def _value(self, field_id: str) -> Answer | None:
|
|
98
111
|
field = self.by_id[field_id]
|
|
99
112
|
if not self.shown(field):
|
|
@@ -13,8 +13,16 @@ def _norm(value: str) -> str:
|
|
|
13
13
|
return unicodedata.normalize("NFC", value).strip()
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
|
|
16
|
+
def _options(field: Field, form: Form | None) -> list[str] | None:
|
|
17
|
+
"""The options an answer is one of; without the form, a dependent list's are unknown."""
|
|
18
|
+
if form is not None:
|
|
19
|
+
return form.options(field)
|
|
20
|
+
return None if field.option_sets else field.options
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _in_options(field: Field, values: list[str], form: Form | None) -> bool:
|
|
24
|
+
options = _options(field, form)
|
|
25
|
+
return options is None or all(v in options for v in values)
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
def _picked_from(answer: str, suggestion: str) -> bool:
|
|
@@ -32,14 +40,16 @@ def _absolute_url(value: str) -> bool:
|
|
|
32
40
|
return parts.scheme in ("http", "https") and bool(parts.netloc)
|
|
33
41
|
|
|
34
42
|
|
|
35
|
-
def matches(
|
|
43
|
+
def matches(
|
|
44
|
+
field: Field, answer: Answer, read_back: Answer | None, form: Form | None = None
|
|
45
|
+
) -> bool:
|
|
36
46
|
if read_back is None:
|
|
37
47
|
return False
|
|
38
48
|
match field.type:
|
|
39
49
|
case FieldType.MULTI_CHOICE:
|
|
40
50
|
a, r = [answer] if isinstance(answer, str) else answer, read_back
|
|
41
51
|
r = [r] if isinstance(r, str) else r
|
|
42
|
-
return sorted(a) == sorted(r) and _in_options(field, a)
|
|
52
|
+
return sorted(a) == sorted(r) and _in_options(field, a, form)
|
|
43
53
|
if not isinstance(answer, str) or not isinstance(read_back, str):
|
|
44
54
|
return False
|
|
45
55
|
match field.type:
|
|
@@ -48,7 +58,7 @@ def matches(field: Field, answer: Answer, read_back: Answer | None) -> bool:
|
|
|
48
58
|
case FieldType.DATE:
|
|
49
59
|
return answer == read_back
|
|
50
60
|
case FieldType.CHOICE:
|
|
51
|
-
return answer == read_back and _in_options(field, [answer])
|
|
61
|
+
return answer == read_back and _in_options(field, [answer], form)
|
|
52
62
|
case FieldType.TYPEAHEAD:
|
|
53
63
|
return _picked_from(answer, read_back)
|
|
54
64
|
case FieldType.FILE:
|
|
@@ -65,7 +75,7 @@ def check(
|
|
|
65
75
|
value is a mismatch too, its condition read wrong."""
|
|
66
76
|
by_id, form = {f.id: f for f in fields}, Form(fields, answers)
|
|
67
77
|
checks = [
|
|
68
|
-
Check(f.id, a, read_back.get(f.id), matches(f, a, read_back.get(f.id)))
|
|
78
|
+
Check(f.id, a, read_back.get(f.id), matches(f, a, read_back.get(f.id), form))
|
|
69
79
|
for f_id, a in answers.items()
|
|
70
80
|
if (f := by_id.get(f_id))
|
|
71
81
|
]
|
|
@@ -76,7 +86,7 @@ def check(
|
|
|
76
86
|
]
|
|
77
87
|
|
|
78
88
|
|
|
79
|
-
def _answer_problem(field: Field, answer: object) -> str | None:
|
|
89
|
+
def _answer_problem(field: Field, answer: object, form: Form) -> str | None:
|
|
80
90
|
if field.type is FieldType.MULTI_CHOICE:
|
|
81
91
|
if not (isinstance(answer, list) and all(isinstance(v, str) for v in answer)):
|
|
82
92
|
return "a multi_choice answer is a list of strings"
|
|
@@ -85,8 +95,12 @@ def _answer_problem(field: Field, answer: object) -> str | None:
|
|
|
85
95
|
return f"a {field.type} answer is a string"
|
|
86
96
|
else:
|
|
87
97
|
values = [answer]
|
|
88
|
-
if field.type in (FieldType.CHOICE, FieldType.MULTI_CHOICE) and not _in_options(
|
|
89
|
-
|
|
98
|
+
if field.type in (FieldType.CHOICE, FieldType.MULTI_CHOICE) and not _in_options(
|
|
99
|
+
field, values, form
|
|
100
|
+
):
|
|
101
|
+
held = form.option_set(field)
|
|
102
|
+
under = f", the options when {describe(held.when, form.by_id)}" if held else ""
|
|
103
|
+
return f"{answer!r} is not one of {form.options(field)}{under}"
|
|
90
104
|
length = len(_norm(values[0]))
|
|
91
105
|
if (
|
|
92
106
|
field.type in (FieldType.TEXT, FieldType.URL)
|
|
@@ -138,6 +152,8 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
|
138
152
|
out += [
|
|
139
153
|
f"{i}: {problem}"
|
|
140
154
|
for i, a in answers.items()
|
|
141
|
-
if i in by_id
|
|
155
|
+
if i in by_id
|
|
156
|
+
and not by_id[i].unfillable
|
|
157
|
+
and (problem := _answer_problem(by_id[i], a, form))
|
|
142
158
|
]
|
|
143
159
|
return out
|
|
@@ -50,6 +50,14 @@ class AllOf:
|
|
|
50
50
|
type Condition = Is | AnyOf | AllOf
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class OptionSet:
|
|
55
|
+
"""A dependent list's options while `when` holds; `[]` leaves nothing to choose."""
|
|
56
|
+
|
|
57
|
+
when: Condition
|
|
58
|
+
options: list[str]
|
|
59
|
+
|
|
60
|
+
|
|
53
61
|
@dataclass(frozen=True)
|
|
54
62
|
class Field:
|
|
55
63
|
id: str
|
|
@@ -65,6 +73,7 @@ class Field:
|
|
|
65
73
|
shown_when: Condition | None = None # a follow-up: shown only when it holds (ADR 0005)
|
|
66
74
|
required_when: Condition | None = None # required once shown when it holds, if not always
|
|
67
75
|
one_of: str | None = None # an alternative: one of the fields with this key answers (ADR 0005)
|
|
76
|
+
option_sets: list[OptionSet] | None = None # a dependent list's; `options` where none holds
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
@dataclass(frozen=True)
|
|
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
|