diff --git a/tabcmd/commands/site/list_command.py b/tabcmd/commands/site/list_command.py index 66c5aac0..ef07a940 100644 --- a/tabcmd/commands/site/list_command.py +++ b/tabcmd/commands/site/list_command.py @@ -19,7 +19,17 @@ def define_args(list_parser): args_group.add_argument( "content", choices=["projects", "workbooks", "datasources", "flows"], help=_("tabcmd.options.select_type") ) - args_group.add_argument("-d", "--details", action="store_true", help=_("tabcmd.options.include_details")) + + format_group = list_parser.add_mutually_exclusive_group() + # TODO: should this be saved directly to csv? + format_group.add_argument("--machine", action="store_true", help=_("tabcmd.listing.help.machine")) + + data_group = list_parser.add_argument_group(title=_("tabcmd.listing.group.attributes")) + # data_group.add_argument("-i", "--id", action="store_true", help="Show item id") # default true + data_group.add_argument("-n", "--name", action="store_true", help=_("tabcmd.listing.help.name")) # default true + data_group.add_argument("-o", "--owner", action="store_true", help=_("tabcmd.listing.help.owner")) + data_group.add_argument("-d", "--details", action="store_true", help=_("tabcmd.listing.help.details")) + data_group.add_argument("-a", "--address", action="store_true", help=_("tabcmd.listing.help.address")) @staticmethod def run_command(args): @@ -43,16 +53,61 @@ def run_command(args): if not items or len(items) == 0: logger.info(_("tabcmd.listing.none")) + exit(0) + + logger.info(ListCommand.show_header(args, content_type)) for item in items: - if args.details: - logger.info("\t{}".format(item)) - if content_type == "workbooks": - server.workbooks.populate_views(item) - for v in item.views: - logger.info(v) + if args.machine: + id = item.id + name = ", " + item.name if args.name else "" + owner = ", " + item.owner_id if args.owner else "" + url = "" + if args.address and content_type in ["workbooks", "datasources"]: + url = ", " + item.content_url + children = ( + ", " + ListCommand.format_children_listing(args, server, content_type, item) + if args.details + else "" + ) + else: - logger.info(_("tabcmd.listing.label.id").format(item.id)) - logger.info(_("tabcmd.listing.label.name").format(item.name)) + id = _("tabcmd.listing.label.id").format(item.id) + name = ", " + _("tabcmd.listing.label.name").format(item.name) if args.name else "" + owner = ", " + _("tabcmd.listing.label.owner").format(item.owner_id) if args.owner else "" + + url = "" + if args.address and content_type in ["workbooks", "datasources"]: + url = ", " + item.content_url + children = ( + ListCommand.format_children_listing(args, server, content_type, item) if args.details else "" + ) + + logger.info("{0}{1}{2}{3}{4}".format(id, name, owner, url, children)) + # TODO: do we want this line if it is csv output? + logger.info(_("tabcmd.listing.summary").format(len(items), content_type)) except Exception as e: Errors.exit_with_error(logger, e) + + @staticmethod + def format_children_listing(args, server, content_type, item): + if args.details: + if content_type == "workbooks": + server.workbooks.populate_views(item) + child_items = item.views[:10] + children = ", " + _("tabcmd.listing.label.views") + ", ".join(map(lambda x: x.name, child_items)) + return children + return "" + + @staticmethod + def show_header(args, content_type): + id = _("tabcmd.listing.header.id") + name = ", " + _("tabcmd.listing.header.name") if args.name else "" + owner = ", " + _("tabcmd.listing.header.owner") if args.owner else "" + url = ( + ", " + _("tabcmd.listing.header.url") + if args.address and content_type in ["workbooks", "datasources"] + else "" + ) + children = ", " + _("tabcmd.listing.header.children") if args.details and content_type == "workbooks" else "" + return "{0}{1}{2}{3}{4}".format(id, name, owner, url, children) diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index c91d8f49..2fe0f59e 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -377,3 +377,18 @@ tabcmdparser.help.description=Show message listing commands and global options, version.description=Print version information +tabcmd.listing.group.attributes=Attributes to include +tabcmd.listing.header.children=CHILDREN +tabcmd.listing.header.id=ID +tabcmd.listing.header.name=NAME +tabcmd.listing.header.owner=OWNER +tabcmd.listing.header.url=URL +tabcmd.listing.help.address=Show web address of the item +tabcmd.listing.help.details=Show children of the item +tabcmd.listing.help.machine=Format output as csv for machine reading +tabcmd.listing.help.name=Show item name +tabcmd.listing.help.owner=Show item owner +tabcmd.listing.label.owner=\tOWNER: {} +tabcmd.listing.label.views=VIEWS: +tabcmd.listing.summary={0} total {1} + diff --git a/tests/commands/test_listing_commands.py b/tests/commands/test_listing_commands.py index 5d45590f..766e12fb 100644 --- a/tests/commands/test_listing_commands.py +++ b/tests/commands/test_listing_commands.py @@ -1,8 +1,11 @@ import argparse -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +import io +import sys from tabcmd.commands.site.list_command import ListCommand from tabcmd.commands.site.list_sites_command import ListSiteCommand +from tabcmd.execution.localize import set_client_locale import unittest from unittest import mock @@ -13,37 +16,65 @@ fake_item.name = "fake-name" fake_item.id = "fake-id" fake_item.extract_encryption_mode = "ENFORCED" +fake_item.owner_id = "fake-owner" +fake_item.content_url = "fake-url" + +fake_view = mock.MagicMock() +fake_view.name = "fake-view" getter = MagicMock() getter.get = MagicMock("get", return_value=([fake_item], 1)) - -mock_args = argparse.Namespace() -mock_args.logging_level = "INFO" +getter.all = MagicMock("all", return_value=[fake_item]) @mock.patch("tabcmd.commands.auth.session.Session.create_session") @mock.patch("tableauserverclient.Server") class ListingTests(unittest.TestCase): + @staticmethod + def _set_up_session(mock_session, mock_server): + mock_session.return_value = mock_server + assert mock_session is not None + mock_session.assert_not_called() + global mock_args + mock_args = argparse.Namespace(logging_level="DEBUG") + # set values for things that should always have a default + # should refactor so this can be automated + mock_args.continue_if_exists = False + mock_args.project_name = None + mock_args.parent_project_path = None + mock_args.parent_path = None + mock_args.timeout = None + mock_args.username = None + mock_args.name = True + mock_args.owner = None + mock_args.address = None + mock_args.machine = False + mock_args.get_extract_encryption_mode = False + mock_args.details = False + def test_list_sites(self, mock_server, mock_session): + ListingTests._set_up_session(mock_session, mock_server) mock_server.sites = getter - mock_args.get_extract_encryption_mode = False - mock_session.return_value = mock_server out_value = ListSiteCommand.run_command(mock_args) def test_list_content(self, mock_server, mock_session): + ListingTests._set_up_session(mock_session, mock_server) mock_server.flows = getter mock_args.content = "flows" - mock_session.return_value = mock_server out_value = ListCommand.run_command(mock_args) def test_list_wb_details(self, mock_server, mock_session): + ListingTests._set_up_session(mock_session, mock_server) mock_server.workbooks = getter + mock_server.workbooks.populate_views = MagicMock() + fake_item.views = [fake_view] mock_args.content = "workbooks" mock_session.return_value = mock_server mock_args.details = True out_value = ListCommand.run_command(mock_args) def test_list_datasources(self, mock_server, mock_session): + ListingTests._set_up_session(mock_session, mock_server) mock_server.datasources = getter mock_args.content = "datasources" mock_session.return_value = mock_server @@ -51,8 +82,160 @@ def test_list_datasources(self, mock_server, mock_session): out_value = ListCommand.run_command(mock_args) def test_list_projects(self, mock_server, mock_session): + ListingTests._set_up_session(mock_session, mock_server) mock_server.projects = getter mock_args.content = "projects" mock_session.return_value = mock_server mock_args.details = True out_value = ListCommand.run_command(mock_args) + + +class ListCommandFunctionalTests(unittest.TestCase): + """Test that ListCommand properly uses localized strings in different scenarios""" + + @patch("tabcmd.commands.site.list_command._") + @patch("tabcmd.commands.auth.session.Session") + @patch("tabcmd.execution.logger_config.log") + def test_show_header_with_all_options(self, mock_log, mock_session, mock_translate): + """Test header generation with all display options enabled""" + # Mock the translation function to return the actual English strings + def translate_side_effect(key): + translations = { + "tabcmd.listing.header.id": "ID", + "tabcmd.listing.header.name": "NAME", + "tabcmd.listing.header.owner": "OWNER", + "tabcmd.listing.header.url": "URL", + "tabcmd.listing.header.children": "CHILDREN", + } + return translations.get(key, key) + + mock_translate.side_effect = translate_side_effect + + mock_args = argparse.Namespace(name=True, owner=True, address=True, details=True) + + # Test workbooks (should include all headers) + header = ListCommand.show_header(mock_args, "workbooks") + self.assertIn("ID", header) + self.assertIn("NAME", header) + self.assertIn("OWNER", header) + self.assertIn("URL", header) + self.assertIn("CHILDREN", header) + + # Test datasources (should include URL but not CHILDREN) + header = ListCommand.show_header(mock_args, "datasources") + self.assertIn("ID", header) + self.assertIn("NAME", header) + self.assertIn("OWNER", header) + self.assertIn("URL", header) + self.assertNotIn("CHILDREN", header) + + # Test projects (should not include URL or CHILDREN) + header = ListCommand.show_header(mock_args, "projects") + self.assertIn("ID", header) + self.assertIn("NAME", header) + self.assertIn("OWNER", header) + self.assertNotIn("URL", header) + self.assertNotIn("CHILDREN", header) + + @patch("tabcmd.commands.site.list_command._") + @patch("tabcmd.commands.auth.session.Session") + @patch("tabcmd.execution.logger_config.log") + def test_show_header_minimal_options(self, mock_log, mock_session, mock_translate): + """Test header generation with minimal options""" + # Mock the translation function + mock_translate.return_value = "ID" + + mock_args = argparse.Namespace(name=False, owner=False, address=False, details=False) + + header = ListCommand.show_header(mock_args, "workbooks") + self.assertEqual(header, "ID") + + @patch("tabcmd.commands.site.list_command._") + @patch("tableauserverclient.Server") + def test_format_children_listing_workbooks(self, mock_server, mock_translate): + """Test children listing format for workbooks""" + # Mock the translation function + mock_translate.return_value = "VIEWS: [" + + mock_args = argparse.Namespace(details=True) + + # Mock workbook item with views - create proper mock objects with string names + view1 = MagicMock() + view1.name = "View1" + view2 = MagicMock() + view2.name = "View2" + + mock_item = MagicMock() + mock_item.views = [view1, view2] + + # Mock server populate_views method + mock_server.workbooks.populate_views = MagicMock() + + result = ListCommand.format_children_listing(mock_args, mock_server, "workbooks", mock_item) + + self.assertIn("VIEWS: ", result) + self.assertIn("View1", result) + self.assertIn("View2", result) + mock_server.workbooks.populate_views.assert_called_once_with(mock_item) + + @patch("tableauserverclient.Server") + def test_format_children_listing_non_workbooks(self, mock_server): + """Test children listing returns empty for non-workbook content types""" + mock_args = argparse.Namespace(details=True) + mock_item = MagicMock() + + result = ListCommand.format_children_listing(mock_args, mock_server, "datasources", mock_item) + self.assertEqual(result, "") + + result = ListCommand.format_children_listing(mock_args, mock_server, "projects", mock_item) + self.assertEqual(result, "") + + @patch("tableauserverclient.Server") + def test_format_children_listing_no_details(self, mock_server): + """Test children listing returns empty when details=False""" + mock_args = argparse.Namespace(details=False) + mock_item = MagicMock() + + result = ListCommand.format_children_listing(mock_args, mock_server, "workbooks", mock_item) + self.assertEqual(result, "") + + +class LocalizedStringKeysTests(unittest.TestCase): + """Test that ListCommand calls the correct localization keys""" + + @patch("tabcmd.commands.site.list_command._") + def test_show_header_datasources_with_url(self, mock_translate): + """Test that datasources headers include URL when address=True""" + mock_translate.return_value = "TRANSLATED" + + mock_args = argparse.Namespace( + name=True, owner=True, address=True, details=True # With address=True, datasources should show URL + ) + + ListCommand.show_header(mock_args, "datasources") + + # Verify that URL key IS called for datasources (but not CHILDREN) + expected_calls = [ + mock.call("tabcmd.listing.header.id"), + mock.call("tabcmd.listing.header.name"), + mock.call("tabcmd.listing.header.owner"), + mock.call("tabcmd.listing.header.url"), # Should be called for datasources too + # Note: tabcmd.listing.header.children should NOT be called (only for workbooks) + ] + mock_translate.assert_has_calls(expected_calls, any_order=True) + + # Verify CHILDREN key was not called (only for workbooks) + all_calls = [call[0][0] for call in mock_translate.call_args_list] + self.assertNotIn("tabcmd.listing.header.children", all_calls) + + def test_show_header_structure_without_mocking(self): + """Test the basic structure of show_header without mocking translations""" + mock_args = argparse.Namespace(name=False, owner=False, address=False, details=False) + + # This should return just the ID header (even if it's the localization key) + header = ListCommand.show_header(mock_args, "workbooks") + + # The result should be a single string (not contain commas when no options are set) + self.assertNotIn(",", header) + self.assertTrue(isinstance(header, str)) + self.assertGreater(len(header), 0) diff --git a/tests/commands/test_publish_command.py b/tests/commands/test_publish_command.py index ab37646a..7be2c79d 100644 --- a/tests/commands/test_publish_command.py +++ b/tests/commands/test_publish_command.py @@ -5,7 +5,6 @@ from tabcmd.commands.datasources_and_workbooks import publish_command - from typing import List, NamedTuple, TextIO, Union import io diff --git a/tests/commands/test_run_commands.py b/tests/commands/test_run_commands.py index 2a6ff820..2359aab3 100644 --- a/tests/commands/test_run_commands.py +++ b/tests/commands/test_run_commands.py @@ -52,6 +52,7 @@ creator = MagicMock() getter = MagicMock() getter.get = MagicMock("get", return_value=([fake_item], fake_item_pagination)) +getter.all = MagicMock("all", return_value=[fake_item]) getter.publish = MagicMock("publish", return_value=fake_item) getter.create_extract = MagicMock("create_extract", return_value=fake_job) getter.decrypt_extract = MagicMock("decrypt_extract", return_value=fake_job) @@ -430,11 +431,21 @@ def test_create_user(self, mock_session, mock_server): mock_session.assert_called() def test_list_content(self, mock_session, mock_server): + RunCommandsTest._set_up_session(mock_session, mock_server) + mock_server.workbooks = getter + mock_server.projects = getter + mock_server.datasources = getter + mock_server.flows = getter + mock_args.name = False + mock_args.owner = None + mock_args.address = None + mock_args.machine = False + mock_args.get_extract_encryption_mode = False + mock_args.details = False mock_args.content = "workbooks" list_command.ListCommand.run_command(mock_args) mock_args.content = "projects" list_command.ListCommand.run_command(mock_args) mock_args.content = "flows" list_command.ListCommand.run_command(mock_args) - # todo: details, filters diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 3267efe2..401f16ae 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -489,21 +489,6 @@ def test_timeout_as_integer_stored_char(self): assert result == 0 -class TimeoutIntegrationTest(unittest.TestCase): - def test_connection_times_out(self): - test_args = Namespace(**vars(args_to_mock)) - new_session = Session() - test_args.timeout = 2 - test_args.username = "u" - test_args.password = "p" - - test_args.server = "https://nothere.com" - with self.assertRaises(SystemExit): - new_session.create_session(test_args, None) - - # should test connection doesn't time out? - - class ConnectionOptionsTest(unittest.TestCase): def test_user_agent(self): mock_session = Session() @@ -549,6 +534,21 @@ def test_timeout(self): """ + +This is too slow for unit tests. +class TimeoutIntegrationTest(unittest.TestCase): + def test_connection_times_out(self): + test_args = Namespace(**vars(args_to_mock)) + new_session = Session() + test_args.timeout = 2 + test_args.username = "u" + test_args.password = "p" + + test_args.server = "https://nothere.com" + with self.assertRaises(SystemExit): + new_session.create_session(test_args, None) + + class CookieTests(unittest.TestCase): def test_no_file_if_no_cookie(self):