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.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/domain/rotation.py
ADDED
|
@@ -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"]
|
stage/domain/signals.py
ADDED
|
@@ -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 = ""
|
stage/domain/sync_run.py
ADDED
|
@@ -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
|
+
]
|
stage/domain/workday.py
ADDED
|
@@ -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)
|