scrapely-python 0.1.0__py3-none-any.whl
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.
- scrapely/__init__.py +246 -0
- scrapely/_browser_locations.py +5470 -0
- scrapely/actor.py +1836 -0
- scrapely/browser_configuration.py +146 -0
- scrapely/charging.py +1143 -0
- scrapely/charging_client.py +107 -0
- scrapely/configuration.py +375 -0
- scrapely/crypto/__init__.py +21 -0
- scrapely/crypto/_secrets.py +210 -0
- scrapely/errors.py +153 -0
- scrapely/events/__init__.py +57 -0
- scrapely/events/_types.py +194 -0
- scrapely/events/event_manager.py +292 -0
- scrapely/log.py +39 -0
- scrapely/patch_browser_pool.py +861 -0
- scrapely/pricing_types.py +172 -0
- scrapely/proxy_configuration.py +741 -0
- scrapely/py.typed +2 -0
- scrapely/request_loaders/__init__.py +5 -0
- scrapely/request_loaders/_scrapely_request_list.py +169 -0
- scrapely/storage/__init__.py +48 -0
- scrapely/storage/_file_system/__init__.py +9 -0
- scrapely/storage/_file_system/_key_value_store_client.py +155 -0
- scrapely/storage/_file_system/_storage_client.py +50 -0
- scrapely/storage/_metadata.py +138 -0
- scrapely/storage/api_client.py +350 -0
- scrapely/storage/client.py +618 -0
- scrapely/storage/patch_storage.py +257 -0
- scrapely/storage/scrapely_dataset_client.py +305 -0
- scrapely/storage/scrapely_kvs_client.py +220 -0
- scrapely/storage/scrapely_rq_client.py +496 -0
- scrapely/storage/scrapely_storage_client.py +192 -0
- scrapely/storage/smart_scrapely_storage_client.py +109 -0
- scrapely/storage/types.py +601 -0
- scrapely/storage/utils.py +38 -0
- scrapely/storage_clients/__init__.py +25 -0
- scrapely/storages/__init__.py +5 -0
- scrapely/types.py +269 -0
- scrapely/webhook.py +82 -0
- scrapely_python-0.1.0.dist-info/METADATA +83 -0
- scrapely_python-0.1.0.dist-info/RECORD +43 -0
- scrapely_python-0.1.0.dist-info/WHEEL +5 -0
- scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Register Scrapely storage with Crawlee's service locator (TS setApiStorage equivalent)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, MutableMapping, Optional, TypedDict, cast
|
|
7
|
+
|
|
8
|
+
from crawlee import service_locator
|
|
9
|
+
from crawlee.storage_clients._base import StorageClient
|
|
10
|
+
|
|
11
|
+
from ._file_system import ScrapelyFileSystemStorageClient
|
|
12
|
+
from .scrapely_storage_client import ScrapelyStorageClient
|
|
13
|
+
from .smart_scrapely_storage_client import SmartScrapelyStorageClient
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_api_factory: Optional[ScrapelyStorageClient] = None
|
|
18
|
+
_smart_client: Optional[SmartScrapelyStorageClient] = None
|
|
19
|
+
|
|
20
|
+
# Cache for auto-saved values: store_id -> key -> value
|
|
21
|
+
# This allows get_auto_saved_value to return the same object reference,
|
|
22
|
+
# and persist_state listeners can iterate and save all cached values.
|
|
23
|
+
_auto_save_cache: Dict[str, Dict[str, Any]] = {}
|
|
24
|
+
|
|
25
|
+
# Track which stores have registered persistState listeners
|
|
26
|
+
_persist_state_registered: set = set()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiStorageConfig(TypedDict, total=False):
|
|
30
|
+
api_url: str
|
|
31
|
+
token: str
|
|
32
|
+
run_id: str
|
|
33
|
+
transport: Any
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_api_storage(
|
|
37
|
+
api_config: Optional[ApiStorageConfig] = None,
|
|
38
|
+
*,
|
|
39
|
+
cloud_storage_client: Optional[ScrapelyStorageClient] = None,
|
|
40
|
+
local_storage_client: Optional[StorageClient] = None,
|
|
41
|
+
) -> SmartScrapelyStorageClient:
|
|
42
|
+
"""Configure Crawlee to use Scrapely storage (Smart wrapper + ``/v2`` cloud).
|
|
43
|
+
|
|
44
|
+
Equivalent to TypeScript ``setApiStorage()`` plus Apify's ``SmartApifyStorageClient``
|
|
45
|
+
registration on the service locator.
|
|
46
|
+
"""
|
|
47
|
+
global _api_factory, _smart_client
|
|
48
|
+
|
|
49
|
+
if cloud_storage_client is not None:
|
|
50
|
+
cloud = cloud_storage_client
|
|
51
|
+
elif api_config is not None:
|
|
52
|
+
cloud = ScrapelyStorageClient(
|
|
53
|
+
api_url=api_config.get('api_url'),
|
|
54
|
+
token=api_config.get('token'),
|
|
55
|
+
run_id=api_config.get('run_id'),
|
|
56
|
+
transport=api_config.get('transport'),
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
cloud = ScrapelyStorageClient()
|
|
60
|
+
|
|
61
|
+
_api_factory = cloud
|
|
62
|
+
local = local_storage_client or ScrapelyFileSystemStorageClient()
|
|
63
|
+
smart = SmartScrapelyStorageClient(
|
|
64
|
+
cloud_storage_client=cloud,
|
|
65
|
+
local_storage_client=local,
|
|
66
|
+
)
|
|
67
|
+
_smart_client = smart
|
|
68
|
+
service_locator.set_storage_client(smart)
|
|
69
|
+
return smart
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_storage_client(client: StorageClient) -> SmartScrapelyStorageClient:
|
|
73
|
+
"""Register a storage client; wrap non-Smart clients like Apify Actor expects."""
|
|
74
|
+
global _api_factory, _smart_client
|
|
75
|
+
|
|
76
|
+
if isinstance(client, SmartScrapelyStorageClient):
|
|
77
|
+
smart = client
|
|
78
|
+
elif isinstance(client, ScrapelyStorageClient):
|
|
79
|
+
_api_factory = client
|
|
80
|
+
smart = SmartScrapelyStorageClient(
|
|
81
|
+
cloud_storage_client=client,
|
|
82
|
+
local_storage_client=ScrapelyFileSystemStorageClient(),
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
smart = SmartScrapelyStorageClient(
|
|
86
|
+
cloud_storage_client=_api_factory or ScrapelyStorageClient(),
|
|
87
|
+
local_storage_client=client,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_smart_client = smart
|
|
91
|
+
service_locator.set_storage_client(smart)
|
|
92
|
+
return smart
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_api_storage_factory() -> Optional[ScrapelyStorageClient]:
|
|
96
|
+
return _api_factory
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_smart_storage_client() -> Optional[SmartScrapelyStorageClient]:
|
|
100
|
+
return _smart_client
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_api_storage_configured() -> bool:
|
|
104
|
+
return _api_factory is not None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def disconnect_storage() -> None:
|
|
108
|
+
global _api_factory, _smart_client, _auto_save_cache, _persist_state_registered
|
|
109
|
+
_api_factory = None
|
|
110
|
+
_smart_client = None
|
|
111
|
+
_auto_save_cache.clear()
|
|
112
|
+
_persist_state_registered.clear()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ensure_persist_state_listener(store_id: str, config: Any) -> None:
|
|
116
|
+
"""Register a persistState listener for a store if not already registered.
|
|
117
|
+
|
|
118
|
+
This listener saves all cached values for that store when persistState is emitted.
|
|
119
|
+
"""
|
|
120
|
+
if store_id in _persist_state_registered:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
event_manager = config.get_event_manager() if hasattr(config, 'get_event_manager') else None
|
|
124
|
+
if event_manager is None:
|
|
125
|
+
logger.warning('No event manager available for persistState listener')
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
async def on_persist_state(_: Any) -> None:
|
|
129
|
+
store_cache = _auto_save_cache.get(store_id)
|
|
130
|
+
if not store_cache:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
factory = _api_factory
|
|
134
|
+
if not factory:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
api = factory._ensure_api()
|
|
139
|
+
for key, value in store_cache.items():
|
|
140
|
+
try:
|
|
141
|
+
await api.kvs_set_record(store_id, key, value)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.warning('Failed to persist state value to %s: %s', key, e)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.warning('Failed to persist auto-saved values: %s', e)
|
|
146
|
+
|
|
147
|
+
event_manager.on('persistState', on_persist_state)
|
|
148
|
+
_persist_state_registered.add(store_id)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def get_auto_saved_value(
|
|
152
|
+
key: str,
|
|
153
|
+
default_value: MutableMapping[str, Any],
|
|
154
|
+
*,
|
|
155
|
+
store_id: Optional[str] = None,
|
|
156
|
+
) -> MutableMapping[str, Any]:
|
|
157
|
+
"""Get a value from KVS that will be automatically saved on changes.
|
|
158
|
+
|
|
159
|
+
This implementation matches Crawlee/Apify's approach:
|
|
160
|
+
1. Check cache first (return same object reference for subsequent calls)
|
|
161
|
+
2. Fetch value from storage if not cached
|
|
162
|
+
3. Cache the value (so mutations are tracked)
|
|
163
|
+
4. Register persistState listener to save cached values
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
key: The key of the record to get
|
|
167
|
+
default_value: The default value to return and store if the key doesn't exist
|
|
168
|
+
store_id: Optional store ID (uses default KVS if not provided)
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The stored value or the default_value
|
|
172
|
+
"""
|
|
173
|
+
from scrapely.configuration import Configuration as ScrapelyConfiguration
|
|
174
|
+
|
|
175
|
+
config = ScrapelyConfiguration.get_global_config()
|
|
176
|
+
|
|
177
|
+
# Resolve store ID from environment if not provided
|
|
178
|
+
if store_id is None:
|
|
179
|
+
store_id = (
|
|
180
|
+
config.get('default_key_value_store_id')
|
|
181
|
+
or config.actor_run_id
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if store_id is None:
|
|
185
|
+
raise ValueError('No key-value store ID available for get_auto_saved_value')
|
|
186
|
+
|
|
187
|
+
# Check if we have a cached value for this store/key
|
|
188
|
+
store_cache = _auto_save_cache.get(store_id)
|
|
189
|
+
if store_cache is not None and key in store_cache:
|
|
190
|
+
return cast(MutableMapping[str, Any], store_cache[key])
|
|
191
|
+
|
|
192
|
+
# Fetch value from storage
|
|
193
|
+
factory = _api_factory
|
|
194
|
+
if factory is None:
|
|
195
|
+
raise ValueError('API storage client not initialized. Call set_api_storage() first.')
|
|
196
|
+
|
|
197
|
+
api = factory._ensure_api()
|
|
198
|
+
|
|
199
|
+
# Try to get existing value
|
|
200
|
+
value, content_type = await api.kvs_get_record(store_id, key)
|
|
201
|
+
|
|
202
|
+
if value is None:
|
|
203
|
+
# Store the default value and return it
|
|
204
|
+
await api.kvs_set_record(store_id, key, default_value)
|
|
205
|
+
value = default_value
|
|
206
|
+
elif not isinstance(value, MutableMapping):
|
|
207
|
+
# If the stored value is not a mutable mapping, we can't track mutations
|
|
208
|
+
# Return it as-is but don't cache for auto-save
|
|
209
|
+
logger.warning(
|
|
210
|
+
'get_auto_saved_value: stored value for key "%s" is not a mutable mapping, '
|
|
211
|
+
'auto-save will not work',
|
|
212
|
+
key,
|
|
213
|
+
)
|
|
214
|
+
return cast(MutableMapping[str, Any], value)
|
|
215
|
+
|
|
216
|
+
# Initialize store cache if needed
|
|
217
|
+
if store_cache is None:
|
|
218
|
+
store_cache = {}
|
|
219
|
+
_auto_save_cache[store_id] = store_cache
|
|
220
|
+
|
|
221
|
+
# Check cache again (race condition: another call might have cached it)
|
|
222
|
+
if key in store_cache:
|
|
223
|
+
return cast(MutableMapping[str, Any], store_cache[key])
|
|
224
|
+
|
|
225
|
+
# Cache the value - mutations to this object will be persisted
|
|
226
|
+
store_cache[key] = value
|
|
227
|
+
|
|
228
|
+
# Register persistState listener for this store
|
|
229
|
+
_ensure_persist_state_listener(store_id, config)
|
|
230
|
+
|
|
231
|
+
return cast(MutableMapping[str, Any], value)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def persist_autosaved_values(*, store_id: Optional[str] = None) -> None:
|
|
235
|
+
"""Force autosaved values to be saved without waiting for an event.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
store_id: Optional store ID to persist. If None, persists all stores.
|
|
239
|
+
"""
|
|
240
|
+
factory = _api_factory
|
|
241
|
+
if factory is None:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
api = factory._ensure_api()
|
|
245
|
+
|
|
246
|
+
stores_to_persist = [store_id] if store_id else list(_auto_save_cache.keys())
|
|
247
|
+
|
|
248
|
+
for sid in stores_to_persist:
|
|
249
|
+
store_cache = _auto_save_cache.get(sid)
|
|
250
|
+
if not store_cache:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
for key, value in store_cache.items():
|
|
254
|
+
try:
|
|
255
|
+
await api.kvs_set_record(sid, key, value)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.warning('Failed to persist state value to %s: %s', key, e)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Scrapely cloud DatasetClient for Crawlee."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, List, Mapping, Optional, Sequence, Union
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Self, override
|
|
8
|
+
|
|
9
|
+
from crawlee.storage_clients._base import DatasetClient
|
|
10
|
+
from crawlee.storage_clients.models import DatasetItemsListPage, DatasetMetadata
|
|
11
|
+
|
|
12
|
+
from .client import ApiStorageClient
|
|
13
|
+
from ._metadata import resolve_storage_id, to_dataset_metadata
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import AsyncIterator
|
|
17
|
+
|
|
18
|
+
from crawlee._types import JsonSerializable
|
|
19
|
+
from crawlee.configuration import Configuration
|
|
20
|
+
from crawlee.storage_clients import StorageClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _old_dataset_to_crawlee(meta: Any) -> DatasetMetadata:
|
|
24
|
+
return to_dataset_metadata(
|
|
25
|
+
{
|
|
26
|
+
'id': meta.id,
|
|
27
|
+
'name': meta.name,
|
|
28
|
+
'createdAt': meta.created_at.isoformat() if getattr(meta, 'created_at', None) else None,
|
|
29
|
+
'accessedAt': meta.accessed_at.isoformat() if getattr(meta, 'accessed_at', None) else None,
|
|
30
|
+
'modifiedAt': meta.modified_at.isoformat() if getattr(meta, 'modified_at', None) else None,
|
|
31
|
+
'itemCount': getattr(meta, 'item_count', 0),
|
|
32
|
+
},
|
|
33
|
+
meta.id,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScrapelyDatasetClient(DatasetClient):
|
|
38
|
+
"""Dataset client backed by Scrapely `/v2/datasets`."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, api: ApiStorageClient, metadata: DatasetMetadata) -> None:
|
|
41
|
+
self._api = api
|
|
42
|
+
self._metadata = metadata
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def id(self) -> str:
|
|
46
|
+
"""Get the storage ID."""
|
|
47
|
+
return self._metadata.id
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> Optional[str]:
|
|
51
|
+
"""Get the storage name."""
|
|
52
|
+
return self._metadata.name
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
async def open(
|
|
56
|
+
cls,
|
|
57
|
+
*,
|
|
58
|
+
api: ApiStorageClient,
|
|
59
|
+
id: Optional[str] = None,
|
|
60
|
+
name: Optional[str] = None,
|
|
61
|
+
alias: Optional[str] = None,
|
|
62
|
+
default_id: Optional[str] = None,
|
|
63
|
+
) -> Self:
|
|
64
|
+
open_id, open_name = resolve_storage_id(
|
|
65
|
+
id=id, name=name, alias=alias, default_id=default_id,
|
|
66
|
+
)
|
|
67
|
+
meta = await api.get_or_create_dataset(id=open_id, name=open_name)
|
|
68
|
+
return cls(api=api, metadata=_old_dataset_to_crawlee(meta))
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
async def get_metadata(self) -> DatasetMetadata:
|
|
72
|
+
meta = await self._api.dataset_get(self._metadata.id)
|
|
73
|
+
self._metadata = _old_dataset_to_crawlee(meta)
|
|
74
|
+
return self._metadata
|
|
75
|
+
|
|
76
|
+
@override
|
|
77
|
+
async def drop(self) -> None:
|
|
78
|
+
await self._api.dataset_delete(self._metadata.id)
|
|
79
|
+
|
|
80
|
+
@override
|
|
81
|
+
async def purge(self) -> None:
|
|
82
|
+
if not self._metadata.name:
|
|
83
|
+
raise NotImplementedError(
|
|
84
|
+
'Dataset.purge() is not supported for unnamed/default datasets on Scrapely '
|
|
85
|
+
'(no clear endpoint). Use a named dataset or drop() and reopen.'
|
|
86
|
+
)
|
|
87
|
+
name = self._metadata.name
|
|
88
|
+
await self.drop()
|
|
89
|
+
meta = await self._api.get_or_create_dataset(name=name)
|
|
90
|
+
self._metadata = _old_dataset_to_crawlee(meta)
|
|
91
|
+
|
|
92
|
+
@override
|
|
93
|
+
async def push_data(
|
|
94
|
+
self,
|
|
95
|
+
data: Union[Sequence[Mapping[str, 'JsonSerializable']], Mapping[str, 'JsonSerializable']],
|
|
96
|
+
) -> None:
|
|
97
|
+
items: List[Mapping[str, Any]]
|
|
98
|
+
if isinstance(data, Mapping):
|
|
99
|
+
items = [data]
|
|
100
|
+
else:
|
|
101
|
+
items = list(data)
|
|
102
|
+
await self._api.dataset_push_items(self._metadata.id, items)
|
|
103
|
+
|
|
104
|
+
@override
|
|
105
|
+
async def get_data(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
offset: int = 0,
|
|
109
|
+
limit: Optional[int] = 999_999_999_999,
|
|
110
|
+
clean: bool = False,
|
|
111
|
+
desc: bool = False,
|
|
112
|
+
fields: Optional[List[str]] = None,
|
|
113
|
+
omit: Optional[List[str]] = None,
|
|
114
|
+
unwind: Optional[List[str]] = None,
|
|
115
|
+
skip_empty: bool = False,
|
|
116
|
+
skip_hidden: bool = False,
|
|
117
|
+
flatten: Optional[List[str]] = None,
|
|
118
|
+
view: Optional[str] = None,
|
|
119
|
+
) -> DatasetItemsListPage:
|
|
120
|
+
_, page = await self._api.dataset_list_items(
|
|
121
|
+
self._metadata.id,
|
|
122
|
+
offset=offset,
|
|
123
|
+
limit=None if limit == 999_999_999_999 else limit,
|
|
124
|
+
clean=clean,
|
|
125
|
+
desc=desc,
|
|
126
|
+
fields=fields,
|
|
127
|
+
omit=omit,
|
|
128
|
+
unwind=unwind,
|
|
129
|
+
skip_empty=skip_empty,
|
|
130
|
+
skip_hidden=skip_hidden,
|
|
131
|
+
flatten=flatten,
|
|
132
|
+
view=view,
|
|
133
|
+
)
|
|
134
|
+
return DatasetItemsListPage(
|
|
135
|
+
count=page.count,
|
|
136
|
+
offset=page.offset,
|
|
137
|
+
limit=page.limit,
|
|
138
|
+
total=page.total,
|
|
139
|
+
desc=page.desc,
|
|
140
|
+
items=list(page.items),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
async def iterate_items(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
offset: int = 0,
|
|
148
|
+
limit: Optional[int] = None,
|
|
149
|
+
clean: bool = False,
|
|
150
|
+
desc: bool = False,
|
|
151
|
+
fields: Optional[List[str]] = None,
|
|
152
|
+
omit: Optional[List[str]] = None,
|
|
153
|
+
unwind: Optional[List[str]] = None,
|
|
154
|
+
skip_empty: bool = False,
|
|
155
|
+
skip_hidden: bool = False,
|
|
156
|
+
) -> AsyncIterator[Mapping[str, 'JsonSerializable']]:
|
|
157
|
+
current = offset
|
|
158
|
+
remaining = limit
|
|
159
|
+
chunk = 1000
|
|
160
|
+
while True:
|
|
161
|
+
fetch = chunk if remaining is None else min(chunk, remaining)
|
|
162
|
+
if fetch <= 0:
|
|
163
|
+
break
|
|
164
|
+
page = await self.get_data(
|
|
165
|
+
offset=current,
|
|
166
|
+
limit=fetch,
|
|
167
|
+
clean=clean,
|
|
168
|
+
desc=desc,
|
|
169
|
+
fields=fields,
|
|
170
|
+
omit=omit,
|
|
171
|
+
unwind=unwind,
|
|
172
|
+
skip_empty=skip_empty,
|
|
173
|
+
skip_hidden=skip_hidden,
|
|
174
|
+
)
|
|
175
|
+
if not page.items:
|
|
176
|
+
break
|
|
177
|
+
for item in page.items:
|
|
178
|
+
yield item
|
|
179
|
+
if remaining is not None:
|
|
180
|
+
remaining -= 1
|
|
181
|
+
if remaining <= 0:
|
|
182
|
+
return
|
|
183
|
+
current += len(page.items)
|
|
184
|
+
if len(page.items) < fetch:
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
async def list_items(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
offset: int = 0,
|
|
191
|
+
limit: Optional[int] = None,
|
|
192
|
+
clean: bool = False,
|
|
193
|
+
desc: bool = False,
|
|
194
|
+
fields: Optional[List[str]] = None,
|
|
195
|
+
omit: Optional[List[str]] = None,
|
|
196
|
+
unwind: Optional[List[str]] = None,
|
|
197
|
+
skip_empty: bool = False,
|
|
198
|
+
skip_hidden: bool = False,
|
|
199
|
+
) -> list[Mapping[str, 'JsonSerializable']]:
|
|
200
|
+
"""List all items in the dataset.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
offset: Number of items to skip at the start.
|
|
204
|
+
limit: Maximum number of items to return.
|
|
205
|
+
clean: Return only non-empty items, exclude hidden fields.
|
|
206
|
+
desc: Sort in descending order.
|
|
207
|
+
fields: Fields to include in each item.
|
|
208
|
+
omit: Fields to exclude from each item.
|
|
209
|
+
unwind: Unwind items by a specified array field.
|
|
210
|
+
skip_empty: Exclude empty items.
|
|
211
|
+
skip_hidden: Exclude fields starting with '#'.
|
|
212
|
+
flatten: Fields to flatten in returned items.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of items as dictionaries.
|
|
216
|
+
"""
|
|
217
|
+
result: list[Mapping[str, 'JsonSerializable']] = []
|
|
218
|
+
async for item in self.iterate_items(
|
|
219
|
+
offset=offset,
|
|
220
|
+
limit=limit,
|
|
221
|
+
clean=clean,
|
|
222
|
+
desc=desc,
|
|
223
|
+
fields=fields,
|
|
224
|
+
omit=omit,
|
|
225
|
+
unwind=unwind,
|
|
226
|
+
skip_empty=skip_empty,
|
|
227
|
+
skip_hidden=skip_hidden,
|
|
228
|
+
):
|
|
229
|
+
result.append(item)
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
async def export_to(
|
|
233
|
+
self,
|
|
234
|
+
key: str,
|
|
235
|
+
*,
|
|
236
|
+
content_type: str = 'json',
|
|
237
|
+
to_kvs_id: Optional[str] = None,
|
|
238
|
+
to_kvs_name: Optional[str] = None,
|
|
239
|
+
to_kvs_storage_client: Optional['StorageClient'] = None,
|
|
240
|
+
to_kvs_configuration: Optional['Configuration'] = None,
|
|
241
|
+
**kwargs: Any,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Export the entire dataset to a key-value store.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
key: Key under which to save the data in the KVS.
|
|
247
|
+
content_type: Format for export ('json' or 'csv').
|
|
248
|
+
to_kvs_id: ID of the target key-value store.
|
|
249
|
+
to_kvs_name: Name of the target key-value store.
|
|
250
|
+
to_kvs_storage_client: Storage client for the target KVS.
|
|
251
|
+
to_kvs_configuration: Configuration for the target KVS.
|
|
252
|
+
**kwargs: Additional parameters for the export (e.g., JSON/CSV options).
|
|
253
|
+
"""
|
|
254
|
+
import json
|
|
255
|
+
import csv
|
|
256
|
+
import io
|
|
257
|
+
|
|
258
|
+
# Get all items from the dataset
|
|
259
|
+
items = await self.list_items(
|
|
260
|
+
clean=kwargs.get('clean', False),
|
|
261
|
+
skip_empty=kwargs.get('skip_empty', False),
|
|
262
|
+
skip_hidden=kwargs.get('skip_hidden', False),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Determine target KVS - use this API client's KVS by default
|
|
266
|
+
if to_kvs_id or to_kvs_name:
|
|
267
|
+
target_kvs_meta = await self._api.get_or_create_key_value_store(
|
|
268
|
+
id=to_kvs_id,
|
|
269
|
+
name=to_kvs_name,
|
|
270
|
+
)
|
|
271
|
+
target_kvs_id = target_kvs_meta.id
|
|
272
|
+
else:
|
|
273
|
+
# Use default KVS
|
|
274
|
+
target_kvs_meta = await self._api.get_or_create_key_value_store()
|
|
275
|
+
target_kvs_id = target_kvs_meta.id
|
|
276
|
+
|
|
277
|
+
# Format the data
|
|
278
|
+
if content_type == 'csv':
|
|
279
|
+
output = io.StringIO()
|
|
280
|
+
if items:
|
|
281
|
+
writer = csv.DictWriter(
|
|
282
|
+
output,
|
|
283
|
+
fieldnames=items[0].keys() if items else [],
|
|
284
|
+
**{k: v for k, v in kwargs.items() if k in [
|
|
285
|
+
'dialect', 'delimiter', 'doublequote', 'escapechar',
|
|
286
|
+
'lineterminator', 'quotechar', 'quoting', 'skipinitialspace', 'strict'
|
|
287
|
+
]},
|
|
288
|
+
)
|
|
289
|
+
writer.writeheader()
|
|
290
|
+
writer.writerows(items)
|
|
291
|
+
value = output.getvalue()
|
|
292
|
+
ct = 'text/csv'
|
|
293
|
+
else:
|
|
294
|
+
# JSON format
|
|
295
|
+
value = json.dumps(
|
|
296
|
+
items,
|
|
297
|
+
**{k: v for k, v in kwargs.items() if k in [
|
|
298
|
+
'skipkeys', 'ensure_ascii', 'check_circular', 'allow_nan',
|
|
299
|
+
'cls', 'indent', 'separators', 'default', 'sort_keys'
|
|
300
|
+
]},
|
|
301
|
+
)
|
|
302
|
+
ct = 'application/json'
|
|
303
|
+
|
|
304
|
+
# Store in KVS
|
|
305
|
+
await self._api.kvs_set_record(target_kvs_id, key, value, ct)
|