diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py index 71e902b4..607c3682 100644 --- a/tabcmd/commands/datasources_and_workbooks/export_command.py +++ b/tabcmd/commands/datasources_and_workbooks/export_command.py @@ -68,6 +68,7 @@ def define_args(export_parser): group.add_argument( "--filter", metavar="COLUMN=VALUE", + action="append", help=_("tabcmd.export.help.filter"), ) group.add_argument( @@ -143,11 +144,19 @@ def run_command(cls, args): @staticmethod def apply_filters_from_args(request_options: RequestOptionsType, args, logger=None) -> None: - if args.filter: - logger.debug("filter = {}".format(args.filter)) - params = args.filter.split("&") - for value in params: - ExportCommand.apply_filter_value(logger, request_options, value) + if not args.filter: + return + logger.debug("filter = {}".format(args.filter)) + # Back-compat: a single --filter flag historically joined multiple pairs + # with '&' (e.g. `--filter "a=1&b=2"`), so that one case still splits on '&'. + # Repeated --filter flags each carry exactly one pair — no split — so a + # literal '&' or '=' in a value is passed through untouched. + if len(args.filter) == 1: + values = args.filter[0].split("&") + else: + values = args.filter + for value in values: + ExportCommand.apply_filter_value(logger, request_options, value) @staticmethod def download_wb_pdf(server, workbook_item, args, logger): diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index 7eda4bff..d4f7e238 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -155,7 +155,7 @@ tabcmd.error.server_connection_failed=Failed to connect to server tabcmd.errors.parent.not.found=Containing project could not be found tabcmd.errors.user_already_exists=A user with the name {0} already exists. Try a different name. tabcmd.export.help.filename=Filename to store the exported data -tabcmd.export.help.filter=Data filter to apply to the view +tabcmd.export.help.filter=Data filter to apply to the view (COLUMN=VALUE). Repeat the flag for multiple filters (--filter "A=1" --filter "B=2"); repeated flags allow '&' inside a value. For back-compat, a single --filter may still join pairs with '&' (--filter "A=1&B=2"). tabcmd.export.help.orientation=Page orientation (landscape or portrait) of the exported PDF tabcmd.export.help.page_size=Set the page size of the exported PDF tabcmd.export.help.url=URL of the workbook or view to export diff --git a/tests/commands/test_datasources_and_workbooks_command.py b/tests/commands/test_datasources_and_workbooks_command.py index 8fa60dde..4afe2497 100644 --- a/tests/commands/test_datasources_and_workbooks_command.py +++ b/tests/commands/test_datasources_and_workbooks_command.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock from tabcmd.commands.datasources_and_workbooks.datasources_and_workbooks_command import DatasourcesAndWorkbooks +from tabcmd.commands.datasources_and_workbooks.export_command import ExportCommand import tableauserverclient as tsc import unittest from unittest import mock @@ -189,6 +190,32 @@ def test_apply_csv_options_with_language(self): DatasourcesAndWorkbooks.apply_csv_options(mock_logger, request_options, mock_args) assert request_options.language == "de" + def test_apply_filters_from_args_none(self): + args = argparse.Namespace(filter=None) + request_options = tsc.PDFRequestOptions() + ExportCommand.apply_filters_from_args(request_options, args, mock_logger) + assert request_options.view_filters == [] + + def test_apply_filters_from_args_single(self): + args = argparse.Namespace(filter=["Region=West"]) + request_options = tsc.PDFRequestOptions() + ExportCommand.apply_filters_from_args(request_options, args, mock_logger) + assert request_options.view_filters == [("Region", "West")] + + def test_apply_filters_from_args_repeated(self): + # repeated --filter flags each carry one pair, so '&' in a value is safe. + args = argparse.Namespace(filter=["Region=West", "Product=AT&T"]) + request_options = tsc.PDFRequestOptions() + ExportCommand.apply_filters_from_args(request_options, args, mock_logger) + assert request_options.view_filters == [("Region", "West"), ("Product", "AT&T")] + + def test_apply_filters_from_args_backcompat_ampersand_join(self): + # Old-style single flag joining pairs with '&' still parses. + args = argparse.Namespace(filter=["Region=West&Product=Widget"]) + request_options = tsc.PDFRequestOptions() + ExportCommand.apply_filters_from_args(request_options, args, mock_logger) + assert request_options.view_filters == [("Region", "West"), ("Product", "Widget")] + @mock.patch("tableauserverclient.Server") class MockedServerTests(unittest.TestCase): diff --git a/tests/parsers/test_parser_export.py b/tests/parsers/test_parser_export.py index 585f500c..5ac8463f 100644 --- a/tests/parsers/test_parser_export.py +++ b/tests/parsers/test_parser_export.py @@ -21,3 +21,17 @@ def test_export_parser_missing_all_args(self): mock_args = [commandname] with self.assertRaises(SystemExit): args = self.parser_under_test.parse_args(mock_args) + + def test_export_parser_single_filter(self): + args = self.parser_under_test.parse_args([commandname, "helloworld", "--pdf", "--filter", "Region=West"]) + assert args.filter == ["Region=West"] + + def test_export_parser_repeated_filter(self): + args = self.parser_under_test.parse_args( + [commandname, "helloworld", "--pdf", "--filter", "Region=West", "--filter", "Product=AT&T"] + ) + assert args.filter == ["Region=West", "Product=AT&T"] + + def test_export_parser_no_filter_defaults_to_none(self): + args = self.parser_under_test.parse_args([commandname, "helloworld", "--pdf"]) + assert args.filter is None