Skip to content

Commit b0b13bf

Browse files
committed
refactor: streamlines typing and platform handling
Raises the local Python version to 3.14.5 and enables stricter mypy warnings to surface bad casts, unreachable code, and unused ignores earlier. Also replaces repeated runtime response checks with typed casts, uses secure randomness for pacing, and relies on explicit platform-specific paths for Firefox checks.
1 parent 064aa15 commit b0b13bf

9 files changed

Lines changed: 100 additions & 149 deletions

File tree

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.14
1+
3.14.5

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ packages = ["src/extro"]
4040
python_version = "3.14"
4141
mypy_path = "src"
4242
strict = true
43+
pretty = true
4344
explicit_package_bases = true
4445
warn_return_any = true
4546
warn_unused_configs = true
47+
warn_unreachable = true
48+
warn_incomplete_stub = true
49+
warn_no_return = true
50+
warn_redundant_casts = true
51+
warn_unused_ignores = true
4652

4753
[[tool.mypy.overrides]]
4854
module = ["questionary", "questionary.*", "platformdirs"]

src/extro/core/api.py

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

33
from __future__ import annotations
44

5-
import random
5+
import secrets
66
import time
7-
from typing import Any, Literal
7+
from typing import Literal, Protocol, cast
88

99
import curl_cffi
1010
from pydantic import BaseModel, ConfigDict, Field
@@ -15,10 +15,6 @@
1515

1616
API_BASE_URL = "https://learning.oreilly.com/api/v2"
1717

18-
# ---------------------------------------------------------------------------
19-
# Response models
20-
# ---------------------------------------------------------------------------
21-
2218

2319
class SearchResult(BaseModel):
2420
model_config = ConfigDict(extra="allow")
@@ -78,11 +74,6 @@ class FilesManifest(BaseModel):
7874
results: list[FilesManifestItem] = Field(default_factory=list)
7975

8076

81-
# ---------------------------------------------------------------------------
82-
# Helpers
83-
# ---------------------------------------------------------------------------
84-
85-
8677
def normalize_book_identifier(value: str) -> str:
8778
cleaned = value.strip().rstrip("/")
8879
if not cleaned:
@@ -96,14 +87,17 @@ def normalize_book_identifier(value: str) -> str:
9687
return cleaned.rsplit("/", maxsplit=1)[-1]
9788

9889

99-
# ---------------------------------------------------------------------------
100-
# Client
101-
# ---------------------------------------------------------------------------
102-
10390
_DEFAULT_DELAY_MIN: float = 0.75
10491
_DEFAULT_DELAY_MAX: float = 1.0
10592

10693

