atsfill 0.0.4.dev61__tar.gz → 0.0.4.dev62__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 (38) hide show
  1. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/greenhouse.py +49 -10
  5. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/LICENSE +0 -0
  6. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/README.md +0 -0
  7. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/__init__.py +0 -0
  8. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/__init__.py +0 -0
  9. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/ashby.py +0 -0
  10. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/bamboohr.py +0 -0
  11. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/breezy.py +0 -0
  12. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/dayforce.py +0 -0
  13. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/hireology.py +0 -0
  14. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/jazzhr.py +0 -0
  15. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/lever.py +0 -0
  16. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/manatal.py +0 -0
  17. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/oracle.py +0 -0
  18. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/paycor.py +0 -0
  19. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/paylocity.py +0 -0
  20. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/phenom.py +0 -0
  21. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/recruitee.py +0 -0
  22. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/smartrecruiters.py +0 -0
  23. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/teamtailor.py +0 -0
  24. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/workable.py +0 -0
  25. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/wttj.py +0 -0
  26. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/cli.py +0 -0
  27. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/__init__.py +0 -0
  28. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/accounts.py +0 -0
  29. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/adapter.py +0 -0
  30. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/attempts.py +0 -0
  31. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/browser.py +0 -0
  32. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/commands.py +0 -0
  33. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/matching.py +0 -0
  34. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/paths.py +0 -0
  35. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/registry.py +0 -0
  36. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/results.py +0 -0
  37. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/py.typed +0 -0
  38. {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/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.dev61
3
+ Version: 0.0.4.dev62
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev61"
3
+ version = "0.0.4.dev62"
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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev61"
3
+ version = "0.0.4.dev62"
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"
@@ -5,6 +5,7 @@ Nothing but an attached file reaches Greenhouse before submit, so the read-back
5
5
  input values, the shown value of each select, the phone's dial code, the attached file's name.
6
6
  """
7
7
 
8
+ import itertools
8
9
  import json
9
10
  import re
10
11
  import time
@@ -21,6 +22,10 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
22
  HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
22
23
  FORM = "#application-form"
23
24
  API = "https://boards-api.greenhouse.io/v1/boards/{board}/jobs/{job}?questions=true"
25
+ # An education row's selects (`school--0`, …) and the endpoint listing their options a hundred
26
+ # per page: the opened menu shows only the first page.
27
+ EDUCATION = re.compile(r"^(school|degree|discipline)--\d+$")
28
+ EDUCATION_API = "https://boards-api.greenhouse.io/v1/boards/{board}/education/{kind}s?page={page}"
24
29
  EMBED = "https://job-boards.greenhouse.io/embed/job_app"
25
30
  EMBED_TIMEOUT_MS = 15_000
26
31
  OPTIONS_TIMEOUT_MS = 3_000
@@ -130,6 +135,7 @@ class Greenhouse:
130
135
  self._fields: dict[str, Field] = {}
131
136
  self._kinds: dict[str, str] = {} # how the page renders each field
132
137
  self._embed: str | None = None # the job_app URL an employer page embeds
138
+ self._education_options: dict[str, list[str] | None] = {} # by school, degree, discipline
133
139
 
134
140
  def detect(self, url: str, page: Page | None) -> bool:
135
141
  """A board URL, or an employer page whose `#grnhse_iframe` embeds a job application."""
@@ -162,11 +168,7 @@ class Greenhouse:
162
168
  if found is None:
163
169
  return None
164
170
  try:
165
- request = urllib.request.Request(
166
- API.format(board=found[0], job=found[1]), headers={"User-Agent": "atsfill"}
167
- )
168
- with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
169
- return _api_fields(json.load(response))
171
+ return _api_fields(_get_json(API.format(board=found[0], job=found[1])))
170
172
  except OSError, ValueError, KeyError, TypeError:
171
173
  return None
172
174
 
@@ -236,7 +238,7 @@ class Greenhouse:
236
238
  case "checkboxes":
237
239
  field_type = FieldType.MULTI_CHOICE
238
240
  case "select" | "multi":
239
- options = self._options(page, raw["id"])
241
+ options = self._education(page, raw["id"]) or self._options(page, raw["id"])
240
242
  if options is None:
241
243
  field_type = FieldType.TYPEAHEAD
242
244
  else:
@@ -256,6 +258,17 @@ class Greenhouse:
256
258
  raw.get("accept"),
257
259
  )
258
260
 
261
+ def _education(self, page: Page, field_id: str) -> list[str] | None:
262
+ """An education select's options, every page of them, read once per kind; None for
263
+ another field or when the endpoint does not answer."""
264
+ found = _board_job(page.url)
265
+ if not (found and (match := EDUCATION.match(field_id))):
266
+ return None
267
+ kind = match[1]
268
+ if kind not in self._education_options:
269
+ self._education_options[kind] = _education_options(found[0], kind)
270
+ return self._education_options[kind]
271
+
259
272
  def _options(self, page: Page, field_id: str) -> list[str] | None:
260
273
  """A select's options, from opening it; None for one that suggests as you type."""
261
274
  box = page.locator(_by_id(field_id))
@@ -302,6 +315,26 @@ def _board_job(url: str) -> tuple[str, str] | None:
302
315
  return None
303
316
 
304
317
 
318
+ def _get_json(url: str) -> Any:
319
+ request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
320
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
321
+ return json.load(response)
322
+
323
+
324
+ def _education_options(board: str, kind: str) -> list[str] | None:
325
+ """Every option the board's education endpoint lists for `kind`, page after page."""
326
+ options: list[str] = []
327
+ try:
328
+ for number in itertools.count(1):
329
+ body = _get_json(EDUCATION_API.format(board=board, kind=kind, page=number))
330
+ options += [item["text"] for item in body["items"]]
331
+ if not body["items"] or len(options) >= body["meta"]["total_count"]:
332
+ return options or None
333
+ except OSError, ValueError, KeyError, TypeError:
334
+ return None
335
+ return None
336
+
337
+
305
338
  def _api_fields(job: dict[str, Any]) -> list[Field] | None:
306
339
  if any(job.get(k) for k in UNMAPPED) or any(
307
340
  c.get("requires_consent") or c.get("requires_processing_consent")
@@ -336,17 +369,23 @@ def _exact(value: str) -> re.Pattern[str]:
336
369
 
337
370
 
338
371
  def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
339
- """Types into a react-select and picks the option: hovered then Enter, which every select
340
- takes. A suggestion list takes its first suggestion. No option leaves the select empty."""
372
+ """Types into a react-select and picks the option: focused with the arrow keys then Enter,
373
+ which every select takes (a hover does not move the focus once typed). A suggestion list
374
+ takes its first suggestion. No option leaves the select empty."""
341
375
  box = page.locator(_by_id(field_id))
342
376
  box.click()
343
377
  box.press_sequentially(value, delay=KEY_DELAY_MS)
344
- menu = page.locator(f"{_by_id(f'react-select-{field_id}-listbox')} [role=option]")
378
+ listbox = _by_id(f"react-select-{field_id}-listbox")
379
+ menu = page.locator(f"{listbox} [role=option]")
345
380
  option = menu.first if suggested else menu.filter(has_text=_exact(value)).first
346
381
  try:
347
382
  option.wait_for(timeout=ANSWER_TIMEOUT_MS)
348
383
  except Timeout:
349
384
  box.press("Escape")
350
385
  return
351
- option.hover()
386
+ focused = page.locator(f"{listbox} .select__option--is-focused")
387
+ for _ in range(menu.count()):
388
+ if suggested or any(_exact(value).match(t) for t in focused.all_inner_texts()):
389
+ break
390
+ box.press("ArrowDown")
352
391
  box.press("Enter")
File without changes
File without changes