atsfill 0.0.4.dev94__tar.gz → 0.0.4.dev96__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 (47) hide show
  1. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/PKG-INFO +2 -1
  2. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/pyproject.toml +2 -1
  3. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/pyproject.toml.orig +2 -1
  4. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/greenhouse.py +39 -13
  5. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/jazzhr.py +23 -7
  6. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/recruitee.py +29 -5
  7. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/browser.py +12 -16
  8. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/commands.py +10 -6
  9. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/conditions.py +27 -3
  10. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/matching.py +9 -2
  11. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/paths.py +3 -3
  12. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/results.py +1 -0
  13. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/LICENSE +0 -0
  14. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/README.md +0 -0
  15. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/__init__.py +0 -0
  16. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/__init__.py +0 -0
  17. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/ashby.py +0 -0
  18. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/bamboohr.py +0 -0
  19. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/breezy.py +0 -0
  20. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/dayforce.py +0 -0
  21. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/eightfold.py +0 -0
  22. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/lever.py +0 -0
  25. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/manatal.py +0 -0
  26. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/oracle.py +0 -0
  27. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/paycor.py +0 -0
  28. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/paylocity.py +0 -0
  29. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/personio.py +0 -0
  30. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/phenom.py +0 -0
  31. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/pinpoint.py +0 -0
  32. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/rippling.py +0 -0
  33. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/smartrecruiters.py +0 -0
  34. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/teamtailor.py +0 -0
  35. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/workable.py +0 -0
  36. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/workday.py +0 -0
  37. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/wttj.py +0 -0
  38. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/zohorecruit.py +0 -0
  39. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/cli.py +0 -0
  40. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/__init__.py +0 -0
  41. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/accounts.py +0 -0
  42. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/adapter.py +0 -0
  43. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/attempts.py +0 -0
  44. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/passwords.py +0 -0
  45. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/py.typed +0 -0
  47. {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/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.dev94
3
+ Version: 0.0.4.dev96
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,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Topic :: Office/Business
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Dist: camoufox==0.5.8
19
+ Requires-Dist: filelock==4.1.1
19
20
  Requires-Dist: keyring==25.7.0
20
21
  Requires-Dist: packaging==26.3
21
22
  Requires-Python: >=3.14
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev94"
3
+ version = "0.0.4.dev96"
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,6 +28,7 @@ classifiers = [
28
28
  ]
29
29
  dependencies = [
30
30
  "camoufox==0.5.8",
31
+ "filelock==4.1.1",
31
32
  "keyring==25.7.0",
32
33
  "packaging==26.3",
33
34
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev94"
3
+ version = "0.0.4.dev96"
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,6 +21,7 @@ classifiers = [
21
21
  ]
22
22
  dependencies = [
23
23
  "camoufox==0.5.8",
24
+ "filelock==4.1.1",
24
25
  "keyring==25.7.0",
25
26
  "packaging==26.3",
26
27
  ]
@@ -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
- found.push([group, {id: group.getAttribute("aria-labelledby").replace(/^upload-label-/, ""),
88
- kind: "file", label: text(group.querySelector(".upload-label")),
89
- required: group.getAttribute("aria-required") === "true",
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
- entry = question["fields"][0] # a file's paste-in textarea comes second
347
- if entry["type"] == "input_hidden": # a location's latitude and longitude
348
- continue
349
- name = entry["name"]
350
- field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
351
- if name == "location":
352
- name, field_type = "candidate-location", FieldType.TYPEAHEAD
353
- options = [v["label"] for v in entry.get("values") or []] or None
354
- fields.append(Field(name, question["label"], field_type, question["required"], options))
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-resumetext-value", "resumator-xml-value", "g-recaptcha-response"]
54
- .includes(el.id) || el.classList.contains("resumator-questionnaire-checkbox")) continue;
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"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
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
- _READ = """form => {
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
- return [...found.values()];
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"], label, KINDS[kind], raw["required"], options, raw.get("maxLength"), accept
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
 
@@ -1,7 +1,6 @@
1
1
  """The browser launcher, the only module that imports camoufox and playwright (ADR 0002)."""
2
2
 
3
3
  import asyncio
4
- import fcntl
5
4
  import json
6
5
  import locale
7
6
  import platform
@@ -24,6 +23,8 @@ from camoufox.exceptions import CamoufoxNotInstalled
24
23
  from camoufox.fingerprints import Screen, generate_fingerprint
25
24
  from camoufox.pkgman import installed_verstr
26
25
  from camoufox.sync_api import Camoufox
26
+ from filelock import FileLock
27
+ from filelock import Timeout as LockTimeout
27
28
  from playwright.sync_api import APIRequestContext as Request
28
29
  from playwright.sync_api import Browser, Locator, Page, Response, sync_playwright
29
30
  from playwright.sync_api import Error as BrowserError
@@ -107,8 +108,7 @@ def _build_blocker() -> None:
107
108
  from disk: fetched by each launch, they would hold its first page for seconds, and each
108
109
  list on is compiled by each launch before the blocker starts."""
109
110
  ADDONS_DIR.mkdir(parents=True, exist_ok=True)
110
- with (ADDONS_DIR / f"{BLOCKER.name}.lock").open("w") as f:
111
- fcntl.flock(f, fcntl.LOCK_EX)
111
+ with FileLock(ADDONS_DIR / f"{BLOCKER.name}.lock"):
112
112
  build = BLOCKER.with_name(f"{BLOCKER.name}.build")
113
113
  shutil.rmtree(build, ignore_errors=True)
114
114
  shutil.copytree(get_addon_path(DefaultAddons.UBO.name), build)
@@ -171,19 +171,15 @@ def machine_locale() -> str | None:
171
171
  @contextmanager
172
172
  def lock(data_dir: Path, deadline: float) -> Generator[None]:
173
173
  """Holds the data directory; a second command waits here until its deadline."""
174
- with (data_dir / "lock").open("w") as f:
175
- while True:
176
- try:
177
- fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
178
- break
179
- except BlockingIOError:
180
- if time.monotonic() > deadline:
181
- raise OutOfTime("time budget ran out waiting for another command") from None
182
- time.sleep(LOCK_POLL_S)
183
- try:
184
- yield
185
- finally:
186
- fcntl.flock(f, fcntl.LOCK_UN)
174
+ held = FileLock(data_dir / "lock", poll_interval=LOCK_POLL_S)
175
+ try:
176
+ held.acquire(timeout=max(0.0, deadline - time.monotonic()))
177
+ except LockTimeout:
178
+ raise OutOfTime("time budget ran out waiting for another command") from None
179
+ try:
180
+ yield
181
+ finally:
182
+ held.release()
187
183
 
188
184
 
189
185
  @contextmanager
@@ -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; an optional one stays listed,
176
- left empty (ADR 0014). A follow-up counts once the answers show it and require it, and
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.unfillable
184
- and f.prefilled is None
185
- and (form.required(f) if answers is not None else f.required and not f.shown_when)
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, or a
18
- field shown by its own answer, through any chain."""
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, answers to
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
@@ -1,10 +1,11 @@
1
- import fcntl
2
1
  import os
3
2
  import platform
4
3
  import re
5
4
  import tomllib
6
5
  from pathlib import Path
7
6
 
7
+ from filelock import FileLock
8
+
8
9
  LINUX = platform.system() == "Linux"
9
10
  DEFAULT_USER = "default"
10
11
  TEST_USER = "test"
@@ -50,8 +51,7 @@ def _move_to_default(data: Path) -> None:
50
51
  user, once, under the lock."""
51
52
  if not any((data / name).exists() for name in MOVED):
52
53
  return
53
- with (data / "lock").open("w") as lock:
54
- fcntl.flock(lock, fcntl.LOCK_EX)
54
+ with FileLock(data / "lock"):
55
55
  default = data / "users" / DEFAULT_USER
56
56
  default.mkdir(mode=0o700, parents=True, exist_ok=True)
57
57
  for name in MOVED:
@@ -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