atsfill 0.0.5.dev187__tar.gz → 0.0.5.dev189__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 (66) hide show
  1. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/phenom.py +46 -5
  5. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/paths.py +8 -0
  6. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/LICENSE +0 -0
  7. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/README.md +0 -0
  8. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/adp.py +0 -0
  11. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/adpmyjobs.py +0 -0
  12. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/bamboohr.py +0 -0
  14. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/breezy.py +0 -0
  15. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/dayforce.py +0 -0
  16. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/eightfold.py +0 -0
  17. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/greenhouse.py +0 -0
  18. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/gupy.py +0 -0
  19. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/hireology.py +0 -0
  20. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/hiringthing.py +0 -0
  21. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/hrmdirect.py +0 -0
  22. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/isolvedhire.py +0 -0
  23. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/jazzhr.py +0 -0
  24. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/jobaffinity.py +0 -0
  25. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/jobstreet.py +0 -0
  26. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/join.py +0 -0
  27. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/lever.py +0 -0
  28. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/manatal.py +0 -0
  29. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/oracle.py +0 -0
  30. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/paycor.py +0 -0
  31. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/paylocity.py +0 -0
  32. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/personio.py +0 -0
  33. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/pinpoint.py +0 -0
  34. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/recruitee.py +0 -0
  35. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/recruiterflow.py +0 -0
  36. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/rippling.py +0 -0
  37. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/smartrecruiters.py +0 -0
  38. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/successfactors.js +0 -0
  39. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/successfactors.py +0 -0
  40. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/taleo.py +0 -0
  41. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/teamtailor.py +0 -0
  42. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/ukg.py +0 -0
  43. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/welcomekit.py +0 -0
  44. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/workable.py +0 -0
  45. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/workatastartup.py +0 -0
  46. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/workday.py +0 -0
  47. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/wttj.py +0 -0
  48. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/adapters/zohorecruit.py +0 -0
  49. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/cli.py +0 -0
  50. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/__init__.py +0 -0
  51. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/accounts.py +0 -0
  52. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/adapter.py +0 -0
  53. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/attempts.py +0 -0
  54. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/browser.py +0 -0
  55. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/commands.py +0 -0
  56. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/conditions.py +0 -0
  57. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/html.py +0 -0
  58. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/matching.py +0 -0
  59. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/otp.py +0 -0
  60. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/passwords.py +0 -0
  61. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/registry.py +0 -0
  62. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/results.py +0 -0
  63. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/snapshot.py +0 -0
  64. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/core/telemetry.py +0 -0
  65. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/py.typed +0 -0
  66. {atsfill-0.0.5.dev187 → atsfill-0.0.5.dev189}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.5.dev187
3
+ Version: 0.0.5.dev189
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.5.dev187"
3
+ version = "0.0.5.dev189"
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.5.dev187"
3
+ version = "0.0.5.dev189"
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"
@@ -8,6 +8,7 @@ fields, dependent lists): a request read or a browser read interprets them witho
8
8
  step. Filling walks the wizard on a fresh session, each step's answers read back before Next.
