From 17141961a2826244ad7679f12ade825260912d49 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 4 Aug 2026 15:45:21 -0700 Subject: [PATCH] feat: parse / into JobItem.status_notes (#1850) The REST Query Job response schema documents a structured status-notes block: JobItem was parsing only the sibling legacy `` element (emitted by some job types like extractRefreshJob), missing the modern statusNotes entirely. For UserImport jobs and any other multi-row job where individual rows have distinct outcomes, `job.notes` came back as an empty list even when the server had sent detailed structured status. Add `JobItem.status_notes: list[dict]`, each dict with keys `type`, `value`, `text` (any of which may be None if the server omitted them). The legacy `notes: list[str]` attribute is unchanged for backwards compatibility -- it still parses the `` element still emitted by extract-refresh and similar older job types. Verified against the public REST doc: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job The existing job_get_by_id.xml test asset already contained a statusNotes block; the get_by_id test now asserts the structured value in addition to the legacy notes list. Two new tests cover the absent case (yields []) and the multi-note case with attribute omissions. Discovered while planning tabcmd createsiteusers nowait / silent-progress work (tableau/tabcmd#35); a live probe against Tableau Server 2025.1 confirmed the server emits this schema for UserImport jobs. Fixes #1850. --- CHANGELOG.md | 7 ++++ tableauserverclient/models/job_item.py | 29 +++++++++++++++- test/test_job.py | 47 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 943436b27..fc1430d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* requests. Returns the matching `ProjectItem` or `None` if no project is found. +* Added `JobItem.status_notes` for the structured `` block documented on the Query + Job REST endpoint. Populated for UserImport and other multi-row jobs where + individual rows have distinct outcomes; each entry is a dict with keys + `type` / `value` / `text`. The existing `notes: list[str]` attribute is + unchanged (it parses the separate legacy `` element still emitted by + some job types). Fixes #1850. ## 0.18.0 (6 April 2022) * Switched to using defused_xml for xml attack protection diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f684c22d4..ae6150eb5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -49,7 +49,19 @@ class JobItem: The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. notes : list[str] | None - Contains detailed notes about the job. + Detail notes emitted by legacy job types (e.g. extract refresh) inside + job-specific elements like `...`. + For modern job types see `status_notes`. + + status_notes : list[dict] | None + Structured per-row / per-metric status entries from the modern job response + schema (``). + Each element is a dict with keys `type`, `value`, `text` (any of which may + be None if the server omitted them). Populated for UserImport and other + multi-row jobs where individual rows have distinct outcomes; documented + types include `CountOfUsersAddedToSite`, `CountOfUsersSkipped`, + `CountOfUsersWithInsufficientLicenses`, etc. + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job mode : str | None @@ -100,6 +112,7 @@ def __init__( updated_at: datetime.datetime | None = None, workbook_name: str | None = None, datasource_name: str | None = None, + status_notes: list[dict] | None = None, ): self._id = id_ self._type = job_type @@ -116,6 +129,7 @@ def __init__( self._updated_at = updated_at self._workbook_name = workbook_name self._datasource_name = datasource_name + self._status_notes: list[dict] = status_notes or [] @property def id(self) -> str: @@ -149,6 +163,10 @@ def finish_code(self) -> int: def notes(self) -> list[str]: return self._notes + @property + def status_notes(self) -> list[dict]: + return self._status_notes + @property def mode(self) -> str | None: return self._mode @@ -222,6 +240,14 @@ def _parse_element(cls, element, ns): completed_at = parse_datetime(element.get("completedAt", None)) finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None + status_notes = [ + { + "type": note.get("type"), + "value": note.get("value"), + "text": note.get("text"), + } + for note in element.findall(".//t:statusNotes/t:statusNote", namespaces=ns) + ] or None mode = element.get("mode", None) workbook = element.find(".//t:workbook[@id]", namespaces=ns) workbook_id = workbook.get("id") if workbook is not None else None @@ -253,6 +279,7 @@ def _parse_element(cls, element, ns): updated_at, workbook_name, datasource_name, + status_notes, ) diff --git a/test/test_job.py b/test/test_job.py index 19f324d1e..7bfdd1840 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -63,6 +63,53 @@ def test_get_by_id(server: TSC.Server) -> None: assert job_id == job.id assert updated_at == job.updated_at assert job.notes == ["Job detail notes"] + # Regression for #1850: the response also carries a + # block per the public REST doc. Verify it now surfaces via job.status_notes. + assert job.status_notes == [ + { + "type": "CountOfUsersAddedToGroup", + "value": "5", + "text": "Description of how many users were added to the group during the import.", + } + ] + + +def test_status_notes_empty_when_absent() -> None: + # A job element with no yields an empty list, not None or an error. + xml = ( + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + assert jobs[0].status_notes == [] + + +def test_status_notes_multiple_entries() -> None: + # Multiple statusNote elements yield an ordered list of dicts. Any of type / + # value / text may be absent on a given note; missing attributes come back as None. + xml = ( + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + notes = jobs[0].status_notes + assert notes == [ + {"type": "line", "value": "0", "text": None}, + {"type": "errorCode", "value": "1", "text": None}, + {"type": "message", "value": "Actor does not have permission", "text": None}, + {"type": "username", "value": "unknown", "text": None}, + ] def test_get_before_signin(server: TSC.Server) -> None: