From 32d385707f3448830b74cbf0b0ee0033290a4e0a Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 18:44:57 -0700 Subject: [PATCH 1/7] fix: correct output messages in listsites, deletegroup, export, createsiteusers, removeusers - listsites: show site content_url instead of internal UUID for SITEID - deletegroup: log group name instead of UUID in delete status message - export/save_to_file: pass filename as content name to fix double-space in "Saved to '...'" message - createsiteusers: append str(e) to error_list instead of e.__class__.__name__ so error details show readable messages - removeusers: use new tabcmd.removeusers.group string key that includes the group name in the status message Fixes #429 Co-Authored-By: Claude Sonnet 4.6 --- .../datasources_and_workbooks_command.py | 4 ++-- tabcmd/commands/group/delete_group_command.py | 2 +- tabcmd/commands/site/list_sites_command.py | 2 +- tabcmd/commands/user/create_site_users.py | 2 +- tabcmd/commands/user/remove_users_command.py | 2 +- tabcmd/locales/en/tabcmd_messages_en.properties | 1 + 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py index ae577bfa..f7fd8b3c 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py @@ -183,14 +183,14 @@ def save_to_data_file(logger, output, filename): logger.info(_("httputils.found_attachment").format(filename)) with open(filename, "wb") as f: f.writelines(output) - logger.info(_("export.success").format("", filename)) + logger.info(_("export.success").format(filename, filename)) @staticmethod def save_to_file(logger, output, filename): logger.info(_("httputils.found_attachment").format(filename)) with open(filename, "wb") as f: f.write(output) - logger.info(_("export.success").format("", filename)) + logger.info(_("export.success").format(filename, filename)) @staticmethod def get_custom_view_by_id(logger, server, custom_view_id) -> TSC.CustomViewItem: diff --git a/tabcmd/commands/group/delete_group_command.py b/tabcmd/commands/group/delete_group_command.py index f7c15abc..fe1ac821 100644 --- a/tabcmd/commands/group/delete_group_command.py +++ b/tabcmd/commands/group/delete_group_command.py @@ -29,7 +29,7 @@ def run_command(cls, args): try: logger.info(_("tabcmd.find.group").format(args.name)) group_id = Server.find_group(logger, server, args.name).id - logger.info(_("deletegroup.status").format(group_id)) + logger.info(_("deletegroup.status").format(args.name)) server.groups.delete(group_id) logger.info(_("common.output.succeeded")) except Exception as e: diff --git a/tabcmd/commands/site/list_sites_command.py b/tabcmd/commands/site/list_sites_command.py index 6f204483..ac80680d 100644 --- a/tabcmd/commands/site/list_sites_command.py +++ b/tabcmd/commands/site/list_sites_command.py @@ -31,7 +31,7 @@ def run_command(cls, args): sites, pagination = server.sites.get() logger.info(_("listsites.status").format(session.username)) for site in sites: - logger.info(_("listsites.output").format(" ", site.name, site.id)) + logger.info(_("listsites.output").format(" ", site.name, site.content_url)) if args.get_extract_encryption_mode: logger.info("EXTRACTENCRYPTION: {}".format(site.extract_encryption_mode)) except Exception as e: diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index c63436b0..ac84af87 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -62,7 +62,7 @@ def run_command(cls, args): logger.debug(type(e)) number_of_errors += 1 logger.debug(number_of_errors) - error_list.append(e.__class__.__name__) # + ": " + e.__cause__ or "Unknown") + error_list.append(str(e)) logger.debug(error_list) logger.info(_("session.monitorjob.percent_complete").format(100)) logger.info(_("importcsvsummary.line.processed").format(number_of_users_listed)) diff --git a/tabcmd/commands/user/remove_users_command.py b/tabcmd/commands/user/remove_users_command.py index 51005534..aaf3471f 100644 --- a/tabcmd/commands/user/remove_users_command.py +++ b/tabcmd/commands/user/remove_users_command.py @@ -27,6 +27,6 @@ def run_command(cls, args): session = Session() server = session.create_session(args, logger) - logger.info(_("tabcmd.removeusers.server").format(args.users.name, args.name)) + logger.info(_("tabcmd.removeusers.group").format(args.users.name, args.name)) UserCommand.act_on_users(logger, server, "removed", server.groups.remove_user, args) diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index 7eda4bff..d2ad3c30 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -204,6 +204,7 @@ tabcmd.publish.options.tabbed.detailed=Publish with tabbed views enabled. Each s tabcmd.refresh.options.bridge=Refresh datasource through Tableau Bridge tabcmd.removeusers.help.group_name=The group to remove users from tabcmd.removeusers.server=Removing users listed in {0} from the server... +tabcmd.removeusers.group=Removing users listed in {0} from the group ''{1}''... tabcmd.report.error.user_csv.at_char=If a user name includes an @ character that represents anything other than a domain separator, you need to refer to the symbol using the hexadecimal format: \\0x40 tabcmd.report.error.user_csv.too_many_columns=The file contains {0} columns, but there are only {1} valid columns in a user import csv file tabcmd.report.error.user.no_spaces_in_username=Username cannot contain spaces From 189980633edebf1aa6864c0a048063b1adb7c380 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:15:33 -0700 Subject: [PATCH 2/7] fix: add ===== prefix to all INFO output, fix delete path, suppress debug noise - logger_config.py: add ===== prefix to INFO format so all action lines match tabcmd 1 style in one place - session.py: remove hardcoded ===== from _print_server_info lines (now added by formatter); demote "Fetched user details from server" to debug - constants.py: demote tabcmd.debug.error_message log call from info to debug so it no longer appears in normal output - delete_command.py: default content_type to "workbook" in status message to fix double-space when type is undetected - tabcmd_messages_en.properties: remove hardcoded ===== from tabcmd.listing.header (would double-prefix with formatter) Closes remaining items from #429 Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 12 ++++++------ tabcmd/commands/constants.py | 2 +- .../datasources_and_workbooks/delete_command.py | 2 +- tabcmd/execution/logger_config.py | 2 +- tabcmd/locales/en/tabcmd_messages_en.properties | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index 606d9bac..a3a86b9a 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -262,15 +262,15 @@ def _read_existing_state(self): self._read_from_json() def _print_server_info(self): - self.logger.info("===== Server: {}".format(self.server_url)) + self.logger.info(" Server: {}".format(self.server_url)) if self.proxy: - self.logger.info("===== Proxy: {}".format(self.proxy)) + self.logger.info(" Proxy: {}".format(self.proxy)) if self.username: - self.logger.info("===== Username: {}".format(self.username)) + self.logger.info(" Username: {}".format(self.username)) if self.certificate: - self.logger.info("===== Certificate: {}".format(self.certificate)) + self.logger.info(" Certificate: {}".format(self.certificate)) else: - self.logger.info("===== Token Name: {}".format(self.token_name)) + self.logger.info(" Token Name: {}".format(self.token_name)) site_display_name = self.site_name or "Default Site" self.logger.info(_("dataconnections.classes.tableau_server_site") + ": {}".format(site_display_name)) @@ -281,7 +281,7 @@ def _validate_existing_signin(self): if self.tableau_server and self.tableau_server.is_signed_in() and self.user_id: server_user = self.tableau_server.users.get_by_id(self.user_id).name if not self.username: - self.logger.info("Fetched user details from server") + self.logger.debug("Fetched user details from server") self.username = server_user return self.tableau_server diff --git a/tabcmd/commands/constants.py b/tabcmd/commands/constants.py index b728c976..102b8eef 100644 --- a/tabcmd/commands/constants.py +++ b/tabcmd/commands/constants.py @@ -65,7 +65,7 @@ def exit_with_error(logger, message: Optional[str] = None, exception: Optional[E Errors.log_stack(logger) elif exception: if message: - logger.info(_("tabcmd.debug.error_message") + message) + logger.debug(_("tabcmd.debug.error_message") + message) Errors.check_common_error_codes_and_explain(logger, exception) else: logger.info(_("tabcmd.debug.no_exception_or_message")) diff --git a/tabcmd/commands/datasources_and_workbooks/delete_command.py b/tabcmd/commands/datasources_and_workbooks/delete_command.py index 71085c74..3d372a05 100644 --- a/tabcmd/commands/datasources_and_workbooks/delete_command.py +++ b/tabcmd/commands/datasources_and_workbooks/delete_command.py @@ -47,7 +47,7 @@ def run_command(cls, args): else: Errors.exit_with_error(logger, _("tabcmd.errors.parent.not.found")) - logger.info(_("delete.status").format(content_type, item_name or args.name)) + logger.info(_("delete.status").format(content_type or "workbook", item_name or args.name)) error = None if args.workbook or not content_type: diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index 72216fa7..c9dfbfc5 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -10,7 +10,7 @@ FORMATS = { logging.ERROR: "%(asctime)s %(levelname)-5s:(%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", logging.WARN: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", - logging.INFO: "%(message)-30s", + logging.INFO: "===== %(message)-30s", logging.DEBUG: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", } diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index d2ad3c30..947bfe6e 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -166,7 +166,7 @@ tabcmd.global.help.page_size=Specify the page size for query results tabcmd.global.help.skip_connection_check=Skip connection check: do not validate the connection during publishing tabcmd.howto=Run a specific command tabcmd.launching=Launching tabcmd -tabcmd.listing.header====== Listing {0} content for user {1}... +tabcmd.listing.header= Listing {0} content for user {1}... tabcmd.listing.label.id=ID: {} tabcmd.listing.label.name=\tNAME: {} tabcmd.listing.none=No content found From 494c015f171f2231c2d1ba72f32f3609efa6ceaf Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:16:19 -0700 Subject: [PATCH 3/7] fix: print version/help output directly instead of via logger.info Avoids the ===== prefix being applied to the version/help display. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/parent_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabcmd/execution/parent_parser.py b/tabcmd/execution/parent_parser.py index 2a4183e7..ad3173f5 100644 --- a/tabcmd/execution/parent_parser.py +++ b/tabcmd/execution/parent_parser.py @@ -198,5 +198,5 @@ def __init__(self, _parser: ParentParser): def run_command(self, args): logger = log(__name__, "info") - logger.info(f"{_('tabcmd.name')} {version}\n") - logger.info(self.parser.root.format_help()) + print(f"{_('tabcmd.name')} {version}\n") + print(self.parser.root.format_help()) From 6d5ff6c339bfd2c234d2b113cb34327dae8210c1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:18:54 -0700 Subject: [PATCH 4/7] feat: add TABCMD_CLASSIC_OUTPUT env var to enable ===== prefix on output Plain output is the default. Set TABCMD_CLASSIC_OUTPUT=true (or 1/yes) to restore the ===== prefix on all INFO-level messages for backward compatibility with tabcmd 1 output style. Also moves the version banner in tabcmd_controller to print() so it is never prefixed regardless of mode. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/logger_config.py | 5 ++++- tabcmd/execution/tabcmd_controller.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index c9dfbfc5..64823770 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -7,10 +7,13 @@ path = os.path.dirname(os.path.abspath(__file__)) +_CLASSIC_OUTPUT = os.environ.get("TABCMD_CLASSIC_OUTPUT", "").lower() in ("1", "true", "yes") +_INFO_FORMAT = "===== %(message)-30s" if _CLASSIC_OUTPUT else "%(message)-30s" + FORMATS = { logging.ERROR: "%(asctime)s %(levelname)-5s:(%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", logging.WARN: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", - logging.INFO: "===== %(message)-30s", + logging.INFO: _INFO_FORMAT, logging.DEBUG: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", } diff --git a/tabcmd/execution/tabcmd_controller.py b/tabcmd/execution/tabcmd_controller.py index 3b544d2c..d728ddb8 100644 --- a/tabcmd/execution/tabcmd_controller.py +++ b/tabcmd/execution/tabcmd_controller.py @@ -36,7 +36,7 @@ def run(parser, user_input=None): print("logging:", namespace.logging_level) logger = log(__name__, namespace.logging_level or logging.INFO) - logger.info("Tabcmd {}".format(version)) + print("Tabcmd {}".format(version)) if hasattr(namespace, "password") or hasattr(namespace, "token_value"): # don't print whole namespace because it has secrets logger.debug(namespace.func) From 0d6dc0a619a8ed09fef908073ebc48e12c620378 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:22:10 -0700 Subject: [PATCH 5/7] fix: remove spurious 'logging: INFO' printed on every command The condition compared logging_level string 'INFO' to the integer logging.INFO (20), which was always True, causing the print to fire on every invocation. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/tabcmd_controller.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tabcmd/execution/tabcmd_controller.py b/tabcmd/execution/tabcmd_controller.py index d728ddb8..4a25243c 100644 --- a/tabcmd/execution/tabcmd_controller.py +++ b/tabcmd/execution/tabcmd_controller.py @@ -32,10 +32,7 @@ def run(parser, user_input=None): parser.print_help() sys.exit(0) - if hasattr(namespace, "logging_level") and namespace.logging_level != logging.INFO: - print("logging:", namespace.logging_level) - - logger = log(__name__, namespace.logging_level or logging.INFO) + logger = log(__name__, namespace.logging_level or "INFO") print("Tabcmd {}".format(version)) if hasattr(namespace, "password") or hasattr(namespace, "token_value"): # don't print whole namespace because it has secrets From ee42aa903bc72a3b6ba37c4539075381e04a62f9 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:23:55 -0700 Subject: [PATCH 6/7] fix: add 'Signing in...' message to match tabcmd 1 login output session.login string already existed; add logger.info call at the start of _sign_in() to surface it as a distinct step after connecting. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index a3a86b9a..9965a523 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -296,6 +296,7 @@ def _sign_in(self, tableau_auth) -> TSC.Server: if not self.tableau_server: Errors.exit_with_error(self.logger, "No server connection available for sign in") + self.logger.info(_("session.login")) self.logger.debug(_("session.login") + (self.server_url or "")) self.logger.debug(_("listsites.output").format("", self.username or self.token_name, self.site_name)) assert self.tableau_server is not None # Type hint for mypy From 1da5af286e7c4515752218fa4c6424b785d23411 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sat, 8 Aug 2026 01:19:44 -0700 Subject: [PATCH 7/7] Thread content name through save_to_file, default ===== prefix on Two follow-up fixes for parity with tabcmd Classic output: - save_to_file/save_to_data_file previously passed filename twice into the "Saved {0} to '{1}'" template, so both slots rendered the same path. Classic output was "Saved to ''" where the two are distinct. Add a content_name parameter and thread export_item.name / get_url_item.name from all four callers. - TABCMD_CLASSIC_OUTPUT default flipped to true. The stated goal of this PR is parity with tabcmd 1 output, and issue #429 item 8 lists the missing "=====" prefix as a bug. Users who prefer the plainer tabcmd 2 style can opt out with TABCMD_CLASSIC_OUTPUT=false. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../datasources_and_workbooks_command.py | 12 +++++--- .../export_command.py | 28 +++++++++++-------- .../get_url_command.py | 8 ++++-- tabcmd/execution/logger_config.py | 5 +++- tests/commands/test_geturl_utils.py | 25 +++++++++++++++++ 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py index f7fd8b3c..b7b1f563 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py @@ -179,18 +179,22 @@ def apply_csv_options(logger, request_options: TSC.CSVRequestOptions, args): request_options.language = args.language @staticmethod - def save_to_data_file(logger, output, filename): + def save_to_data_file(logger, output, filename, content_name=None): logger.info(_("httputils.found_attachment").format(filename)) with open(filename, "wb") as f: f.writelines(output) - logger.info(_("export.success").format(filename, filename)) + # export.success renders as 'Saved to ""' -- content + # name is the workbook/view/datasource being exported, filename is the + # destination path. Fall back to filename twice if no content name was + # threaded through (better than an empty {0}). + logger.info(_("export.success").format(content_name or filename, filename)) @staticmethod - def save_to_file(logger, output, filename): + def save_to_file(logger, output, filename, content_name=None): logger.info(_("httputils.found_attachment").format(filename)) with open(filename, "wb") as f: f.write(output) - logger.info(_("export.success").format(filename, filename)) + logger.info(_("export.success").format(content_name or filename, filename)) @staticmethod def get_custom_view_by_id(logger, server, custom_view_id) -> TSC.CustomViewItem: diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py index 71e902b4..edd87a18 100644 --- a/tabcmd/commands/datasources_and_workbooks/export_command.py +++ b/tabcmd/commands/datasources_and_workbooks/export_command.py @@ -102,30 +102,33 @@ def run_command(cls, args): ) Errors.exit_with_error(logger, message) + # `content_item` tracks the workbook/view/custom_view we're exporting; used + # for the "Saved to ''" success message. + content_item = None try: if args.fullpdf: # it's a workbook - workbook_item = ExportCommand.get_wb_by_content_url(logger, server, wb_content_url) - output = ExportCommand.download_wb_pdf(server, workbook_item, args, logger) + content_item = ExportCommand.get_wb_by_content_url(logger, server, wb_content_url) + output = ExportCommand.download_wb_pdf(server, content_item, args, logger) - default_filename = "{}.pdf".format(workbook_item.name) + default_filename = "{}.pdf".format(content_item.name) elif args.pdf or args.png or args.csv: # it's a view or custom_view ( - export_item, + content_item, server_content_type, ) = DatasourcesWorkbooksAndViewsUrlParser.get_export_item_and_server_content_type_from_export_url( view_content_url, logger, server, custom_view_id ) if args.pdf: - output = ExportCommand.download_view_pdf(server_content_type, export_item, args, logger) - default_filename = "{}.pdf".format(export_item.name) + output = ExportCommand.download_view_pdf(server_content_type, content_item, args, logger) + default_filename = "{}.pdf".format(content_item.name) elif args.csv: - output = ExportCommand.download_csv(server_content_type, export_item, args, logger) - default_filename = "{}.csv".format(export_item.name) + output = ExportCommand.download_csv(server_content_type, content_item, args, logger) + default_filename = "{}.csv".format(content_item.name) elif args.png: - output = ExportCommand.download_png(server_content_type, export_item, args, logger) - default_filename = "{}.png".format(export_item.name) + output = ExportCommand.download_png(server_content_type, content_item, args, logger) + default_filename = "{}.png".format(content_item.name) except TSC.ServerResponseError as e: Errors.exit_with_error(logger, _("publish.errors.unexpected_server_response").format(""), exception=e) @@ -133,10 +136,11 @@ def run_command(cls, args): Errors.exit_with_error(logger, exception=e) try: save_name = args.filename or default_filename + content_name = content_item.name if content_item is not None else None if args.csv: - ExportCommand.save_to_data_file(logger, output, save_name) + ExportCommand.save_to_data_file(logger, output, save_name, content_name=content_name) else: - ExportCommand.save_to_file(logger, output, save_name) + ExportCommand.save_to_file(logger, output, save_name, content_name=content_name) except Exception as e: Errors.exit_with_error(logger, "Error saving to file", exception=e) diff --git a/tabcmd/commands/datasources_and_workbooks/get_url_command.py b/tabcmd/commands/datasources_and_workbooks/get_url_command.py index 88e4c281..7b965d28 100644 --- a/tabcmd/commands/datasources_and_workbooks/get_url_command.py +++ b/tabcmd/commands/datasources_and_workbooks/get_url_command.py @@ -106,7 +106,7 @@ def generate_pdf(logger, server_content_type, args, get_url_item): DatasourcesAndWorkbooks.apply_values_from_url_params(logger, req_option_pdf, args.url) server_content_type.populate_pdf(get_url_item, req_option_pdf) filename = GetUrl.filename_from_args(args.filename, get_url_item.name, "pdf") - DatasourcesAndWorkbooks.save_to_file(logger, get_url_item.pdf, filename) + DatasourcesAndWorkbooks.save_to_file(logger, get_url_item.pdf, filename, content_name=get_url_item.name) except Exception as e: Errors.exit_with_error(logger, exception=e) @@ -119,7 +119,7 @@ def generate_png(logger, server_content_type, args, get_url_item): DatasourcesAndWorkbooks.apply_values_from_url_params(logger, req_option_png, args.url) server_content_type.populate_image(get_url_item, req_option_png) filename = GetUrl.filename_from_args(args.filename, get_url_item.name, "png") - DatasourcesAndWorkbooks.save_to_file(logger, get_url_item.image, filename) + DatasourcesAndWorkbooks.save_to_file(logger, get_url_item.image, filename, content_name=get_url_item.name) except Exception as e: Errors.exit_with_error(logger, exception=e) @@ -132,7 +132,9 @@ def generate_csv(logger, server_content_type, args, get_url_item): DatasourcesAndWorkbooks.apply_values_from_url_params(logger, req_option_csv, args.url) server_content_type.populate_csv(get_url_item, req_option_csv) file_name_with_path = GetUrl.filename_from_args(args.filename, get_url_item.name, "csv") - DatasourcesAndWorkbooks.save_to_data_file(logger, get_url_item.csv, file_name_with_path) + DatasourcesAndWorkbooks.save_to_data_file( + logger, get_url_item.csv, file_name_with_path, content_name=get_url_item.name + ) except Exception as e: Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index 64823770..b7163c72 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -7,7 +7,10 @@ path = os.path.dirname(os.path.abspath(__file__)) -_CLASSIC_OUTPUT = os.environ.get("TABCMD_CLASSIC_OUTPUT", "").lower() in ("1", "true", "yes") +# tabcmd Classic prefixes every INFO line with "=====". This PR's goal is parity +# with Classic output, so the prefix is on by default. Users who prefer the +# plainer tabcmd 2 style can opt out with TABCMD_CLASSIC_OUTPUT=false (or 0/no). +_CLASSIC_OUTPUT = os.environ.get("TABCMD_CLASSIC_OUTPUT", "true").lower() not in ("0", "false", "no") _INFO_FORMAT = "===== %(message)-30s" if _CLASSIC_OUTPUT else "%(message)-30s" FORMATS = { diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py index 60f5a868..05ad133a 100644 --- a/tests/commands/test_geturl_utils.py +++ b/tests/commands/test_geturl_utils.py @@ -448,6 +448,31 @@ def test_save_to_data_file(self): filename = "test_out.csv" ExportCommand.save_to_data_file(mock_logger, mock_content, filename) + def test_save_to_file_uses_content_name_when_supplied(self): + # tabcmd 1 prints "Saved to ''" -- the content + # name is a distinct value from the destination filename. Passing them + # both as the filename was a bug. + logger = mock.MagicMock() + with mock.patch( + "tabcmd.commands.datasources_and_workbooks.datasources_and_workbooks_command._", + side_effect=lambda k: "Saved {0} to '{1}'" if k == "export.success" else k, + ): + ExportCommand.save_to_file(logger, bytes(), "Regional.pdf", content_name="Regional Sales") + rendered = [c[0][0] for c in logger.info.call_args_list] + assert "Saved Regional Sales to 'Regional.pdf'" in rendered, rendered + + def test_save_to_file_falls_back_to_filename_when_no_content_name(self): + # If a caller doesn't supply content_name we still want a usable message; + # falling back to filename is better than an empty {0} placeholder. + logger = mock.MagicMock() + with mock.patch( + "tabcmd.commands.datasources_and_workbooks.datasources_and_workbooks_command._", + side_effect=lambda k: "Saved {0} to '{1}'" if k == "export.success" else k, + ): + ExportCommand.save_to_file(logger, bytes(), "test_out.pdf") + rendered = [c[0][0] for c in logger.info.call_args_list] + assert "Saved test_out.pdf to 'test_out.pdf'" in rendered, rendered + class FilenameExtensionTests(unittest.TestCase): # get_file_type_from_filename(logger, url, file_name)