From daeee15c3f0188bd6e2dbe27c230fa83fc136681 Mon Sep 17 00:00:00 2001 From: "bryant.howell" Date: Wed, 26 Mar 2025 15:17:33 -0500 Subject: [PATCH 1/7] Start of 1.8.85 --- setup.cfg | 2 +- src/thoughtspot_rest_api_v1/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index f36fb9e..9a6a7f5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = thoughtspot_rest_api_v1 -version = 1.8.4 +version = 1.8.5 description = Library implementing the ThoughtSpot V1 REST API long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/thoughtspot_rest_api_v1/_version.py b/src/thoughtspot_rest_api_v1/_version.py index fa2822c..7e3de06 100644 --- a/src/thoughtspot_rest_api_v1/_version.py +++ b/src/thoughtspot_rest_api_v1/_version.py @@ -1 +1 @@ -__version__ = '1.8.4' +__version__ = '1.8.5' From 96dcb8e642f5077cc36628a283a07affa54adf72 Mon Sep 17 00:00:00 2001 From: "bryant.howell" Date: Wed, 26 Mar 2025 15:17:33 -0500 Subject: [PATCH 2/7] Start of 1.8.85 --- setup.cfg | 2 +- src/thoughtspot_rest_api_v1/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index f36fb9e..9a6a7f5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = thoughtspot_rest_api_v1 -version = 1.8.4 +version = 1.8.5 description = Library implementing the ThoughtSpot V1 REST API long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/thoughtspot_rest_api_v1/_version.py b/src/thoughtspot_rest_api_v1/_version.py index fa2822c..7e3de06 100644 --- a/src/thoughtspot_rest_api_v1/_version.py +++ b/src/thoughtspot_rest_api_v1/_version.py @@ -1 +1 @@ -__version__ = '1.8.4' +__version__ = '1.8.5' From a2c7fc45c0051e91257c45e941e96a0517d41b79 Mon Sep 17 00:00:00 2001 From: "bryant.howell" Date: Thu, 27 Mar 2025 11:59:42 -0500 Subject: [PATCH 3/7] Added .synk file --- .synk | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .synk diff --git a/.synk b/.synk new file mode 100644 index 0000000..1202e97 --- /dev/null +++ b/.synk @@ -0,0 +1,4 @@ +# .snyk file +ignore: + certifi@MPL-2.0: + - "*" \ No newline at end of file From 53c0ad31abce3f89f2c91c405fb75516ac514883 Mon Sep 17 00:00:00 2001 From: "bryant.howell" Date: Wed, 2 Apr 2025 10:21:51 -0500 Subject: [PATCH 4/7] Added spotter_apis.py example --- examples_v2/spotter_apis.py | 198 ++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 examples_v2/spotter_apis.py diff --git a/examples_v2/spotter_apis.py b/examples_v2/spotter_apis.py new file mode 100644 index 0000000..f39ee74 --- /dev/null +++ b/examples_v2/spotter_apis.py @@ -0,0 +1,198 @@ +# This is a simple example of using the AI endpoints with Python. +# Thanks to Bill Back for the initial example +import requests +from requests.exceptions import HTTPError +import tomllib + +from thoughtspot_rest_api_v1 import * + +# +# Basic pattern for using Spotter / AI REST APIs: +# 1. Create Conversation with Model (formerly Worksheet) GUID to the Create Conversation endpoint +# 2. Provide Conversation ID and the same Model GUID to the Converse endpoint, along with a natural language question +# 3. Converse returns TML Search Tokens, which then you have to send into the /searchdata endpoint to get the data itself +# + +with open("config.toml", "rb") as config_file: + config = tomllib.load(config_file) + +# Assign config to local values for ease of use. + +# Login info +TS_URL = config["config"]["TS_URL"] +USERNAME = config["config"]["USERNAME"] +PASSWORD = config["config"]["PASSWORD"] +ORG_ID = config["config"]["ORG_ID"] + +# GUID for the model to use. +MODEL_GUID = '{model_guid}' + + +def create_client() -> TSRestApiV2: + """ + Creates a new API client. + :return: A TSRestApiV2 client instance for making calls. + """ + ts: TSRestApiV2 = TSRestApiV2(server_url=TS_URL) + + try: + auth_token_response = ts.auth_token_full(username=USERNAME, password=PASSWORD, + org_id=ORG_ID, validity_time_in_sec=36000) + ts.bearer_token = auth_token_response['token'] + + except HTTPError as error: + _bail_with_error(error) + + print('Created a client') + return ts + + +def do_single_call(ts: TSRestApiV2) -> None: + """ + Tests single call to get an answer via an API. + :param ts: A TSRestApiV2 client instance for making calls that has already been authenticated. + :return: None + """ + print('Testing single call questions') + + resp = ts.ai_answer_create(metadata_identifier=MODEL_GUID, + query="give me a list of all the things I sold and how many of each") + tokens = resp['tokens'] + + search_data_resp = call_search_data_api(ts=ts, model_guid=MODEL_GUID, search_tokens=tokens) + print_search_data(search_data_resp) + + +def do_conversation(ts: TSRestApiV2) -> None: + """ + Tests having a data conversation. + :param ts: A TSRestApiV2 client instance for making calls that has already been authenticated. + :return: None + """ + print('Testing a full conversation') + + try: + conv_create_resp = ts.ai_conversation_create(metadata_identifier=MODEL_GUID) + conversation_id = conv_create_resp['conversation_identifier'] + + msg_1 = "show me the top 20 selling items for the west region" + resp = ts.ai_conversation_converse(conversation_identifier=conversation_id, + metadata_identifier=MODEL_GUID, + message=msg_1) + + # The response has a list with one item (??) that has the details. + search_data_resp = call_search_data_api(ts=ts, model_guid=MODEL_GUID, search_tokens=resp[0]['tokens']) + print_search_data(search_data_resp) + + # follow-up questions + msg_2 = "break these out by store" + resp = ts.ai_conversation_converse(conversation_identifier=conversation_id, + metadata_identifier=MODEL_GUID, + message=msg_2) + + search_data_resp = call_search_data_api(ts=ts, model_guid=MODEL_GUID, search_tokens=resp[0]['tokens']) + print_search_data(search_data_resp) + + except HTTPError as error: + _bail_with_error(error) + +# Untested at this time, future feature +''' +def do_decomposed_query(ts: TSRestApiV2) -> None: + """ + Tests decomposed queries (whatever those are). + :param ts: A TSRestApiV2 client instance for making calls that has already been authenticated. + :return: None + """ + print('Testing a decomposed query') + + # conversation ID is optional in this call. + # conversation_id = ts.ai_conversation_create(metadata_identifier=RETAIL_SALES_WS)['conversation_identifier'] + + # First, let's just use generically against a liveboard. + endpoint = "ai/analytical-questions" # endpoint for decomposing + resp = ts.post_request(endpoint=endpoint, request={ + "liveboardIds": [ + "3f5d2d4b-87da-4f59-a144-85d444eada18" + ] + }) + + print(resp) +''' + +def _bail_with_error(error: requests.exceptions.HTTPError) -> None: + """ + Prints info about the error and then exits. + :param error: + :return: + """ + print(error) + print(error.response.content) + + +def call_search_data_api(ts: TSRestApiV2, model_guid: str, search_tokens: str) -> List: + """ + Uses the search data API to get the data and then prints the results. + :param ts: The TSRestApiV2 client instance for making calls that has already been authenticated. + :param model_guid: A valid data source GUID. + :param search_tokens: The search tokens to use. + :return: None + """ + print(f'Searching data {search_tokens}') + resp = ts.searchdata( + {"logical_table_identifier": MODEL_GUID, "query_string": search_tokens, "record_size": 50}) + + return resp + + +def print_search_data(search_data) -> None: + """ + Prints the data from a 'searchdata' call. This assumes it works. + :param search_data: The response which has contents with the actual data. + :return: None + """ + # Extract the table contents from the API response + contents = search_data['contents'][0] + column_names = contents['column_names'] + data_rows = contents['data_rows'] + + # Compute the maximum width for each column for proper alignment + widths = [] + for i, header in enumerate(column_names): + col_width = max(len(str(header)), max(len(str(row[i])) for row in data_rows)) + widths.append(col_width) + + # Create the header row and a separator row + header_row = " | ".join(str(header).ljust(widths[i]) for i, header in enumerate(column_names)) + separator = "-+-".join("-" * widths[i] for i in range(len(widths))) + table_width = len(header_row) + + # Separator for readability. + print("") + print("-" * table_width) + print("") + + # Print the table to stdout + print(header_row) + print(separator) + for row in data_rows: + print(" | ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row))) + + # Separator for readability. + print("") + print("-" * table_width) + print("") + + +if __name__ == "__main__": + print('Testing AI') + + tsapi = create_client() + + do_single_call(tsapi) + + do_conversation(tsapi) + + # do_decomposed_query(tsapi) # new feature not yet in this version. + + print('Testing complete') \ No newline at end of file From 8d568b3d8fc504eafe67a583ae2d485b748f8b03 Mon Sep 17 00:00:00 2001 From: "bryant.howell" Date: Fri, 18 Apr 2025 10:31:39 -0500 Subject: [PATCH 5/7] Better file name heuristic --- examples_v2/set_obj_id.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/examples_v2/set_obj_id.py b/examples_v2/set_obj_id.py index f61139e..4d18dd2 100644 --- a/examples_v2/set_obj_id.py +++ b/examples_v2/set_obj_id.py @@ -22,7 +22,6 @@ print(e) print(e.response.content) exit() - def create_obj_id_update_request(guid: str, obj_id: str): update_req = { "headers_update": @@ -39,6 +38,7 @@ def create_obj_id_update_request(guid: str, obj_id: str): } return update_req + # { 'guid' : 'obj_id' } def create_multi_obj_id_update_request(guid_obj_id_map: Dict): update_req = { @@ -98,16 +98,23 @@ def export_tml_with_obj_id(guid:Optional[str] = None, "include_obj_id": True } - yaml_tml = ts.metadata_tml_export(metadata_ids=[guid], edoc_format='YAML', export_options=exp_opt) + # Get obj_id from the TML + lines = yaml_tml[0]['edoc'].splitlines() + if obj_id is None: + if lines[0].find('obj_id: ') != -1: + obj_id = lines[0].replace('obj_id: ', "") + + obj_type = lines[1].replace(":", "") + if save_to_disk is True: print(yaml_tml[0]['edoc']) print("-------") # Save the file with {obj_id}.{type}.{tml} - filename = "{}.table.tml".format(obj_id) + filename = "{}.{}.tml".format(obj_id, obj_type) with open(file=filename, mode='w') as f: f.write(yaml_tml[0]['edoc']) From 9f47fc94dcc949772a3187e48c20bb26bd3cccbf Mon Sep 17 00:00:00 2001 From: "bryant.howell" Date: Wed, 7 May 2025 15:07:27 -0500 Subject: [PATCH 6/7] Quick fix --- examples_v2/abac_token_parameters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples_v2/abac_token_parameters.py b/examples_v2/abac_token_parameters.py index e620b3d..6942397 100644 --- a/examples_v2/abac_token_parameters.py +++ b/examples_v2/abac_token_parameters.py @@ -18,7 +18,7 @@ ts: TSRestApiV2 = TSRestApiV2(server_url=server) # Simple function for translating {key: List} arguments into the full ABAC syntax for Full Access Token -# parameters : {name: value ... } +# parameters : {name: [value] ... } # filters: {{name}: [{values}... } def create_abac_section(parameters, filters, persist_all=False): From c86a1c17ceadc15a235cbdc03f14047bd7c67464 Mon Sep 17 00:00:00 2001 From: "bryant.howell" Date: Wed, 7 May 2025 15:31:22 -0500 Subject: [PATCH 7/7] Added metadata/update-obj-id endpoint --- src/thoughtspot_rest_api_v1/tsrestapiv2.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/thoughtspot_rest_api_v1/tsrestapiv2.py b/src/thoughtspot_rest_api_v1/tsrestapiv2.py index 77a7da0..c9a61d0 100644 --- a/src/thoughtspot_rest_api_v1/tsrestapiv2.py +++ b/src/thoughtspot_rest_api_v1/tsrestapiv2.py @@ -706,6 +706,27 @@ def metadata_headers_update(self, request: Dict): endpoint = 'metadata/headers/update' return self.post_request(endpoint=endpoint, request=request) + def metadata_update_obj_id(self, new_obj_id: str, guid: Optional[str], current_obj_id: Optional[str], + request_override: Optional[Dict] = None): + endpoint = 'metadata/update-obj-id' + if request_override is not None: + request = request_override + else: + request = { + "metadata": [ + {"new_obj_id" : new_obj_id} + ] + } + if guid is not None: + request["metadata"][0]["metadata_identifier"] = guid + elif current_obj_id is not None: + request["metadata"][0]["current_obj_id"] = current_obj_id + else: + raise Exception("Must provide one of guid or current_obj_id") + + return self.post_request(endpoint=endpoint, request=request) + + # # /reports/ endpoints #