Skip to content

Commit e55918b

Browse files
authored
Merge pull request #40 from karaposu/dev
v2.4.0: sync parity + colorless job verbs, and dataset snapshot error reporting
2 parents 2226c20 + ac0dcb3 commit e55918b

12 files changed

Lines changed: 1146 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Bright Data Python SDK Changelog
22

3+
## Version 2.4.0 - Sync parity, colorless job verbs, dataset error reporting
4+
5+
- **Sync client parity**: `SyncBrightDataClient` now mirrors the async surface. Added `client.datasets` (fixes the `SyncBrightDataClient` `datasets` `AttributeError`), the 5 missing scrapers (`scrape.tiktok` / `youtube` / `reddit` / `perplexity` / `digikey`), the 2 missing search verticals (`search.tiktok` / `youtube`), Pinterest trigger/status/fetch, and Instagram-search `profiles` / `reels_all`.
6+
- **Service-level job verbs (colorless pattern)**: every scraper now exposes generic `status` / `wait` / `fetch` / `to_result(snapshot_id)` (on `BaseWebScraper`), and `DiscoverService` gained `status` / `wait` / `fetch` / `to_result(task_id)` — so a triggered job can be driven by its id alone, like the crawler. Purely additive; the existing `job.fetch()` etc. are unchanged.
7+
- **Discover sync manual path (new)**: `SyncBrightDataClient` adds `discover_status` / `discover_wait` / `discover_fetch` / `discover_to_result(task_id)`, and a colorless `DiscoverSnapshot` handle.
8+
- **Contract change**: sync `discover_trigger()` now returns a `DiscoverSnapshot` (a typed, drivable handle) instead of the async-only `DiscoverJob` (which could not be used from sync). Migration: poll via `client.discover_status(snap.task_id)` / `client.discover_fetch(snap.task_id)`.
9+
- **Fixed**: failed dataset snapshots now expose the API failure reason — and, when no recognized reason key is present, the raw snapshot status response as a fallback — plus the `snapshot_id`, instead of the unhelpful `DatasetError: Snapshot failed: None`. `SnapshotStatus` now retains the full API response (`.raw`) and matches more reason keys (`error` / `error_message` / `message` / `failure_reason`). The synchronous path inherits the fix.
10+
11+
---
12+
313
## Version 2.3.0 - Browser API, Scraper Studio, 175 Datasets
414

515
- **Browser API**: Connect to cloud Chrome via CDP WebSocket. SDK builds the `wss://` URL, you connect with Playwright/Puppeteer (`client.browser.get_connect_url()`)

‎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.3.2"
10+
version = "2.4.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/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from .scrapers.job import ScrapeJob
2828

2929
# Export Discover API models
30-
from .discover.models import DiscoverResult, DiscoverJob
30+
from .discover.models import DiscoverResult, DiscoverJob, DiscoverSnapshot
3131

