From 8727af8c8720aa4e62f3ac7760539ca577e01bb9 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 17 Jun 2026 20:51:51 -0700 Subject: [PATCH 1/3] fix: prevent double-encoding of colons in date filter values Fixes #1025 Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/server/filter.py | 5 +++++ test/test_filter.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index fd90e281f..0d628b0e8 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,3 +1,6 @@ +import datetime + +from tableauserverclient.datetime_helpers import format_datetime from .request_options import RequestOptions @@ -9,6 +12,8 @@ def __init__(self, field, operator, value): self.value = value def __str__(self): + if isinstance(self._value, datetime.datetime): + return f"{self.field}:{self.operator}:{format_datetime(self._value)}" value_string = str(self._value) if isinstance(self._value, list): # this should turn the string representation of the list diff --git a/test/test_filter.py b/test/test_filter.py index 460813dd5..536469837 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,3 +1,5 @@ +import datetime + import tableauserverclient as TSC @@ -14,3 +16,18 @@ def test_filter_in(): filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) assert str(filter) == "name:in:[default,Salesforce Sales Projeśt]" + + +def test_filter_date_no_encoding(): + """Date filter values should not have colons pre-encoded (fixes #1025). + + The requests library handles URL encoding of the whole filter parameter, + so pre-encoding colons in datetime values causes double-encoding on the wire. + """ + utc = datetime.timezone.utc + dt = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=utc) + filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.LessThan, dt) + + result = str(filter) + assert result == "createdAt:lt:2023-01-01T00:00:00Z" + assert "%3A" not in result, "Colons in datetime values must not be percent-encoded" From 82468f3cef008a463943a7ba125ae5010c081cfb Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 26 Jun 2026 02:19:14 -0700 Subject: [PATCH 2/3] fix: serialize boolean filter values as lowercase true/false Tableau REST API requires boolean filter values to be lowercase ('true'/'false'), but Python's str(bool) produces 'True'/'False'. Add explicit bool handling in Filter.__str__ alongside the existing datetime fix, and expand test coverage to include bool, int, datetime (format, timezone conversion, no-encoding), list edge cases, and the invalid-operator-with-list guard. Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/server/filter.py | 3 + test/test_filter.py | 82 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index 0d628b0e8..f9c245a67 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -14,6 +14,9 @@ def __init__(self, field, operator, value): def __str__(self): if isinstance(self._value, datetime.datetime): return f"{self.field}:{self.operator}:{format_datetime(self._value)}" + if isinstance(self._value, bool): + # Tableau REST API requires lowercase 'true'/'false', not Python's 'True'/'False' + return f"{self.field}:{self.operator}:{str(self._value).lower()}" value_string = str(self._value) if isinstance(self._value, list): # this should turn the string representation of the list diff --git a/test/test_filter.py b/test/test_filter.py index 536469837..02df479bb 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -18,6 +18,33 @@ def test_filter_in(): assert str(filter) == "name:in:[default,Salesforce Sales Projeśt]" +def test_filter_in_single_value(): + """A single-element list produces valid bracket syntax.""" + filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample"]) + + assert str(filter) == "tags:in:[sample]" + + +def test_filter_in_multiple_values(): + """Multi-element list produces comma-separated values inside brackets.""" + filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["a", "b", "c"]) + + assert str(filter) == "tags:in:[a,b,c]" + + +def test_filter_integer_value(): + """Integer filter values are serialized as plain decimal strings.""" + filter = TSC.Filter(TSC.RequestOptions.Field.Size, TSC.RequestOptions.Operator.GreaterThan, 0) + + assert str(filter) == "size:gt:0" + + +def test_filter_integer_nonzero(): + filter = TSC.Filter(TSC.RequestOptions.Field.SheetCount, TSC.RequestOptions.Operator.GreaterThanOrEqual, 5) + + assert str(filter) == "sheetCount:gte:5" + + def test_filter_date_no_encoding(): """Date filter values should not have colons pre-encoded (fixes #1025). @@ -31,3 +58,58 @@ def test_filter_date_no_encoding(): result = str(filter) assert result == "createdAt:lt:2023-01-01T00:00:00Z" assert "%3A" not in result, "Colons in datetime values must not be percent-encoded" + + +def test_filter_date_uses_tableau_format(): + """Datetime values are serialized in Tableau ISO-8601 format, not Python default.""" + utc = datetime.timezone.utc + dt = datetime.datetime(2024, 6, 15, 12, 30, 45, tzinfo=utc) + filter = TSC.Filter(TSC.RequestOptions.Field.UpdatedAt, TSC.RequestOptions.Operator.GreaterThan, dt) + + result = str(filter) + # Must use 'T' separator and 'Z' suffix, not Python's space-separated format + assert result == "updatedAt:gt:2024-06-15T12:30:45Z" + assert " " not in result.split(":", 2)[2], "Datetime value must not contain a space (Python default format)" + + +def test_filter_date_non_utc_converted(): + """Non-UTC datetime values are converted to UTC before serialization.""" + eastern = datetime.timezone(datetime.timedelta(hours=-5)) + dt = datetime.datetime(2023, 3, 10, 12, 0, 0, tzinfo=eastern) + filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, dt) + + result = str(filter) + assert result == "createdAt:eq:2023-03-10T17:00:00Z" + + +def test_filter_bool_true(): + """Boolean True is serialized as lowercase 'true' for Tableau REST API.""" + filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, True) + + result = str(filter) + assert result == "isCertified:eq:true" + assert "True" not in result, "Boolean True must be lowercase 'true'" + + +def test_filter_bool_false(): + """Boolean False is serialized as lowercase 'false' for Tableau REST API.""" + filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, False) + + result = str(filter) + assert result == "isCertified:eq:false" + assert "False" not in result, "Boolean False must be lowercase 'false'" + + +def test_filter_bool_has_extracts(): + """Boolean filter works for hasExtracts field.""" + filter = TSC.Filter(TSC.RequestOptions.Field.HasExtracts, TSC.RequestOptions.Operator.Equals, True) + + assert str(filter) == "hasExtracts:eq:true" + + +def test_filter_list_rejects_non_in_operator(): + """A list value with a non-In operator raises ValueError.""" + import pytest + + with pytest.raises(ValueError): + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["a", "b"]) From a1aad6314d86cde74ddbc3313ba198519cf05192 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 26 Jun 2026 14:13:12 -0700 Subject: [PATCH 3/3] fix: reject naive datetimes and document Filter.__str__ format (#1025) Naive datetime objects passed to Filter now raise ValueError with a clear message instead of silently converting using the local system timezone, which would produce wrong UTC values on any non-UTC machine. Also adds a docstring to Filter.__str__ documenting the serialization rules for all supported value types. Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/server/filter.py | 19 +++++++++++++++++++ test/test_filter.py | 10 ++++++++++ 2 files changed, 29 insertions(+) diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index f9c245a67..2e64361ba 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -12,7 +12,26 @@ def __init__(self, field, operator, value): self.value = value def __str__(self): + """Return the filter as a Tableau REST API filter string. + + Format: ``::`` + + Value serialization rules: + - datetime: ISO-8601 UTC, e.g. ``2023-01-01T00:00:00Z``. + Naive datetimes (no tzinfo) are rejected with ValueError; always + pass timezone-aware datetime objects. + - bool: lowercase ``true`` or ``false`` as required by the REST API. + - list: bracket-enclosed comma-separated values, e.g. ``[a,b,c]``. + Only valid with the ``in`` operator. + - All other types: ``str()`` of the value. + """ if isinstance(self._value, datetime.datetime): + if self._value.tzinfo is None: + raise ValueError( + "Naive datetime passed to Filter; Tableau Server requires UTC. " + "Use a timezone-aware datetime, e.g. " + "datetime.datetime(..., tzinfo=datetime.timezone.utc)." + ) return f"{self.field}:{self.operator}:{format_datetime(self._value)}" if isinstance(self._value, bool): # Tableau REST API requires lowercase 'true'/'false', not Python's 'True'/'False' diff --git a/test/test_filter.py b/test/test_filter.py index 02df479bb..8d3ec541e 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -107,6 +107,16 @@ def test_filter_bool_has_extracts(): assert str(filter) == "hasExtracts:eq:true" +def test_filter_date_naive_raises(): + """A naive datetime (no tzinfo) raises ValueError with a helpful message.""" + import pytest + + naive_dt = datetime.datetime(2023, 1, 1, 12, 0, 0) # no tzinfo + f = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, naive_dt) + with pytest.raises(ValueError, match="Naive datetime"): + str(f) + + def test_filter_list_rejects_non_in_operator(): """A list value with a non-In operator raises ValueError.""" import pytest