atsfill 0.0.4.dev26__tar.gz → 0.0.4.dev29__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.dev26 → atsfill-0.0.4.dev29}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/README.md +1 -1
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev29/src/atsfill/adapters/lever.py +252 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/browser.py +10 -2
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/registry.py +1 -1
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/LICENSE +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev26 → atsfill-0.0.4.dev29}/src/atsfill/py.typed +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.dev29
|
|
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
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
        
|
|
56
|
+
         
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
        
|
|
32
|
+
         
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
2
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
|
+
from atsfill.adapters.lever import Lever
|
|
3
4
|
from atsfill.adapters.manatal import Manatal
|
|
4
5
|
from atsfill.adapters.oracle import Oracle
|
|
5
6
|
from atsfill.adapters.paylocity import Paylocity
|
|
@@ -19,4 +20,5 @@ ADAPTERS: list[Adapter] = [
|
|
|
19
20
|
Manatal(),
|
|
20
21
|
Paylocity(),
|
|
21
22
|
Workable(),
|
|
23
|
+
Lever(),
|
|
22
24
|
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Lever (`jobs.lever.co` job pages and their application form), as docs/adapters/lever.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
The form is server-rendered HTML: every field is a named control, and its name is the field's
|
|
5
|
+
id. Nothing but the resume reaches Lever before submit, so the read-back is the page.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from datetime import date
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from atsfill.core import browser
|
|
15
|
+
from atsfill.core.adapter import Blocked, Unsupported
|
|
16
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
17
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
|
+
|
|
19
|
+
HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
|
|
20
|
+
JOB = re.compile(r"^/([^/]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b")
|
|
21
|
+
FORM = "form#application-form"
|
|
22
|
+
FORM_TIMEOUT_MS = 30_000
|
|
23
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
24
|
+
SUGGEST_TIMEOUT_MS = 10_000
|
|
25
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
26
|
+
YES_NO = ["Yes", "No"]
|
|
27
|
+
KINDS = {
|
|
28
|
+
"text": FieldType.TEXT,
|
|
29
|
+
"phone": FieldType.PHONE,
|
|
30
|
+
"date": FieldType.DATE,
|
|
31
|
+
"location": FieldType.TYPEAHEAD,
|
|
32
|
+
"file": FieldType.FILE,
|
|
33
|
+
"select": FieldType.CHOICE,
|
|
34
|
+
"radio": FieldType.CHOICE,
|
|
35
|
+
"consent": FieldType.CHOICE,
|
|
36
|
+
"checkboxes": FieldType.MULTI_CHOICE,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# The fields in page order, one per control name: its question's label, and how it renders.
|
|
40
|
+
_READ = """form => {
|
|
41
|
+
const text = el => {
|
|
42
|
+
if (!el) return "";
|
|
43
|
+
const copy = el.cloneNode(true);
|
|
44
|
+
copy.querySelectorAll(".required, a, .description, .eeo-option-description, ul, select")
|
|
45
|
+
.forEach(e => e.remove());
|
|
46
|
+
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
47
|
+
};
|
|
48
|
+
const optionText = input => text(input.closest("label")?.querySelector(
|
|
49
|
+
".application-answer-alternative")) || input.value;
|
|
50
|
+
const found = new Map();
|
|
51
|
+
const controls = form.querySelectorAll(
|
|
52
|
+
"input[name]:not([type=hidden]):not([type=submit]), select[name], textarea[name]");
|
|
53
|
+
for (const el of controls) {
|
|
54
|
+
const name = el.name;
|
|
55
|
+
if (!name || el.id === "customPronounsTextField") continue;
|
|
56
|
+
const box = el.closest(".application-question, .application-additional");
|
|
57
|
+
if (!box) continue;
|
|
58
|
+
let field = found.get(name);
|
|
59
|
+
if (!field) {
|
|
60
|
+
const label = box.querySelector(".application-label .text") ||
|
|
61
|
+
box.querySelector(".application-label");
|
|
62
|
+
field = {id: name, label: text(label), required: !!box.querySelector(".required"),
|
|
63
|
+
options: [], count: 0};
|
|
64
|
+
if (el.type === "file") {
|
|
65
|
+
field.kind = "file";
|
|
66
|
+
} else if (el.tagName === "SELECT") {
|
|
67
|
+
field.kind = "select";
|
|
68
|
+
field.options = [...el.options].filter(o => o.value).map(o => o.textContent.trim());
|
|
69
|
+
} else if (el.type === "radio") field.kind = "radio";
|
|
70
|
+
else if (el.type === "checkbox") field.kind = name.startsWith("consent[") ? "consent" :
|
|
71
|
+
"checkboxes";
|
|
72
|
+
else if (name === "location") field.kind = "location";
|
|
73
|
+
else if (name === "phone") field.kind = "phone";
|
|
74
|
+
else if (el.placeholder === "MM/DD/YYYY") field.kind = "date";
|
|
75
|
+
else field.kind = "text";
|
|
76
|
+
field.maxLength = el.maxLength > 0 ? el.maxLength : null;
|
|
77
|
+
if (field.kind === "consent") field.label = text(el.closest("label"));
|
|
78
|
+
if (!field.label) field.label = el.placeholder ||
|
|
79
|
+
text(box.closest(".section")?.querySelector("h4"));
|
|
80
|
+
found.set(name, field);
|
|
81
|
+
}
|
|
82
|
+
field.required ||= el.required;
|
|
83
|
+
if (el.type === "radio" || el.type === "checkbox") field.options.push(optionText(el));
|
|
84
|
+
}
|
|
85
|
+
return [...found.values()];
|
|
86
|
+
}"""
|
|
87
|
+
|
|
88
|
+
# What each field shows: a choice its checked option's text, a select its option's text, a file
|
|
89
|
+
# its attached name.
|
|
90
|
+
_READ_BACK = """(form, kinds) => {
|
|
91
|
+
const optionText = input => (input.closest("label")?.querySelector(
|
|
92
|
+
".application-answer-alternative")?.textContent || input.value).replace(/\\s+/g, " ").trim();
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [name, kind] of Object.entries(kinds)) {
|
|
95
|
+
const all = [...form.querySelectorAll(`[name="${CSS.escape(name)}"]`)]
|
|
96
|
+
.filter(e => e.type !== "hidden");
|
|
97
|
+
if (!all.length) continue;
|
|
98
|
+
const el = all[0];
|
|
99
|
+
if (kind === "file") {
|
|
100
|
+
out[name] = el.closest("a, .application-field")?.querySelector(".filename")
|
|
101
|
+
?.textContent.trim() || null;
|
|
102
|
+
} else if (kind === "select") {
|
|
103
|
+
const option = el.selectedOptions[0];
|
|
104
|
+
out[name] = option && option.value ? option.textContent.trim() : null;
|
|
105
|
+
} else if (kind === "radio") {
|
|
106
|
+
const checked = all.find(e => e.checked);
|
|
107
|
+
out[name] = checked ? optionText(checked) : null;
|
|
108
|
+
} else if (kind === "checkboxes") {
|
|
109
|
+
out[name] = all.filter(e => e.type === "checkbox" && e.checked).map(optionText);
|
|
110
|
+
} else if (kind === "consent") out[name] = el.checked ? "Yes" : "No";
|
|
111
|
+
else out[name] = el.value;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Lever:
|
|
118
|
+
name = "lever"
|
|
119
|
+
side_effects = [
|
|
120
|
+
SideEffect("upload", "hiring_system", "the resume is uploaded to Lever and parsed at once"),
|
|
121
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
126
|
+
|
|
127
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
128
|
+
return _job(url) is not None
|
|
129
|
+
|
|
130
|
+
def posting(self, url: str) -> str:
|
|
131
|
+
parts, job = urlsplit(url), _job(url)
|
|
132
|
+
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
133
|
+
|
|
134
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
135
|
+
return None # Lever's postings API leaves the application's questions out
|
|
136
|
+
|
|
137
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
138
|
+
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
139
|
+
page.goto(f"{self.posting(page.url)}/apply")
|
|
140
|
+
form = page.locator(FORM)
|
|
141
|
+
try:
|
|
142
|
+
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
143
|
+
except Timeout:
|
|
144
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
145
|
+
fields, self._kinds = [], {}
|
|
146
|
+
for raw in form.evaluate(_READ):
|
|
147
|
+
fields.append(_field(raw))
|
|
148
|
+
self._kinds[raw["id"]] = raw["kind"]
|
|
149
|
+
return fields
|
|
150
|
+
|
|
151
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
152
|
+
# the resume is parsed into the form, so it goes first and is waited for
|
|
153
|
+
for name, answer in sorted(answers.items(), key=lambda a: a[0] != "resume"):
|
|
154
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
155
|
+
controls = page.locator(f"{FORM} [name='{name}']:not([type=hidden])")
|
|
156
|
+
match self._kinds[name]:
|
|
157
|
+
case "file":
|
|
158
|
+
_attach(controls.first, values[0], resume=name == "resume")
|
|
159
|
+
case "location":
|
|
160
|
+
_locate(page, controls.first, values[0])
|
|
161
|
+
case "select":
|
|
162
|
+
controls.first.select_option(label=values[0])
|
|
163
|
+
case "radio" | "checkboxes":
|
|
164
|
+
for value in values:
|
|
165
|
+
option = _option(controls, value)
|
|
166
|
+
if option is not None:
|
|
167
|
+
option.check()
|
|
168
|
+
case "consent":
|
|
169
|
+
controls.first.set_checked(answer == "Yes")
|
|
170
|
+
case "date": # typed as its placeholder shows, MM/DD/YYYY
|
|
171
|
+
day = date.fromisoformat(values[0])
|
|
172
|
+
controls.first.fill(f"{day.month:02}/{day.day:02}/{day.year}")
|
|
173
|
+
case _:
|
|
174
|
+
controls.first.fill(values[0])
|
|
175
|
+
|
|
176
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
177
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
178
|
+
for name, kind in self._kinds.items():
|
|
179
|
+
if kind == "date" and (
|
|
180
|
+
shown := re.fullmatch(r"(\d\d)/(\d\d)/(\d{4})", values.get(name) or "")
|
|
181
|
+
):
|
|
182
|
+
values[name] = f"{shown[3]}-{shown[1]}-{shown[2]}"
|
|
183
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
184
|
+
|
|
185
|
+
def submit(self, page: Page) -> str | None:
|
|
186
|
+
"""Clicks submit; hCaptcha checks the visitor, then the form posts and Lever shows its
|
|
187
|
+
thanks page, the evidence."""
|
|
188
|
+
browser.click(page.locator(f"{FORM} #btn-submit"))
|
|
189
|
+
try:
|
|
190
|
+
page.wait_for_url(re.compile(r"/thanks\b"), timeout=SUBMIT_TIMEOUT_MS)
|
|
191
|
+
except Timeout:
|
|
192
|
+
if page.locator("iframe[src*='hcaptcha'][title*='challenge']").is_visible():
|
|
193
|
+
raise Blocked("captcha: hCaptcha asked for a challenge") from None
|
|
194
|
+
raise
|
|
195
|
+
return page.url
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
199
|
+
"""The account and posting id, from `jobs[.eu].lever.co/<account>/<id>[/apply]`."""
|
|
200
|
+
parts = urlsplit(url)
|
|
201
|
+
if not HOSTS.match(parts.hostname or ""):
|
|
202
|
+
return None
|
|
203
|
+
match = JOB.match(parts.path)
|
|
204
|
+
return (match[1], match[2]) if match else None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _field(raw: dict[str, Any]) -> Field:
|
|
208
|
+
kind = raw["kind"]
|
|
209
|
+
if kind not in KINDS:
|
|
210
|
+
raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
|
|
211
|
+
options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
|
|
212
|
+
if kind in ("radio", "checkboxes"):
|
|
213
|
+
options = raw["options"]
|
|
214
|
+
return Field(
|
|
215
|
+
raw["id"], raw["label"], KINDS[kind], raw["required"], options, raw.get("maxLength")
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _option(controls: Locator, value: str) -> Locator | None:
|
|
220
|
+
"""The radio or checkbox whose text is `value`."""
|
|
221
|
+
for control in controls.all():
|
|
222
|
+
label = control.locator("xpath=ancestor::label[1]")
|
|
223
|
+
shown = label.locator(".application-answer-alternative")
|
|
224
|
+
text = (shown.first.text_content() if shown.count() else None) or (
|
|
225
|
+
control.get_attribute("value") or ""
|
|
226
|
+
)
|
|
227
|
+
if " ".join(text.split()) == value:
|
|
228
|
+
return control
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _attach(control: Locator, path: str, resume: bool) -> None:
|
|
233
|
+
"""Attaches a file; the resume is uploaded and parsed at once, waited for until Lever says
|
|
234
|
+
it read it or could not."""
|
|
235
|
+
control.set_input_files(path)
|
|
236
|
+
if not resume:
|
|
237
|
+
return
|
|
238
|
+
box = control.locator("xpath=ancestor::li[1]")
|
|
239
|
+
with suppress(Timeout):
|
|
240
|
+
box.locator(".resume-upload-success, .resume-upload-failure").locator(
|
|
241
|
+
"visible=true"
|
|
242
|
+
).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _locate(page: Page, control: Locator, value: str) -> None:
|
|
246
|
+
"""Types the location and picks Lever's first suggestion: the field takes only a pick."""
|
|
247
|
+
control.fill("")
|
|
248
|
+
control.press_sequentially(value, delay=40)
|
|
249
|
+
suggestion = page.locator(".dropdown-results .dropdown-location").first
|
|
250
|
+
with suppress(Timeout):
|
|
251
|
+
suggestion.wait_for(timeout=SUGGEST_TIMEOUT_MS)
|
|
252
|
+
suggestion.click()
|
|
@@ -14,6 +14,7 @@ from concurrent.futures import Future
|
|
|
14
14
|
from contextlib import contextmanager
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
from camoufox.addons import DefaultAddons, maybe_download_addons
|
|
17
18
|
from camoufox.exceptions import CamoufoxNotInstalled
|
|
18
19
|
from camoufox.fingerprints import Screen, generate_fingerprint
|
|
19
20
|
from camoufox.pkgman import installed_verstr
|
|
@@ -66,8 +67,15 @@ def version() -> str:
|
|
|
66
67
|
|
|
67
68
|
|
|
68
69
|
def install() -> int:
|
|
69
|
-
"""Fetches the pinned browser build
|
|
70
|
-
|
|
70
|
+
"""Fetches the pinned browser build unless it is installed, then the add-ons every launch
|
|
71
|
+
loads, so that browsers started side by side do not each download them."""
|
|
72
|
+
if version() != BUILD:
|
|
73
|
+
subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
|
|
74
|
+
if version() != BUILD: # `camoufox fetch` exits 0 when it fails
|
|
75
|
+
print(f"the Camoufox build {BUILD} is not installed", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
maybe_download_addons(list(DefaultAddons))
|
|
78
|
+
return 0
|
|
71
79
|
|
|
72
80
|
|
|
73
81
|
def identity(data_dir: Path) -> dict:
|
|
@@ -181,7 +181,7 @@ def backlog(registry: dict[str, System] | None = None) -> str:
|
|
|
181
181
|
"# Hiring systems",
|
|
182
182
|
"",
|
|
183
183
|
"Generated from [src/atsfill/registry.toml](../../src/atsfill/registry.toml) by"
|
|
184
|
-
" `uv run scripts
|
|
184
|
+
" `uv run python -m scripts.backlog`. The registry is described in"
|
|
185
185
|
" [ADR 0012](../adr/0012-systems-registry.md), the order in"
|
|
186
186
|
" [ADR 0013](../adr/0013-survey-and-adapter-rounds.md): no account first, blocked last,"
|
|
187
187
|
" then by share of postings.",
|
|
@@ -192,7 +192,9 @@ kind = "ats"
|
|
|
192
192
|
share = 0.0116
|
|
193
193
|
account = "none"
|
|
194
194
|
hosting = ["hosted"]
|
|
195
|
+
adapter = "lever"
|
|
195
196
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
197
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "d35da0b" }
|
|
196
198
|
|
|
197
199
|
[adp]
|
|
198
200
|
kind = "ats"
|
|
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
|