94+
class _JsonResponse(Protocol):
95+
url: str
96+
97+
def raise_for_status(self) -> None: ...
98+
def json(self) -> dict[str, str] | list[dict[str, str]]: ...
99+
100+
107101
class OreillyClient:
108102
"""Unauthenticated client for the public O'Reilly Learning API.
109103
@@ -126,97 +120,89 @@ def __init__(
126120
self._delay_min = delay_min
127121
self._delay_max = delay_max
128122

129-
# ------------------------------------------------------------------
130-
# Public API
131-
# ------------------------------------------------------------------
132-
133123
def search(self, keyword: str, *, field: SearchField = "title") -> SearchResponse:
134-
response = curl_cffi.get(
135-
f"{self._base_url}/search/",
136-
params={
137-
"formats": "book",
138-
"languages": "en",
139-
"include_facets": "false",
140-
"field": field,
141-
"query": keyword,
142-
"sort": "popularity",
143-
"order": "desc",
144-
"limit": "10",
145-
},
146-
headers=BROWSER_HEADERS,
147-
http_version="v2",
148-
allow_redirects=True,
149-
verify=True,
150-
impersonate="chrome146",
151-
timeout=self._timeout,
124+
response = cast(
125+
"_JsonResponse",
126+
curl_cffi.get(
127+
f"{self._base_url}/search/",
128+
params={
129+
"formats": "book",
130+
"languages": "en",
131+
"include_facets": "false",
132+
"field": field,
133+
"query": keyword,
134+
"sort": "popularity",
135+
"order": "desc",
136+
"limit": "10",
137+
},
138+
headers=BROWSER_HEADERS,
139+
http_version="v2",
140+
allow_redirects=True,
141+
verify=True,
142+
impersonate="chrome146",
143+
timeout=self._timeout,
144+
),
152145
)
153146
response.raise_for_status()
154-
return SearchResponse.model_validate(response.json())
147+
data = response.json()
148+
return SearchResponse.model_validate(data)
155149

156150
def fetch_metadata(self, identifier: str) -> BookMetadata:
157151
book_id = normalize_book_identifier(identifier)
158152
data = self._get_json(f"{self._base_url}/epubs/urn:orm:book:{book_id}/")
159153
return BookMetadata.model_validate(data)
160154

161-
def fetch_spine(self, metadata: BookMetadata) -> dict[str, Any]:
155+
def fetch_spine(self, metadata: BookMetadata) -> object:
162156
return self._get_json(metadata.spine, params={"limit": "1000"})
163157

164-
def fetch_files(self, metadata: BookMetadata) -> dict[str, Any]:
158+
def fetch_files(self, metadata: BookMetadata) -> object:
165159
return self._get_json(metadata.files, params={"limit": "10000"})
166160

167161
def fetch_table_of_contents(self, metadata: BookMetadata) -> object:
168162
return self._get_json_or_list(metadata.table_of_contents)
169163

170-
def fetch_chapters(self, metadata: BookMetadata) -> dict[str, Any]:
164+
def fetch_chapters(self, metadata: BookMetadata) -> object:
171165
return self._get_json(metadata.chapters, params={"limit": "1000"})
172166

173-
# ------------------------------------------------------------------
174-
# Internal helpers
175-
# ------------------------------------------------------------------
176-
177167
def _random_delay(self) -> None:
178-
time.sleep(random.uniform(self._delay_min, self._delay_max)) # noqa: S311
168+
time.sleep(secrets.SystemRandom().uniform(self._delay_min, self._delay_max))
179169

180170
def _get_json(
181171
self,
182172
url: str,
183173
*,
184174
params: dict[str, str] | None = None,
185-
) -> dict[str, Any]:
175+
) -> object:
186176
self._random_delay()
187-
response = curl_cffi.get(
188-
url,
189-
params=params,
190-
headers=BROWSER_HEADERS,
191-
http_version="v2",
192-
allow_redirects=True,
193-
verify=True,
194-
impersonate="chrome146",
195-
timeout=self._timeout,
177+
response = cast(
178+
"_JsonResponse",
179+
curl_cffi.get(
180+
url,
181+
params=params,
182+
headers=BROWSER_HEADERS,
183+
http_version="v2",
184+
allow_redirects=True,
185+
verify=True,
186+
impersonate="chrome146",
187+
timeout=self._timeout,
188+
),
196189
)
197-
print(response.url)
198190
response.raise_for_status()
199-
data = response.json()
200-
if not isinstance(data, dict):
201-
msg = f"Expected object response from {url}"
202-
raise TypeError(msg)
203-
return data
191+
return response.json()
204192

205193
def _get_json_or_list(self, url: str) -> object:
206194
self._random_delay()
207-
response = curl_cffi.get(
208-
url,
209-
headers=BROWSER_HEADERS,
210-
http_version="v2",
211-
allow_redirects=True,
212-
verify=True,
213-
impersonate="chrome146",
214-
timeout=self._timeout,
195+
response = cast(
196+
"_JsonResponse",
197+
curl_cffi.get(
198+
url,
199+
headers=BROWSER_HEADERS,
200+
http_version="v2",
201+
allow_redirects=True,
202+
verify=True,
203+
impersonate="chrome146",
204+
timeout=self._timeout,
205+
),
215206
)
216-
print(response.url)
217207
response.raise_for_status()
218-
data = response.json()
219-
if not isinstance(data, (dict, list)):
220-
msg = f"Expected object or array response from {url}"
221-
raise TypeError(msg)
222-
return data
208+
return response.json()

src/extro/core/config.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ def _coerce_path(cls, v: str | Path | None) -> Path | None:
3232
return None
3333
return Path(v)
3434

35-
# ------------------------------------------------------------------
36-
# Persistence helpers
37-
# ------------------------------------------------------------------
38-
3935
@classmethod
4036
def load(cls) -> AppConfig:
4137
"""Load config from disk. Returns a default instance if not found.

src/extro/core/cookies.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

3+
import platform
34
import subprocess
4-
import sys
55
import time
66
from typing import TYPE_CHECKING, cast
77

@@ -49,9 +49,14 @@ def oreilly_cookies_from_profile(profile_dir: Path) -> dict[str, str]:
4949

5050

5151
def is_firefox_running() -> bool:
52-
if sys.platform == "win32":
52+
if platform.system() == "Windows":
5353
result = subprocess.run(
54-
["tasklist", "/FI", "IMAGENAME eq firefox.exe", "/NH"], # noqa: S607
54+
[
55+
"C:/Windows/System32/tasklist.exe",
56+
"/FI",
57+
"IMAGENAME eq firefox.exe",
58+
"/NH",
59+
],
5560
capture_output=True,
5661
text=True,
5762
check=False,
@@ -60,8 +65,9 @@ def is_firefox_running() -> bool:
6065
line.lower().startswith("firefox.exe")
6166
for line in result.stdout.splitlines()
6267
)
68+
6369
result = subprocess.run(
64-
["pgrep", "-x", "firefox"], # noqa: S607
70+
["/usr/bin/pgrep", "-x", "firefox"],
6571
capture_output=True,
6672
text=True,
6773
check=False,

0 commit comments

Comments
 (0)