diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 4d4604461..658736a3d 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -42,7 +42,7 @@ def __init__(self, name, description=None, content_permissions=None): self._data_quality_warnings = None - self._tables = None # Not implemented yet + self._tables = None def __str__(self): return f"" @@ -177,9 +177,11 @@ def contact_id(self, value): @property def tables(self): if self._tables is None: - error = "Database must be populated with tables first." + error = ( + "Database tables are not available: the REST API cannot list the tables of a database. " + "Use the Metadata API to find them." + ) raise UnpopulatedPropertyError(error) - # Each call to `.tables` should create a new pager, this just runs the callable return self._tables() def _set_values(self, database_values): @@ -246,9 +248,6 @@ def _set_values(self, database_values): def _set_permissions(self, permissions): self._permissions = permissions - def _set_tables(self, tables): - self._tables = tables - def _set_default_permissions(self, permissions, content_type): attr = f"_default_{content_type}_permissions" setattr( diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index caf9a985f..d063231a2 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,4 +1,5 @@ import logging +import warnings from typing import TYPE_CHECKING from collections.abc import Iterable @@ -8,9 +9,9 @@ from tableauserverclient.server.endpoint.endpoint import api, Endpoint from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin, UPDATE_TAGS_UNSUPPORTED_MESSAGE from tableauserverclient.server import RequestFactory -from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource +from tableauserverclient.models import DatabaseItem, PaginationItem, Resource from tableauserverclient.helpers.logging import logger @@ -118,7 +119,7 @@ def delete(self, database_id: str) -> None: @api(version="3.5") def update(self, database_item: DatabaseItem) -> DatabaseItem: """ - Update the database description, certify the database, set permissions, + Update the database description, certify the database, set content permissions, or assign a User as the database contact. REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_database @@ -149,24 +150,26 @@ def update(self, database_item: DatabaseItem) -> DatabaseItem: updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database - # Not Implemented Yet - @api(version="99") - def populate_tables(self, database_item): - if not database_item.id: - error = "database item missing ID. database must be retrieved from server first." - raise MissingRequiredFieldError(error) - - def column_fetcher(): - return self._get_tables_for_database(database_item) + def populate_tables(self, database_item: DatabaseItem) -> None: + """ + Not supported. The REST API has no endpoint that lists the tables of a + database. - database_item._set_tables(column_fetcher) - logger.info(f"Populated tables for database (ID: {database_item.id}") + To find the tables in a database, use the Metadata API with + ``server.metadata.query`` and a query such as + ``databases(filter: {luid: $luid}) { tables { luid name } }``, then + retrieve each table with ``server.tables.get_by_id``. - def _get_tables_for_database(self, database_item): - url = f"{self.baseurl}/{database_item.id}/tables" - server_response = self.get_request(url) - tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) - return tables + Raises + ------ + NotImplementedError + Always. + """ + raise NotImplementedError( + "The REST API has no endpoint to list the tables of a database. Use the Metadata API " + "(server.metadata.query with databases(filter: {luid: $luid}) { tables { luid name } }) " + "to find the table LUIDs, then call server.tables.get_by_id." + ) @api(version="3.5") def populate_permissions(self, item: DatabaseItem) -> None: @@ -191,7 +194,32 @@ def update_table_default_permissions( return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="3.5") - def delete_table_default_permissions(self, rule: PermissionsRule, item: DatabaseItem) -> None: + def delete_table_default_permissions(self, item: DatabaseItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default table permission rule from the database. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : DatabaseItem + The database item to delete default table permissions from. + + rule : PermissionsRule + The permissions rule to delete from the database. + + Returns + ------- + None + """ + if isinstance(item, PermissionsRule) and isinstance(rule, DatabaseItem): + warnings.warn( + "delete_table_default_permissions(rule, item) is deprecated; pass (item, rule) like the other " + "permissions methods.", + DeprecationWarning, + stacklevel=3, + ) + item, rule = rule, item self._default_permissions.delete_default_permission(item, rule, Resource.Table) @api(version="3.5") @@ -219,5 +247,5 @@ def delete_tags(self, item: DatabaseItem | str, tags: Iterable[str]) -> None: super().delete_tags(item, tags) @api(version="3.9") - def update_tags(self, item: DatabaseItem) -> None: - raise NotImplementedError("Update tags is not supported for databases.") + def update_tags(self, item: DatabaseItem) -> None: # type: ignore[override] + raise NotImplementedError(UPDATE_TAGS_UNSUPPORTED_MESSAGE.format(kind="databases")) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 72c7274c8..471619eef 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -12,6 +12,11 @@ from tableauserverclient.helpers.logging import logger +UPDATE_TAGS_UNSUPPORTED_MESSAGE = ( + "update_tags is not supported for {kind}: the item does not track its tags. " + "Use add_tags and delete_tags with the item or its ID instead." +) + if TYPE_CHECKING: from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.database_item import DatabaseItem diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index f50f0df0c..ee7137288 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.server.endpoint.endpoint import api, Endpoint from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin, UPDATE_TAGS_UNSUPPORTED_MESSAGE from tableauserverclient.server import RequestFactory from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from tableauserverclient.server.pager import Pager @@ -259,5 +259,6 @@ def add_tags(self, item: TableItem | str, tags: Iterable[str] | str) -> set[str] def delete_tags(self, item: TableItem | str, tags: Iterable[str] | str) -> None: return super().delete_tags(item, tags) - def update_tags(self, item: TableItem) -> None: # type: ignore - raise NotImplementedError("Update tags is not implemented for TableItem") + @api(version="3.9") + def update_tags(self, item: TableItem) -> None: # type: ignore[override] + raise NotImplementedError(UPDATE_TAGS_UNSUPPORTED_MESSAGE.format(kind="tables")) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 517a26956..e63dc45b6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -179,14 +179,18 @@ def update_req(self, database_item): contact_element = ET.SubElement(database_element, "contact") contact_element.attrib["id"] = database_item.contact_id - database_element.attrib["isCertified"] = str(database_item.certified).lower() + if database_item.certified is not None: + database_element.attrib["isCertified"] = str(database_item.certified).lower() - if database_item.certification_note: + if database_item.certification_note is not None: database_element.attrib["certificationNote"] = str(database_item.certification_note) - if database_item.description: + if database_item.description is not None: database_element.attrib["description"] = str(database_item.description) + if database_item.content_permissions is not None: + database_element.attrib["contentPermissions"] = str(database_item.content_permissions) + return ET.tostring(xml_request) @@ -945,12 +949,13 @@ def update_req(self, table_item): contact_element = ET.SubElement(table_element, "contact") contact_element.attrib["id"] = table_item.contact_id - table_element.attrib["isCertified"] = str(table_item.certified).lower() + if table_item.certified is not None: + table_element.attrib["isCertified"] = str(table_item.certified).lower() - if table_item.certification_note: + if table_item.certification_note is not None: table_element.attrib["certificationNote"] = str(table_item.certification_note) - if table_item.description: + if table_item.description is not None: table_element.attrib["description"] = str(table_item.description) return ET.tostring(xml_request) diff --git a/test/test_database.py b/test/test_database.py index 951eebe00..7bf47c36d 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -1,9 +1,12 @@ +import warnings from pathlib import Path +from xml.etree import ElementTree as ET import pytest import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.request_factory import RequestFactory TEST_ASSET_DIR = Path(__file__).parent / "assets" @@ -110,3 +113,88 @@ def test_delete(server): with requests_mock.mock() as m: m.delete(server.databases.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) server.databases.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") + + +def test_populate_tables_not_supported(server): + database = TSC.DatabaseItem("test") + database._id = "23591f2c-4802-4d6a-9e28-574a8ea9bc4c" + with pytest.raises(NotImplementedError, match="Metadata API"): + server.databases.populate_tables(database) + + +def _delete_table_default_permission_setup(server, m): + database = TSC.DatabaseItem("test") + database._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + rule = TSC.PermissionsRule( + grantee=TSC.GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af"), + capabilities={TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow}, + ) + url = ( + f"{server.databases.baseurl}/{database.id}/default-permissions/tables/groups/" + "5e5e1978-71fa-11e4-87dd-7382f5c437af/Read/Allow" + ) + m.delete(url, status_code=204) + return database, rule + + +def test_delete_table_default_permissions(server): + with requests_mock.mock() as m, warnings.catch_warnings(): + warnings.simplefilter("error") + database, rule = _delete_table_default_permission_setup(server, m) + server.databases.delete_table_default_permissions(database, rule) + assert m.call_count == 1 + + +def test_delete_table_default_permissions_swapped_arguments_warns(server): + with requests_mock.mock() as m: + database, rule = _delete_table_default_permission_setup(server, m) + with pytest.warns(DeprecationWarning, match="deprecated") as record: + server.databases.delete_table_default_permissions(rule, database) + assert m.call_count == 1 + assert record[0].filename == __file__ + + +def test_update_req_omits_unset_fields() -> None: + item = TSC.DatabaseItem("test") + xml = ET.fromstring(RequestFactory.Database.update_req(item)) + assert xml.findall("database")[0].attrib == {} + + +def test_update_req_sends_set_fields() -> None: + item = TSC.DatabaseItem("test") + item.certified = False + item.certification_note = "note" + item.description = "desc" + attrib = ET.fromstring(RequestFactory.Database.update_req(item)).findall("database")[0].attrib + assert attrib["isCertified"] == "false" + assert attrib["certificationNote"] == "note" + assert attrib["description"] == "desc" + + +def test_update_req_empty_strings_clear_fields() -> None: + item = TSC.DatabaseItem("test") + item.certification_note = "" + item.description = "" + attrib = ET.fromstring(RequestFactory.Database.update_req(item)).findall("database")[0].attrib + assert attrib == {"certificationNote": "", "description": ""} + + +def test_update_req_sends_content_permissions() -> None: + item = TSC.DatabaseItem("test", content_permissions=TSC.DatabaseItem.ContentPermissions.ManagedByOwner) + attrib = ET.fromstring(RequestFactory.Database.update_req(item)).findall("database")[0].attrib + assert attrib == {"contentPermissions": "ManagedByOwner"} + + +def test_update_tags_not_supported(server) -> None: + server.version = "3.9" + item = TSC.DatabaseItem("test") + item._id = "10224773-ecee-42ac-b822-d786b0b8e4d9" + with pytest.raises(NotImplementedError, match="add_tags and delete_tags"): + server.databases.update_tags(item) + + +def test_update_tags_checks_version(server) -> None: + server.version = "3.5" + item = TSC.DatabaseItem("test") + with pytest.raises(TSC.server.exceptions.EndpointUnavailableError): + server.databases.update_tags(item) diff --git a/test/test_table.py b/test/test_table.py index 2f3c3c8d6..cbd5573a1 100644 --- a/test/test_table.py +++ b/test/test_table.py @@ -1,9 +1,11 @@ from pathlib import Path +from xml.etree import ElementTree as ET import pytest import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.request_factory import RequestFactory TEST_ASSET_DIR = Path(__file__).parent / "assets" @@ -63,3 +65,43 @@ def test_delete(server: TSC.Server) -> None: with requests_mock.mock() as m: m.delete(server.tables.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) server.tables.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") + + +def test_update_req_omits_unset_fields() -> None: + item = TSC.TableItem("test") + xml = ET.fromstring(RequestFactory.Table.update_req(item)) + assert xml.findall("table")[0].attrib == {} + + +def test_update_req_sends_set_fields() -> None: + item = TSC.TableItem("test") + item.certified = False + item.certification_note = "note" + item.description = "desc" + attrib = ET.fromstring(RequestFactory.Table.update_req(item)).findall("table")[0].attrib + assert attrib["isCertified"] == "false" + assert attrib["certificationNote"] == "note" + assert attrib["description"] == "desc" + + +def test_update_req_empty_strings_clear_fields() -> None: + item = TSC.TableItem("test") + item.certification_note = "" + item.description = "" + attrib = ET.fromstring(RequestFactory.Table.update_req(item)).findall("table")[0].attrib + assert attrib == {"certificationNote": "", "description": ""} + + +def test_update_tags_not_supported(server) -> None: + server.version = "3.9" + item = TSC.TableItem("test") + item._id = "10224773-ecee-42ac-b822-d786b0b8e4d9" + with pytest.raises(NotImplementedError, match="add_tags and delete_tags"): + server.tables.update_tags(item) + + +def test_update_tags_checks_version(server) -> None: + server.version = "3.5" + item = TSC.TableItem("test") + with pytest.raises(TSC.server.exceptions.EndpointUnavailableError): + server.tables.update_tags(item)