9
9
  """
10
10
 
11
+ import hashlib
11
12
  import http.cookiejar
12
13
  import json
13
14
  import re
@@ -23,7 +24,7 @@ from pathlib import PurePath
23
24
  from typing import Any
24
25
  from urllib.parse import parse_qs, urlsplit
25
26
 
26
- from atsfill.core import browser, matching
27
+ from atsfill.core import browser, matching, paths
27
28
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
28
29
  from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Locator, Overlaid, Page, Timeout
29
30
  from atsfill.core.results import (
@@ -67,10 +68,12 @@ PICK_TRIES = 3
67
68
  MAX_STEPS = 12
68
69
  OPTIONS_TIMEOUT_S = 8
69
70
  LOADERS_AT_ONCE = 8 # a loader request a country: 245 on one site
71
+ COUNTRIES_KEPT_S = 24 * 3600 # a site's country fields, kept between reads
70
72
  YES_NO = ["Yes", "No"]
71
73
  NO_YES = ["No", "Yes"]
72
74
  MONTH = " (MM/YYYY)"
73
75
  YEAR = " (year)"
76
+ NUMBER = " (a number)" # an input that takes only digits
74
77
  PICKER = ".react-datepicker"
75
78
  MONTHS: list[str] = [
76
79
  "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"
@@ -363,7 +366,7 @@ class Phenom:
363
366
  self._open(page, fresh=True)
364
367
  self._accept_terms(page)
365
368
  self._kept, self._shown = {}, {}
366
- if RESUME_ID in answers and page.locator(RESUME).count():
369
+ if RESUME_ID in answers and _shows_upload(page):
367
370
  _upload(page, _one(answers[RESUME_ID]))
368
371
  self._kept[RESUME_ID] = PurePath(_one(answers[RESUME_ID])).name
369
372
  for number in range(1, MAX_STEPS + 1):
@@ -662,7 +665,8 @@ class _Config:
662
665
  if html == "daterangepicker" or widget == "daterangepicker":
663
666
  return self.date_range(p, u, path, nav, when)
664
667
  if p.get("type") == "array" and get(p, "items.properties") and html not in WIDGETS:
665
- if not (int(p.get("minItems") or 0) or int(u.get("minItemsRequired") or 0)):
668
+ shown = p.get("minItems"), p.get("defaultEntries"), u.get("minItemsRequired")
669
+ if not any(int(n or 0) for n in shown):
666
670
  return [] # entries only once Add is clicked
667
671
  return self.object(p["items"], u.get("items") or {}, f"{path}[0].", nav, False, when)
668
672
  if p.get("type") == "object" or html in ("object", "phoneNumber_v2"):
@@ -679,6 +683,7 @@ class _Config:
679
683
  # a signature's date: today's, the only one the page takes
680
684
  "prefilled": date.today().isoformat() if p.get("defaultTodayDate") is True else None,
681
685
  "max_length": p.get("maxLength") if isinstance(p.get("maxLength"), int) else None,
686
+ "number": kind == "text" and _number(p, html, widget),
682
687
  }
683
688
  self.labels[path] = raw["label"]
684
689
  if kind in ("select", "typeahead", "radio", "checkboxes"):
@@ -1005,7 +1010,7 @@ class _Config:
1005
1010
  values, texts = self.listed.get(path) or ([], [])
1006
1011
  held, _ = self.prefilled(path)
1007
1012
  codes = sorted(values, key=lambda c: c != held)
1008
- parts = self.calls(payload, [{"countryCode": c} for c in codes])
1013
+ parts = self.country_parts(payload, codes)
1009
1014
  by_id: dict[str, dict[Any, dict[str, Any]]] = {}
1010
1015
  for code, part in zip(codes, parts, strict=True):
1011
1016
  part = get(part, "applyGetReferences.data")
@@ -1017,6 +1022,24 @@ class _Config:
1017
1022
  shown = dict(zip(values, texts, strict=False))
1018
1023
  return [_by_country(path, rows, codes, shown) for rows in by_id.values()]
1019
1024
 
1025
+ def country_parts(self, payload: str, codes: list[Any]) -> list[Any]:
1026
+ """Each country's loader answer, kept a day for the site: a read posts one a country,
1027
+ and two reads in a row get `/applySubmit` refused (403) on one site."""
1028
+ key = hashlib.sha1(f"{self.ph.get('locale')} {payload}".encode()).hexdigest()[:12]
1029
+ path = paths.cache_dir() / "phenom" / f"{self.ph.get('refNum')}-{key}.json"
1030
+ kept: dict[str, Any] = {}
1031
+ with suppress(OSError, ValueError):
1032
+ if time.time() - path.stat().st_mtime < COUNTRIES_KEPT_S:
1033
+ kept = json.loads(path.read_text())
1034
+ missing = [c for c in codes if str(c) not in kept]
1035
+ if missing:
1036
+ parts = self.calls(payload, [{"countryCode": c} for c in missing])
1037
+ kept |= {str(c): p for c, p in zip(missing, parts, strict=True) if p is not None}
1038
+ with suppress(OSError):
1039
+ path.parent.mkdir(parents=True, exist_ok=True)
1040
+ path.write_text(json.dumps(kept))
1041
+ return [kept.get(str(c)) for c in codes]
1042
+
1020
1043
  def date_range(
1021
1044
  self, p: dict, u: dict, path: str, nav: Any, when: Condition | None
1022
1045
  ) -> list[dict[str, Any]]:
@@ -1064,7 +1087,10 @@ class _Config:
1064
1087
  held = get(
1065
1088
  {"phApp": self.ph}, get(self.config, f"uiSchema.{name}.preFillWindowObj") or ""
1066
1089
  )
1067
- held = held if held is not None else {"locale": self.ph.get("locale")}.get(name)
1090
+ if held is None: # the job's data by its name (`jobCountry`), or the page's locale
1091
+ held = ({"locale": self.ph.get("locale")} | self.ddo).get(name)
1092
+ if "pattern" in test: # a job location naming a county
1093
+ return isinstance(held, str) and re.search(test["pattern"], held) is not None
1068
1094
  return True if values is None else held in values
1069
1095
  return Is(f"{path}{name}", values)
1070
1096
 
@@ -1229,6 +1255,11 @@ def _kind(p: dict, html: Any, widget: Any, path: str) -> str:
1229
1255
  return "text"
1230
1256
 
1231
1257
 
1258
+ def _number(p: dict, html: Any, widget: Any) -> bool:
1259
+ """Whether the input takes only digits (`input[type=number]`)."""
1260
+ return p.get("type") in ("integer", "number") or bool({html, widget} & {"number", "updown"})
1261
+
1262
+
1232
1263
  def _picker(form: str) -> str:
1233
1264
  """The calendar a format picks from: `date` (a day), `month` (`MM/yyyy`), `year`."""
1234
1265
  tokens = {t[0] for t in re.findall(r"y+|m+|d+", form.lower())}
@@ -1292,6 +1323,7 @@ def _field(raw: dict[str, Any]) -> Field:
1292
1323
  if kind == "checkbox": # an optional box starts clear
1293
1324
  options = YES_NO if raw["required"] else NO_YES
1294
1325
  label = raw["label"] + {"month": MONTH, "year": YEAR}.get(kind, "")
1326
+ label += NUMBER if raw.get("number") else ""
1295
1327
  sets = raw.get("option_sets")
1296
1328
  field_type = (
1297
1329
  FieldType.TYPEAHEAD if kind == "typeahead" and not options and not sets else TYPES[kind]
@@ -1449,6 +1481,15 @@ def _one(answer: Answer) -> str:
1449
1481
  return answer if isinstance(answer, str) else answer[0]
1450
1482
 
1451
1483
 
1484
+ def _shows_upload(page: Page) -> bool:
1485
+ """Whether the resume upload shows: on one site a moment after the form, past its terms."""
1486
+ try:
1487
+ page.locator(RESUME).first.wait_for(state="attached", timeout=FIELD_TIMEOUT_MS)
1488
+ except Timeout:
1489
+ return False
1490
+ return True
1491
+
1492
+
1452
1493
  def _upload(page: Page, path: str) -> None:
1453
1494
  """Attaches the resume, taken once Phenom's `/fileUpload` answers or the page shows its
1454
1495
  name, and waits for its parse, which fills and clears fields when it comes back: until
@@ -60,6 +60,14 @@ def _move_to_default(data: Path) -> None:
60
60
  (data / name).rename(default / name)
61
61
 
62
62
 
63
+ def cache_dir() -> Path:
64
+ """Public data atsfill can fetch again, kept to spare a hiring system the requests:
65
+ `$XDG_CACHE_HOME/atsfill` on Linux (`~/.cache/atsfill`), `~/.atsfill/cache` elsewhere."""
66
+ if xdg := os.environ.get("XDG_CACHE_HOME"):
67
+ return Path(xdg) / "atsfill"
68
+ return Path.home() / (".cache/atsfill" if LINUX else ".atsfill/cache")
69
+
70
+
63
71
  def config_file() -> Path:
64
72
  if xdg := os.environ.get("XDG_CONFIG_HOME"):
65
73
  return Path(xdg) / "atsfill/config.toml"
File without changes
File without changes