Skip to content

Commit 30b05c7

Browse files
jacalataCopilotbcantoni
authored
list content with attributes (#332)
* Add detail to list output * Extract hardcoded strings from list command * Added some unit tests for coverage --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Brian Cantoni <bcantoni@salesforce.com>
1 parent 1a0d872 commit 30b05c7

6 files changed

Lines changed: 296 additions & 33 deletions

File tree

tabcmd/commands/site/list_command.py

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,17 @@ def define_args(list_parser):
1919
args_group.add_argument(
2020
"content", choices=["projects", "workbooks", "datasources", "flows"], help=_("tabcmd.options.select_type")
2121
)
22-
args_group.add_argument("-d", "--details", action="store_true", help=_("tabcmd.options.include_details"))
22+
23+
format_group = list_parser.add_mutually_exclusive_group()
24+
# TODO: should this be saved directly to csv?
25+
format_group.add_argument("--machine", action="store_true", help=_("tabcmd.listing.help.machine"))
26+
27+
data_group = list_parser.add_argument_group(title=_("tabcmd.listing.group.attributes"))
28+
# data_group.add_argument("-i", "--id", action="store_true", help="Show item id") # default true
29+
data_group.add_argument("-n", "--name", action="store_true", help=_("tabcmd.listing.help.name")) # default true
30+
data_group.add_argument("-o", "--owner", action="store_true", help=_("tabcmd.listing.help.owner"))
31+
data_group.add_argument("-d", "--details", action="store_true", help=_("tabcmd.listing.help.details"))
32+
data_group.add_argument("-a", "--address", action="store_true", help=_("tabcmd.listing.help.address"))
2333

2434
@staticmethod
2535
def run_command(args):
@@ -43,16 +53,61 @@ def run_command(args):
4353

4454
if not items or len(items) == 0:
4555
logger.info(_("tabcmd.listing.none"))
56+
exit(0)
57+
58+
logger.info(ListCommand.show_header(args, content_type))
4659
for item in items:
47-
if args.details:
48-
logger.info("\t{}".format(item))
49-
if content_type == "workbooks":
50-
server.workbooks.populate_views(item)
51-
for v in item.views:
52-
logger.info(v)
60+
if args.machine:
61+
id = item.id
62+
name = ", " + item.name if args.name else ""
63+
owner = ", " + item.owner_id if args.owner else ""
64+
url = ""
65+
if args.address and content_type in ["workbooks", "datasources"]:
66+
url = ", " + item.content_url
67+
children = (
68+
", " + ListCommand.format_children_listing(args, server, content_type, item)
69+
if args.details
70+
else ""
71+
)
72+
5373
else:
54-
logger.info(_("tabcmd.listing.label.id").format(item.id))
55-
logger.info(_("tabcmd.listing.label.name").format(item.name))
74+
id = _("tabcmd.listing.label.id").format(item.id)
75+
name = ", " + _("tabcmd.listing.label.name").format(item.name) if args.name else ""
76+
owner = ", " + _("tabcmd.listing.label.owner").format(item.owner_id) if args.owner else ""
77+
78+
url = ""
79+
if args.address and content_type in ["workbooks", "datasources"]:
80+
url = ", " + item.content_url
81+
children = (
82+
ListCommand.format_children_listing(args, server, content_type, item) if args.details else ""
83+
)
84+
85+
logger.info("{0}{1}{2}{3}{4}".format(id, name, owner, url, children))
5686

87+
# TODO: do we want this line if it is csv output?
88+
logger.info(_("tabcmd.listing.summary").format(len(items), content_type))
5789
except Exception as e:
5890
Errors.exit_with_error(logger, e)
91+
92+
@staticmethod
93+
def format_children_listing(args, server, content_type, item):
94+
if args.details:
95+
if content_type == "workbooks":
96+
server.workbooks.populate_views(item)
97+
child_items = item.views[:10]
98+
children = ", " + _("tabcmd.listing.label.views") + ", ".join(map(lambda x: x.name, child_items))
99+
return children
100+
return ""
101+
102+
@staticmethod
103+
def show_header(args, content_type):
104+
id = _("tabcmd.listing.header.id")
105+
name = ", " + _("tabcmd.listing.header.name") if args.name else ""
106+
owner = ", " + _("tabcmd.listing.header.owner") if args.owner else ""
107+
url = (
108+
", " + _("tabcmd.listing.header.url")
109+
if args.address and content_type in ["workbooks", "datasources"]
110+
else ""
111+
)
112+
children = ", " + _("tabcmd.listing.header.children") if args.details and content_type == "workbooks" else ""
113+
return "{0}{1}{2}{3}{4}".format(id, name, owner, url, children)

tabcmd/locales/en/tabcmd_messages_en.properties

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,18 @@ tabcmdparser.help.description=Show message listing commands and global options,
377377

378378
version.description=Print version information
379379

380+
tabcmd.listing.group.attributes=Attributes to include
381+
tabcmd.listing.header.children=CHILDREN
382+
tabcmd.listing.header.id=ID
383+
tabcmd.listing.header.name=NAME
384+
tabcmd.listing.header.owner=OWNER
385+
tabcmd.listing.header.url=URL
386+
tabcmd.listing.help.address=Show web address of the item
387+
tabcmd.listing.help.details=Show children of the item
388+
tabcmd.listing.help.machine=Format output as csv for machine reading
389+
tabcmd.listing.help.name=Show item name
390+
tabcmd.listing.help.owner=Show item owner
391+
tabcmd.listing.label.owner=\tOWNER: {}
392+
tabcmd.listing.label.views=VIEWS:
393+
tabcmd.listing.summary={0} total {1}
394+
Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import argparse
2-
from unittest.mock import MagicMock
2+
from unittest.mock import MagicMock, patch
3+
import io
4+
import sys
35

46
from tabcmd.commands.site.list_command import ListCommand
57
from tabcmd.commands.site.list_sites_command import ListSiteCommand
8+
from tabcmd.execution.localize import set_client_locale
69

710
import unittest
811
from unittest import mock
@@ -13,46 +16,226 @@
1316
fake_item.name = "fake-name"
1417
fake_item.id = "fake-id"
1518
fake_item.extract_encryption_mode = "ENFORCED"
19+
fake_item.owner_id = "fake-owner"
20+
fake_item.content_url = "fake-url"
21+
22+
fake_view = mock.MagicMock()
23+
fake_view.name = "fake-view"
1624

1725
getter = MagicMock()
1826
getter.get = MagicMock("get", return_value=([fake_item], 1))
19-
20-
mock_args = argparse.Namespace()
21-
mock_args.logging_level = "INFO"
27+
getter.all = MagicMock("all", return_value=[fake_item])
2228

2329

2430
@mock.patch("tabcmd.commands.auth.session.Session.create_session")
2531
@mock.patch("tableauserverclient.Server")
2632
class ListingTests(unittest.TestCase):
33+
@staticmethod
34+
def _set_up_session(mock_session, mock_server):
35+
mock_session.return_value = mock_server
36+
assert mock_session is not None
37+
mock_session.assert_not_called()
38+
global mock_args
39+
mock_args = argparse.Namespace(logging_level="DEBUG")
40+
# set values for things that should always have a default
41+
# should refactor so this can be automated
42+
mock_args.continue_if_exists = False
43+
mock_args.project_name = None
44+
mock_args.parent_project_path = None
45+
mock_args.parent_path = None
46+
mock_args.timeout = None
47+
mock_args.username = None
48+
mock_args.name = True
49+
mock_args.owner = None
50+
mock_args.address = None
51+
mock_args.machine = False
52+
mock_args.get_extract_encryption_mode = False
53+
mock_args.details = False
54+
2755
def test_list_sites(self, mock_server, mock_session):
56+
ListingTests._set_up_session(mock_session, mock_server)
2857
mock_server.sites = getter
29-
mock_args.get_extract_encryption_mode = False
30-
mock_session.return_value = mock_server
3158
out_value = ListSiteCommand.run_command(mock_args)
3259

3360
def test_list_content(self, mock_server, mock_session):
61+
ListingTests._set_up_session(mock_session, mock_server)
3462
mock_server.flows = getter
3563
mock_args.content = "flows"
36-
mock_session.return_value = mock_server
3764
out_value = ListCommand.run_command(mock_args)
3865

3966
def test_list_wb_details(self, mock_server, mock_session):
67+
ListingTests._set_up_session(mock_session, mock_server)
4068
mock_server.workbooks = getter
69+
mock_server.workbooks.populate_views = MagicMock()
70+
fake_item.views = [fake_view]
4171
mock_args.content = "workbooks"
4272
mock_session.return_value = mock_server
4373
mock_args.details = True
4474
out_value = ListCommand.run_command(mock_args)
4575

4676
def test_list_datasources(self, mock_server, mock_session):
77+
ListingTests._set_up_session(mock_session, mock_server)
4778
mock_server.datasources = getter
4879
mock_args.content = "datasources"
4980
mock_session.return_value = mock_server
5081
mock_args.details = True
5182
out_value = ListCommand.run_command(mock_args)
5283

5384
def test_list_projects(self, mock_server, mock_session):
85+
ListingTests._set_up_session(mock_session, mock_server)
5486
mock_server.projects = getter
5587
mock_args.content = "projects"
5688
mock_session.return_value = mock_server
5789
mock_args.details = True
5890
out_value = ListCommand.run_command(mock_args)
91+
92+
93+
class ListCommandFunctionalTests(unittest.TestCase):
94+
"""Test that ListCommand properly uses localized strings in different scenarios"""
95+
96+
@patch("tabcmd.commands.site.list_command._")
97+
@patch("tabcmd.commands.auth.session.Session")
98+
@patch("tabcmd.execution.logger_config.log")
99+
def test_show_header_with_all_options(self, mock_log, mock_session, mock_translate):
100+
"""Test header generation with all display options enabled"""
101+
# Mock the translation function to return the actual English strings
102+
def translate_side_effect(key):
103+
translations = {
104+
"tabcmd.listing.header.id": "ID",
105+
"tabcmd.listing.header.name": "NAME",
106+
"tabcmd.listing.header.owner": "OWNER",
107+
"tabcmd.listing.header.url": "URL",
108+
"tabcmd.listing.header.children": "CHILDREN",
109+
}
110+
return translations.get(key, key)
111+
112+
mock_translate.side_effect = translate_side_effect
113+
114+
mock_args = argparse.Namespace(name=True, owner=True, address=True, details=True)
115+
116+
# Test workbooks (should include all headers)
117+
header = ListCommand.show_header(mock_args, "workbooks")
118+
self.assertIn("ID", header)
119+
self.assertIn("NAME", header)
120+
self.assertIn("OWNER", header)
121+
self.assertIn("URL", header)
122+
self.assertIn("CHILDREN", header)
123+
124+
# Test datasources (should include URL but not CHILDREN)
125+
header = ListCommand.show_header(mock_args, "datasources")
126+
self.assertIn("ID", header)
127+
self.assertIn("NAME", header)
128+
self.assertIn("OWNER", header)
129+
self.assertIn("URL", header)
130+
self.assertNotIn("CHILDREN", header)
131+
132+
# Test projects (should not include URL or CHILDREN)
133+
header = ListCommand.show_header(mock_args, "projects")
134+
self.assertIn("ID", header)
135+
self.assertIn("NAME", header)
136+
self.assertIn("OWNER", header)
137+
self.assertNotIn("URL", header)
138+
self.assertNotIn("CHILDREN", header)
139+
140+
@patch("tabcmd.commands.site.list_command._")
141+
@patch("tabcmd.commands.auth.session.Session")
142+
@patch("tabcmd.execution.logger_config.log")
143+
def test_show_header_minimal_options(self, mock_log, mock_session, mock_translate):
144+
"""Test header generation with minimal options"""
145+
# Mock the translation function
146+
mock_translate.return_value = "ID"
147+
148+
mock_args = argparse.Namespace(name=False, owner=False, address=False, details=False)
149+
150+
header = ListCommand.show_header(mock_args, "workbooks")
151+
self.assertEqual(header, "ID")
152+
153+
@patch("tabcmd.commands.site.list_command._")
154+
@patch("tableauserverclient.Server")
155+
def test_format_children_listing_workbooks(self, mock_server, mock_translate):
156+
"""Test children listing format for workbooks"""
157+
# Mock the translation function
158+
mock_translate.return_value = "VIEWS: ["
159+
160+
mock_args = argparse.Namespace(details=True)
161+
162+
# Mock workbook item with views - create proper mock objects with string names
163+
view1 = MagicMock()
164+
view1.name = "View1"
165+
view2 = MagicMock()
166+
view2.name = "View2"
167+
168+
mock_item = MagicMock()
169+
mock_item.views = [view1, view2]
170+
171+
# Mock server populate_views method
172+
mock_server.workbooks.populate_views = MagicMock()
173+
174+
result = ListCommand.format_children_listing(mock_args, mock_server, "workbooks", mock_item)
175+
176+
self.assertIn("VIEWS: ", result)
177+
self.assertIn("View1", result)
178+
self.assertIn("View2", result)
179+
mock_server.workbooks.populate_views.assert_called_once_with(mock_item)
180+
181+
@patch("tableauserverclient.Server")
182+
def test_format_children_listing_non_workbooks(self, mock_server):
183+
"""Test children listing returns empty for non-workbook content types"""
184+
mock_args = argparse.Namespace(details=True)
185+
mock_item = MagicMock()
186+
187+
result = ListCommand.format_children_listing(mock_args, mock_server, "datasources", mock_item)
188+
self.assertEqual(result, "")
189+
190+
result = ListCommand.format_children_listing(mock_args, mock_server, "projects", mock_item)
191+
self.assertEqual(result, "")
192+
193+
@patch("tableauserverclient.Server")
194+
def test_format_children_listing_no_details(self, mock_server):
195+
"""Test children listing returns empty when details=False"""
196+
mock_args = argparse.Namespace(details=False)
197+
mock_item = MagicMock()
198+
199+
result = ListCommand.format_children_listing(mock_args, mock_server, "workbooks", mock_item)
200+
self.assertEqual(result, "")
201+
202+
203+
class LocalizedStringKeysTests(unittest.TestCase):
204+
"""Test that ListCommand calls the correct localization keys"""
205+
206+
@patch("tabcmd.commands.site.list_command._")
207+
def test_show_header_datasources_with_url(self, mock_translate):
208+
"""Test that datasources headers include URL when address=True"""
209+
mock_translate.return_value = "TRANSLATED"
210+
211+
mock_args = argparse.Namespace(
212+
name=True, owner=True, address=True, details=True # With address=True, datasources should show URL
213+
)
214+
215+
ListCommand.show_header(mock_args, "datasources")
216+
217+
# Verify that URL key IS called for datasources (but not CHILDREN)
218+
expected_calls = [
219+
mock.call("tabcmd.listing.header.id"),
220+
mock.call("tabcmd.listing.header.name"),
221+
mock.call("tabcmd.listing.header.owner"),
222+
mock.call("tabcmd.listing.header.url"), # Should be called for datasources too
223+
# Note: tabcmd.listing.header.children should NOT be called (only for workbooks)
224+
]
225+
mock_translate.assert_has_calls(expected_calls, any_order=True)
226+
227+
# Verify CHILDREN key was not called (only for workbooks)
228+
all_calls = [call[0][0] for call in mock_translate.call_args_list]
229+
self.assertNotIn("tabcmd.listing.header.children", all_calls)
230+
231+
def test_show_header_structure_without_mocking(self):
232+
"""Test the basic structure of show_header without mocking translations"""
233+
mock_args = argparse.Namespace(name=False, owner=False, address=False, details=False)
234+
235+
# This should return just the ID header (even if it's the localization key)
236+
header = ListCommand.show_header(mock_args, "workbooks")
237+
238+
# The result should be a single string (not contain commas when no options are set)
239+
self.assertNotIn(",", header)
240+
self.assertTrue(isinstance(header, str))
241+
self.assertGreater(len(header), 0)

tests/commands/test_publish_command.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from tabcmd.commands.datasources_and_workbooks import publish_command
77

8-
98
from typing import List, NamedTuple, TextIO, Union
109
import io
1110

tests/commands/test_run_commands.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
creator = MagicMock()
5353
getter = MagicMock()
5454
getter.get = MagicMock("get", return_value=([fake_item], fake_item_pagination))
55+
getter.all = MagicMock("all", return_value=[fake_item])
5556
getter.publish = MagicMock("publish", return_value=fake_item)
5657
getter.create_extract = MagicMock("create_extract", return_value=fake_job)
5758
getter.decrypt_extract = MagicMock("decrypt_extract", return_value=fake_job)
@@ -430,11 +431,21 @@ def test_create_user(self, mock_session, mock_server):
430431
mock_session.assert_called()
431432

432433
def test_list_content(self, mock_session, mock_server):
434+
433435
RunCommandsTest._set_up_session(mock_session, mock_server)
436+
mock_server.workbooks = getter
437+
mock_server.projects = getter
438+
mock_server.datasources = getter
439+
mock_server.flows = getter
440+
mock_args.name = False
441+
mock_args.owner = None
442+
mock_args.address = None
443+
mock_args.machine = False
444+
mock_args.get_extract_encryption_mode = False
445+
mock_args.details = False
434446
mock_args.content = "workbooks"
435447
list_command.ListCommand.run_command(mock_args)
436448
mock_args.content = "projects"
437449
list_command.ListCommand.run_command(mock_args)
438450
mock_args.content = "flows"
439451
list_command.ListCommand.run_command(mock_args)
440-
# todo: details, filters

0 commit comments

Comments
 (0)