Skip to content

Commit 237ec78

Browse files
authored
feat: BasicCrawler.export_data helper (#222)
- closes #211
1 parent 337286e commit 237ec78

4 files changed

Lines changed: 59 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Adds
66

7+
- BREAKING: `BasicCrawler.export_data` helper method which replaces `BasicCrawler.export_to`
78
- `Configuration.get_global_configuration` method
89

910
### Fixes

src/crawlee/basic_crawler/basic_crawler.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from datetime import timedelta
1212
from functools import partial
1313
from logging import getLogger
14-
from typing import TYPE_CHECKING, Any, AsyncContextManager, Callable, Generic, Union, cast
14+
from pathlib import Path
15+
from typing import TYPE_CHECKING, Any, AsyncContextManager, Callable, Generic, Literal, Union, cast
1516

1617
import httpx
1718
from tldextract import TLDExtract
@@ -48,7 +49,7 @@
4849
from crawlee.proxy_configuration import ProxyConfiguration, ProxyInfo
4950
from crawlee.sessions import Session
5051
from crawlee.statistics import FinalStatistics, StatisticsState
51-
from crawlee.storages.dataset import ExportToKwargs, GetDataKwargs, PushDataKwargs
52+
from crawlee.storages.dataset import GetDataKwargs, PushDataKwargs
5253
from crawlee.storages.request_provider import RequestProvider
5354
from crawlee.types import JSONSerializable
5455

@@ -411,24 +412,31 @@ async def get_data(
411412
dataset = await Dataset.open(id=dataset_id, name=dataset_name)
412413
return await dataset.get_data(**kwargs)
413414

414-
async def export_to(
415+
async def export_data(
415416
self,
417+
path: str | Path,
418+
content_type: Literal['json', 'csv'] | None = None,
416419
dataset_id: str | None = None,
417420
dataset_name: str | None = None,
418-
**kwargs: Unpack[ExportToKwargs],
419421
) -> None:
420422
"""Export data from a dataset.
421423
422424
This helper method simplifies the process of exporting data from a dataset. It opens the specified
423425
dataset and then exports the data based on the provided parameters.
424426
425427
Args:
428+
path: The destination path
429+
content_type: The output format
426430
dataset_id: The ID of the dataset.
427431
dataset_name: The name of the dataset.
428-
kwargs: Keyword arguments to be passed to the dataset's `export_to` method.
429432
"""
430433
dataset = await Dataset.open(id=dataset_id, name=dataset_name)
431-
return await dataset.export_to(**kwargs)
434+
path = path if isinstance(path, Path) else Path(path)
435+
436+
if content_type is None:
437+
content_type = 'csv' if path.suffix == '.csv' else 'json'
438+
439+
return await dataset.write_to(content_type, path.open('w', newline=''))
432440

433441
async def _push_data(
434442
self,

src/crawlee/storages/dataset.py

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import csv
44
import io
5-
from typing import TYPE_CHECKING, AsyncIterator, Literal, TypedDict, cast
5+
import json
6+
from typing import TYPE_CHECKING, AsyncIterator, Literal, TextIO, TypedDict, cast
67

78
from typing_extensions import NotRequired, Required, Unpack, override
89

@@ -189,6 +190,32 @@ async def get_data(self, **kwargs: Unpack[GetDataKwargs]) -> DatasetItemsListPag
189190
# https://github.com/apify/apify-sdk-python/issues/140
190191
return await self._resource_client.list_items(**kwargs)
191192

193+
async def write_to(self, content_type: Literal['json', 'csv'], destination: TextIO) -> None:
194+
"""Exports the entire dataset into an arbitrary stream.
195+
196+
Args:
197+
content_type: Specifies the output format
198+
destination: The stream into which the dataset contents should be written
199+
"""
200+
items: list[dict] = []
201+
limit = 1000
202+
offset = 0
203+
204+
while True:
205+
list_items = await self._resource_client.list_items(limit=limit, offset=offset)
206+
items.extend(list_items.items)
207+
if list_items.total <= offset + list_items.count:
208+
break
209+
offset += list_items.count
210+
211+
if content_type == 'csv':
212+
writer = csv.writer(destination, quoting=csv.QUOTE_MINIMAL)
213+
writer.writerows([items[0].keys(), *[item.values() for item in items]])
214+
elif content_type == 'json':
215+
json.dump(items, destination)
216+
else:
217+
raise ValueError(f'Unsupported content type: {content_type}')
218+
192219
async def export_to(self, **kwargs: Unpack[ExportToKwargs]) -> None:
193220
"""Exports the entire dataset into a specified file stored under a key in a key-value store.
194221
@@ -206,30 +233,15 @@ async def export_to(self, **kwargs: Unpack[ExportToKwargs]) -> None:
206233
to_key_value_store_name = kwargs.get('to_key_value_store_name', None)
207234

208235
key_value_store = await KeyValueStore.open(id=to_key_value_store_id, name=to_key_value_store_name)
209-
items: list[dict] = []
210-
limit = 1000
211-
offset = 0
212236

213-
while True:
214-
list_items = await self._resource_client.list_items(limit=limit, offset=offset)
215-
items.extend(list_items.items)
216-
if list_items.total <= offset + list_items.count:
217-
break
218-
offset += list_items.count
237+
output = io.StringIO()
238+
await self.write_to(content_type, output)
219239

220240
if content_type == 'csv':
221-
content_type_full = 'text/csv'
222-
output = io.StringIO()
223-
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
224-
writer.writerows([items[0].keys(), *[item.values() for item in items]])
225-
value = output.getvalue()
226-
return await key_value_store.set_value(key, value, content_type_full)
241+
await key_value_store.set_value(key, output.getvalue(), 'text/csv')
227242

228243
if content_type == 'json':
229-
content_type_full = 'application/json'
230-
return await key_value_store.set_value(key, items, content_type_full)
231-
232-
raise ValueError(f'Unsupported content type: {content_type}')
244+
await key_value_store.set_value(key, output.getvalue(), 'application/json')
233245

234246
async def get_info(self) -> DatasetMetadata | None:
235247
"""Get an object containing general information about the dataset."""

tests/unit/basic_crawler/test_basic_crawler.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
if TYPE_CHECKING:
2525
from collections.abc import Sequence
26+
from pathlib import Path
2627

2728
import respx
2829

@@ -516,26 +517,25 @@ async def handler(context: BasicCrawlingContext) -> None:
516517
assert stats.requests_finished == 1
517518

518519

519-
async def test_crawler_push_and_export_data() -> None:
520+
async def test_crawler_push_and_export_data(tmp_path: Path) -> None:
520521
crawler = BasicCrawler()
521522
dataset = await Dataset.open()
522523

523524
await dataset.push_data([{'id': 0, 'test': 'test'}, {'id': 1, 'test': 'test'}])
524525
await dataset.push_data({'id': 2, 'test': 'test'})
525526

526-
await crawler.export_to(key='dataset-json', content_type='json')
527-
await crawler.export_to(key='dataset-csv', content_type='csv')
527+
await crawler.export_data(tmp_path / 'dataset.json')
528+
await crawler.export_data(tmp_path / 'dataset.csv')
528529

529-
kvs = await KeyValueStore.open()
530-
assert await kvs.get_value('dataset-json') == [
530+
assert json.load((tmp_path / 'dataset.json').open()) == [
531531
{'id': 0, 'test': 'test'},
532532
{'id': 1, 'test': 'test'},
533533
{'id': 2, 'test': 'test'},
534534
]
535-
assert await kvs.get_value('dataset-csv') == 'id,test\r\n0,test\r\n1,test\r\n2,test\r\n'
535+
assert (tmp_path / 'dataset.csv').read_bytes() == b'id,test\r\n0,test\r\n1,test\r\n2,test\r\n'
536536

537537

538-
async def test_context_push_and_export_data(httpbin: str) -> None:
538+
async def test_context_push_and_export_data(httpbin: str, tmp_path: Path) -> None:
539539
crawler = BasicCrawler()
540540

541541
@crawler.router.default_handler
@@ -545,16 +545,16 @@ async def handler(context: BasicCrawlingContext) -> None:
545545

546546
await crawler.run([f'{httpbin}/1'])
547547

548-
await crawler.export_to(key='dataset-json', content_type='json')
549-
await crawler.export_to(key='dataset-csv', content_type='csv')
548+
await crawler.export_data(tmp_path / 'dataset.json')
549+
await crawler.export_data(tmp_path / 'dataset.csv')
550550

551-
kvs = await KeyValueStore.open()
552-
assert await kvs.get_value('dataset-json') == [
551+
assert json.load((tmp_path / 'dataset.json').open()) == [
553552
{'id': 0, 'test': 'test'},
554553
{'id': 1, 'test': 'test'},
555554
{'id': 2, 'test': 'test'},
556555
]
557-
assert await kvs.get_value('dataset-csv') == 'id,test\r\n0,test\r\n1,test\r\n2,test\r\n'
556+
557+
assert (tmp_path / 'dataset.csv').read_bytes() == b'id,test\r\n0,test\r\n1,test\r\n2,test\r\n'
558558

559559

560560
async def test_max_requests_per_crawl(httpbin: str) -> None:

0 commit comments

Comments
 (0)