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.
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/PKG-INFO +2 -1
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/pyproject.toml +2 -1
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/pyproject.toml.orig +2 -1
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/greenhouse.py +39 -13
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/jazzhr.py +23 -7
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/recruitee.py +29 -5
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/browser.py +12 -16
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/commands.py +10 -6
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/conditions.py +27 -3
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/matching.py +9 -2
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/paths.py +3 -3
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/results.py +1 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/LICENSE +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/README.md +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev94 → atsfill-0.0.4.dev96}/src/atsfill/py.typed +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
|
@@ -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")
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
@@ -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")
|
|
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
|
|
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
|