Skip to content

Commit 94ab761

Browse files
jacalataclaude
andauthored
fix: correct Filter serialization for datetimes and booleans (#1805)
* fix: prevent double-encoding of colons in date filter values Fixes #1025 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 09ffa11 commit 94ab761

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

‎tableauserverclient/server/filter.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import datetime
2+
3+
from tableauserverclient.datetime_helpers import format_datetime
14
from .request_options import RequestOptions
25

36

@@ -9,6 +12,30 @@ def __init__(self, field, operator, value):
912
self.value = value
1013

1114
def __str__(self):
15+
"""Return the filter as a Tableau REST API filter string.
16+
17+
Format: ``<field>:<operator>:<value>``
18+
19+
Value serialization rules:
20+
- datetime: ISO-8601 UTC, e.g. ``2023-01-01T00:00:00Z``.
21+
Naive datetimes (no tzinfo) are rejected with ValueError; always
22+
pass timezone-aware datetime objects.
23+
- bool: lowercase ``true`` or ``false`` as required by the REST API.
24+
- list: bracket-enclosed comma-separated values, e.g. ``[a,b,c]``.
25+
Only valid with the ``in`` operator.
26+
- All other types: ``str()`` of the value.
27+
"""
28+
if isinstance(self._value, datetime.datetime):
29+
if self._value.tzinfo is None:
30+
raise ValueError(
31+
"Naive datetime passed to Filter; Tableau Server requires UTC. "
32+
"Use a timezone-aware datetime, e.g. "
33+
"datetime.datetime(..., tzinfo=datetime.timezone.utc)."
34+
)
35+
return f"{self.field}:{self.operator}:{format_datetime(self._value)}"
36+
if isinstance(self._value, bool):
37+
# Tableau REST API requires lowercase 'true'/'false', not Python's 'True'/'False'
38+
return f"{self.field}:{self.operator}:{str(self._value).lower()}"
1239
value_string = str(self._value)
1340
if isinstance(self._value, list):
1441
# this should turn the string representation of the list

‎test/test_filter.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import datetime
2+
13
import tableauserverclient as TSC
24

35

@@ -14,3 +16,110 @@ def test_filter_in():
1416
filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find)
1517

1618
assert str(filter) == "name:in:[default,Salesforce Sales Projeśt]"
19+
20+
21+
def test_filter_in_single_value():
22+
"""A single-element list produces valid bracket syntax."""
23+
filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample"])
24+
25+
assert str(filter) == "tags:in:[sample]"
26+
27+
28+
def test_filter_in_multiple_values():
29+
"""Multi-element list produces comma-separated values inside brackets."""
30+
filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["a", "b", "c"])
31+
32+
assert str(filter) == "tags:in:[a,b,c]"
33+
34+
35+
def test_filter_integer_value():
36+
"""Integer filter values are serialized as plain decimal strings."""
37+
filter = TSC.Filter(TSC.RequestOptions.Field.Size, TSC.RequestOptions.Operator.GreaterThan, 0)
38+
39+
assert str(filter) == "size:gt:0"
40+
41+
42+
def test_filter_integer_nonzero():
43+
filter = TSC.Filter(TSC.RequestOptions.Field.SheetCount, TSC.RequestOptions.Operator.GreaterThanOrEqual, 5)
44+
45+
assert str(filter) == "sheetCount:gte:5"
46+
47+
48+
def test_filter_date_no_encoding():
49+
"""Date filter values should not have colons pre-encoded (fixes #1025).
50+
51+
The requests library handles URL encoding of the whole filter parameter,
52+
so pre-encoding colons in datetime values causes double-encoding on the wire.
53+
"""
54+
utc = datetime.timezone.utc
55+
dt = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=utc)
56+
filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.LessThan, dt)
57+
58+
result = str(filter)
59+
assert result == "createdAt:lt:2023-01-01T00:00:00Z"
60+
assert "%3A" not in result, "Colons in datetime values must not be percent-encoded"
61+
62+
63+
def test_filter_date_uses_tableau_format():
64+
"""Datetime values are serialized in Tableau ISO-8601 format, not Python default."""
65+
utc = datetime.timezone.utc
66+
dt = datetime.datetime(2024, 6, 15, 12, 30, 45, tzinfo=utc)
67+
filter = TSC.Filter(TSC.RequestOptions.Field.UpdatedAt, TSC.RequestOptions.Operator.GreaterThan, dt)
68+
69+
result = str(filter)
70+
# Must use 'T' separator and 'Z' suffix, not Python's space-separated format
71+
assert result == "updatedAt:gt:2024-06-15T12:30:45Z"
72+
assert " " not in result.split(":", 2)[2], "Datetime value must not contain a space (Python default format)"
73+
74+
75+
def test_filter_date_non_utc_converted():
76+
"""Non-UTC datetime values are converted to UTC before serialization."""
77+
eastern = datetime.timezone(datetime.timedelta(hours=-5))
78+
dt = datetime.datetime(2023, 3, 10, 12, 0, 0, tzinfo=eastern)
79+
filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, dt)
80+
81+
result = str(filter)
82+
assert result == "createdAt:eq:2023-03-10T17:00:00Z"
83+
84+
85+
def test_filter_bool_true():
86+
"""Boolean True is serialized as lowercase 'true' for Tableau REST API."""
87+
filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, True)
88+
89+
result = str(filter)
90+
assert result == "isCertified:eq:true"
91+
assert "True" not in result, "Boolean True must be lowercase 'true'"
92+
93+
94+
def test_filter_bool_false():
95+
"""Boolean False is serialized as lowercase 'false' for Tableau REST API."""
96+
filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, False)
97+
98+
result = str(filter)
99+
assert result == "isCertified:eq:false"
100+
assert "False" not in result, "Boolean False must be lowercase 'false'"
101+
102+
103+
def test_filter_bool_has_extracts():
104+
"""Boolean filter works for hasExtracts field."""
105+
filter = TSC.Filter(TSC.RequestOptions.Field.HasExtracts, TSC.RequestOptions.Operator.Equals, True)
106+
107+
assert str(filter) == "hasExtracts:eq:true"
108+
109+
110+
def test_filter_date_naive_raises():
111+
"""A naive datetime (no tzinfo) raises ValueError with a helpful message."""
112+
import pytest
113+
114+
naive_dt = datetime.datetime(2023, 1, 1, 12, 0, 0) # no tzinfo
115+
f = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, naive_dt)
116+
with pytest.raises(ValueError, match="Naive datetime"):
117+
str(f)
118+
119+
120+
def test_filter_list_rejects_non_in_operator():
121+
"""A list value with a non-In operator raises ValueError."""
122+
import pytest
123+
124+
with pytest.raises(ValueError):
125+
TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["a", "b"])

0 commit comments

Comments
 (0)