From 695f78460a088ec20e3746d130b1941949e17334 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 20 Jul 2026 13:54:14 -0700 Subject: [PATCH 1/2] fix: increase streaming chunk size for CSV and Excel view downloads Raises iter_content chunk size from 1024 to 65536 bytes in _get_view_csv and _get_view_excel. The 1-KB default required ~200k iterations for a 200 MB download, causing apparent hangs when callers materialise the full stream with b''.join(view.csv). Closes #1374 Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/server/endpoint/views_endpoint.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index c2779b17d..fd0afc2a5 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,6 +1,8 @@ import logging from contextlib import closing +from tableauserverclient.config import BYTES_PER_MB, config + from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, UnsupportedAttributeError @@ -264,7 +266,7 @@ def _get_view_csv(self, view_item: ViewItem, req_options: "CSVRequestOptions | N url = f"{self.baseurl}/{view_item.id}/data" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - yield from server_response.iter_content(1024) + yield from server_response.iter_content(config.CHUNK_SIZE_MB * BYTES_PER_MB) @api(version="3.8") def populate_excel(self, view_item: ViewItem, req_options: "ExcelRequestOptions | None" = None) -> None: @@ -303,7 +305,7 @@ def _get_view_excel(self, view_item: ViewItem, req_options: "ExcelRequestOptions url = f"{self.baseurl}/{view_item.id}/crosstab/excel" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - yield from server_response.iter_content(1024) + yield from server_response.iter_content(config.CHUNK_SIZE_MB * BYTES_PER_MB) @api(version="3.2") def populate_permissions(self, item: ViewItem) -> None: From b5b3de14bc4fa79b1c98bb399baecf1314bd1f8d Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 28 Jul 2026 01:01:32 -0700 Subject: [PATCH 2/2] fix: unify streaming download chunk size across Views, CustomViews, and DownloadableMixin Views, CustomViews, Workbooks, Datasources, and Flows all had a hardcoded 1024-byte chunk size for streaming downloads, which caused effective hangs on large (200+ MB) view exports (#1374). - DownloadableMixin._download_content: use config.CHUNK_SIZE_MB * BYTES_PER_MB instead of hardcoded 1024. Benefits workbooks, datasources, flows. - DownloadableMixin._stream_content: new sibling method for endpoints that need Iterator[bytes] rather than file-writing. Same chunk-size config. - Views and CustomViews: migrate to _stream_content, removing their duplicate streaming loops. Callers that materialize CSV/Excel via b"".join(view.csv) or iterate lazily continue to work exactly the same way; only the internal chunk size changes. --- .../server/endpoint/custom_views_endpoint.py | 9 ++--- .../server/endpoint/endpoint.py | 36 +++++++++++++++---- .../server/endpoint/views_endpoint.py | 15 +++----- test/test_view.py | 31 ++++++++++++++++ test/test_workbook.py | 29 +++++++++++++++ 5 files changed, 97 insertions(+), 23 deletions(-) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 55f0c7ea2..29f4b7328 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,14 +1,13 @@ import io import logging import os -from contextlib import closing from pathlib import Path from typing import TYPE_CHECKING from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size -from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem from tableauserverclient.server import ( @@ -42,7 +41,7 @@ io_types_w = (io.BufferedWriter, io.BytesIO) -class CustomViews(QuerysetEndpoint[CustomViewItem]): +class CustomViews(QuerysetEndpoint[CustomViewItem], DownloadableMixin): def __init__(self, parent_srv): super().__init__(parent_srv) @@ -229,9 +228,7 @@ def _get_custom_view_csv( self, custom_view_item: CustomViewItem, req_options: "CSVRequestOptions | None" ) -> Iterator[bytes]: url = f"{self.baseurl}/{custom_view_item.id}/data" - - with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - yield from server_response.iter_content(1024) + return self._stream_content(url, req_options) @api(version="3.18") def update(self, view_item: CustomViewItem) -> CustomViewItem | None: diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 31a0806dc..5f00296af 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -9,6 +9,7 @@ from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError +from collections.abc import Iterator from typing import ( Any, Callable, @@ -20,8 +21,9 @@ ) from typing_extensions import Self +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.models.pagination_item import PaginationItem -from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.request_options import RequestOptions, RequestOptionsBase from tableauserverclient.filesys_helpers import to_filename, make_download_path from tableauserverclient.server.endpoint.exceptions import ( @@ -338,9 +340,13 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: class DownloadableMixin: """Mixin for endpoints whose resources can be downloaded as binary files. - Provides a single private helper that streams a server response to a file - path or writable file object, avoiding copy-paste of the identical streaming - loop in Workbooks, Datasources, and Flows. + Provides two private helpers, avoiding copy-paste of the streaming loop + across endpoints: + + - _download_content streams a response to a file path or writable object + (used by Workbooks, Datasources, Flows). + - _stream_content yields chunks for callers that want to materialize the + body in-memory or process it lazily (used by Views, CustomViews). """ def _download_content( @@ -368,18 +374,36 @@ def _download_content( m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] filename = m.get_filename(failobj="") + chunk_size = config.CHUNK_SIZE_MB * BYTES_PER_MB if isinstance(filepath, _io_types_w): - for chunk in server_response.iter_content(1024): # 1KB + for chunk in server_response.iter_content(chunk_size): filepath.write(chunk) return filepath else: filename = to_filename(os.path.basename(filename)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB + for chunk in server_response.iter_content(chunk_size): f.write(chunk) return os.path.abspath(download_path) + def _stream_content( + self, + url: str, + request_object: RequestOptionsBase | None = None, + ) -> Iterator[bytes]: + """Stream content at url as chunks. + + Suitable for callers that want to materialize the body in-memory + (e.g. b"".join(iterator)) or process it lazily as a stream, rather than + write it to a file. Complements _download_content, which writes to disk. + """ + chunk_size = config.CHUNK_SIZE_MB * BYTES_PER_MB + with closing( + self.get_request(url, request_object=request_object, parameters={"stream": True}) # type: ignore[attr-defined] + ) as server_response: + yield from server_response.iter_content(chunk_size) + class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index fd0afc2a5..1831d5615 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,10 +1,7 @@ import logging -from contextlib import closing - -from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.models.permissions_item import PermissionsRule -from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, UnsupportedAttributeError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin @@ -27,7 +24,7 @@ ) -class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): +class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem], DownloadableMixin): """ The Tableau Server Client provides methods for interacting with view resources, or endpoints. These methods correspond to the endpoints for views @@ -264,9 +261,7 @@ def csv_fetcher(): def _get_view_csv(self, view_item: ViewItem, req_options: "CSVRequestOptions | None") -> Iterator[bytes]: url = f"{self.baseurl}/{view_item.id}/data" - - with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - yield from server_response.iter_content(config.CHUNK_SIZE_MB * BYTES_PER_MB) + return self._stream_content(url, req_options) @api(version="3.8") def populate_excel(self, view_item: ViewItem, req_options: "ExcelRequestOptions | None" = None) -> None: @@ -303,9 +298,7 @@ def excel_fetcher(): def _get_view_excel(self, view_item: ViewItem, req_options: "ExcelRequestOptions | None") -> Iterator[bytes]: url = f"{self.baseurl}/{view_item.id}/crosstab/excel" - - with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - yield from server_response.iter_content(config.CHUNK_SIZE_MB * BYTES_PER_MB) + return self._stream_content(url, req_options) @api(version="3.2") def populate_permissions(self, item: ViewItem) -> None: diff --git a/test/test_view.py b/test/test_view.py index f4d5b7e1f..55214e3c2 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -328,6 +328,37 @@ def test_populate_csv_default_maxage(server: TSC.Server) -> None: assert response == csv_file +def test_stream_content_uses_configured_chunk_size(server: TSC.Server, monkeypatch) -> None: + # Regression guard: the shared DownloadableMixin._stream_content path must + # stream with config.CHUNK_SIZE_MB * BYTES_PER_MB, not the pre-fix 1024 bytes. + from tableauserverclient.config import BYTES_PER_MB, config + + captured: list[int] = [] + real_iter_content = None + + def spy_iter_content(self, chunk_size=None, decode_unicode=False): + captured.append(chunk_size) + assert real_iter_content is not None + return real_iter_content(self, chunk_size, decode_unicode) + + import requests + + real_iter_content = requests.Response.iter_content + monkeypatch.setattr(requests.Response, "iter_content", spy_iter_content) + + response = POPULATE_CSV.read_bytes() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + server.views.populate_csv(single_view) + # Materialize the iterator so iter_content is actually invoked. + b"".join(single_view.csv) + + assert captured, "iter_content was never invoked" + assert captured[0] == config.CHUNK_SIZE_MB * BYTES_PER_MB + + def test_populate_image_missing_id(server: TSC.Server) -> None: single_view = TSC.ViewItem() single_view._id = None diff --git a/test/test_workbook.py b/test/test_workbook.py index 7f4d80041..c312beaf3 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -339,6 +339,35 @@ def test_download_object(server: TSC.Server) -> None: assert isinstance(file_path, BytesIO) +def test_download_uses_configured_chunk_size(server: TSC.Server, tmp_path: Path, monkeypatch) -> None: + # Regression guard: the shared DownloadableMixin._download_content path must + # stream with config.CHUNK_SIZE_MB * BYTES_PER_MB, not the pre-fix 1024 bytes. + from tableauserverclient.config import BYTES_PER_MB, config + + captured: list[int] = [] + real_iter_content = None + + def spy_iter_content(self, chunk_size=None, decode_unicode=False): + captured.append(chunk_size) + assert real_iter_content is not None + return real_iter_content(self, chunk_size, decode_unicode) + + import requests + + real_iter_content = requests.Response.iter_content + monkeypatch.setattr(requests.Response, "iter_content", spy_iter_content) + + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + ) + server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=tmp_path) + + assert captured, "iter_content was never invoked" + assert captured[0] == config.CHUNK_SIZE_MB * BYTES_PER_MB + + def test_download_sanitizes_name(server: TSC.Server, tmp_path: Path) -> None: filename = "Name,With,Commas.twbx" disposition = f'name="tableau_workbook"; filename="{filename}"'