litescrape-sdk 0.1.0__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.
- litescrape_sdk-0.1.0/.gitattributes +1 -0
- litescrape_sdk-0.1.0/.github/workflows/ci.yml +21 -0
- litescrape_sdk-0.1.0/.github/workflows/publish.yml +34 -0
- litescrape_sdk-0.1.0/.gitignore +9 -0
- litescrape_sdk-0.1.0/.python-version +1 -0
- litescrape_sdk-0.1.0/LICENSE +21 -0
- litescrape_sdk-0.1.0/PKG-INFO +50 -0
- litescrape_sdk-0.1.0/README.md +30 -0
- litescrape_sdk-0.1.0/pyproject.toml +52 -0
- litescrape_sdk-0.1.0/src/litescrape_sdk/__init__.py +91 -0
- litescrape_sdk-0.1.0/src/litescrape_sdk/_runtime.py +201 -0
- litescrape_sdk-0.1.0/src/litescrape_sdk/_version.py +1 -0
- litescrape_sdk-0.1.0/src/litescrape_sdk/client.py +331 -0
- litescrape_sdk-0.1.0/src/litescrape_sdk/errors.py +129 -0
- litescrape_sdk-0.1.0/src/litescrape_sdk/models.py +621 -0
- litescrape_sdk-0.1.0/src/litescrape_sdk/py.typed +0 -0
- litescrape_sdk-0.1.0/tests/__init__.py +0 -0
- litescrape_sdk-0.1.0/tests/allowlists.json +313 -0
- litescrape_sdk-0.1.0/tests/conftest.py +116 -0
- litescrape_sdk-0.1.0/tests/test_models.py +200 -0
- litescrape_sdk-0.1.0/tests/test_scrape.py +441 -0
- litescrape_sdk-0.1.0/uv.lock +427 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ${{ matrix.os }}
|
|
8
|
+
strategy:
|
|
9
|
+
fail-fast: false
|
|
10
|
+
matrix:
|
|
11
|
+
os: [ubuntu-latest, windows-latest]
|
|
12
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v6
|
|
16
|
+
with:
|
|
17
|
+
python-version: ${{ matrix.python-version }}
|
|
18
|
+
- run: uv sync --frozen
|
|
19
|
+
- run: uv run ruff check .
|
|
20
|
+
- run: uv run ruff format --check .
|
|
21
|
+
- run: uv run pytest -q
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v6
|
|
13
|
+
- name: Check that the tag matches the package version
|
|
14
|
+
run: |
|
|
15
|
+
version=$(python -c "import re, pathlib; print(re.search(r'\"([^\"]+)\"', pathlib.Path('src/litescrape_sdk/_version.py').read_text()).group(1))")
|
|
16
|
+
test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME does not match version $version"; exit 1; }
|
|
17
|
+
- run: uv build
|
|
18
|
+
- uses: actions/upload-artifact@v4
|
|
19
|
+
with:
|
|
20
|
+
name: dist
|
|
21
|
+
path: dist/
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
needs: build
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
environment: pypi
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/download-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Litescrape
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: litescrape-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Litescrape API: validated, batched, retrying calls to search, maps, and reviews endpoints.
|
|
5
|
+
Project-URL: Homepage, https://litescrape.com
|
|
6
|
+
Project-URL: Source, https://github.com/litescrape/litescrape-sdk
|
|
7
|
+
Author-email: Litescraper <support@litescrape.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,google,litescrape,maps,reviews,scraping,serp
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx<1,>=0.27
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
18
|
+
Requires-Dist: tqdm<5,>=4.66
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# litescrape-sdk
|
|
22
|
+
|
|
23
|
+
Python SDK for the [Litescrape API](https://litescrape.com): validated, concurrent, retrying calls to the
|
|
24
|
+
Google, Bing, DuckDuckGo, Yelp, Tripadvisor, and Apple Maps endpoints, with results returned in input order.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
pip install litescrape-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
from litescrape_sdk import GoogleMaps, scrape
|
|
33
|
+
|
|
34
|
+
os.environ["LITESCRAPE_API_KEY"] = "ls_live_..."
|
|
35
|
+
|
|
36
|
+
results = scrape(
|
|
37
|
+
[
|
|
38
|
+
{"endpoint": "google_search", "q": "coffee grinders", "gl": "us"},
|
|
39
|
+
GoogleMaps(q="coffee", type="search", ll="@40.745,-73.988,14z"),
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
for result in results:
|
|
43
|
+
print(result.ok, result.data or result.error)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`help(litescrape_sdk.scrape)` documents every argument, the retry and concurrency rules, and the error
|
|
47
|
+
types. `ascrape` is the same function for asyncio code, and `litescrape_sdk.REQUEST_TYPES` maps each
|
|
48
|
+
`endpoint` slug to its request class.
|
|
49
|
+
|
|
50
|
+
Development: `uv sync`, then `uv run pytest`, `uv run ruff check .`, and `uv run ruff format .`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# litescrape-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Litescrape API](https://litescrape.com): validated, concurrent, retrying calls to the
|
|
4
|
+
Google, Bing, DuckDuckGo, Yelp, Tripadvisor, and Apple Maps endpoints, with results returned in input order.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
pip install litescrape-sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import os
|
|
12
|
+
from litescrape_sdk import GoogleMaps, scrape
|
|
13
|
+
|
|
14
|
+
os.environ["LITESCRAPE_API_KEY"] = "ls_live_..."
|
|
15
|
+
|
|
16
|
+
results = scrape(
|
|
17
|
+
[
|
|
18
|
+
{"endpoint": "google_search", "q": "coffee grinders", "gl": "us"},
|
|
19
|
+
GoogleMaps(q="coffee", type="search", ll="@40.745,-73.988,14z"),
|
|
20
|
+
]
|
|
21
|
+
)
|
|
22
|
+
for result in results:
|
|
23
|
+
print(result.ok, result.data or result.error)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`help(litescrape_sdk.scrape)` documents every argument, the retry and concurrency rules, and the error
|
|
27
|
+
types. `ascrape` is the same function for asyncio code, and `litescrape_sdk.REQUEST_TYPES` maps each
|
|
28
|
+
`endpoint` slug to its request class.
|
|
29
|
+
|
|
30
|
+
Development: `uv sync`, then `uv run pytest`, `uv run ruff check .`, and `uv run ruff format .`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "litescrape-sdk"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for the Litescrape API: validated, batched, retrying calls to search, maps, and reviews endpoints."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Litescraper", email = "support@litescrape.com" }]
|
|
14
|
+
keywords = ["litescrape", "serp", "google", "maps", "reviews", "scraping", "api"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.27,<1",
|
|
23
|
+
"pydantic>=2.7,<3",
|
|
24
|
+
"tqdm>=4.66,<5",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://litescrape.com"
|
|
29
|
+
Source = "https://github.com/litescrape/litescrape-sdk"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8,<9",
|
|
34
|
+
"ruff>=0.12,<1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
path = "src/litescrape_sdk/_version.py"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/litescrape_sdk"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 110
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
addopts = "-q"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Python SDK for the Litescrape API. Start with ``help(litescrape_sdk.scrape)``."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import Result, akey_status, ascrape, key_status, scrape
|
|
5
|
+
from .errors import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
LitescrapeError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
PaymentRequiredError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
TransportError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
from .models import (
|
|
16
|
+
REQUEST_TYPES,
|
|
17
|
+
AnyRequest,
|
|
18
|
+
AppleMapsPlaces,
|
|
19
|
+
AppleMapsReviews,
|
|
20
|
+
BingMaps,
|
|
21
|
+
BingSearch,
|
|
22
|
+
DuckDuckGoMaps,
|
|
23
|
+
DuckDuckGoSearch,
|
|
24
|
+
GoogleAds,
|
|
25
|
+
GoogleAiMode,
|
|
26
|
+
GoogleAiOverview,
|
|
27
|
+
GoogleContributorReviews,
|
|
28
|
+
GoogleLocal,
|
|
29
|
+
GoogleMaps,
|
|
30
|
+
GoogleMapsLiveFootTraffic,
|
|
31
|
+
GoogleMapsPhoto,
|
|
32
|
+
GoogleMapsPosts,
|
|
33
|
+
GoogleMapsWebResults,
|
|
34
|
+
GoogleReviews,
|
|
35
|
+
GoogleSearch,
|
|
36
|
+
GoogleShopping,
|
|
37
|
+
GoogleShoppingProduct,
|
|
38
|
+
KeyStatus,
|
|
39
|
+
ScrapeRequest,
|
|
40
|
+
TripadvisorPlace,
|
|
41
|
+
TripadvisorReviews,
|
|
42
|
+
TripadvisorSearch,
|
|
43
|
+
YelpReviews,
|
|
44
|
+
YelpSearch,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"__version__",
|
|
49
|
+
"scrape",
|
|
50
|
+
"ascrape",
|
|
51
|
+
"key_status",
|
|
52
|
+
"akey_status",
|
|
53
|
+
"Result",
|
|
54
|
+
"KeyStatus",
|
|
55
|
+
"ScrapeRequest",
|
|
56
|
+
"AnyRequest",
|
|
57
|
+
"REQUEST_TYPES",
|
|
58
|
+
"LitescrapeError",
|
|
59
|
+
"ValidationError",
|
|
60
|
+
"TransportError",
|
|
61
|
+
"APIError",
|
|
62
|
+
"AuthenticationError",
|
|
63
|
+
"PaymentRequiredError",
|
|
64
|
+
"NotFoundError",
|
|
65
|
+
"RateLimitError",
|
|
66
|
+
"GoogleSearch",
|
|
67
|
+
"GoogleAiOverview",
|
|
68
|
+
"GoogleAiMode",
|
|
69
|
+
"GoogleAds",
|
|
70
|
+
"GoogleShopping",
|
|
71
|
+
"GoogleShoppingProduct",
|
|
72
|
+
"GoogleLocal",
|
|
73
|
+
"GoogleMaps",
|
|
74
|
+
"GoogleMapsLiveFootTraffic",
|
|
75
|
+
"GoogleMapsPosts",
|
|
76
|
+
"GoogleMapsPhoto",
|
|
77
|
+
"GoogleMapsWebResults",
|
|
78
|
+
"GoogleReviews",
|
|
79
|
+
"GoogleContributorReviews",
|
|
80
|
+
"BingSearch",
|
|
81
|
+
"BingMaps",
|
|
82
|
+
"DuckDuckGoSearch",
|
|
83
|
+
"DuckDuckGoMaps",
|
|
84
|
+
"YelpSearch",
|
|
85
|
+
"YelpReviews",
|
|
86
|
+
"TripadvisorSearch",
|
|
87
|
+
"TripadvisorPlace",
|
|
88
|
+
"TripadvisorReviews",
|
|
89
|
+
"AppleMapsPlaces",
|
|
90
|
+
"AppleMapsReviews",
|
|
91
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Event loop, HTTP client, concurrency gate, and retry loop behind the public functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import contextlib
|
|
8
|
+
import random
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable, Coroutine
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import APIError, LitescrapeError, TransportError, api_error_from_response
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.litescrape.com"
|
|
21
|
+
DEFAULT_CONCURRENCY = 25
|
|
22
|
+
SELECTOR_LOOP_CAP = 500
|
|
23
|
+
BACKOFF_CAP = 30.0
|
|
24
|
+
CONNECT_TIMEOUT = 10.0
|
|
25
|
+
|
|
26
|
+
_RETRY_CODES = frozenset(
|
|
27
|
+
{"proxy_capacity_unavailable", "upstream_session_unavailable", "service_unavailable"}
|
|
28
|
+
)
|
|
29
|
+
_RETRY_TRANSPORT = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
|
|
30
|
+
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
_LoopEntry = tuple[asyncio.AbstractEventLoop, dict[Any, Any]]
|
|
33
|
+
|
|
34
|
+
_clients: dict[int, _LoopEntry] = {}
|
|
35
|
+
_semaphores: dict[int, _LoopEntry] = {}
|
|
36
|
+
_sleep = asyncio.sleep
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class Outcome:
|
|
41
|
+
data: dict[str, Any] | None = None
|
|
42
|
+
error: LitescrapeError | None = None
|
|
43
|
+
status_code: int | None = None
|
|
44
|
+
request_id: str = ""
|
|
45
|
+
attempts: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _per_loop(registry: dict[int, _LoopEntry]) -> dict[Any, Any]:
|
|
49
|
+
loop = asyncio.get_running_loop()
|
|
50
|
+
for key, (known, _) in list(registry.items()):
|
|
51
|
+
if known.is_closed():
|
|
52
|
+
del registry[key]
|
|
53
|
+
return registry.setdefault(id(loop), (loop, {}))[1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _make_client(base_url: str) -> httpx.AsyncClient:
|
|
57
|
+
return httpx.AsyncClient(
|
|
58
|
+
base_url=base_url,
|
|
59
|
+
headers={"Accept": "application/json", "User-Agent": f"litescrape-sdk/{__version__}"},
|
|
60
|
+
limits=httpx.Limits(max_connections=None, max_keepalive_connections=None),
|
|
61
|
+
timeout=httpx.Timeout(120.0, connect=CONNECT_TIMEOUT),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def client_for(base_url: str) -> httpx.AsyncClient:
|
|
66
|
+
clients = _per_loop(_clients)
|
|
67
|
+
client = clients.get(base_url)
|
|
68
|
+
if client is None:
|
|
69
|
+
client = clients[base_url] = _make_client(base_url)
|
|
70
|
+
return client
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def semaphore_for(api_key: str, base_url: str, limit: int) -> asyncio.Semaphore:
|
|
74
|
+
semaphores = _per_loop(_semaphores)
|
|
75
|
+
entry = semaphores.get((api_key, base_url))
|
|
76
|
+
if entry is None or entry[0] != limit:
|
|
77
|
+
entry = semaphores[(api_key, base_url)] = (limit, asyncio.Semaphore(limit))
|
|
78
|
+
return entry[1]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_selector_limited(loop: asyncio.AbstractEventLoop) -> bool:
|
|
82
|
+
return sys.platform == "win32" and isinstance(loop, asyncio.SelectorEventLoop)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def effective_limit(status_limit: int | None, override: int | None, *, selector_limited: bool) -> int:
|
|
86
|
+
limit = status_limit or DEFAULT_CONCURRENCY
|
|
87
|
+
if override is not None:
|
|
88
|
+
limit = min(limit, override)
|
|
89
|
+
if selector_limited:
|
|
90
|
+
limit = min(limit, SELECTOR_LOOP_CAP)
|
|
91
|
+
return max(1, limit)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _should_retry(error: LitescrapeError) -> bool:
|
|
95
|
+
if isinstance(error, TransportError):
|
|
96
|
+
return error.retryable
|
|
97
|
+
if isinstance(error, APIError):
|
|
98
|
+
status = error.status_code or 0
|
|
99
|
+
return error.retryable or status == 429 or status >= 500 or error.error_code in _RETRY_CODES
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _delay(attempt: int, retry_after: float | None) -> float:
|
|
104
|
+
if retry_after is not None:
|
|
105
|
+
return min(max(retry_after, 0.0), BACKOFF_CAP)
|
|
106
|
+
ceiling = min(BACKOFF_CAP, float(2 ** (attempt - 1)))
|
|
107
|
+
return random.uniform(ceiling / 2, ceiling)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _attempt(
|
|
111
|
+
client: httpx.AsyncClient,
|
|
112
|
+
path: str,
|
|
113
|
+
params: dict[str, str],
|
|
114
|
+
headers: dict[str, str],
|
|
115
|
+
timeout: float,
|
|
116
|
+
outcome: Outcome,
|
|
117
|
+
) -> LitescrapeError | None:
|
|
118
|
+
outcome.status_code, outcome.request_id = None, ""
|
|
119
|
+
try:
|
|
120
|
+
response = await client.get(
|
|
121
|
+
path,
|
|
122
|
+
params=params,
|
|
123
|
+
headers=headers,
|
|
124
|
+
timeout=httpx.Timeout(timeout, connect=min(CONNECT_TIMEOUT, timeout)),
|
|
125
|
+
)
|
|
126
|
+
except _RETRY_TRANSPORT as exc:
|
|
127
|
+
return TransportError(f"{type(exc).__name__}: {exc}", retryable=True, cause=exc)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
return TransportError(f"{type(exc).__name__}: {exc}", retryable=False, cause=exc)
|
|
130
|
+
outcome.status_code = response.status_code
|
|
131
|
+
outcome.request_id = response.headers.get("x-request-id", "")
|
|
132
|
+
if response.status_code != 200:
|
|
133
|
+
error = api_error_from_response(response)
|
|
134
|
+
outcome.request_id = error.request_id or outcome.request_id
|
|
135
|
+
return error
|
|
136
|
+
try:
|
|
137
|
+
data = response.json()
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
return TransportError("Response body was not JSON", retryable=True, cause=exc)
|
|
140
|
+
if not isinstance(data, dict):
|
|
141
|
+
return TransportError("Response body was not a JSON object", retryable=True)
|
|
142
|
+
outcome.data = data
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def request_with_retries(
|
|
147
|
+
client: httpx.AsyncClient,
|
|
148
|
+
path: str,
|
|
149
|
+
params: dict[str, str],
|
|
150
|
+
*,
|
|
151
|
+
headers: dict[str, str],
|
|
152
|
+
attempts: int,
|
|
153
|
+
timeout: float,
|
|
154
|
+
semaphore: asyncio.Semaphore | None = None,
|
|
155
|
+
gate: Callable[[], LitescrapeError | None] | None = None,
|
|
156
|
+
) -> Outcome:
|
|
157
|
+
outcome = Outcome()
|
|
158
|
+
slot = semaphore if semaphore is not None else contextlib.nullcontext()
|
|
159
|
+
for attempt in range(1, attempts + 1):
|
|
160
|
+
async with slot:
|
|
161
|
+
blocked = gate() if gate is not None else None
|
|
162
|
+
if blocked is not None:
|
|
163
|
+
outcome.error = blocked
|
|
164
|
+
outcome.status_code = getattr(blocked, "status_code", None)
|
|
165
|
+
return outcome
|
|
166
|
+
outcome.attempts = attempt
|
|
167
|
+
error = await _attempt(client, path, params, headers, timeout, outcome)
|
|
168
|
+
outcome.error = error
|
|
169
|
+
if error is None:
|
|
170
|
+
return outcome
|
|
171
|
+
if attempt == attempts or not _should_retry(error):
|
|
172
|
+
return outcome
|
|
173
|
+
await _sleep(_delay(attempt, getattr(error, "retry_after", None)))
|
|
174
|
+
return outcome
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_loop_lock = threading.Lock()
|
|
178
|
+
_loop: asyncio.AbstractEventLoop | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def background_loop() -> asyncio.AbstractEventLoop:
|
|
182
|
+
global _loop
|
|
183
|
+
with _loop_lock:
|
|
184
|
+
if _loop is None or _loop.is_closed():
|
|
185
|
+
loop = asyncio.ProactorEventLoop() if sys.platform == "win32" else asyncio.new_event_loop()
|
|
186
|
+
threading.Thread(target=loop.run_forever, name="litescrape-sdk", daemon=True).start()
|
|
187
|
+
_loop = loop
|
|
188
|
+
return _loop
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_sync(coro: Coroutine[Any, Any, T]) -> T:
|
|
192
|
+
future = asyncio.run_coroutine_threadsafe(coro, background_loop())
|
|
193
|
+
try:
|
|
194
|
+
while True:
|
|
195
|
+
try:
|
|
196
|
+
return future.result(timeout=0.25)
|
|
197
|
+
except concurrent.futures.TimeoutError:
|
|
198
|
+
continue
|
|
199
|
+
except BaseException:
|
|
200
|
+
future.cancel()
|
|
201
|
+
raise
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|