From 50da5028f5c13de77fe1619efc84900dc666becc Mon Sep 17 00:00:00 2001 From: va3093 Date: Fri, 14 Nov 2025 11:28:07 +0000 Subject: [PATCH 1/5] Add modern Uploads and Datasets APIs, deprecate legacy Table API This commit introduces the modern /v1/uploads and /v1/datasets API endpoints while maintaining backward compatibility with the legacy /table endpoints. Changes: - Add UploadsAPI with /v1/uploads/* endpoints for table management - Add DatasetsAPI with /v1/datasets/* endpoints for dataset discovery - Deprecate all TableAPI methods with migration guidance - Add comprehensive response models for new APIs - Update ExtendedAPI to include new APIs with proper MRO - Add unit and E2E tests for new functionality - Configure ruff to allow unittest-style assertions in tests All existing TableAPI methods remain functional but emit deprecation warnings pointing users to the new UploadsAPI methods. --- dune_client/api/datasets.py | 71 +++++++++ dune_client/api/extensions.py | 20 ++- dune_client/api/table.py | 28 ++++ dune_client/api/uploads.py | 209 +++++++++++++++++++++++++ dune_client/models.py | 170 ++++++++++++++++++++ pyproject.toml | 4 +- tests/e2e/test_datasets_integration.py | 94 +++++++++++ tests/e2e/test_uploads_integration.py | 122 +++++++++++++++ tests/unit/test_datasets_api.py | 140 +++++++++++++++++ tests/unit/test_uploads_api.py | 175 +++++++++++++++++++++ 10 files changed, 1030 insertions(+), 3 deletions(-) create mode 100644 dune_client/api/datasets.py create mode 100644 dune_client/api/uploads.py create mode 100644 tests/e2e/test_datasets_integration.py create mode 100644 tests/e2e/test_uploads_integration.py create mode 100644 tests/unit/test_datasets_api.py create mode 100644 tests/unit/test_uploads_api.py diff --git a/dune_client/api/datasets.py b/dune_client/api/datasets.py new file mode 100644 index 0000000..16447d2 --- /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="/v1/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, slug: str) -> DatasetResponse: + """ + https://docs.dune.com/api-reference/datasets/endpoint/get + Retrieve detailed information about a specific dataset. + + Args: + slug: The dataset slug (e.g., 'dex.trades') + + Returns: + DatasetResponse with full dataset details including columns and metadata + """ + response_json = self._get(route=f"/v1/datasets/{slug}") + 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..032cbdc --- /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="/v1/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="/v1/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="/v1/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"/v1/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"/v1/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"/v1/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..dc20db5 100644 --- a/dune_client/models.py +++ b/dune_client/models.py @@ -522,3 +522,173 @@ 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""" + + id: int + handle: str + + +@dataclass +class DatasetColumn(DataClassJsonMixin): + """Column information for a dataset""" + + name: str + type: str + + +@dataclass +class Dataset(DataClassJsonMixin): + """Dataset information returned by list datasets endpoint""" + + slug: str + name: str + type: str + owner: DatasetOwner + namespace: 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/{slug}""" + + slug: str + name: str + type: str + owner: DatasetOwner + namespace: str + columns: list[DatasetColumn] + description: str | None + created_at: str + updated_at: str + is_private: bool + + +@dataclass +class TableOwner(DataClassJsonMixin): + """Owner information for an uploaded table""" + + id: int + handle: str + + +@dataclass +class TableColumn(DataClassJsonMixin): + """Column information for an uploaded table""" + + name: str + type: str + + +@dataclass +class TableElement(DataClassJsonMixin): + """Individual table metadata in list response""" + + namespace: str + table_name: str + full_name: str + example_query: str + description: str | None + is_private: bool + columns: list[TableColumn] + size_bytes: int + row_count: int + owner: TableOwner + created_at: str + updated_at: str + + +@dataclass +class UploadListResponse(DataClassJsonMixin): + """Response from GET /v1/uploads""" + + tables: list[TableElement] + next_offset: int | 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 + table_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..25f644f --- /dev/null +++ b/tests/e2e/test_datasets_integration.py @@ -0,0 +1,94 @@ +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) + + 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.slug) + self.assertIsNotNone(dataset.name) + self.assertIsNotNone(dataset.type) + self.assertIsNotNone(dataset.owner) + self.assertIsNotNone(dataset.namespace) + + 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 = self.dune.get_dataset("dex.trades") + + self.assertIsInstance(result, DatasetResponse) + self.assertEqual(result.slug, "dex.trades") + self.assertIsNotNone(result.name) + self.assertIsNotNone(result.type) + self.assertIsNotNone(result.owner) + self.assertIsNotNone(result.namespace) + 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) + + 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: + slug = result_list.datasets[0].slug + result = self.dune.get_dataset(slug) + + self.assertIsInstance(result, DatasetResponse) + self.assertEqual(result.slug, slug) + 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..77b7f67 --- /dev/null +++ b/tests/e2e/test_uploads_integration.py @@ -0,0 +1,122 @@ +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 = "test" + self.test_table_name = f"test_uploads_api_{int(__import__('time').time())}" + + def test_create_and_delete_table(self): + schema = [ + {"name": "id", "type": "int"}, + {"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=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": "int"}, + {"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..f8667d3 --- /dev/null +++ b/tests/unit/test_datasets_api.py @@ -0,0 +1,140 @@ +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": [ + { + "slug": "dex.trades", + "name": "DEX Trades", + "type": "transformation_view", + "owner": {"id": 1, "handle": "dune"}, + "namespace": "dex", + "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="/v1/datasets", + params={"limit": 50, "offset": 0}, + ) + self.assertIsInstance(result, DatasetListResponse) + self.assertEqual(len(result.datasets), 1) + self.assertEqual(result.datasets[0].slug, "dex.trades") + self.assertEqual(result.total, 1) + + def test_list_datasets_with_filters(self): + mock_response = { + "datasets": [ + { + "slug": "user.my_table", + "name": "My Table", + "type": "uploaded_table", + "owner": {"id": 123, "handle": "test_user"}, + "namespace": "user", + "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="/v1/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 = { + "slug": "dex.trades", + "name": "DEX Trades", + "type": "transformation_view", + "owner": {"id": 1, "handle": "dune"}, + "namespace": "dex", + "columns": [ + {"name": "block_time", "type": "timestamp"}, + {"name": "token_bought_address", "type": "varchar"}, + {"name": "token_sold_address", "type": "varchar"}, + {"name": "amount_usd", "type": "double"}, + ], + "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("dex.trades") + + self.api._get.assert_called_once_with(route="/v1/datasets/dex.trades") + self.assertIsInstance(result, DatasetResponse) + self.assertEqual(result.slug, "dex.trades") + self.assertEqual(result.name, "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.description, "All DEX trades across multiple chains") + + def test_get_dataset_no_description(self): + mock_response = { + "slug": "test.dataset", + "name": "Test Dataset", + "type": "uploaded_table", + "owner": {"id": 123, "handle": "test_user"}, + "namespace": "test", + "columns": [ + {"name": "id", "type": "int"}, + {"name": "value", "type": "varchar"}, + ], + "description": None, + "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("test.dataset") + + self.assertIsInstance(result, DatasetResponse) + self.assertIsNone(result.description) + 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..95e3035 --- /dev/null +++ b/tests/unit/test_uploads_api.py @@ -0,0 +1,175 @@ +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": [ + { + "namespace": "test_namespace", + "table_name": "test_table", + "full_name": "test_namespace.test_table", + "example_query": "SELECT * FROM test_namespace.test_table", + "description": "Test table", + "is_private": False, + "columns": [ + {"name": "col1", "type": "varchar"}, + {"name": "col2", "type": "int"}, + ], + "size_bytes": 1024, + "row_count": 100, + "owner": {"id": 1, "handle": "test_user"}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + } + ], + "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="/v1/uploads", + params={"limit": 50, "offset": 0}, + ) + self.assertIsInstance(result, UploadListResponse) + self.assertEqual(len(result.tables), 1) + self.assertEqual(result.tables[0].table_name, "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="/v1/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="/v1/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, + "table_name": "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="/v1/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.table_name, "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="/v1/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="/v1/uploads/test_namespace/test_table") + self.assertIsInstance(result, DeleteTableResponse) + self.assertEqual(result.message, "Table deleted successfully") + + +if __name__ == "__main__": + unittest.main() From af2e3bf9aba61baa7cca8e35ae9c976716204c7a Mon Sep 17 00:00:00 2001 From: va3093 Date: Fri, 14 Nov 2025 12:53:45 +0000 Subject: [PATCH 2/5] Fix API routes and response models for Uploads and Datasets APIs - Remove double /v1 prefix from all routes (api_version already includes it) - Update Dataset models to match actual API responses (full_name instead of slug/namespace) - Update Table models to match actual API responses (full_name format) - Change owner from id to handle+type fields - Add nullable field to column models - Change InsertDataResponse to use 'name' field instead of 'table_name' - Update datasets integration tests to use full_name and required filters - Update uploads integration tests to use DUNE_NAMESPACE env var and handle CSV upload naming - Fix column types in test schemas (int -> integer) --- dune_client/api/datasets.py | 8 +++---- dune_client/api/uploads.py | 12 +++++----- dune_client/models.py | 33 +++++++++++--------------- tests/e2e/test_datasets_integration.py | 25 ++++++++++--------- tests/e2e/test_uploads_integration.py | 9 +++---- 5 files changed, 43 insertions(+), 44 deletions(-) diff --git a/dune_client/api/datasets.py b/dune_client/api/datasets.py index 16447d2..b2f83f1 100644 --- a/dune_client/api/datasets.py +++ b/dune_client/api/datasets.py @@ -45,7 +45,7 @@ def list_datasets( params["type"] = type response_json = self._get( - route="/v1/datasets", + route="/datasets", params=params, ) try: @@ -53,18 +53,18 @@ def list_datasets( except KeyError as err: raise DuneError(response_json, "DatasetListResponse", err) from err - def get_dataset(self, slug: str) -> DatasetResponse: + 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: - slug: The dataset slug (e.g., 'dex.trades') + 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"/v1/datasets/{slug}") + response_json = self._get(route=f"/datasets/{full_name}") try: return DatasetResponse.from_dict(response_json) except KeyError as err: diff --git a/dune_client/api/uploads.py b/dune_client/api/uploads.py index 032cbdc..a7a3c88 100644 --- a/dune_client/api/uploads.py +++ b/dune_client/api/uploads.py @@ -42,7 +42,7 @@ def list_uploads( UploadListResponse with list of tables and pagination info """ response_json = self._get( - route="/v1/uploads", + route="/uploads", params={ "limit": limit, "offset": offset, @@ -78,7 +78,7 @@ def create_table( UploadCreateResponse with table details """ result_json = self._post( - route="/v1/uploads", + route="/uploads", params={ "namespace": namespace, "table_name": table_name, @@ -117,7 +117,7 @@ def upload_csv( CSVUploadResponse with the created table name """ response_json = self._post( - route="/v1/uploads/csv", + route="/uploads/csv", params={ "table_name": table_name, "data": data, @@ -155,7 +155,7 @@ def insert_data( InsertDataResponse with rows/bytes written """ result_json = self._post( - route=f"/v1/uploads/{namespace}/{table_name}/insert", + route=f"/uploads/{namespace}/{table_name}/insert", headers={"Content-Type": content_type}, data=data, ) @@ -180,7 +180,7 @@ def clear_table( Returns: ClearTableResponse with confirmation message """ - result_json = self._post(route=f"/v1/uploads/{namespace}/{table_name}/clear") + result_json = self._post(route=f"/uploads/{namespace}/{table_name}/clear") try: return ClearTableResponse.from_dict(result_json) except KeyError as err: @@ -202,7 +202,7 @@ def delete_table( Returns: DeleteTableResponse with confirmation message """ - response_json = self._delete(route=f"/v1/uploads/{namespace}/{table_name}") + response_json = self._delete(route=f"/uploads/{namespace}/{table_name}") try: return DeleteTableResponse.from_dict(response_json) except KeyError as err: diff --git a/dune_client/models.py b/dune_client/models.py index dc20db5..e267d6d 100644 --- a/dune_client/models.py +++ b/dune_client/models.py @@ -541,8 +541,8 @@ class DatasetType(Enum): class DatasetOwner(DataClassJsonMixin): """Owner information for a dataset""" - id: int handle: str + type: str @dataclass @@ -551,17 +551,18 @@ class DatasetColumn(DataClassJsonMixin): name: str type: str + nullable: bool @dataclass class Dataset(DataClassJsonMixin): """Dataset information returned by list datasets endpoint""" - slug: str - name: str + full_name: str type: str owner: DatasetOwner - namespace: str + columns: list[DatasetColumn] + metadata: dict[str, str] created_at: str updated_at: str is_private: bool @@ -577,15 +578,13 @@ class DatasetListResponse(DataClassJsonMixin): @dataclass class DatasetResponse(DataClassJsonMixin): - """Response from GET /v1/datasets/{slug}""" + """Response from GET /v1/datasets/{full_name}""" - slug: str - name: str + full_name: str type: str owner: DatasetOwner - namespace: str columns: list[DatasetColumn] - description: str | None + metadata: dict[str, str] created_at: str updated_at: str is_private: bool @@ -595,8 +594,8 @@ class DatasetResponse(DataClassJsonMixin): class TableOwner(DataClassJsonMixin): """Owner information for an uploaded table""" - id: int handle: str + type: str @dataclass @@ -605,24 +604,20 @@ class TableColumn(DataClassJsonMixin): name: str type: str + nullable: bool @dataclass class TableElement(DataClassJsonMixin): """Individual table metadata in list response""" - namespace: str - table_name: str full_name: str - example_query: str - description: str | None is_private: bool - columns: list[TableColumn] - size_bytes: int - row_count: int - owner: TableOwner + table_size_bytes: str created_at: str updated_at: str + owner: TableOwner + columns: list[TableColumn] @dataclass @@ -677,7 +672,7 @@ class InsertDataResponse(DataClassJsonMixin): rows_written: int bytes_written: int - table_name: str + name: str @dataclass diff --git a/tests/e2e/test_datasets_integration.py b/tests/e2e/test_datasets_integration.py index 25f644f..559e0b4 100644 --- a/tests/e2e/test_datasets_integration.py +++ b/tests/e2e/test_datasets_integration.py @@ -17,7 +17,7 @@ def setUp(self) -> None: self.dune = DuneClient() def test_list_datasets(self): - result = self.dune.list_datasets(limit=10, offset=0) + result = self.dune.list_datasets(limit=10, offset=0, type="uploaded_table") self.assertIsInstance(result, DatasetListResponse) self.assertIsInstance(result.datasets, list) @@ -26,11 +26,10 @@ def test_list_datasets(self): if len(result.datasets) > 0: dataset = result.datasets[0] - self.assertIsNotNone(dataset.slug) - self.assertIsNotNone(dataset.name) + self.assertIsNotNone(dataset.full_name) self.assertIsNotNone(dataset.type) self.assertIsNotNone(dataset.owner) - self.assertIsNotNone(dataset.namespace) + self.assertIsNotNone(dataset.columns) def test_list_datasets_with_filters(self): result = self.dune.list_datasets( @@ -59,14 +58,17 @@ def test_list_datasets_by_owner(self): self.assertEqual(dataset.owner.handle, "dune") def test_get_dataset(self): - result = self.dune.get_dataset("dex.trades") + 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.slug, "dex.trades") - self.assertIsNotNone(result.name) + self.assertEqual(result.full_name, full_name) self.assertIsNotNone(result.type) self.assertIsNotNone(result.owner) - self.assertIsNotNone(result.namespace) self.assertIsNotNone(result.columns) self.assertIsInstance(result.columns, list) self.assertGreater(len(result.columns), 0) @@ -74,6 +76,7 @@ def test_get_dataset(self): 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( @@ -82,11 +85,11 @@ def test_get_dataset_with_uploaded_table(self): ) if len(result_list.datasets) > 0: - slug = result_list.datasets[0].slug - result = self.dune.get_dataset(slug) + full_name = result_list.datasets[0].full_name + result = self.dune.get_dataset(full_name) self.assertIsInstance(result, DatasetResponse) - self.assertEqual(result.slug, slug) + self.assertEqual(result.full_name, full_name) self.assertEqual(result.type, "uploaded_table") diff --git a/tests/e2e/test_uploads_integration.py b/tests/e2e/test_uploads_integration.py index 77b7f67..6a7f8ec 100644 --- a/tests/e2e/test_uploads_integration.py +++ b/tests/e2e/test_uploads_integration.py @@ -1,3 +1,4 @@ +import os import unittest from io import BytesIO @@ -23,12 +24,12 @@ class TestUploadsIntegration(unittest.TestCase): def setUp(self) -> None: self.dune = DuneClient() - self.test_namespace = "test" + self.test_namespace = os.getenv("DUNE_NAMESPACE", "test") self.test_table_name = f"test_uploads_api_{int(__import__('time').time())}" def test_create_and_delete_table(self): schema = [ - {"name": "id", "type": "int"}, + {"name": "id", "type": "integer"}, {"name": "name", "type": "varchar"}, {"name": "value", "type": "double"}, ] @@ -70,7 +71,7 @@ def test_upload_csv_and_delete(self): delete_result = self.dune.delete_table( namespace=self.test_namespace, - table_name=self.test_table_name, + table_name=f"dataset_{self.test_table_name}", ) self.assertIsInstance(delete_result, DeleteTableResponse) @@ -82,7 +83,7 @@ def test_list_uploads(self): def test_full_table_lifecycle(self): schema = [ - {"name": "id", "type": "int"}, + {"name": "id", "type": "integer"}, {"name": "message", "type": "varchar"}, ] From 44ac8a5cc8c40180eccf4485b6bd4f19889ead86 Mon Sep 17 00:00:00 2001 From: va3093 Date: Fri, 14 Nov 2025 13:02:35 +0000 Subject: [PATCH 3/5] Update unit test mocks to match new API response structure - Change Dataset mocks to use full_name instead of slug/namespace - Update owner structure to use handle+type instead of id+handle - Add nullable field to all column mocks - Change metadata from description field to dict - Update TableElement mocks to use full_name format - Change InsertDataResponse mock to use 'name' instead of 'table_name' - Fix all route expectations to remove /v1 prefix (already in api_version) --- tests/unit/test_datasets_api.py | 65 ++++++++++++++++----------------- tests/unit/test_uploads_api.py | 37 ++++++++----------- 2 files changed, 47 insertions(+), 55 deletions(-) diff --git a/tests/unit/test_datasets_api.py b/tests/unit/test_datasets_api.py index f8667d3..468db99 100644 --- a/tests/unit/test_datasets_api.py +++ b/tests/unit/test_datasets_api.py @@ -17,11 +17,13 @@ def test_list_datasets_minimal(self): mock_response = { "datasets": [ { - "slug": "dex.trades", - "name": "DEX Trades", + "full_name": "dune.dex.trades", "type": "transformation_view", - "owner": {"id": 1, "handle": "dune"}, - "namespace": "dex", + "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, @@ -34,23 +36,23 @@ def test_list_datasets_minimal(self): result = self.api.list_datasets(limit=50, offset=0) self.api._get.assert_called_once_with( - route="/v1/datasets", + route="/datasets", params={"limit": 50, "offset": 0}, ) self.assertIsInstance(result, DatasetListResponse) self.assertEqual(len(result.datasets), 1) - self.assertEqual(result.datasets[0].slug, "dex.trades") + 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": [ { - "slug": "user.my_table", - "name": "My Table", + "full_name": "dune.user.my_table", "type": "uploaded_table", - "owner": {"id": 123, "handle": "test_user"}, - "namespace": "user", + "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, @@ -68,7 +70,7 @@ def test_list_datasets_with_filters(self): ) self.api._get.assert_called_once_with( - route="/v1/datasets", + route="/datasets", params={ "limit": 100, "offset": 10, @@ -82,57 +84,52 @@ def test_list_datasets_with_filters(self): def test_get_dataset(self): mock_response = { - "slug": "dex.trades", - "name": "DEX Trades", + "full_name": "dune.dex.trades", "type": "transformation_view", - "owner": {"id": 1, "handle": "dune"}, - "namespace": "dex", + "owner": {"handle": "dune", "type": "team"}, "columns": [ - {"name": "block_time", "type": "timestamp"}, - {"name": "token_bought_address", "type": "varchar"}, - {"name": "token_sold_address", "type": "varchar"}, - {"name": "amount_usd", "type": "double"}, + {"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}, ], - "description": "All DEX trades across multiple chains", + "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("dex.trades") + result = self.api.get_dataset("dune.dex.trades") - self.api._get.assert_called_once_with(route="/v1/datasets/dex.trades") + self.api._get.assert_called_once_with(route="/datasets/dune.dex.trades") self.assertIsInstance(result, DatasetResponse) - self.assertEqual(result.slug, "dex.trades") - self.assertEqual(result.name, "DEX Trades") + 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.description, "All DEX trades across multiple chains") + self.assertEqual(result.metadata.get("description"), "All DEX trades across multiple chains") def test_get_dataset_no_description(self): mock_response = { - "slug": "test.dataset", - "name": "Test Dataset", + "full_name": "dune.test.dataset", "type": "uploaded_table", - "owner": {"id": 123, "handle": "test_user"}, - "namespace": "test", + "owner": {"handle": "test_user", "type": "user"}, "columns": [ - {"name": "id", "type": "int"}, - {"name": "value", "type": "varchar"}, + {"name": "id", "type": "integer", "nullable": False}, + {"name": "value", "type": "varchar", "nullable": True}, ], - "description": None, + "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("test.dataset") + result = self.api.get_dataset("dune.test.dataset") self.assertIsInstance(result, DatasetResponse) - self.assertIsNone(result.description) + self.assertEqual(result.metadata, {}) self.assertTrue(result.is_private) diff --git a/tests/unit/test_uploads_api.py b/tests/unit/test_uploads_api.py index 95e3035..1cba18d 100644 --- a/tests/unit/test_uploads_api.py +++ b/tests/unit/test_uploads_api.py @@ -24,21 +24,16 @@ def test_list_uploads(self): mock_response = { "tables": [ { - "namespace": "test_namespace", - "table_name": "test_table", - "full_name": "test_namespace.test_table", - "example_query": "SELECT * FROM test_namespace.test_table", - "description": "Test table", + "full_name": "dune.test_namespace.test_table", "is_private": False, - "columns": [ - {"name": "col1", "type": "varchar"}, - {"name": "col2", "type": "int"}, - ], - "size_bytes": 1024, - "row_count": 100, - "owner": {"id": 1, "handle": "test_user"}, + "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, @@ -48,12 +43,12 @@ def test_list_uploads(self): result = self.api.list_uploads(limit=50, offset=0) self.api._get.assert_called_once_with( - route="/v1/uploads", + route="/uploads", params={"limit": 50, "offset": 0}, ) self.assertIsInstance(result, UploadListResponse) self.assertEqual(len(result.tables), 1) - self.assertEqual(result.tables[0].table_name, "test_table") + self.assertEqual(result.tables[0].full_name, "dune.test_namespace.test_table") self.assertEqual(result.next_offset, 50) def test_create_table(self): @@ -75,7 +70,7 @@ def test_create_table(self): ) self.api._post.assert_called_once_with( - route="/v1/uploads", + route="/uploads", params={ "namespace": "test_namespace", "table_name": "test_table", @@ -103,7 +98,7 @@ def test_upload_csv(self): ) self.api._post.assert_called_once_with( - route="/v1/uploads/csv", + route="/uploads/csv", params={ "table_name": "test_table", "data": csv_data, @@ -118,7 +113,7 @@ def test_insert_data(self): mock_response = { "rows_written": 100, "bytes_written": 2048, - "table_name": "test_table", + "name": "dune.test_namespace.test_table", } self.api._post.return_value = mock_response @@ -131,14 +126,14 @@ def test_insert_data(self): ) self.api._post.assert_called_once_with( - route="/v1/uploads/test_namespace/test_table/insert", + 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.table_name, "test_table") + self.assertEqual(result.name, "dune.test_namespace.test_table") def test_clear_table(self): mock_response = { @@ -151,7 +146,7 @@ def test_clear_table(self): table_name="test_table", ) - self.api._post.assert_called_once_with(route="/v1/uploads/test_namespace/test_table/clear") + 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") @@ -166,7 +161,7 @@ def test_delete_table(self): table_name="test_table", ) - self.api._delete.assert_called_once_with(route="/v1/uploads/test_namespace/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") From 1f6cb0a35cdf392afe0d2f44311ce8f9d84bb1b3 Mon Sep 17 00:00:00 2001 From: va3093 Date: Fri, 14 Nov 2025 13:09:28 +0000 Subject: [PATCH 4/5] Fix formatting in test_datasets_api.py --- .github/workflows/pull-request.yaml | 2 +- tests/e2e/test_uploads_integration.py | 2 +- tests/unit/test_datasets_api.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) 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/tests/e2e/test_uploads_integration.py b/tests/e2e/test_uploads_integration.py index 6a7f8ec..6bfc765 100644 --- a/tests/e2e/test_uploads_integration.py +++ b/tests/e2e/test_uploads_integration.py @@ -24,7 +24,7 @@ class TestUploadsIntegration(unittest.TestCase): def setUp(self) -> None: self.dune = DuneClient() - self.test_namespace = os.getenv("DUNE_NAMESPACE", "test") + 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): diff --git a/tests/unit/test_datasets_api.py b/tests/unit/test_datasets_api.py index 468db99..3987257 100644 --- a/tests/unit/test_datasets_api.py +++ b/tests/unit/test_datasets_api.py @@ -20,9 +20,7 @@ def test_list_datasets_minimal(self): "full_name": "dune.dex.trades", "type": "transformation_view", "owner": {"handle": "dune", "type": "team"}, - "columns": [ - {"name": "block_time", "type": "timestamp", "nullable": False} - ], + "columns": [{"name": "block_time", "type": "timestamp", "nullable": False}], "metadata": {}, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-02T00:00:00Z", @@ -108,7 +106,9 @@ def test_get_dataset(self): 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") + self.assertEqual( + result.metadata.get("description"), "All DEX trades across multiple chains" + ) def test_get_dataset_no_description(self): mock_response = { From 121b6876dd514e70e3eb4ca7ab5728b54594a168 Mon Sep 17 00:00:00 2001 From: va3093 Date: Fri, 14 Nov 2025 15:18:53 +0000 Subject: [PATCH 5/5] Make next_offset optional in UploadListResponse --- dune_client/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dune_client/models.py b/dune_client/models.py index e267d6d..3e2a0b7 100644 --- a/dune_client/models.py +++ b/dune_client/models.py @@ -625,7 +625,7 @@ class UploadListResponse(DataClassJsonMixin): """Response from GET /v1/uploads""" tables: list[TableElement] - next_offset: int | None + next_offset: int | None = None @dataclass