From f6f5e5217350fba94f5738f86a120a82125f9ad3 Mon Sep 17 00:00:00 2001 From: BarCo Date: Wed, 15 Jul 2026 13:13:55 +0300 Subject: [PATCH 1/5] Add async support to run_query and implement polling for results - Introduced _build_headers function to manage request headers. - Added async_mode parameter to run_query for asynchronous query execution. - Implemented _poll_async_result function to handle polling for async results. - Created tests for async mode in test_async_query_stubbed.py. - Refactored user creation logic in test_user_impersonation.py for clarity. --- pytimbr_api/timbr_http_connector.py | 105 ++++++++++--- test/test_async_query_stubbed.py | 219 ++++++++++++++++++++++++++++ test/test_user_impersonation.py | 29 ++-- 3 files changed, 317 insertions(+), 36 deletions(-) create mode 100644 test/test_async_query_stubbed.py diff --git a/pytimbr_api/timbr_http_connector.py b/pytimbr_api/timbr_http_connector.py index b2c012e..bb867f4 100644 --- a/pytimbr_api/timbr_http_connector.py +++ b/pytimbr_api/timbr_http_connector.py @@ -9,6 +9,7 @@ # `````````````````````````````````````````````````````````````` # Copyright (C) 2018-2024 timbr.ai +import time import requests @@ -24,6 +25,56 @@ def _parse_response(response): return response_json +def _build_headers( + token: str, + nested: str = 'false', + is_jwt: bool = False, + jwt_tenant_id: str = None, + additional_headers: dict = None, +) -> dict: + headers = { + 'Content-Type': 'application/text', + 'nested': nested, + 'Connection': 'close', + } + if is_jwt: + headers['x-jwt-token'] = token + if jwt_tenant_id: + headers['x-jwt-tenant-id'] = jwt_tenant_id + else: + headers['x-api-key'] = token + if additional_headers: + for key, value in additional_headers.items(): + headers[key.replace('_', '-')] = value + return headers + + +def _poll_async_result(base_url, response_id, headers, verify_ssl, poll_interval, timeout): + """Poll for async query result until completed, error, or timeout.""" + deadline = time.monotonic() + timeout + while True: + response = requests.get( + f'{base_url}timbr/openapi/get-async-results/{response_id}', + headers=headers, + verify=verify_ssl, + ) + if response.status_code == 404: + raise Exception(f'Async result not found (expired or already fetched): {response_id}') + try: + result = response.json() + except Exception as e: + raise Exception(f'Could not parse async result response: {e}') + + status = result.get('status') + if status == 'completed': + return result.get('response') + if status == 'error': + raise Exception(f'Async query failed: {result.get("error")} ({result.get("error_type", "")})') + if time.monotonic() >= deadline: + raise TimeoutError(f'Async query did not complete within {timeout}s (response_id={response_id})') + time.sleep(poll_interval) + + def run_query( url: str, ontology: str, @@ -36,40 +87,46 @@ def run_query( is_jwt: bool = False, jwt_tenant_id: str = None, additional_headers: dict = None, + async_mode: bool = False, + poll_interval: float = 2.0, + timeout: float = 300.0, ): datasource_addition = '' if datasource: - datasource_addition = f'?datasource={datasource}' - + datasource_addition = f'?datasource={datasource}' + base_url = url if not base_url.endswith('/'): - base_url = base_url + '/' - - headers = { - 'Content-Type': 'application/text', - 'nested': nested, - 'Connection': 'close', - } - - if is_jwt: - headers['x-jwt-token'] = token - if jwt_tenant_id: - headers['x-jwt-tenant-id'] = jwt_tenant_id - else: - headers['x-api-key'] = token + base_url = base_url + '/' - if additional_headers: - for key, value in additional_headers.items(): - headers[key.replace('_', '-')] = value + headers = _build_headers(token, nested, is_jwt, jwt_tenant_id, additional_headers) + if async_mode: + headers['x-async'] = 'true' requests.packages.urllib3.util.connection.HAS_IPV6 = enable_IPv6 response = requests.post( - f'{base_url}timbr/openapi/ontology/{ontology}/query{datasource_addition}', - headers = headers, - data = query.encode('utf-8') if isinstance(query, str) else query, - verify = verify_ssl, + f'{base_url}timbr/openapi/ontology/{ontology}/query{datasource_addition}', + headers=headers, + data=query.encode('utf-8') if isinstance(query, str) else query, + verify=verify_ssl, ) - return _parse_response(response) + + if not async_mode: + return _parse_response(response) + + # Async mode: expect 202, then poll for result + if response.status_code != 202: + raise Exception(f'Error submitting async query: {response.text}') + try: + submission = response.json() + except Exception as e: + raise Exception(f'Could not parse async submission response: {e}') + + response_id = submission.get('response_id') + if not response_id: + raise Exception(f'Server did not return a response_id: {submission}') + + return _poll_async_result(base_url, response_id, headers, verify_ssl, poll_interval, timeout) # Deprecated - Backward compatibility diff --git a/test/test_async_query_stubbed.py b/test/test_async_query_stubbed.py new file mode 100644 index 0000000..d620dee --- /dev/null +++ b/test/test_async_query_stubbed.py @@ -0,0 +1,219 @@ +import time +import pytest +from unittest.mock import patch, MagicMock +import pytimbr_api as timbr + +MOCK_URL = 'http://mock-timbr.example.com:11000' +MOCK_ONTOLOGY = 'test_ontology' +MOCK_TOKEN = 'tk_test_api_key_12345' +MOCK_QUERY = 'SELECT * FROM timbr.sys_concepts' +MOCK_RESPONSE_ID = 'abc12345-0000-0000-0000-000000000001' + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_200(data=None): + mock = MagicMock() + mock.status_code = 200 + mock.json.return_value = data or [{'concept': 'customer'}] + return mock + + +def _mock_202(response_id=MOCK_RESPONSE_ID): + mock = MagicMock() + mock.status_code = 202 + mock.json.return_value = { + 'async': True, + 'response_id': response_id, + 'poll_url': f'/timbr/openapi/get-async-results/{response_id}', + 'message': 'Request accepted for async processing', + } + return mock + + +def _mock_get_running(response_id=MOCK_RESPONSE_ID): + mock = MagicMock() + mock.status_code = 200 + mock.json.return_value = {'status': 'running', 'response_id': response_id, 'message': 'Still processing'} + return mock + + +def _mock_get_completed(data=None): + mock = MagicMock() + mock.status_code = 200 + mock.json.return_value = { + 'status': 'completed', + 'response': data or [{'concept': 'customer'}], + 'original_status_code': 200, + } + return mock + + +def _mock_get_error(): + mock = MagicMock() + mock.status_code = 200 + mock.json.return_value = {'status': 'error', 'error': 'Query timed out', 'error_type': 'ExecutionError'} + return mock + + +def _mock_get_404(): + mock = MagicMock() + mock.status_code = 404 + mock.json.return_value = {'error': 'Response not found'} + return mock + + +# --------------------------------------------------------------------------- +# run_query (sync mode) — unchanged behavior +# --------------------------------------------------------------------------- + +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_run_query_sync_returns_data(mock_post): + mock_post.return_value = _mock_200([{'id': 1}]) + result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY) + assert result == [{'id': 1}] + + +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_run_query_sync_does_not_send_x_async_header(mock_post): + mock_post.return_value = _mock_200() + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY) + _, kwargs = mock_post.call_args + assert 'x-async' not in kwargs['headers'] + + +# --------------------------------------------------------------------------- +# run_query(async_mode=True) — submit + poll +# --------------------------------------------------------------------------- + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_sends_x_async_header(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_completed() + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + _, kwargs = mock_post.call_args + assert kwargs['headers']['x-async'] == 'true' + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_returns_data_on_immediate_complete(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_completed([{'id': 42}]) + result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + assert result == [{'id': 42}] + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_sends_api_key_header(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_completed() + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + _, kwargs = mock_post.call_args + assert kwargs['headers']['x-api-key'] == MOCK_TOKEN + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_jwt_headers(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_completed() + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, 'jwt_token', MOCK_QUERY, + is_jwt=True, jwt_tenant_id='tenant1', async_mode=True) + _, kwargs = mock_post.call_args + assert kwargs['headers']['x-jwt-token'] == 'jwt_token' + assert kwargs['headers']['x-jwt-tenant-id'] == 'tenant1' + + +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_raises_on_non_202(mock_post): + mock = MagicMock() + mock.status_code = 500 + mock.text = 'Internal Server Error' + mock_post.return_value = mock + with pytest.raises(Exception, match='Error submitting async query'): + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_with_datasource(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_completed() + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, datasource='my_ds', async_mode=True) + call_url = mock_post.call_args[0][0] + assert 'datasource=my_ds' in call_url + + +@patch('pytimbr_api.timbr_http_connector.time.sleep') +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_polls_until_completed(mock_post, mock_get, mock_sleep): + mock_post.return_value = _mock_202() + mock_get.side_effect = [ + _mock_get_running(), + _mock_get_running(), + _mock_get_completed([{'id': 99}]), + ] + result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, + async_mode=True, poll_interval=1.0) + assert result == [{'id': 99}] + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(1.0) + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_raises_on_server_error(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_error() + with pytest.raises(Exception, match='Async query failed'): + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + + +@patch('pytimbr_api.timbr_http_connector.time.monotonic') +@patch('pytimbr_api.timbr_http_connector.time.sleep') +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_raises_timeout(mock_post, mock_get, mock_sleep, mock_monotonic): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_running() + # First call sets deadline, subsequent calls exceed it + mock_monotonic.side_effect = [0.0, 0.0, 999.0] + with pytest.raises(TimeoutError, match='did not complete within'): + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, + async_mode=True, timeout=10.0) + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_raises_on_404(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_404() + with pytest.raises(Exception, match='Async result not found'): + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + + +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_raises_when_no_response_id(mock_post): + mock = MagicMock() + mock.status_code = 202 + mock.json.return_value = {'async': True} # missing response_id + mock_post.return_value = mock + with pytest.raises(Exception, match='did not return a response_id'): + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +@patch('pytimbr_api.timbr_http_connector.requests.post') +def test_async_mode_polls_correct_url(mock_post, mock_get): + mock_post.return_value = _mock_202() + mock_get.return_value = _mock_get_completed() + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + poll_url = mock_get.call_args[0][0] + assert MOCK_RESPONSE_ID in poll_url + assert 'get-async-results' in poll_url diff --git a/test/test_user_impersonation.py b/test/test_user_impersonation.py index 8f7d901..8cbbfac 100644 --- a/test/test_user_impersonation.py +++ b/test/test_user_impersonation.py @@ -29,55 +29,60 @@ granting_user = f"timbr_granting" impersonating_user = f"timbr_impersonating" -def create_users(test_config): - print("Creating users...") - granting_user_stmt = create_granting_user_stmt.format(username=granting_user, password=test_config['timbr_user_password'], first_name="Granting", last_name="User") - impersonating_user_stmt = create_impersonating_user_stmt.format(username=impersonating_user, password=test_config['timbr_user_password'], first_name="Impersonating", last_name="User") - +def refresh_permissions(test_config): run_query( url=test_config['url'], ontology=test_config['ontology'], token=test_config['token'], - query=granting_user_stmt, + query="refresh permissions", datasource=test_config['datasource'], nested='false', verify_ssl=test_config['verify_ssl'], enable_IPv6=test_config['enableIPv6'], ) + time.sleep(10) # Make sure the permissions are propagated + +def create_users(test_config): + print("Creating users...") + granting_user_stmt = create_granting_user_stmt.format(username=granting_user, password=test_config['timbr_user_password'], first_name="Granting", last_name="User") + impersonating_user_stmt = create_impersonating_user_stmt.format(username=impersonating_user, password=test_config['timbr_user_password'], first_name="Impersonating", last_name="User") + run_query( url=test_config['url'], ontology=test_config['ontology'], token=test_config['token'], - query=impersonating_user_stmt, + query=granting_user_stmt, datasource=test_config['datasource'], nested='false', verify_ssl=test_config['verify_ssl'], enable_IPv6=test_config['enableIPv6'], ) + # refresh_permissions(test_config) - grant_auth = grant_auth_stmt.format(granting=granting_user, impersonating=impersonating_user) run_query( url=test_config['url'], ontology=test_config['ontology'], token=test_config['token'], - query=grant_auth, + query=impersonating_user_stmt, datasource=test_config['datasource'], nested='false', verify_ssl=test_config['verify_ssl'], enable_IPv6=test_config['enableIPv6'], ) + # refresh_permissions(test_config) - # Waiting for user creation to propagate + grant_auth = grant_auth_stmt.format(granting=granting_user, impersonating=impersonating_user) run_query( url=test_config['url'], ontology=test_config['ontology'], token=test_config['token'], - query="refresh permissions", + query=grant_auth, datasource=test_config['datasource'], nested='false', verify_ssl=test_config['verify_ssl'], enable_IPv6=test_config['enableIPv6'], ) + refresh_permissions(test_config) def drop_users(test_config): @@ -150,7 +155,7 @@ def test_user_impersonation(self, setup_test_users): impersonating_user_token = impersonating_user_token_res[0]['token'] # Act - time.sleep(5) # Make sure the permissions are propagated + time.sleep(10) # Make sure the permissions are propagated res = run_query( url=test_config['url'], ontology=test_config['ontology'], From a2c00df1ee523af8ef12645f6c12e90ddadc076a Mon Sep 17 00:00:00 2001 From: BarCo Date: Wed, 15 Jul 2026 13:54:51 +0300 Subject: [PATCH 2/5] Add async support to run_query and update related documentation and tests --- README.md | 27 +++++++++++++++- pytimbr_api/timbr_http_connector.py | 6 ++-- test/test_async_query_stubbed.py | 50 ++++++++++++++--------------- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e3e4355..5848504 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ This project is a pure python connector to timbr (no dependencies required). is_jwt = , jwt_tenant_id = "", additional_headers = <{ "x-api-impersonate-user": "" }>, + is_async = , + poll_interval = , + timeout = , ) # url - Required - String - The IP / Hostname of the Timbr platform. @@ -56,10 +59,32 @@ This project is a pure python connector to timbr (no dependencies required). # verify_ssl - Optional - Boolean - Verifying the target server's SSL Certificate, use False to disable this process. # enable_IPv6 - Optional - Boolean - Change to 'true' if you are using IPv6 connection. # is_jwt - Optional - Boolean - Set to True if you are using JWT token, otherwise set to False. - # jwt_tenant_id - Optional - String - The tenant ID for JWT authentication + # jwt_tenant_id - Optional - String - The tenant ID for JWT authentication. # additional_headers - Optional - Dict - Extra Timbr connection parameters sent with every request (e.g., 'x-api-impersonate-user'). + # is_async - Optional - Boolean - Set to True to submit the query asynchronously and poll until complete. Useful for long-running queries that may exceed synchronous HTTP timeouts. Default: False. + # poll_interval - Optional - Float - Seconds to wait between polling attempts when is_async=True. Default: 2.0. + # timeout - Optional - Float - Maximum seconds to wait for an async query to complete. Raises TimeoutError if exceeded. Default: 300.0. ``` +## Async query execution + +For long-running queries that may time out on a synchronous HTTP connection, set `is_async=True`. The function submits the query in the background and polls for the result transparently — the return value is identical to a regular synchronous call. + +```python + response = pytimbr_api.run_query( + url = "https://mytimbrenv.com:443", + ontology = "my_ontology", + token = "tk_mytimbrtoken", + query = "SELECT * FROM timbr.large_table", + is_async = True, # submit in background, poll until done + poll_interval = 3.0, # check every 3 seconds (default: 2.0) + timeout = 600.0, # wait up to 10 minutes (default: 300.0) + ) + print(response) +``` + +A `TimeoutError` is raised if the query does not complete within `timeout` seconds. Server-side errors raise a standard `Exception` with details from the server response. + ### Using Timbr token #### HTTP example diff --git a/pytimbr_api/timbr_http_connector.py b/pytimbr_api/timbr_http_connector.py index bb867f4..b366783 100644 --- a/pytimbr_api/timbr_http_connector.py +++ b/pytimbr_api/timbr_http_connector.py @@ -87,7 +87,7 @@ def run_query( is_jwt: bool = False, jwt_tenant_id: str = None, additional_headers: dict = None, - async_mode: bool = False, + is_async: bool = False, poll_interval: float = 2.0, timeout: float = 300.0, ): @@ -100,7 +100,7 @@ def run_query( base_url = base_url + '/' headers = _build_headers(token, nested, is_jwt, jwt_tenant_id, additional_headers) - if async_mode: + if is_async: headers['x-async'] = 'true' requests.packages.urllib3.util.connection.HAS_IPV6 = enable_IPv6 @@ -111,7 +111,7 @@ def run_query( verify=verify_ssl, ) - if not async_mode: + if not is_async: return _parse_response(response) # Async mode: expect 202, then poll for result diff --git a/test/test_async_query_stubbed.py b/test/test_async_query_stubbed.py index d620dee..acf30ec 100644 --- a/test/test_async_query_stubbed.py +++ b/test/test_async_query_stubbed.py @@ -85,66 +85,66 @@ def test_run_query_sync_does_not_send_x_async_header(mock_post): # --------------------------------------------------------------------------- -# run_query(async_mode=True) — submit + poll +# run_query(is_async=True) — submit + poll # --------------------------------------------------------------------------- @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_sends_x_async_header(mock_post, mock_get): +def test_is_async_sends_x_async_header(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_completed() - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) _, kwargs = mock_post.call_args assert kwargs['headers']['x-async'] == 'true' @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_returns_data_on_immediate_complete(mock_post, mock_get): +def test_is_async_returns_data_on_immediate_complete(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_completed([{'id': 42}]) - result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) assert result == [{'id': 42}] @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_sends_api_key_header(mock_post, mock_get): +def test_is_async_sends_api_key_header(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_completed() - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) _, kwargs = mock_post.call_args assert kwargs['headers']['x-api-key'] == MOCK_TOKEN @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_jwt_headers(mock_post, mock_get): +def test_is_async_jwt_headers(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_completed() timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, 'jwt_token', MOCK_QUERY, - is_jwt=True, jwt_tenant_id='tenant1', async_mode=True) + is_jwt=True, jwt_tenant_id='tenant1', is_async=True) _, kwargs = mock_post.call_args assert kwargs['headers']['x-jwt-token'] == 'jwt_token' assert kwargs['headers']['x-jwt-tenant-id'] == 'tenant1' @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_raises_on_non_202(mock_post): +def test_is_async_raises_on_non_202(mock_post): mock = MagicMock() mock.status_code = 500 mock.text = 'Internal Server Error' mock_post.return_value = mock with pytest.raises(Exception, match='Error submitting async query'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_with_datasource(mock_post, mock_get): +def test_is_async_with_datasource(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_completed() - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, datasource='my_ds', async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, datasource='my_ds', is_async=True) call_url = mock_post.call_args[0][0] assert 'datasource=my_ds' in call_url @@ -152,7 +152,7 @@ def test_async_mode_with_datasource(mock_post, mock_get): @patch('pytimbr_api.timbr_http_connector.time.sleep') @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_polls_until_completed(mock_post, mock_get, mock_sleep): +def test_is_async_polls_until_completed(mock_post, mock_get, mock_sleep): mock_post.return_value = _mock_202() mock_get.side_effect = [ _mock_get_running(), @@ -160,7 +160,7 @@ def test_async_mode_polls_until_completed(mock_post, mock_get, mock_sleep): _mock_get_completed([{'id': 99}]), ] result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, - async_mode=True, poll_interval=1.0) + is_async=True, poll_interval=1.0) assert result == [{'id': 99}] assert mock_sleep.call_count == 2 mock_sleep.assert_called_with(1.0) @@ -168,52 +168,52 @@ def test_async_mode_polls_until_completed(mock_post, mock_get, mock_sleep): @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_raises_on_server_error(mock_post, mock_get): +def test_is_async_raises_on_server_error(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_error() with pytest.raises(Exception, match='Async query failed'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) @patch('pytimbr_api.timbr_http_connector.time.monotonic') @patch('pytimbr_api.timbr_http_connector.time.sleep') @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_raises_timeout(mock_post, mock_get, mock_sleep, mock_monotonic): +def test_is_async_raises_timeout(mock_post, mock_get, mock_sleep, mock_monotonic): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_running() # First call sets deadline, subsequent calls exceed it mock_monotonic.side_effect = [0.0, 0.0, 999.0] with pytest.raises(TimeoutError, match='did not complete within'): timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, - async_mode=True, timeout=10.0) + is_async=True, timeout=10.0) @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_raises_on_404(mock_post, mock_get): +def test_is_async_raises_on_404(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_404() with pytest.raises(Exception, match='Async result not found'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_raises_when_no_response_id(mock_post): +def test_is_async_raises_when_no_response_id(mock_post): mock = MagicMock() mock.status_code = 202 mock.json.return_value = {'async': True} # missing response_id mock_post.return_value = mock with pytest.raises(Exception, match='did not return a response_id'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) @patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_async_mode_polls_correct_url(mock_post, mock_get): +def test_is_async_polls_correct_url(mock_post, mock_get): mock_post.return_value = _mock_202() mock_get.return_value = _mock_get_completed() - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, async_mode=True) + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) poll_url = mock_get.call_args[0][0] assert MOCK_RESPONSE_ID in poll_url assert 'get-async-results' in poll_url From bbd056643903a440905ed25d2bf4d75d0f685c1c Mon Sep 17 00:00:00 2001 From: BarCo Date: Wed, 15 Jul 2026 17:00:37 +0300 Subject: [PATCH 3/5] Add async support to get_async_result and update run_query documentation and tests --- README.md | 55 ++++++++---- pytimbr_api/__init__.py | 2 +- pytimbr_api/timbr_http_connector.py | 82 ++++++++++------- test/test_async_query_stubbed.py | 132 ++++++++++++++-------------- 4 files changed, 156 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 5848504..ee23a78 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,6 @@ This project is a pure python connector to timbr (no dependencies required). jwt_tenant_id = "", additional_headers = <{ "x-api-impersonate-user": "" }>, is_async = , - poll_interval = , - timeout = , ) # url - Required - String - The IP / Hostname of the Timbr platform. @@ -61,29 +59,54 @@ This project is a pure python connector to timbr (no dependencies required). # is_jwt - Optional - Boolean - Set to True if you are using JWT token, otherwise set to False. # jwt_tenant_id - Optional - String - The tenant ID for JWT authentication. # additional_headers - Optional - Dict - Extra Timbr connection parameters sent with every request (e.g., 'x-api-impersonate-user'). - # is_async - Optional - Boolean - Set to True to submit the query asynchronously and poll until complete. Useful for long-running queries that may exceed synchronous HTTP timeouts. Default: False. - # poll_interval - Optional - Float - Seconds to wait between polling attempts when is_async=True. Default: 2.0. - # timeout - Optional - Float - Maximum seconds to wait for an async query to complete. Raises TimeoutError if exceeded. Default: 300.0. + # is_async - Optional - Boolean - Set to True to submit the query asynchronously. Returns the submission response (including response_id) immediately instead of waiting for the result. Use get_async_result() to poll for completion. Default: False. ``` ## Async query execution -For long-running queries that may time out on a synchronous HTTP connection, set `is_async=True`. The function submits the query in the background and polls for the result transparently — the return value is identical to a regular synchronous call. +For long-running queries that may time out on a synchronous HTTP connection, use the two-step async flow: + +1. **Submit** — call `run_query` with `is_async=True`. It returns immediately with a submission response containing a `response_id`. +2. **Poll** — call `get_async_result` with the `response_id` to check the status. Repeat until `status` is `'completed'` or `'error'`. ```python - response = pytimbr_api.run_query( - url = "https://mytimbrenv.com:443", - ontology = "my_ontology", - token = "tk_mytimbrtoken", - query = "SELECT * FROM timbr.large_table", - is_async = True, # submit in background, poll until done - poll_interval = 3.0, # check every 3 seconds (default: 2.0) - timeout = 600.0, # wait up to 10 minutes (default: 300.0) +import time +import pytimbr_api + +URL = "https://mytimbrenv.com:443" +ONTOLOGY = "my_ontology" +TOKEN = "tk_mytimbrtoken" + +# Step 1: submit the query asynchronously +submission = pytimbr_api.run_query( + url = URL, + ontology = ONTOLOGY, + token = TOKEN, + query = "SELECT * FROM timbr.large_table", + is_async = True, +) +response_id = submission["response_id"] +print(f"Query submitted — response_id: {response_id}") + +# Step 2: poll until the result is ready +while True: + result = pytimbr_api.get_async_result( + url = URL, + response_id = response_id, + token = TOKEN, ) - print(response) + status = result["status"] + if status == "completed": + print(result["response"]) + break + elif status == "error": + raise Exception(f"Query failed: {result.get('error')}") + else: + print(f"Status: {status} — waiting...") + time.sleep(3) ``` -A `TimeoutError` is raised if the query does not complete within `timeout` seconds. Server-side errors raise a standard `Exception` with details from the server response. +`get_async_result` makes a **single** request each call and returns the raw server response — polling cadence and timeout logic are left to the caller. ### Using Timbr token diff --git a/pytimbr_api/__init__.py b/pytimbr_api/__init__.py index e8082cf..50cbce5 100644 --- a/pytimbr_api/__init__.py +++ b/pytimbr_api/__init__.py @@ -1 +1 @@ -from .timbr_http_connector import run_query, advancedQueryExecute, simpleQueryExecution, executeQuery \ No newline at end of file +from .timbr_http_connector import run_query, get_async_result, advancedQueryExecute, simpleQueryExecution, executeQuery \ No newline at end of file diff --git a/pytimbr_api/timbr_http_connector.py b/pytimbr_api/timbr_http_connector.py index b366783..aa1fa5f 100644 --- a/pytimbr_api/timbr_http_connector.py +++ b/pytimbr_api/timbr_http_connector.py @@ -9,7 +9,6 @@ # `````````````````````````````````````````````````````````````` # Copyright (C) 2018-2024 timbr.ai -import time import requests @@ -49,32 +48,6 @@ def _build_headers( return headers -def _poll_async_result(base_url, response_id, headers, verify_ssl, poll_interval, timeout): - """Poll for async query result until completed, error, or timeout.""" - deadline = time.monotonic() + timeout - while True: - response = requests.get( - f'{base_url}timbr/openapi/get-async-results/{response_id}', - headers=headers, - verify=verify_ssl, - ) - if response.status_code == 404: - raise Exception(f'Async result not found (expired or already fetched): {response_id}') - try: - result = response.json() - except Exception as e: - raise Exception(f'Could not parse async result response: {e}') - - status = result.get('status') - if status == 'completed': - return result.get('response') - if status == 'error': - raise Exception(f'Async query failed: {result.get("error")} ({result.get("error_type", "")})') - if time.monotonic() >= deadline: - raise TimeoutError(f'Async query did not complete within {timeout}s (response_id={response_id})') - time.sleep(poll_interval) - - def run_query( url: str, ontology: str, @@ -88,8 +61,6 @@ def run_query( jwt_tenant_id: str = None, additional_headers: dict = None, is_async: bool = False, - poll_interval: float = 2.0, - timeout: float = 300.0, ): datasource_addition = '' if datasource: @@ -114,7 +85,7 @@ def run_query( if not is_async: return _parse_response(response) - # Async mode: expect 202, then poll for result + # Async mode: expect 202 with response_id, return submission body as-is if response.status_code != 202: raise Exception(f'Error submitting async query: {response.text}') try: @@ -122,11 +93,56 @@ def run_query( except Exception as e: raise Exception(f'Could not parse async submission response: {e}') - response_id = submission.get('response_id') - if not response_id: + if not submission.get('response_id'): raise Exception(f'Server did not return a response_id: {submission}') - return _poll_async_result(base_url, response_id, headers, verify_ssl, poll_interval, timeout) + return submission + + +def get_async_result( + url: str, + response_id: str, + token: str, + is_jwt: bool = False, + jwt_tenant_id: str = None, + verify_ssl: bool = True, + additional_headers: dict = None, +) -> dict: + """Fetch the current result of a previously submitted async query. + + Makes a single GET request and returns the raw JSON response from the server. + The caller is responsible for checking ``result['status']`` and polling again + if it is ``'running'``. + + Returns a dict such as:: + + {'status': 'running', 'response_id': '...', 'message': '...'} + {'status': 'completed', 'response': [...], 'original_status_code': 200} + {'status': 'error', 'error': '...', 'error_type': '...'} + + Raises: + Exception: if the server returns 404 (result expired/not found) or a non-200 HTTP status. + """ + base_url = url + if not base_url.endswith('/'): + base_url = base_url + '/' + + headers = _build_headers(token, is_jwt=is_jwt, jwt_tenant_id=jwt_tenant_id, + additional_headers=additional_headers) + + response = requests.get( + f'{base_url}timbr/openapi/get-async-results/{response_id}', + headers=headers, + verify=verify_ssl, + ) + if response.status_code == 404: + raise Exception(f'Async result not found (expired or already fetched): {response_id}') + if response.status_code != 200: + raise Exception(f'Error fetching async result: {response.text}') + try: + return response.json() + except Exception as e: + raise Exception(f'Could not parse async result response: {e}') # Deprecated - Backward compatibility diff --git a/test/test_async_query_stubbed.py b/test/test_async_query_stubbed.py index acf30ec..7ccb5e6 100644 --- a/test/test_async_query_stubbed.py +++ b/test/test_async_query_stubbed.py @@ -88,40 +88,34 @@ def test_run_query_sync_does_not_send_x_async_header(mock_post): # run_query(is_async=True) — submit + poll # --------------------------------------------------------------------------- -@patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_sends_x_async_header(mock_post, mock_get): +def test_is_async_sends_x_async_header(mock_post): mock_post.return_value = _mock_202() - mock_get.return_value = _mock_get_completed() timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) _, kwargs = mock_post.call_args assert kwargs['headers']['x-async'] == 'true' -@patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_returns_data_on_immediate_complete(mock_post, mock_get): +def test_is_async_returns_submission_body(mock_post): mock_post.return_value = _mock_202() - mock_get.return_value = _mock_get_completed([{'id': 42}]) result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) - assert result == [{'id': 42}] + assert result['response_id'] == MOCK_RESPONSE_ID + assert result['async'] is True + assert 'poll_url' in result -@patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_sends_api_key_header(mock_post, mock_get): +def test_is_async_sends_api_key_header(mock_post): mock_post.return_value = _mock_202() - mock_get.return_value = _mock_get_completed() timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) _, kwargs = mock_post.call_args assert kwargs['headers']['x-api-key'] == MOCK_TOKEN -@patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_jwt_headers(mock_post, mock_get): +def test_is_async_jwt_headers(mock_post): mock_post.return_value = _mock_202() - mock_get.return_value = _mock_get_completed() timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, 'jwt_token', MOCK_QUERY, is_jwt=True, jwt_tenant_id='tenant1', is_async=True) _, kwargs = mock_post.call_args @@ -139,81 +133,89 @@ def test_is_async_raises_on_non_202(mock_post): timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) -@patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_with_datasource(mock_post, mock_get): +def test_is_async_with_datasource(mock_post): mock_post.return_value = _mock_202() - mock_get.return_value = _mock_get_completed() timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, datasource='my_ds', is_async=True) call_url = mock_post.call_args[0][0] assert 'datasource=my_ds' in call_url -@patch('pytimbr_api.timbr_http_connector.time.sleep') -@patch('pytimbr_api.timbr_http_connector.requests.get') @patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_polls_until_completed(mock_post, mock_get, mock_sleep): - mock_post.return_value = _mock_202() - mock_get.side_effect = [ - _mock_get_running(), - _mock_get_running(), - _mock_get_completed([{'id': 99}]), - ] - result = timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, - is_async=True, poll_interval=1.0) - assert result == [{'id': 99}] - assert mock_sleep.call_count == 2 - mock_sleep.assert_called_with(1.0) +def test_is_async_raises_when_no_response_id(mock_post): + mock = MagicMock() + mock.status_code = 202 + mock.json.return_value = {'async': True} # missing response_id + mock_post.return_value = mock + with pytest.raises(Exception, match='did not return a response_id'): + timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) + +# --------------------------------------------------------------------------- +# get_async_result — single poll +# --------------------------------------------------------------------------- @patch('pytimbr_api.timbr_http_connector.requests.get') -@patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_raises_on_server_error(mock_post, mock_get): - mock_post.return_value = _mock_202() - mock_get.return_value = _mock_get_error() - with pytest.raises(Exception, match='Async query failed'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) +def test_get_async_result_returns_completed(mock_get): + mock_get.return_value = _mock_get_completed([{'id': 42}]) + result = timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, MOCK_TOKEN) + assert result['status'] == 'completed' + assert result['response'] == [{'id': 42}] -@patch('pytimbr_api.timbr_http_connector.time.monotonic') -@patch('pytimbr_api.timbr_http_connector.time.sleep') @patch('pytimbr_api.timbr_http_connector.requests.get') -@patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_raises_timeout(mock_post, mock_get, mock_sleep, mock_monotonic): - mock_post.return_value = _mock_202() +def test_get_async_result_returns_running(mock_get): mock_get.return_value = _mock_get_running() - # First call sets deadline, subsequent calls exceed it - mock_monotonic.side_effect = [0.0, 0.0, 999.0] - with pytest.raises(TimeoutError, match='did not complete within'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, - is_async=True, timeout=10.0) + result = timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, MOCK_TOKEN) + assert result['status'] == 'running' @patch('pytimbr_api.timbr_http_connector.requests.get') -@patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_raises_on_404(mock_post, mock_get): - mock_post.return_value = _mock_202() +def test_get_async_result_raises_on_404(mock_get): mock_get.return_value = _mock_get_404() with pytest.raises(Exception, match='Async result not found'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) + timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, MOCK_TOKEN) -@patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_raises_when_no_response_id(mock_post): - mock = MagicMock() - mock.status_code = 202 - mock.json.return_value = {'async': True} # missing response_id - mock_post.return_value = mock - with pytest.raises(Exception, match='did not return a response_id'): - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) +@patch('pytimbr_api.timbr_http_connector.requests.get') +def test_get_async_result_returns_error_status(mock_get): + mock_get.return_value = _mock_get_error() + result = timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, MOCK_TOKEN) + assert result['status'] == 'error' @patch('pytimbr_api.timbr_http_connector.requests.get') -@patch('pytimbr_api.timbr_http_connector.requests.post') -def test_is_async_polls_correct_url(mock_post, mock_get): - mock_post.return_value = _mock_202() +def test_get_async_result_sends_api_key_header(mock_get): mock_get.return_value = _mock_get_completed() - timbr.run_query(MOCK_URL, MOCK_ONTOLOGY, MOCK_TOKEN, MOCK_QUERY, is_async=True) - poll_url = mock_get.call_args[0][0] - assert MOCK_RESPONSE_ID in poll_url - assert 'get-async-results' in poll_url + timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, MOCK_TOKEN) + _, kwargs = mock_get.call_args + assert kwargs['headers']['x-api-key'] == MOCK_TOKEN + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +def test_get_async_result_jwt_headers(mock_get): + mock_get.return_value = _mock_get_completed() + timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, 'jwt_token', + is_jwt=True, jwt_tenant_id='tenant1') + _, kwargs = mock_get.call_args + assert kwargs['headers']['x-jwt-token'] == 'jwt_token' + assert kwargs['headers']['x-jwt-tenant-id'] == 'tenant1' + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +def test_get_async_result_uses_correct_url(mock_get): + mock_get.return_value = _mock_get_completed() + timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, MOCK_TOKEN) + call_url = mock_get.call_args[0][0] + assert MOCK_RESPONSE_ID in call_url + assert 'get-async-results' in call_url + + +@patch('pytimbr_api.timbr_http_connector.requests.get') +def test_get_async_result_raises_on_non_200(mock_get): + mock = MagicMock() + mock.status_code = 500 + mock.text = 'Internal Server Error' + mock_get.return_value = mock + with pytest.raises(Exception, match='Error fetching async result'): + timbr.get_async_result(MOCK_URL, MOCK_RESPONSE_ID, MOCK_TOKEN) From f1abed4c9efa0a39d9b252b68213d35277aff46d Mon Sep 17 00:00:00 2001 From: timbr_admin Date: Wed, 15 Jul 2026 17:55:16 +0300 Subject: [PATCH 4/5] Update requirements.txt to include necessary dependencies for async support --- requirements.txt | Bin 80 -> 80 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 008df9a770f0afee2fb71cc3c9952633c8678d45..21292d137c088297b8094864dc6f2396931576d7 100644 GIT binary patch delta 13 UcmWFtm>|Py%%I0$Fi}w-02JZ^!~g&Q delta 13 UcmWFtm>|Py#GuDuGEq?<02Jy1#{d8T From a001afb97a2cb050b92b4abf55a24339b231df3a Mon Sep 17 00:00:00 2001 From: timbr_admin Date: Wed, 15 Jul 2026 18:28:37 +0300 Subject: [PATCH 5/5] Update requests dependency version to 2.33.0 for compatibility with async features --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e9131fb..2a01282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "requests>=2.32.4" + "requests>=2.33.0" ] [project.urls]