1099 lines
34 KiB
Python
1099 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from crawlee import service_locator
|
|
from crawlee.configuration import Configuration
|
|
from crawlee.storage_clients import FileSystemStorageClient, MemoryStorageClient
|
|
from crawlee.storages import Dataset, KeyValueStore
|
|
from crawlee.storages._storage_instance_manager import StorageInstanceManager
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncGenerator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from crawlee.storage_clients import StorageClient
|
|
|
|
|
|
@pytest.fixture
|
|
async def dataset(
|
|
storage_client: StorageClient,
|
|
) -> AsyncGenerator[Dataset, None]:
|
|
"""Fixture that provides a dataset instance for each test."""
|
|
dataset = await Dataset.open(
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
yield dataset
|
|
await dataset.drop()
|
|
|
|
|
|
async def test_open_creates_new_dataset(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that open() creates a new dataset with proper metadata."""
|
|
dataset = await Dataset.open(
|
|
name='new-dataset',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Verify dataset properties
|
|
assert dataset.id is not None
|
|
assert dataset.name == 'new-dataset'
|
|
|
|
metadata = await dataset.get_metadata()
|
|
assert metadata.item_count == 0
|
|
|
|
await dataset.drop()
|
|
|
|
|
|
async def test_reopen_default(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test reopening a dataset with default parameters."""
|
|
# Create a first dataset instance with default parameters
|
|
dataset_1 = await Dataset.open(
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Verify default properties
|
|
assert dataset_1.id is not None
|
|
metadata_1 = await dataset_1.get_metadata()
|
|
assert metadata_1.item_count == 0
|
|
|
|
# Add an item
|
|
await dataset_1.push_data({'key': 'value'})
|
|
metadata_1 = await dataset_1.get_metadata()
|
|
assert metadata_1.item_count == 1
|
|
|
|
# Reopen the same dataset
|
|
dataset_2 = await Dataset.open(
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Verify both instances reference the same dataset
|
|
assert dataset_2.id == dataset_1.id
|
|
assert dataset_2.name == dataset_1.name
|
|
metadata_1 = await dataset_1.get_metadata()
|
|
metadata_2 = await dataset_2.get_metadata()
|
|
assert metadata_2.item_count == metadata_1.item_count == 1
|
|
|
|
# Verify they are the same object (cached)
|
|
assert id(dataset_1) == id(dataset_2)
|
|
|
|
# Clean up
|
|
await dataset_1.drop()
|
|
|
|
|
|
async def test_open_by_id(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test opening a dataset by its ID."""
|
|
# First create a dataset by name
|
|
dataset1 = await Dataset.open(
|
|
name='dataset-by-id-test',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Add some data to identify it
|
|
test_item = {'test': 'opening_by_id', 'timestamp': 12345}
|
|
await dataset1.push_data(test_item)
|
|
|
|
# Open the dataset by ID
|
|
dataset2 = await Dataset.open(
|
|
id=dataset1.id,
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Verify it's the same dataset
|
|
assert dataset2.id == dataset1.id
|
|
assert dataset2.name == 'dataset-by-id-test'
|
|
|
|
# Verify the data is still there
|
|
data = await dataset2.get_data()
|
|
assert data.count == 1
|
|
assert data.items[0]['test'] == 'opening_by_id'
|
|
assert data.items[0]['timestamp'] == 12345
|
|
|
|
# Clean up
|
|
await dataset2.drop()
|
|
|
|
|
|
async def test_open_existing_dataset(
|
|
dataset: Dataset,
|
|
) -> None:
|
|
"""Test that open() loads an existing dataset correctly."""
|
|
# Open the same dataset again
|
|
reopened_dataset = await Dataset.open(
|
|
name=dataset.name,
|
|
)
|
|
|
|
# Verify dataset properties
|
|
assert dataset.id == reopened_dataset.id
|
|
assert dataset.name == reopened_dataset.name
|
|
metadata = await dataset.get_metadata()
|
|
reopened_metadata = await reopened_dataset.get_metadata()
|
|
assert metadata.item_count == reopened_metadata.item_count
|
|
|
|
# Verify they are the same object (from cache)
|
|
assert id(dataset) == id(reopened_dataset)
|
|
|
|
|
|
async def test_open_with_id_and_name(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that open() raises an error when both id and name are provided."""
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Only one of "id", "name", "alias" can be specified, but following arguments '
|
|
r'were specified: "id", "name".',
|
|
):
|
|
await Dataset.open(
|
|
id='some-id',
|
|
name='some-name',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
|
|
async def test_push_data_single_item(dataset: Dataset) -> None:
|
|
"""Test pushing a single item to the dataset."""
|
|
item = {'key': 'value', 'number': 42}
|
|
await dataset.push_data(item)
|
|
|
|
# Verify item was stored
|
|
result = await dataset.get_data()
|
|
assert result.count == 1
|
|
assert result.items[0] == item
|
|
|
|
|
|
async def test_push_data_multiple_items(dataset: Dataset) -> None:
|
|
"""Test pushing multiple items to the dataset."""
|
|
items = [
|
|
{'id': 1, 'name': 'Item 1'},
|
|
{'id': 2, 'name': 'Item 2'},
|
|
{'id': 3, 'name': 'Item 3'},
|
|
]
|
|
await dataset.push_data(items)
|
|
|
|
# Verify items were stored
|
|
result = await dataset.get_data()
|
|
assert result.count == 3
|
|
assert result.items == items
|
|
|
|
|
|
async def test_get_data_empty_dataset(dataset: Dataset) -> None:
|
|
"""Test getting data from an empty dataset returns empty results."""
|
|
result = await dataset.get_data()
|
|
|
|
assert result.count == 0
|
|
assert result.total == 0
|
|
assert result.items == []
|
|
|
|
|
|
async def test_get_data_with_pagination(dataset: Dataset) -> None:
|
|
"""Test getting data with offset and limit parameters for pagination."""
|
|
# Add some items
|
|
items = [{'id': i} for i in range(1, 11)] # 10 items
|
|
await dataset.push_data(items)
|
|
|
|
# Test offset
|
|
result = await dataset.get_data(offset=3)
|
|
assert result.count == 7
|
|
assert result.offset == 3
|
|
assert result.items[0]['id'] == 4
|
|
|
|
# Test limit
|
|
result = await dataset.get_data(limit=5)
|
|
assert result.count == 5
|
|
assert result.limit == 5
|
|
assert result.items[-1]['id'] == 5
|
|
|
|
# Test both offset and limit
|
|
result = await dataset.get_data(offset=2, limit=3)
|
|
assert result.count == 3
|
|
assert result.offset == 2
|
|
assert result.limit == 3
|
|
assert result.items[0]['id'] == 3
|
|
assert result.items[-1]['id'] == 5
|
|
|
|
|
|
async def test_get_data_descending_order(dataset: Dataset) -> None:
|
|
"""Test getting data in descending order reverses the item order."""
|
|
# Add some items
|
|
items = [{'id': i} for i in range(1, 6)] # 5 items
|
|
await dataset.push_data(items)
|
|
|
|
# Get items in descending order
|
|
result = await dataset.get_data(desc=True)
|
|
|
|
assert result.desc is True
|
|
assert result.items[0]['id'] == 5
|
|
assert result.items[-1]['id'] == 1
|
|
|
|
|
|
async def test_get_data_skip_empty(dataset: Dataset) -> None:
|
|
"""Test getting data with skip_empty option filters out empty items."""
|
|
# Add some items including an empty one
|
|
items = [
|
|
{'id': 1, 'name': 'Item 1'},
|
|
{}, # Empty item
|
|
{'id': 3, 'name': 'Item 3'},
|
|
]
|
|
await dataset.push_data(items)
|
|
|
|
# Get all items
|
|
result = await dataset.get_data()
|
|
assert result.count == 3
|
|
|
|
# Get non-empty items
|
|
result = await dataset.get_data(skip_empty=True)
|
|
assert result.count == 2
|
|
assert all(item != {} for item in result.items)
|
|
|
|
|
|
async def test_iterate_items(dataset: Dataset) -> None:
|
|
"""Test iterating over dataset items yields each item in the correct order."""
|
|
# Add some items
|
|
items = [{'id': i} for i in range(1, 6)] # 5 items
|
|
await dataset.push_data(items)
|
|
|
|
# Iterate over all items
|
|
collected_items = [item async for item in dataset.iterate_items()]
|
|
|
|
assert len(collected_items) == 5
|
|
assert collected_items[0]['id'] == 1
|
|
assert collected_items[-1]['id'] == 5
|
|
|
|
|
|
async def test_iterate_items_with_options(dataset: Dataset) -> None:
|
|
"""Test iterating with offset, limit and desc parameters."""
|
|
# Add some items
|
|
items = [{'id': i} for i in range(1, 11)] # 10 items
|
|
await dataset.push_data(items)
|
|
|
|
# Test with offset and limit
|
|
collected_items = [item async for item in dataset.iterate_items(offset=3, limit=3)]
|
|
|
|
assert len(collected_items) == 3
|
|
assert collected_items[0]['id'] == 4
|
|
assert collected_items[-1]['id'] == 6
|
|
|
|
# Test with descending order
|
|
collected_items = []
|
|
async for item in dataset.iterate_items(desc=True, limit=3):
|
|
collected_items.append(item)
|
|
|
|
assert len(collected_items) == 3
|
|
assert collected_items[0]['id'] == 10
|
|
assert collected_items[-1]['id'] == 8
|
|
|
|
|
|
async def test_list_items(dataset: Dataset) -> None:
|
|
"""Test that list_items returns all dataset items as a list."""
|
|
# Add some items
|
|
items = [{'id': i} for i in range(1, 6)] # 5 items
|
|
await dataset.push_data(items)
|
|
|
|
# Get all items as a list
|
|
collected_items = await dataset.list_items()
|
|
|
|
assert len(collected_items) == 5
|
|
assert collected_items[0]['id'] == 1
|
|
assert collected_items[-1]['id'] == 5
|
|
|
|
|
|
async def test_list_items_with_options(dataset: Dataset) -> None:
|
|
"""Test that list_items respects filtering options."""
|
|
# Add some items
|
|
items: list[dict[str, Any]] = [
|
|
{'id': 1, 'name': 'Item 1'},
|
|
{'id': 2, 'name': 'Item 2'},
|
|
{'id': 3}, # Item with missing 'name' field
|
|
{}, # Empty item
|
|
{'id': 5, 'name': 'Item 5'},
|
|
]
|
|
await dataset.push_data(items)
|
|
|
|
# Test with offset and limit
|
|
collected_items = await dataset.list_items(offset=1, limit=2)
|
|
assert len(collected_items) == 2
|
|
assert collected_items[0]['id'] == 2
|
|
assert collected_items[1]['id'] == 3
|
|
|
|
# Test with descending order - skip empty items to avoid KeyError
|
|
collected_items = await dataset.list_items(desc=True, skip_empty=True)
|
|
|
|
# Filter items that have an 'id' field
|
|
items_with_ids = [item for item in collected_items if 'id' in item]
|
|
id_values = [item['id'] for item in items_with_ids]
|
|
|
|
# Verify the list is sorted in descending order
|
|
assert sorted(id_values, reverse=True) == id_values, f'IDs should be in descending order. Got {id_values}'
|
|
|
|
# Verify key IDs are present and in the right order
|
|
if 5 in id_values and 3 in id_values:
|
|
assert id_values.index(5) < id_values.index(3), 'ID 5 should come before ID 3 in descending order'
|
|
|
|
# Test with skip_empty
|
|
collected_items = await dataset.list_items(skip_empty=True)
|
|
assert len(collected_items) == 4 # Should skip the empty item
|
|
assert all(item != {} for item in collected_items)
|
|
|
|
# Test with fields - manually filter since 'fields' parameter is not supported
|
|
# Get all items first
|
|
collected_items = await dataset.list_items()
|
|
assert len(collected_items) == 5
|
|
|
|
# Manually extract only the 'id' field from each item
|
|
filtered_items = [{key: item[key] for key in ['id'] if key in item} for item in collected_items]
|
|
|
|
# Verify 'name' field is not present in any item
|
|
assert all('name' not in item for item in filtered_items)
|
|
|
|
# Test clean functionality manually instead of using the clean parameter
|
|
# Get all items
|
|
collected_items = await dataset.list_items()
|
|
|
|
# Manually filter out empty items as 'clean' would do
|
|
clean_items = [item for item in collected_items if item != {}]
|
|
|
|
assert len(clean_items) == 4 # Should have 4 non-empty items
|
|
assert all(item != {} for item in clean_items)
|
|
|
|
|
|
async def test_drop(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test dropping a dataset removes it from cache and clears its data."""
|
|
dataset = await Dataset.open(
|
|
name='drop-test',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Add some data
|
|
await dataset.push_data({'test': 'data'})
|
|
|
|
# Drop the dataset
|
|
await dataset.drop()
|
|
|
|
# Verify dataset is empty (by creating a new one with the same name)
|
|
new_dataset = await Dataset.open(
|
|
name='drop-test',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
result = await new_dataset.get_data()
|
|
assert result.count == 0
|
|
await new_dataset.drop()
|
|
|
|
|
|
async def test_export_to_json(
|
|
dataset: Dataset,
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test exporting dataset to JSON format."""
|
|
# Create a key-value store for export
|
|
kvs = await KeyValueStore.open(
|
|
name='export-kvs',
|
|
)
|
|
|
|
# Add some items to the dataset
|
|
items = [
|
|
{'id': 1, 'name': 'Item 1'},
|
|
{'id': 2, 'name': 'Item 2'},
|
|
{'id': 3, 'name': 'Item 3'},
|
|
]
|
|
await dataset.push_data(items)
|
|
|
|
# Export to JSON
|
|
await dataset.export_to(
|
|
key='dataset_export.json',
|
|
content_type='json',
|
|
to_kvs_name='export-kvs',
|
|
to_kvs_storage_client=storage_client,
|
|
)
|
|
|
|
# Retrieve the exported file
|
|
record = await kvs.get_value(key='dataset_export.json')
|
|
assert record is not None
|
|
|
|
# Verify content has all the items
|
|
assert '"id": 1' in record
|
|
assert '"id": 2' in record
|
|
assert '"id": 3' in record
|
|
|
|
await kvs.drop()
|
|
|
|
|
|
async def test_export_to_csv(
|
|
dataset: Dataset,
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test exporting dataset to CSV format."""
|
|
# Create a key-value store for export
|
|
kvs = await KeyValueStore.open(
|
|
name='export-kvs',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Add some items to the dataset
|
|
items = [
|
|
{'id': 1, 'name': 'Item 1'},
|
|
{'id': 2, 'name': 'Item 2'},
|
|
{'id': 3, 'name': 'Item 3'},
|
|
]
|
|
await dataset.push_data(items)
|
|
|
|
# Export to CSV
|
|
await dataset.export_to(
|
|
key='dataset_export.csv',
|
|
content_type='csv',
|
|
to_kvs_name='export-kvs',
|
|
to_kvs_storage_client=storage_client,
|
|
)
|
|
|
|
# Retrieve the exported file
|
|
record = await kvs.get_value(key='dataset_export.csv')
|
|
assert record is not None
|
|
|
|
# Verify content has all the items
|
|
assert 'id,name' in record
|
|
assert '1,Item 1' in record
|
|
assert '2,Item 2' in record
|
|
assert '3,Item 3' in record
|
|
|
|
await kvs.drop()
|
|
|
|
|
|
async def test_export_to_invalid_content_type(dataset: Dataset) -> None:
|
|
"""Test exporting dataset with invalid content type raises error."""
|
|
with pytest.raises(ValueError, match=r'Unsupported content type'):
|
|
await dataset.export_to(key='invalid_export', content_type='invalid') # ty: ignore[no-matching-overload]
|
|
|
|
|
|
async def test_export_with_multiple_kwargs(dataset: Dataset, tmp_path: Path) -> None:
|
|
"""Test exporting dataset using many optional arguments together."""
|
|
target_kvs_name = 'some-kvs'
|
|
target_storage_client = FileSystemStorageClient()
|
|
export_key = 'exported_dataset'
|
|
data = {'some key': 'some data'}
|
|
|
|
# Prepare custom directory and configuration
|
|
custom_dir_name = 'some_dir'
|
|
custom_dir = tmp_path / custom_dir_name
|
|
custom_dir.mkdir()
|
|
target_configuration = Configuration(storage_dir=str(custom_dir))
|
|
|
|
# Set expected values
|
|
expected_exported_data = f'{json.dumps([{"some key": "some data"}])}'
|
|
expected_kvs_dir = custom_dir / 'key_value_stores' / target_kvs_name
|
|
|
|
# Populate dataset and export
|
|
await dataset.push_data(data)
|
|
await dataset.export_to(
|
|
key=export_key,
|
|
content_type='json',
|
|
to_kvs_name=target_kvs_name,
|
|
to_kvs_storage_client=target_storage_client,
|
|
to_kvs_configuration=target_configuration,
|
|
)
|
|
|
|
# Verify the directory was created
|
|
assert expected_kvs_dir.is_dir()
|
|
# Verify that kvs contains the exported data
|
|
kvs = await KeyValueStore.open(
|
|
name=target_kvs_name, storage_client=target_storage_client, configuration=target_configuration
|
|
)
|
|
|
|
assert await kvs.get_value(key=export_key) == expected_exported_data
|
|
|
|
|
|
async def test_large_dataset(dataset: Dataset) -> None:
|
|
"""Test handling a large dataset with many items."""
|
|
items = [{'id': i, 'value': f'value-{i}'} for i in range(100)]
|
|
await dataset.push_data(items)
|
|
|
|
# Test that all items are retrieved
|
|
result = await dataset.get_data(limit=None)
|
|
assert result.count == 100
|
|
assert result.total == 100
|
|
|
|
# Test pagination with large datasets
|
|
result = await dataset.get_data(offset=50, limit=25)
|
|
assert result.count == 25
|
|
assert result.offset == 50
|
|
assert result.items[0]['id'] == 50
|
|
assert result.items[-1]['id'] == 74
|
|
|
|
|
|
async def test_purge(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test purging a dataset removes all data but keeps the dataset itself."""
|
|
# First create a dataset
|
|
dataset = await Dataset.open(
|
|
name='purge-test-dataset',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Add some data
|
|
initial_items = [
|
|
{'id': 1, 'name': 'Item 1'},
|
|
{'id': 2, 'name': 'Item 2'},
|
|
{'id': 3, 'name': 'Item 3'},
|
|
]
|
|
await dataset.push_data(initial_items)
|
|
|
|
# Verify data was added
|
|
data = await dataset.get_data()
|
|
assert data.count == 3
|
|
assert data.total == 3
|
|
metadata = await dataset.get_metadata()
|
|
assert metadata.item_count == 3
|
|
|
|
# Record the dataset ID
|
|
dataset_id = dataset.id
|
|
|
|
# Purge the dataset
|
|
await dataset.purge()
|
|
|
|
# Verify the dataset still exists but is empty
|
|
assert dataset.id == dataset_id # Same ID preserved
|
|
assert dataset.name == 'purge-test-dataset' # Same name preserved
|
|
|
|
# Dataset should be empty now
|
|
data = await dataset.get_data()
|
|
assert data.count == 0
|
|
assert data.total == 0
|
|
metadata = await dataset.get_metadata()
|
|
assert metadata.item_count == 0
|
|
|
|
# Verify we can add new data after purging
|
|
new_item = {'id': 4, 'name': 'New Item After Purge'}
|
|
await dataset.push_data(new_item)
|
|
|
|
data = await dataset.get_data()
|
|
assert data.count == 1
|
|
assert data.items[0]['name'] == 'New Item After Purge'
|
|
|
|
# Clean up
|
|
await dataset.drop()
|
|
|
|
|
|
async def test_open_with_alias(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test opening datasets with alias parameter for NDU functionality."""
|
|
# Create datasets with different aliases
|
|
dataset_1 = await Dataset.open(
|
|
alias='test_alias_1',
|
|
storage_client=storage_client,
|
|
)
|
|
dataset_2 = await Dataset.open(
|
|
alias='test_alias_2',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Verify they have different IDs but no names (unnamed)
|
|
assert dataset_1.id != dataset_2.id
|
|
assert dataset_1.name is None
|
|
assert dataset_2.name is None
|
|
|
|
# Add different data to each
|
|
await dataset_1.push_data({'source': 'alias_1', 'value': 1})
|
|
await dataset_2.push_data({'source': 'alias_2', 'value': 2})
|
|
|
|
# Verify data isolation
|
|
data_1 = await dataset_1.get_data()
|
|
data_2 = await dataset_2.get_data()
|
|
|
|
assert data_1.count == 1
|
|
assert data_2.count == 1
|
|
assert data_1.items[0]['source'] == 'alias_1'
|
|
assert data_2.items[0]['source'] == 'alias_2'
|
|
|
|
# Clean up
|
|
await dataset_1.drop()
|
|
await dataset_2.drop()
|
|
|
|
|
|
async def test_alias_caching(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that datasets with same alias return same instance (cached)."""
|
|
# Open dataset with alias
|
|
dataset_1 = await Dataset.open(
|
|
alias='cache_test',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Open again with same alias
|
|
dataset_2 = await Dataset.open(
|
|
alias='cache_test',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Should be same instance
|
|
assert dataset_1 is dataset_2
|
|
assert dataset_1.id == dataset_2.id
|
|
|
|
# Clean up
|
|
await dataset_1.drop()
|
|
|
|
|
|
async def test_alias_with_id_error(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that providing both alias and id raises error."""
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Only one of "id", "name", "alias" can be specified, but following arguments '
|
|
r'were specified: "id", "alias".',
|
|
):
|
|
await Dataset.open(
|
|
id='some-id',
|
|
alias='some-alias',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
|
|
async def test_alias_with_name_error(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that providing both alias and name raises error."""
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Only one of "id", "name", "alias" can be specified, but following arguments '
|
|
r'were specified: "name", "alias".',
|
|
):
|
|
await Dataset.open(
|
|
name='some-name',
|
|
alias='some-alias',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
|
|
async def test_alias_with_all_parameters_error(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that providing id, name, and alias raises error."""
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Only one of "id", "name", "alias" can be specified, but following arguments '
|
|
r'were specified: "id", "name", "alias".',
|
|
):
|
|
await Dataset.open(
|
|
id='some-id',
|
|
name='some-name',
|
|
alias='some-alias',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
|
|
async def test_alias_with_special_characters(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test alias functionality with special characters."""
|
|
special_aliases = [
|
|
'alias-with-dashes',
|
|
'alias_with_underscores',
|
|
'alias.with.dots',
|
|
'alias123with456numbers',
|
|
'CamelCaseAlias',
|
|
]
|
|
|
|
datasets = []
|
|
for alias in special_aliases:
|
|
dataset = await Dataset.open(
|
|
alias=alias,
|
|
storage_client=storage_client,
|
|
)
|
|
datasets.append(dataset)
|
|
|
|
# Add data with the alias as identifier
|
|
await dataset.push_data({'alias_used': alias, 'test': 'special_chars'})
|
|
|
|
# Verify all work correctly
|
|
for i, dataset in enumerate(datasets):
|
|
data = await dataset.get_data()
|
|
assert data.count == 1
|
|
assert data.items[0]['alias_used'] == special_aliases[i]
|
|
|
|
# Clean up
|
|
for dataset in datasets:
|
|
await dataset.drop()
|
|
|
|
|
|
async def test_named_vs_alias_conflict_detection(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that conflicts between named and alias storages are detected."""
|
|
# Test 1: Create named storage first, then try alias with same name
|
|
named_dataset = await Dataset.open(name='conflict-test', storage_client=storage_client)
|
|
assert named_dataset.name == 'conflict-test'
|
|
|
|
# Try to create alias with same name - should raise error
|
|
with pytest.raises(ValueError, match=r'Cannot create alias storage "conflict-test".*already exists'):
|
|
await Dataset.open(alias='conflict-test', storage_client=storage_client)
|
|
|
|
# Clean up
|
|
await named_dataset.drop()
|
|
|
|
# Test 2: Create alias first, then try named with same name
|
|
alias_dataset = await Dataset.open(alias='conflict-test2', storage_client=storage_client)
|
|
assert alias_dataset.name is None # Alias storages have no name
|
|
|
|
# Try to create named with same name - should raise error
|
|
with pytest.raises(ValueError, match=r'Cannot create named storage "conflict-test2".*already exists'):
|
|
await Dataset.open(name='conflict-test2', storage_client=storage_client)
|
|
|
|
# Clean up
|
|
await alias_dataset.drop()
|
|
|
|
|
|
async def test_alias_parameter(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test dataset creation and operations with alias parameter."""
|
|
# Create dataset with alias
|
|
alias_dataset = await Dataset.open(
|
|
alias='test_alias',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Verify alias dataset properties
|
|
assert alias_dataset.id is not None
|
|
assert alias_dataset.name is None # Alias storages should be unnamed
|
|
|
|
# Test data operations
|
|
await alias_dataset.push_data({'type': 'alias', 'value': 1})
|
|
data = await alias_dataset.get_data()
|
|
assert data.count == 1
|
|
assert data.items[0]['type'] == 'alias'
|
|
|
|
await alias_dataset.drop()
|
|
|
|
|
|
async def test_alias_vs_named_isolation(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that alias and named datasets with same identifier are isolated."""
|
|
# Create named dataset
|
|
named_dataset = await Dataset.open(
|
|
name='test-identifier',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Verify named dataset
|
|
assert named_dataset.name == 'test-identifier'
|
|
await named_dataset.push_data({'type': 'named'})
|
|
|
|
# Clean up named dataset first
|
|
await named_dataset.drop()
|
|
|
|
# Now create alias dataset with same identifier (should work after cleanup)
|
|
alias_dataset = await Dataset.open(
|
|
alias='test_identifier',
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Should be different instance
|
|
assert alias_dataset.name is None
|
|
await alias_dataset.push_data({'type': 'alias'})
|
|
|
|
# Verify alias data
|
|
alias_data = await alias_dataset.get_data()
|
|
assert alias_data.items[0]['type'] == 'alias'
|
|
|
|
await alias_dataset.drop()
|
|
|
|
|
|
async def test_default_vs_alias_default_equivalence(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that default dataset and alias='default' are equivalent."""
|
|
# Open default dataset
|
|
default_dataset = await Dataset.open(
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
alias_default_dataset = await Dataset.open(
|
|
alias=StorageInstanceManager._DEFAULT_STORAGE_ALIAS,
|
|
storage_client=storage_client,
|
|
)
|
|
|
|
# Should be the same
|
|
assert default_dataset.id == alias_default_dataset.id
|
|
assert default_dataset.name is None
|
|
assert alias_default_dataset.name is None
|
|
|
|
# Data should be shared
|
|
await default_dataset.push_data({'source': 'default'})
|
|
data = await alias_default_dataset.get_data()
|
|
assert data.items[0]['source'] == 'default'
|
|
|
|
await default_dataset.drop()
|
|
|
|
|
|
async def test_multiple_alias_isolation(
|
|
storage_client: StorageClient,
|
|
) -> None:
|
|
"""Test that different aliases create separate datasets."""
|
|
datasets = []
|
|
|
|
for i in range(3):
|
|
dataset = await Dataset.open(
|
|
alias=f'alias_{i}',
|
|
storage_client=storage_client,
|
|
)
|
|
await dataset.push_data({'alias': f'alias_{i}', 'index': i})
|
|
datasets.append(dataset)
|
|
|
|
# All should be different
|
|
for i in range(3):
|
|
for j in range(i + 1, 3):
|
|
assert datasets[i].id != datasets[j].id
|
|
|
|
# Verify data isolation
|
|
for i, dataset in enumerate(datasets):
|
|
data = await dataset.get_data()
|
|
assert data.items[0]['alias'] == f'alias_{i}'
|
|
await dataset.drop()
|
|
|
|
|
|
async def test_purge_on_start_enabled(storage_client: StorageClient) -> None:
|
|
"""Test purge behavior when purge_on_start=True: named storages retain data, unnamed storages are purged."""
|
|
|
|
# Skip this test for memory storage since it doesn't persist data between client instances.
|
|
if isinstance(storage_client, MemoryStorageClient):
|
|
pytest.skip('Memory storage does not persist data between client instances.')
|
|
|
|
configuration = Configuration(purge_on_start=True)
|
|
|
|
# First, create all storage types with purge enabled and add data.
|
|
default_dataset = await Dataset.open(
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
alias_dataset = await Dataset.open(
|
|
alias='purge-test-alias',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
named_dataset = await Dataset.open(
|
|
name='purge-test-named',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
await default_dataset.push_data({'type': 'default', 'data': 'should_be_purged'})
|
|
await alias_dataset.push_data({'type': 'alias', 'data': 'should_be_purged'})
|
|
await named_dataset.push_data({'type': 'named', 'data': 'should_persist'})
|
|
|
|
# Verify data was added
|
|
default_data = await default_dataset.get_data()
|
|
alias_data = await alias_dataset.get_data()
|
|
named_data = await named_dataset.get_data()
|
|
|
|
assert len(default_data.items) == 1
|
|
assert len(alias_data.items) == 1
|
|
assert len(named_data.items) == 1
|
|
|
|
# Verify that default and alias storages are unnamed
|
|
default_metadata = await default_dataset.get_metadata()
|
|
alias_metadata = await alias_dataset.get_metadata()
|
|
named_metadata = await named_dataset.get_metadata()
|
|
|
|
assert default_metadata.name is None
|
|
assert alias_metadata.name is None
|
|
assert named_metadata.name == 'purge-test-named'
|
|
|
|
# Clear storage cache to simulate "reopening" storages
|
|
service_locator.storage_instance_manager.clear_cache()
|
|
|
|
# Now "reopen" all storages
|
|
default_dataset_2 = await Dataset.open(
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
alias_dataset_2 = await Dataset.open(
|
|
alias='purge-test-alias',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
named_dataset_2 = await Dataset.open(
|
|
name='purge-test-named',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
# Check the data after purge
|
|
default_data_after = await default_dataset_2.get_data()
|
|
alias_data_after = await alias_dataset_2.get_data()
|
|
named_data_after = await named_dataset_2.get_data()
|
|
|
|
# Unnamed storages (alias and default) should be purged (data removed)
|
|
assert len(default_data_after.items) == 0
|
|
assert len(alias_data_after.items) == 0
|
|
|
|
# Named storage should retain data (not purged)
|
|
assert len(named_data_after.items) == 1
|
|
|
|
# Clean up
|
|
await named_dataset_2.drop()
|
|
await alias_dataset_2.drop()
|
|
await default_dataset_2.drop()
|
|
|
|
|
|
async def test_purge_on_start_disabled(storage_client: StorageClient) -> None:
|
|
"""Test purge behavior when purge_on_start=False: all storages retain data regardless of type."""
|
|
|
|
# Skip this test for memory storage since it doesn't persist data between client instances.
|
|
if isinstance(storage_client, MemoryStorageClient):
|
|
pytest.skip('Memory storage does not persist data between client instances.')
|
|
|
|
configuration = Configuration(purge_on_start=False)
|
|
|
|
# First, create all storage types with purge disabled and add data.
|
|
default_dataset = await Dataset.open(
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
alias_dataset = await Dataset.open(
|
|
alias='purge-test-alias',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
named_dataset = await Dataset.open(
|
|
name='purge-test-named',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
await default_dataset.push_data({'type': 'default', 'data': 'should_persist'})
|
|
await alias_dataset.push_data({'type': 'alias', 'data': 'should_persist'})
|
|
await named_dataset.push_data({'type': 'named', 'data': 'should_persist'})
|
|
|
|
# Verify data was added
|
|
default_data = await default_dataset.get_data()
|
|
alias_data = await alias_dataset.get_data()
|
|
named_data = await named_dataset.get_data()
|
|
|
|
assert len(default_data.items) == 1
|
|
assert len(alias_data.items) == 1
|
|
assert len(named_data.items) == 1
|
|
|
|
# Verify that default and alias storages are unnamed
|
|
default_metadata = await default_dataset.get_metadata()
|
|
alias_metadata = await alias_dataset.get_metadata()
|
|
named_metadata = await named_dataset.get_metadata()
|
|
|
|
assert default_metadata.name is None
|
|
assert alias_metadata.name is None
|
|
assert named_metadata.name == 'purge-test-named'
|
|
|
|
# Clear storage cache to simulate "reopening" storages
|
|
service_locator.storage_instance_manager.clear_cache()
|
|
|
|
# Now "reopen" all storages
|
|
default_dataset_2 = await Dataset.open(
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
alias_dataset_2 = await Dataset.open(
|
|
alias='purge-test-alias',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
named_dataset_2 = await Dataset.open(
|
|
name='purge-test-named',
|
|
storage_client=storage_client,
|
|
configuration=configuration,
|
|
)
|
|
|
|
# Check the data after purge
|
|
default_data_after = await default_dataset_2.get_data()
|
|
alias_data_after = await alias_dataset_2.get_data()
|
|
named_data_after = await named_dataset_2.get_data()
|
|
|
|
# All storages should retain data (not purged)
|
|
assert len(default_data_after.items) == 1
|
|
assert len(alias_data_after.items) == 1
|
|
assert len(named_data_after.items) == 1
|
|
|
|
assert default_data_after.items[0]['data'] == 'should_persist'
|
|
assert alias_data_after.items[0]['data'] == 'should_persist'
|
|
assert named_data_after.items[0]['data'] == 'should_persist'
|
|
|
|
# Clean up
|
|
await default_dataset_2.drop()
|
|
await alias_dataset_2.drop()
|
|
await named_dataset_2.drop()
|
|
|
|
|
|
async def test_name_default_not_allowed(storage_client: StorageClient) -> None:
|
|
"""Test that storage can't have default alias as name, to prevent collisions with unnamed storage alias."""
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=f'Storage name cannot be "{StorageInstanceManager._DEFAULT_STORAGE_ALIAS}" as '
|
|
f'it is reserved for default alias.',
|
|
):
|
|
await Dataset.open(name=StorageInstanceManager._DEFAULT_STORAGE_ALIAS, storage_client=storage_client)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('name', 'is_valid'),
|
|
[
|
|
pytest.param('F', True, id='single-char'),
|
|
pytest.param('7', True, id='single-digit'),
|
|
pytest.param('FtghdfseySds', True, id='mixed-case'),
|
|
pytest.param('125673450', True, id='all-digits'),
|
|
pytest.param('Ft2134Sfe0O1hf', True, id='mixed-alphanumeric'),
|
|
pytest.param('name-with-dashes', True, id='dashes'),
|
|
pytest.param('1-value', True, id='number start'),
|
|
pytest.param('value-1', True, id='number end'),
|
|
pytest.param('test-1-value', True, id='number middle'),
|
|
pytest.param('test-------value', True, id='multiple-dashes'),
|
|
pytest.param('test-VALUES-test', True, id='multiple-cases'),
|
|
pytest.param('name_with_underscores', False, id='underscores'),
|
|
pytest.param('name with spaces', False, id='spaces'),
|
|
pytest.param('-test', False, id='dashes start'),
|
|
pytest.param('test-', False, id='dashes end'),
|
|
],
|
|
)
|
|
async def test_validate_name(storage_client: StorageClient, name: str, *, is_valid: bool) -> None:
|
|
"""Test name validation logic."""
|
|
if is_valid:
|
|
# Should not raise
|
|
dataset = await Dataset.open(name=name, storage_client=storage_client)
|
|
assert dataset.name == name
|
|
await dataset.drop()
|
|
else:
|
|
with pytest.raises(ValueError, match=rf'Invalid storage name "{name}".*'):
|
|
await Dataset.open(name=name, storage_client=storage_client)
|
|
|
|
|
|
async def test_record_with_noascii_chars(dataset: Dataset) -> None:
|
|
"""Test handling record with non-ASCII characters."""
|
|
init_value = {
|
|
'record_1': 'Supermaxi El Jardín',
|
|
'record_2': 'záznam dva',
|
|
'record_3': '記録三',
|
|
}
|
|
|
|
# Save the record to the dataset
|
|
await dataset.push_data(init_value)
|
|
|
|
# Get the record and verify
|
|
value = await dataset.get_data()
|
|
assert value is not None
|
|
assert value.items[0] == init_value
|