diff --git a/VizQLDataServiceOpenAPISchema.json b/VizQLDataServiceOpenAPISchema.json index a8744f4..bd0618f 100644 --- a/VizQLDataServiceOpenAPISchema.json +++ b/VizQLDataServiceOpenAPISchema.json @@ -3,7 +3,7 @@ "info": { "title": "VizQL Data Service", "description": "An API to query Tableau published data sources", - "version": "20253.0" + "version": "20261.0" }, "servers": [ { @@ -73,6 +73,11 @@ "schema": { "$ref": "#/components/schemas/QueryOutput" } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/SseResultStream" + } } } }, @@ -148,6 +153,49 @@ } } } + }, + "/list-supported-functions": { + "post": { + "tags": ["HeadlessBI"], + "operationId": "ListSupportedFunctions", + "summary": "List supported Tableau functions for a datasource", + "description": "Returns the list of Tableau function names supported for the specified datasource. The 200 response is an array of strings. Example response: [\"SUM\", \"AVG\", \"MIN\", \"MAX\"].", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSupportedFunctionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "List of supported function object for the datasource", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SupportedFunction" + } + } + } + } + }, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableauError" + } + } + } + } + } + } } }, "components": { @@ -201,6 +249,7 @@ }, "sortPriority": { "type": "integer", + "minimum": 1, "description": "To enable sorting on a specific Field, provide a sortPriority for that field, and that field will be sorted. The sortPriority provides a ranking of how to sort fields when multiple fields are being sorted. The highest priority (lowest number) field is sorted first. If only one field is being sorted, then any value may be used for sortPriority. SortPriority should be an integer greater than 0." } } @@ -299,7 +348,8 @@ "properties": { "binSize": { "type": "number", - "description": "The size of the bin to be applied." + "minimum": 1, + "description": "The size of the bin to be applied. The binSize value must be greater than 0." }, "fieldCaption": {}, "fieldAlias": {}, @@ -553,7 +603,7 @@ }, "previous": { "type": "integer", - "default": -2 + "default": 2 }, "next": { "type": "integer", @@ -636,6 +686,14 @@ "$ref": "#/components/schemas/DataType", "description": "Data type of the column, i.e., \"STRING\", \"BOOLEAN\", etc." }, + "fieldRole": { + "$ref": "#/components/schemas/FieldRole", + "description": "Field role of the column, i.e., \"MEASURE\", \"DIMENSION\"." + }, + "fieldType": { + "$ref": "#/components/schemas/FieldType", + "description": "Field role of the column, i.e., \"CONTINUOUS\", \"NOMINAL\", \"ORDINAL\"." + }, "defaultAggregation": { "$ref": "#/components/schemas/Function", "description": "The default aggregation applied to the field." @@ -654,9 +712,41 @@ "type": "string", "description": "The formula for this field if it is a calculation." }, + "groupFormula": { + "$ref": "#/components/schemas/GroupFormula", + "description": "The group formula for this field if it is a group calculation." + }, "logicalTableId": { "type": "string", "description": "An internal unique identifier for the logical table that this field originates from." + }, + "description": { + "type": "string", + "description": "Optional description for the field." + }, + + "imageRole": { + "$ref": "#/components/schemas/ImageRole", + "description": "Optional image role for this field." + }, + "hidden": { + "type": "boolean", + "description": "Indicates whether the field is hidden in the data source." + }, + "defaultFormatting": { + "$ref": "#/components/schemas/Formatting", + "description": "Describing the default formatting for this field." + }, + "isLODCalc": { + "type": "boolean", + "description": "Indicates whether the field is an LOD calculation." + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldAlias" + }, + "description": "A list of aliases mapping original member values to alias values." } } }, @@ -673,6 +763,39 @@ "UNKNOWN" ] }, + "FieldRole": { + "type": "string", + "enum": [ + "MEASURE", + "DIMENSION", + "UNKNOWN" + ] + }, + "FieldType": { + "type": "string", + "enum": [ + "CONTINUOUS", + "NOMINAL", + "ORDINAL", + "UNKNOWN" + ] + }, + "ImageRole": { + "type": "string", + "enum": [ + "URL" + ] + }, + "Formatting": { + "type": "object", + "description": "Describing the default formatting for this field.", + "properties": { + "decimalPlaces": { + "type": "string", + "description": "Number of places after decimal for a real valued field - Optional." + } + } + }, "Connection": { "type": "object", "required": ["connectionUsername", "connectionPassword"], @@ -721,6 +844,7 @@ "QUANTITATIVE_NUMERICAL", "SET", "MATCH", + "CONDITION", "DATE", "TOP" ] @@ -738,12 +862,14 @@ "QUANTITATIVE_NUMERICAL": "#/components/schemas/QuantitativeNumericalFilter", "SET": "#/components/schemas/SetFilter", "MATCH": "#/components/schemas/MatchFilter", + "CONDITION": "#/components/schemas/ConditionFilter", "DATE": "#/components/schemas/RelativeDateFilter", "TOP": "#/components/schemas/TopNFilter" } } }, "FilterField": { + "x-class-extra-annotation": "@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)", "oneOf": [ { "$ref": "#/components/schemas/DimensionFilterField" @@ -848,6 +974,47 @@ } ] }, + "ConditionFilter": { + "allOf": [ + { + "$ref": "#/components/schemas/Filter" + }, + { + "type": "object", + "description": "A filter that filters based on the condition of another field.", + "properties": { + "condition": { + "$ref": "#/components/schemas/ConditionalFilterCondition" + }, + "calculation": { + "type": "string", + "description": "A alternate conditional calculation to filter upon." + } + } + } + ] + }, + "ConditionalFilterCondition": { + "type": "object", + "required": ["fieldCaption", "function", "comparison", "value"], + "properties": { + "fieldCaption": { + "type": "string", + "description": "A Field that provide the condition for filtering." + }, + "function": { + "$ref": "#/components/schemas/Function" + }, + "comparison": { + "type": "string", + "description": "The comparison operator.", + "enum": [ "=", "<>", "<", "<=", ">", ">=" ] + }, + "value": { + "description": "The value of the condition. Must be a number or date or date/time string." + } + } + }, "MetadataOutput": { "type": "object", "properties": { @@ -977,6 +1144,15 @@ }, "returnFormat": { "$ref": "#/components/schemas/ReturnFormat" + }, + "rowLimit": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "returnServerSentEvents": { + "type": "boolean", + "default": false } } } @@ -998,6 +1174,83 @@ "type": "boolean", "default": false, "description": "When true, user will pass in the Field's fieldName instead of the Field's fieldCaption in every place that fieldCaption appears (in Fields, Filters, and Calculation formulas). See FieldMetadata of the read-metadata endpoint." + }, + "includeHiddenFields": { + "type": "boolean", + "default": false, + "description": "When true, hidden fields will be included in the response of read-metadata endpoint." + }, + "includeGroupFormulas": { + "type": "boolean", + "default": false, + "description": "When true, group formula information will be included in the response of read-metadata endpoint for fields that have categorical bin data." + } + } + }, + "SseResultStream": { + "oneOf": [ + { + "$ref": "#/components/schemas/SseMetadataEvent" + }, + { + "$ref": "#/components/schemas/SseDataEvent" + }, + { + "$ref": "#/components/schemas/SseErrorEvent" + } + ], + "discriminator": { + "propertyName": "event", + "mapping": { + "METADATA": "#/components/schemas/SseMetadataEvent", + "DATA": "#/components/schemas/SseDataEvent", + "ERROR": "#/components/schemas/SseErrorEvent" + } + } + }, + "SseMetadataEvent": { + "type": "object", + "required": [ "event", "data" ], + "properties": { + "event": { + "type": "string", + "enum": ["METADATA"] + }, + "data": { + "type": "object", + "properties": { + "rowCount": { + "type": "integer" + } + } + } + } + }, + "SseDataEvent": { + "type": "object", + "required": [ "event", "data" ], + "properties": { + "event": { + "type": "string", + "enum": ["DATA"] + }, + "data": { + "type": "array", + "items": { + } + } + } + }, + "SseErrorEvent": { + "type": "object", + "required": [ "event", "data" ], + "properties": { + "event": { + "type": "string", + "enum": ["ERROR"] + }, + "data": { + "$ref": "#/components/schemas/TableauError" } } }, @@ -1008,6 +1261,9 @@ "type": "array", "items": { } + }, + "error": { + "type": "object" } } }, @@ -1184,6 +1440,35 @@ "NullableAny": { "nullable": true }, + "FieldAlias": { + "type": "object", + "description": "A field alias mapping an original member value to an alias value.", + "required": ["member", "value"], + "properties": { + "member": { + "description": "The original member value that will be replaced." + }, + "value": { + "description": "The alias value to replace the original member with." + } + }, + "additionalProperties": false + }, + "AliasedDataValue": { + "type": "object", + "description": "Parameter data value with alias.", + "x-class-extra-annotation": "@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)" , + "required": ["alias", "value"], + "properties": { + "alias": { + "type": "string", + "description": "The alias of the data value." + }, + "value": { + "description": "The data value of the parameter." + } + } + }, "AnyValueParameter": { "allOf": [ { @@ -1207,7 +1492,7 @@ "members": { "type": "array", "items": { - "$ref": "#/components/schemas/NullableAny" + "$ref": "#/components/schemas/AliasedDataValue" } } } @@ -1335,6 +1620,7 @@ }, "LogicalTable": { "type": "object", + "x-class-extra-annotation": "@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)" , "required": ["logicalTableId", "caption"], "properties": { "logicalTableId": { @@ -1342,6 +1628,9 @@ }, "caption": { "type": "string" + }, + "description": { + "type": "string" } } }, @@ -1354,6 +1643,10 @@ }, "toLogicalTable": { "$ref": "#/components/schemas/LogicalTableRelationshipEndpoint" + }, + "expression": { + "$ref": "#/components/schemas/FieldRelationshipExpression", + "description": "The expression associated with the relationship, representing how the tables are related." } } }, @@ -1365,6 +1658,154 @@ "type": "string" } } + }, + "ListSupportedFunctionRequest": { + "type": "object", + "required": [ + "datasource" + ], + "properties": { + "datasource": { + "$ref": "#/components/schemas/Datasource" + }, + "options": { + "$ref": "#/components/schemas/QueryOptions" + } + } + }, + "FunctionType": { + "type": "string", + "enum": [ + "UNSPECIFIED", + "BOOL", + "REAL", + "INT", + "STR", + "DATETIME", + "DATE", + "LOCALSTR", + "NULL", + "ANY", + "BIN", + "TUPLE", + "LOCALREAL", + "LOCALINT", + "SPATIAL", + "TABLE", + "ERROR" + ] + }, + "SupportedFunctionOverload": { + "type": "object", + "description": "Describes a supported function overload for a data source.", + "required": ["arg_types", "return_type"], + "properties": { + "arg_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FunctionType" + } + }, + "return_type": { + "$ref": "#/components/schemas/FunctionType" + } + } + }, + "SupportedFunction": { + "type": "object", + "description": "Describes a supported function for a data source.", + "required": ["name", "overloads"], + "properties": { + "name": { + "type": "string" + }, + "overloads": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SupportedFunctionOverload" + } + } + } + }, + "GroupFormula": { + "type": "object", + "description": "For a Group aka Categorical Bin field, defines how domain values are grouped together", + "required": ["groupings", "baseFieldName", "hasIncludeOther"], + "properties": { + "baseFieldName": { + "type": "string", + "description": "The field name of the column the group is on." + }, + "groupings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Grouping" + }, + "description": "A list of groupings in the group formula." + }, + "hasIncludeOther": { + "type": "boolean", + "default": false, + "description": "If true, all domain values not specified in a grouping will be grouped together." + } + } + }, + "Grouping": { + "type": "object", + "description": "A grouping object that defines a group within a group formula.", + "required": ["members"], + "properties": { + "alias": { + "type": "string", + "description": "The name of the grouping." + }, + "members": { + "type": "array", + "items": {}, + "description": "A list of members that belong to this grouping. Members can be of any type." + } + } + }, + "FieldRelationshipExpression": { + "type": "object", + "description": "Represents a relationship expression structure with an operator and an array of relationships. This format is used to represent logical table relationships where multiple field pairs are related using a common operator.", + "x-class-extra-annotation": "@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)", + "required": [ "relationships"], + "additionalProperties": false, + "properties": { + "op": { + "type": "string", + "description": "The logical operator that combines all relationships (e.g., AND, OR)." + }, + "relationships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RelationshipExpressionItem" + }, + "description": "An array of relationship items, each representing a single field-to-field relationship." + } + } + }, + "RelationshipExpressionItem": { + "type": "object", + "description": "Represents a single relationship item within a RelationshipExpression. Defines a relationship between two fields using an operator.", + "required": ["operator", "fromField", "toField"], + "additionalProperties": false, + "properties": { + "operator": { + "type": "string", + "description": "The comparison operator used in this relationship (e.g., =, <>, >, <, >=, <=).", + "enum": ["=", "<>", ">", "<", ">=", "<="] + }, + "fromField": { + "type": "string", + "description": "The field reference from which the relationship originates (e.g., [Order ID]). Field references are typically enclosed in square brackets." + }, + "toField": { + "type": "string", + "description": "The field reference to which the relationship targets (e.g., [Order ID (Returns)]). Field references are typically enclosed in square brackets." + } + } } }, "securitySchemes": { diff --git a/python_sdk/CHANGELOG b/python_sdk/CHANGELOG index 5520c7a..888c23c 100644 --- a/python_sdk/CHANGELOG +++ b/python_sdk/CHANGELOG @@ -1,3 +1,7 @@ +## 20261.0.0 (January 2026) + +* Update SDK to 20261.0.0 + ## 20253.0.0 (September 2025) * Update SDK to 20253.0.0 diff --git a/python_sdk/README.md b/python_sdk/README.md index 87e20b5..d02800c 100644 --- a/python_sdk/README.md +++ b/python_sdk/README.md @@ -24,14 +24,18 @@ The VizQL Data Service Python SDK supports different versions of the VizQLDataSe [20252.0](https://github.com/tableau/VizQL-Data-Service/blob/release-20252.0/VizQLDataServiceOpenAPISchema.json) -[20253.0](https://github.com/tableau/VizQL-Data-Service/blob/main/VizQLDataServiceOpenAPISchema.json) +[20253.0](https://github.com/tableau/VizQL-Data-Service/blob/release-20253.0/VizQLDataServiceOpenAPISchema.json) + +[20261.0](https://github.com/tableau/VizQL-Data-Service/blob/main/VizQLDataServiceOpenAPISchema.json) ### Python SDK Versions None for 20251.0 [20252.0](https://github.com/tableau/VizQL-Data-Service/tree/release-20252.0/python_sdk) -[20253.0](https://github.com/tableau/VizQL-Data-Service/tree/main/python_sdk) +[20253.0](https://github.com/tableau/VizQL-Data-Service/tree/release-20253.0/python_sdk) + +[20261.0](https://github.com/tableau/VizQL-Data-Service/tree/main/python_sdk) ## 🔧 Installation ```bash diff --git a/python_sdk/pyproject.toml b/python_sdk/pyproject.toml index fa83382..a6a762b 100644 --- a/python_sdk/pyproject.toml +++ b/python_sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vizql-data-service-py" -version = "20253.0.0" # This value is manually updated. The major and minor versions must always be the same as the version defined in VizQLDataServiceOpenAPISchema.json +version = "20261.0.0" # This value is manually updated. The major and minor versions must always be the same as the version defined in VizQLDataServiceOpenAPISchema.json description = "A Python client library for interacting with the VizQL Data Service API" readme = "README.md" diff --git a/python_sdk/scripts/post_process.py b/python_sdk/scripts/post_process.py index 0d91f9b..3bde0c9 100644 --- a/python_sdk/scripts/post_process.py +++ b/python_sdk/scripts/post_process.py @@ -24,17 +24,63 @@ def convert_file(input_file, output_file): "import TableauModel": "from .tableau_model import TableauModel", "ParameterRecord": "ParameterRecordBase", "Optional[List[ParameterRecordBase]]": "Optional[List[ParameterRecord]]", + "QuantitativeFilterBase,": "QuantitativeNumericalFilter, QuantitativeDateFilter,", } for old, new in replacements.items(): content = content.replace(old, new) - # Add TabFilter class at the end of file + # Add Literal filterType to each filter subclass for discriminator support + filter_literals = [ + ( + "class MatchFilter(Filter):", + "class MatchFilter(Filter):\n" + " filterType: Literal[FilterType.MATCH] = FilterType.MATCH", + ), + ( + "class ConditionFilter(Filter):", + "class ConditionFilter(Filter):\n" + " filterType: Literal[FilterType.CONDITION] = FilterType.CONDITION", + ), + ( + "class QuantitativeNumericalFilter(QuantitativeFilterBase):", + "class QuantitativeNumericalFilter(QuantitativeFilterBase):\n" + " filterType: Literal[FilterType.QUANTITATIVE_NUMERICAL] = " + "FilterType.QUANTITATIVE_NUMERICAL", + ), + ( + "class QuantitativeDateFilter(QuantitativeFilterBase):", + "class QuantitativeDateFilter(QuantitativeFilterBase):\n" + " filterType: Literal[FilterType.QUANTITATIVE_DATE] = " + "FilterType.QUANTITATIVE_DATE", + ), + ( + "class SetFilter(Filter):", + "class SetFilter(Filter):\n" + " filterType: Literal[FilterType.SET] = FilterType.SET", + ), + ( + "class RelativeDateFilter(Filter):", + "class RelativeDateFilter(Filter):\n" + " filterType: Literal[FilterType.DATE] = FilterType.DATE", + ), + ( + "class TopNFilter(Filter):", + "class TopNFilter(Filter):\n" + " filterType: Literal[FilterType.TOP] = FilterType.TOP", + ), + ] + for old, new in filter_literals: + content = content.replace(old, new) + + # Add TabFilter class with discriminator at the end of file tab_filter_code = """ -class TabFilter(RootModel[Union[ - MatchFilter, QuantitativeNumericalFilter, QuantitativeDateFilter, SetFilter, RelativeDateFilter, TopNFilter]]): - root: Union[ - MatchFilter, QuantitativeNumericalFilter, QuantitativeDateFilter, SetFilter, RelativeDateFilter, TopNFilter] +class TabFilter(RootModel[Annotated[Union[ + MatchFilter, QuantitativeNumericalFilter, QuantitativeDateFilter, SetFilter, RelativeDateFilter, TopNFilter, ConditionFilter], + PydanticField(discriminator='filterType')]]): + root: Annotated[Union[ + MatchFilter, QuantitativeNumericalFilter, QuantitativeDateFilter, SetFilter, RelativeDateFilter, TopNFilter, ConditionFilter], + PydanticField(discriminator='filterType')] """ content += tab_filter_code diff --git a/python_sdk/src/__init__.py b/python_sdk/src/__init__.py index 4a6cd68..effb586 100644 --- a/python_sdk/src/__init__.py +++ b/python_sdk/src/__init__.py @@ -1,46 +1,107 @@ from .api.openapi_generated import ( TableauError, + TableCalcType, + RelativeTo, + RankType, + TableCalcComputedAggregation, + ColumnClass, DataType, + FieldRole, + FieldType, + ImageRole, + Formatting, Connection, Datasource, FilterType, DimensionFilterField, CalculatedFilterField, Function, + Comparison, + ConditionalFilterCondition, QuantitativeFilterType, + QueryOptions, + Event, + Data, + SseMetadataEvent, + Event1, + SseDataEvent, + Event2, + SseErrorEvent, QueryOutput, + ReadMetadataRequest, ReturnFormat, SortDirection, PeriodType, DateRangeType, Direction, + Parameter, + NullableAny, + FieldAlias, + AliasedDataValue, + ParameterType, + ParameterRecordBase, + GetDatasourceModelRequest, + LogicalTable, + LogicalTableRelationshipEndpoint, + ListSupportedFunctionRequest, + FunctionType, + SupportedFunctionOverload, + SupportedFunction, + Grouping, + Operator, + RelationshipExpressionItem, FieldBase, DimensionField, MeasureField, CalculatedField, - FieldMetadata, + BinField, + TableCalcFieldReference, + TableCalcCustomSort, MeasureFilterField, - MetadataOutput, - QueryOptions, - ReadMetadataRequest, - Field, - FilterField, + ExtraData, QueryDatasourceOptions, + SseResultStream, + AnyValueParameter, + ListParameter, + QuantitativeRangeParameter, + QuantitativeDateParameter, + GroupFormula, + FieldRelationshipExpression, + TableCalcSpecification, + CustomTableCalcSpecification, + NestedTableCalcSpecification, + DifferenceTableCalcSpecification, + PercentOfTotalTableCalcSpecification, + RankTableCalcSpecification, + PercentileTableCalcSpecification, + RunningTotalTableCalcSpecification, + MovingTableCalcSpecification, + FieldMetadata, + FilterField, + MetadataOutput, + LogicalTableRelationship, + TableCalcField, Filter, MatchFilter, + ConditionFilter, QuantitativeFilterBase, QuantitativeNumericalFilter, QuantitativeDateFilter, - Query, - QueryRequest, SetFilter, RelativeDateFilter, TopNFilter, + DatasourceModelOutput, + Field, + Query, + QueryRequest, + TabFilter, + ParameterRecord, ) from .api import ( VizQLDataServiceClient, read_metadata, query_datasource, + get_datasource_model, ) __all__ = [ @@ -48,42 +109,103 @@ "VizQLDataServiceClient", "read_metadata", "query_datasource", + "get_datasource_model", # OpenAPI models "TableauError", + "TableCalcType", + "RelativeTo", + "RankType", + "TableCalcComputedAggregation", + "ColumnClass", "DataType", + "FieldRole", + "FieldType", + "ImageRole", + "Formatting", "Connection", "Datasource", "FilterType", "DimensionFilterField", "CalculatedFilterField", "Function", + "Comparison", + "ConditionalFilterCondition", "QuantitativeFilterType", + "QueryOptions", + "Event", + "Data", + "SseMetadataEvent", + "Event1", + "SseDataEvent", + "Event2", + "SseErrorEvent", "QueryOutput", + "ReadMetadataRequest", "ReturnFormat", "SortDirection", "PeriodType", "DateRangeType", "Direction", + "Parameter", + "NullableAny", + "FieldAlias", + "AliasedDataValue", + "ParameterType", + "ParameterRecordBase", + "GetDatasourceModelRequest", + "LogicalTable", + "LogicalTableRelationshipEndpoint", + "ListSupportedFunctionRequest", + "FunctionType", + "SupportedFunctionOverload", + "SupportedFunction", + "Grouping", + "Operator", + "RelationshipExpressionItem", "FieldBase", "DimensionField", "MeasureField", "CalculatedField", - "FieldMetadata", + "BinField", + "TableCalcFieldReference", + "TableCalcCustomSort", "MeasureFilterField", - "MetadataOutput", - "QueryOptions", - "ReadMetadataRequest", - "Field", - "FilterField", + "ExtraData", "QueryDatasourceOptions", + "SseResultStream", + "AnyValueParameter", + "ListParameter", + "QuantitativeRangeParameter", + "QuantitativeDateParameter", + "GroupFormula", + "FieldRelationshipExpression", + "TableCalcSpecification", + "CustomTableCalcSpecification", + "NestedTableCalcSpecification", + "DifferenceTableCalcSpecification", + "PercentOfTotalTableCalcSpecification", + "RankTableCalcSpecification", + "PercentileTableCalcSpecification", + "RunningTotalTableCalcSpecification", + "MovingTableCalcSpecification", + "FieldMetadata", + "FilterField", + "MetadataOutput", + "LogicalTableRelationship", + "TableCalcField", "Filter", "MatchFilter", + "ConditionFilter", "QuantitativeFilterBase", "QuantitativeNumericalFilter", "QuantitativeDateFilter", - "Query", - "QueryRequest", "SetFilter", "RelativeDateFilter", "TopNFilter", + "DatasourceModelOutput", + "Field", + "Query", + "QueryRequest", + "TabFilter", + "ParameterRecord", ] diff --git a/python_sdk/src/api/__init__.py b/python_sdk/src/api/__init__.py index abc259b..7d6cab6 100644 --- a/python_sdk/src/api/__init__.py +++ b/python_sdk/src/api/__init__.py @@ -1,9 +1,11 @@ from .client import VizQLDataServiceClient from . import read_metadata from . import query_datasource +from . import get_datasource_model __all__ = [ "VizQLDataServiceClient", "read_metadata", "query_datasource", + "get_datasource_model", ] diff --git a/python_sdk/src/api/get_datasource_model.py b/python_sdk/src/api/get_datasource_model.py new file mode 100644 index 0000000..a0577a6 --- /dev/null +++ b/python_sdk/src/api/get_datasource_model.py @@ -0,0 +1,184 @@ +from http import HTTPStatus +from typing import Any, Optional + +import httpx + +from .client import VizQLDataServiceClient +from .errors import UnexpectedStatus +from .openapi_generated import DatasourceModelOutput, GetDatasourceModelRequest +from .types import Response + + +def _get_kwargs( + *, + body: GetDatasourceModelRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/get-datasource-model", + } + + _body = body.model_dump(mode="json", exclude_none=True) + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: VizQLDataServiceClient, response: httpx.Response +) -> Optional[DatasourceModelOutput]: + if response.status_code == 200: + response_200 = DatasourceModelOutput.model_validate_json(response.content) + + return response_200 + if client.raise_on_unexpected_status: + raise UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: VizQLDataServiceClient, response: httpx.Response +) -> Response[DatasourceModelOutput]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: VizQLDataServiceClient, + body: GetDatasourceModelRequest, +) -> Response[DatasourceModelOutput]: + """Request data source model with detailed response information + + Requests the data model for a specific data source and returns a detailed response containing: + - The data source model (`DatasourceModelOutput`) + - HTTP status code + - Response headers + - Raw response content + + Args: + body (GetDatasourceModelRequest): The data source model request parameters. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DatasourceModelOutput]: A response object containing both the data source model and response metadata. + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: VizQLDataServiceClient, + body: GetDatasourceModelRequest, +) -> Optional[DatasourceModelOutput]: + """Request data source model and get only the model information + + Requests the data model for a specific data source and returns only the model information without response metadata. + This is a convenience wrapper around sync_detailed() that returns only the parsed model. + + Args: + body (GetDatasourceModelRequest): The data source model request parameters. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Optional[DatasourceModelOutput]: The data source model, or None if the request was unsuccessful. + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: VizQLDataServiceClient, + body: GetDatasourceModelRequest, +) -> Response[DatasourceModelOutput]: + """Request data source model asynchronously with detailed response information + + Asynchronously requests the data model for a specific data source and returns a detailed response containing: + - The data source model (`DatasourceModelOutput`) + - HTTP status code + - Response headers + - Raw response content + + Args: + body (GetDatasourceModelRequest): The data source model request parameters. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DatasourceModelOutput]: A response object containing both the data source model and response metadata. + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: VizQLDataServiceClient, + body: GetDatasourceModelRequest, +) -> Optional[DatasourceModelOutput]: + """Request data source model asynchronously and get only the model information + + Asynchronously requests the data model for a specific data source and returns only the model information without response metadata. + This is a convenience wrapper around asyncio_detailed() that returns only the parsed model. + + Args: + body (GetDatasourceModelRequest): The data source model request parameters. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Optional[DatasourceModelOutput]: The data source model, or None if the request was unsuccessful. + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed + + +__all__ = [ + "sync", + "sync_detailed", + "asyncio", + "asyncio_detailed", +] diff --git a/python_sdk/src/examples/async_examples.py b/python_sdk/src/examples/async_examples.py index e1b1c73..c43428d 100644 --- a/python_sdk/src/examples/async_examples.py +++ b/python_sdk/src/examples/async_examples.py @@ -12,15 +12,31 @@ if is_development: import src.examples.common as common - from src.api import query_datasource, read_metadata + from src.api import ( + get_datasource_model, + query_datasource, + read_metadata, + ) from src.api.client import VizQLDataServiceClient - from src.api.openapi_generated import QueryRequest, ReadMetadataRequest + from src.api.openapi_generated import ( + GetDatasourceModelRequest, + QueryRequest, + ReadMetadataRequest, + ) from src.examples.payload import QUERY_FUNCTIONS else: import vizql_data_service_py.examples.common as common # type: ignore - from vizql_data_service_py.api import query_datasource, read_metadata # type: ignore + from vizql_data_service_py.api import ( # type: ignore + get_datasource_model, + query_datasource, + read_metadata, + ) from vizql_data_service_py.api.client import VizQLDataServiceClient # type: ignore - from vizql_data_service_py.api.openapi_generated import QueryRequest, ReadMetadataRequest # type: ignore + from vizql_data_service_py.api.openapi_generated import ( # type: ignore + GetDatasourceModelRequest, + QueryRequest, + ReadMetadataRequest, + ) from vizql_data_service_py.examples.payload import QUERY_FUNCTIONS # type: ignore @@ -78,3 +94,19 @@ async def execute_query(query_func): # Execute queries sequentially for query_func in QUERY_FUNCTIONS: await execute_query(query_func) + + # Get datasource model example + try: + print("\n=== GetDatasourceModel ===") + datasource_model_request = GetDatasourceModelRequest(datasource=datasource) + if args.verbose: + print(f"Request Body: {datasource_model_request}") + + datasource_model_response = await get_datasource_model.asyncio_detailed( + client=client, body=datasource_model_request + ) + common.handle_response( + datasource_model_response, "GetDatasourceModel", args.verbose + ) + except Exception as e: + common.handle_error(e, "GetDatasourceModel", args.verbose) diff --git a/python_sdk/src/examples/payload.py b/python_sdk/src/examples/payload.py index 1ee4388..db4e90c 100644 --- a/python_sdk/src/examples/payload.py +++ b/python_sdk/src/examples/payload.py @@ -13,6 +13,9 @@ from src.api.openapi_generated import ( BinField, CalculatedField, + CalculatedFilterField, + ConditionalFilterCondition, + ConditionFilter, DateRangeType, DifferenceTableCalcSpecification, DimensionField, @@ -38,8 +41,13 @@ ) else: from vizql_data_service_py.api.openapi_generated import ( # type: ignore + BinField, CalculatedField, + CalculatedFilterField, + ConditionalFilterCondition, + ConditionFilter, DateRangeType, + DifferenceTableCalcSpecification, DimensionField, DimensionFilterField, Direction, @@ -48,6 +56,7 @@ MatchFilter, MeasureField, MeasureFilterField, + Parameter, PeriodType, QuantitativeDateFilter, QuantitativeFilterType, @@ -55,6 +64,9 @@ Query, RelativeDateFilter, SetFilter, + TableCalcField, + TableCalcFieldReference, + TableCalcType, TopNFilter, ) @@ -95,6 +107,42 @@ def create_dimension_filter(): ) +def create_dimension_filter_with_function(): + return Query( + fields=[ + DimensionField(fieldCaption="Category", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + SetFilter( + field=MeasureFilterField( + fieldCaption="Order Date", function=Function.MONTH + ), + filterType=FilterType.SET, + values=[1, 5, 9], + exclude=False, + ) + ], + ) + + +def create_dimension_filter_with_calculation(): + return Query( + fields=[ + DimensionField(fieldCaption="Category", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + SetFilter( + field=CalculatedFilterField(calculation="[Ship Mode] = 'First Class'"), + filterType=FilterType.SET, + values=[True], + exclude=False, + ) + ], + ) + + def create_quantitative_range_filter(): return Query( fields=[ @@ -154,6 +202,46 @@ def create_relative_date_filter(): ) +def create_relative_date_filter_with_calculation(): + return Query( + fields=[ + DimensionField(fieldCaption="Category", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + RelativeDateFilter( + field=CalculatedFilterField( + calculation="DATEPARSE('YYYY-MM-dd', STR([Order Date]))" + ), + filterType=FilterType.DATE, + periodType=PeriodType.MONTHS, + dateRangeType=DateRangeType.CURRENT, + anchorDate=date(2024, 9, 1), + ) + ], + ) + + +def create_relative_date_filter_with_function(): + return Query( + fields=[ + DimensionField(fieldCaption="Product Name", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + RelativeDateFilter( + field=MeasureFilterField( + fieldCaption="Order Date", function=Function.MIN + ), + filterType=FilterType.DATE, + periodType=PeriodType.MONTHS, + dateRangeType=DateRangeType.CURRENT, + anchorDate=date(2024, 9, 1), + ) + ], + ) + + def create_match_filter(): return Query( fields=[ @@ -173,6 +261,60 @@ def create_match_filter(): ) +def create_match_filter_with_calculation(): + return Query( + fields=[ + DimensionField(fieldCaption="Category", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + MatchFilter( + field=CalculatedFilterField(calculation="[State/Province]"), + filterType=FilterType.MATCH, + startsWith="A", + ) + ], + ) + + +def create_quantitative_date_filter_with_calculation(): + return Query( + fields=[ + DimensionField(fieldCaption="Category", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + QuantitativeDateFilter( + field=CalculatedFilterField( + calculation="DATEPARSE('YYYY-MM-dd', STR([Order Date]))" + ), + filterType=FilterType.QUANTITATIVE_DATE, + quantitativeFilterType=QuantitativeFilterType.MIN, + minDate=date(2013, 5, 1), + ) + ], + ) + + +def create_quantitative_date_filter_with_function(): + return Query( + fields=[ + DimensionField(fieldCaption="Category", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + QuantitativeDateFilter( + field=MeasureFilterField( + fieldCaption="Order Date", function=Function.TRUNC_MONTH + ), + filterType=FilterType.QUANTITATIVE_DATE, + quantitativeFilterType=QuantitativeFilterType.MIN, + minDate=date(2013, 1, 1), + ) + ], + ) + + def create_top_n_filter(): return Query( fields=[ @@ -365,7 +507,7 @@ def create_simple_table_calculation(): fieldCaption="Sales", function=Function.SUM, tableCalculation=DifferenceTableCalcSpecification( - tableCalcType=TableCalcType.DIFFERENCE_FROM, + tableCalcType=TableCalcType.DIFFERENCE_FROM.value, dimensions=[ TableCalcFieldReference(fieldCaption="Region"), TableCalcFieldReference(fieldCaption="Segment"), @@ -377,22 +519,63 @@ def create_simple_table_calculation(): ) +def create_condition_filter(): + return Query( + fields=[ + DimensionField(fieldCaption="Category", sortPriority=1), + MeasureField(fieldCaption="Sales", function=Function.SUM), + ], + filters=[ + ConditionFilter( + field=CalculatedFilterField(calculation="[State/Province]"), + filterType=FilterType.CONDITION, + condition=ConditionalFilterCondition( + fieldCaption="Profit", + function=Function.SUM, + comparison=">", + value=10000, + ), + ) + ], + ) + + +def create_count_of_table_cal(): + return Query( + fields=[ + CalculatedField( + fieldCaption="Count of Rows", + calculation="COUNT([Orders])", + ), + ] + ) + + QUERY_FUNCTIONS = [ create_simple_query, create_custom_calculation, create_dimension_filter, create_quantitative_range_filter, create_quantitative_date_filter, + create_quantitative_date_filter_with_calculation, + create_quantitative_date_filter_with_function, create_relative_date_filter, + create_relative_date_filter_with_calculation, + create_relative_date_filter_with_function, create_match_filter, + create_match_filter_with_calculation, create_top_n_filter, create_multiple_dimension_filters, create_multiple_min_max_numeric_filters, create_dimension_numeric_filters, + create_dimension_filter_with_calculation, + create_dimension_filter_with_function, create_context_filter, create_numeric_date_dimension_filters, create_bin_formatting_with_parameter, create_new_bin_field, create_parameter_calculated_field, create_simple_table_calculation, + create_condition_filter, + create_count_of_table_cal, ] diff --git a/python_sdk/src/examples/sync_examples.py b/python_sdk/src/examples/sync_examples.py index 7aabfd0..56b1409 100644 --- a/python_sdk/src/examples/sync_examples.py +++ b/python_sdk/src/examples/sync_examples.py @@ -12,15 +12,31 @@ if is_development: import src.examples.common as common - from src.api import query_datasource, read_metadata + from src.api import ( + get_datasource_model, + query_datasource, + read_metadata, + ) from src.api.client import VizQLDataServiceClient - from src.api.openapi_generated import QueryRequest, ReadMetadataRequest + from src.api.openapi_generated import ( + GetDatasourceModelRequest, + QueryRequest, + ReadMetadataRequest, + ) from src.examples.payload import QUERY_FUNCTIONS else: import vizql_data_service_py.examples.common as common # type: ignore - from vizql_data_service_py.api import query_datasource, read_metadata # type: ignore + from vizql_data_service_py.api import ( # type: ignore + get_datasource_model, + query_datasource, + read_metadata, + ) from vizql_data_service_py.api.client import VizQLDataServiceClient # type: ignore - from vizql_data_service_py.api.openapi_generated import QueryRequest, ReadMetadataRequest # type: ignore + from vizql_data_service_py.api.openapi_generated import ( # type: ignore + GetDatasourceModelRequest, + QueryRequest, + ReadMetadataRequest, + ) from vizql_data_service_py.examples.payload import QUERY_FUNCTIONS # type: ignore @@ -73,3 +89,19 @@ def execute(args): except Exception as e: print(f"\n=== ExecuteQuery: {query_func.__name__} ===") common.handle_error(e, f"Query {query_func.__name__}", args.verbose) + + # Get datasource model example + try: + print("\n=== GetDatasourceModel ===") + datasource_model_request = GetDatasourceModelRequest(datasource=datasource) + if args.verbose: + print(f"Request Body: {datasource_model_request}") + + datasource_model_response = get_datasource_model.sync_detailed( + client=client, body=datasource_model_request + ) + common.handle_response( + datasource_model_response, "GetDatasourceModel", args.verbose + ) + except Exception as e: + common.handle_error(e, "GetDatasourceModel", args.verbose) diff --git a/python_sdk/tests/test_get_datasource_model.py b/python_sdk/tests/test_get_datasource_model.py new file mode 100644 index 0000000..6cca75d --- /dev/null +++ b/python_sdk/tests/test_get_datasource_model.py @@ -0,0 +1,138 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from src.api.client import AuthenticatedClient, VizQLDataServiceClient +from src.api.get_datasource_model import asyncio, asyncio_detailed, sync, sync_detailed +from src.api.openapi_generated import ( + Datasource, + DatasourceModelOutput, + GetDatasourceModelRequest, +) + + +@pytest.fixture +def mock_client(): + client = Mock(spec=VizQLDataServiceClient) + client.raise_on_unexpected_status = True + mock_authenticated_client = Mock(spec=AuthenticatedClient) + mock_httpx_client = Mock() + mock_authenticated_client.get_httpx_client.return_value = mock_httpx_client + client.client = mock_authenticated_client + return client + + +@pytest.fixture +def mock_async_client(): + client = Mock(spec=VizQLDataServiceClient) + client.raise_on_unexpected_status = True + mock_authenticated_client = Mock(spec=AuthenticatedClient) + mock_async_httpx_client = AsyncMock() + mock_authenticated_client.get_async_httpx_client.return_value = ( + mock_async_httpx_client + ) + client.client = mock_authenticated_client + return client + + +@pytest.fixture +def mock_get_datasource_model_request(): + return GetDatasourceModelRequest( + datasource=Datasource(datasourceLuid="test_datasource") + ) + + +def test_sync_detailed_success(mock_client, mock_get_datasource_model_request): + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = ( + b'{"logicalTables": [{"logicalTableId": "lt1", "caption": "Table 1", ' + b'"description": "Test table"}]}' + ) + mock_response.headers = {} + + mock_client.client.get_httpx_client.return_value.request.return_value = ( + mock_response + ) + + response = sync_detailed(client=mock_client, body=mock_get_datasource_model_request) + + assert response.status_code == 200 + assert isinstance(response.parsed, DatasourceModelOutput) + assert ( + response.content + == b'{"logicalTables": [{"logicalTableId": "lt1", "caption": "Table 1", ' + b'"description": "Test table"}]}' + ) + + +def test_sync_success(mock_client, mock_get_datasource_model_request): + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = ( + b'{"logicalTables": [{"logicalTableId": "lt1", "caption": "Table 1", ' + b'"description": "Test table"}]}' + ) + mock_response.headers = {} + + mock_client.client.get_httpx_client.return_value.request.return_value = ( + mock_response + ) + + result = sync(client=mock_client, body=mock_get_datasource_model_request) + + assert isinstance(result, DatasourceModelOutput) + + +@pytest.mark.asyncio +async def test_asyncio_detailed_success( + mock_async_client, mock_get_datasource_model_request +): + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = ( + b'{"logicalTables": [{"logicalTableId": "lt1", "caption": "Table 1", ' + b'"description": "Test table"}]}' + ) + mock_response.headers = {} + + mock_async_client.client.get_async_httpx_client.return_value.request.return_value = ( + mock_response + ) + + response = await asyncio_detailed( + client=mock_async_client, body=mock_get_datasource_model_request + ) + + assert response.status_code == 200 + assert isinstance(response.parsed, DatasourceModelOutput) + assert ( + response.content + == b'{"logicalTables": [{"logicalTableId": "lt1", "caption": "Table 1", ' + b'"description": "Test table"}]}' + ) + + +@pytest.mark.asyncio +async def test_asyncio_success(mock_async_client, mock_get_datasource_model_request): + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = ( + b'{"logicalTables": [{"logicalTableId": "lt1", "caption": "Table 1", ' + b'"description": "Test table"}]}' + ) + mock_response.headers = {} + + mock_async_client.client.get_async_httpx_client.return_value.request.return_value = ( + mock_response + ) + + result = await asyncio( + client=mock_async_client, body=mock_get_datasource_model_request + ) + + assert isinstance(result, DatasourceModelOutput) diff --git a/python_sdk/tests/test_query.py b/python_sdk/tests/test_query.py index 00b3bef..d039218 100644 --- a/python_sdk/tests/test_query.py +++ b/python_sdk/tests/test_query.py @@ -19,6 +19,10 @@ ) +def _unwrap(obj): + return obj.root if hasattr(obj, "root") else obj + + @pytest.fixture def sample_metadata_request(): return {"datasource": {"datasourceLuid": "74ff134d-7f8f-475c-a63e-bf14ea26cbb1"}} @@ -133,7 +137,7 @@ def test_metadata_output_from_obj(sample_metadata_output): assert output.extraData.parameters is not None assert len(output.extraData.parameters) == 1 - param = output.extraData.parameters[0].root + param = _unwrap(output.extraData.parameters[0]) assert isinstance(param, QuantitativeRangeParameter) assert param.parameterType == ParameterType.QUANTITATIVE_RANGE assert param.parameterName == "Parameter 1" @@ -195,41 +199,44 @@ def test_query_request_from_dict(sample_query_request): # Test fields assert len(request.query.fields) == 3 - assert isinstance(request.query.fields[0].root, DimensionField) - assert request.query.fields[0].root.fieldCaption == "Order Date" - assert isinstance(request.query.fields[1].root, MeasureField) - assert request.query.fields[1].root.fieldCaption == "Sales" - assert request.query.fields[1].root.function == Function.SUM - assert isinstance(request.query.fields[2].root, DimensionField) - assert request.query.fields[2].root.fieldCaption == "Ship Mode" + field0 = _unwrap(request.query.fields[0]) + assert isinstance(field0, DimensionField) + assert field0.fieldCaption == "Order Date" + field1 = _unwrap(request.query.fields[1]) + assert isinstance(field1, MeasureField) + assert field1.fieldCaption == "Sales" + assert field1.function == Function.SUM + field2 = _unwrap(request.query.fields[2]) + assert isinstance(field2, DimensionField) + assert field2.fieldCaption == "Ship Mode" # Test filters assert len(request.query.filters) == 3 # Test quantitative filter - quant_filter = request.query.filters[0].root + quant_filter = _unwrap(request.query.filters[0]) assert quant_filter.filterType == FilterType.QUANTITATIVE_NUMERICAL assert quant_filter.quantitativeFilterType == QuantitativeFilterType.RANGE assert quant_filter.min == 10 assert quant_filter.max == 63 - assert quant_filter.field.root.fieldCaption == "Sales" - assert quant_filter.field.root.function == Function.SUM + assert _unwrap(quant_filter.field).fieldCaption == "Sales" + assert _unwrap(quant_filter.field).function == Function.SUM # Test date filter - date_filter = request.query.filters[1].root + date_filter = _unwrap(request.query.filters[1]) assert date_filter.filterType == FilterType.DATE assert date_filter.periodType == PeriodType.MONTHS assert date_filter.dateRangeType == DateRangeType.NEXTN assert date_filter.rangeN == 3 assert date_filter.anchorDate == datetime.date(2021, 1, 1) - assert date_filter.field.root.fieldCaption == "Order Date" + assert _unwrap(date_filter.field).fieldCaption == "Order Date" # Test set filter - set_filter = request.query.filters[2].root + set_filter = _unwrap(request.query.filters[2]) assert set_filter.filterType == FilterType.SET - assert [value.root for value in set_filter.values] == ["First Class"] + assert [_unwrap(value) for value in set_filter.values] == ["First Class"] assert set_filter.exclude is False - assert set_filter.field.root.fieldCaption == "Ship Mode" + assert _unwrap(set_filter.field).fieldCaption == "Ship Mode" def test_query_request_to_dict(sample_query_request):