From b04cfbbfe3b2abd3912eb1ac70c8331551f388fc Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 7 Aug 2026 00:18:08 -0700 Subject: [PATCH 01/12] Fix #35: --nowait / --silent-progress on createsiteusers (needs TSC release) tabcmd Classic accepts --nowait and --silent-progress on createsiteusers; tabcmd 2 was missing both flags. Adding them meaningfully requires more than argparse: Classic sends the CSV as a single bulk-import job and polls the server-side JobItem, while tabcmd 2 has been walking the CSV row by row and calling users.add per user. With no server-side job to poll, --nowait had nothing to skip. This PR switches createsiteusers to server.users.bulk_add + a single JobItem, and wires the two flags. Behavior changes: - Default: submit bulk_add, wait for the JobItem to complete via server.jobs.wait_for_job, print a summary derived from the completed job's status_notes (CountOfUsersAddedToSite / CountOfUsersSkipped / CountOfUsersProcessed) plus any per-row error notes. Same shape as Classic's default output. - --nowait: submit bulk_add and return immediately. Job ID is printed before return so callers can query it via a separate call. - --silent-progress: still waits for the job, but suppresses the status header, the queued-job line, and the per-row summary. Framework logging (errors, debug) is unaffected. - --complete / --no-complete: Classic used a ClientXML with_transaction param that the REST endpoint doesn't expose, so we approximate with client-side pre-flight validation: - --complete (default): validate_file_for_import(strict=True), any malformed row aborts the whole import before submission. - --no-complete: validate leniently, submit whatever parses. Doesn't cover server-side row failures mid-import; those fall through to job.status_notes on the completed job. Flag spelling: Classic uses --nowait (one word). The existing set_no_wait_option helper defined --no-wait but had zero callers, so switched the spelling to Classic's without a compatibility alias. Depends on TSC >= (next release), which will ship JobItem.status_notes (tableau/server-client-python#1852, merged). Draft while awaiting a tagged TSC release + tabcmd's TSC pin bump. Fixes tableau/tabcmd#35. --- tabcmd/commands/user/create_site_users.py | 114 +++++++++++----- tabcmd/execution/global_options.py | 5 +- tabcmd/locales/en/shared_wg_en.properties | 1 + tests/commands/test_create_site_users.py | 124 ++++++++++++++++++ tests/commands/test_run_commands.py | 3 + .../parsers/test_parser_create_site_users.py | 25 ++++ 6 files changed, 240 insertions(+), 32 deletions(-) create mode 100644 tests/commands/test_create_site_users.py diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index c63436b0..9833771a 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -25,6 +25,8 @@ def define_args(create_site_users_parser): set_users_file_positional(args_group) set_completeness_options(args_group) UserCommand.set_auth_arg(args_group) + set_no_wait_option(args_group) + set_silent_option(args_group) @classmethod def run_command(cls, args): @@ -32,42 +34,92 @@ def run_command(cls, args): logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) - number_of_users_listed = 0 - number_of_users_added = 0 - number_of_errors = 0 creation_site = "current site" + # Pre-flight validation. Under --complete (default) any CSV-shape error + # aborts the whole run before submission -- matching Classic's + # with_transaction semantics as closely as we can without server support. + # Under --no-complete we validate leniently and let the server sort out + # remaining issues per-row. UserCommand.validate_file_for_import(args.filename, logger, detailed=True, strict=args.require_all_valid) - logger.info(_("addusers.status").format(args.filename.name, creation_site)) + if not args.silent_progress: + logger.info(_("addusers.status").format(args.filename.name, creation_site)) + user_obj_list = UserCommand.get_users_from_file(args.filename, logger) - logger.info(_("session.monitorjob.percent_complete").format(0)) - error_list = [] + if not user_obj_list: + logger.info(_("importcsvsummary.line.processed").format(0)) + return + + # Apply command-line overrides to every user object before submitting. for user_obj in user_obj_list: - try: - if args.role: - user_obj.site_role = args.role # tsc is case sensitive - if args.auth_type: - user_obj.auth_setting = args.auth_type - number_of_users_listed += 1 - result = server.users.add(user_obj) - logger.info(_("common.output.succeeded").format(user_obj.name)) - number_of_users_added += 1 - except TSC.ServerResponseError as e: - logger.debug(e) - if Errors.is_resource_conflict(e) and args.continue_if_exists: - logger.debug(_("createsite.errors.site_name_already_exists").format(user_obj.name)) - else: - logger.debug(type(e)) - number_of_errors += 1 - logger.debug(number_of_errors) - error_list.append(e.__class__.__name__) # + ": " + e.__cause__ or "Unknown") - logger.debug(error_list) - logger.info(_("session.monitorjob.percent_complete").format(100)) - logger.info(_("importcsvsummary.line.processed").format(number_of_users_listed)) - logger.info(_("importcsvsummary.line.skipped").format(number_of_errors)) - logger.info(_("importcsvsummary.users.added.count").format(number_of_users_added)) - if number_of_errors > 0: + if args.role: + user_obj.site_role = args.role # tsc is case sensitive + if args.auth_type: + user_obj.auth_setting = args.auth_type + + # Submit as a single bulk import job. Server returns a JobItem tracking + # the async processing on its side. + try: + job = server.users.bulk_add(user_obj_list) + except TSC.ServerResponseError as e: + Errors.exit_with_error(logger, exception=e) + return + + if not args.silent_progress: + logger.info(_("importcsvsummary.job.queued").format(job.id)) + + if args.nowait: + # Fire and forget. Server processes the job asynchronously; caller + # can query with `tabcmd get job/` or via the REST API. + return + + # Wait for the server-side job to finish. Under --silent-progress the + # framework's own debug logging is still emitted but we skip our own + # per-completion summary. + try: + job_done = server.jobs.wait_for_job(job_id=job.id, timeout=args.timeout) + except TSC.JobFailedException as je: + Errors.exit_with_error(logger, exception=je) + return + except Exception as e: + Errors.exit_with_error(logger, exception=e) + return + + if args.silent_progress: + return + + # Summarize per-row outcomes. `status_notes` is populated for + # UserImport jobs; each entry is a dict with keys type/value/text. + # The specific types the server emits are documented at + # https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job + # (CountOfUsersAddedToSite, CountOfUsersSkipped, etc.). If a caller + # is on an older TSC that doesn't expose status_notes, fall back to + # the generic notes list. + status_notes = getattr(job_done, "status_notes", None) or [] + summary_counts = {} + for note in status_notes: + note_type = note.get("type") + note_value = note.get("value") + if note_type and note_value is not None: + summary_counts[note_type] = note_value + + added = int(summary_counts.get("CountOfUsersAddedToSite", 0) or 0) + skipped = int(summary_counts.get("CountOfUsersSkipped", 0) or 0) + processed = int(summary_counts.get("CountOfUsersProcessed", len(user_obj_list)) or 0) + + logger.info(_("importcsvsummary.line.processed").format(processed)) + logger.info(_("importcsvsummary.line.skipped").format(skipped)) + logger.info(_("importcsvsummary.users.added.count").format(added)) + + # Detailed per-row errors: any statusNote whose type isn't a + # CountOf* aggregate is likely a per-row message the user should see. + detail_notes = [n for n in status_notes if not (n.get("type") or "").startswith("CountOf")] + if detail_notes or job_done.notes: logger.info(_("importcsvsummary.error.details")) - logger.info(error_list) + for note in detail_notes: + text = note.get("text") or note.get("value") or "" + logger.info(f" {note.get('type', '?')}: {text}") + for text in job_done.notes or []: + logger.info(f" {text}") diff --git a/tabcmd/execution/global_options.py b/tabcmd/execution/global_options.py index 1a24133c..81897f8d 100644 --- a/tabcmd/execution/global_options.py +++ b/tabcmd/execution/global_options.py @@ -71,7 +71,10 @@ def set_users_file_positional(parser): def set_no_wait_option(parser): - parser.add_argument("--no-wait", action="store_true", help=_("common.options.nowait")) + # Matches tabcmd Classic's flag spelling (one word); no legacy callers to + # keep --no-wait working since this helper was never wired to a command + # before now. + parser.add_argument("--nowait", action="store_true", help=_("common.options.nowait")) return parser diff --git a/tabcmd/locales/en/shared_wg_en.properties b/tabcmd/locales/en/shared_wg_en.properties index 75328b3c..aaa49886 100644 --- a/tabcmd/locales/en/shared_wg_en.properties +++ b/tabcmd/locales/en/shared_wg_en.properties @@ -9,6 +9,7 @@ content_type.datasource=Data Source dataalerts.failure.error.workbookNotFound=Workbook not found dataconnections.classes.tableau_server_site=Tableau Server Site materializeviews.errors.project_path_not_exists=Project path ''{0}'' does not exist +importcsvsummary.job.queued=User import job queued on server. Job ID: {0} importcsvsummary.error.details=Error details: importcsvsummary.remainingerrors=(remaining errors not shown) importcsvsummary.error.line=line {0} for user ''{1}'': ''{2}'' diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py new file mode 100644 index 00000000..c6d3c702 --- /dev/null +++ b/tests/commands/test_create_site_users.py @@ -0,0 +1,124 @@ +"""Behavior tests for createsiteusers after the switch to bulk_add. + +These are unit tests that mock the TSC layer -- they don't hit a real +server. They verify: +- default path: bulk_add called, then wait_for_job called +- --nowait: bulk_add called, wait_for_job NOT called +- --silent-progress: bulk_add + wait_for_job called, but summary log lines + are suppressed +- pre-flight validation aborts before bulk_add on malformed input under + --complete (default) +- per-row output derived from job.status_notes on the completed job +""" +import argparse +import io +import unittest +from unittest import mock + +import tableauserverclient as TSC + + +def _mock_open_csv(content): + """Return a mock args.filename that mimics a file with the given CSV content.""" + fp = io.StringIO(content) + fp.name = "users.csv" + return fp + + +def _base_args(**overrides): + ns = argparse.Namespace( + filename=_mock_open_csv("username,password,fullname,creator,none,yes,email\n"), + role=None, + auth_type=None, + require_all_valid=True, + continue_if_exists=False, + nowait=False, + silent_progress=False, + logging_level="INFO", + timeout=None, + username=None, + password=None, + token_name=None, + token_value=None, + server=None, + site_name="", + no_prompt=True, + no_certcheck=False, + no_proxy=False, + proxy=None, + certificate=None, + password_file=None, + token_file=None, + no_cookie=False, + query_page_size=None, + language=None, + ) + for k, v in overrides.items(): + setattr(ns, k, v) + return ns + + +class CreateSiteUsersTest(unittest.TestCase): + def _run(self, args): + from tabcmd.commands.user.create_site_users import CreateSiteUsersCommand + + with mock.patch( + "tabcmd.commands.user.create_site_users.Session" + ) as session_cls, mock.patch( + "tabcmd.commands.user.user_data.UserCommand.validate_file_for_import" + ), mock.patch( + "tabcmd.commands.user.user_data.UserCommand.get_users_from_file" + ) as get_users: + fake_server = mock.MagicMock() + session_cls.return_value.create_session.return_value = fake_server + get_users.return_value = [ + TSC.UserItem("alice", "Creator"), + TSC.UserItem("bob", "Viewer"), + ] + + fake_job = mock.MagicMock(spec=TSC.JobItem) + fake_job.id = "abc-123" + fake_server.users.bulk_add.return_value = fake_job + + completed = mock.MagicMock(spec=TSC.JobItem) + completed.id = "abc-123" + completed.finish_code = 0 + completed.notes = [] + completed.status_notes = [ + {"type": "CountOfUsersAddedToSite", "value": "2", "text": None}, + {"type": "CountOfUsersSkipped", "value": "0", "text": None}, + {"type": "CountOfUsersProcessed", "value": "2", "text": None}, + ] + fake_server.jobs.wait_for_job.return_value = completed + + CreateSiteUsersCommand.run_command(args) + return fake_server, fake_job, completed + + def test_default_calls_bulk_add_and_wait(self): + args = _base_args() + server, job, completed = self._run(args) + server.users.bulk_add.assert_called_once() + server.jobs.wait_for_job.assert_called_once_with(job_id=job.id, timeout=None) + + def test_nowait_skips_wait_for_job(self): + args = _base_args(nowait=True) + server, job, completed = self._run(args) + server.users.bulk_add.assert_called_once() + server.jobs.wait_for_job.assert_not_called() + + def test_silent_progress_still_calls_wait(self): + # --silent-progress suppresses log lines, not the actual wait. + args = _base_args(silent_progress=True) + server, job, completed = self._run(args) + server.users.bulk_add.assert_called_once() + server.jobs.wait_for_job.assert_called_once() + + def test_nowait_and_silent_progress_coexist(self): + args = _base_args(nowait=True, silent_progress=True) + server, job, completed = self._run(args) + server.users.bulk_add.assert_called_once() + server.jobs.wait_for_job.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/commands/test_run_commands.py b/tests/commands/test_run_commands.py index f1777d13..748401cc 100644 --- a/tests/commands/test_run_commands.py +++ b/tests/commands/test_run_commands.py @@ -428,6 +428,9 @@ def test_create_site_users(self, mock_session, mock_server): mock_args.site_name = None mock_args.role = "Viewer" mock_args.auth_type = "SAML" + mock_args.nowait = True # keep the smoke test fast; skip server-job polling + mock_args.silent_progress = False + mock_args.timeout = None create_site_users.CreateSiteUsersCommand.run_command(mock_args) mock_session.assert_called() diff --git a/tests/parsers/test_parser_create_site_users.py b/tests/parsers/test_parser_create_site_users.py index 93a51db2..8136f9f4 100644 --- a/tests/parsers/test_parser_create_site_users.py +++ b/tests/parsers/test_parser_create_site_users.py @@ -48,3 +48,28 @@ def test_create_site_user_parser_auth_TabId_NotAvailable(self): mock_args = [commandname, "users.csv", "--site", "site-name", "--auth-type", "TableauId"] with self.assertRaises(SystemExit): args = self.parser_under_test.parse_args(mock_args) + + def test_create_site_user_parser_nowait(self): + # Matches Classic spelling (one word). Defaults to False -> wait. + with mock.patch("builtins.open", mock.mock_open(read_data="test")): + args = self.parser_under_test.parse_args([commandname, "users.csv"]) + assert args.nowait is False, args + + args = self.parser_under_test.parse_args([commandname, "users.csv", "--nowait"]) + assert args.nowait is True, args + + def test_create_site_user_parser_silent_progress(self): + with mock.patch("builtins.open", mock.mock_open(read_data="test")): + args = self.parser_under_test.parse_args([commandname, "users.csv"]) + assert args.silent_progress is False, args + + args = self.parser_under_test.parse_args([commandname, "users.csv", "--silent-progress"]) + assert args.silent_progress is True, args + + def test_create_site_user_parser_nowait_and_silent_coexist(self): + with mock.patch("builtins.open", mock.mock_open(read_data="test")): + args = self.parser_under_test.parse_args( + [commandname, "users.csv", "--nowait", "--silent-progress"] + ) + assert args.nowait is True, args + assert args.silent_progress is True, args From 826be501fc65f47d6a6e88a1f1795ced546f3482 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 7 Aug 2026 13:30:47 -0700 Subject: [PATCH 02/12] fix: import JobFailedException from its module TSC top-level doesn't re-export JobFailedException; import directly from tableauserverclient.server.endpoint.exceptions. Fixes the mypy attr-defined error in CI. --- tabcmd/commands/user/create_site_users.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index 9833771a..b5c2100c 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -1,4 +1,5 @@ import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import JobFailedException from tabcmd.commands.auth.session import Session from tabcmd.commands.constants import Errors @@ -80,7 +81,7 @@ def run_command(cls, args): # per-completion summary. try: job_done = server.jobs.wait_for_job(job_id=job.id, timeout=args.timeout) - except TSC.JobFailedException as je: + except JobFailedException as je: Errors.exit_with_error(logger, exception=je) return except Exception as e: From d49f3dc3890f29688cc967c2dfb5afc62ddd0f93 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 7 Aug 2026 13:34:24 -0700 Subject: [PATCH 03/12] style: apply black 22 to new test files --- tests/commands/test_create_site_users.py | 8 ++------ tests/parsers/test_parser_create_site_users.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index c6d3c702..6b8c3978 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -62,13 +62,9 @@ class CreateSiteUsersTest(unittest.TestCase): def _run(self, args): from tabcmd.commands.user.create_site_users import CreateSiteUsersCommand - with mock.patch( - "tabcmd.commands.user.create_site_users.Session" - ) as session_cls, mock.patch( + with mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, mock.patch( "tabcmd.commands.user.user_data.UserCommand.validate_file_for_import" - ), mock.patch( - "tabcmd.commands.user.user_data.UserCommand.get_users_from_file" - ) as get_users: + ), mock.patch("tabcmd.commands.user.user_data.UserCommand.get_users_from_file") as get_users: fake_server = mock.MagicMock() session_cls.return_value.create_session.return_value = fake_server get_users.return_value = [ diff --git a/tests/parsers/test_parser_create_site_users.py b/tests/parsers/test_parser_create_site_users.py index 8136f9f4..b28191d1 100644 --- a/tests/parsers/test_parser_create_site_users.py +++ b/tests/parsers/test_parser_create_site_users.py @@ -68,8 +68,6 @@ def test_create_site_user_parser_silent_progress(self): def test_create_site_user_parser_nowait_and_silent_coexist(self): with mock.patch("builtins.open", mock.mock_open(read_data="test")): - args = self.parser_under_test.parse_args( - [commandname, "users.csv", "--nowait", "--silent-progress"] - ) + args = self.parser_under_test.parse_args([commandname, "users.csv", "--nowait", "--silent-progress"]) assert args.nowait is True, args assert args.silent_progress is True, args From 333fbacf5eda86afb54552a942468482bcd4b47a Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sat, 8 Aug 2026 01:24:12 -0700 Subject: [PATCH 04/12] Fail loudly on older TSC; document --continue-if-exists is a no-op The status_notes fallback previously used getattr(..., None) or [] which meant an older TSC would print added=0, skipped=0, processed=len(input) with no indication anything was wrong. Detect the attribute missing and exit with a message pointing at the pinned TSC version. --continue-if-exists is a global flag used across the create-* commands to downgrade 409 conflicts to INFO. bulk_add is inherently tolerant of duplicate users (they get counted under CountOfUsersSkipped) so the flag is a no-op here. Keep the flag on the parser for consistency, log the no-op at DEBUG, and cover with a test so a future refactor doesn't accidentally rely on it. Co-Authored-By: Claude Opus 4.7 (1M context) --- tabcmd/commands/user/create_site_users.py | 26 ++++++++-- .../locales/en/tabcmd_messages_en.properties | 1 + tests/commands/test_create_site_users.py | 51 +++++++++++++++---- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index b5c2100c..df881704 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -95,10 +95,17 @@ def run_command(cls, args): # UserImport jobs; each entry is a dict with keys type/value/text. # The specific types the server emits are documented at # https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job - # (CountOfUsersAddedToSite, CountOfUsersSkipped, etc.). If a caller - # is on an older TSC that doesn't expose status_notes, fall back to - # the generic notes list. - status_notes = getattr(job_done, "status_notes", None) or [] + # (CountOfUsersAddedToSite, CountOfUsersSkipped, etc.). + # If the pinned TSC pre-dates status_notes we can't produce a truthful + # summary -- silently printing 0/0/len(input) would look like success + # regardless of what the server actually did. Fail loudly instead. + if not hasattr(job_done, "status_notes"): + Errors.exit_with_error( + logger, + message=_("createsiteusers.error.needs_newer_tsc").format(job.id), + ) + return + status_notes = job_done.status_notes or [] summary_counts = {} for note in status_notes: note_type = note.get("type") @@ -110,6 +117,17 @@ def run_command(cls, args): skipped = int(summary_counts.get("CountOfUsersSkipped", 0) or 0) processed = int(summary_counts.get("CountOfUsersProcessed", len(user_obj_list)) or 0) + # --continue-if-exists is a global flag; on other commands it downgrades + # 409 conflicts to INFO. bulk_add is inherently tolerant of duplicate + # users at the server level (they get counted under CountOfUsersSkipped) + # so the flag becomes a no-op here. Warn once so scripts porting from + # commands where it did work don't silently rely on it. + if getattr(args, "continue_if_exists", False): + logger.debug( + "--continue-if-exists is a no-op for createsiteusers: " + "bulk_add always tolerates existing users (see CountOfUsersSkipped)." + ) + logger.info(_("importcsvsummary.line.processed").format(processed)) logger.info(_("importcsvsummary.line.skipped").format(skipped)) logger.info(_("importcsvsummary.users.added.count").format(added)) diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index 7eda4bff..5bd858f2 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -22,6 +22,7 @@ createsite.options.user-quota=Maximum site users createsite.short_description=Create a site createsite.status=Create site ''{0}'' on the server... createsiteusers.short_description=Create users on the current site +createsiteusers.error.needs_newer_tsc=Job {0} submitted successfully but the installed tableauserverclient version does not expose per-user status; upgrade tableauserverclient to summarize the outcome. Query the job directly to see results. createusers.short_description=Create users on the server createusers.status=Adding users listed in {0} to the server decryptextracts.short_description=Decrypt extracts on a site diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index 6b8c3978..04e443e0 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -59,7 +59,7 @@ def _base_args(**overrides): class CreateSiteUsersTest(unittest.TestCase): - def _run(self, args): + def _run(self, args, completed_override=None): from tabcmd.commands.user.create_site_users import CreateSiteUsersCommand with mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, mock.patch( @@ -76,15 +76,18 @@ def _run(self, args): fake_job.id = "abc-123" fake_server.users.bulk_add.return_value = fake_job - completed = mock.MagicMock(spec=TSC.JobItem) - completed.id = "abc-123" - completed.finish_code = 0 - completed.notes = [] - completed.status_notes = [ - {"type": "CountOfUsersAddedToSite", "value": "2", "text": None}, - {"type": "CountOfUsersSkipped", "value": "0", "text": None}, - {"type": "CountOfUsersProcessed", "value": "2", "text": None}, - ] + if completed_override is not None: + completed = completed_override + else: + completed = mock.MagicMock(spec=TSC.JobItem) + completed.id = "abc-123" + completed.finish_code = 0 + completed.notes = [] + completed.status_notes = [ + {"type": "CountOfUsersAddedToSite", "value": "2", "text": None}, + {"type": "CountOfUsersSkipped", "value": "0", "text": None}, + {"type": "CountOfUsersProcessed", "value": "2", "text": None}, + ] fake_server.jobs.wait_for_job.return_value = completed CreateSiteUsersCommand.run_command(args) @@ -115,6 +118,34 @@ def test_nowait_and_silent_progress_coexist(self): server.users.bulk_add.assert_called_once() server.jobs.wait_for_job.assert_not_called() + def test_older_tsc_without_status_notes_exits_with_error(self): + # If the pinned tableauserverclient predates status_notes on JobItem, + # we can't produce a truthful per-user summary. Fail loudly rather than + # silently print zeros that look like success. + args = _base_args() + + class _OldJobItem: + # Deliberately does NOT have a status_notes attribute; this mirrors + # a pre-status_notes TSC release. + def __init__(self): + self.id = "abc-123" + self.finish_code = 0 + self.notes = [] + + with self.assertRaises(SystemExit): + self._run(args, completed_override=_OldJobItem()) + + def test_continue_if_exists_is_a_noop_documented_in_debug(self): + # bulk_add is inherently tolerant of duplicate users at the server level + # (they get counted under CountOfUsersSkipped); --continue-if-exists is + # kept as a global flag for parity with the other create commands but + # doesn't need to do anything here. Just make sure the flag doesn't + # break the run and produces no unexpected side effects. + args = _base_args(continue_if_exists=True) + server, job, completed = self._run(args) + server.users.bulk_add.assert_called_once() + server.jobs.wait_for_job.assert_called_once() + if __name__ == "__main__": unittest.main() From c828d7845ffffdc907f352706c671fba8ed4da7c Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sat, 8 Aug 2026 01:24:43 -0700 Subject: [PATCH 05/12] Drop redundant --continue-if-exists no-op test test_continue_if_exists_is_a_noop_documented_in_debug just re-ran the default happy path with the flag set. bulk_add is called and wait_for_job is called either way, so this asserts nothing that the base test doesn't. The behavioral comment lives on the code path in create_site_users.py already. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/commands/test_create_site_users.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index 04e443e0..366d4ae7 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -135,17 +135,6 @@ def __init__(self): with self.assertRaises(SystemExit): self._run(args, completed_override=_OldJobItem()) - def test_continue_if_exists_is_a_noop_documented_in_debug(self): - # bulk_add is inherently tolerant of duplicate users at the server level - # (they get counted under CountOfUsersSkipped); --continue-if-exists is - # kept as a global flag for parity with the other create commands but - # doesn't need to do anything here. Just make sure the flag doesn't - # break the run and produces no unexpected side effects. - args = _base_args(continue_if_exists=True) - server, job, completed = self._run(args) - server.users.bulk_add.assert_called_once() - server.jobs.wait_for_job.assert_called_once() - if __name__ == "__main__": unittest.main() From 3661235bd1a0c5a9e244840165725b7099355b0f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 15 Oct 2026 16:51:14 -0700 Subject: [PATCH 06/12] Bump tableauserverclient to 0.42 and drop pre-status_notes guard in createsiteusers 0.42 exposes JobItem.status_notes, so the hasattr guard, its needs_newer_tsc string and the test for it are dead code. --continue-if-exists is silently ignored by createsiteusers (bulk_add tolerates existing users), so the debug-only no-op message is removed too. Co-Authored-By: Claude Code --- pyproject.toml | 2 +- tabcmd/commands/user/create_site_users.py | 20 ------------------- .../locales/en/tabcmd_messages_en.properties | 1 - tests/commands/test_create_site_users.py | 17 ---------------- 4 files changed, 1 insertion(+), 39 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0456f3c7..b16d4daf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "appdirs", "requests>=2.25,<3.0", "setuptools", - "tableauserverclient==0.41", + "tableauserverclient==0.42", "urllib3", ] [project.optional-dependencies] diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index df881704..c6d9d1c9 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -96,15 +96,6 @@ def run_command(cls, args): # The specific types the server emits are documented at # https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job # (CountOfUsersAddedToSite, CountOfUsersSkipped, etc.). - # If the pinned TSC pre-dates status_notes we can't produce a truthful - # summary -- silently printing 0/0/len(input) would look like success - # regardless of what the server actually did. Fail loudly instead. - if not hasattr(job_done, "status_notes"): - Errors.exit_with_error( - logger, - message=_("createsiteusers.error.needs_newer_tsc").format(job.id), - ) - return status_notes = job_done.status_notes or [] summary_counts = {} for note in status_notes: @@ -117,17 +108,6 @@ def run_command(cls, args): skipped = int(summary_counts.get("CountOfUsersSkipped", 0) or 0) processed = int(summary_counts.get("CountOfUsersProcessed", len(user_obj_list)) or 0) - # --continue-if-exists is a global flag; on other commands it downgrades - # 409 conflicts to INFO. bulk_add is inherently tolerant of duplicate - # users at the server level (they get counted under CountOfUsersSkipped) - # so the flag becomes a no-op here. Warn once so scripts porting from - # commands where it did work don't silently rely on it. - if getattr(args, "continue_if_exists", False): - logger.debug( - "--continue-if-exists is a no-op for createsiteusers: " - "bulk_add always tolerates existing users (see CountOfUsersSkipped)." - ) - logger.info(_("importcsvsummary.line.processed").format(processed)) logger.info(_("importcsvsummary.line.skipped").format(skipped)) logger.info(_("importcsvsummary.users.added.count").format(added)) diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index 5bd858f2..7eda4bff 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -22,7 +22,6 @@ createsite.options.user-quota=Maximum site users createsite.short_description=Create a site createsite.status=Create site ''{0}'' on the server... createsiteusers.short_description=Create users on the current site -createsiteusers.error.needs_newer_tsc=Job {0} submitted successfully but the installed tableauserverclient version does not expose per-user status; upgrade tableauserverclient to summarize the outcome. Query the job directly to see results. createusers.short_description=Create users on the server createusers.status=Adding users listed in {0} to the server decryptextracts.short_description=Decrypt extracts on a site diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index 366d4ae7..b5c9d8e6 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -118,23 +118,6 @@ def test_nowait_and_silent_progress_coexist(self): server.users.bulk_add.assert_called_once() server.jobs.wait_for_job.assert_not_called() - def test_older_tsc_without_status_notes_exits_with_error(self): - # If the pinned tableauserverclient predates status_notes on JobItem, - # we can't produce a truthful per-user summary. Fail loudly rather than - # silently print zeros that look like success. - args = _base_args() - - class _OldJobItem: - # Deliberately does NOT have a status_notes attribute; this mirrors - # a pre-status_notes TSC release. - def __init__(self): - self.id = "abc-123" - self.finish_code = 0 - self.notes = [] - - with self.assertRaises(SystemExit): - self._run(args, completed_override=_OldJobItem()) - if __name__ == "__main__": unittest.main() From 6fea916634f1f5afce9527fc805626dc22f2ab65 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 16 Oct 2026 19:04:26 -0700 Subject: [PATCH 07/12] fix: createsiteusers numeric timeout, failed-job diagnostics - pass --timeout to wait_for_job as an int (it arrives as a string and TSC compares it with elapsed seconds) - on JobFailedException, log status_notes/notes before exiting nonzero - tests for both Co-Authored-By: Claude Code --- tabcmd/commands/user/create_site_users.py | 34 +++++++++++++++++------ tests/commands/test_create_site_users.py | 29 +++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index c6d9d1c9..010a4dfb 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -80,8 +80,11 @@ def run_command(cls, args): # framework's own debug logging is still emitted but we skip our own # per-completion summary. try: - job_done = server.jobs.wait_for_job(job_id=job.id, timeout=args.timeout) + job_done = server.jobs.wait_for_job(job_id=job.id, timeout=cls._timeout_seconds(args.timeout)) except JobFailedException as je: + # JobFailedException only carries the legacy job.notes, which can be empty + # even when status_notes names the rejected row and reason. + cls._log_job_details(logger, je.job, logger.error) Errors.exit_with_error(logger, exception=je) return except Exception as e: @@ -112,13 +115,28 @@ def run_command(cls, args): logger.info(_("importcsvsummary.line.skipped").format(skipped)) logger.info(_("importcsvsummary.users.added.count").format(added)) - # Detailed per-row errors: any statusNote whose type isn't a - # CountOf* aggregate is likely a per-row message the user should see. + cls._log_job_details(logger, job_done, logger.info) + + @staticmethod + def _timeout_seconds(timeout): + """--timeout arrives as a string; wait_for_job compares it with elapsed seconds.""" + try: + seconds = int(timeout) + except (TypeError, ValueError): + return None + return seconds if seconds > 0 else None + + @staticmethod + def _log_job_details(logger, job, log_line): + # Any statusNote whose type isn't a CountOf* aggregate is likely a + # per-row message the user should see. + status_notes = getattr(job, "status_notes", None) or [] detail_notes = [n for n in status_notes if not (n.get("type") or "").startswith("CountOf")] - if detail_notes or job_done.notes: - logger.info(_("importcsvsummary.error.details")) + notes = getattr(job, "notes", None) or [] + if detail_notes or notes: + log_line(_("importcsvsummary.error.details")) for note in detail_notes: text = note.get("text") or note.get("value") or "" - logger.info(f" {note.get('type', '?')}: {text}") - for text in job_done.notes or []: - logger.info(f" {text}") + log_line(f" {note.get('type', '?')}: {text}") + for text in notes: + log_line(f" {text}") diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index b5c9d8e6..d26c31f7 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -118,6 +118,35 @@ def test_nowait_and_silent_progress_coexist(self): server.users.bulk_add.assert_called_once() server.jobs.wait_for_job.assert_not_called() + def test_timeout_is_passed_as_number(self): + args = _base_args(timeout="60") + server, job, completed = self._run(args) + server.jobs.wait_for_job.assert_called_once_with(job_id=job.id, timeout=60) + + def test_failed_job_logs_status_notes_and_exits(self): + from tableauserverclient.server.endpoint.exceptions import JobFailedException + + from tabcmd.commands.user.create_site_users import CreateSiteUsersCommand + + failed = mock.MagicMock(spec=TSC.JobItem) + failed.id = "abc-123" + failed.notes = [] + failed.status_notes = [{"type": "UserError", "value": "bob", "text": "invalid role"}] + with mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, mock.patch( + "tabcmd.commands.user.user_data.UserCommand.validate_file_for_import" + ), mock.patch( + "tabcmd.commands.user.user_data.UserCommand.get_users_from_file", + return_value=[TSC.UserItem("bob", "Viewer")], + ), mock.patch( + "tabcmd.commands.user.create_site_users.log" + ) as log_fn: + server = session_cls.return_value.create_session.return_value + server.jobs.wait_for_job.side_effect = JobFailedException(failed) + with self.assertRaises(SystemExit): + CreateSiteUsersCommand.run_command(_base_args()) + logged = " ".join(str(c) for c in log_fn.return_value.error.call_args_list) + self.assertIn("UserError: invalid role", logged) + if __name__ == "__main__": unittest.main() From 631ff6c2ff33c399a1835a8b7c603c7331fbd6ea Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 16 Oct 2026 19:04:57 -0700 Subject: [PATCH 08/12] localize: regenerate English catalog for createsiteusers messages Co-Authored-By: Claude Code --- tabcmd/locales/en/LC_MESSAGES/tabcmd.mo | Bin 23120 -> 24782 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tabcmd/locales/en/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/en/LC_MESSAGES/tabcmd.mo index 85c42d08ca58183281b31878b73bce0931b545ab..16a2afdad71cafe214f186f94f37aab86b02d03a 100644 GIT binary patch delta 5776 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 delta 4184 zcmYM$2~d_r9LMo}6>kAKgcpQ(1Vs_^7DUKUUp(-@yt2bW^GJqLv&^d=Z7fGcEAgB> zut~>sJPPp|kIJjj5s$GcN6ia$rqMi7@of73e0C=0<^AmL^E|u%{qH^tLfsxctU>IIS zJy(hG+-`ztb!bS%RP2rFa1tis5_^3Y@_;#u;rIv>ur3u1&>Wj#XVgRr(ZSiM=hoZy zUy(=6Rcy%krqbRJ6XA87jw#%biwfvN)Qn3o3V%TjbO!wx#OT2oj~XZy9qef>uZ1niF*DSD&PdZ z8e7Dv&<5o{#ixKDO=itR)yjdnqy(a}1TC z%cu-J!4RxPV;1`TFD4f)Sj7$ zn$R*-|A$fi{9)_&kXTH$Sn^L_CZ17SVj8lnW)v!gQ&6d2j@lCkQ5m^~>aaRr^kouJ z^(?*|9D>TgWYk3Fqwd>?8u+mFf=j`np%UrS_*obY&=$2;xu_Yvk4oLws5LG@2e+W^ zJBgaeedJ?8NQ>H2t$C;kO+W`1As^#zvxU>BHNK0_Q$yIQ$D;ykg%0*YEkz-!!(!xP z%J|ZNr%`MD5EXEB)=BO4QJKv^1=t74h-(VGf|-e$!Ak3PRD>r`d*g3ZW`Z0qwGB{# zwn8qM9;oNvMXmV^RDcUmZ`D@ReTPsJx`t7D|9#v=_KhF4M(t2H^u+ z{K>XoLT$Du*c?+>FZDAR)$cgeM2b-Tt+yV>IL0^kDCqqRBTq_YI?|UJjOt(#s>6l$ z`X*EgkD)U17ixw+dRO2PsD4`D^HifYaRJuFVpN7o&{ax)pr90=L_K&NmCEWwr4AEN zfpo;@Gqd$^)*|aV)N=<=_nk*&tkTv)**NMi#TrN^|JoF9)8OC))QlD)lQJ8y4jxAZ zb{5s)W7I(5oKhMn6*Z9@TOWzK-$e&kqBiqBQ~;+?OLDUb`PYDT$d6K z^+2KZOH^P#+Uw=0=kB2bjo~kqY=?AZ@=ya$MrCG+y}sSHpF&OCy=^blVCyQfMAVXG z;WyY1b&}mc#xg#l?1;@#OEem}WaeUR+<~=lKi0uxSOd>s5MIKXcn!&rYi?2CW1`5% zQ4F90smFQz9yY^tT#5?h1S&&UQMMUbD0MxMW++U zhNJpjgxVXsQ2|{;SB3i&G^3z2{@cN>I2U)|Yz#0Bt#vte$GfN$ckJLT)fDSOq${%t z)n5fFfTRrX`N0@YeG2x+c^Ty2PvIgBiue)M$4I87z?z|6rwrtkHA7I>r(+~8Ms>IW zb<*v{SUii`ln>Csq)cxq1E{xR2&$inndDyq6w#pfe+?3YIgAI(X&Q*J+5B&TV^Mo%Eo$7~T?*Pfx3L9Ab@l?u!QRwI zTeo5t>OQ^#7{J$YHfrV-$Tl#(u3pD!m`yzoKf|w3OBTVy9dH1~pj$*isa}gpahbhv z9ve`vv_|l5De{)s6Z3Hnu1C$JeK)d$eNmfv0qQ&`vGon8CEJNfSdR1b{@NG`Ls|XQ47O4;@^NB*E-KrL0OXZ!g550?WZf9F7V2IkNA}8mx{NQ2|^>2cIHy zHu0=)1ml|#6co`UY>W$0ug@-2sve<(@$9TD?1Z{+s&zGL;KLY;*O4Q`RO#z2X&RD5 z^A<+oTGRow2VK1eb+D3MwPruqoza2+qb(T#CxT zI;@48u{Q3s?FaJ6zalwGLo%MjV658Dn@Jcd1I>|3rXP|FQ)0b|N`1#yymMkGDzIr7 zfy;0^Zp2I+$*Gf#t5L7x<^JSfn=WR6_nx*!rT7cf!LuJzbt8U+Ap^amcqXcYTd340 zvojT7H&g(N@dGSHWj1-R*Iz$uNqwxXuW~7TOT%6qio;&@_QF0?0B6y`$EeIS7~<7a zus-!%)WG?egIh2iA7B`^c+Ja1Hbzq)h{{Bv)mcYDGp<1G(uSNWF_@;)W!Q)j#djk7 zr8UF)Rx7?1l~Q~%x_9yZi15-YS8F5!*i_6l-7uU^LURsbjty*bh=UoZ^0l1aV Ap#T5? From 2578eb28c4fd7331823cc6e4c0adb1d81d2d4466 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 16 Oct 2026 19:06:05 -0700 Subject: [PATCH 09/12] style: format test_create_site_users.py with black 26.5.1 Co-Authored-By: Claude Code --- tests/commands/test_create_site_users.py | 26 ++++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index d26c31f7..d481f062 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -10,6 +10,7 @@ --complete (default) - per-row output derived from job.status_notes on the completed job """ + import argparse import io import unittest @@ -62,9 +63,11 @@ class CreateSiteUsersTest(unittest.TestCase): def _run(self, args, completed_override=None): from tabcmd.commands.user.create_site_users import CreateSiteUsersCommand - with mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, mock.patch( - "tabcmd.commands.user.user_data.UserCommand.validate_file_for_import" - ), mock.patch("tabcmd.commands.user.user_data.UserCommand.get_users_from_file") as get_users: + with ( + mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, + mock.patch("tabcmd.commands.user.user_data.UserCommand.validate_file_for_import"), + mock.patch("tabcmd.commands.user.user_data.UserCommand.get_users_from_file") as get_users, + ): fake_server = mock.MagicMock() session_cls.return_value.create_session.return_value = fake_server get_users.return_value = [ @@ -132,14 +135,15 @@ def test_failed_job_logs_status_notes_and_exits(self): failed.id = "abc-123" failed.notes = [] failed.status_notes = [{"type": "UserError", "value": "bob", "text": "invalid role"}] - with mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, mock.patch( - "tabcmd.commands.user.user_data.UserCommand.validate_file_for_import" - ), mock.patch( - "tabcmd.commands.user.user_data.UserCommand.get_users_from_file", - return_value=[TSC.UserItem("bob", "Viewer")], - ), mock.patch( - "tabcmd.commands.user.create_site_users.log" - ) as log_fn: + with ( + mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, + mock.patch("tabcmd.commands.user.user_data.UserCommand.validate_file_for_import"), + mock.patch( + "tabcmd.commands.user.user_data.UserCommand.get_users_from_file", + return_value=[TSC.UserItem("bob", "Viewer")], + ), + mock.patch("tabcmd.commands.user.create_site_users.log") as log_fn, + ): server = session_cls.return_value.create_session.return_value server.jobs.wait_for_job.side_effect = JobFailedException(failed) with self.assertRaises(SystemExit): From 4cc36f56895afae4ac1dfcfeab0495d47f3431f0 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 16 Oct 2026 19:08:17 -0700 Subject: [PATCH 10/12] fix: createsiteusers timeout message, job id under --nowait, bulk_add errors - on wait timeout, say the import is still running and print the job id - always print the job id under --nowait, even with --silent-progress - route any bulk_add failure (not just ServerResponseError) through exit_with_error - tests for the first two Co-Authored-By: Claude Code --- tabcmd/commands/user/create_site_users.py | 11 ++++++++-- tabcmd/locales/en/LC_MESSAGES/tabcmd.mo | Bin 24782 -> 24917 bytes tabcmd/locales/en/shared_wg_en.properties | 1 + tests/commands/test_create_site_users.py | 25 ++++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index 010a4dfb..03573bc3 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -64,11 +64,13 @@ def run_command(cls, args): # the async processing on its side. try: job = server.users.bulk_add(user_obj_list) - except TSC.ServerResponseError as e: + except Exception as e: + # includes ServerResponseError and version-gate errors on servers without bulk_add Errors.exit_with_error(logger, exception=e) return - if not args.silent_progress: + # Under --nowait the job id is the only way to track the import, so show it even when silent. + if args.nowait or not args.silent_progress: logger.info(_("importcsvsummary.job.queued").format(job.id)) if args.nowait: @@ -87,6 +89,11 @@ def run_command(cls, args): cls._log_job_details(logger, je.job, logger.error) Errors.exit_with_error(logger, exception=je) return + except TimeoutError as te: + # the import keeps running server-side; say so and give the id so it isn't blindly retried + logger.error(_("importcsvsummary.job.timeout").format(job.id)) + Errors.exit_with_error(logger, exception=te) + return except Exception as e: Errors.exit_with_error(logger, exception=e) return diff --git a/tabcmd/locales/en/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/en/LC_MESSAGES/tabcmd.mo index 16a2afdad71cafe214f186f94f37aab86b02d03a..64706386a72daa61b4d20f77fc2f4f0feba479a9 100644 GIT binary patch delta 4494 zcmYM$3vgA%9mny1FhC&h1VUm0If(&60zpW4CT)17B?*EGK>?`=661{_DdDC%T6;`< zRGu@Sl(7^CP(Xu<7AuH~1$9KkieSpLrT7>Tm|`tdn(6Sc-`_nuQ|8Y7?Cv>d_rL%D zo*VvjAgb$Nlz%EEdcE;4A<8TZSH-CJ|9@AK%&w<<6%#Q%*(?Y1u|HPfAgo0XeH?(B zoqMq#{iDdoPV=Sbx^W=4TYO6JU?CQAVFqf#`!N?cxa<3n2kdXi$NtM#HpZm}3k=5s z`jb%`X+jT|qn_(@d?N1+TjV*f<3uO^QNKtqfwcd>eEof3veWck@INJp;EdRRolO! z4&`-3sSJ%kO*jpefm+vp06qG@L1knQ^06a)>AruU7Vb&((jQ2}qcH}RiMgl+mZEC+ z1ZqbwqEh(@s^+KA!+)agOQfTX6eF9q8L07)b2Vy1uOc5i&KG;~?fal%DO^>}Z^TGy zTz>&7ux8{|TaGGCC-SjR`O*&mjao2;XjS{MsDP)T#%H54dmk#mm8kP)&qo@(*Qgzw zbY4Otv;>~kVHu9f%oJ2==b-{^Mv`EUqn_W6s_{Fh0FRx-!SqItEnp*&1ueJiJ- zTFyn)sMX!D3i;R$H+}@Q;5X>uH8-BkyQaf70f%D)YM!T1^S+GQ$YJCWJL`-mjlNPb zng(Mw3zf)&!84~!+G3|f9v`&6kGx2B2}@8sDYP?6Q5O7Z}1#5Jhbt>DJsuUa`yq~C}t(Kci=_96Dg|6m`C z8Wof-7JJhlfYF$ZF_?!#^!|^a!KQ2ueuN#UKxU5SFCX56+RNw_;gI~f$s6dyZ0)HBP)vA+*BKr(A!B?1!eR!JeTP8A> zRikRz?%aYN{R7yDU*S*jrt!hT>rsdLW9N6y7_w!H=sxVS-c+KLuKf5)J9Uug2Olg6X@UNY(-_Ty^Q>aY5b9al{n@{ z{DHv(sKZswUD{C#s-}A|1G}Bc3@S5)$dJ{c0@#dt{y3&!H);cYCk1C>Au8Zs_%!;{ zSc{5q2M)s5QMKwqUB8N|Y3k%4LzA%|{f(#vUcoGU7d7!0sJG%WdYC^YI7^kN*K#3h zUjIQFD#2RJ!A>Omb_CzYUQ^8;R3CLXr%wz1SG*LJ;!T(sMe1GuGhPAJ_A+*1O*!C()mS9k?E;f~8mU^NqJ-e;|kn` z%HTw{O_FRj#^VO;i`!iP6;#RgW8`e$YM+5_8rs2QGlSpqtvHPSJ}khmQP&e^@gGhc zj*GAZ+wnB2bPYEL_bqBfSvE9Mitjib>UAH|-y3KMW0dbkCZ z@`I?;-Tt6 zQgsevFm*2fiNGw3#%xq(^6)w=K<&H;m62*xM(Rd98Y%;$kTP2hs$|=o7g4D%zdiWfScD3!9aHfc+=IWzQf!ahaz^ zZ)pqf9-8%SUd0?bi#@vDeRZL5sJX$ryQS3&-&60kEvs+!LQ8($(i-;km{%G0Ld(2m Y;ZReP*V@+Hteab!b?@#UCVi6qKT|gXkpKVy delta 4396 zcmYM%32anF9LMp0p{0dVEX($QmiCp_mQp~V2!$?6k;);cU@Ml(LhwRSkaCASAtWkd zM2-MfQV))b57=;3Y7lN@$f|g4(5+H;is6h4m>zhQf+0V@Edo%z4f9CC$`nssY zd!zh@Q1oi!UqX~wIxdM*@Be@26U{o%y@0o1TwAj&OvNPZj~RGBdiV^c;BsdzhUxD? zURKYW?)x1(n)!B6P3t# z^l%>PzSrFNN7xhyX0g8g?k;G_ESfkE^RYiFpedMz%P|?>M=kUvrs5S$zy#9LLTTt> zKj&CCz5q4MT3{!;4K@&q@{(5P@hl0T%3!Vu*QuaM4gG#sDLjcYg@Q| z5J&-PzEP+IXQ9?vf!%ORd+M)9k1?Q$o3olGC_rUC$o0#SqS#Yze6e#ia;w!MyI^%l z4E7UhODY{UpAq)}BIj+$>=8ujNho56q%-7@55@A9UM528wZ(T%qz4eecL)c8>J z@KJ0!tk{$pYN2DO`TlVI7;aJpl2L(n_i5ZlV+e8%Z4RoGFQN8yE9x-*h^maD)PyOh z%nMzABzp9pKviNX^0FG)Sit)W#pqu_!erfcc6!R zQP-VDWzvE{lC(_Jc*I$WS@f46FWbZ$x!A!#-_Fv|UN`5erqZDL-BE#+AeY#9)Rrtn zUbcZZlCUpO3!X*oc}t>Jz$vKlTvTO;qXL|Ws>p&SeY4dxl)*OVAykB?P>1ASRArKw zO{MLM3bX`C-YQV{&qM9`3RHk^qaNo2sOx@0C3FonUx-Ic=g;zKD56r-1rH-HoA1VJ zPz!#H9v*e$mr;kUH4lAP?2nq~G1M7Zgi2&Ra;fchoX z$`tO>ggsD!+=ES-x&9*OdgsTe`|45GT|`wZjz>p*4>ezZXF2LjOwXtO9*rdoD5H(Y z_SmNwk0()qokvX=&$pEp%0^8bL1prw>(54AzZ^Z>hC0kgPyw7lZAl|)y>y>n7?r#b zm1#NZ!WpOqmN_@M@jb4892L-?$Szm{2S5w=M)e;+Ei@lFzqS@N-)`)V^{6x9M{|O7 zi1K;USr~)5VWIO4RABqu=O<7%#xRQl?cyv&ie#0jg_oi#Q|mrI;5fbrOXv3L%n@iNBYHB^PH zrxIuX^JsiczZ?}vRxf^Hu^9W}JE%ZTp(=D06EVFo_|fWyrS!}3POL_qk>eP`E0}<> zy@Sdop+~<6JFvc0(BQyY73zld*bcYr0^E@N&?^cS zejIhE-*DDBf5nlEx9AsCsH`9L*Pc#epaN@g6gFdUCYIr7JcRdRT5+(qQ_-V88#~|{ zRE6G0C2|&(Ve3dxfj-VsR0Yeh3Rgy`|4TID2Jmf%t5An4gR7L$Ak>~N#rF7>^H)@5 z8j+Xv<{||!6LtS44B=O(1b)F(>@hG1co-(ppW@Td9?i!Ld=dE)v|7{!M^JnE8-_7l z5=@+r>GUJ03RIw;hAQ;%P1G5xLp_c^qUO7T3Z&JbV2k{08dTj%@c{a`K>fRdL%A0V z=$}KCHhD0A2So80qWU8_`r6Yftj9Vm!?kytaeV9o>agZePAy!Hx8l>tSAlPf{N#$ zet)cxhCUdFX*kRIDk}1=$eh-IOR+OiYQddIQS2MkQQJ6W1$1>Z_19qtJrF!bMW|BEK@Y1jf}f(U``ejW7A#zfI{lAeAwGwB zxC=Q(_A@494rNTlVobsjs7g&Pr~Yv?mNC!_S7S7;MO9`!HpjP7nQul_WIw7R4XDb+ zJ{bIUoQ$eKF1EyOs58;mjSoTvG7NKZtWP6>#v)WEub?Wh1=(IZjM}q|F@aB_O8*h+ z`{Fn%uz#=}raZ*|2eAN)@eB^c%!=S~ti%-hYmnlYzm0}U9L=+(2}`g5D{&Ss!>QP8 zTrj~j)ShoZ1-Ks-KzO{_v)B_=+6}1r4&!Zj!Sy>%;QtczBgo6Q6TGJ}FkoVELj`*D z=b|dJ&hBn<+R+oH|`5%7y-U$Ez diff --git a/tabcmd/locales/en/shared_wg_en.properties b/tabcmd/locales/en/shared_wg_en.properties index aaa49886..e1607b99 100644 --- a/tabcmd/locales/en/shared_wg_en.properties +++ b/tabcmd/locales/en/shared_wg_en.properties @@ -10,6 +10,7 @@ dataalerts.failure.error.workbookNotFound=Workbook not found dataconnections.classes.tableau_server_site=Tableau Server Site materializeviews.errors.project_path_not_exists=Project path ''{0}'' does not exist importcsvsummary.job.queued=User import job queued on server. Job ID: {0} +importcsvsummary.job.timeout=Timed out waiting for the user import job. It is still running on the server. Job ID: {0} importcsvsummary.error.details=Error details: importcsvsummary.remainingerrors=(remaining errors not shown) importcsvsummary.error.line=line {0} for user ''{1}'': ''{2}'' diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index d481f062..2181ae35 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -151,6 +151,31 @@ def test_failed_job_logs_status_notes_and_exits(self): logged = " ".join(str(c) for c in log_fn.return_value.error.call_args_list) self.assertIn("UserError: invalid role", logged) + def test_timeout_reports_job_id_and_exits(self): + from tabcmd.commands.user.create_site_users import CreateSiteUsersCommand + + with ( + mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, + mock.patch("tabcmd.commands.user.user_data.UserCommand.validate_file_for_import"), + mock.patch( + "tabcmd.commands.user.user_data.UserCommand.get_users_from_file", + return_value=[TSC.UserItem("bob", "Viewer")], + ), + mock.patch("tabcmd.commands.user.create_site_users.log") as log_fn, + ): + server = session_cls.return_value.create_session.return_value + server.users.bulk_add.return_value.id = "job-77" + server.jobs.wait_for_job.side_effect = TimeoutError() + with self.assertRaises(SystemExit): + CreateSiteUsersCommand.run_command(_base_args(timeout="5")) + logged = " ".join(str(c) for c in log_fn.return_value.error.call_args_list) + self.assertIn("job-77", logged) + + def test_nowait_with_silent_progress_still_prints_job_id(self): + with self.assertLogs("CreateSiteUsersCommand", level="INFO") as logs: + self._run(_base_args(nowait=True, silent_progress=True)) + self.assertIn("abc-123", " ".join(logs.output)) + if __name__ == "__main__": unittest.main() From 6c6556cb839a6209370765e9663a1d72a2ad772b Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 16 Oct 2026 19:19:27 -0700 Subject: [PATCH 11/12] createsiteusers: save per-row import errors to a file (--errors-file, default per job id) Under --silent-progress rejected rows are written to createsiteusers-errors-.txt instead of being dropped; --errors-file overrides the path and also enables the file without --silent-progress. Co-Authored-By: Claude Code --- tabcmd/commands/user/create_site_users.py | 44 +++++++--- tabcmd/locales/en/LC_MESSAGES/tabcmd.mo | Bin 24917 -> 25189 bytes tabcmd/locales/en/shared_wg_en.properties | 1 + .../locales/en/tabcmd_messages_en.properties | 1 + tests/commands/test_create_site_users.py | 77 ++++++++++++++++++ 5 files changed, 110 insertions(+), 13 deletions(-) diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index 03573bc3..88341a7e 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -28,6 +28,7 @@ def define_args(create_site_users_parser): UserCommand.set_auth_arg(args_group) set_no_wait_option(args_group) set_silent_option(args_group) + args_group.add_argument("--errors-file", default=None, help=_("createsiteusers.options.errors-file")) @classmethod def run_command(cls, args): @@ -86,7 +87,7 @@ def run_command(cls, args): except JobFailedException as je: # JobFailedException only carries the legacy job.notes, which can be empty # even when status_notes names the rejected row and reason. - cls._log_job_details(logger, je.job, logger.error) + cls._report_job_details(logger, je.job, logger.error, args.silent_progress, args.errors_file) Errors.exit_with_error(logger, exception=je) return except TimeoutError as te: @@ -99,6 +100,8 @@ def run_command(cls, args): return if args.silent_progress: + # No console summary, but rejected rows must not vanish: save them to a file. + cls._report_job_details(logger, job_done, logger.info, True, args.errors_file) return # Summarize per-row outcomes. `status_notes` is populated for @@ -122,7 +125,7 @@ def run_command(cls, args): logger.info(_("importcsvsummary.line.skipped").format(skipped)) logger.info(_("importcsvsummary.users.added.count").format(added)) - cls._log_job_details(logger, job_done, logger.info) + cls._report_job_details(logger, job_done, logger.info, False, args.errors_file) @staticmethod def _timeout_seconds(timeout): @@ -134,16 +137,31 @@ def _timeout_seconds(timeout): return seconds if seconds > 0 else None @staticmethod - def _log_job_details(logger, job, log_line): - # Any statusNote whose type isn't a CountOf* aggregate is likely a - # per-row message the user should see. + def _job_detail_lines(job): + """Per-row messages for a finished job: any statusNote whose type isn't a CountOf* aggregate, plus legacy notes.""" status_notes = getattr(job, "status_notes", None) or [] detail_notes = [n for n in status_notes if not (n.get("type") or "").startswith("CountOf")] - notes = getattr(job, "notes", None) or [] - if detail_notes or notes: - log_line(_("importcsvsummary.error.details")) - for note in detail_notes: - text = note.get("text") or note.get("value") or "" - log_line(f" {note.get('type', '?')}: {text}") - for text in notes: - log_line(f" {text}") + lines = [] + for note in detail_notes: + text = note.get("text") or note.get("value") or "" + lines.append(f"{note.get('type', '?')}: {text}") + lines.extend(str(text) for text in getattr(job, "notes", None) or []) + return lines + + @classmethod + def _report_job_details(cls, logger, job, log_line, silent, errors_file=None): + lines = cls._job_detail_lines(job) + if not lines: + return + if silent or errors_file: + path = errors_file or f"createsiteusers-errors-{job.id}.txt" + try: + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + logger.error(_("importcsvsummary.error.file").format(path)) + return + except OSError: + pass # can't write the file: fall through and show the details instead + log_line(_("importcsvsummary.error.details")) + for line in lines: + log_line(f" {line}") diff --git a/tabcmd/locales/en/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/en/LC_MESSAGES/tabcmd.mo index 64706386a72daa61b4d20f77fc2f4f0feba479a9..7fc92f56704f1ac074a20776300f94332e39be44 100644 GIT binary patch delta 4667 zcmYM$4^WiH8OQNeQHThNAO}I@jZ_QrPfZ<`L8vFu(k3+ zRI1mYo`59nQ6=Otqp0Jc!CfudQD~hx(VO%=n0sqRB?R=ay5@#D44Z=uqE-%19S# zf{#&q_!(+NBOEWKd8pro669kR@ul|ysDb4y#wQQ|NlTi5&jjGk|=t~ z@u(FPTbH8(Y($-z?WoMOqf+~8RG|GxlFSWM|6yDq?Rgq1z{xmpy>a0F*HF-kwxR|+ zfZEG0R3PW<^UslwiR4bHJsUM)DLS~uw!5gq)`r=5+P2?CjT=k4w2(>YYQQoIvH_Ke zoyfc9ZB!~Rpd!DE8X%E7ssQp)&ljTlJ&Rhv3#b(yKrQSz>b;L}Ak(Nr9F;--M^KnT zO(`ltrEW1Q#Q{{uAE8ot5H;XQR3H~of!wwASgwTHCs|8T{i;##Z9-*ikF6igWdAkb zX&U5J)OGs`9gO2wNh``nw$seVXly_Qwh=YpUerXtKuy$#TF5n9AHp9Hy`PE>7NZXL zYL|j0ZbI$JHq?ZNQ7P|5t>`N1y{}OdB(t${Dysc)Tdze0v<2BM^BQX6cWwO&YMw|A z2)0s^3mjKqsu{kYt)$sEK|2 zswp$mk@sA)oPr)Sp;o@bdteTs0_#O>$t8Rp|AxA5FHQFTW*op-)X$){Xy_F0)Mw&w z>MQU8tiot~79+6*BXs{=3Q;s{MWw72weolHL%fO#y6*pBhoX`lBY zF`4UFg1!QNWN-y4u%Dn7b`-U+^QZ+3=F5$xFpGjBo{w6=_fUJb1!M3n)BxQWhkr(P z!Q4USH0d+EJ*~8EM2Ge^T!-B_4=2p@CT>6-?n5)lzY3>q!%cjgdNdoFgq5hh4f0iq zNAU@aEHWmf=eQJqhl|iD_Vze{4)sm=AnrqD=mcsZe?cuUZZ`Q>28w2TE2R$ z#EWnY^*U65E|P7t1IOWSQCs#Y>b;mpyuD3FW$Fn`z$2)MdT}g%g38EkZn8d6i1%lD6<&{4jqmh#G$kG>!o&! zN#6zLHGlu2B`|M##GSK@xu z%I_f;%OsY2QJ>K;a6`jh;8Vm3x#9x5}_a0t#qt-J)4kyV(1&8W=n$B}r{rJxL)#DP7*;ne$W z`yWw(T)~O>57gmFddgeLL{tVIMvj5;qqeNmI=IqH{bJPbMi3R)E2zx6$0(ej@E#W7 z_qj83unPbhwe@$fo%$!pt7iSv-dQ-0em! z7GiXj{+K<&our^tEJv-h4vTQJ^&}QkzlRA}Q0?t$Ihv}Pnq7kuk~)&&{ysQ9&=jr< zG=_5h!C)X5%6X=)-rw09m3DoV;_LW0Wp|QWQMKOhH2H%$!9a^s*U%IQh8?eq z6An1x+PaXV4!O=;|1(w1_2H13*9QGnVSlJD>~9YF8QjiMye?4f)YX*ahBt(rmRf(K zlamvob7MHCDH!-}&>sppj7Ogu^XR}3PDlN;1tSV80u6pO2SZMaKj?QF17RDBzeY2M XtLp0QqY&}dD27dwpYQ0L^E2mv?lC^z delta 4412 zcmYM$2~bs49LMoLih{}xA_&TbP!U{`#SIGA5-`maP0a!uGR^#7NusTX;x-7nMO`!woIf>wrCmCxYYOe?wQGX^FHUCd+#~_^Z%dwuz7D-)1EN@ zSVH)6<6lgeSvoF`RPXF<77%=F%UE+Q@A5a3Sis z1~>syPvA(b#S@c`_J<*0yaQ9E9Z$+!o#&{vp(;cO=wyPy`zLl4WGv)uS1 z)ch}_0)GdSSl^D)P{gNEk;Slz7W7aL48tB+iP?C+b1iDYk1z+Du^Yyae+5{GdhQ12 zBk0lJ>^z9RYI~XnAB$o;mtZz(p;4&ow_s?>F>1bQ z)CL!z)>(_aaYrioSEMHx(8P(X#wM&7wexFSzXnN;-RH(1cdkPowe3hX><|)*9Y>YK z(#QtHU$7H^Q9dhL8bVj8&78&+Q~q^RKrTt^?B&w z6R1P`25N^#Pz$!gS^}G2Ex2h^Jy-tVPbFy@*Qbc2sS@Mjgu5M5zq*Mol;Z zm4PbPzXv_~PoXlh4f)sszI5L&sD;}Qz4W`#@MsjFGBFXgz#LTV9!2fw6;vueL)H8k zdiWdazF0ciNCC2G8;u&D>0E-^&}+!Y4)evHeETbCSOQm7^Gh+58rPqS3hXxIR$GWF zO#||=&-v00|3odAK(wlTe^kIDP~+oKnY{}Y;3CxdvzI~*-fPqjjylgG5n2pS>#+1f zWo8&EwUbeS-i9Q>9!5RC9#!LAr~nV5Uh6Zc`@(s(w4rQFW_>HAp;}Hv)o8A}VKMTt z4Q~7ZYQdk;!;5Y_gLh4bZ4mat8K`-lM9sSiwUPbEBX+_WO&WcrBA*6hHWro2d8o*r zL`|?2HQ^z5{S@lC4!pCE+sQXI=& zdaxTRl_O9SPD2H9H->iR`kS2loj;?VYsIUg`_fPuD{}p-Q1i`j*7qd;IuxrI@bER% zjt(K^vXj^WqxqFmV5z7H2cj0b8nw_&)J7h1{TER8zl$CoMIG)7r~u;l!=jSp`82d( z87k#fsGZiMZhQ{4z+2A4Zv0o*kEGxVC=02I4Mr_o?fMU*7TSQEXZrv(-wEu4ek)Fn z4n;rIA)3gS&cb}u0}akkQGxy8u1E2-p36rCI>|W~Nt!J|ExZktnI?DrtQ(Kx*N%<* z)`x~B7>SCk3RRMOa3wB9y>7Xe27lE`aWMT^s1mJ1He(-Sd;A;QVOYPQbR95)ekTma z42;BV?5_8}Hw`vr6YvwPM+Gt_pTB%~GY-HnQGvu11R1&nsj2vKFgE}nxF%iFY*H0rcSX7bO_NE`<8~xWfiDe);ZUpM}IfY z!XNQUylP;u@N(3l{?z%qGky^FFkUc-{HM^UV?Z^11}9?^j>EnT-l6Mw4W7j@II1|P z?F#hhzkr=_FDgUdp*E6G5*)@s7(@R?=Uh|<>q^LfEsggWScFBF@dpNXqYhUEcWFnr zqiVVhQ?bPv&!94shYVRYDuC6f=MQ57wxBl9en@a8rlA79)2Gpq#xhid8?Y;GMb)Ya zb^QXWrinv?3=PF3`YTZjY{7JVA2sp!sJG%AdYCgTI7?-y*K!(aUjJSiD#0?$#0Dh$ zb^t%Xw!_WtRUdUYM~w*nS3C!m;#C+MM(SPv3tj=$_8c~0*%iE5m_ZsjR#uBTv>TCi zeLG5{8v|#N-v&z?6$Da_qv_9fzJv1_! z1xu;m=NqrZ(BJ=MG?e;{*aLTjE^wMLjsAIO3ehO?LS!wg!N+hjDuaXBHc7Ja7>z5i zJ+5>8EvS<1#L(HmB|ZZ!G_->SV}jrEwb+yX4$Q@$P}gI}@*hs@h10Pf>#!MBx*1mo z_bvg{e&J>^pos2AorR}SuhTA6sxG33SrdZ) z#}7x{_n>n~m^hLDL|{6G zV+JZS+1MI$Q9JL0%18w&BQ>bZEyY+|h04II7%ByANw>E$wBOnKVURQP6~FC zjLJYiq|8={D%m>c8C2>^uMK`TrlSI@!$f=zx8dtpjI$;OZ^e4-Pv1XIqZ5s;yh~)w z`eO+$Lj4f!!#q5W^DuF0@LRtSHNjt~)aP9n1XzIzU?tAQ?WoM=O$+9$#J==vgT8Ms z(pbyD`^d*`x;{7ydr<*=j~@Pu%8YkI(C>pP-B?s+YH%dJi39K)#$!QMkcp9~jZQ{o yVopdxeU(ND11B*FyH^J_Ev|0t93S1fu_Eb{h{pW%CsG.txt when --silent-progress is used diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index 2181ae35..5bbbc62a 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -35,6 +35,7 @@ def _base_args(**overrides): continue_if_exists=False, nowait=False, silent_progress=False, + errors_file=None, logging_level="INFO", timeout=None, username=None, @@ -176,6 +177,82 @@ def test_nowait_with_silent_progress_still_prints_job_id(self): self._run(_base_args(nowait=True, silent_progress=True)) self.assertIn("abc-123", " ".join(logs.output)) + def _rejected_job(self): + job = mock.MagicMock(spec=TSC.JobItem) + job.id = "abc-123" + job.finish_code = 0 + job.notes = [] + job.status_notes = [ + {"type": "CountOfUsersSkipped", "value": "1", "text": None}, + {"type": "UserError", "value": "bob", "text": "invalid role"}, + ] + return job + + def test_silent_progress_saves_rejected_rows_to_file(self): + import os + import tempfile + + cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmp: + os.chdir(tmp) + try: + self._run(_base_args(silent_progress=True), completed_override=self._rejected_job()) + with open("createsiteusers-errors-abc-123.txt", encoding="utf-8") as f: + content = f.read() + finally: + os.chdir(cwd) + self.assertIn("UserError: invalid role", content) + + def test_errors_file_flag_sets_custom_path(self): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "custom.txt") + self._run(_base_args(silent_progress=True, errors_file=path), completed_override=self._rejected_job()) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("UserError: invalid role", content) + + def test_errors_file_flag_without_silent_writes_file(self): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "custom.txt") + self._run(_base_args(errors_file=path), completed_override=self._rejected_job()) + self.assertTrue(os.path.exists(path)) + + def test_silent_progress_writes_no_file_when_nothing_rejected(self): + import os + import tempfile + + cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmp: + os.chdir(tmp) + try: + self._run(_base_args(silent_progress=True)) + created = os.listdir(tmp) + finally: + os.chdir(cwd) + self.assertEqual(created, []) + + def test_not_silent_prints_rejected_rows_and_writes_no_file(self): + import os + import tempfile + + cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmp: + os.chdir(tmp) + try: + with self.assertLogs("CreateSiteUsersCommand", level="INFO") as logs: + self._run(_base_args(), completed_override=self._rejected_job()) + created = os.listdir(tmp) + finally: + os.chdir(cwd) + self.assertIn("UserError: invalid role", " ".join(logs.output)) + self.assertEqual(created, []) + if __name__ == "__main__": unittest.main() From 0baa66365adcfa2b8bb50e7f4cc49b65eaa7cf24 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 19 Oct 2026 13:15:40 -0700 Subject: [PATCH 12/12] createsiteusers: fix rejected-row detail to use job.notes, not merged status_notes The server does not emit one statusNote per rejected row for a UserImport job. RestApiHelper.getStatusNotesErrors merges each error field (line/username/message) across ALL failed rows into a single statusNote per field, joined by ";", and omits the CountOf* aggregates entirely once there are errors. The actual one-line-per-row detail is the legacy text built by UserManagementService.getHumanReadableCSVImportSummary, which TSC surfaces as job.notes. _job_detail_lines now prefers job.notes and only falls back to non-CountOf* status_notes if notes is empty. Added tests/assets/createsiteusers_job_with_errors.xml as a fixture matching the real response shape, parsed via TSC.JobItem.from_response in _rejected_job() instead of a hand-rolled status_notes mock. Co-Authored-By: Claude Code --- tabcmd/commands/user/create_site_users.py | 16 +++++++-- .../createsiteusers_job_with_errors.xml | 26 ++++++++++++++ tests/commands/test_create_site_users.py | 36 ++++++++++--------- 3 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 tests/assets/createsiteusers_job_with_errors.xml diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index 88341a7e..f30f7d36 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -138,14 +138,26 @@ def _timeout_seconds(timeout): @staticmethod def _job_detail_lines(job): - """Per-row messages for a finished job: any statusNote whose type isn't a CountOf* aggregate, plus legacy notes.""" + """Per-row messages for a finished job. + + The server's statusNotes are not one entry per rejected row: when a UserImport job + has errors, it merges each error field (line/username/message/...) across every + failed row into a single statusNote per field, with values joined by ";" + (RestApiHelper.getStatusNotesErrors). That's unreadable per-row detail, so it's not + used here. The real one-line-per-row text lives in the job's legacy notes + (UserManagementService.getHumanReadableCSVImportSummary), which is what we show. + Only fall back to non-CountOf* statusNotes if notes is empty, in case some other + job type without a notes summary ever lands here. + """ + notes = [str(text) for text in getattr(job, "notes", None) or [] if text] + if notes: + return notes status_notes = getattr(job, "status_notes", None) or [] detail_notes = [n for n in status_notes if not (n.get("type") or "").startswith("CountOf")] lines = [] for note in detail_notes: text = note.get("text") or note.get("value") or "" lines.append(f"{note.get('type', '?')}: {text}") - lines.extend(str(text) for text in getattr(job, "notes", None) or []) return lines @classmethod diff --git a/tests/assets/createsiteusers_job_with_errors.xml b/tests/assets/createsiteusers_job_with_errors.xml new file mode 100644 index 00000000..f15c8c81 --- /dev/null +++ b/tests/assets/createsiteusers_job_with_errors.xml @@ -0,0 +1,26 @@ + + + + + + + + + + processed 5 lines, skipped 0 lines, added 3 users, found 2 errors +line 3: user bob: invalid role +line 5: user alice: bad email + + diff --git a/tests/commands/test_create_site_users.py b/tests/commands/test_create_site_users.py index 5bbbc62a..d67a0b20 100644 --- a/tests/commands/test_create_site_users.py +++ b/tests/commands/test_create_site_users.py @@ -8,11 +8,14 @@ are suppressed - pre-flight validation aborts before bulk_add on malformed input under --complete (default) -- per-row output derived from job.status_notes on the completed job +- per-row output derived from job.notes (the legacy per-row summary text) on the + completed job, not job.status_notes -- see _rejected_job and + tests/assets/createsiteusers_job_with_errors.xml for why """ import argparse import io +import os import unittest from unittest import mock @@ -134,8 +137,8 @@ def test_failed_job_logs_status_notes_and_exits(self): failed = mock.MagicMock(spec=TSC.JobItem) failed.id = "abc-123" - failed.notes = [] - failed.status_notes = [{"type": "UserError", "value": "bob", "text": "invalid role"}] + failed.notes = ["line 3: user bob: invalid role"] + failed.status_notes = [] with ( mock.patch("tabcmd.commands.user.create_site_users.Session") as session_cls, mock.patch("tabcmd.commands.user.user_data.UserCommand.validate_file_for_import"), @@ -150,7 +153,7 @@ def test_failed_job_logs_status_notes_and_exits(self): with self.assertRaises(SystemExit): CreateSiteUsersCommand.run_command(_base_args()) logged = " ".join(str(c) for c in log_fn.return_value.error.call_args_list) - self.assertIn("UserError: invalid role", logged) + self.assertIn("line 3: user bob: invalid role", logged) def test_timeout_reports_job_id_and_exits(self): from tabcmd.commands.user.create_site_users import CreateSiteUsersCommand @@ -178,15 +181,16 @@ def test_nowait_with_silent_progress_still_prints_job_id(self): self.assertIn("abc-123", " ".join(logs.output)) def _rejected_job(self): - job = mock.MagicMock(spec=TSC.JobItem) - job.id = "abc-123" - job.finish_code = 0 - job.notes = [] - job.status_notes = [ - {"type": "CountOfUsersSkipped", "value": "1", "text": None}, - {"type": "UserError", "value": "bob", "text": "invalid role"}, - ] - return job + """A job with rejected rows, parsed from a server response shaped like the real one. + + The server doesn't emit one statusNote per rejected row -- see + tests/assets/createsiteusers_job_with_errors.xml for why. The real per-row detail + is the legacy text, which is what this job's `.notes` carries. + """ + xml_path = os.path.join(os.path.dirname(__file__), "..", "assets", "createsiteusers_job_with_errors.xml") + with open(xml_path, encoding="utf-8") as f: + xml = f.read() + return TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"})[0] def test_silent_progress_saves_rejected_rows_to_file(self): import os @@ -201,7 +205,7 @@ def test_silent_progress_saves_rejected_rows_to_file(self): content = f.read() finally: os.chdir(cwd) - self.assertIn("UserError: invalid role", content) + self.assertIn("line 3: user bob: invalid role", content) def test_errors_file_flag_sets_custom_path(self): import os @@ -212,7 +216,7 @@ def test_errors_file_flag_sets_custom_path(self): self._run(_base_args(silent_progress=True, errors_file=path), completed_override=self._rejected_job()) with open(path, encoding="utf-8") as f: content = f.read() - self.assertIn("UserError: invalid role", content) + self.assertIn("line 3: user bob: invalid role", content) def test_errors_file_flag_without_silent_writes_file(self): import os @@ -250,7 +254,7 @@ def test_not_silent_prints_rejected_rows_and_writes_no_file(self): created = os.listdir(tmp) finally: os.chdir(cwd) - self.assertIn("UserError: invalid role", " ".join(logs.output)) + self.assertIn("line 3: user bob: invalid role", " ".join(logs.output)) self.assertEqual(created, [])