stage-cli 1.0.0__py3-none-any.whl

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 (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,46 @@
1
+ from bisect import bisect_right
2
+ from collections.abc import Sequence
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class RotationMember:
8
+ key: str
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Rotation:
13
+ selected: tuple[str, ...]
14
+ deferred: tuple[str, ...]
15
+ cursor: str
16
+ wrapped: bool = False
17
+
18
+ @property
19
+ def rotating(self) -> bool:
20
+ return bool(self.deferred)
21
+
22
+
23
+ def rotate(members: Sequence[RotationMember], *, cursor: str = "", budget: int = 0) -> Rotation:
24
+ if budget <= 0:
25
+ keys = tuple(member.key for member in members)
26
+ return Rotation(selected=keys, deferred=(), cursor=cursor)
27
+
28
+ ring = sorted(member.key for member in members)
29
+
30
+ if budget >= len(ring):
31
+ return Rotation(selected=tuple(ring), deferred=(), cursor="", wrapped=True)
32
+
33
+ start = bisect_right(ring, cursor) if cursor else 0
34
+ wrapped = start + budget > len(ring)
35
+ taken = [ring[(start + offset) % len(ring)] for offset in range(budget)]
36
+ remaining = [key for key in ring if key not in set(taken)]
37
+
38
+ return Rotation(
39
+ selected=tuple(taken),
40
+ deferred=tuple(remaining),
41
+ cursor=taken[-1] if taken else cursor,
42
+ wrapped=wrapped or start >= len(ring),
43
+ )
44
+
45
+
46
+ __all__ = ["Rotation", "RotationMember", "rotate"]
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class SourceSignals:
6
+ terms: tuple[str, ...] = field(default_factory=tuple)
7
+ season: str = ""
8
+
9
+ sponsorship: str = ""
10
+ degrees: tuple[str, ...] = field(default_factory=tuple)
11
+ category: str = ""
12
+ employment_type: str = ""
@@ -0,0 +1,35 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+
4
+ from stage.domain.enums import SyncOutcome
5
+ from stage.domain.health import UNRECORDED_VOLUME
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class SourceRunStats:
10
+ source: str
11
+ fetched: int = 0
12
+ added: int = 0
13
+ updated: int = 0
14
+ closed: int = 0
15
+ quarantined: int = 0
16
+ errors: int = 0
17
+ requests: int = 0
18
+ not_modified: int = 0
19
+ retries: int = 0
20
+ tightenings: int = 0
21
+ latency_p50_ms: float = 0.0
22
+ latency_p95_ms: float = 0.0
23
+ elapsed_ms: float = 0.0
24
+ deferred: int = 0
25
+ stored: int = UNRECORDED_VOLUME
26
+
27
+ blocked: bool = False
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class SyncRun:
32
+ started_at: datetime
33
+ finished_at: datetime
34
+ outcome: SyncOutcome
35
+ sources: tuple[SourceRunStats, ...] = field(default_factory=tuple)
stage/domain/text.py ADDED
@@ -0,0 +1,113 @@
1
+ import json
2
+ import re
3
+ import socket
4
+ import unicodedata
5
+ from datetime import UTC, datetime
6
+ from ipaddress import ip_address
7
+ from urllib.parse import urlsplit
8
+
9
+ _CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
10
+ _UNSAFE_IN_URL = re.compile(r"[\x00-\x20\x7f-\x9f]")
11
+ WEB_SCHEMES = ("http", "https")
12
+
13
+
14
+ def sanitize(value: str) -> str:
15
+ return _CONTROL.sub("", value)
16
+
17
+
18
+ def web_url(raw: str) -> str | None:
19
+ candidate = raw.strip()
20
+ if not candidate or _UNSAFE_IN_URL.search(candidate):
21
+ return None
22
+ try:
23
+ parts = urlsplit(candidate)
24
+ except ValueError:
25
+ return None
26
+ if parts.scheme not in WEB_SCHEMES or not parts.netloc:
27
+ return None
28
+ return candidate
29
+
30
+
31
+ def public_https_url(raw: str) -> str | None:
32
+ candidate = web_url(raw)
33
+ if candidate is None:
34
+ return None
35
+ parts = urlsplit(candidate)
36
+ if parts.scheme != "https" or parts.username or parts.password:
37
+ return None
38
+ host = (parts.hostname or "").rstrip(".").lower()
39
+ if not host or host == "localhost" or host.endswith(".localhost"):
40
+ return None
41
+ try:
42
+ address = ip_address(host)
43
+ except ValueError:
44
+ try:
45
+ address = ip_address(socket.inet_aton(host))
46
+ except OSError:
47
+ return candidate
48
+ return candidate if address.is_global else None
49
+
50
+
51
+ def first_line(value: str) -> str:
52
+ lines = [line.strip() for line in sanitize(value).splitlines() if line.strip()]
53
+ return lines[0] if lines else ""
54
+
55
+
56
+ def graphemes(value: str) -> list[str]:
57
+ clusters: list[str] = []
58
+ for char in value:
59
+ if unicodedata.combining(char) and clusters:
60
+ clusters[-1] += char
61
+ else:
62
+ clusters.append(char)
63
+ return clusters
64
+
65
+
66
+ def truncate(value: str, width: int) -> str:
67
+ clean = sanitize(value)
68
+ clusters = graphemes(clean)
69
+ if len(clusters) <= width:
70
+ return clean
71
+ return "".join(clusters[: max(width - 1, 0)]) + "…"
72
+
73
+
74
+ def summary(value: str, width: int) -> str:
75
+ return truncate(first_line(value), width)
76
+
77
+
78
+ def json_default(value: object) -> object:
79
+ if isinstance(value, datetime):
80
+ return value.astimezone(UTC).isoformat()
81
+ raise TypeError(f"unserializable value of type {type(value).__name__}")
82
+
83
+
84
+ def json_safe(value: object) -> object:
85
+ if isinstance(value, str):
86
+ return sanitize(value)
87
+ if isinstance(value, dict):
88
+ return {str(key): json_safe(item) for key, item in value.items()}
89
+ if isinstance(value, list | tuple):
90
+ return [json_safe(item) for item in value]
91
+ return value
92
+
93
+
94
+ def dump(payload: object) -> str:
95
+ return json.dumps(json_safe(payload), indent=2, ensure_ascii=False, default=json_default)
96
+
97
+
98
+ __all__ = [
99
+ "dump",
100
+ "first_line",
101
+ "graphemes",
102
+ "json_default",
103
+ "json_safe",
104
+ "public_https_url",
105
+ "sanitize",
106
+ "summary",
107
+ "truncate",
108
+ "web_url",
109
+ ]
110
+
111
+
112
+ def escape_markup(value: str) -> str:
113
+ return sanitize(value).replace("\\", "\\\\").replace("[", "\\[")
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+
5
+ @dataclass(frozen=True, slots=True)
6
+ class HttpValidator:
7
+ url: str
8
+ etag: str | None = None
9
+ last_modified: str | None = None
10
+ fetched_at: datetime | None = None
11
+
12
+ @property
13
+ def usable(self) -> bool:
14
+ return bool(self.etag or self.last_modified)
stage/domain/visits.py ADDED
@@ -0,0 +1,60 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime, timedelta
3
+
4
+ REST_AFTER_FAILURES = 3
5
+ REST_BASE_HOURS = 6
6
+ REST_MAX_HOURS = 168
7
+ _MAX_BACKOFF_STEPS = 8
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class CompanyVisit:
12
+ board: str
13
+ succeeded: bool
14
+ error: str = ""
15
+ label: str = ""
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class SourceVisit:
20
+ source: str
21
+ board: str
22
+ last_attempt_at: datetime
23
+ last_success_at: datetime | None = None
24
+ consecutive_failures: int = 0
25
+ last_error: str = ""
26
+ label: str = ""
27
+
28
+ @property
29
+ def never_succeeded(self) -> bool:
30
+ return self.last_success_at is None
31
+
32
+
33
+ def rested_until(visit: SourceVisit) -> datetime | None:
34
+ if visit.consecutive_failures < REST_AFTER_FAILURES:
35
+ return None
36
+ steps = min(visit.consecutive_failures - REST_AFTER_FAILURES, _MAX_BACKOFF_STEPS)
37
+ hours = min(REST_BASE_HOURS * 2**steps, REST_MAX_HOURS)
38
+ return visit.last_attempt_at + timedelta(hours=hours)
39
+
40
+
41
+ def is_resting(visit: SourceVisit, now: datetime) -> bool:
42
+ until = rested_until(visit)
43
+ return until is not None and now < until
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class DetailFetch:
48
+ id: str
49
+ resolved: bool = False
50
+ failed: bool = False
51
+
52
+
53
+ __all__ = [
54
+ "REST_AFTER_FAILURES",
55
+ "CompanyVisit",
56
+ "DetailFetch",
57
+ "SourceVisit",
58
+ "is_resting",
59
+ "rested_until",
60
+ ]
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+
5
+ @dataclass(frozen=True, slots=True)
6
+ class WorkdayFacet:
7
+ tenant: str
8
+ site: str
9
+ parameter: str
10
+ facet_ids: tuple[str, ...]
11
+ descriptor: str = ""
12
+ resolved_at: datetime | None = None
13
+ pinned: bool = False
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class WorkdayCrawl:
18
+ board: str
19
+ next_offset: int = 0
20
+ total: int | None = None
21
+ facet_parameter: str = ""
22
+ facet_ids: tuple[str, ...] = ()
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class WorkdayCrawlStep:
27
+ board: str
28
+ next_offset: int = 0
29
+ total: int | None = None
30
+ facet_parameter: str = ""
31
+ facet_ids: tuple[str, ...] = ()
32
+ seen_ids: tuple[str, ...] = ()
33
+ complete: bool = False
34
+ reset: bool = False
35
+ discard: bool = False
36
+
37
+
38
+ __all__ = ["WorkdayCrawl", "WorkdayCrawlStep", "WorkdayFacet"]
stage/http/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ from stage.http.breaker import BreakerState, CircuitBreaker
2
+ from stage.http.cache import ValidatorCache
3
+ from stage.http.client import (
4
+ USER_AGENT,
5
+ BreakerOpenError,
6
+ BucketBlockedError,
7
+ ForbiddenError,
8
+ HostBudget,
9
+ HostBudgetExceededError,
10
+ HostMetrics,
11
+ HostNotAllowedError,
12
+ HttpClient,
13
+ HttpError,
14
+ HttpStatusError,
15
+ JsonResponse,
16
+ RedirectNotAllowedError,
17
+ RequestRecord,
18
+ ResponseTooLargeError,
19
+ RetryableStatusError,
20
+ UnreadablePayloadError,
21
+ )
22
+ from stage.http.profiles import (
23
+ CEILING_BACKSTOP,
24
+ PROFILES,
25
+ RatePosture,
26
+ UnknownProfileError,
27
+ profile,
28
+ resolve,
29
+ )
30
+
31
+ __all__ = [
32
+ "PROFILES",
33
+ "USER_AGENT",
34
+ "BreakerOpenError",
35
+ "BreakerState",
36
+ "BucketBlockedError",
37
+ "ForbiddenError",
38
+ "CircuitBreaker",
39
+ "HostBudget",
40
+ "HostBudgetExceededError",
41
+ "HostMetrics",
42
+ "HostNotAllowedError",
43
+ "HttpClient",
44
+ "HttpError",
45
+ "HttpStatusError",
46
+ "RedirectNotAllowedError",
47
+ "JsonResponse",
48
+ "CEILING_BACKSTOP",
49
+ "RatePosture",
50
+ "RequestRecord",
51
+ "ResponseTooLargeError",
52
+ "UnreadablePayloadError",
53
+ "RetryableStatusError",
54
+ "UnknownProfileError",
55
+ "ValidatorCache",
56
+ "profile",
57
+ "resolve",
58
+ ]
stage/http/breaker.py ADDED
@@ -0,0 +1,53 @@
1
+ import time
2
+ from dataclasses import dataclass, field
3
+ from enum import StrEnum
4
+
5
+
6
+ class BreakerState(StrEnum):
7
+ CLOSED = "closed"
8
+ OPEN = "open"
9
+ HALF_OPEN = "half-open"
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class CircuitBreaker:
14
+ failure_threshold: int = 5
15
+ cooldown_s: float = 30.0
16
+ consecutive_failures: int = 0
17
+ opened_at: float | None = None
18
+ _probing: bool = field(default=False, repr=False)
19
+
20
+ def state(self, now: float | None = None) -> BreakerState:
21
+ if self.opened_at is None:
22
+ return BreakerState.CLOSED
23
+ moment = now if now is not None else time.monotonic()
24
+ if moment - self.opened_at >= self.cooldown_s:
25
+ return BreakerState.HALF_OPEN
26
+ return BreakerState.OPEN
27
+
28
+ def allows(self, now: float | None = None) -> bool:
29
+ current = self.state(now)
30
+ if current is BreakerState.CLOSED:
31
+ return True
32
+ if current is BreakerState.HALF_OPEN and not self._probing:
33
+ self._probing = True
34
+ return True
35
+ return False
36
+
37
+ def is_open(self, now: float | None = None) -> bool:
38
+ return self.state(now) is BreakerState.OPEN
39
+
40
+ def release_probe(self) -> None:
41
+ self._probing = False
42
+
43
+ def record_success(self) -> None:
44
+ self.consecutive_failures = 0
45
+ self.opened_at = None
46
+ self._probing = False
47
+
48
+ def record_failure(self, now: float | None = None) -> None:
49
+ self.consecutive_failures += 1
50
+ self._probing = False
51
+ tripped = self.consecutive_failures >= self.failure_threshold
52
+ if tripped or self.opened_at is not None:
53
+ self.opened_at = now if now is not None else time.monotonic()
stage/http/cache.py ADDED
@@ -0,0 +1,44 @@
1
+ from collections.abc import Mapping
2
+ from datetime import datetime
3
+
4
+ import httpx
5
+
6
+ from stage.domain import HttpValidator
7
+
8
+
9
+ class ValidatorCache:
10
+ def __init__(self, seed: Mapping[str, HttpValidator] | None = None) -> None:
11
+ self._entries: dict[str, HttpValidator] = dict(seed or {})
12
+ self._dirty: dict[str, HttpValidator] = {}
13
+
14
+ def get(self, url: str) -> HttpValidator | None:
15
+ entry = self._entries.get(url)
16
+ return entry if entry is not None and entry.usable else None
17
+
18
+ def conditional_headers(self, url: str) -> dict[str, str]:
19
+ entry = self.get(url)
20
+ if entry is None:
21
+ return {}
22
+ headers: dict[str, str] = {}
23
+ if entry.etag:
24
+ headers["If-None-Match"] = entry.etag
25
+ if entry.last_modified:
26
+ headers["If-Modified-Since"] = entry.last_modified
27
+ return headers
28
+
29
+ def record(self, url: str, headers: httpx.Headers, fetched_at: datetime) -> None:
30
+ etag = headers.get("etag")
31
+ last_modified = headers.get("last-modified")
32
+ if not etag and not last_modified:
33
+ return
34
+ entry = HttpValidator(
35
+ url=url, etag=etag, last_modified=last_modified, fetched_at=fetched_at
36
+ )
37
+ if self._entries.get(url) == entry:
38
+ return
39
+ self._entries[url] = entry
40
+ self._dirty[url] = entry
41
+
42
+ @property
43
+ def pending(self) -> Mapping[str, HttpValidator]:
44
+ return dict(self._dirty)