3232
# Export payload models (dataclasses)
3333
from .payloads import (
@@ -137,6 +137,7 @@
137137
# Discover API
138138
"DiscoverResult",
139139
"DiscoverJob",
140+
"DiscoverSnapshot",
140141
# Services
141142
"WebUnlockerService",
142143
"BrowserService",

‎src/brightdata/datasets/base.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ async def __call__(
102102
data = await response.json()
103103

104104
if "snapshot_id" not in data:
105-
error_msg = data.get("error") or data.get("message") or str(data)
105+
error_msg = (
106+
data.get("error") or data.get("message") or data.get("failure_reason") or str(data)
107+
)
106108
raise DatasetError(f"Failed to create snapshot: {error_msg}")
107109

108110
return data["snapshot_id"]
@@ -180,11 +182,12 @@ async def download(
180182
if status.status == "ready":
181183
break
182184
elif status.status == "failed":
183-
raise DatasetError(f"Snapshot failed: {status.error}")
185+
reason = status.error or status.raw or "no reason returned by API"
186+
raise DatasetError(f"Snapshot {snapshot_id} failed: {reason}")
184187
elif time.time() - start_time > timeout:
185188
raise TimeoutError(
186189
f"Snapshot {snapshot_id} not ready after {timeout}s "
187-
f"(status: {status.status})"
190+
f"(status: {status.status}, last_response: {status.raw})"
188191
)
189192

190193
await asyncio.sleep(poll_interval)

‎src/brightdata/datasets/models.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class SnapshotStatus:
5858
file_size: Optional[int] = None # bytes
5959
cost: Optional[float] = None
6060
error: Optional[str] = None
61+
raw: Dict[str, Any] = field(default_factory=dict) # full API response — never lose the reason
6162

6263
@classmethod
6364
def from_dict(cls, data: Dict[str, Any]) -> "SnapshotStatus":
@@ -69,5 +70,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "SnapshotStatus":
6970
dataset_size=data.get("dataset_size"),
7071
file_size=data.get("file_size"),
7172
cost=data.get("cost"),
72-
error=data.get("error", data.get("error_message")),
73+
error=(
74+
data.get("error")
75+
or data.get("error_message")
76+
or data.get("message")
77+
or data.get("failure_reason")
78+
),
79+
raw=data,
7380
)

‎src/brightdata/discover/models.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,30 @@ def __repr__(self) -> str:
4545
return f"<DiscoverResult {base_repr} query={query_str!r}{total_str}>"
4646

4747

48+
@dataclass
49+
class DiscoverSnapshot:
50+
"""
51+
Colorless handle for a triggered Discover search — data only, no I/O methods.
52+
53+
Mirrors the crawler's CrawlJob shape: the verbs live on the service, not the
54+
handle. Returned by the sync client's ``discover_trigger()``; poll/fetch via
55+
``discover_status`` / ``discover_wait`` / ``discover_fetch`` / ``discover_to_result``
56+
(by ``task_id``), or the async ``DiscoverService`` verbs.
57+
58+
Attributes:
59+
task_id: Discover API task identifier.
60+
query: Original search query (echo).
61+
intent: Intent used for ranking (echo).
62+
"""
63+
64+
task_id: str
65+
query: str = ""
66+
intent: Optional[str] = None
67+
68+
def __repr__(self) -> str:
69+
return f"<DiscoverSnapshot task_id={self.task_id[:16]}...>"
70+
71+
4872
@dataclass
4973
class DiscoverJob:
5074
"""

‎src/brightdata/discover/service.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,62 @@ async def trigger(
160160
intent=intent,
161161
)
162162

163+
# ------------------------------------------------------------------
164+
# PUBLIC SERVICE VERBS (drive a discover task by task_id)
165+
# ------------------------------------------------------------------
166+
# Discover was the one subsystem lacking id-based status/fetch on the
167+
# service (it had only the DiscoverJob methods). These are additive and
168+
# mirror DiscoverJob.status/fetch/wait/to_result, keyed by task_id, so a
169+
# caller can poll/fetch a triggered search with only its task_id.
170+
171+
async def status(self, task_id: str) -> str:
172+
"""Check a discover task's status by task_id ('processing' or 'done')."""
173+
response_data = await self._poll_once(task_id)
174+
return response_data.get("status", "processing")
175+
176+
async def fetch(self, task_id: str) -> List[Dict[str, Any]]:
177+
"""Fetch a discover task's results by task_id. Call after status == 'done'."""
178+
response_data = await self._poll_once(task_id)
179+
return response_data.get("results", [])
180+
181+
async def wait(self, task_id: str, timeout: int = 60, poll_interval: int = 2) -> str:
182+
"""Poll a discover task until done (or fail/timeout), by task_id."""
183+
await self._poll_until_done(task_id, timeout, poll_interval)
184+
return "done"
185+
186+
async def to_result(
187+
self, task_id: str, timeout: int = 60, poll_interval: int = 2
188+
) -> DiscoverResult:
189+
"""
190+
Wait + fetch + wrap a discover task (by task_id) as DiscoverResult.
191+
192+
Note: query/intent are not recoverable from a bare task_id, so they are
193+
left empty here; use DiscoverJob.to_result() (or the service's search())
194+
when you need them populated.
195+
"""
196+
trigger_time = datetime.now(timezone.utc)
197+
try:
198+
response_data = await self._poll_until_done(task_id, timeout, poll_interval)
199+
fetch_time = datetime.now(timezone.utc)
200+
results = response_data.get("results", [])
201+
return DiscoverResult(
202+
success=True,
203+
data=results,
204+
duration_seconds=response_data.get("duration_seconds"),
205+
total_results=len(results),
206+
task_id=task_id,
207+
trigger_sent_at=trigger_time,
208+
data_fetched_at=fetch_time,
209+
)
210+
except Exception as e:
211+
return DiscoverResult(
212+
success=False,
213+
error=str(e),
214+
task_id=task_id,
215+
trigger_sent_at=trigger_time,
216+
data_fetched_at=datetime.now(timezone.utc),
217+
)
218+
163219
async def _trigger(
164220
self,
165221
query: str,

‎src/brightdata/scrapers/base.py‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
import asyncio
1313
import os
14+
import time
1415
import concurrent.futures
1516
from abc import ABC
17+
from datetime import datetime, timezone
1618
from typing import List, Dict, Any, Optional, Union
1719

1820
from ..core.engine import AsyncEngine
@@ -356,6 +358,137 @@ def _fetch_results(self, snapshot_id: str, format: str = "json") -> Any:
356358
"""Fetch scrape job results (internal sync wrapper)."""
357359
return _run_blocking(self._fetch_results_async(snapshot_id, format=format))
358360

361+
# ============================================================================
362+
# PUBLIC SERVICE VERBS (drive a triggered job by snapshot_id)
363+
# ============================================================================
364+
# These let any triggered scrape be polled/fetched from the service, given
365+
# only its snapshot_id — the "colorless job" pattern the crawler already uses
366+
# (CrawlJob is data; CrawlerService.status/download own the verbs). They are
367+
# purely additive: the same logic that ScrapeJob.status/wait/fetch/to_result
368+
# run, exposed on the service so a plain snapshot_id (or a future colorless
369+
# snapshot) is all a caller needs.
370+
371+
async def status(self, snapshot_id: str) -> str:
372+
"""
373+
Check a triggered job's status by snapshot_id.
374+
375+
Returns:
376+
Status string: "ready", "in_progress", "error", etc.
377+
378+
Example:
379+
>>> sid = (await scraper.products_trigger(url)).snapshot_id
380+
>>> s = await scraper.status(sid)
381+
"""
382+
return await self._check_status_async(snapshot_id)
383+
384+
async def wait(
385+
self,
386+
snapshot_id: str,
387+
timeout: Optional[int] = None,
388+
poll_interval: int = DEFAULT_POLL_INTERVAL,
389+
verbose: bool = False,
390+
) -> str:
391+
"""
392+
Poll a triggered job until it is ready (by snapshot_id).
393+
394+
Args:
395+
snapshot_id: Snapshot identifier from a trigger operation.
396+
timeout: Maximum seconds to wait (uses MIN_POLL_TIMEOUT if None).
397+
poll_interval: Seconds between status checks.
398+
verbose: Print status updates.
399+
400+
Returns:
401+
Final status ("ready").
402+
403+
Raises:
404+
TimeoutError: If timeout is reached.
405+
APIError: If the job fails.
406+
"""
407+
wait_timeout = timeout or self.MIN_POLL_TIMEOUT
408+
start_time = time.time()
409+
410+
while True:
411+
elapsed = time.time() - start_time
412+
if elapsed > wait_timeout:
413+
raise TimeoutError(f"Job {snapshot_id} timed out after {wait_timeout}s")
414+
415+
current = await self.status(snapshot_id)
416+
417+
if verbose:
418+
print(f" [{elapsed:.1f}s] Job status: {current}")
419+
420+
if current == "ready":
421+
return current
422+
elif current == "error" or current == "failed":
423+
raise APIError(f"Job {snapshot_id} failed with status: {current}")
424+
425+
await asyncio.sleep(poll_interval)
426+
427+
async def fetch(self, snapshot_id: str, format: str = "json") -> Any:
428+
"""
429+
Fetch a triggered job's results by snapshot_id.
430+
431+
Note: Does not check readiness. Use wait() first or check status().
432+
433+
Args:
434+
snapshot_id: Snapshot identifier from a trigger operation.
435+
format: Result format ("json" or "raw").
436+
437+
Returns:
438+
The job results.
439+
"""
440+
return await self._fetch_results_async(snapshot_id, format=format)
441+
442+
async def to_result(
443+
self,
444+
snapshot_id: str,
445+
timeout: Optional[int] = None,
446+
poll_interval: int = DEFAULT_POLL_INTERVAL,
447+
) -> ScrapeResult:
448+
"""
449+
Wait for a triggered job to complete, fetch it, and wrap in a ScrapeResult.
450+
451+
Convenience that combines wait() + fetch() + result construction, by
452+
snapshot_id. Cost/timing are derived from the service's own
453+
PLATFORM_NAME / COST_PER_RECORD, so nothing is needed beyond the id.
454+
455+
Args:
456+
snapshot_id: Snapshot identifier from a trigger operation.
457+
timeout: Maximum seconds to wait (uses MIN_POLL_TIMEOUT if None).
458+
poll_interval: Seconds between status checks.
459+
460+
Returns:
461+
ScrapeResult (success or, on failure, success=False with error set).
462+
"""
463+
start_time = datetime.now(timezone.utc)
464+
465+
try:
466+
await self.wait(snapshot_id, timeout=timeout, poll_interval=poll_interval)
467+
data = await self.fetch(snapshot_id)
468+
end_time = datetime.now(timezone.utc)
469+
470+
record_count = len(data) if isinstance(data, list) else 1
471+
estimated_cost = record_count * self.COST_PER_RECORD
472+
473+
return ScrapeResult(
474+
success=True,
475+
data=data,
476+
platform=self.PLATFORM_NAME or None,
477+
cost=estimated_cost,
478+
snapshot_id=snapshot_id,
479+
trigger_sent_at=start_time,
480+
data_fetched_at=end_time,
481+
)
482+
except Exception as e:
483+
return ScrapeResult(
484+
success=False,
485+
error=str(e),
486+
platform=self.PLATFORM_NAME or None,
487+
snapshot_id=snapshot_id,
488+
trigger_sent_at=start_time,
489+
data_fetched_at=datetime.now(timezone.utc),
490+
)
491+
359492
# ============================================================================
360493
# CONTEXT MANAGER SUPPORT (for standalone usage)
361494
# ============================================================================

0 commit comments

Comments
 (0)