Skip to content

Commit 35e8880

Browse files
authored
Merge pull request #47 from brightdata/dev
v2.5.0: CLI-credentials auth, scraper-core dedup, X scraper
2 parents a4a81a0 + e9637e5 commit 35e8880

18 files changed

Lines changed: 519 additions & 302 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ Get your API Token from the [Bright Data Control Panel](https://brightdata.com/c
1919
export BRIGHTDATA_API_TOKEN="your_api_token_here"
2020
```
2121

22+
**Already logged in with the CLI?** The SDK works with no configuration — it automatically
23+
falls back to the credentials stored by `brightdata login`.
24+
2225
## Quick Start
2326

2427
This SDK is **async-native**. A sync client is also available (see [Sync Client](#sync-client)).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ where = ["src"]
77

88
[project]
99
name = "brightdata-sdk"
10-
version = "2.4.1"
10+
version = "2.5.0"
1111
description = "Modern async-first Python SDK for Bright Data APIs"
1212
authors = [{name = "Bright Data", email = "support@brightdata.com"}]
1313
license = {text = "MIT"}

src/brightdata/cli_credentials.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
Read the API key stored by the Bright Data CLI (`brightdata login`).
3+
4+
The CLI persists credentials to a well-known per-platform location:
5+
6+
- Linux: ~/.config/brightdata-cli/credentials.json
7+
- macOS: ~/Library/Application Support/brightdata-cli/credentials.json
8+
- Windows: %APPDATA%\\brightdata-cli\\credentials.json
9+
10+
File format (all login flows): {"api_key": "KEY"}
11+
12+
The SDK treats this store as strictly read-only: it never writes into the
13+
brightdata-cli directory and never reads the config.json that lives beside
14+
credentials.json.
15+
"""
16+
17+
import json
18+
import os
19+
import sys
20+
from pathlib import Path
21+
from typing import Optional
22+
23+
24+
def _cli_credentials_path() -> Path:
25+
"""Return the platform-specific path of the CLI's credentials.json."""
26+
if sys.platform == "win32":
27+
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
28+
elif sys.platform == "darwin":
29+
base = Path.home() / "Library" / "Application Support"
30+
else: # linux and others
31+
base = Path.home() / ".config"
32+
return base / "brightdata-cli" / "credentials.json"
33+
34+
35+
def read_cli_credentials() -> Optional[str]:
36+
"""
37+
Return the API key stored by `brightdata login`, or None if unavailable.
38+
39+
Any failure — missing file, malformed JSON, wrong value type, empty key,
40+
no read permission — means "not available" and returns None. This function
41+
never raises and never writes.
42+
"""
43+
try:
44+
key = json.loads(_cli_credentials_path().read_text()).get("api_key")
45+
return key.strip() if isinstance(key, str) and key.strip() else None
46+
except Exception:
47+
return None # missing file, bad JSON, no permission — all mean "not available"

src/brightdata/client.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .datasets import DatasetsClient
3535
from .models import ScrapeResult
3636
from .types import AccountInfo
37+
from .cli_credentials import read_cli_credentials
3738
from http import HTTPStatus
3839
from .exceptions import ValidationError, AuthenticationError, APIError
3940

@@ -68,8 +69,9 @@ class BrightDataClient:
6869
DEFAULT_WEB_UNLOCKER_ZONE = "sdk_unlocker"
6970
DEFAULT_SERP_ZONE = "sdk_serp"
7071

71-
# Environment variable name for API token
72+
# Environment variable names for API token (checked in this order)
7273
TOKEN_ENV_VAR = "BRIGHTDATA_API_TOKEN"
74+
TOKEN_ENV_VAR_ALT = "BRIGHTDATA_API_KEY"
7375

7476
def __init__(
7577
self,
@@ -95,8 +97,10 @@ def __init__(
9597
Supports loading from .env files (requires python-dotenv package).
9698
9799
Args:
98-
token: API token. If None, loads from BRIGHTDATA_API_TOKEN environment variable
99-
(supports .env files via python-dotenv)
100+
token: API token. If None, loads from the BRIGHTDATA_API_TOKEN /
101+
BRIGHTDATA_API_KEY environment variables (supports .env files via
102+
python-dotenv), then falls back to the credentials stored by the
103+
Bright Data CLI (`brightdata login`)
100104
timeout: Default timeout in seconds for all requests (default: 30)
101105
web_unlocker_zone: Zone name for web unlocker (default: "sdk_unlocker")
102106
serp_zone: Zone name for SERP API (default: "sdk_serp")
@@ -129,7 +133,7 @@ def __init__(
129133
... validate_token=True
130134
... )
131135
"""
132-
self.token = self._load_token(token)
136+
self.token, self.auth_source = self._load_token(token)
133137
self.timeout = timeout
134138
self.web_unlocker_zone = web_unlocker_zone or self.DEFAULT_WEB_UNLOCKER_ZONE
135139
self.serp_zone = serp_zone or self.DEFAULT_SERP_ZONE
@@ -146,6 +150,7 @@ def __init__(
146150
rate_period=rate_period,
147151
ssl_verify=ssl_verify,
148152
ssl_ca_cert=ssl_ca_cert,
153+
auth_source=self.auth_source,
149154
)
150155

151156
self._scrape_service: Optional[ScrapeService] = None
@@ -177,17 +182,23 @@ def _ensure_initialized(self) -> None:
177182
"Use: async with BrightDataClient() as client: ..."
178183
)
179184

180-
def _load_token(self, token: Optional[str]) -> str:
185+
def _load_token(self, token: Optional[str]) -> tuple:
181186
"""
182-
Load token from parameter or environment variable.
187+
Resolve the API token and record where it came from.
188+
189+
Resolution order: explicit parameter → environment variables
190+
(BRIGHTDATA_API_TOKEN, then BRIGHTDATA_API_KEY) → the credentials
191+
stored by the Bright Data CLI (`brightdata login`).
183192
184193
Fails fast with clear error message if no token found.
185194
186195
Args:
187196
token: Explicit token (takes precedence)
188197
189198
Returns:
190-
Valid token string
199+
Tuple of (token, auth_source) where auth_source is "param",
200+
"env", or "cli_credentials" — reported in the User-Agent so
201+
SDK onboarding is measurable. The token itself is never logged.
191202
192203
Raises:
193204
ValidationError: If no token found
@@ -198,19 +209,25 @@ def _load_token(self, token: Optional[str]) -> str:
198209
f"Invalid token format. Token must be a string with at least 10 characters. "
199210
f"Got: {type(token).__name__} with length {len(str(token))}"
200211
)
201-
return token.strip()
212+
return token.strip(), "param"
202213

203-
# Try loading from environment variable
204-
env_token = os.getenv(self.TOKEN_ENV_VAR)
214+
# Try loading from environment variables
215+
env_token = os.getenv(self.TOKEN_ENV_VAR) or os.getenv(self.TOKEN_ENV_VAR_ALT)
205216
if env_token:
206-
return env_token.strip()
217+
return env_token.strip(), "env"
218+
219+
# Fall back to the CLI's stored credentials (read-only)
220+
cli_token = read_cli_credentials()
221+
if cli_token:
222+
return cli_token, "cli_credentials"
207223

208224
# No token found - fail fast with helpful message
209225
raise ValidationError(
210226
f"API token required but not found.\n\n"
211227
f"Provide token in one of these ways:\n"
212228
f" 1. Pass as parameter: BrightDataClient(token='your_token')\n"
213-
f" 2. Set environment variable: {self.TOKEN_ENV_VAR}\n\n"
229+
f" 2. Set environment variable: {self.TOKEN_ENV_VAR}\n"
230+
f" 3. Log in with the Bright Data CLI: brightdata login\n\n"
214231
f"Get your API token from: https://brightdata.com/cp/setting/users"
215232
)
216233

src/brightdata/core/engine.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def __init__(
5252
rate_period: float = 1.0,
5353
ssl_verify: bool = True,
5454
ssl_ca_cert: Optional[str] = None,
55+
auth_source: Optional[str] = None,
5556
):
5657
"""
5758
Initialize async engine.
@@ -66,12 +67,17 @@ def __init__(
6667
Set to False for sandbox/proxy environments.
6768
ssl_ca_cert: Path to a custom CA certificate bundle file.
6869
Use when behind a corporate proxy with its own CA.
70+
auth_source: How the bearer token was obtained ("param", "env",
71+
"cli_credentials"). Reported in the User-Agent for
72+
onboarding metrics; the token itself is never logged.
73+
None (e.g. standalone scraper usage) omits the field.
6974
"""
7075
self.bearer_token = bearer_token
7176
self.timeout = aiohttp.ClientTimeout(total=timeout)
7277
self._session: Optional[aiohttp.ClientSession] = None
7378
self._ssl_verify = ssl_verify
7479
self._ssl_ca_cert = ssl_ca_cert
80+
self._auth_source = auth_source
7581

7682
# Store rate limit config (create limiter per event loop in __aenter__)
7783
if rate_limit is None:
@@ -103,14 +109,18 @@ async def __aenter__(self):
103109
)
104110

105111
# Create session with the connector
112+
user_agent = f"brightdata-sdk-python/{__version__}"
113+
if self._auth_source:
114+
user_agent += f" (auth={self._auth_source})"
115+
106116
self._session = aiohttp.ClientSession(
107117
connector=connector,
108118
trust_env=True,
109119
timeout=self.timeout,
110120
headers={
111121
"Authorization": f"Bearer {self.bearer_token}",
112122
"Content-Type": "application/json",
113-
"User-Agent": f"brightdata-sdk/{__version__}",
123+
"User-Agent": user_agent,
114124
},
115125
)
116126

src/brightdata/scrapers/amazon/search.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,14 @@
1111
import asyncio
1212
from typing import Union, List, Optional, Dict, Any
1313

14-
from ...core.engine import AsyncEngine
1514
from ...models import ScrapeResult
1615
from ...exceptions import ValidationError
1716
from ...utils.function_detection import get_caller_function_name
1817
from ...constants import DEFAULT_POLL_INTERVAL, DEFAULT_TIMEOUT_MEDIUM, DEFAULT_COST_PER_RECORD
19-
from ..api_client import DatasetAPIClient
20-
from ..workflow import WorkflowExecutor
18+
from ..base import ScraperCore
2119

2220

23-
class AmazonSearchScraper:
21+
class AmazonSearchScraper(ScraperCore):
2422
"""
2523
Amazon Search Scraper for parameter-based discovery.
2624
@@ -39,22 +37,12 @@ class AmazonSearchScraper:
3937
# Amazon dataset IDs
4038
DATASET_ID_PRODUCTS_SEARCH = "gd_lwdb4vjm1ehb499uxs" # Amazon Products Search (15.84M records)
4139

42-
def __init__(self, bearer_token: str, engine: Optional[AsyncEngine] = None):
43-
"""
44-
Initialize Amazon search scraper.
40+
# Platform configuration (consumed by ScraperCore.__init__)
41+
PLATFORM_NAME = "amazon"
42+
COST_PER_RECORD = DEFAULT_COST_PER_RECORD
4543

46-
Args:
47-
bearer_token: Bright Data API token
48-
engine: Optional AsyncEngine instance (reused from client)
49-
"""
50-
self.bearer_token = bearer_token
51-
self.engine = engine if engine is not None else AsyncEngine(bearer_token)
52-
self.api_client = DatasetAPIClient(self.engine)
53-
self.workflow_executor = WorkflowExecutor(
54-
api_client=self.api_client,
55-
platform_name="amazon",
56-
cost_per_record=DEFAULT_COST_PER_RECORD,
57-
)
44+
# Construction (token/engine/api_client/workflow_executor) and async
45+
# context-manager support are inherited from ScraperCore.
5846

5947
# ============================================================================
6048
# PRODUCTS SEARCH (by keyword + filters)

0 commit comments

Comments
 (0)