Skip to content

Commit e64432d

Browse files
committed
chore(types): add pyright gate and tighten engine seams
Wire pyright into make check, tighten Settings.sentry and DriverProtocol seams, and relax test-only diagnostics via executionEnvironments.
1 parent b8564c8 commit e64432d

10 files changed

Lines changed: 70 additions & 42 deletions

File tree

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: test build serve health scrape-example smoke lint lintfix check ready openapi openapi-verify spectral
1+
.PHONY: test build serve health scrape-example smoke lint lintfix check ready openapi openapi-verify spectral typecheck
22

33
.DEFAULT_GOAL := check
44

@@ -35,13 +35,16 @@ openapi-verify:
3535
$(PYTHON) scripts/export_openapi.py --out $$tmp && \
3636
diff -u $(OPENAPI_FILE) $$tmp
3737

38-
check: lint test openapi-verify
38+
check: lint test typecheck openapi-verify
3939

4040
ready: check
4141

4242
test:
4343
$(PYTHON) -m unittest discover -s tests
4444

45+
typecheck:
46+
$(PYTHON) -m pyright app tests
47+
4548

4649
build:
4750
docker build -t $(IMAGE) .

app/api/openapi.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6+
from typing import Any, cast
67

78
from app.api.openapi_examples import (
89
HEALTH_EXAMPLE,
@@ -17,9 +18,9 @@
1718
class OpenApiMetadata:
1819
api_description: str
1920
json_error_example: dict[str, dict[str, dict]]
20-
scrape_error_responses: dict[int, dict]
21-
health_responses: dict[int, dict]
22-
scrape_success_response: dict[int, dict]
21+
scrape_error_responses: dict[int | str, dict[str, Any]]
22+
health_responses: dict[int | str, dict[str, Any]]
23+
scrape_success_response: dict[int | str, dict[str, Any]]
2324
openapi_tags: list[dict[str, str]]
2425
servers: list[dict[str, str]]
2526
contact: dict[str, str]
@@ -115,7 +116,9 @@ def build_openapi_metadata(settings: Settings) -> OpenApiMetadata:
115116
return OpenApiMetadata(
116117
api_description=api_description,
117118
json_error_example=json_error_example,
118-
scrape_error_responses=scrape_error_responses,
119+
scrape_error_responses=cast(
120+
dict[int | str, dict[str, Any]], scrape_error_responses
121+
),
119122
health_responses={
120123
200: {
121124
"description": "Service is up.",
@@ -158,9 +161,9 @@ def build_openapi_metadata(settings: Settings) -> OpenApiMetadata:
158161
# Populated by configure_openapi() during create_app(); route modules import these names.
159162
API_DESCRIPTION = ""
160163
JSON_ERROR_EXAMPLE: dict[str, dict[str, dict]] = {}
161-
SCRAPE_ERROR_RESPONSES: dict[int, dict] = {}
162-
HEALTH_RESPONSES: dict[int, dict] = {}
163-
SCRAPE_SUCCESS_RESPONSE: dict[int, dict] = {}
164+
SCRAPE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {}
165+
HEALTH_RESPONSES: dict[int | str, dict[str, Any]] = {}
166+
SCRAPE_SUCCESS_RESPONSE: dict[int | str, dict[str, Any]] = {}
164167
OPENAPI_TAGS: list[dict[str, str]] = []
165168
SERVERS: list[dict[str, str]] = []
166169
CONTACT: dict[str, str] = {}

app/config.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,10 @@ class Settings(BaseSettings):
7474
)
7575
runtime_root: Path = Field(default=Path("/tmp/scrape"))
7676
environment: str = Field(default="production", validation_alias="ENVIRONMENT")
77-
sentry: SentrySettings | None = None
77+
sentry: SentrySettings = Field(default_factory=SentrySettings)
7878

7979
@model_validator(mode="after")
8080
def validate_timeout_relationship(self) -> Settings:
81-
object.__setattr__(self, "sentry", SentrySettings())
8281
if self.scrape_work_timeout_seconds > self.scrape_timeout_seconds:
8382
raise ValueError(
8483
"SCRAPE_WORK_TIMEOUT_SECONDS cannot exceed SCRAPE_TIMEOUT_SECONDS: "

app/engine/browser_tier.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import errno
66
import logging
77
import time
8-
from typing import Any
8+
from typing import Any, cast
99
from urllib.parse import urlparse
1010

1111
from app.config import Settings
@@ -113,21 +113,25 @@ def run_browser_tier(
113113
else None
114114
)
115115

116-
session.driver = Driver(
117-
headless=payload.headless,
118-
enable_xvfb_virtual_display=not payload.headless,
119-
proxy=payload.proxy,
120-
profile=str(session.profile_dir),
121-
tiny_profile=True,
122-
block_images=payload.block_images,
123-
block_images_and_css=payload.block_images_and_css,
124-
wait_for_complete_page_load=payload.wait_for_complete_page_load,
125-
user_agent=payload.effective_user_agent,
126-
window_size=driver_window_size,
127-
lang=payload.lang,
128-
remove_default_browser_check_argument=True,
116+
driver = cast(
117+
DriverProtocol,
118+
Driver(
119+
headless=payload.headless,
120+
enable_xvfb_virtual_display=not payload.headless,
121+
proxy=payload.proxy,
122+
profile=str(session.profile_dir),
123+
tiny_profile=True,
124+
block_images=payload.block_images,
125+
block_images_and_css=payload.block_images_and_css,
126+
wait_for_complete_page_load=payload.wait_for_complete_page_load,
127+
user_agent=payload.effective_user_agent,
128+
window_size=driver_window_size,
129+
lang=payload.lang,
130+
remove_default_browser_check_argument=True,
131+
),
129132
)
130-
configure_driver(session.driver, payload, target_url, collector=collector)
133+
session.driver = driver
134+
configure_driver(driver, payload, target_url, collector=collector)
131135
browser_ready_monotonic = time.monotonic()
132136
progress.mark(
133137
TimeoutPhase.WORK,
@@ -146,33 +150,33 @@ def run_browser_tier(
146150
step_budget = browser_step_budget_seconds(
147151
settings, started_monotonic, browser_ready_monotonic
148152
)
149-
navigate(session.driver, target_url, strategy, step_budget)
153+
navigate(driver, target_url, strategy, step_budget)
150154
wait_for_readiness(
151-
session.driver,
155+
driver,
152156
selector=payload.wait_for_selector,
153157
timeout_seconds=min(payload.wait_timeout_seconds, step_budget),
154158
)
155159

156160
if payload.scroll:
157-
apply_scrolling(session.driver)
161+
apply_scrolling(driver)
158162

159163
html, meta, assessment, xhr_responses = collect_page_state(
160-
session.driver,
164+
driver,
161165
target_url,
162166
collector,
163167
include_xhr=False,
164168
)
165169

166170
if assessment.challenge_detected or assessment.blocked_detected:
167-
call_if_available(session.driver, "bypass_cloudflare")
171+
call_if_available(driver, "bypass_cloudflare")
168172
html, meta, assessment, xhr_responses = collect_page_state(
169-
session.driver,
173+
driver,
170174
target_url,
171175
collector,
172176
include_xhr=True,
173177
)
174178
else:
175-
xhr_responses = harvest_xhr(collector, session.driver)
179+
xhr_responses = harvest_xhr(collector, driver)
176180

177181
if assessment.challenge_detected or assessment.blocked_detected:
178182
logger.warning(

app/engine/driver_capabilities.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import Any, Protocol, runtime_checkable
6+
from typing import Any, Protocol, cast, runtime_checkable
77

88
logger = logging.getLogger("botasaurus_scrape_api")
99

@@ -62,7 +62,7 @@ def call_if_available[T](
6262
if not callable(method):
6363
return default
6464
try:
65-
return method(*args, **kwargs)
65+
return cast(T, method(*args, **kwargs))
6666
except Exception as exc:
6767
logger.debug("driver_capability_failed method=%s error=%s", name, exc)
6868
return default

app/engine/envelope.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import Any
5+
from typing import Any, cast
66

77
from app.infra.detector import ChallengeAssessment
88
from app.schemas.enums import ErrorCategory, ExecutionTier, NavigationMode, TimeoutPhase
@@ -96,7 +96,7 @@ def build_success(
9696
headers=headers,
9797
html=html,
9898
metadata_error=metadata_error,
99-
xhr_responses=xhr_responses or [],
99+
xhr_responses=cast(list[XhrResponse], xhr_responses or []),
100100
diagnostics=build_diagnostics(
101101
request_id=request_id,
102102
attempts=attempts,

app/infra/request_id.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def resolve_request_id(
2222
@return tuple of resolved id and used_fallback flag
2323
"""
2424
candidate = inbound.strip() if inbound is not None else None
25-
if _is_valid(candidate):
25+
if candidate is not None and _is_valid(candidate):
2626
return candidate, False
2727

2828
reason = "absent" if not candidate else "invalid"

app/security/url_guard.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def validate(cls, raw_url: str) -> ValidationResult:
7777
for info in addr_infos:
7878
sockaddr = info[4]
7979
if sockaddr:
80-
resolved_ips.add(sockaddr[0])
80+
resolved_ips.add(str(sockaddr[0]))
8181

8282
for ip_text in resolved_ips:
8383
try:

pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
dev = [
1717
"ruff==0.16.4",
1818
"PyYAML==6.0.3",
19+
"pyright==1.1.406",
1920
]
2021

2122
[tool.ruff]
@@ -27,3 +28,21 @@ ignore = [
2728
"E501",
2829
"SIM105",
2930
]
31+
32+
[tool.pyright]
33+
pythonVersion = "3.14"
34+
venvPath = "."
35+
venv = ".venv"
36+
typeCheckingMode = "standard"
37+
include = ["app", "tests"]
38+
exclude = [".venv"]
39+
reportMissingTypeStubs = false
40+
reportMissingImports = false
41+
42+
[[tool.pyright.executionEnvironments]]
43+
root = "tests"
44+
reportArgumentType = "none"
45+
reportAttributeAccessIssue = "none"
46+
reportOptionalSubscript = "none"
47+
reportOptionalMemberAccess = "none"
48+
reportFunctionMemberAccess = "none"

tests/support/fakes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from pathlib import Path
6-
from typing import ClassVar
6+
from typing import Any, ClassVar
77

88

99
class FakeMetadataResponse:
@@ -51,7 +51,7 @@ def close(self):
5151

5252

5353
class CaptureDriver(FakeDriver):
54-
last_init_kwargs = None
54+
last_init_kwargs: ClassVar[dict[str, Any] | None] = None
5555

5656
def __init__(self, *args, **kwargs):
5757
type(self).last_init_kwargs = dict(kwargs)
@@ -67,7 +67,7 @@ def __init__(self, *, text, status_code, headers, url):
6767

6868

6969
class FakeRequest:
70-
response = None
70+
response: FakeHttpResponse | None = None
7171

7272
def get(self, *_args, **_kwargs):
7373
return type(self).response

0 commit comments

Comments
 (0)