atsfill 0.0.4.dev73__tar.gz → 0.0.4.dev75__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.dev73 → atsfill-0.0.4.dev75}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/README.md +1 -1
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev75/src/atsfill/adapters/personio.py +293 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/LICENSE +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/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.dev75
|
|
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
|
|
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
## Supported hiring systems
|
|
55
55
|
|
|
56
56
|
<!-- badges -->
|
|
57
|
-
                  
|
|
57
|
+
                   
|
|
58
58
|
<!-- /badges -->
|
|
59
59
|
|
|
60
60
|
| 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 |
|
|
@@ -10,6 +10,7 @@ from atsfill.adapters.manatal import Manatal
|
|
|
10
10
|
from atsfill.adapters.oracle import Oracle
|
|
11
11
|
from atsfill.adapters.paycor import Paycor
|
|
12
12
|
from atsfill.adapters.paylocity import Paylocity
|
|
13
|
+
from atsfill.adapters.personio import Personio
|
|
13
14
|
from atsfill.adapters.phenom import Phenom
|
|
14
15
|
from atsfill.adapters.recruitee import Recruitee
|
|
15
16
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
@@ -41,5 +42,6 @@ def adapters() -> list[Adapter]:
|
|
|
41
42
|
Breezy(),
|
|
42
43
|
BambooHR(),
|
|
43
44
|
ZohoRecruit(),
|
|
45
|
+
Personio(),
|
|
44
46
|
Phenom(), # last: it claims a URL by its path alone
|
|
45
47
|
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Personio (`<employer>.jobs.personio.com/job/<id>`), as docs/adapters/personio.md observes.
|
|
2
|
+
|
|
3
|
+
The apply page is rendered on the server, its form whole in the HTML: every field is one native
|
|
4
|
+
control named by its id, labelled by a `label[for]` or its group's label, a star marking it
|
|
5
|
+
required. `read_api` fetches that HTML over HTTP, `read_form` takes the page's form, and both
|
|
6
|
+
list the fields from it with one parser. A document is uploaded to Personio once attached.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import urllib.request
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
17
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = re.compile(r"^([a-z0-9-]+)\.jobs\.personio\.(?:com|de)$")
|
|
21
|
+
JOB = re.compile(r"^/job/(\d+)(?:/apply)?/?$")
|
|
22
|
+
APPLIED = re.compile(r"/api/v1/jobs/\d+/application$")
|
|
23
|
+
FORM = "form:has(button[type=submit])"
|
|
24
|
+
SUBMIT = f"{FORM} button[type=submit]"
|
|
25
|
+
FORM_TIMEOUT_MS = 30_000
|
|
26
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
27
|
+
ENABLED_TIMEOUT_MS = 30_000
|
|
28
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
29
|
+
PHONE = "phone"
|
|
30
|
+
# Each control the form renders, by its tag or input type, and its field type.
|
|
31
|
+
KINDS = {
|
|
32
|
+
"text": FieldType.TEXT,
|
|
33
|
+
"email": FieldType.TEXT,
|
|
34
|
+
"date": FieldType.DATE,
|
|
35
|
+
"select": FieldType.CHOICE,
|
|
36
|
+
"file": FieldType.FILE,
|
|
37
|
+
}
|
|
38
|
+
VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
|
|
39
|
+
|
|
40
|
+
# What each control holds: a value, a select's option value, a document's uploaded names.
|
|
41
|
+
_READ_BACK = """(form, ids) => {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const el = form.querySelector(`[name="${id}"]`);
|
|
45
|
+
if (!el) continue;
|
|
46
|
+
if (el.type === "file") {
|
|
47
|
+
const group = el.closest("[role=group]");
|
|
48
|
+
out[id] = [...(group?.querySelectorAll("[role=listitem] span") ?? [])]
|
|
49
|
+
.map(e => e.textContent.trim());
|
|
50
|
+
} else out[id] = el.value;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Personio:
|
|
57
|
+
name = "personio"
|
|
58
|
+
known_gaps: dict[str, Gap] = {}
|
|
59
|
+
side_effects = [
|
|
60
|
+
SideEffect("upload", "hiring_system", "a document is uploaded to Personio at once"),
|
|
61
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self._options: dict[str, dict[str, str]] = {} # a choice's option texts by value
|
|
66
|
+
self._fields: list[Field] = []
|
|
67
|
+
|
|
68
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
69
|
+
return _job(url) is not None
|
|
70
|
+
|
|
71
|
+
def posting(self, url: str) -> str:
|
|
72
|
+
job = _job(url)
|
|
73
|
+
return f"https://{job[0]}.jobs.personio.com/job/{job[1]}" if job else url
|
|
74
|
+
|
|
75
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
76
|
+
if _job(url) is None:
|
|
77
|
+
return None
|
|
78
|
+
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
81
|
+
html = response.read().decode()
|
|
82
|
+
except OSError, ValueError:
|
|
83
|
+
return None
|
|
84
|
+
form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
|
|
85
|
+
return [f for f, _ in _form(form)] if form else None
|
|
86
|
+
|
|
87
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
88
|
+
if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
89
|
+
page.goto(_apply(page.url))
|
|
90
|
+
try:
|
|
91
|
+
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
92
|
+
except Timeout:
|
|
93
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
94
|
+
listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")))
|
|
95
|
+
self._options = {f.id: o for f, o in listed if o and not f.unfillable}
|
|
96
|
+
self._fields = [f for f, _ in listed if not f.unfillable]
|
|
97
|
+
return [f for f, _ in listed]
|
|
98
|
+
|
|
99
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
100
|
+
form = page.locator(FORM)
|
|
101
|
+
for f in self._fields:
|
|
102
|
+
if f.id not in answers:
|
|
103
|
+
continue
|
|
104
|
+
answer = answers[f.id]
|
|
105
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
106
|
+
control = form.locator(f"[name='{f.id}']")
|
|
107
|
+
match f.type:
|
|
108
|
+
case FieldType.FILE:
|
|
109
|
+
_attach(control, value)
|
|
110
|
+
case FieldType.CHOICE:
|
|
111
|
+
by_text = {t: v for v, t in self._options[f.id].items()}
|
|
112
|
+
control.select_option(value=by_text.get(value, value))
|
|
113
|
+
case _:
|
|
114
|
+
control.fill(value)
|
|
115
|
+
|
|
116
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
117
|
+
values = page.locator(FORM).evaluate(_READ_BACK, [f.id for f in self._fields])
|
|
118
|
+
out: dict[str, Answer] = {}
|
|
119
|
+
for field_id, value in values.items():
|
|
120
|
+
if field_id in self._options:
|
|
121
|
+
value = self._options[field_id].get(value, value)
|
|
122
|
+
elif isinstance(value, list): # a document's last upload
|
|
123
|
+
value = value[-1] if value else None
|
|
124
|
+
if value not in (None, ""):
|
|
125
|
+
out[field_id] = value
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
def submit(self, page: Page) -> str | None:
|
|
129
|
+
"""Waits for submit to be enabled (Turnstile), clicks it; the evidence is the answer to the
|
|
130
|
+
application's POST."""
|
|
131
|
+
button = page.locator(SUBMIT)
|
|
132
|
+
try:
|
|
133
|
+
page.wait_for_function(
|
|
134
|
+
"el => !el.disabled", arg=button.element_handle(), timeout=ENABLED_TIMEOUT_MS
|
|
135
|
+
)
|
|
136
|
+
except Timeout:
|
|
137
|
+
raise Blocked("captcha: Turnstile kept the submit disabled") from None
|
|
138
|
+
with page.expect_response(
|
|
139
|
+
lambda r: (
|
|
140
|
+
r.request.method == "POST" and APPLIED.search(urlsplit(r.url).path) is not None
|
|
141
|
+
),
|
|
142
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
143
|
+
) as applied:
|
|
144
|
+
button.click()
|
|
145
|
+
response = applied.value
|
|
146
|
+
if not response.ok:
|
|
147
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
148
|
+
return f"{response.url} {response.status}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
152
|
+
"""The account and job id, from `<account>.jobs.personio.{com,de}/job/<id>[/apply]`."""
|
|
153
|
+
parts = urlsplit(url)
|
|
154
|
+
host, path = HOST.match(parts.hostname or ""), JOB.match(parts.path)
|
|
155
|
+
return (host[1], path[1]) if host and path else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _apply(url: str) -> str:
|
|
159
|
+
"""The posting's apply page, in the language the URL asks for."""
|
|
160
|
+
parts = urlsplit(url)
|
|
161
|
+
job = JOB.match(parts.path)
|
|
162
|
+
assert job is not None
|
|
163
|
+
language = re.search(r"(?:^|&)(language=[\w-]+)", parts.query)
|
|
164
|
+
query = f"?{language[1]}" if language else ""
|
|
165
|
+
return f"https://{parts.hostname}/job/{job[1]}/apply{query}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class _Node:
|
|
170
|
+
tag: str
|
|
171
|
+
attrs: dict[str, str]
|
|
172
|
+
parent: _Node | None = None
|
|
173
|
+
children: list[_Node | str] = field(default_factory=list)
|
|
174
|
+
|
|
175
|
+
def walk(self) -> list[_Node]:
|
|
176
|
+
out = []
|
|
177
|
+
for child in self.children:
|
|
178
|
+
if isinstance(child, _Node):
|
|
179
|
+
out += [child, *child.walk()]
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
def text(self) -> str:
|
|
183
|
+
return "".join(c if isinstance(c, str) else c.text() for c in self.children)
|
|
184
|
+
|
|
185
|
+
def label(self) -> str:
|
|
186
|
+
"""The text, without the spans that mark it required or optional."""
|
|
187
|
+
own = (c for c in self.children if isinstance(c, str) or c.tag != "span")
|
|
188
|
+
return " ".join("".join(c if isinstance(c, str) else c.text() for c in own).split())
|
|
189
|
+
|
|
190
|
+
def starred(self) -> bool:
|
|
191
|
+
return any(
|
|
192
|
+
isinstance(c, _Node) and c.tag == "span" and c.text().strip() == "*"
|
|
193
|
+
for c in self.children
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def ancestor(self, role: str) -> _Node | None:
|
|
197
|
+
node = self.parent
|
|
198
|
+
while node is not None and node.attrs.get("role") != role:
|
|
199
|
+
node = node.parent
|
|
200
|
+
return node
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class _Tree(HTMLParser):
|
|
204
|
+
def __init__(self) -> None:
|
|
205
|
+
super().__init__()
|
|
206
|
+
self.root = self.open = _Node("", {})
|
|
207
|
+
|
|
208
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
209
|
+
node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
|
|
210
|
+
self.open.children.append(node)
|
|
211
|
+
if tag not in VOID:
|
|
212
|
+
self.open = node
|
|
213
|
+
|
|
214
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
215
|
+
self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
|
|
216
|
+
|
|
217
|
+
def handle_endtag(self, tag: str) -> None:
|
|
218
|
+
node: _Node | None = self.open
|
|
219
|
+
while node is not None and node.tag != tag:
|
|
220
|
+
node = node.parent
|
|
221
|
+
if node is not None and node.parent is not None:
|
|
222
|
+
self.open = node.parent
|
|
223
|
+
|
|
224
|
+
def handle_data(self, data: str) -> None:
|
|
225
|
+
self.open.children.append(data)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse(html: str) -> _Node:
|
|
229
|
+
tree = _Tree()
|
|
230
|
+
tree.feed(html)
|
|
231
|
+
return tree.root
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
|
|
235
|
+
"""Each named control of the form, with a choice's option texts by value."""
|
|
236
|
+
nodes = form.walk()
|
|
237
|
+
by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
|
|
238
|
+
labels = {n.attrs["for"]: n for n in nodes if n.tag == "label" and "for" in n.attrs}
|
|
239
|
+
out: list[tuple[Field, dict[str, str] | None]] = []
|
|
240
|
+
for node in nodes:
|
|
241
|
+
name = node.attrs.get("name")
|
|
242
|
+
if node.tag not in ("input", "select", "textarea") or not name:
|
|
243
|
+
continue
|
|
244
|
+
kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
|
|
245
|
+
if kind == "hidden":
|
|
246
|
+
continue
|
|
247
|
+
group = node.ancestor("group")
|
|
248
|
+
group_label = by_id.get(group.attrs.get("aria-labelledby", "")) if group else None
|
|
249
|
+
own = labels.get(node.attrs.get("id", ""))
|
|
250
|
+
label = (own.label() if own else "") or (group_label.label() if group_label else "") or name
|
|
251
|
+
required = any(n is not None and n.starred() for n in (own, group_label))
|
|
252
|
+
if kind not in KINDS or "multiple" in node.attrs and kind == "select":
|
|
253
|
+
reason = f"unhandled_rendering: {name} is a {kind}"
|
|
254
|
+
out.append((Field(name, label, FieldType.TEXT, required, unfillable=reason), None))
|
|
255
|
+
continue
|
|
256
|
+
field_type = FieldType.PHONE if name == PHONE else KINDS[kind]
|
|
257
|
+
options = None
|
|
258
|
+
if kind == "select":
|
|
259
|
+
options = {
|
|
260
|
+
o.attrs["value"]: " ".join(o.text().split())
|
|
261
|
+
for o in node.walk()
|
|
262
|
+
if o.tag == "option" and o.attrs.get("value")
|
|
263
|
+
}
|
|
264
|
+
max_length = int(node.attrs.get("maxlength", 0)) or None
|
|
265
|
+
accept = (
|
|
266
|
+
node.attrs["accept"].split(",") if kind == "file" and "accept" in node.attrs else None
|
|
267
|
+
)
|
|
268
|
+
out.append(
|
|
269
|
+
(
|
|
270
|
+
Field(
|
|
271
|
+
name,
|
|
272
|
+
label,
|
|
273
|
+
field_type,
|
|
274
|
+
required,
|
|
275
|
+
list(options.values()) if options else None,
|
|
276
|
+
max_length if field_type in (FieldType.TEXT, FieldType.PHONE) else None,
|
|
277
|
+
accept,
|
|
278
|
+
),
|
|
279
|
+
options,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _attach(control: Locator, path: str) -> None:
|
|
286
|
+
"""Attaches a document and waits for Personio's upload to show its name in the category."""
|
|
287
|
+
name = path.rsplit("/", 1)[-1]
|
|
288
|
+
control.set_input_files(path)
|
|
289
|
+
shown = control.locator("xpath=ancestor::*[@role='group'][1]").locator(
|
|
290
|
+
"[role=listitem] span", has_text=name
|
|
291
|
+
)
|
|
292
|
+
with suppress(Timeout):
|
|
293
|
+
shown.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
@@ -334,7 +334,9 @@ kind = "ats"
|
|
|
334
334
|
share = 0.0041
|
|
335
335
|
account = "none"
|
|
336
336
|
hosting = ["hosted"]
|
|
337
|
+
adapter = "personio"
|
|
337
338
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
339
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "ca99247" }
|
|
338
340
|
|
|
339
341
|
[gupy]
|
|
340
342
|
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
|
|
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
|