Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Get your API Token from the [Bright Data Control Panel](https://brightdata.com/c
export BRIGHTDATA_API_TOKEN="your_api_token_here"
```

**Already logged in with the CLI?** The SDK works with no configuration — it automatically
falls back to the credentials stored by `brightdata login`.

## Quick Start

This SDK is **async-native**. A sync client is also available (see [Sync Client](#sync-client)).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ where = ["src"]

[project]
name = "brightdata-sdk"
version = "2.4.1"
version = "2.5.0"
description = "Modern async-first Python SDK for Bright Data APIs"
authors = [{name = "Bright Data", email = "support@brightdata.com"}]
license = {text = "MIT"}
Expand Down
47 changes: 47 additions & 0 deletions src/brightdata/cli_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
Read the API key stored by the Bright Data CLI (`brightdata login`).

The CLI persists credentials to a well-known per-platform location:

- Linux: ~/.config/brightdata-cli/credentials.json
- macOS: ~/Library/Application Support/brightdata-cli/credentials.json
- Windows: %APPDATA%\\brightdata-cli\\credentials.json

File format (all login flows): {"api_key": "KEY"}

The SDK treats this store as strictly read-only: it never writes into the
brightdata-cli directory and never reads the config.json that lives beside
credentials.json.
"""

import json
import os
import sys
from pathlib import Path
from typing import Optional


def _cli_credentials_path() -> Path:
"""Return the platform-specific path of the CLI's credentials.json."""
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
elif sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
else: # linux and others
base = Path.home() / ".config"
return base / "brightdata-cli" / "credentials.json"


def read_cli_credentials() -> Optional[str]:
"""
Return the API key stored by `brightdata login`, or None if unavailable.

Any failure — missing file, malformed JSON, wrong value type, empty key,
no read permission — means "not available" and returns None. This function
never raises and never writes.
"""
try:
key = json.loads(_cli_credentials_path().read_text()).get("api_key")
return key.strip() if isinstance(key, str) and key.strip() else None
except Exception:
return None # missing file, bad JSON, no permission — all mean "not available"
41 changes: 29 additions & 12 deletions src/brightdata/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .datasets import DatasetsClient
from .models import ScrapeResult
from .types import AccountInfo
from .cli_credentials import read_cli_credentials
from http import HTTPStatus
from .exceptions import ValidationError, AuthenticationError, APIError

Expand Down Expand Up @@ -68,8 +69,9 @@ class BrightDataClient:
DEFAULT_WEB_UNLOCKER_ZONE = "sdk_unlocker"
DEFAULT_SERP_ZONE = "sdk_serp"

# Environment variable name for API token
# Environment variable names for API token (checked in this order)
TOKEN_ENV_VAR = "BRIGHTDATA_API_TOKEN"
TOKEN_ENV_VAR_ALT = "BRIGHTDATA_API_KEY"

def __init__(
self,
Expand All @@ -95,8 +97,10 @@ def __init__(
Supports loading from .env files (requires python-dotenv package).

Args:
token: API token. If None, loads from BRIGHTDATA_API_TOKEN environment variable
(supports .env files via python-dotenv)
token: API token. If None, loads from the BRIGHTDATA_API_TOKEN /
BRIGHTDATA_API_KEY environment variables (supports .env files via
python-dotenv), then falls back to the credentials stored by the
Bright Data CLI (`brightdata login`)
timeout: Default timeout in seconds for all requests (default: 30)
web_unlocker_zone: Zone name for web unlocker (default: "sdk_unlocker")
serp_zone: Zone name for SERP API (default: "sdk_serp")
Expand Down Expand Up @@ -129,7 +133,7 @@ def __init__(
... validate_token=True
... )
"""
self.token = self._load_token(token)
self.token, self.auth_source = self._load_token(token)
self.timeout = timeout
self.web_unlocker_zone = web_unlocker_zone or self.DEFAULT_WEB_UNLOCKER_ZONE
self.serp_zone = serp_zone or self.DEFAULT_SERP_ZONE
Expand All @@ -146,6 +150,7 @@ def __init__(
rate_period=rate_period,
ssl_verify=ssl_verify,
ssl_ca_cert=ssl_ca_cert,
auth_source=self.auth_source,
)

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

def _load_token(self, token: Optional[str]) -> str:
def _load_token(self, token: Optional[str]) -> tuple:
"""
Load token from parameter or environment variable.
Resolve the API token and record where it came from.

Resolution order: explicit parameter → environment variables
(BRIGHTDATA_API_TOKEN, then BRIGHTDATA_API_KEY) → the credentials
stored by the Bright Data CLI (`brightdata login`).

Fails fast with clear error message if no token found.

Args:
token: Explicit token (takes precedence)

Returns:
Valid token string
Tuple of (token, auth_source) where auth_source is "param",
"env", or "cli_credentials" — reported in the User-Agent so
SDK onboarding is measurable. The token itself is never logged.

Raises:
ValidationError: If no token found
Expand All @@ -198,19 +209,25 @@ def _load_token(self, token: Optional[str]) -> str:
f"Invalid token format. Token must be a string with at least 10 characters. "
f"Got: {type(token).__name__} with length {len(str(token))}"
)
return token.strip()
return token.strip(), "param"

# Try loading from environment variable
env_token = os.getenv(self.TOKEN_ENV_VAR)
# Try loading from environment variables
env_token = os.getenv(self.TOKEN_ENV_VAR) or os.getenv(self.TOKEN_ENV_VAR_ALT)
if env_token:
return env_token.strip()
return env_token.strip(), "env"

# Fall back to the CLI's stored credentials (read-only)
cli_token = read_cli_credentials()
if cli_token:
return cli_token, "cli_credentials"

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

Expand Down
12 changes: 11 additions & 1 deletion src/brightdata/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def __init__(
rate_period: float = 1.0,
ssl_verify: bool = True,
ssl_ca_cert: Optional[str] = None,
auth_source: Optional[str] = None,
):
"""
Initialize async engine.
Expand All @@ -66,12 +67,17 @@ def __init__(
Set to False for sandbox/proxy environments.
ssl_ca_cert: Path to a custom CA certificate bundle file.
Use when behind a corporate proxy with its own CA.
auth_source: How the bearer token was obtained ("param", "env",
"cli_credentials"). Reported in the User-Agent for
onboarding metrics; the token itself is never logged.
None (e.g. standalone scraper usage) omits the field.
"""
self.bearer_token = bearer_token
self.timeout = aiohttp.ClientTimeout(total=timeout)
self._session: Optional[aiohttp.ClientSession] = None
self._ssl_verify = ssl_verify
self._ssl_ca_cert = ssl_ca_cert
self._auth_source = auth_source

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

# Create session with the connector
user_agent = f"brightdata-sdk-python/{__version__}"
if self._auth_source:
user_agent += f" (auth={self._auth_source})"

self._session = aiohttp.ClientSession(
connector=connector,
trust_env=True,
timeout=self.timeout,
headers={
"Authorization": f"Bearer {self.bearer_token}",
"Content-Type": "application/json",
"User-Agent": f"brightdata-sdk/{__version__}",
"User-Agent": user_agent,
},
)

Expand Down
26 changes: 7 additions & 19 deletions src/brightdata/scrapers/amazon/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@
import asyncio
from typing import Union, List, Optional, Dict, Any

from ...core.engine import AsyncEngine
from ...models import ScrapeResult
from ...exceptions import ValidationError
from ...utils.function_detection import get_caller_function_name
from ...constants import DEFAULT_POLL_INTERVAL, DEFAULT_TIMEOUT_MEDIUM, DEFAULT_COST_PER_RECORD
from ..api_client import DatasetAPIClient
from ..workflow import WorkflowExecutor
from ..base import ScraperCore


class AmazonSearchScraper:
class AmazonSearchScraper(ScraperCore):
"""
Amazon Search Scraper for parameter-based discovery.

Expand All @@ -39,22 +37,12 @@ class AmazonSearchScraper:
# Amazon dataset IDs
DATASET_ID_PRODUCTS_SEARCH = "gd_lwdb4vjm1ehb499uxs" # Amazon Products Search (15.84M records)

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

Args:
bearer_token: Bright Data API token
engine: Optional AsyncEngine instance (reused from client)
"""
self.bearer_token = bearer_token
self.engine = engine if engine is not None else AsyncEngine(bearer_token)
self.api_client = DatasetAPIClient(self.engine)
self.workflow_executor = WorkflowExecutor(
api_client=self.api_client,
platform_name="amazon",
cost_per_record=DEFAULT_COST_PER_RECORD,
)
# Construction (token/engine/api_client/workflow_executor) and async
# context-manager support are inherited from ScraperCore.

# ============================================================================
# PRODUCTS SEARCH (by keyword + filters)
Expand Down
Loading