diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index fd90e281f..2e64361ba 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,30 @@ 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' + 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 460813dd5..8d3ec541e 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,3 +1,5 @@ +import datetime + import tableauserverclient as TSC @@ -14,3 +16,110 @@ 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_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). + + 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" + + +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_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 + + with pytest.raises(ValueError): + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["a", "b"])