diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index b42f831..44d03d1 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -45,5 +45,5 @@ jobs: - name: Run E2E tests (Python 3.13) env: DUNE_API_KEY: ${{ secrets.DUNE_API_KEY }} + DUNE_API_KEY_OWNER_HANDLE: ${{ secrets.DUNE_API_KEY_OWNER_HANDLE }} run: uv run --python 3.13 --dev python -m pytest tests/e2e -v - diff --git a/dune_client/api/datasets.py b/dune_client/api/datasets.py new file mode 100644 index 0000000..b2f83f1 --- /dev/null +++ b/dune_client/api/datasets.py @@ -0,0 +1,71 @@ +""" +Datasets API endpoints for dataset discovery via /v1/datasets/* +""" + +from __future__ import annotations + +from dune_client.api.base import BaseRouter +from dune_client.models import DatasetListResponse, DatasetResponse, DuneError + + +class DatasetsAPI(BaseRouter): + """ + Implementation of Datasets endpoints + https://docs.dune.com/api-reference/datasets/ + """ + + def list_datasets( + self, + limit: int = 50, + offset: int = 0, + owner_handle: str | None = None, + type: str | None = None, + ) -> DatasetListResponse: + """ + https://docs.dune.com/api-reference/datasets/endpoint/list + Retrieve a paginated list of datasets with optional filtering. + + Args: + limit: Maximum number of datasets to return (max 250) + offset: Pagination offset + owner_handle: Optional filter by owner handle + type: Optional filter by dataset type (transformation_view, transformation_table, + uploaded_table, decoded_table, spell, dune_table) + + Returns: + DatasetListResponse with list of datasets and total count + """ + params: dict[str, int | str] = { + "limit": limit, + "offset": offset, + } + if owner_handle is not None: + params["owner_handle"] = owner_handle + if type is not None: + params["type"] = type + + response_json = self._get( + route="/datasets", + params=params, + ) + try: + return DatasetListResponse.from_dict(response_json) + except KeyError as err: + raise DuneError(response_json, "DatasetListResponse", err) from err + + def get_dataset(self, full_name: str) -> DatasetResponse: + """ + https://docs.dune.com/api-reference/datasets/endpoint/get + Retrieve detailed information about a specific dataset. + + Args: + full_name: The dataset full name (e.g., 'dune.shozaib_khan.aarna') + + Returns: + DatasetResponse with full dataset details including columns and metadata + """ + response_json = self._get(route=f"/datasets/{full_name}") + try: + return DatasetResponse.from_dict(response_json) + except KeyError as err: + raise DuneError(response_json, "DatasetResponse", err) from err diff --git a/dune_client/api/extensions.py b/dune_client/api/extensions.py index 1876e14..76ad866 100644 --- a/dune_client/api/extensions.py +++ b/dune_client/api/extensions.py @@ -16,10 +16,12 @@ MAX_NUM_ROWS_PER_BATCH, ) from dune_client.api.custom import CustomEndpointAPI +from dune_client.api.datasets import DatasetsAPI from dune_client.api.execution import ExecutionAPI from dune_client.api.pipeline import PipelineAPI from dune_client.api.query import QueryAPI from dune_client.api.table import TableAPI +from dune_client.api.uploads import UploadsAPI from dune_client.api.usage import UsageAPI from dune_client.models import ( DuneError, @@ -40,10 +42,26 @@ POLL_FREQUENCY_SECONDS = 1 -class ExtendedAPI(ExecutionAPI, QueryAPI, TableAPI, UsageAPI, CustomEndpointAPI, PipelineAPI): +class ExtendedAPI( # type: ignore[misc] + ExecutionAPI, + QueryAPI, + UploadsAPI, + DatasetsAPI, + TableAPI, + UsageAPI, + CustomEndpointAPI, + PipelineAPI, +): """ Provides higher level helper methods for faster and easier development on top of the base ExecutionAPI. + + Includes both legacy TableAPI (deprecated) and modern UploadsAPI/DatasetsAPI. + UploadsAPI is listed before TableAPI in the MRO to ensure modern methods + take precedence over deprecated ones with the same name. + + Note: TableAPI has incompatible method signatures with UploadsAPI but is + kept for backward compatibility. The UploadsAPI methods take precedence. """ def run_query( diff --git a/dune_client/api/table.py b/dune_client/api/table.py index 5a596b5..a50d3b5 100644 --- a/dune_client/api/table.py +++ b/dune_client/api/table.py @@ -1,12 +1,17 @@ """ Table API endpoints enables users to create and insert data into Dune. + +DEPRECATED: This API uses legacy /table/* routes. +Please use UploadsAPI for the modern /v1/uploads/* endpoints instead. """ from __future__ import annotations from typing import IO +from deprecated import deprecated + from dune_client.api.base import BaseRouter from dune_client.models import ( ClearTableResult, @@ -21,8 +26,15 @@ class TableAPI(BaseRouter): """ Implementation of Table endpoints - Plus subscription only https://docs.dune.com/api-reference/tables/ + + DEPRECATED: This API uses legacy /table/* routes. + Please use UploadsAPI for the modern /v1/uploads/* endpoints instead. """ + @deprecated( + version="1.9.0", + reason="Use UploadsAPI.upload_csv() instead. This method uses legacy /table/* routes.", + ) def upload_csv( self, table_name: str, @@ -54,6 +66,10 @@ def upload_csv( except KeyError as err: raise DuneError(response_json, "UploadCsvResponse", err) from err + @deprecated( + version="1.9.0", + reason="Use UploadsAPI.create_table() instead. This method uses legacy /table/* routes.", + ) def create_table( self, namespace: str, @@ -87,6 +103,10 @@ def create_table( except KeyError as err: raise DuneError(result_json, "CreateTableResult", err) from err + @deprecated( + version="1.9.0", + reason="Use UploadsAPI.insert_data() instead. This method uses legacy /table/* routes.", + ) def insert_table( self, namespace: str, @@ -113,6 +133,10 @@ def insert_table( except KeyError as err: raise DuneError(result_json, "InsertTableResult", err) from err + @deprecated( + version="1.9.0", + reason="Use UploadsAPI.clear_table() instead. This method uses legacy /table/* routes.", + ) def clear_data(self, namespace: str, table_name: str) -> ClearTableResult: """ https://docs.dune.com/api-reference/tables/endpoint/clear @@ -126,6 +150,10 @@ def clear_data(self, namespace: str, table_name: str) -> ClearTableResult: except KeyError as err: raise DuneError(result_json, "ClearTableResult", err) from err + @deprecated( + version="1.9.0", + reason="Use UploadsAPI.delete_table() instead. This method uses legacy /table/* routes.", + ) def delete_table(self, namespace: str, table_name: str) -> DeleteTableResult: """ https://docs.dune.com/api-reference/tables/endpoint/delete diff --git a/dune_client/api/uploads.py b/dune_client/api/uploads.py new file mode 100644 index 0000000..a7a3c88 --- /dev/null +++ b/dune_client/api/uploads.py @@ -0,0 +1,209 @@ +""" +Uploads API endpoints for table management via /v1/uploads/* +This is the modern replacement for the legacy /table/* endpoints. +""" + +from __future__ import annotations + +from typing import IO + +from dune_client.api.base import BaseRouter +from dune_client.models import ( + ClearTableResponse, + CSVUploadResponse, + DeleteTableResponse, + DuneError, + InsertDataResponse, + UploadCreateResponse, + UploadListResponse, +) + + +class UploadsAPI(BaseRouter): + """ + Implementation of Uploads endpoints - Modern table management API + https://docs.dune.com/api-reference/uploads/ + """ + + def list_uploads( + self, + limit: int = 50, + offset: int = 0, + ) -> UploadListResponse: + """ + https://docs.dune.com/api-reference/uploads/endpoint/list + List all tables owned by the authenticated account. + + Args: + limit: Maximum number of tables to return (max 10000) + offset: Pagination offset + + Returns: + UploadListResponse with list of tables and pagination info + """ + response_json = self._get( + route="/uploads", + params={ + "limit": limit, + "offset": offset, + }, + ) + try: + return UploadListResponse.from_dict(response_json) + except KeyError as err: + raise DuneError(response_json, "UploadListResponse", err) from err + + def create_table( + self, + namespace: str, + table_name: str, + schema: list[dict[str, str]], + description: str = "", + is_private: bool = False, + ) -> UploadCreateResponse: + """ + https://docs.dune.com/api-reference/uploads/endpoint/create + Create an empty table with a specific schema. + + This endpoint consumes 10 credits per successful creation. + + Args: + namespace: The namespace for the table + table_name: The name of the table to create + schema: List of column definitions, e.g. [{"name": "col1", "type": "varchar"}] + description: Optional table description + is_private: Whether the table should be private + + Returns: + UploadCreateResponse with table details + """ + result_json = self._post( + route="/uploads", + params={ + "namespace": namespace, + "table_name": table_name, + "schema": schema, + "description": description, + "is_private": is_private, + }, + ) + try: + return UploadCreateResponse.from_dict(result_json) + except KeyError as err: + raise DuneError(result_json, "UploadCreateResponse", err) from err + + def upload_csv( + self, + table_name: str, + data: str, + description: str = "", + is_private: bool = False, + ) -> CSVUploadResponse: + """ + https://docs.dune.com/api-reference/uploads/endpoint/upload-csv + Upload a CSV file to create a table with automatic schema inference. + + Limitations: + - File must be < 200 MB + - Storage limits vary by plan (1MB free, 15GB plus, 50GB premium) + + Args: + table_name: The name of the table to create + data: CSV data as a string + description: Optional table description + is_private: Whether the table should be private + + Returns: + CSVUploadResponse with the created table name + """ + response_json = self._post( + route="/uploads/csv", + params={ + "table_name": table_name, + "data": data, + "description": description, + "is_private": is_private, + }, + ) + try: + return CSVUploadResponse.from_dict(response_json) + except KeyError as err: + raise DuneError(response_json, "CSVUploadResponse", err) from err + + def insert_data( + self, + namespace: str, + table_name: str, + data: IO[bytes], + content_type: str, + ) -> InsertDataResponse: + """ + https://docs.dune.com/api-reference/uploads/endpoint/insert + Insert data into an existing table. + + Supported content types: + - text/csv + - application/x-ndjson + + Args: + namespace: The namespace of the table + table_name: The name of the table + data: File-like object containing the data to insert + content_type: MIME type of the data (text/csv or application/x-ndjson) + + Returns: + InsertDataResponse with rows/bytes written + """ + result_json = self._post( + route=f"/uploads/{namespace}/{table_name}/insert", + headers={"Content-Type": content_type}, + data=data, + ) + try: + return InsertDataResponse.from_dict(result_json) + except KeyError as err: + raise DuneError(result_json, "InsertDataResponse", err) from err + + def clear_table( + self, + namespace: str, + table_name: str, + ) -> ClearTableResponse: + """ + https://docs.dune.com/api-reference/uploads/endpoint/clear + Remove all data from a table while preserving its structure and schema. + + Args: + namespace: The namespace of the table + table_name: The name of the table + + Returns: + ClearTableResponse with confirmation message + """ + result_json = self._post(route=f"/uploads/{namespace}/{table_name}/clear") + try: + return ClearTableResponse.from_dict(result_json) + except KeyError as err: + raise DuneError(result_json, "ClearTableResponse", err) from err + + def delete_table( + self, + namespace: str, + table_name: str, + ) -> DeleteTableResponse: + """ + https://docs.dune.com/api-reference/uploads/endpoint/delete + Permanently delete a table and all its data. + + Args: + namespace: The namespace of the table + table_name: The name of the table + + Returns: + DeleteTableResponse with confirmation message + """ + response_json = self._delete(route=f"/uploads/{namespace}/{table_name}") + try: + return DeleteTableResponse.from_dict(response_json) + except KeyError as err: + raise DuneError(response_json, "DeleteTableResponse", err) from err diff --git a/dune_client/models.py b/dune_client/models.py index a75f8bd..3e2a0b7 100644 --- a/dune_client/models.py +++ b/dune_client/models.py @@ -522,3 +522,168 @@ def from_dict(cls, data: dict[str, Any]) -> PipelineStatusResponse: status=data["status"], node_executions=[PipelineNodeExecution.from_dict(ne) for ne in data["node_executions"]], ) + + +class DatasetType(Enum): + """ + Enum for possible dataset types + """ + + TRANSFORMATION_VIEW = "transformation_view" + TRANSFORMATION_TABLE = "transformation_table" + UPLOADED_TABLE = "uploaded_table" + DECODED_TABLE = "decoded_table" + SPELL = "spell" + DUNE_TABLE = "dune_table" + + +@dataclass +class DatasetOwner(DataClassJsonMixin): + """Owner information for a dataset""" + + handle: str + type: str + + +@dataclass +class DatasetColumn(DataClassJsonMixin): + """Column information for a dataset""" + + name: str + type: str + nullable: bool + + +@dataclass +class Dataset(DataClassJsonMixin): + """Dataset information returned by list datasets endpoint""" + + full_name: str + type: str + owner: DatasetOwner + columns: list[DatasetColumn] + metadata: dict[str, str] + created_at: str + updated_at: str + is_private: bool + + +@dataclass +class DatasetListResponse(DataClassJsonMixin): + """Response from GET /v1/datasets""" + + datasets: list[Dataset] + total: int + + +@dataclass +class DatasetResponse(DataClassJsonMixin): + """Response from GET /v1/datasets/{full_name}""" + + full_name: str + type: str + owner: DatasetOwner + columns: list[DatasetColumn] + metadata: dict[str, str] + created_at: str + updated_at: str + is_private: bool + + +@dataclass +class TableOwner(DataClassJsonMixin): + """Owner information for an uploaded table""" + + handle: str + type: str + + +@dataclass +class TableColumn(DataClassJsonMixin): + """Column information for an uploaded table""" + + name: str + type: str + nullable: bool + + +@dataclass +class TableElement(DataClassJsonMixin): + """Individual table metadata in list response""" + + full_name: str + is_private: bool + table_size_bytes: str + created_at: str + updated_at: str + owner: TableOwner + columns: list[TableColumn] + + +@dataclass +class UploadListResponse(DataClassJsonMixin): + """Response from GET /v1/uploads""" + + tables: list[TableElement] + next_offset: int | None = None + + +@dataclass +class UploadCreateRequest: + """Request for POST /v1/uploads""" + + namespace: str + table_name: str + schema: list[dict[str, str]] + description: str = "" + is_private: bool = False + + +@dataclass +class UploadCreateResponse(DataClassJsonMixin): + """Response from POST /v1/uploads""" + + namespace: str + table_name: str + full_name: str + example_query: str + + +@dataclass +class CSVUploadRequest: + """Request for POST /v1/uploads/csv""" + + table_name: str + data: str + description: str = "" + is_private: bool = False + + +@dataclass +class CSVUploadResponse(DataClassJsonMixin): + """Response from POST /v1/uploads/csv""" + + table_name: str + + +@dataclass +class InsertDataResponse(DataClassJsonMixin): + """Response from POST /v1/uploads/{namespace}/{table_name}/insert""" + + rows_written: int + bytes_written: int + name: str + + +@dataclass +class ClearTableResponse(DataClassJsonMixin): + """Response from POST /v1/uploads/{namespace}/{table_name}/clear""" + + message: str + + +@dataclass +class DeleteTableResponse(DataClassJsonMixin): + """Response from DELETE /v1/uploads/{namespace}/{table_name}""" + + message: str diff --git a/pyproject.toml b/pyproject.toml index da4897f..1f0b628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo known-first-party = ["dune_client"] [tool.ruff.lint.per-file-ignores] -# Tests can use magic values, assertions, and print statements -"tests/**/*.py" = ["PLR2004", "S101", "T201"] +# Tests can use magic values, assertions, print statements, and unittest-style assertions +"tests/**/*.py" = ["PLR2004", "S101", "T201", "PT009"] # Type stubs can use unused imports "*.pyi" = ["F401"] diff --git a/tests/e2e/test_datasets_integration.py b/tests/e2e/test_datasets_integration.py new file mode 100644 index 0000000..559e0b4 --- /dev/null +++ b/tests/e2e/test_datasets_integration.py @@ -0,0 +1,97 @@ +import unittest + +import pytest + +from dune_client.client import DuneClient +from dune_client.models import DatasetListResponse, DatasetResponse + + +@pytest.mark.e2e +class TestDatasetsIntegration(unittest.TestCase): + """ + E2E tests for DatasetsAPI endpoints. + These tests require a valid DUNE_API_KEY. + """ + + def setUp(self) -> None: + self.dune = DuneClient() + + def test_list_datasets(self): + result = self.dune.list_datasets(limit=10, offset=0, type="uploaded_table") + + self.assertIsInstance(result, DatasetListResponse) + self.assertIsInstance(result.datasets, list) + self.assertIsInstance(result.total, int) + self.assertGreater(result.total, 0) + + if len(result.datasets) > 0: + dataset = result.datasets[0] + self.assertIsNotNone(dataset.full_name) + self.assertIsNotNone(dataset.type) + self.assertIsNotNone(dataset.owner) + self.assertIsNotNone(dataset.columns) + + def test_list_datasets_with_filters(self): + result = self.dune.list_datasets( + limit=5, + offset=0, + type="transformation_view", + ) + + self.assertIsInstance(result, DatasetListResponse) + self.assertIsInstance(result.datasets, list) + + for dataset in result.datasets: + self.assertEqual(dataset.type, "transformation_view") + + def test_list_datasets_by_owner(self): + result = self.dune.list_datasets( + limit=5, + offset=0, + owner_handle="dune", + ) + + self.assertIsInstance(result, DatasetListResponse) + self.assertIsInstance(result.datasets, list) + + for dataset in result.datasets: + self.assertEqual(dataset.owner.handle, "dune") + + def test_get_dataset(self): + result_list = self.dune.list_datasets(limit=1, type="uploaded_table") + if len(result_list.datasets) == 0: + self.skipTest("No uploaded tables found to test") + + full_name = result_list.datasets[0].full_name + result = self.dune.get_dataset(full_name) + + self.assertIsInstance(result, DatasetResponse) + self.assertEqual(result.full_name, full_name) + self.assertIsNotNone(result.type) + self.assertIsNotNone(result.owner) + self.assertIsNotNone(result.columns) + self.assertIsInstance(result.columns, list) + self.assertGreater(len(result.columns), 0) + + column = result.columns[0] + self.assertIsNotNone(column.name) + self.assertIsNotNone(column.type) + self.assertIsNotNone(column.nullable) + + def test_get_dataset_with_uploaded_table(self): + result_list = self.dune.list_datasets( + limit=1, + type="uploaded_table", + ) + + if len(result_list.datasets) > 0: + full_name = result_list.datasets[0].full_name + result = self.dune.get_dataset(full_name) + + self.assertIsInstance(result, DatasetResponse) + self.assertEqual(result.full_name, full_name) + self.assertEqual(result.type, "uploaded_table") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/e2e/test_uploads_integration.py b/tests/e2e/test_uploads_integration.py new file mode 100644 index 0000000..6bfc765 --- /dev/null +++ b/tests/e2e/test_uploads_integration.py @@ -0,0 +1,123 @@ +import os +import unittest +from io import BytesIO + +import pytest + +from dune_client.client import DuneClient +from dune_client.models import ( + ClearTableResponse, + CSVUploadResponse, + DeleteTableResponse, + InsertDataResponse, + UploadCreateResponse, + UploadListResponse, +) + + +@pytest.mark.e2e +class TestUploadsIntegration(unittest.TestCase): + """ + E2E tests for UploadsAPI endpoints. + These tests require a valid DUNE_API_KEY and Plus subscription. + """ + + def setUp(self) -> None: + self.dune = DuneClient() + self.test_namespace = os.getenv("DUNE_API_KEY_OWNER_HANDLE", "test") + self.test_table_name = f"test_uploads_api_{int(__import__('time').time())}" + + def test_create_and_delete_table(self): + schema = [ + {"name": "id", "type": "integer"}, + {"name": "name", "type": "varchar"}, + {"name": "value", "type": "double"}, + ] + + result = self.dune.create_table( + namespace=self.test_namespace, + table_name=self.test_table_name, + schema=schema, + description="Test table created by E2E test", + is_private=True, + ) + + self.assertIsInstance(result, UploadCreateResponse) + self.assertEqual(result.table_name, self.test_table_name) + self.assertEqual(result.namespace, self.test_namespace) + + delete_result = self.dune.delete_table( + namespace=self.test_namespace, + table_name=self.test_table_name, + ) + self.assertIsInstance(delete_result, DeleteTableResponse) + + def test_upload_csv_and_delete(self): + csv_data = """id,name,value +1,Alice,10.5 +2,Bob,20.3 +3,Charlie,15.7 +""" + + result = self.dune.upload_csv( + table_name=self.test_table_name, + data=csv_data, + description="CSV uploaded by E2E test", + is_private=True, + ) + + self.assertIsInstance(result, CSVUploadResponse) + self.assertEqual(result.table_name, self.test_table_name) + + delete_result = self.dune.delete_table( + namespace=self.test_namespace, + table_name=f"dataset_{self.test_table_name}", + ) + self.assertIsInstance(delete_result, DeleteTableResponse) + + def test_list_uploads(self): + result = self.dune.list_uploads(limit=10, offset=0) + + self.assertIsInstance(result, UploadListResponse) + self.assertIsInstance(result.tables, list) + + def test_full_table_lifecycle(self): + schema = [ + {"name": "id", "type": "integer"}, + {"name": "message", "type": "varchar"}, + ] + + create_result = self.dune.create_table( + namespace=self.test_namespace, + table_name=self.test_table_name, + schema=schema, + description="Full lifecycle test", + is_private=True, + ) + self.assertIsInstance(create_result, UploadCreateResponse) + + csv_data = b"id,message\n1,Hello\n2,World\n" + insert_result = self.dune.insert_data( + namespace=self.test_namespace, + table_name=self.test_table_name, + data=BytesIO(csv_data), + content_type="text/csv", + ) + self.assertIsInstance(insert_result, InsertDataResponse) + self.assertEqual(insert_result.rows_written, 2) + + clear_result = self.dune.clear_table( + namespace=self.test_namespace, + table_name=self.test_table_name, + ) + self.assertIsInstance(clear_result, ClearTableResponse) + + delete_result = self.dune.delete_table( + namespace=self.test_namespace, + table_name=self.test_table_name, + ) + self.assertIsInstance(delete_result, DeleteTableResponse) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_datasets_api.py b/tests/unit/test_datasets_api.py new file mode 100644 index 0000000..3987257 --- /dev/null +++ b/tests/unit/test_datasets_api.py @@ -0,0 +1,137 @@ +import unittest +from unittest.mock import MagicMock + +from dune_client.api.datasets import DatasetsAPI +from dune_client.models import ( + DatasetListResponse, + DatasetResponse, +) + + +class TestDatasetsAPI(unittest.TestCase): + def setUp(self) -> None: + self.api = DatasetsAPI(api_key="test_key") + self.api._get = MagicMock() + + def test_list_datasets_minimal(self): + mock_response = { + "datasets": [ + { + "full_name": "dune.dex.trades", + "type": "transformation_view", + "owner": {"handle": "dune", "type": "team"}, + "columns": [{"name": "block_time", "type": "timestamp", "nullable": False}], + "metadata": {}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "is_private": False, + } + ], + "total": 1, + } + self.api._get.return_value = mock_response + + result = self.api.list_datasets(limit=50, offset=0) + + self.api._get.assert_called_once_with( + route="/datasets", + params={"limit": 50, "offset": 0}, + ) + self.assertIsInstance(result, DatasetListResponse) + self.assertEqual(len(result.datasets), 1) + self.assertEqual(result.datasets[0].full_name, "dune.dex.trades") + self.assertEqual(result.total, 1) + + def test_list_datasets_with_filters(self): + mock_response = { + "datasets": [ + { + "full_name": "dune.user.my_table", + "type": "uploaded_table", + "owner": {"handle": "test_user", "type": "user"}, + "columns": [{"name": "id", "type": "integer", "nullable": False}], + "metadata": {}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "is_private": True, + } + ], + "total": 1, + } + self.api._get.return_value = mock_response + + result = self.api.list_datasets( + limit=100, + offset=10, + owner_handle="test_user", + type="uploaded_table", + ) + + self.api._get.assert_called_once_with( + route="/datasets", + params={ + "limit": 100, + "offset": 10, + "owner_handle": "test_user", + "type": "uploaded_table", + }, + ) + self.assertIsInstance(result, DatasetListResponse) + self.assertEqual(result.datasets[0].type, "uploaded_table") + self.assertEqual(result.datasets[0].owner.handle, "test_user") + + def test_get_dataset(self): + mock_response = { + "full_name": "dune.dex.trades", + "type": "transformation_view", + "owner": {"handle": "dune", "type": "team"}, + "columns": [ + {"name": "block_time", "type": "timestamp", "nullable": False}, + {"name": "token_bought_address", "type": "varchar", "nullable": False}, + {"name": "token_sold_address", "type": "varchar", "nullable": False}, + {"name": "amount_usd", "type": "double", "nullable": True}, + ], + "metadata": {"description": "All DEX trades across multiple chains"}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "is_private": False, + } + self.api._get.return_value = mock_response + + result = self.api.get_dataset("dune.dex.trades") + + self.api._get.assert_called_once_with(route="/datasets/dune.dex.trades") + self.assertIsInstance(result, DatasetResponse) + self.assertEqual(result.full_name, "dune.dex.trades") + self.assertEqual(len(result.columns), 4) + self.assertEqual(result.columns[0].name, "block_time") + self.assertEqual(result.columns[0].type, "timestamp") + self.assertEqual( + result.metadata.get("description"), "All DEX trades across multiple chains" + ) + + def test_get_dataset_no_description(self): + mock_response = { + "full_name": "dune.test.dataset", + "type": "uploaded_table", + "owner": {"handle": "test_user", "type": "user"}, + "columns": [ + {"name": "id", "type": "integer", "nullable": False}, + {"name": "value", "type": "varchar", "nullable": True}, + ], + "metadata": {}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "is_private": True, + } + self.api._get.return_value = mock_response + + result = self.api.get_dataset("dune.test.dataset") + + self.assertIsInstance(result, DatasetResponse) + self.assertEqual(result.metadata, {}) + self.assertTrue(result.is_private) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_uploads_api.py b/tests/unit/test_uploads_api.py new file mode 100644 index 0000000..1cba18d --- /dev/null +++ b/tests/unit/test_uploads_api.py @@ -0,0 +1,170 @@ +import unittest +from io import BytesIO +from unittest.mock import MagicMock + +from dune_client.api.uploads import UploadsAPI +from dune_client.models import ( + ClearTableResponse, + CSVUploadResponse, + DeleteTableResponse, + InsertDataResponse, + UploadCreateResponse, + UploadListResponse, +) + + +class TestUploadsAPI(unittest.TestCase): + def setUp(self) -> None: + self.api = UploadsAPI(api_key="test_key") + self.api._get = MagicMock() + self.api._post = MagicMock() + self.api._delete = MagicMock() + + def test_list_uploads(self): + mock_response = { + "tables": [ + { + "full_name": "dune.test_namespace.test_table", + "is_private": False, + "table_size_bytes": "1024", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "owner": {"handle": "test_user", "type": "user"}, + "columns": [ + {"name": "col1", "type": "varchar", "nullable": False}, + {"name": "col2", "type": "integer", "nullable": False}, + ], + } + ], + "next_offset": 50, + } + self.api._get.return_value = mock_response + + result = self.api.list_uploads(limit=50, offset=0) + + self.api._get.assert_called_once_with( + route="/uploads", + params={"limit": 50, "offset": 0}, + ) + self.assertIsInstance(result, UploadListResponse) + self.assertEqual(len(result.tables), 1) + self.assertEqual(result.tables[0].full_name, "dune.test_namespace.test_table") + self.assertEqual(result.next_offset, 50) + + def test_create_table(self): + mock_response = { + "namespace": "test_namespace", + "table_name": "test_table", + "full_name": "test_namespace.test_table", + "example_query": "SELECT * FROM test_namespace.test_table", + } + self.api._post.return_value = mock_response + + schema = [{"name": "col1", "type": "varchar"}, {"name": "col2", "type": "int"}] + result = self.api.create_table( + namespace="test_namespace", + table_name="test_table", + schema=schema, + description="Test description", + is_private=False, + ) + + self.api._post.assert_called_once_with( + route="/uploads", + params={ + "namespace": "test_namespace", + "table_name": "test_table", + "schema": schema, + "description": "Test description", + "is_private": False, + }, + ) + self.assertIsInstance(result, UploadCreateResponse) + self.assertEqual(result.table_name, "test_table") + self.assertEqual(result.namespace, "test_namespace") + + def test_upload_csv(self): + mock_response = { + "table_name": "test_table", + } + self.api._post.return_value = mock_response + + csv_data = "col1,col2\nval1,val2\n" + result = self.api.upload_csv( + table_name="test_table", + data=csv_data, + description="Test CSV", + is_private=True, + ) + + self.api._post.assert_called_once_with( + route="/uploads/csv", + params={ + "table_name": "test_table", + "data": csv_data, + "description": "Test CSV", + "is_private": True, + }, + ) + self.assertIsInstance(result, CSVUploadResponse) + self.assertEqual(result.table_name, "test_table") + + def test_insert_data(self): + mock_response = { + "rows_written": 100, + "bytes_written": 2048, + "name": "dune.test_namespace.test_table", + } + self.api._post.return_value = mock_response + + data = BytesIO(b"col1,col2\nval1,val2\n") + result = self.api.insert_data( + namespace="test_namespace", + table_name="test_table", + data=data, + content_type="text/csv", + ) + + self.api._post.assert_called_once_with( + route="/uploads/test_namespace/test_table/insert", + headers={"Content-Type": "text/csv"}, + data=data, + ) + self.assertIsInstance(result, InsertDataResponse) + self.assertEqual(result.rows_written, 100) + self.assertEqual(result.bytes_written, 2048) + self.assertEqual(result.name, "dune.test_namespace.test_table") + + def test_clear_table(self): + mock_response = { + "message": "Table cleared successfully", + } + self.api._post.return_value = mock_response + + result = self.api.clear_table( + namespace="test_namespace", + table_name="test_table", + ) + + self.api._post.assert_called_once_with(route="/uploads/test_namespace/test_table/clear") + self.assertIsInstance(result, ClearTableResponse) + self.assertEqual(result.message, "Table cleared successfully") + + def test_delete_table(self): + mock_response = { + "message": "Table deleted successfully", + } + self.api._delete.return_value = mock_response + + result = self.api.delete_table( + namespace="test_namespace", + table_name="test_table", + ) + + self.api._delete.assert_called_once_with(route="/uploads/test_namespace/test_table") + self.assertIsInstance(result, DeleteTableResponse) + self.assertEqual(result.message, "Table deleted successfully") + + +if __name__ == "__main__": + unittest.main()