atsfill 0.0.4.dev94__tar.gz → 0.0.4.dev95__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.dev94 → atsfill-0.0.4.dev95}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/greenhouse.py +39 -13
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/jazzhr.py +23 -7
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/recruitee.py +29 -5
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/commands.py +10 -6
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/conditions.py +27 -3
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/matching.py +9 -2
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/results.py +1 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/LICENSE +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/README.md +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev95}/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.dev95
|
|
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
|
|
@@ -61,6 +61,7 @@ _READ = """form => {
|
|
|
61
61
|
const found = [];
|
|
62
62
|
for (const el of form.querySelectorAll("input, textarea")) {
|
|
63
63
|
if (!el.id || ["hidden", "search", "file", "checkbox"].includes(el.type)) continue;
|
|
64
|
+
if (el.closest(".file-upload")) continue; // a file's paste-in textarea, listed with the file
|
|
64
65
|
const combo = el.getAttribute("role") === "combobox";
|
|
65
66
|
if (combo && el.closest(".phone-input")) continue; // the phone's country, set by its number
|
|
66
67
|
const shell = el.closest(".select");
|
|
@@ -84,10 +85,14 @@ _READ = """form => {
|
|
|
84
85
|
}
|
|
85
86
|
for (const group of form.querySelectorAll(".file-upload")) {
|
|
86
87
|
const input = group.querySelector("input[type=file]");
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const id = group.getAttribute("aria-labelledby").replace(/^upload-label-/, "");
|
|
89
|
+
const label = text(group.querySelector(".upload-label"));
|
|
90
|
+
const required = group.getAttribute("aria-required") === "true";
|
|
91
|
+
const manual = group.querySelector(`button[data-testid="${id}-text"]`);
|
|
92
|
+
found.push([group, {id, kind: "file", label, required, oneOf: manual ? id : null,
|
|
90
93
|
accept: input?.accept ? input.accept.split(",") : null}]);
|
|
94
|
+
const pasted = {id: `${id}_text`, kind: "pasted", label, required, oneOf: id};
|
|
95
|
+
if (manual) found.push([manual, pasted]);
|
|
91
96
|
}
|
|
92
97
|
const before = (a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
93
98
|
found.sort(([a], [b]) => before(a, b) ? -1 : 1);
|
|
@@ -196,6 +201,15 @@ class Greenhouse:
|
|
|
196
201
|
upload.locator("input[type=file]").set_input_files(values[0])
|
|
197
202
|
with suppress(Timeout):
|
|
198
203
|
upload.locator(".file-upload__filename").wait_for(timeout=ANSWER_TIMEOUT_MS)
|
|
204
|
+
case "pasted": # "Enter manually" adds the textarea beside the file input
|
|
205
|
+
text = page.locator(_by_id(field_id))
|
|
206
|
+
if not text.count():
|
|
207
|
+
browser.click(
|
|
208
|
+
page.locator(
|
|
209
|
+
f"button[data-testid='{field_id.removesuffix('_text')}-text']"
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
text.fill(values[0])
|
|
199
213
|
case "consent":
|
|
200
214
|
box = page.locator(f"{FORM} input[type=checkbox][name='{field_id}']").first
|
|
201
215
|
if box.is_checked() != (answer == "Yes"):
|
|
@@ -227,7 +241,7 @@ class Greenhouse:
|
|
|
227
241
|
def _field(self, page: Page, raw: dict[str, Any]) -> Field:
|
|
228
242
|
kind, options = raw["kind"], raw.get("options")
|
|
229
243
|
match kind:
|
|
230
|
-
case "text" | "number":
|
|
244
|
+
case "text" | "number" | "pasted":
|
|
231
245
|
field_type = FieldType.TEXT
|
|
232
246
|
case "tel":
|
|
233
247
|
field_type = FieldType.PHONE
|
|
@@ -256,6 +270,7 @@ class Greenhouse:
|
|
|
256
270
|
options,
|
|
257
271
|
raw.get("maxLength"),
|
|
258
272
|
raw.get("accept"),
|
|
273
|
+
one_of=raw.get("oneOf"),
|
|
259
274
|
)
|
|
260
275
|
|
|
261
276
|
def _education(self, page: Page, field_id: str) -> list[str] | None:
|
|
@@ -343,15 +358,26 @@ def _api_fields(job: dict[str, Any]) -> list[Field] | None:
|
|
|
343
358
|
return None
|
|
344
359
|
fields = []
|
|
345
360
|
for question in job["questions"] + (job.get("location_questions") or []):
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
361
|
+
entries = question["fields"] # a file's paste-in textarea comes second, its alternative
|
|
362
|
+
one_of = entries[0]["name"] if len(entries) > 1 else None
|
|
363
|
+
for entry in entries:
|
|
364
|
+
if entry["type"] == "input_hidden": # a location's latitude and longitude
|
|
365
|
+
continue
|
|
366
|
+
name = entry["name"]
|
|
367
|
+
field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
|
|
368
|
+
if name == "location":
|
|
369
|
+
name, field_type = "candidate-location", FieldType.TYPEAHEAD
|
|
370
|
+
options = [v["label"] for v in entry.get("values") or []] or None
|
|
371
|
+
fields.append(
|
|
372
|
+
Field(
|
|
373
|
+
name,
|
|
374
|
+
question["label"],
|
|
375
|
+
field_type,
|
|
376
|
+
question["required"],
|
|
377
|
+
options,
|
|
378
|
+
one_of=one_of,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
355
381
|
return fields
|
|
356
382
|
|
|
357
383
|
|
|
@@ -47,13 +47,15 @@ _READ = """form => {
|
|
|
47
47
|
const shown = el => el.offsetParent !== null;
|
|
48
48
|
const disability = "#resumator-new-disability-message";
|
|
49
49
|
const found = [], radios = new Set();
|
|
50
|
+
const pasted = "resumator-resumetext-value"; // "Paste resume", the file's alternative
|
|
51
|
+
const pasteable = !!form.querySelector(`#${pasted}`);
|
|
50
52
|
const controls = form.querySelectorAll(
|
|
51
53
|
"input:not([type=hidden]), select, textarea, input.resumator-questionnaire-checkbox-answer");
|
|
52
54
|
for (const el of controls) {
|
|
53
|
-
if (["resumator-
|
|
54
|
-
|
|
55
|
+
if (["resumator-xml-value", "g-recaptcha-response"].includes(el.id)
|
|
56
|
+
|| el.classList.contains("resumator-questionnaire-checkbox")) continue;
|
|
55
57
|
const hidden = el.type === "file" ? el.id !== "resumator-resume-value"
|
|
56
|
-
: !shown(el) && el.type !== "hidden";
|
|
58
|
+
: !shown(el) && el.type !== "hidden" && el.id !== pasted;
|
|
57
59
|
if (hidden) continue;
|
|
58
60
|
const group = el.closest(".form-group, .resumator-field-wrapper");
|
|
59
61
|
const groupLabel = group?.querySelector(".control-label, .resumator-label");
|
|
@@ -79,10 +81,11 @@ _READ = """form => {
|
|
|
79
81
|
field.kind = "select";
|
|
80
82
|
field.options = [...el.options].filter(o => !o.value.startsWith("resumator_no_selection")
|
|
81
83
|
&& !/^no answer$/i.test(o.text.trim())).map(o => o.text.trim());
|
|
82
|
-
} else if (el.type === "file") {
|
|
83
|
-
field.kind = "file";
|
|
84
|
+
} else if (el.type === "file" || el.id === pasted) {
|
|
85
|
+
field.kind = el.type === "file" ? "file" : "pasted";
|
|
84
86
|
field.label = text(groupLabel);
|
|
85
87
|
field.required = starred(groupLabel);
|
|
88
|
+
if (pasteable) field.oneOf = "resume";
|
|
86
89
|
} else {
|
|
87
90
|
field.kind = el.classList.contains("resumator-datepicker") ? "date"
|
|
88
91
|
: el.type === "tel" ? "tel" : "text";
|
|
@@ -162,6 +165,12 @@ class JazzHR:
|
|
|
162
165
|
if attach.is_visible():
|
|
163
166
|
attach.click()
|
|
164
167
|
field.set_input_files(values[0])
|
|
168
|
+
case "pasted": # behind "Paste resume", from the choice or the file's own link
|
|
169
|
+
for link in ("#resumator-choose-paste", "#resumator-resume-switch-paste"):
|
|
170
|
+
if page.locator(link).is_visible():
|
|
171
|
+
page.locator(link).click()
|
|
172
|
+
break
|
|
173
|
+
field.fill(values[0])
|
|
165
174
|
case "select":
|
|
166
175
|
field.select_option(label=values[0])
|
|
167
176
|
case "radio":
|
|
@@ -235,7 +244,7 @@ def _signed_on_disability(fields: list[Field]) -> list[Field]:
|
|
|
235
244
|
def _field(raw: dict[str, Any]) -> Field:
|
|
236
245
|
kind, options = raw["kind"], raw.get("options")
|
|
237
246
|
match kind:
|
|
238
|
-
case "text":
|
|
247
|
+
case "text" | "pasted":
|
|
239
248
|
field_type = FieldType.TEXT
|
|
240
249
|
case "date":
|
|
241
250
|
field_type = FieldType.DATE
|
|
@@ -256,5 +265,12 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
256
265
|
)
|
|
257
266
|
accept = RESUME_TYPES if kind == "file" else None
|
|
258
267
|
return Field(
|
|
259
|
-
raw["id"],
|
|
268
|
+
raw["id"],
|
|
269
|
+
raw["label"],
|
|
270
|
+
field_type,
|
|
271
|
+
raw["required"],
|
|
272
|
+
options,
|
|
273
|
+
raw.get("maxLength"),
|
|
274
|
+
accept,
|
|
275
|
+
one_of=raw.get("oneOf"),
|
|
260
276
|
)
|
|
@@ -22,6 +22,8 @@ FORM_TIMEOUT_MS = 30_000
|
|
|
22
22
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
23
23
|
KEY_DELAY_MS = 20
|
|
24
24
|
YES_NO = ["Yes", "No"]
|
|
25
|
+
LETTER_FILE, LETTER = "candidate.coverLetterFile", "candidate.coverLetter"
|
|
26
|
+
LETTER_SWITCH = "[data-cy=cover-letter-switch-button]"
|
|
25
27
|
EXTENSIONS = {"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "application/pdf": [".pdf"]}
|
|
26
28
|
KINDS = {
|
|
27
29
|
"text": FieldType.TEXT,
|
|
@@ -29,6 +31,7 @@ KINDS = {
|
|
|
29
31
|
"phone": FieldType.PHONE,
|
|
30
32
|
"date": FieldType.DATE,
|
|
31
33
|
"file": FieldType.FILE,
|
|
34
|
+
"letter": FieldType.TEXT,
|
|
32
35
|
"radio": FieldType.CHOICE,
|
|
33
36
|
"legal": FieldType.CHOICE,
|
|
34
37
|
"checkboxes": FieldType.MULTI_CHOICE,
|
|
@@ -36,8 +39,9 @@ KINDS = {
|
|
|
36
39
|
|
|
37
40
|
# The fields in page order, one per control name: its question's label, and how it renders. A
|
|
38
41
|
# labelled question without a named control (a video recorder, drawn late) comes as kind
|
|
39
|
-
# `video`; one without a label is text only.
|
|
40
|
-
|
|
42
|
+
# `video`; one without a label is text only. A cover letter upload the switch turns into a
|
|
43
|
+
# textarea is followed by that textarea, its alternative.
|
|
44
|
+
_READ = """(form, [letterFile, letter, letterSwitch]) => {
|
|
41
45
|
const text = el => {
|
|
42
46
|
if (!el) return "";
|
|
43
47
|
const copy = el.cloneNode(true);
|
|
@@ -84,7 +88,16 @@ _READ = """form => {
|
|
|
84
88
|
if (el.type === "radio" || el.type === "checkbox" && field.kind === "checkboxes")
|
|
85
89
|
field.options.push(text(form.querySelector(`label[for="${el.id}"]`)) || el.value);
|
|
86
90
|
}
|
|
87
|
-
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const field of found.values()) {
|
|
93
|
+
out.push(field);
|
|
94
|
+
if (field.id === letterFile && form.querySelector(letterSwitch)) {
|
|
95
|
+
field.oneOf = letterFile;
|
|
96
|
+
out.push({id: letter, kind: "letter", label: field.label, required: field.required,
|
|
97
|
+
options: [], accept: null, maxLength: null, oneOf: letterFile});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
88
101
|
}"""
|
|
89
102
|
|
|
90
103
|
# What each field shows: a choice its checked option's text, a file its attached name.
|
|
@@ -139,7 +152,7 @@ class Recruitee:
|
|
|
139
152
|
except Timeout:
|
|
140
153
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
141
154
|
fields, self._kinds = [], {}
|
|
142
|
-
for raw in form.evaluate(_READ):
|
|
155
|
+
for raw in form.evaluate(_READ, [LETTER_FILE, LETTER, LETTER_SWITCH]):
|
|
143
156
|
fields.append(_field(raw))
|
|
144
157
|
if raw["kind"] != "video":
|
|
145
158
|
self._kinds[raw["id"]] = raw["kind"]
|
|
@@ -158,6 +171,10 @@ class Recruitee:
|
|
|
158
171
|
_set(option, True)
|
|
159
172
|
case "legal":
|
|
160
173
|
_set(controls.first, answer == "Yes")
|
|
174
|
+
case "letter": # the switch swaps the upload for the textarea
|
|
175
|
+
if not controls.count():
|
|
176
|
+
browser.click(page.locator(f"{FORM} {LETTER_SWITCH}"))
|
|
177
|
+
controls.first.fill(values[0])
|
|
161
178
|
case "phone": # the country picker follows the number's own prefix
|
|
162
179
|
controls.first.fill("")
|
|
163
180
|
controls.first.press_sequentially(values[0], delay=KEY_DELAY_MS)
|
|
@@ -196,7 +213,14 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
196
213
|
if raw["accept"]:
|
|
197
214
|
accept = [e for a in raw["accept"] for e in EXTENSIONS.get(a, [])] or None
|
|
198
215
|
return Field(
|
|
199
|
-
raw["id"],
|
|
216
|
+
raw["id"],
|
|
217
|
+
label,
|
|
218
|
+
KINDS[kind],
|
|
219
|
+
raw["required"],
|
|
220
|
+
options,
|
|
221
|
+
raw.get("maxLength"),
|
|
222
|
+
accept,
|
|
223
|
+
one_of=raw.get("oneOf"),
|
|
200
224
|
)
|
|
201
225
|
|
|
202
226
|
|
|
@@ -172,17 +172,21 @@ def _result(adapter: Adapter, url: str, user: str) -> Result:
|
|
|
172
172
|
|
|
173
173
|
|
|
174
174
|
def _unfillable(result: Result, answers: dict[str, Answer] | None = None) -> bool:
|
|
175
|
-
"""Stops the form on a required field atsfill cannot fill
|
|
176
|
-
left empty (ADR 0014). A follow-up counts
|
|
177
|
-
without answers never."""
|
|
175
|
+
"""Stops the form on a required field atsfill cannot fill, or a required group none of whose
|
|
176
|
+
alternatives it can; an optional one stays listed, left empty (ADR 0014). A follow-up counts
|
|
177
|
+
once the answers show it and require it, and without answers never."""
|
|
178
178
|
form = conditions.Form(result.fields, answers or {})
|
|
179
|
+
|
|
180
|
+
def stuck(f: Field) -> bool:
|
|
181
|
+
return f.unfillable is not None and f.prefilled is None
|
|
182
|
+
|
|
179
183
|
field = next(
|
|
180
184
|
(
|
|
181
185
|
f
|
|
182
186
|
for f in result.fields
|
|
183
|
-
if f
|
|
184
|
-
and f
|
|
185
|
-
and (
|
|
187
|
+
if stuck(f)
|
|
188
|
+
and (form.asked(f) if answers is not None else f.required and not f.shown_when)
|
|
189
|
+
and all(stuck(m) for m in form.groups.get(f.one_of or "", [f]))
|
|
186
190
|
),
|
|
187
191
|
None,
|
|
188
192
|
)
|
|
@@ -13,9 +13,19 @@ def _parents(condition: Condition) -> list[str]:
|
|
|
13
13
|
return [p for c in condition.all_of for p in _parents(c)]
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def alternatives(fields: list[Field]) -> dict[str, list[Field]]:
|
|
17
|
+
"""The fields of each `one_of` group, by its key."""
|
|
18
|
+
out: dict[str, list[Field]] = {}
|
|
19
|
+
for f in fields:
|
|
20
|
+
if f.one_of:
|
|
21
|
+
out.setdefault(f.one_of, []).append(f)
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
16
25
|
def faults(fields: list[Field]) -> list[str]:
|
|
17
|
-
"""What makes the fields' conditions unusable: a field they name that is not listed,
|
|
18
|
-
field shown by its own answer
|
|
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, a group of alternatives with one member or
|
|
28
|
+
members disagreeing on `required`."""
|
|
19
29
|
by_id = {f.id: f for f in fields}
|
|
20
30
|
out = [
|
|
21
31
|
f"{f.id}: its condition names no field {p}"
|
|
@@ -34,6 +44,11 @@ def faults(fields: list[Field]) -> list[str]:
|
|
|
34
44
|
return any(loops(p, (*seen, field_id)) for p in parents)
|
|
35
45
|
|
|
36
46
|
out += [f"{f.id}: shown by its own answer" for f in fields if loops(f.id, ())]
|
|
47
|
+
for key, members in alternatives(fields).items():
|
|
48
|
+
if len(members) == 1:
|
|
49
|
+
out.append(f"{key}: one alternative, {members[0].id}")
|
|
50
|
+
elif len({m.required for m in members}) > 1:
|
|
51
|
+
out.append(f"{key}: alternatives disagree on required")
|
|
37
52
|
return out
|
|
38
53
|
|
|
39
54
|
|
|
@@ -43,6 +58,7 @@ class Form:
|
|
|
43
58
|
|
|
44
59
|
def __init__(self, fields: list[Field], answers: dict[str, Answer]) -> None:
|
|
45
60
|
self.by_id = {f.id: f for f in fields}
|
|
61
|
+
self.groups = alternatives(fields)
|
|
46
62
|
self.answers = answers
|
|
47
63
|
|
|
48
64
|
def holds(self, condition: Condition) -> bool:
|
|
@@ -64,7 +80,15 @@ class Form:
|
|
|
64
80
|
return field.shown_when is None or self.holds(field.shown_when)
|
|
65
81
|
|
|
66
82
|
def required(self, field: Field) -> bool:
|
|
67
|
-
"""Required as the form asks it: shown, and always required or required by an answer.
|
|
83
|
+
"""Required as the form asks it: shown, and always required or required by an answer.
|
|
84
|
+
An alternative never is on its own: its group is (`group_required`)."""
|
|
85
|
+
return field.one_of is None and self.asked(field)
|
|
86
|
+
|
|
87
|
+
def group_required(self, key: str) -> bool:
|
|
88
|
+
return any(self.asked(m) for m in self.groups[key])
|
|
89
|
+
|
|
90
|
+
def asked(self, field: Field) -> bool:
|
|
91
|
+
"""The field's own flag and conditions, whatever its group."""
|
|
68
92
|
if not self.shown(field):
|
|
69
93
|
return False
|
|
70
94
|
when = field.required_when
|
|
@@ -108,8 +108,8 @@ def _answer_problem(field: Field, answer: object) -> str | None:
|
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
111
|
-
"""What refuses a fill before typing: unknown ids, missing required answers,
|
|
112
|
-
follow-ups the answers keep hidden, bad values."""
|
|
111
|
+
"""What refuses a fill before typing: unknown ids, missing required answers, more than one
|
|
112
|
+
alternative answered, answers to follow-ups the answers keep hidden, bad values."""
|
|
113
113
|
by_id = {f.id: f for f in fields}
|
|
114
114
|
out = [f"no field {i}" for i in answers if i not in by_id]
|
|
115
115
|
form = Form(fields, {i: a for i, a in answers.items() if i in by_id})
|
|
@@ -118,6 +118,13 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
|
118
118
|
for f in fields
|
|
119
119
|
if form.required(f) and f.prefilled is None and not f.unfillable and f.id not in answers
|
|
120
120
|
]
|
|
121
|
+
for key, members in form.groups.items():
|
|
122
|
+
given = [m.id for m in members if m.id in answers]
|
|
123
|
+
if len(given) > 1:
|
|
124
|
+
out.append(f"{key}: one of {', '.join(given)}, not both")
|
|
125
|
+
elif form.group_required(key) and not given and all(m.prefilled is None for m in members):
|
|
126
|
+
labels = " or ".join(dict.fromkeys(m.label for m in members))
|
|
127
|
+
out.append(f"no answer for required {key} ({labels})")
|
|
121
128
|
out += [
|
|
122
129
|
f"{i}: not asked, it shows when {describe(f.shown_when, by_id)}"
|
|
123
130
|
for i in answers
|
|
@@ -64,6 +64,7 @@ class Field:
|
|
|
64
64
|
unfillable: str | None = None # why atsfill cannot fill it, a reason (ADR 0014)
|
|
65
65
|
shown_when: Condition | None = None # a follow-up: shown only when it holds (ADR 0005)
|
|
66
66
|
required_when: Condition | None = None # required once shown when it holds, if not always
|
|
67
|
+
one_of: str | None = None # an alternative: one of the fields with this key answers (ADR 0005)
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
@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
|
|
File without changes
|
|
File without changes
|